/** * API-key UI logic (TASK-0128) — pure functions so the scope-implication * rules, the curl example, and the date formatting are unit-testable * without the DOM. * * Scope semantics (docs/api/authentication.md §7): the scopes are an * implication hierarchy — ADMIN implies WRITE implies READ. The UI shows * the implication (a checkbox row states what it grants) and the CREATION * request stores the MINIMAL selected set (selecting ADMIN alone is sent * as `["ADMIN"]`; the server expands the hierarchy, and the stored row * keeps the minimal set — `api-keys.service.ts` already dedupes, and * `auth.guard.ts` expands ADMIN→WRITE→READ on lookup). */ export const API_KEY_SCOPES = ["READ", "WRITE", "ADMIN"] as const; export type ApiKeyScope = (typeof API_KEY_SCOPES)[number]; /** What each scope grants (shown in the create dialog + badges). * Kept free of the other scope names so the E2E can address each * checkbox by role + name unambiguously. The implication is stated once, * as the dialog footer line ("Implication: ADMIN → WRITE → READ"). */ export const SCOPE_GRANTS: Record = { READ: "Read workspace content.", WRITE: "Read and create/update content.", ADMIN: "Full access, including admin operations.", }; /** The implied hierarchy: scope → every scope it implies (itself included). */ export const SCOPE_IMPLIES: Record = { READ: ["READ"], WRITE: ["WRITE", "READ"], ADMIN: ["ADMIN", "WRITE", "READ"], }; /** * The minimal set to STORE for a checked set: drop any scope that is * implied by another selected scope. `["ADMIN","WRITE","READ"]` → * `["ADMIN"]`; `["WRITE","READ"]` → `["WRITE"]`; `["READ"]` → `["READ"]`. */ export function minimalScopes(selected: readonly ApiKeyScope[]): ApiKeyScope[] { const set = new Set(selected); return API_KEY_SCOPES.filter((s) => { if (!set.has(s)) return false; // Keep `s` only if no OTHER selected scope implies it. const impliedByOther = selected.some((o) => o !== s && SCOPE_IMPLIES[o].includes(s)); return !impliedByOther; }); } /** True when `selected` grants `need` (through the implication chain). */ export function grants(selected: readonly ApiKeyScope[], need: ApiKeyScope): boolean { return selected.some((s) => SCOPE_IMPLIES[s].includes(need)); } /** * A curl example for a key row (the expandable per-key helper). The prefix * is shown masked, the secret is a placeholder — the raw key is never in * this string (it only ever exists in the create response + the reveal * dialog state). */ export function curlExample( prefix: string, method: "GET" | "POST" | "PATCH" | "DELETE", url: string, body?: string, ): string { const lines = [`curl -X ${method} \\`]; lines.push(` -H "Authorization: Bearer ${prefix}…(your key)" \\`); if (body) { lines.push(` -H "Content-Type: application/json" \\`); lines.push(` -d '${body}' \\`); } lines.push(` ${url}`); return lines.join("\n"); } export type KeyExpiryState = "never" | "active" | "soon" | "expired"; const DAY_MS = 24 * 60 * 60 * 1000; /** * Expiry state for the list: `never` (no expiry), `soon` (≤ 7 d — red), * `expired` (passed — grey), `active`. Pure (takes `now`) so the red-window * test is deterministic. */ export function expiryState(expiresAt: string | null | undefined, now: number): KeyExpiryState { if (!expiresAt) return "never"; const t = new Date(expiresAt).getTime(); if (Number.isNaN(t)) return "expired"; if (t <= now) return "expired"; if (t - now <= 7 * DAY_MS) return "soon"; return "active"; } /** Status badge: Revoked wins, then Expired, then Active. */ export function keyStatus( row: { revokedAt: string | null; expiresAt: string | null }, now: number, ): "Active" | "Revoked" | "Expired" { if (row.revokedAt) return "Revoked"; if (expiryState(row.expiresAt, now) === "expired") return "Expired"; return "Active"; } /** Relative time for `lastUsedAt` ("never", "just now", "3 m ago", …). */ export function relativeTime(iso: string | null | undefined, now: number): string { if (!iso) return "never"; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return "never"; const diff = now - t; if (diff < 0) return "just now"; if (diff < 45_000) return "just now"; const min = Math.floor(diff / 60_000); if (min < 60) return `${min} m ago`; const h = Math.floor(min / 60); if (h < 24) return `${h} h ago`; const d = Math.floor(h / 24); if (d < 30) return `${d} d ago`; const mo = Math.floor(d / 30); if (mo < 12) return `${mo} mo ago`; return `${Math.floor(mo / 12)} y ago`; } /** A short date label for expiry ("Jan 5, 2027"). */ export function shortDate(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } /** Validation bounds (mirror the server DTO — the server is authoritative). */ export const KEY_NAME_MAX = 100; export const RATE_LIMIT_MIN = 1; export const RATE_LIMIT_MAX = 10_000; /** Expiry window: now + 1 h … now + 1 y (docs §7 / the create dialog). */ export function expiryBounds(now: number): { min: string; max: string } { const toInput = (ms: number) => new Date(ms).toISOString().slice(0, 16); return { min: toInput(now + 3_600_000), max: toInput(now + 365 * 24 * 3_600_000) }; }