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">&times;</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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
'@

}