From ca21348fcd0a4e2b4e33e7e696bf57d6d0062295 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 8 Sep 2026 08:07:17 -0700 Subject: [PATCH] Add `logicsrc teams tui`, a browser for team vaults (#146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams on the left, their vaults in the middle, and the selected vault's detail on the right across three tabs: secret names, who can decrypt, and the audit trail. It never handles plaintext, and that is the point rather than a limitation. The server only ever holds ciphertext and this keeps it that way: no decryption key is fetched, none is unwrapped, and there is no keybinding that would. The secrets tab says so on screen and points at `logicsrc teams pull`, because otherwise the first thing anyone does is hunt for a reveal key. A value that can appear on screen can appear in a screen share, a scrollback buffer or a recording; names are what you need to navigate, values are what you rarely need to look at. Names, fingerprints and versions are enough to answer the questions you actually open this for: does the vault exist, has the rotation landed, and who can still read it. Notes on the shape: - Vaults and detail load lazily, because each is a round trip. The three detail calls are settled independently, so a member without decryption access still sees the vault's shape and a missing audit endpoint does not blank the secrets list. - Changing team resets the vault selection. The old index means nothing in a different team's list, and keeping it silently selects an unrelated vault. - The secrets table shows a date rather than a timestamp. Three fixed columns plus a name that can run to thirty characters leaves no room, and a truncated clock looks like data while telling you nothing. - The audit table gives its widest floor to the action, not the actor: an email truncates to something recognisable, where "secrets…" could be put, get or delete. The view is split from the loader so it renders headlessly without an authenticated client or a terminal. 17 tests cover that, including one asserting no ciphertext reaches the screen; 226 pass across the CLI. Claude-Session: https://claude.ai/code/session_017Df2FNu5DhinMV2soRz3cy Co-authored-by: Claude Opus 5 (1M context) --- package-lock.json | 14 ++ packages/cli/package.json | 1 + packages/cli/src/index.ts | 8 + packages/cli/src/teams.ts | 13 ++ packages/cli/src/vault-tui-run.ts | 126 +++++++++++ packages/cli/src/vault-tui.test.ts | 175 ++++++++++++++ packages/cli/src/vault-tui.ts | 351 +++++++++++++++++++++++++++++ 7 files changed, 688 insertions(+) create mode 100644 packages/cli/src/vault-tui-run.ts create mode 100644 packages/cli/src/vault-tui.test.ts create mode 100644 packages/cli/src/vault-tui.ts 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 }], + }); +}