import type { EvidenceRow, Framework } from "./exporter"; export interface ExportListRow { id: string; framework: string; format: string; status: string; created_at: string; } export interface InstallationOption { installation_id: number; org_login: string; } // Shown only when the user can see more than one installation; a single-org // user gets the plain org name instead of a pointless dropdown. function installationSwitcher( installations: InstallationOption[], current: number, returnTo: "dashboard" | "access-review", ): string { if (installations.length < 2) return ""; const options = installations .map( (i) => ``, ) .join(""); return `
`; } export const POSTURES = ["negative", "positive", "informational"] as const; export type Posture = (typeof POSTURES)[number]; export function normalizePosture(value: string | null): Posture | null { return POSTURES.includes(value as Posture) ? (value as Posture) : null; } export interface DashboardData { login: string; installationId: number; orgLogin: string; installations: InstallationOption[]; framework: Framework; // Null = show every posture; otherwise the table is narrowed to the one // whose card the user clicked. posture: Posture | null; rows: EvidenceRow[]; exports: ExportListRow[]; lastPolledAt: string | null; excludedRepos: string[]; // Repos seen in this installation's snapshots that aren't excluded yet — // the options the exclusion form offers. excludableRepos: string[]; } // Deliberately narrower than `unknown`: an object reaching here would render // as "[object Object]" in an evidence table, which is worse than failing. // Keeping the parameter to primitives makes that a compile error instead. function esc(value: string | number | null | undefined): string { return String(value ?? "").replace(/[&<>"']/g, (c) => { switch (c) { case "&": return "&"; case "<": return "<"; case ">": return ">"; case '"': return """; default: return "'"; } }); } const STYLE = ` :root { color-scheme: light; } * { box-sizing: border-box; } body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #1a1a1a; background: #f6f7f9; } header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: 1rem 1.5rem; background: #fff; border-bottom: 1px solid #e2e5e9; flex-wrap: wrap; } header h1 { font-size: 1.05rem; margin: 0; } header .who { color: #666; font-size: 0.85rem; } header .who a { color: #0055dc; margin-left: 0.75rem; } main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; } .cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } .card { flex: 1 1 120px; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; padding: 0.9rem 1rem; } a.card { display: block; color: inherit; text-decoration: none; } a.card:hover { border-color: #0055dc; } a.card.active { border-color: #0055dc; box-shadow: inset 0 0 0 1px #0055dc; } a.card.active .l::after { content: " ✕"; } .card .n { font-size: 1.6rem; font-weight: 600; } .card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; } .n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; } .bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; } .bar .filters a { margin-right: 0.5rem; text-decoration: none; color: #0055dc; padding: 0.2rem 0.5rem; border-radius: 5px; } .bar .filters a.active { background: #0055dc; color: #fff; } form { display: inline-flex; gap: 0.4rem; align-items: center; margin: 0; } input, select, button { font: inherit; padding: 0.35rem 0.6rem; border: 1px solid #c9ced6; border-radius: 6px; background: #fff; } button { cursor: pointer; background: #0055dc; color: #fff; border-color: #0055dc; } button.secondary { background: #fff; color: #1a1a1a; } table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; overflow: hidden; } th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eef0f3; font-size: 0.85rem; } th { background: #fafbfc; font-weight: 600; color: #444; } tr:last-child td { border-bottom: none; } .posture { font-weight: 600; } .posture.positive { color: #1a8039; } .posture.negative { color: #b32626; } .posture.informational { color: #888; } .section-title { font-size: 1rem; margin: 2rem 0 0.75rem; } .muted { color: #888; } code { background: #eef0f3; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; } `; export function renderDashboard(data: DashboardData): string { const counts = { positive: 0, negative: 0, informational: 0 }; const repos = new Set(); for (const r of data.rows) { counts[r.posture as keyof typeof counts] = (counts[r.posture as keyof typeof counts] ?? 0) + 1; if (r.repo) repos.add(r.repo); } // Cards count the whole framework view, so the numbers stay stable while a // posture filter is applied — otherwise clicking "Gaps" would zero the other // two cards and there would be nothing left to click. const href = (framework: Framework, posture: Posture | null) => `/?framework=${framework}${posture ? `&posture=${posture}` : ""}`; const frameworkTab = (value: Framework, label: string) => `${label}`; // Clicking the active card clears the filter rather than reapplying it. const postureCard = (posture: Posture, label: string) => { const active = data.posture === posture; return `
${counts[posture]}
${label}
`; }; const visibleRows = data.posture ? data.rows.filter((r) => r.posture === data.posture) : data.rows; const evidenceRows = visibleRows .map( (r) => ` ${esc(r.framework)} ${esc(r.control_id)} ${esc(r.posture)} ${esc(r.repo ?? r.subject ?? "—")} ${esc(r.resource)} ${esc(r.status)} `, ) .join(""); const exportRows = data.exports .map((e) => { const done = e.status === "done"; const cell = done ? `Download ${esc(e.format.toUpperCase())}` : `${esc(e.status)}…`; return ` ${esc(e.created_at)} ${esc(e.framework)} ${esc(e.format.toUpperCase())} ${cell} `; }) .join(""); const excludeForm = data.excludableRepos.length ? `
` : `

