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; --accent-soft: #eef4fb; --uniq: #b45309; --uniq-bg: #fff7ed; --inh: #9aa5b1; --border: #e2e8f0; --bg: #f8fafc; --text: #1e293b; --dim: #64748b; --warn: #b91c1c; } * { 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: 13px 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: 16px 22px; } .site-head { margin: 2px 0 8px; } .site-head h2 { margin: 0; font-size: 17px; } .site-head .url { font-size: 12px; color: var(--dim); } .chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 12px; } .chip { background: #fff; border: 1px solid var(--border); border-radius: 999px; padding: 3px 12px; font-size: 12px; color: var(--dim); } .chip b { color: var(--text); font-variant-numeric: tabular-nums; } .chip.warn b { color: var(--warn); } .tabs { display: flex; gap: 0; margin-bottom: 10px; border-bottom: 2px solid var(--border); } .tab { border: 0; background: none; padding: 8px 18px; font-size: 14px; cursor: pointer; color: var(--dim); border-bottom: 2px solid transparent; margin-bottom: -2px; } .tab.active { color: var(--accent); font-weight: 600; border-bottom-color: var(--accent); } .controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; } .controls input[type=text] { padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px; min-width: 210px; font-size: 13px; background: #fff; } .controls label.chk { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--dim); cursor: pointer; white-space: nowrap; } .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); white-space: nowrap; } .matrix-wrap { overflow: auto; max-height: 72vh; 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: 172px; vertical-align: bottom; min-width: 34px; max-width: 46px; cursor: pointer; background: #fff; padding: 4px 4px 6px; text-align: center; } th.colhead:hover .vert { color: var(--accent); text-decoration: underline; } .vert { writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap; max-height: 138px; overflow: hidden; text-overflow: ellipsis; font-weight: 500; font-size: 12px; display: inline-block; } .cnt { display: block; font-size: 10px; color: var(--dim); font-weight: 400; margin-top: 3px; font-variant-numeric: tabular-nums; } .cnt.zero { color: var(--warn); font-weight: 600; } .g-sp { background: #dbeafe; } .g-entra { background: #ede9fe; } .g-user { background: #dcfce7; } .g-other { background: #f1f5f9; } .g-share { background: #fee2e2; } .g-person { background: #dcfce7; } .g-special { background: #f1f5f9; } th.colhead.g-sp { background: #eff6ff; } th.colhead.g-entra { background: #f5f3ff; } th.colhead.g-user, th.colhead.g-person { background: #f0fdf4; } th.colhead.g-other, th.colhead.g-special { 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-color: var(--uniq-bg); } tbody tr:hover td { background-image: linear-gradient(rgba(31,78,121,.055), rgba(31,78,121,.055)); } .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; cursor: default; } td.c.click { cursor: pointer; } td.c.uniq { color: var(--uniq); font-weight: 700; } td.c.inh { color: var(--inh); } td.c.via span { border-bottom: 2px dotted currentColor; } td.c.empty { background-color: #fcfcfd; } .footnotes { margin-top: 10px; font-size: 12px; color: var(--dim); } .legendbar { margin-top: 8px; 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: var(--accent-soft); } table.overview .num { text-align: right; font-variant-numeric: tabular-nums; } .warntxt { color: var(--warn); 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: 680px; 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 h4 { margin: 12px 0 4px; font-size: 13px; } .modal-box .sub { font-size: 12px; color: var(--dim); margin-bottom: 8px; } .modal-box code { font-size: 11px; background: #f1f5f9; padding: 1px 5px; border-radius: 4px; word-break: break-all; } .modal-box ul.tree { margin: 6px 0; padding-left: 18px; list-style: none; } .modal-box ul.tree ul { padding-left: 18px; list-style: none; } .modal-box li { margin: 3px 0; } .mi { margin-right: 4px; } .src { display: flex; gap: 8px; align-items: baseline; margin: 4px 0; font-size: 13px; } .perm-chip { display: inline-block; min-width: 22px; text-align: center; border-radius: 5px; padding: 1px 6px; font-weight: 700; font-size: 12px; background: var(--accent-soft); color: var(--accent); } .backlink { display: inline-block; margin-bottom: 8px; 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, .tabs, header button { display: none; } .matrix-wrap { max-height: none; overflow: visible; } } </style> <style id="colHi"></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__"; /* ---------- sanitize: drop limited-access noise and normalize legacy codes (scans from older versions) ---------- */ (function sanitize() { const limitedRole = /(limited access|beschränkter zugriff|beschraenkter zugriff)/i; const LEGACY_PERM = { V: 'FC', B: 'E', M: 'C', L: 'R' }; const mapCode = c => LEGACY_PERM[c] || (/^C\d+$/.test(c) ? 'X' + c.slice(1) : c); (DATA.sites || []).forEach(s => { const dropP = new Set((s.principals || []).filter(p => /^Limited Access System Group/i.test(p.name || '')).map(p => p.id)); s.principals = (s.principals || []).filter(p => !dropP.has(p.id)); const dropRoles = new Set(Object.entries(s.customRoles || {}).filter(e => limitedRole.test(e[1] || '')).map(e => e[0])); (s.nodes || []).forEach(n => { n.assignments = (n.assignments || []).filter(a => !dropP.has(a.principalId) && !dropRoles.has(a.permission)); n.assignments.forEach(a => { a.permission = mapCode(a.permission); }); }); const roles = {}; Object.entries(s.customRoles || {}).forEach(e => { if (!dropRoles.has(e[0])) roles[mapCode(e[0])] = e[1]; }); s.customRoles = roles; if (s.stats) { s.stats.principalCount = s.principals.length; s.stats.sharingLinkCount = s.principals.filter(p => p.type === 'SharingLink').length; } }); })(); 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', person: 'Personen (effektiv)', special: 'Sammel-Berechtigte' }; 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 = { FC: 'Full Control', E: 'Edit', C: 'Contribute', R: 'Read' }; const PERM_RANK = { FC: 0, E: 1, C: 2, R: 3 }; const NODE_ICON = { Site: '\u{1F310}', Library: '\u{1F4DA}', Folder: '\u{1F4C1}' }; const GROUPY = ['SharePointGroup', 'EntraGroup', 'M365Group', 'SecurityGroup', 'SharingLink']; const state = { siteIndex: (DATA.sites && DATA.sites.length === 1) ? 0 : null, view: 'groups', collapsed: new Set(), showSharing: false, onlyUnique: false, rowFilter: '', personFilter: '' }; const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const rankOf = perm => (perm in PERM_RANK) ? PERM_RANK[perm] : 4; 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 = ''; } // Effective persons: per principal a map personKey -> membership chains; global person index. const perPrincipal = new Map(); const personIndex = new Map(); site.principals.forEach(p => { const map = new Map(); const add = (m, chain) => { const key = String(m.upn || m.name || '').trim().toLowerCase(); if (!key) return; let e = map.get(key); if (!e) { e = { chains: [] }; map.set(key, e); } if (e.chains.length < 5) e.chains.push(chain); const g = personIndex.get(key); if (!g) personIndex.set(key, { key, name: m.name || m.upn, upn: m.upn || null }); else if (!g.upn && m.upn) g.upn = m.upn; }; const walk = (list, chain) => (list || []).forEach(m => { if (m.type === 'User') add(m, chain); else if (m.members) walk(m.members, chain.concat(m.name || '?')); }); if (p.type === 'User') add(p, []); else if (GROUPY.includes(p.type)) walk(p.members, []); perPrincipal.set(p.id, map); }); const persons = Array.from(personIndex.values()).sort((a, b) => String(a.name).localeCompare(String(b.name), 'de')); const info = { site, byId, children, cellMap, haystacks, principalById, origin, perPrincipal, persons }; siteCache.set(i, info); return info; } const absUrl = (info, u) => !u ? null : (/^https?:/i.test(u) ? u : info.origin + u); const 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 (multi-site) ---------- */ 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 ? 'warntxt' : '') + '">' + (st.sharingLinkCount ?? 0) + '</td>' + '<td class="num ' + ((st.externalPrincipalCount || 0) > 0 ? 'warntxt' : '') + '">' + (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); state.collapsed = new Set(); state.rowFilter = ''; state.personFilter = ''; state.onlyUnique = false; render(); }); }); } /* ---------- shared row computation ---------- */ function computeRows(info, hasHit) { const rf = state.rowFilter.trim().toLowerCase(); const filtering = !!(rf || hasHit || 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 keep = new Set(); info.site.nodes.forEach(n => { if (rf && !String(n.title || '').toLowerCase().includes(rf)) return; if (state.onlyUnique && !n.hasUniquePermissions) return; if (hasHit && !hasHit(n)) 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 treeCell(info, n, filtering) { 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>' : ''; return '<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>'; } function attachRowHandlers(info, wrap) { 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); })); } /* ---------- group view ---------- */ function visibleGroupPrincipals(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.showSharing) 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 renderGroupMatrix(info, wrap) { const principals = visibleGroupPrincipals(info); const pf = state.personFilter.trim().toLowerCase(); const pids = new Set(principals.map(p => p.id)); const hasHit = pf ? (n => { const cm = info.cellMap.get(n.id); for (const id of pids) { if (cm.has(id)) return true; } return false; }) : null; const rows = computeRows(info, hasHit); const filtering = !!(state.rowFilter.trim() || pf || state.onlyUnique); if (!principals.length || !rows.length) { wrap.innerHTML = '<div class="empty-note">Keine Treffer für die aktuellen Filter.</div>'; return { rows: 0, cols: 0 }; } 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' ? ' ⚠' : ''; let cnt = ''; if (GROUPY.includes(p.type)) { const size = info.perPrincipal.get(p.id) ? info.perPrincipal.get(p.id).size : 0; cnt = '<span class="cnt' + (size === 0 ? ' zero' : '') + '">' + size + '</span>'; } 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>' + cnt + '</th>'; }); html += '</tr></thead><tbody>'; rows.forEach(n => { const cm = info.cellMap.get(n.id); let row = '<tr class="' + ((n.hasUniquePermissions && n.level > 0) ? 'row-uniq' : '') + '">' + treeCell(info, n, filtering); 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) + '"><span>' + esc(a.permission) + '</span></td>'; }); html += row + '</tr>'; }); html += '</tbody></table>'; wrap.innerHTML = html; attachRowHandlers(info, wrap); wrap.querySelectorAll('.colhead').forEach(th => th.addEventListener('click', () => showPrincipal(info, th.dataset.pid))); return { rows: rows.length, cols: principals.length }; } /* ---------- person view ---------- */ function personSources(info, node, person, includeSharing) { const out = []; for (const a of (node.assignments || [])) { const p = info.principalById.get(a.principalId); if (!p) continue; if (!includeSharing && p.type === 'SharingLink') continue; if (p.type === 'User') { const key = String(p.upn || p.name || '').trim().toLowerCase(); if (key === person.key) out.push({ perm: a.permission, label: 'direkt', direct: true }); } else if (GROUPY.includes(p.type)) { const e = info.perPrincipal.get(p.id).get(person.key); if (e) { e.chains.slice(0, 3).forEach(chain => out.push({ perm: a.permission, label: 'via ' + [p.name].concat(chain).join(' → ') + (p.type === 'SharingLink' ? ' ⚠' : ''), direct: false })); } } } return out; } function renderPersonMatrix(info, wrap) { const pf = state.personFilter.trim().toLowerCase(); let persons = info.persons; if (pf) persons = persons.filter(p => (String(p.name) + ' ' + String(p.upn || '')).toLowerCase().includes(pf)); // Collective principals (Everyone, ...) cannot be expanded to persons - keep them as own columns. const specials = info.site.principals.filter(p => p.type === 'Everyone' || p.type === 'EveryoneExceptExternal'); // Drop person columns without any access on any node (e.g. only via hidden sharing links). const cellCache = new Map(); // nodeId|personKey -> cell const cellFor = (n, person) => { const k = n.id + '|' + person.key; if (cellCache.has(k)) return cellCache.get(k); const sources = personSources(info, n, person, state.showSharing); let cell = null; if (sources.length) { let best = sources[0]; sources.forEach(s => { if (rankOf(s.perm) < rankOf(best.perm)) best = s; }); cell = { perm: best.perm, direct: sources.some(s => s.direct), sources }; } cellCache.set(k, cell); return cell; }; persons = persons.filter(person => info.site.nodes.some(n => cellFor(n, person))); const hasHit = pf ? (n => persons.some(person => cellFor(n, person))) : null; const rows = computeRows(info, hasHit); const filtering = !!(state.rowFilter.trim() || pf || state.onlyUnique); if ((!persons.length && !specials.length) || !rows.length) { wrap.innerHTML = '<div class="empty-note">Keine Treffer für die aktuellen Filter.' + (info.persons.length === 0 ? '<br>Keine Gruppenmitglieder im Scan aufgelöst (Graph-Berechtigung Directory.Read.All vorhanden?).' : '') + '</div>'; return { rows: 0, cols: 0 }; } let html = '<table class="matrix"><thead><tr><th class="corner" rowspan="2">Hierarchie (Site → Bibliothek → Ordner)</th>'; if (persons.length) html += '<th class="grp g-person" colspan="' + persons.length + '">' + GROUP_LABEL.person + '</th>'; if (specials.length) html += '<th class="grp g-special" colspan="' + specials.length + '">' + GROUP_LABEL.special + '</th>'; html += '</tr><tr>'; persons.forEach((person, idx) => { html += '<th class="colhead g-person" data-person="' + idx + '" title="' + esc((person.upn || person.name) + ' – Klick für Details') + '"><span class="vert">' + esc(person.name) + '</span></th>'; }); specials.forEach(p => { html += '<th class="colhead g-special" data-pid="' + esc(p.id) + '" title="' + esc(TYPE_LABEL[p.type] || p.type) + '"><span class="vert">' + esc(p.name) + '</span></th>'; }); html += '</tr></thead><tbody>'; rows.forEach(n => { const cm = info.cellMap.get(n.id); let row = '<tr class="' + ((n.hasUniquePermissions && n.level > 0) ? 'row-uniq' : '') + '">' + treeCell(info, n, filtering); persons.forEach((person, idx) => { const cell = cellFor(n, person); if (!cell) { row += '<td class="c empty"></td>'; return; } const cls = (n.hasUniquePermissions ? 'uniq' : 'inh') + (cell.direct ? '' : ' via'); const tip = cell.sources.map(s => (PERM_LABEL[s.perm] || s.perm) + ' ' + s.label).join(' | ') + (n.hasUniquePermissions ? '' : ' (geerbt)'); row += '<td class="c click ' + cls + '" data-node="' + n.id + '" data-person="' + idx + '" title="' + esc(tip) + '"><span>' + esc(cell.perm) + '</span></td>'; }); specials.forEach(p => { const a = cm.get(p.id); if (!a) { row += '<td class="c empty"></td>'; return; } row += '<td class="c ' + (n.hasUniquePermissions ? 'uniq' : 'inh') + '" title="' + esc((PERM_LABEL[a.permission] || a.permission) + ' – ' + (a.roles || []).join(', ')) + '"><span>' + esc(a.permission) + '</span></td>'; }); html += row + '</tr>'; }); html += '</tbody></table>'; wrap.innerHTML = html; attachRowHandlers(info, wrap); wrap.querySelectorAll('th.colhead[data-person]').forEach(th => th.addEventListener('click', () => showPerson(info, persons[Number(th.dataset.person)]))); wrap.querySelectorAll('th.colhead[data-pid]').forEach(th => th.addEventListener('click', () => showPrincipal(info, th.dataset.pid))); wrap.querySelectorAll('td.c.click').forEach(td => td.addEventListener('click', () => { const n = info.byId.get(td.dataset.node); const person = persons[Number(td.dataset.person)]; showPersonCell(info, n, person); })); return { rows: rows.length, cols: persons.length + specials.length }; } /* ---------- site view shell ---------- */ function renderMatrix(info) { const wrap = document.getElementById('matrixWrap'); document.getElementById('colHi').textContent = ''; const res = state.view === 'groups' ? renderGroupMatrix(info, wrap) : renderPersonMatrix(info, wrap); const ci = document.getElementById('countInfo'); if (ci) ci.textContent = res.rows ? (res.rows + ' Zeilen · ' + res.cols + ' Spalten') : ''; } function renderSite(i) { const info = siteInfo(i); const s = info.site; const st = s.stats || {}; const multi = DATA.sites.length > 1; const sharingN = st.sharingLinkCount || 0; const fn = s.customRoles && Object.keys(s.customRoles).length ? '<div class="footnotes"><b>Fussnoten (benutzerdefinierte Berechtigungsstufen):</b> ' + Object.entries(s.customRoles).map(e => esc(e[0]) + ' = ' + esc(e[1])).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="chips">' + '<span class="chip">Zeilen <b>' + (st.nodeCount ?? s.nodes.length) + '</b></span>' + '<span class="chip">davon eindeutig <b>' + (st.uniqueNodeCount ?? '?') + '</b></span>' + '<span class="chip">Berechtigte <b>' + s.principals.length + '</b></span>' + '<span class="chip">Personen aufgelöst <b>' + info.persons.length + '</b></span>' + '<span class="chip' + (sharingN ? ' warn' : '') + '">Freigabelinks <b>' + sharingN + '</b></span>' + '<span class="chip' + ((st.externalPrincipalCount || 0) ? ' warn' : '') + '">Externe <b>' + (st.externalPrincipalCount || 0) + '</b></span>' + '</div>' + '<div class="tabs">' + '<button class="tab' + (state.view === 'groups' ? ' active' : '') + '" id="tabGroups" type="button">Gruppen & Rollen</button>' + '<button class="tab' + (state.view === 'persons' ? ' active' : '') + '" id="tabPersons" type="button">Personen (effektiv)</button>' + '</div>' + '<div class="controls">' + '<input type="text" id="rowFilter" placeholder="Zeilen filtern (Bibliothek/Ordner) …" value="' + esc(state.rowFilter) + '">' + '<input type="text" id="personFilter" placeholder="Person/Gruppe suchen …" 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="showSharing"' + (state.showSharing ? ' checked' : '') + '> Freigabelinks anzeigen (' + sharingN + ')</label>' + '<button class="btn" id="expandAll" type="button">Aufklappen</button>' + '<button class="btn" id="collapseAll" type="button">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>FC</b> Full Control</span><span><b>E</b> Edit</span><span><b>C</b> Contribute</span><span><b>R</b> Read</span>' + '<span style="color:var(--inh)">grau = geerbt</span><span style="color:var(--uniq)"><b>farbig</b> = eindeutig (Vererbung unterbrochen)</span>' + '<span><span style="border-bottom:2px dotted var(--dim)">gepunktet</span> = Zugriff via Gruppe</span>' + '<span>⚠ = Freigabelink</span><span>Zelle/Spaltenkopf anklicken = Details</span></div>'; if (multi) document.getElementById('backlink').addEventListener('click', () => { state.siteIndex = null; render(); }); document.getElementById('tabGroups').addEventListener('click', () => { if (state.view !== 'groups') { state.view = 'groups'; renderSite(i); } }); document.getElementById('tabPersons').addEventListener('click', () => { if (state.view !== 'persons') { state.view = 'persons'; renderSite(i); } }); 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('showSharing').addEventListener('change', e => { state.showSharing = 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); }); // column crosshair (tbody only; header rows have differing cell counts) const wrapEl = document.getElementById('matrixWrap'); wrapEl.addEventListener('mouseover', e => { const td = e.target.closest('td'); const style = document.getElementById('colHi'); if (!td || td.cellIndex === 0) { style.textContent = ''; return; } style.textContent = '#matrixWrap tbody td:nth-child(' + (td.cellIndex + 1) + '){background-image:linear-gradient(rgba(31,78,121,.055),rgba(31,78,121,.055));}'; }); wrapEl.addEventListener('mouseleave', () => { document.getElementById('colHi').textContent = ''; }); renderMatrix(info); } /* ---------- modals ---------- */ function openModal(html) { document.getElementById('modalContent').innerHTML = html; document.getElementById('modal').classList.remove('hidden'); } 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 class="tree">' + 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 = GROUPY.includes(p.type); const size = info.perPrincipal.get(p.id) ? info.perPrincipal.get(p.id).size : 0; openModal( '<h3>' + esc(p.name) + '</h3>' + '<div class="sub">' + esc(TYPE_LABEL[p.type] || p.type) + (p.upn ? ' · ' + esc(p.upn) : '') + (hasTree ? ' · ' + size + ' Person(en) effektiv' : '') + '</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>Mitglieder (inkl. verschachtelter Gruppen)</h4>' + memberTree(p.members) : '')); } function showPerson(info, person) { const memberships = []; info.site.principals.forEach(p => { if (p.type === 'User') { const key = String(p.upn || p.name || '').trim().toLowerCase(); if (key === person.key) memberships.push('<li>direkt berechtigt (eigene Rollenzuweisungen)</li>'); return; } const e = info.perPrincipal.get(p.id) ? info.perPrincipal.get(p.id).get(person.key) : null; if (e) e.chains.slice(0, 3).forEach(chain => memberships.push('<li>' + esc([p.name].concat(chain).join(' → ')) + (p.type === 'SharingLink' ? ' ⚠' : '') + '</li>')); }); openModal( '<h3>' + esc(person.name) + '</h3>' + '<div class="sub">' + esc(person.upn || '') + '</div>' + '<h4>Mitgliedschaften / Berechtigungswege auf dieser Site</h4>' + (memberships.length ? '<ul class="tree">' + memberships.join('') + '</ul>' : '<p class="dim">Keine (nur über ausgeblendete Freigabelinks?).</p>')); } function showPersonCell(info, node, person) { const sources = personSources(info, node, person, state.showSharing); openModal( '<h3>' + esc(person.name) + '</h3>' + '<div class="sub">' + esc(person.upn || '') + '</div>' + '<div class="sub">' + esc(node.title) + ' <code>' + esc(node.url || '') + '</code>' + (node.hasUniquePermissions ? '' : ' · geerbt vom übergeordneten Element') + '</div>' + '<h4>Zugriffsherkunft</h4>' + sources.map(s => '<div class="src"><span class="perm-chip">' + esc(s.perm) + '</span><span>' + esc((PERM_LABEL[s.perm] || s.perm)) + ' – ' + esc(s.label) + '</span></div>').join('')); } function showLegend() { openModal( '<h3>Legende</h3>' + '<ul class="tree" style="padding-left:18px;list-style:disc">' + '<li><b>FC</b> = Full Control (Vollzugriff), <b>E</b> = Edit (Bearbeiten), <b>C</b> = Contribute (Mitwirken), <b>R</b> = Read (Lesen); <b>X1, X2, …</b> = benutzerdefinierte Stufen (Fussnoten)</li>' + '<li><b>Gruppen & Rollen</b>: Spalten = Berechtigte wie in SharePoint vergeben (SharePoint-/Entra-Gruppen, Benutzer, Freigabelinks); Zahl unter dem Spaltenkopf = effektive Personen (transitiv aufgelöst)</li>' + '<li><b>Personen (effektiv)</b>: Spalten = Einzelpersonen; <span style="border-bottom:2px dotted var(--dim)">gepunktet</span> = Zugriff kommt (nur) über Gruppen – Klick auf die Zelle zeigt die Herkunftskette</li>' + '<li><span style="color:var(--inh)">Graue Kürzel</span> = geerbt; <span style="color:var(--uniq);font-weight:700">farbige Zeile</span> = eindeutige Berechtigungen (Vererbung unterbrochen)</li>' + '<li>⚠ = automatisch erzeugte Freigabelink-Gruppe – standardmässig ausgeblendet, über die Checkbox einblendbar</li>' + '<li>„Eingeschränkter Zugriff“/Limited-Access-Systemgruppen werden als Rauschen gefiltert</li>' + '</ul>'); } 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> '@ } |