diff --git a/package-lock.json b/package-lock.json index 2daa765..432be9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2569,6 +2569,19 @@ "integrity": "sha512-/uhHJJGH+1xSSz3mJn6X+m6aruYjMD3JOaRp/d4R/YWlzpy07H9z0/JUleIyRyBPNmaANSIwjTZ7aVjaukOEpg==", "license": "MIT" }, + "node_modules/@profullstack/hqtui": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@profullstack/hqtui/-/hqtui-0.3.0.tgz", + "integrity": "sha512-ceiLIarditlryaHqYW1V2VCkF7mlV+u9LJoNB3zEW6FSnRzvugjgLkju4qEsWZSkojeE+lEir5W6nxtuuGzmXw==", + "license": "MIT", + "bin": { + "hqtui": "bin/hqtui.mjs" + }, + "engines": { + "bun": ">=1.1", + "node": ">=22.6" + } + }, "node_modules/@profullstack/logicsrc-mcp": { "resolved": "packages/logicsrc-mcp", "link": true @@ -7794,6 +7807,7 @@ "@logicsrc/plugin-ugig": "file:../../plugins/ugig", "@logicsrc/tui": "file:../tui", "@logicsrc/validators": "file:../validators", + "@profullstack/hqtui": "^0.3.0", "commander": "^14.0.2" }, "bin": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 2cf1cfc..a8e5981 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,6 +29,7 @@ "@logicsrc/plugin-ugig": "file:../../plugins/ugig", "@logicsrc/tui": "file:../tui", "@logicsrc/validators": "file:../validators", + "@profullstack/hqtui": "^0.3.0", "commander": "^14.0.2" }, "devDependencies": { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fe33516..1dbc518 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -21,6 +21,7 @@ import { teamsMembersAction, teamsVaultsAction, teamsGrantAction, + teamsTuiAction, teamsPushAction, teamsPullAction, secretsTeamsLinkAction, @@ -752,6 +753,13 @@ teams .description("List a team's credential vaults.") .action((slug, options) => teamsVaultsAction(slug, options.format as OutputFormat)); +teams + .command("tui") + .alias("ui") + .option("--theme ", "hqtui theme name") + .description("Browse team vaults, their secret names, who can decrypt them, and the audit trail. Read-only; values stay encrypted.") + .action((options) => teamsTuiAction({ theme: options.theme })); + teams .command("grant") .argument("", "Team slug") diff --git a/packages/cli/src/teams.ts b/packages/cli/src/teams.ts index da2e2cb..716b836 100644 --- a/packages/cli/src/teams.ts +++ b/packages/cli/src/teams.ts @@ -503,3 +503,16 @@ export async function secretsDownAction(envName: string | undefined, options: { const link = requireSecretsLink(options.cwd); await teamsPullAction(link.team, link.project, envName ?? link.env, { env: options.env, format: options.format }); } + +/** + * The vault browser. + * + * Read-only and never handles plaintext: it shows which vaults exist, what + * their secrets are called, who can decrypt them and what has changed, but + * never a value. See vault-tui.ts for why. + */ +export async function teamsTuiAction(options: { theme?: string } = {}): Promise { + const { client, identity } = authedClient(); + const { runVaultTui } = await import("./vault-tui-run.js"); + await runVaultTui({ client, identity: identity.email, theme: options.theme }); +} diff --git a/packages/cli/src/vault-tui-run.ts b/packages/cli/src/vault-tui-run.ts new file mode 100644 index 0000000..75a5563 --- /dev/null +++ b/packages/cli/src/vault-tui-run.ts @@ -0,0 +1,126 @@ +/** + * Loading and running the vault browser. + * + * Split from vault-tui.ts so the view can be rendered headlessly in tests + * without an authenticated client or a terminal. + */ +import type { TeamClient } from "@logicsrc/plugin-credential-sharing"; +import { + createVaultState, currentTeam, currentVault, moveSelection, nextPane, nextTab, + view, type VaultDetail, type VaultSnapshot, type VaultTuiState, +} from "./vault-tui.js"; + +/** Teams first; vaults and detail load lazily, because both cost a round trip. */ +export async function loadTeams(client: TeamClient): Promise { + const { teams } = await client.listTeams(); + return { teams, vaults: {}, details: {} }; +} + +export async function loadVaults(client: TeamClient, state: VaultTuiState): Promise { + const team = currentTeam(state); + if (!team || state.snapshot.vaults[team.slug]) return; + try { + const { vaults } = await client.listVaults(team.slug); + state.snapshot.vaults[team.slug] = vaults; + } catch (error) { + state.snapshot.vaults[team.slug] = []; + state.note = `could not list vaults: ${message(error)}`; + } +} + +/** + * Secret names, grants and audit for one vault. + * + * Each is fetched independently and a failure is recorded rather than thrown: + * a member without decryption access can still read the vault's shape, and + * losing the audit endpoint should not blank the secrets list. + */ +export async function loadDetail(client: TeamClient, state: VaultTuiState): Promise { + const vault = currentVault(state); + if (!vault || state.snapshot.details[vault.id]) return; + const detail: VaultDetail = { secrets: [], grants: [], audit: [] }; + const [secrets, grants, audit] = await Promise.allSettled([ + client.listSecrets(vault.id), + client.listGrants(vault.id), + client.listAudit(vault.id), + ]); + if (secrets.status === "fulfilled") detail.secrets = secrets.value.secrets; + else detail.error = `secrets: ${message(secrets.reason)}`; + if (grants.status === "fulfilled") detail.grants = grants.value.grants; + if (audit.status === "fulfilled") detail.audit = audit.value.audit; + state.snapshot.details[vault.id] = detail; +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export interface VaultTuiOptions { + client: TeamClient; + identity?: string; + theme?: string; +} + +export async function runVaultTui({ client, identity = "", theme }: VaultTuiOptions): Promise { + const { createApp, themes } = await loadHqtui(); + const state = createVaultState(await loadTeams(client), identity); + + const named = theme ? (themes as Record)[theme] : undefined; + const app = await createApp({ + theme: named ?? themes.dark, + title: "logicsrc vaults", + quitKeys: ["ctrl+c"], + }); + + const refresh = async (): Promise => { + state.loading = true; + app.invalidate(); + await loadVaults(client, state); + await loadDetail(client, state); + state.loading = false; + app.invalidate(); + }; + + app.on("key", (event: { key: string }) => { + switch (event.key) { + case "q": app.quit(); return; + case "tab": nextPane(state, 1); void refresh(); return; + case "shift+tab": nextPane(state, -1); return; + case "right": nextTab(state, 1); return; + case "left": nextTab(state, -1); return; + case "up": moveSelection(state, -1); void refresh(); return; + case "down": moveSelection(state, 1); void refresh(); return; + case "pageup": moveSelection(state, -10); void refresh(); return; + case "pagedown": moveSelection(state, 10); void refresh(); return; + case "r": + // Drop the caches so a reload actually re-fetches rather than + // redrawing what is already on screen. + state.snapshot.vaults = {}; + state.snapshot.details = {}; + state.note = "reloaded"; + void refresh(); + return; + } + }); + + await refresh(); + app.render((args) => view(args, state)); + await app.start(); +} + +/** hqtui needs Node 22.6+; say so rather than showing a module resolution error. */ +async function loadHqtui(): Promise { + try { + return await import("@profullstack/hqtui"); + } catch (error) { + const [major, minor] = process.versions.node.split(".").map(Number); + const tooOld = (major ?? 0) < 22 || ((major ?? 0) === 22 && (minor ?? 0) < 6); + throw new Error( + tooOld + ? `The vault browser needs Node 22.6 or newer (this is ${process.versions.node}). ` + + `Use \`logicsrc teams vaults \` instead.` + : `Could not load @profullstack/hqtui: ${message(error)}. ` + + `Use \`logicsrc teams vaults \` instead.`, + ); + } +} diff --git a/packages/cli/src/vault-tui.test.ts b/packages/cli/src/vault-tui.test.ts new file mode 100644 index 0000000..7d5c868 --- /dev/null +++ b/packages/cli/src/vault-tui.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; +import { renderToText } from "@profullstack/hqtui/testing"; +import type { RemoteTeam, RemoteVault } from "@logicsrc/plugin-credential-sharing"; +import { + createVaultState, currentVault, moveSelection, nextPane, nextTab, splitVault, view, + type VaultSnapshot, type VaultTuiState, +} from "./vault-tui.js"; + +const TEAMS: RemoteTeam[] = [ + { id: "t1", slug: "profullstack", name: "Profullstack" }, + { id: "t2", slug: "coinpay", name: "CoinPay" }, +]; + +const VAULTS: RemoteVault[] = [ + { id: "v1", name: "crawlproof--prod", hasAccess: true, secretCount: 12 }, + { id: "v2", name: "crawlproof--staging", hasAccess: false, secretCount: 9 }, + { id: "v3", name: "legacy", hasAccess: true, secretCount: 1 }, +]; + +function snapshot(): VaultSnapshot { + return { + teams: TEAMS, + vaults: { profullstack: VAULTS, coinpay: [] }, + details: { + v1: { + secrets: [ + { name: "DATABASE_URL", nonce: "n", ciphertext: "c", fingerprint: "abcdef0123456789", version: 3, updatedAt: "2026-09-08T12:00:00.000Z" }, + { name: "STRIPE_SECRET_KEY", nonce: "n", ciphertext: "c", fingerprint: "9876543210fedcba", version: 1, updatedAt: "2026-09-01T09:30:00.000Z" }, + ], + grants: [ + { email: "anthony@profullstack.com", publicKey: "pk", status: "active", hasPublicKey: true, hasAccess: true }, + { email: "new@profullstack.com", publicKey: null, status: "invited", hasPublicKey: false, hasAccess: false }, + ], + audit: [{ createdAt: "2026-09-08T12:00:00.000Z", actorEmail: "anthony@profullstack.com", action: "secrets.put" }], + }, + }, + }; +} + +const state = (mutate: (s: VaultTuiState) => void = () => {}): VaultTuiState => { + const s = createVaultState(snapshot(), "anthony@profullstack.com"); + mutate(s); + return s; +}; + +const frame = (s: VaultTuiState, width = 120, height = 26): string => + renderToText((args) => view(args as never, s), { width, height }); + +describe("vault name parsing", () => { + it("splits project and environment", () => { + expect(splitVault("crawlproof--prod")).toEqual({ project: "crawlproof", env: "prod" }); + }); + + it("keeps a name with no separator whole rather than inventing an env", () => { + expect(splitVault("legacy")).toEqual({ project: "legacy", env: "—" }); + }); + + it("splits on the last separator, so a project containing one survives", () => { + expect(splitVault("a--b--prod")).toEqual({ project: "a--b", env: "prod" }); + }); +}); + +describe("rendering", () => { + it("draws teams, vaults and detail", () => { + const out = frame(state()); + expect(out).toMatch(/Teams \(2\)/); + expect(out).toMatch(/profullstack/); + expect(out).toMatch(/crawlproof/); + expect(out).toMatch(/prod/); + }); + + it("shows who is signed in, because access depends on it", () => { + expect(frame(state())).toMatch(/anthony@profullstack\.com/); + expect(frame(state((s) => { s.identity = ""; }))).toMatch(/not signed in/); + }); + + it("shows secret names and fingerprints but never a value", () => { + const out = frame(state((s) => { s.pane = "detail"; })); + expect(out).toMatch(/DATABASE_URL/); + expect(out).toMatch(/STRIPE_SECRET_KEY/); + // The ciphertext and nonce are in the fixture; neither may reach the screen. + expect(out).not.toMatch(/ciphertext/); + expect(out.includes("\nc\n")).toBe(false); + }); + + it("says values stay encrypted, so nobody hunts for a reveal key", () => { + const out = frame(state((s) => { s.pane = "detail"; })); + expect(out).toMatch(/Values stay encrypted/); + expect(out).toMatch(/teams pull/); + }); + + it("shows grants and the audit trail on their own tabs", () => { + const grants = frame(state((s) => { s.tab = "grants"; })); + expect(grants).toMatch(/anthony@profullstack\.com/); + expect(grants).toMatch(/invited/); + + // Wider than the default here: the assertion is about the action reaching + // the screen intact, and at 120 columns the detail pane truncates it. + const audit = frame(state((s) => { s.tab = "audit"; }), 150); + expect(audit).toMatch(/secrets\.put/); + expect(audit).toMatch(/2026-09-08 12:00/); + }); + + it("reports a team with no vaults rather than drawing an empty pane", () => { + const out = frame(state((s) => { s.team = 1; })); + expect(out).toMatch(/No vaults in this team/); + expect(out).toMatch(/teams push coinpay/); + }); + + it("handles having no teams at all", () => { + const out = frame(state((s) => { s.snapshot.teams = []; })); + expect(out).toMatch(/No teams/); + }); + + it("surfaces a detail error instead of an empty list", () => { + const out = frame(state((s) => { + s.snapshot.details.v1 = { secrets: [], grants: [], audit: [], error: "secrets: 403 forbidden" }; + s.pane = "detail"; + })); + expect(out).toMatch(/403 forbidden/); + }); + + it("does not overflow a narrow terminal", () => { + const out = frame(state(), 70, 20); + expect(out.split("\n").every((line) => line.length <= 70)).toBe(true); + }); +}); + +describe("navigation", () => { + it("cycles panes both ways", () => { + const s = state(); + nextPane(s, 1); + expect(s.pane).toBe("vaults"); + nextPane(s, -1); + expect(s.pane).toBe("teams"); + nextPane(s, -1); + expect(s.pane).toBe("detail"); + }); + + it("cycles detail tabs and resets the scroll", () => { + const s = state((x) => { x.detailOffset = 5; }); + nextTab(s, 1); + expect(s.tab).toBe("grants"); + expect(s.detailOffset).toBe(0); + }); + + it("clamps the selection to the list", () => { + const s = state((x) => { x.pane = "vaults"; }); + moveSelection(s, -5); + expect(s.vault).toBe(0); + moveSelection(s, 99); + expect(s.vault).toBe(2); + expect(currentVault(s)?.name).toBe("legacy"); + }); + + it("resets the vault selection when the team changes", () => { + // The old index means nothing in a different team's list, so keeping it + // would silently select an unrelated vault. + const s = state((x) => { x.pane = "vaults"; }); + moveSelection(s, 2); + expect(s.vault).toBe(2); + s.pane = "teams"; + moveSelection(s, 1); + expect(s.team).toBe(1); + expect(s.vault).toBe(0); + }); + + it("scrolls the detail pane when it has focus", () => { + const s = state((x) => { x.pane = "detail"; }); + moveSelection(s, 3); + expect(s.detailOffset).toBe(3); + moveSelection(s, -99); + expect(s.detailOffset).toBe(0); + }); +}); diff --git a/packages/cli/src/vault-tui.ts b/packages/cli/src/vault-tui.ts new file mode 100644 index 0000000..f208550 --- /dev/null +++ b/packages/cli/src/vault-tui.ts @@ -0,0 +1,351 @@ +/** + * `logicsrc teams tui` — a terminal browser for team credential vaults. + * + * Read-only, and it never handles plaintext. + * + * The server only ever holds ciphertext, and this screen keeps it that way: it + * shows which vaults exist, how many secrets each holds, what those secrets are + * *called*, who can decrypt them, and what has changed. It does not fetch a + * decryption key, does not unwrap one, and has no keybinding that would. A + * secret's value is `logicsrc teams pull`, in a shell, on purpose — a value + * that can appear on screen is a value that can appear in a screen share, a + * scrollback buffer, or a recording. + * + * Names are not secret and are the thing you actually need to navigate; values + * are, and they are the thing you rarely need to look at. + */ +import type { Container, Theme } from "@profullstack/hqtui"; +import type { + RemoteGrantRow, RemoteSecret, RemoteTeam, RemoteVault, +} from "@logicsrc/plugin-credential-sharing"; + +/** A vault name is `--`; anything else is shown as it came. */ +export function splitVault(name: string): { project: string; env: string } { + const at = name.lastIndexOf("--"); + if (at <= 0) return { project: name, env: "—" }; + return { project: name.slice(0, at), env: name.slice(at + 2) }; +} + +export interface VaultDetail { + secrets: RemoteSecret[]; + grants: RemoteGrantRow[]; + audit: Array>; + error?: string; +} + +export interface VaultSnapshot { + teams: RemoteTeam[]; + /** Vaults per team slug, loaded when the team is first selected. */ + vaults: Record; + /** Detail per vault id, loaded when the vault is first selected. */ + details: Record; +} + +export type Pane = "teams" | "vaults" | "detail"; +export type DetailTab = "secrets" | "grants" | "audit"; + +export interface VaultTuiState { + snapshot: VaultSnapshot; + pane: Pane; + tab: DetailTab; + team: number; + vault: number; + offsets: Record; + detailOffset: number; + loading: boolean; + note: string; + /** Who is signed in, shown so you know whose access you are looking at. */ + identity: string; +} + +export function createVaultState( + snapshot: VaultSnapshot, + identity = "", +): VaultTuiState { + return { + snapshot, + pane: "teams", + tab: "secrets", + team: 0, + vault: 0, + offsets: { teams: 0, vaults: 0, detail: 0 }, + detailOffset: 0, + loading: false, + note: "", + identity, + }; +} + +export function currentTeam(state: VaultTuiState): RemoteTeam | undefined { + return state.snapshot.teams[state.team]; +} + +export function currentVaults(state: VaultTuiState): RemoteVault[] { + const team = currentTeam(state); + return team ? (state.snapshot.vaults[team.slug] ?? []) : []; +} + +export function currentVault(state: VaultTuiState): RemoteVault | undefined { + return currentVaults(state)[state.vault]; +} + +export function currentDetail(state: VaultTuiState): VaultDetail | undefined { + const vault = currentVault(state); + return vault ? state.snapshot.details[vault.id] : undefined; +} + +const PANES: Pane[] = ["teams", "vaults", "detail"]; +const TABS: DetailTab[] = ["secrets", "grants", "audit"]; + +export function nextPane(state: VaultTuiState, delta: number): void { + const at = PANES.indexOf(state.pane); + state.pane = PANES[(at + delta + PANES.length) % PANES.length] as Pane; +} + +export function nextTab(state: VaultTuiState, delta: number): void { + const at = TABS.indexOf(state.tab); + state.tab = TABS[(at + delta + TABS.length) % TABS.length] as DetailTab; + state.detailOffset = 0; +} + +/** Move the selection in whichever pane has focus, clamped to its contents. */ +export function moveSelection(state: VaultTuiState, delta: number): void { + if (state.pane === "teams") { + const total = state.snapshot.teams.length; + if (total === 0) return; + const next = Math.max(0, Math.min(total - 1, state.team + delta)); + if (next === state.team) return; + state.team = next; + // A different team means a different vault list, so the old index is + // meaningless rather than merely out of range. + state.vault = 0; + state.detailOffset = 0; + return; + } + if (state.pane === "vaults") { + const total = currentVaults(state).length; + if (total === 0) return; + const next = Math.max(0, Math.min(total - 1, state.vault + delta)); + if (next === state.vault) return; + state.vault = next; + state.detailOffset = 0; + return; + } + state.detailOffset = Math.max(0, state.detailOffset + delta); +} + +function shortTime(value: unknown): string { + if (typeof value !== "string" || value === "") return "—"; + const at = new Date(value); + if (Number.isNaN(at.getTime())) return "—"; + return at.toISOString().slice(0, 16).replace("T", " "); +} + +/** + * Date only, for the secrets table. + * + * Three fixed columns plus a name that can run to 30 characters leaves no room + * for a timestamp, and a truncated one ("2026-09-0…") is worse than no clock: + * it looks like data while telling you nothing the date did not. + */ +function shortDate(value: unknown): string { + if (typeof value !== "string" || value === "") return "—"; + const at = new Date(value); + if (Number.isNaN(at.getTime())) return "—"; + return at.toISOString().slice(0, 10); +} + +/** A fingerprint identifies a value without revealing it. */ +function shortFingerprint(value: string): string { + return value.length <= 12 ? value : `${value.slice(0, 12)}…`; +} + +export function view( + { ui, theme, height }: { ui: Container; theme: Theme; height: number }, + state: VaultTuiState, +): void { + const team = currentTeam(state); + const vaults = currentVaults(state); + const vault = currentVault(state); + const detail = currentDetail(state); + + ui.row({ size: 1 }, (header) => { + header.text(" logicsrc vaults", { fg: theme.title, bold: true, size: 17 }); + header.text(state.identity || "not signed in", { + fg: state.identity ? theme.accent : theme.danger, + size: 30, + }); + header.text( + `${state.loading ? "loading… " : ""}Tab panes ←/→ view r reload q quit `, + { fg: theme.muted, align: "right" }, + ); + }); + + ui.row({ size: height - 3, gap: 1 }, (row) => { + row.panel({ + title: `Teams (${state.snapshot.teams.length})`, + width: "0.7fr", + borderColor: state.pane === "teams" ? theme.borderFocused : theme.border, + }, (p) => { + if (state.snapshot.teams.length === 0) { + p.label("No teams."); + p.label("logicsrc teams create ", { size: 1 }); + return; + } + p.list({ + items: state.snapshot.teams.map((t) => t.slug), + selected: state.team, + offset: state.offsets.teams, + scrollbar: true, + onScroll: (d) => { state.offsets.teams = Math.max(0, state.offsets.teams + d); }, + }); + }); + + row.panel({ + title: team ? `Vaults · ${team.slug} (${vaults.length})` : "Vaults", + width: "1.1fr", + borderColor: state.pane === "vaults" ? theme.borderFocused : theme.border, + }, (p) => { + if (!team) { p.label("Select a team."); return; } + if (vaults.length === 0) { + p.label("No vaults in this team."); + p.label(`logicsrc teams push ${team.slug} `, { size: 1 }); + return; + } + p.table({ + rows: vaults.map((v) => { + const parts = splitVault(v.name); + return { + project: parts.project, + env: parts.env, + n: String(v.secretCount), + access: v.hasAccess ? "yes" : "no", + hasAccess: v.hasAccess, + }; + }), + selected: state.vault, + offset: state.offsets.vaults, + followSelection: true, + scrollbar: true, + onScroll: (d) => { state.offsets.vaults = Math.max(0, state.offsets.vaults + d); }, + columns: [ + { key: "project", title: "Project", min: 8, color: theme.foreground }, + { key: "env", title: "Env", width: 10, color: theme.accent }, + { key: "n", title: "Secrets", width: 8, align: "right", color: theme.muted }, + { + key: "access", title: "Access", width: 7, + // Whether you can decrypt is the thing you came to find out, so it + // is coloured per row rather than shown as plain text. + color: (r) => (r.hasAccess ? theme.success : theme.warning), + }, + ], + }); + }); + + row.panel({ + title: vault ? `${splitVault(vault.name).project} · ${state.tab}` : "Detail", + width: "1.3fr", + borderColor: state.pane === "detail" ? theme.borderFocused : theme.border, + }, (p) => { + if (!vault) { p.label("Select a vault."); return; } + p.row({ size: 1 }, (r) => { + r.tabs({ + tabs: TABS.map((t) => t), + active: TABS.indexOf(state.tab), + onSelect: (index) => { state.tab = TABS[index] as DetailTab; state.detailOffset = 0; }, + }); + }); + p.divider(); + + if (!detail) { p.label("Loading…"); return; } + if (detail.error) { p.text(detail.error, { fg: theme.danger, wrap: true }); return; } + + if (state.tab === "secrets") { + if (detail.secrets.length === 0) { p.label("No secrets in this vault."); return; } + p.table({ + rows: detail.secrets.slice(state.detailOffset).map((s) => ({ + name: s.name, + version: `v${s.version}`, + fingerprint: shortFingerprint(s.fingerprint), + updated: shortDate(s.updatedAt), + })), + selected: -1, + scrollbar: true, + onScroll: (d) => { state.detailOffset = Math.max(0, state.detailOffset + d); }, + columns: [ + { key: "name", title: "Name", min: 12, color: theme.foreground }, + { key: "version", title: "Ver", width: 5, color: theme.muted }, + { key: "fingerprint", title: "Fingerprint", width: 14, color: theme.secondary }, + { key: "updated", title: "Updated", width: 10, color: theme.muted }, + ], + }); + p.divider(); + // Said on the screen, not just in the docs: someone will look for the + // reveal key, and the answer is that there deliberately isn't one. + p.text( + `Values stay encrypted. To read them: logicsrc teams pull ${team?.slug ?? ""} ` + + `${splitVault(vault.name).project} ${splitVault(vault.name).env}`, + { fg: theme.muted, wrap: true }, + ); + return; + } + + if (state.tab === "grants") { + if (detail.grants.length === 0) { p.label("Nobody can decrypt this vault."); return; } + p.table({ + rows: detail.grants.slice(state.detailOffset).map((g) => ({ + email: g.email, + status: g.status, + key: g.hasPublicKey ? "yes" : "no", + access: g.hasAccess ? "yes" : "no", + hasAccess: g.hasAccess, + })), + selected: -1, + scrollbar: true, + onScroll: (d) => { state.detailOffset = Math.max(0, state.detailOffset + d); }, + columns: [ + { key: "email", title: "Member", min: 14, color: theme.foreground }, + { key: "status", title: "Status", width: 8, color: theme.muted }, + { key: "key", title: "Key", width: 4, color: theme.muted }, + { + key: "access", title: "Decrypt", width: 8, + color: (r) => (r.hasAccess ? theme.success : theme.muted), + }, + ], + }); + return; + } + + if (detail.audit.length === 0) { p.label("No audit entries."); return; } + p.table({ + rows: detail.audit.slice(state.detailOffset).map((entry) => ({ + when: shortTime(entry.createdAt ?? entry.at), + who: String(entry.actorEmail ?? entry.actor ?? "—"), + what: String(entry.action ?? entry.event ?? "—"), + })), + selected: -1, + scrollbar: true, + onScroll: (d) => { state.detailOffset = Math.max(0, state.detailOffset + d); }, + columns: [ + { key: "when", title: "When", width: 17, color: theme.muted }, + // The action is the point of an audit row, so it gets the wider + // floor: an email truncates to something still recognisable, where + // "secrets…" could be put, get or delete. + { key: "who", title: "Who", min: 8, color: theme.foreground }, + { key: "what", title: "What", min: 16, color: theme.accent }, + ], + }); + }); + }); + + ui.statusBar({ + items: [ + { key: "Tab", label: state.pane, active: true }, + { key: "←/→", label: state.tab }, + { key: "↑↓", label: "Move" }, + { key: "r", label: "Reload" }, + { key: "q", label: "Quit" }, + ], + right: [{ label: state.note }], + }); +}