No repositories left to exclude.

`; const exclusionRows = data.excludedRepos .map( (repo) => ` ${esc(repo)}
`, ) .join(""); return ` gh-attest — Compliance Evidence

gh-attest — Compliance Evidence

${esc(data.login)} · ${installationSwitcher(data.installations, data.installationId, "dashboard") || esc(data.orgLogin)} Access reviewLog out
${postureCard("negative", "Gaps")} ${postureCard("positive", "Satisfied")} ${postureCard("informational", "Informational")}
${repos.size}
Repositories
${frameworkTab("all", "All")} ${frameworkTab("soc2", "SOC 2")} ${frameworkTab("iso27001", "ISO 27001")}

${ data.lastPolledAt ? `Last synced ${esc(data.lastPolledAt)}` : "Not yet synced — click Re-sync now." }

${ evidenceRows || `` }
FrameworkControlPostureRepo / SubjectResourceStatus
${ data.posture ? `No ${esc(data.posture)} evidence in this view.` : "No evidence yet." }

Excluded repositories

Excluded repositories are skipped by the sync and contribute no evidence. Their existing history is kept, so including one again restores it.

${excludeForm} ${exclusionRows || ``}
Repository
No repositories excluded.

Recent exports

${exportRows || ``}
CreatedFrameworkFormatFile
No exports yet.
`; } export interface AccessReviewData { login: string; installationId: number; orgLogin: string; installations: InstallationOption[]; since: string; diff: import("./access-review").AccessDiff; } const CHANGE_CLASS: Record = { added: "negative", // new access is what an access review scrutinises removed: "positive", changed: "informational", }; export function renderAccessReview(data: AccessReviewData): string { const { diff } = data; const rows = diff.entries .map( (e) => ` ${esc(e.change)} ${esc(e.resource === "org_member" ? "org member" : "team member")} ${esc(e.subject)} ${esc(e.from ?? "—")} ${esc(e.to ?? "—")} `, ) .join(""); let banner: string; if (!diff.currentAt) { banner = `

No access data collected yet. Access review requires the App to be installed on an organization (personal accounts have no membership to review), and at least one sync to have run.

`; } else if (!diff.priorAt) { banner = `

Baseline captured ${esc(diff.currentAt)} (${diff.currentCount} access entries). No earlier snapshot before ${esc(data.since)} to compare against yet — the next sync after that date will produce a diff.

`; } else { banner = `

Comparing ${esc(diff.priorAt)} → ${esc(diff.currentAt)} · ${diff.currentCount} current access entries · ${diff.entries.length} change(s).

`; } return ` gh-attest — Access Review

gh-attest — Access Review

${esc(data.login)} · ${installationSwitcher(data.installations, data.installationId, "access-review") || esc(data.orgLogin)} DashboardLog out
${banner} ${rows || ``}
ChangeTypeSubjectWasNow
No membership changes in this window.
`; }