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="en">
<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 Variable Text", "Segoe UI", system-ui, -apple-system, sans-serif; color: var(--text); background: var(--bg); }
header { background: linear-gradient(135deg, #0b1f33 0%, #16344f 45%, #1f4e79 100%); color: #fff; padding: 20px 26px 18px; display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-wrap: wrap; position: relative; box-shadow: 0 2px 18px rgba(11,31,51,.35); }
header::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: linear-gradient(90deg, #5eead4, #38bdf8, #818cf8); opacity: .9; }
.brand { display: flex; align-items: center; gap: 16px; min-width: 0; }
.logo { width: 46px; height: 46px; flex: none; filter: drop-shadow(0 2px 6px rgba(0,0,0,.25)); }
.kicker { font-size: 11px; text-transform: uppercase; letter-spacing: .18em; color: #7dd3fc; font-weight: 600; margin-bottom: 2px; }
header h1 { margin: 0; font-size: 22px; font-weight: 650; letter-spacing: -.01em; }
.meta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 9px; }
.mchip { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; color: rgba(255,255,255,.88); background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.17); border-radius: 999px; padding: 3px 11px; white-space: nowrap; }
.mchip svg { width: 12px; height: 12px; opacity: .75; flex: none; }
header button { background: rgba(255,255,255,.1); color: #fff; border: 1px solid rgba(255,255,255,.35); border-radius: 8px; padding: 8px 18px; cursor: pointer; font-size: 13px; font-weight: 500; letter-spacing: .02em; transition: background .15s, transform .15s; }
header button:hover { background: rgba(255,255,255,.22); transform: translateY(-1px); }
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 class="brand">
    <svg class="logo" viewBox="0 0 46 46" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
      <rect x="1.5" y="1.5" width="43" height="43" rx="11" fill="rgba(255,255,255,.06)" stroke="rgba(255,255,255,.35)" stroke-width="1.5"/>
      <rect x="10" y="10" width="7.5" height="7.5" rx="2" fill="#5eead4"/>
      <rect x="19.5" y="10" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/>
      <rect x="29" y="10" width="7.5" height="7.5" rx="2" fill="#38bdf8"/>
      <rect x="10" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/>
      <rect x="19.5" y="19.5" width="7.5" height="7.5" rx="2" fill="#818cf8"/>
      <rect x="29" y="19.5" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.28)"/>
      <rect x="10" y="29" width="7.5" height="7.5" rx="2" fill="#38bdf8"/>
      <rect x="19.5" y="29" width="7.5" height="7.5" rx="2" fill="rgba(255,255,255,.16)"/>
      <rect x="29" y="29" width="7.5" height="7.5" rx="2" fill="#5eead4"/>
    </svg>
    <div>
      <div class="kicker">SPPermissionMatrix &middot; Permission Audit Report</div>
      <h1>__REPORT_TITLE__</h1>
      <div class="meta" id="meta"></div>
    </div>
  </div>
  <button id="legendBtn" type="button">Legend</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="Close">&times;</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: 'W', M: 'C', L: 'R', E: 'W' };
  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 groups', entra: 'Entra groups', user: 'Users', other: 'Other', share: 'Sharing links', person: 'People (effective)', special: 'Collective principals' };
const TYPE_LABEL = { SharePointGroup: 'SharePoint group', EntraGroup: 'Entra ID security group', M365Group: 'Microsoft 365 group', SecurityGroup: 'Security group (unresolved)', User: 'User', Everyone: 'Everyone', EveryoneExceptExternal: 'Everyone except external users', SharingLink: 'Sharing link group (auto-generated)', Other: 'Other' };
const PERM_LABEL = { FC: 'Full Control', W: 'Write (Edit)', C: 'Contribute', R: 'Read' };
const PERM_RANK = { FC: 0, W: 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 people: 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)));

  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(undefined, { dateStyle: 'medium', timeStyle: 'short' }); } catch (e) { return iso; } };

/* ---------- header meta chips ---------- */
(function renderMeta() {
  const icon = d => '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
  const ic = {
    cal: icon('<rect x="2" y="3" width="12" height="11" rx="2"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3"/>'),
    layers: icon('<path d="M8 2 14 5 8 8 2 5Z"/><path d="M2 8.5 8 11.5 14 8.5"/><path d="M2 11.5 8 14.5 14 11.5"/>'),
    globe: icon('<circle cx="8" cy="8" r="6.2"/><path d="M1.8 8h12.4M8 1.8c-3.4 3.6-3.4 8.8 0 12.4 3.4-3.6 3.4-8.8 0-12.4Z"/>'),
    clock: icon('<circle cx="8" cy="8" r="6.2"/><path d="M8 4.5V8l2.4 1.6"/>'),
    tag: icon('<path d="M8.6 1.8 14 7.2a1.6 1.6 0 0 1 0 2.3l-4.5 4.5a1.6 1.6 0 0 1-2.3 0L1.8 8.6V1.8h6.8Z"/><circle cx="5" cy="5" r="1" fill="currentColor" stroke="none"/>')
  };
  const chip = (i, t) => '<span class="mchip">' + i + esc(t) + '</span>';
  const n = DATA.siteCount || (DATA.sites || []).length;
  document.getElementById('meta').innerHTML =
    chip(ic.cal, 'Scanned ' + fmtDate(DATA.scanDate)) +
    chip(ic.layers, 'Depth ' + DATA.depth) +
    chip(ic.globe, n + ' site' + (n === 1 ? '' : 's')) +
    chip(ic.clock, 'Generated __GENERATED__') +
    (DATA.generator ? chip(ic.tag, DATA.generator) : '');
})();

