${ data.lastPolledAt ? `Last synced ${esc(data.lastPolledAt)}` : "Not yet synced — click Re-sync now." }
| Framework | Control | Posture | Repo / Subject | Resource | Status |
|---|---|---|---|---|---|
| No evidence yet. | |||||
Recent exports
| Created | Framework | Format | File |
|---|---|---|---|
| No exports yet. | |||
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 interface DashboardData { login: string; installationId: number; orgLogin: string; installations: InstallationOption[]; framework: Framework; rows: EvidenceRow[]; exports: ExportListRow[]; lastPolledAt: string | null; } // 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; } .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${ data.lastPolledAt ? `Last synced ${esc(data.lastPolledAt)}` : "Not yet synced — click Re-sync now." }
| Framework | Control | Posture | Repo / Subject | Resource | Status |
|---|---|---|---|---|---|
| No evidence yet. | |||||
| Created | Framework | Format | File |
|---|---|---|---|
| No exports yet. | |||
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 `| Change | Type | Subject | Was | Now |
|---|---|---|---|---|
| No membership changes in this window. | ||||