Private/Get-SPMHtmlTemplate.ps1
|
function Get-SPMHtmlTemplate { <# .SYNOPSIS Returns the self-contained HTML report template (placeholders: __REPORT_TITLE__, __SCAN_DATA__, __GENERATED__). #> [CmdletBinding()] param() return @' <!DOCTYPE html> <html lang="de"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>__REPORT_TITLE__</title> <style> :root { --accent: #1f4e79; --uniq: #b45309; --uniq-bg: #fff7ed; --inh: #9aa5b1; --border: #e2e8f0; --bg: #f8fafc; --text: #1e293b; --dim: #64748b; } * { box-sizing: border-box; } body { margin: 0; font: 14px/1.45 "Segoe UI", system-ui, sans-serif; color: var(--text); background: var(--bg); } header { background: var(--accent); color: #fff; padding: 14px 22px; display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; } header h1 { margin: 0; font-size: 19px; font-weight: 600; } header .meta { font-size: 12px; opacity: .85; margin-top: 2px; } header button { background: rgba(255,255,255,.15); color: #fff; border: 1px solid rgba(255,255,255,.4); border-radius: 6px; padding: 6px 14px; cursor: pointer; font-size: 13px; } header button:hover { background: rgba(255,255,255,.28); } main { padding: 18px 22px; } .controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; } .controls input[type=text] { padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; min-width: 220px; font-size: 13px; background: #fff; } .controls label.chk { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--dim); cursor: pointer; } .controls .btn { background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px; color: var(--text); } .controls .btn:hover { border-color: var(--accent); color: var(--accent); } .controls .spacer { flex: 1; } .hint { font-size: 12px; color: var(--dim); } .site-head { margin: 4px 0 10px; } .site-head h2 { margin: 0; font-size: 17px; } .site-head .url { font-size: 12px; color: var(--dim); } .matrix-wrap { overflow: auto; max-height: 76vh; border: 1px solid var(--border); border-radius: 8px; background: #fff; } table.matrix { border-collapse: separate; border-spacing: 0; width: max-content; min-width: 100%; } table.matrix th, table.matrix td { border-bottom: 1px solid var(--border); border-right: 1px solid #f1f5f9; padding: 3px 6px; } th.corner { position: sticky; top: 0; left: 0; z-index: 6; background: #fff; text-align: left; min-width: 300px; vertical-align: bottom; font-size: 12px; color: var(--dim); border-right: 1px solid var(--border); } th.grp { position: sticky; top: 0; z-index: 4; height: 30px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: #334155; } th.colhead { position: sticky; top: 30px; z-index: 4; height: 168px; vertical-align: bottom; min-width: 34px; max-width: 44px; cursor: pointer; background: #fff; padding: 6px 4px; } th.colhead:hover .vert { color: var(--accent); text-decoration: underline; } .vert { writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap; max-height: 152px; overflow: hidden; text-overflow: ellipsis; font-weight: 500; font-size: 12px; display: inline-block; } .g-sp { background: #dbeafe; } .g-entra { background: #ede9fe; } .g-user { background: #dcfce7; } .g-other { background: #f1f5f9; } .g-share { background: #fee2e2; } th.colhead.g-sp { background: #eff6ff; } th.colhead.g-entra { background: #f5f3ff; } th.colhead.g-user { background: #f0fdf4; } th.colhead.g-other { background: #f8fafc; } th.colhead.g-share { background: #fef2f2; } td.tree { position: sticky; left: 0; z-index: 3; background: #fff; white-space: nowrap; max-width: 520px; overflow: hidden; text-overflow: ellipsis; border-right: 1px solid var(--border); } tr.row-uniq td.tree { box-shadow: inset 3px 0 0 var(--uniq); background: var(--uniq-bg); } tr.row-uniq td.c { background: var(--uniq-bg); } .tw { border: 0; background: none; cursor: pointer; width: 18px; padding: 0; font-size: 11px; color: var(--dim); } .tw-sp { display: inline-block; width: 18px; } .ic { margin-right: 4px; } .nt { font-weight: 500; } .badge { margin-left: 8px; font-size: 10px; background: var(--uniq); color: #fff; border-radius: 8px; padding: 1px 7px; vertical-align: middle; } .nl { margin-left: 6px; text-decoration: none; font-size: 11px; color: var(--dim); } .nl:hover { color: var(--accent); } td.c { text-align: center; font-size: 13px; min-width: 34px; } td.c.uniq { color: var(--uniq); font-weight: 700; } td.c.inh { color: var(--inh); } td.c.empty { background: #fcfcfd; } .footnotes { margin-top: 10px; font-size: 12px; color: var(--dim); } .legendbar { margin-top: 10px; font-size: 12px; color: var(--dim); display: flex; gap: 14px; flex-wrap: wrap; } .legendbar b { color: var(--text); } table.overview { border-collapse: collapse; background: #fff; border: 1px solid var(--border); border-radius: 8px; width: 100%; } table.overview th, table.overview td { padding: 9px 14px; border-bottom: 1px solid var(--border); text-align: left; font-size: 13px; } table.overview th { background: #f1f5f9; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); } table.overview tbody tr { cursor: pointer; } table.overview tbody tr:hover { background: #eff6ff; } table.overview .num { text-align: right; font-variant-numeric: tabular-nums; } .warn { color: #b91c1c; font-weight: 600; } .dim { color: var(--dim); } .modal { position: fixed; inset: 0; background: rgba(15,23,42,.5); display: flex; align-items: center; justify-content: center; z-index: 50; } .modal.hidden { display: none; } .modal-box { background: #fff; border-radius: 10px; max-width: 640px; width: 92%; max-height: 82vh; overflow: auto; padding: 20px 24px; position: relative; box-shadow: 0 20px 50px rgba(0,0,0,.3); } .modal-close { position: absolute; top: 8px; right: 12px; border: 0; background: none; font-size: 22px; cursor: pointer; color: var(--dim); } .modal-box h3 { margin: 0 0 2px; font-size: 16px; } .modal-box .sub { font-size: 12px; color: var(--dim); margin-bottom: 10px; } .modal-box code { font-size: 11px; background: #f1f5f9; padding: 1px 5px; border-radius: 4px; word-break: break-all; } .modal-box ul { margin: 6px 0; padding-left: 20px; list-style: none; } .modal-box li { margin: 3px 0; } .mi { margin-right: 4px; } .backlink { display: inline-block; margin-bottom: 10px; color: var(--accent); cursor: pointer; font-size: 13px; text-decoration: none; } .backlink:hover { text-decoration: underline; } .empty-note { padding: 30px; text-align: center; color: var(--dim); } @media print { .controls, header button { display: none; } .matrix-wrap { max-height: none; overflow: visible; } } </style> </head> <body> <header> <div> <h1>__REPORT_TITLE__</h1> <div class="meta" id="meta"></div> </div> <button id="legendBtn" type="button">Legende</button> </header> <main id="app"></main> <div id="modal" class="modal hidden" role="dialog" aria-modal="true"> <div class="modal-box"> <button class="modal-close" id="modalClose" type="button" aria-label="Schliessen">×</button> <div id="modalContent"></div> </div> </div> <script> const DATA = "__SCAN_DATA__"; const TYPE_GROUP = t => ({ SharePointGroup: 'sp', EntraGroup: 'entra', M365Group: 'entra', SecurityGroup: 'entra', User: 'user', Everyone: 'other', EveryoneExceptExternal: 'other', Other: 'other', SharingLink: 'share' }[t] || 'other'); const GROUP_ORDER = ['sp', 'entra', 'user', 'other', 'share']; const GROUP_LABEL = { sp: 'SharePoint-Gruppen', entra: 'Entra-Gruppen', user: 'Benutzer', other: 'Sonstige', share: 'Freigabelinks' }; const TYPE_LABEL = { SharePointGroup: 'SharePoint-Gruppe', EntraGroup: 'Entra-ID-Sicherheitsgruppe', M365Group: 'Microsoft-365-Gruppe', SecurityGroup: 'Sicherheitsgruppe (nicht aufgelöst)', User: 'Benutzer', Everyone: 'Jeder', EveryoneExceptExternal: 'Jeder ausser externen Benutzern', SharingLink: 'Freigabelink-Gruppe (automatisch erzeugt)', Other: 'Sonstige' }; const PERM_LABEL = { V: 'Vollzugriff', B: 'Bearbeiten', M: 'Mitwirken', L: 'Lesen' }; const NODE_ICON = { Site: '\u{1F310}', Library: '\u{1F4DA}', Folder: '\u{1F4C1}' }; const state = { siteIndex: (DATA.sites && DATA.sites.length === 1) ? 0 : null, collapsed: new Set(), hideSharing: false, onlyUnique: false, rowFilter: '', personFilter: '' }; const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function principalHaystack(p) { const parts = [p.name || '', p.upn || '', p.loginName || '']; const walk = m => { if (!m) return; parts.push(m.name || '', m.upn || ''); (m.members || []).forEach(walk); }; (p.members || []).forEach(walk); return parts.join(' ').toLowerCase(); } // per-site caches const siteCache = new Map(); function siteInfo(i) { if (siteCache.has(i)) return siteCache.get(i); const site = DATA.sites[i]; const byId = new Map(site.nodes.map(n => [n.id, n])); const children = new Map(); site.nodes.forEach(n => { if (n.parentId) { if (!children.has(n.parentId)) children.set(n.parentId, []); children.get(n.parentId).push(n.id); } }); const cellMap = new Map(site.nodes.map(n => [n.id, new Map((n.assignments || []).map(a => [a.principalId, a]))])); const haystacks = new Map((site.principals || []).map(p => [p.id, principalHaystack(p)])); const principalById = new Map((site.principals || []).map(p => [p.id, p])); let origin = ''; try { origin = new URL(site.url).origin; } catch (e) { origin = ''; } const info = { site, byId, children, cellMap, haystacks, principalById, origin }; siteCache.set(i, info); return info; } function absUrl(info, u) { if (!u) return null; return /^https?:/i.test(u) ? u : info.origin + u; } function fmtDate(iso) { try { return new Date(iso).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' }); } catch (e) { return iso; } } document.getElementById('meta').textContent = 'Scan vom ' + fmtDate(DATA.scanDate) + ' · Tiefe ' + DATA.depth + ' · ' + DATA.siteCount + ' Site(s) · ' + (DATA.generator || '') + ' · Report erstellt: __GENERATED__'; /* ---------- Overview ---------- */ function renderOverview() { const rows = DATA.sites.map((s, i) => { const st = s.stats || {}; return '<tr data-site="' + i + '"><td><b>' + esc(s.title) + '</b><div class="dim" style="font-size:11px">' + esc(s.url) + '</div></td>' + '<td class="num">' + (st.nodeCount ?? '') + '</td>' + '<td class="num">' + (st.uniqueNodeCount ?? '') + '</td>' + '<td class="num ' + ((st.sharingLinkCount || 0) > 0 ? 'warn' : '') + '">' + (st.sharingLinkCount ?? 0) + '</td>' + '<td class="num ' + ((st.externalPrincipalCount || 0) > 0 ? 'warn' : '') + '">' + (st.externalPrincipalCount ?? 0) + '</td>' + '<td class="num">' + (st.principalCount ?? '') + '</td></tr>'; }).join(''); document.getElementById('app').innerHTML = '<div class="site-head"><h2>Übersicht – ' + DATA.sites.length + ' Sites</h2>' + '<div class="url">Klick auf eine Site öffnet deren Berechtigungsmatrix.</div></div>' + '<table class="overview"><thead><tr><th>Site</th><th class="num">Zeilen</th><th class="num">Eindeutige Berechtigungen</th><th class="num">Freigabelinks</th><th class="num">Externe</th><th class="num">Berechtigte</th></tr></thead>' + '<tbody>' + rows + '</tbody></table>'; document.querySelectorAll('table.overview tbody tr').forEach(tr => { tr.addEventListener('click', () => { state.siteIndex = Number(tr.dataset.site); resetSiteState(); render(); }); }); } function resetSiteState() { state.collapsed = new Set(); state.rowFilter = ''; state.personFilter = ''; state.onlyUnique = false; } /* ---------- Site view ---------- */ function visiblePrincipals(info) { let ps = (info.site.principals || []).slice().sort((a, b) => { const ga = GROUP_ORDER.indexOf(TYPE_GROUP(a.type)); const gb = GROUP_ORDER.indexOf(TYPE_GROUP(b.type)); return (ga - gb) || String(a.name || '').localeCompare(String(b.name || ''), 'de'); }); if (state.hideSharing) ps = ps.filter(p => p.type !== 'SharingLink'); const pf = state.personFilter.trim().toLowerCase(); if (pf) ps = ps.filter(p => info.haystacks.get(p.id).includes(pf)); return ps; } function visibleNodes(info, principals) { const rf = state.rowFilter.trim().toLowerCase(); const pf = state.personFilter.trim().toLowerCase(); const filtering = !!(rf || pf || state.onlyUnique); if (!filtering) { return info.site.nodes.filter(n => { let p = n.parentId; while (p) { if (state.collapsed.has(p)) return false; p = info.byId.get(p).parentId; } return true; }); } const pids = new Set(principals.map(p => p.id)); const keep = new Set(); info.site.nodes.forEach(n => { if (rf && !String(n.title || '').toLowerCase().includes(rf)) return; if (state.onlyUnique && !n.hasUniquePermissions) return; if (pf) { const cm = info.cellMap.get(n.id); let hit = false; pids.forEach(id => { if (cm.has(id)) hit = true; }); if (!hit) return; } keep.add(n.id); }); Array.from(keep).forEach(id => { let p = info.byId.get(id).parentId; while (p) { keep.add(p); p = info.byId.get(p).parentId; } }); return info.site.nodes.filter(n => keep.has(n.id)); } function renderMatrix(info) { const principals = visiblePrincipals(info); const nodes = visibleNodes(info, principals); const filtering = !!(state.rowFilter.trim() || state.personFilter.trim() || state.onlyUnique); const wrap = document.getElementById('matrixWrap'); if (!principals.length || !nodes.length) { wrap.innerHTML = '<div class="empty-note">Keine Treffer für die aktuellen Filter.</div>'; document.getElementById('countInfo').textContent = ''; return; } // grouped header segments const segs = []; principals.forEach(p => { const g = TYPE_GROUP(p.type); if (segs.length && segs[segs.length - 1].g === g) segs[segs.length - 1].n++; else segs.push({ g, n: 1 }); }); let html = '<table class="matrix"><thead><tr><th class="corner" rowspan="2">Hierarchie (Site → Bibliothek → Ordner)</th>'; segs.forEach(s => { html += '<th class="grp g-' + s.g + '" colspan="' + s.n + '">' + GROUP_LABEL[s.g] + '</th>'; }); html += '</tr><tr>'; principals.forEach(p => { const warn = p.type === 'SharingLink' ? ' ⚠' : ''; html += '<th class="colhead g-' + TYPE_GROUP(p.type) + '" data-pid="' + esc(p.id) + '" title="' + esc(TYPE_LABEL[p.type] || p.type) + ' – Klick für Details"><span class="vert">' + esc(p.name) + warn + '</span></th>'; }); html += '</tr></thead><tbody>'; nodes.forEach(n => { const hasChildren = info.children.has(n.id); const toggle = (!filtering && hasChildren) ? '<button class="tw" type="button" data-node="' + n.id + '">' + (state.collapsed.has(n.id) ? '▸' : '▾') + '</button>' : '<span class="tw-sp"></span>'; const badge = (n.hasUniquePermissions && n.level > 0) ? '<span class="badge">eindeutig</span>' : ''; const href = absUrl(info, n.url); const link = href ? '<a class="nl" href="' + esc(href) + '" target="_blank" rel="noopener" title="In SharePoint öffnen">↗</a>' : ''; let row = '<tr class="' + ((n.hasUniquePermissions && n.level > 0) ? 'row-uniq' : '') + '">' + '<td class="tree" style="padding-left:' + (8 + n.level * 20) + 'px" title="' + esc(n.title) + '">' + toggle + '<span class="ic">' + (NODE_ICON[n.type] || '') + '</span><span class="nt">' + esc(n.title) + '</span>' + badge + link + '</td>'; const cm = info.cellMap.get(n.id); principals.forEach(p => { const a = cm.get(p.id); if (!a) { row += '<td class="c empty"></td>'; return; } const cls = n.hasUniquePermissions ? 'uniq' : 'inh'; const tip = (PERM_LABEL[a.permission] || a.permission) + ' – ' + (a.roles || []).join(', ') + (n.hasUniquePermissions ? '' : ' (geerbt)'); row += '<td class="c ' + cls + '" title="' + esc(tip) + '">' + esc(a.permission) + '</td>'; }); row += '</tr>'; html += row; }); html += '</tbody></table>'; wrap.innerHTML = html; document.getElementById('countInfo').textContent = nodes.length + ' Zeilen · ' + principals.length + ' Berechtigte'; wrap.querySelectorAll('.tw').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); const id = btn.dataset.node; if (state.collapsed.has(id)) state.collapsed.delete(id); else state.collapsed.add(id); renderMatrix(info); })); wrap.querySelectorAll('.colhead').forEach(th => th.addEventListener('click', () => showPrincipal(info, th.dataset.pid))); } function renderSite(i) { const info = siteInfo(i); const s = info.site; const multi = DATA.sites.length > 1; const fn = s.customRoles && Object.keys(s.customRoles).length ? '<div class="footnotes"><b>Fussnoten (benutzerdefinierte Berechtigungsstufen):</b> ' + Object.entries(s.customRoles).map(([k, v]) => esc(k) + ' = ' + esc(v)).join(' · ') + '</div>' : ''; document.getElementById('app').innerHTML = (multi ? '<a class="backlink" id="backlink">‹ Zurück zur Übersicht</a>' : '') + '<div class="site-head"><h2>' + esc(s.title) + '</h2><div class="url">' + esc(s.url) + '</div></div>' + '<div class="controls">' + '<input type="text" id="rowFilter" placeholder="Zeilen filtern (Bibliothek/Ordner) …" value="' + esc(state.rowFilter) + '">' + '<input type="text" id="personFilter" placeholder="Wo hat Person/Gruppe X Zugriff? …" value="' + esc(state.personFilter) + '">' + '<label class="chk"><input type="checkbox" id="onlyUnique"' + (state.onlyUnique ? ' checked' : '') + '> Nur eindeutige</label>' + '<label class="chk"><input type="checkbox" id="hideSharing"' + (state.hideSharing ? ' checked' : '') + '> Freigabelinks ausblenden</label>' + '<button class="btn" id="expandAll" type="button">Alles aufklappen</button>' + '<button class="btn" id="collapseAll" type="button">Alles zuklappen</button>' + '<span class="spacer"></span><span class="hint" id="countInfo"></span>' + '</div>' + '<div class="matrix-wrap" id="matrixWrap"></div>' + fn + '<div class="legendbar"><span><b>V</b> Vollzugriff</span><span><b>B</b> Bearbeiten</span><span><b>M</b> Mitwirken</span><span><b>L</b> Lesen</span>' + '<span style="color:var(--inh)">grau = geerbt</span><span style="color:var(--uniq)"><b>farbig</b> = eindeutig (Vererbung unterbrochen)</span>' + '<span>⚠ = Freigabelink</span><span>Spaltenkopf anklicken = Mitglieder</span></div>'; if (multi) document.getElementById('backlink').addEventListener('click', () => { state.siteIndex = null; render(); }); document.getElementById('rowFilter').addEventListener('input', e => { state.rowFilter = e.target.value; renderMatrix(info); }); document.getElementById('personFilter').addEventListener('input', e => { state.personFilter = e.target.value; renderMatrix(info); }); document.getElementById('onlyUnique').addEventListener('change', e => { state.onlyUnique = e.target.checked; renderMatrix(info); }); document.getElementById('hideSharing').addEventListener('change', e => { state.hideSharing = e.target.checked; renderMatrix(info); }); document.getElementById('expandAll').addEventListener('click', () => { state.collapsed = new Set(); renderMatrix(info); }); document.getElementById('collapseAll').addEventListener('click', () => { state.collapsed = new Set(info.site.nodes.filter(n => info.children.has(n.id) && n.level > 0).map(n => n.id)); renderMatrix(info); }); renderMatrix(info); } /* ---------- Modal ---------- */ function memberTree(list) { if (!list) return '<p class="dim">Mitglieder nicht aufgelöst (keine Graph-Berechtigung?).</p>'; if (!list.length) return '<p class="dim">Keine Mitglieder.</p>'; return '<ul>' + list.map(m => { const ic = m.type === 'User' ? '\u{1F464}' : (m.type === 'Note' ? 'ℹ️' : '\u{1F465}'); const upn = m.upn ? ' <span class="dim">(' + esc(m.upn) + ')</span>' : ''; const sub = (m.members !== undefined && m.members !== null) ? memberTree(m.members) : ''; return '<li><span class="mi">' + ic + '</span>' + esc(m.name) + upn + sub + '</li>'; }).join('') + '</ul>'; } function showPrincipal(info, pid) { const p = info.principalById.get(pid); if (!p) return; const hasTree = ['SharePointGroup', 'EntraGroup', 'M365Group', 'SecurityGroup', 'SharingLink'].includes(p.type); document.getElementById('modalContent').innerHTML = '<h3>' + esc(p.name) + '</h3>' + '<div class="sub">' + esc(TYPE_LABEL[p.type] || p.type) + (p.upn ? ' · ' + esc(p.upn) : '') + '</div>' + (p.loginName ? '<div class="sub"><code>' + esc(p.loginName) + '</code></div>' : '') + (p.type === 'SharingLink' ? '<p class="dim">Diese Gruppe wurde automatisch durch einen Freigabelink erzeugt (Schatten-Berechtigung).</p>' : '') + (hasTree ? '<h4 style="margin:10px 0 2px">Mitglieder (inkl. verschachtelter Gruppen)</h4>' + memberTree(p.members) : ''); document.getElementById('modal').classList.remove('hidden'); } function showLegend() { document.getElementById('modalContent').innerHTML = '<h3>Legende</h3>' + '<ul style="padding-left:18px;list-style:disc">' + '<li><b>V</b> = Vollzugriff, <b>B</b> = Bearbeiten, <b>M</b> = Mitwirken, <b>L</b> = Lesen</li>' + '<li><b>C1, C2, …</b> = benutzerdefinierte Berechtigungsstufen (siehe Fussnoten unter der Matrix)</li>' + '<li><span style="color:var(--inh)">Graue Kürzel</span> = geerbte Berechtigungen (vom übergeordneten Element)</li>' + '<li><span style="color:var(--uniq);font-weight:700">Farbige Zeile/Kürzel</span> = eindeutige Berechtigungen – hier ist die Vererbung unterbrochen</li>' + '<li>⚠ = automatisch erzeugte Freigabelink-Gruppe (über Filter ausblendbar)</li>' + '<li>Klick auf einen Spaltenkopf zeigt die Mitglieder inkl. verschachtelter Gruppen</li>' + '<li>„Eingeschränkter Zugriff“ (Limited Access) wird als Rauschen gefiltert</li>' + '</ul>'; document.getElementById('modal').classList.remove('hidden'); } document.getElementById('legendBtn').addEventListener('click', showLegend); document.getElementById('modalClose').addEventListener('click', () => document.getElementById('modal').classList.add('hidden')); document.getElementById('modal').addEventListener('click', e => { if (e.target === e.currentTarget) e.currentTarget.classList.add('hidden'); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') document.getElementById('modal').classList.add('hidden'); }); function render() { if (!DATA.sites || !DATA.sites.length) { document.getElementById('app').innerHTML = '<div class="empty-note">Der Scan enthält keine Sites.</div>'; return; } if (state.siteIndex === null) renderOverview(); else renderSite(state.siteIndex); } render(); </script> </body> </html> '@ } |