/* ---------- 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>Overview – ' + DATA.sites.length + ' sites</h2>' +
    '<div class="url">Click a site to open its permission matrix.</div></div>' +
    '<table class="overview"><thead><tr><th>Site</th><th class="num">Rows</th><th class="num">Unique permissions</th><th class="num">Sharing links</th><th class="num">External</th><th class="num">Principals</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">unique</span>' : '';
  const href = absUrl(info, n.url);
  const link = href ? '<a class="nl" href="' + esc(href) + '" target="_blank" rel="noopener" title="Open in SharePoint">↗</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 || ''));
  });
  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">No matches for the current filters.</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">Hierarchy (site → library → folder)</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) + ' – click for 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 ? '' : ' (inherited)');
      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: 'direct', 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 people - 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">No matches for the current filters.' +
      (info.persons.length === 0 ? '<br>No group members were resolved in this scan (is the Graph permission Directory.Read.All granted?).' : '') + '</div>';
    return { rows: 0, cols: 0 };
  }

  let html = '<table class="matrix"><thead><tr><th class="corner" rowspan="2">Hierarchy (site → library → folder)</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) + ' – click for 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 ? '' : ' (inherited)');
      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 + ' rows · ' + res.cols + ' columns') : '';
}

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>Footnotes (custom permission levels):</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">‹ Back to overview</a>' : '') +
    '<div class="site-head"><h2>' + esc(s.title) + '</h2><div class="url">' + esc(s.url) + '</div></div>' +
    '<div class="chips">' +
    '<span class="chip">Rows <b>' + (st.nodeCount ?? s.nodes.length) + '</b></span>' +
    '<span class="chip">unique <b>' + (st.uniqueNodeCount ?? '?') + '</b></span>' +
    '<span class="chip">Principals <b>' + s.principals.length + '</b></span>' +
    '<span class="chip">People resolved <b>' + info.persons.length + '</b></span>' +
    '<span class="chip' + (sharingN ? ' warn' : '') + '">Sharing links <b>' + sharingN + '</b></span>' +
    '<span class="chip' + ((st.externalPrincipalCount || 0) ? ' warn' : '') + '">External <b>' + (st.externalPrincipalCount || 0) + '</b></span>' +
    '</div>' +
    '<div class="tabs">' +
    '<button class="tab' + (state.view === 'groups' ? ' active' : '') + '" id="tabGroups" type="button">Groups &amp; Roles</button>' +
    '<button class="tab' + (state.view === 'persons' ? ' active' : '') + '" id="tabPersons" type="button">People (effective)</button>' +
    '</div>' +
    '<div class="controls">' +
    '<input type="text" id="rowFilter" placeholder="Filter rows (library/folder) …" value="' + esc(state.rowFilter) + '">' +
    '<input type="text" id="personFilter" placeholder="Find person or group …" value="' + esc(state.personFilter) + '">' +
    '<label class="chk"><input type="checkbox" id="onlyUnique"' + (state.onlyUnique ? ' checked' : '') + '> Unique only</label>' +
    '<label class="chk"><input type="checkbox" id="showSharing"' + (state.showSharing ? ' checked' : '') + '> Show sharing links (' + sharingN + ')</label>' +
    '<button class="btn" id="expandAll" type="button">Expand all</button>' +
    '<button class="btn" id="collapseAll" type="button">Collapse all</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>W</b> Write (Edit)</span><span><b>C</b> Contribute</span><span><b>R</b> Read</span>' +
    '<span style="color:var(--inh)">gray = inherited</span><span style="color:var(--uniq)"><b>colored</b> = unique (inheritance broken)</span>' +
    '<span><span style="border-bottom:2px dotted var(--dim)">dotted</span> = access via group</span>' +
    '<span>⚠ = sharing link</span><span>click a cell/column header for 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">Members not resolved (missing Graph permission?).</p>';
  if (!list.length) return '<p class="dim">No members.</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 + ' effective ' + (size === 1 ? 'person' : 'people') : '') + '</div>' +
    (p.loginName ? '<div class="sub"><code>' + esc(p.loginName) + '</code></div>' : '') +
    (p.type === 'SharingLink' ? '<p class="dim">This group was auto-generated by a sharing link (shadow permission).</p>' : '') +
    (hasTree ? '<h4>Members (including nested groups)</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>directly assigned (own role assignments)</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>Memberships / permission paths on this site</h4>' +
    (memberships.length ? '<ul class="tree">' + memberships.join('') + '</ul>' : '<p class="dim">None (only via hidden sharing links?).</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 ? '' : ' · inherited from parent') + '</div>' +
    '<h4>Access derivation</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>Legend</h3>' +
    '<ul class="tree" style="padding-left:18px;list-style:disc">' +
    '<li><b>FC</b> = Full Control, <b>W</b> = Write (SharePoint level "Edit"), <b>C</b> = Contribute, <b>R</b> = Read; <b>X1, X2, …</b> = custom permission levels (see footnotes)</li>' +
    '<li><b>Groups &amp; Roles</b>: columns are principals as assigned in SharePoint (SharePoint/Entra groups, users, sharing links); the number below a column header is the count of effective people (resolved transitively)</li>' +
    '<li><b>People (effective)</b>: columns are individual people; <span style="border-bottom:2px dotted var(--dim)">dotted</span> = access comes (only) via groups – click the cell to see the derivation chain</li>' +
    '<li><span style="color:var(--inh)">Gray codes</span> = inherited; <span style="color:var(--uniq);font-weight:700">colored row</span> = unique permissions (inheritance broken)</li>' +
    '<li>⚠ = auto-generated sharing link group – hidden by default, toggle via checkbox</li>' +
    '<li>Limited-access system noise is filtered out</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">The scan contains no sites.</div>';
    return;
  }
  if (state.siteIndex === null) renderOverview();
  else renderSite(state.siteIndex);
}
render();
</script>
</body>
</html>
'@

}