From 2f6d227a8d7f715d52d806010b6dac9bc607a910 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:59:14 -0700 Subject: [PATCH] CL-5864: Settings credentials section (list/create/revoke) Add a Credentials settings section over native hub credential routes. Secrets are write-only; section access is grant-gated like People/Roles. --- apps/web/src/pages/settings-page.tsx | 10 + packages/settings-ui/src/access.ts | 14 +- packages/settings-ui/src/credentials-api.ts | 111 +++++ .../settings-ui/src/credentials-section.tsx | 390 ++++++++++++++++++ packages/settings-ui/src/index.ts | 18 + packages/settings-ui/src/strings.ts | 26 ++ .../settings-ui/test/credentials-api.test.ts | 119 ++++++ 7 files changed, 682 insertions(+), 6 deletions(-) create mode 100644 packages/settings-ui/src/credentials-api.ts create mode 100644 packages/settings-ui/src/credentials-section.tsx create mode 100644 packages/settings-ui/test/credentials-api.test.ts diff --git a/apps/web/src/pages/settings-page.tsx b/apps/web/src/pages/settings-page.tsx index 442811521..799ca0d78 100644 --- a/apps/web/src/pages/settings-page.tsx +++ b/apps/web/src/pages/settings-page.tsx @@ -10,6 +10,7 @@ import { AccountSection, BenchSection, ChatSection, + CredentialsSection, GrantsSection, PeopleSection, RolesSection, @@ -72,6 +73,15 @@ export function SettingsRoute() { ), }); } + if (access.credentials === "allowed") { + sections.push({ + id: "credentials", + title: "Credentials", + render: (ctx: SettingsContext) => ( + + ), + }); + } return ( diff --git a/packages/settings-ui/src/access.ts b/packages/settings-ui/src/access.ts index 086e472b8..e956ef922 100644 --- a/packages/settings-ui/src/access.ts +++ b/packages/settings-ui/src/access.ts @@ -1,9 +1,9 @@ -// Whether the People/Roles/Grants sections belong in the settings nav at -// all, decided the way the rest of this surface's og pages already gate -// access: never a disabled tab, just an absent one. There's no capability -// listing to read this off of, so this probes the one grant-checked route -// that requires no grant of its own — `evaluate` — for the resource each -// section is built on. +// Whether the People/Roles/Grants/Credentials sections belong in the +// settings nav at all, decided the way the rest of this surface's og +// pages already gate access: never a disabled tab, just an absent one. +// There's no capability listing to read this off of, so this probes the +// one grant-checked route that requires no grant of its own — +// `evaluate` — for the resource each section is built on. import { useEffect, useState } from "react"; @@ -15,6 +15,7 @@ export type TenancyAccess = { readonly people: SectionAccess; readonly roles: SectionAccess; readonly grants: SectionAccess; + readonly credentials: SectionAccess; }; function useResourceAccess( @@ -58,5 +59,6 @@ export function useTenancyAccess( people: useResourceAccess(tenantId, principalId, "principal"), roles: useResourceAccess(tenantId, principalId, "role"), grants: useResourceAccess(tenantId, principalId, "grant"), + credentials: useResourceAccess(tenantId, principalId, "credential"), }; } diff --git a/packages/settings-ui/src/credentials-api.ts b/packages/settings-ui/src/credentials-api.ts new file mode 100644 index 000000000..c00efda8f --- /dev/null +++ b/packages/settings-ui/src/credentials-api.ts @@ -0,0 +1,111 @@ +// Credentials section seam to Interchange's native credential + provider +// routes. Secrets are write-only: list/get never return them; create +// accepts the secret once and the hub encrypts it. + +import { type } from "arktype"; +import type { ArkErrors } from "arktype"; +import { + CredentialResponse, + ProviderResponse, + paginatedSchema, + type CredentialType, +} from "@intx/types"; + +export type Credential = typeof CredentialResponse.infer; +export type Provider = typeof ProviderResponse.infer; + +const CredentialsPage = paginatedSchema(CredentialResponse); +const ProvidersPage = paginatedSchema(ProviderResponse); + +export class CredentialsApiError extends Error { + constructor( + message: string, + readonly status?: number, + ) { + super(message); + } +} + +type Validator = (data: unknown) => T | ArkErrors; + +async function request( + path: string, + schema: Validator, + init?: RequestInit, +): Promise { + let response: Response; + try { + response = await fetch(path, { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + } catch (cause) { + throw new CredentialsApiError( + cause instanceof Error ? cause.message : String(cause), + ); + } + if (response.status === 401) { + throw new CredentialsApiError(`Not signed in for ${path}.`, 401); + } + if (response.status === 403) { + throw new CredentialsApiError(`Not permitted to view ${path}.`, 403); + } + if (!response.ok) { + throw new CredentialsApiError( + `The hub answered ${response.status} for ${path}.`, + response.status, + ); + } + if (response.status === 204) return undefined as T; + const body: unknown = await response.json().catch(() => undefined); + const parsed = schema(body); + if (parsed instanceof type.errors) { + throw new CredentialsApiError( + `Unexpected response shape from ${path}: ${parsed.summary}`, + ); + } + return parsed; +} + +export function listCredentials( + tenantId: string, +): Promise { + return request(`/api/tenants/${tenantId}/credentials`, CredentialsPage).then( + (page) => page.data, + ); +} + +export function listProviders(tenantId: string): Promise { + return request(`/api/tenants/${tenantId}/providers`, ProvidersPage).then( + (page) => page.data, + ); +} + +export type CreateCredentialInput = { + readonly providerId: string; + readonly name: string; + readonly type: CredentialType; + readonly secret: string; + readonly description?: string; +}; + +export function createCredential( + tenantId: string, + input: CreateCredentialInput, +): Promise { + return request(`/api/tenants/${tenantId}/credentials`, CredentialResponse, { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function deleteCredential( + tenantId: string, + credentialId: string, +): Promise { + return request( + `/api/tenants/${tenantId}/credentials/${credentialId}`, + (data) => data as void, + { method: "DELETE" }, + ); +} diff --git a/packages/settings-ui/src/credentials-section.tsx b/packages/settings-ui/src/credentials-section.tsx new file mode 100644 index 000000000..b1a5ae0f3 --- /dev/null +++ b/packages/settings-ui/src/credentials-section.tsx @@ -0,0 +1,390 @@ +// The "Credentials" settings section: tenant-owned secrets (API keys, +// tokens) listed without the secret material, creatable, and revocable +// over the native `/api/tenants/:tenantId/credentials` routes. + +import { + Badge, + Button, + ConfirmButton, + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + EmptyState, + Input, + SettingsPanel, + Skeleton, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@corbits/react-ui"; +import { credentialTypes } from "@intx/types"; +import type { CredentialType } from "@intx/types"; +import { CircleAlert, KeyRound } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { + createCredential, + deleteCredential, + listCredentials, + listProviders, + type Credential, + type Provider, +} from "./credentials-api"; +import { errorMessage, type LoadState } from "./load-state"; +import { SETTINGS_STRINGS } from "./strings"; + +const STATUS_TONE: Record< + Credential["status"], + "success" | "danger" | "neutral" | "info" +> = { + active: "success", + expired: "neutral", + revoked: "danger", + error: "danger", +}; + +type CredentialsData = { + readonly credentials: readonly Credential[]; + readonly providers: readonly Provider[]; +}; + +export function CredentialsSection({ + tenantId, +}: { + readonly tenantId: string | null; +}) { + const [state, setState] = useState>({ + kind: "loading", + }); + const [reloadKey, setReloadKey] = useState(0); + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [rowError, setRowError] = useState(null); + + useEffect(() => { + if (tenantId === null) return; + let cancelled = false; + setState({ kind: "loading" }); + Promise.all([listCredentials(tenantId), listProviders(tenantId)]) + .then(([credentials, providers]) => { + if (!cancelled) + setState({ kind: "ready", data: { credentials, providers } }); + }) + .catch((cause: unknown) => { + if (!cancelled) + setState({ kind: "error", message: errorMessage(cause) }); + }); + return () => { + cancelled = true; + }; + }, [tenantId, reloadKey]); + + if (tenantId === null) { + return ( + + ); + } + if (state.kind === "loading") return ; + if (state.kind === "error") { + return ( + } + title={`Couldn't load ${SETTINGS_STRINGS.credentialsLoadError}`} + description={state.message} + /> + ); + } + + function reload() { + setReloadKey((value) => value + 1); + } + + function handleCreate(input: { + readonly providerId: string; + readonly name: string; + readonly type: CredentialType; + readonly secret: string; + readonly description: string; + }) { + if (tenantId === null) return; + setCreating(true); + setCreateError(null); + createCredential(tenantId, { + providerId: input.providerId, + name: input.name, + type: input.type, + secret: input.secret, + ...(input.description.trim() !== "" + ? { description: input.description.trim() } + : {}), + }) + .then(() => { + setCreateOpen(false); + reload(); + }) + .catch(() => setCreateError(SETTINGS_STRINGS.credentialsCreateError)) + .finally(() => setCreating(false)); + } + + function handleDelete(credential: Credential) { + if (tenantId === null) return; + setRowError(null); + deleteCredential(tenantId, credential.id) + .then(reload) + .catch(() => setRowError(SETTINGS_STRINGS.credentialsDeleteError)); + } + + const providerNameById = new Map( + state.data.providers.map((provider) => [provider.id, provider.name]), + ); + + return ( + +
+ +
+ {rowError !== null && ( +

+ {rowError} +

+ )} + + +
+ ); +} + +export function CredentialsTable({ + credentials, + providerNameById, + onDelete, +}: { + readonly credentials: readonly Credential[]; + readonly providerNameById: ReadonlyMap; + readonly onDelete: (credential: Credential) => void; +}) { + if (credentials.length === 0) { + return ( + } + title={SETTINGS_STRINGS.credentialsEmptyTitle} + description={SETTINGS_STRINGS.credentialsEmptyDescription} + /> + ); + } + return ( + + + + Name + Provider + Type + Status + Actions + + + + {credentials.map((credential) => ( + + {credential.name} + + {providerNameById.get(credential.providerId) ?? + credential.providerId} + + + {credential.type} + + + + {credential.status} + + + + onDelete(credential)} + > + {SETTINGS_STRINGS.credentialsDelete} + + + + ))} + +
+ ); +} + +export function CreateCredentialDialog({ + open, + onOpenChange, + providers, + onCreate, + submitting, + error, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly providers: readonly Provider[]; + readonly onCreate: (input: { + readonly providerId: string; + readonly name: string; + readonly type: CredentialType; + readonly secret: string; + readonly description: string; + }) => void; + readonly submitting: boolean; + readonly error: string | null; +}) { + const [providerId, setProviderId] = useState(""); + const [name, setName] = useState(""); + const [type, setType] = useState("api_key"); + const [secret, setSecret] = useState(""); + const [description, setDescription] = useState(""); + + useEffect(() => { + if (!open) return; + setProviderId(providers[0]?.id ?? ""); + setName(""); + setType("api_key"); + setSecret(""); + setDescription(""); + }, [open, providers]); + + const canSubmit = + providerId !== "" && + name.trim() !== "" && + secret.trim() !== "" && + !submitting; + + return ( + + + + + {SETTINGS_STRINGS.credentialsCreateDialogTitle} + + + {SETTINGS_STRINGS.credentialsCreateDialogDescription} + + + + + + + + + {error !== null && ( +

+ {error} +

+ )} +
+ + + + +
+
+ ); +} diff --git a/packages/settings-ui/src/index.ts b/packages/settings-ui/src/index.ts index 6efb496fa..12829f818 100644 --- a/packages/settings-ui/src/index.ts +++ b/packages/settings-ui/src/index.ts @@ -20,6 +20,11 @@ export { GrantsTable, CreateGrantDialog, } from "./grants-section"; +export { + CredentialsSection, + CredentialsTable, + CreateCredentialDialog, +} from "./credentials-section"; export { principalLabel } from "./identity"; export type { PrincipalLabel } from "./identity"; @@ -55,6 +60,19 @@ export type { CreateGrantInput, } from "./tenancy-api"; +export { + CredentialsApiError, + listCredentials, + listProviders, + createCredential, + deleteCredential, +} from "./credentials-api"; +export type { + Credential, + Provider, + CreateCredentialInput, +} from "./credentials-api"; + export { contextWindowLabel, parseContextWindowInput, diff --git a/packages/settings-ui/src/strings.ts b/packages/settings-ui/src/strings.ts index 6d86e97b7..8d8072577 100644 --- a/packages/settings-ui/src/strings.ts +++ b/packages/settings-ui/src/strings.ts @@ -134,4 +134,30 @@ export const SETTINGS_STRINGS = { grantsFilterEffect: "Effect", grantsFilterAny: "Any", grantsNoExpiry: "Never", + + credentialsSectionTitle: "Credentials", + credentialsSectionDescription: + "API keys and tokens this bench stores for providers. Secrets are write-only — they are never shown again after create.", + credentialsLoadError: "this bench's credentials", + credentialsEmptyTitle: "No credentials yet", + credentialsEmptyDescription: + "Add a provider credential so agents and tools can authenticate.", + credentialsCreateAction: "New credential", + credentialsCreateDialogTitle: "New credential", + credentialsCreateDialogDescription: + "Store a secret against a provider. The secret is encrypted and never returned on later reads.", + credentialsProviderLabel: "Provider", + credentialsNoProviders: "No providers configured yet", + credentialsNameLabel: "Name", + credentialsNamePlaceholder: "e.g. OpenAI production", + credentialsTypeLabel: "Type", + credentialsSecretLabel: "Secret", + credentialsDescriptionLabel: "Description (optional)", + credentialsCreateSubmit: "Store", + credentialsCreateSubmitting: "Storing…", + credentialsCreateCancel: "Cancel", + credentialsCreateError: "Couldn't store that credential — try again.", + credentialsDelete: "Revoke", + credentialsDeleteConfirm: "Revoke for good?", + credentialsDeleteError: "Couldn't revoke that credential — try again.", } as const; diff --git a/packages/settings-ui/test/credentials-api.test.ts b/packages/settings-ui/test/credentials-api.test.ts new file mode 100644 index 000000000..7d7df0d23 --- /dev/null +++ b/packages/settings-ui/test/credentials-api.test.ts @@ -0,0 +1,119 @@ +// Credentials API client: stub global fetch, assert request + parse. + +import { afterEach, describe, expect, test } from "bun:test"; + +import { + CredentialsApiError, + createCredential, + deleteCredential, + listCredentials, + listProviders, +} from "../src/credentials-api"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +type RecordedCall = { readonly path: string; readonly init?: RequestInit }; + +function stubFetch(respond: (path: string) => Response): RecordedCall[] { + const calls: RecordedCall[] = []; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const path = + typeof input === "string" ? input : new URL(String(input)).pathname; + calls.push(init === undefined ? { path } : { path, init }); + return Promise.resolve(respond(path)); + }) as typeof fetch; + return calls; +} + +const json = (body: unknown, status = 200) => + new Response(body === undefined ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const timestamps = { + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +const credentialRow = { + id: "cred_1", + tenantId: "tnt_1", + providerId: "prov_1", + name: "OpenAI", + type: "api_key" as const, + status: "active" as const, + ...timestamps, +}; + +const providerRow = { + id: "prov_1", + tenantId: "tnt_1", + name: "OpenAI", + plugin: "openai", + ...timestamps, +}; + +describe("listCredentials", () => { + test("fetches the tenant's credentials page", async () => { + const calls = stubFetch(() => + json({ data: [credentialRow], nextCursor: null }), + ); + const rows = await listCredentials("tnt_1"); + expect(calls[0]?.path).toBe("/api/tenants/tnt_1/credentials"); + expect(rows).toHaveLength(1); + expect(rows[0]?.name).toBe("OpenAI"); + }); + + test("throws CredentialsApiError on 403", async () => { + stubFetch(() => json({ error: "nope" }, 403)); + await expect(listCredentials("tnt_1")).rejects.toBeInstanceOf( + CredentialsApiError, + ); + }); +}); + +describe("listProviders", () => { + test("fetches the tenant's providers page", async () => { + const calls = stubFetch(() => + json({ data: [providerRow], nextCursor: null }), + ); + const rows = await listProviders("tnt_1"); + expect(calls[0]?.path).toBe("/api/tenants/tnt_1/providers"); + expect(rows[0]?.plugin).toBe("openai"); + }); +}); + +describe("createCredential", () => { + test("POSTs provider, name, type, and secret", async () => { + const calls = stubFetch(() => json(credentialRow, 201)); + const created = await createCredential("tnt_1", { + providerId: "prov_1", + name: "OpenAI", + type: "api_key", + secret: "sk-test", + }); + expect(calls[0]?.path).toBe("/api/tenants/tnt_1/credentials"); + expect(calls[0]?.init?.method).toBe("POST"); + const body = JSON.parse(String(calls[0]?.init?.body)) as { + secret: string; + providerId: string; + }; + expect(body.secret).toBe("sk-test"); + expect(body.providerId).toBe("prov_1"); + expect(created.id).toBe("cred_1"); + }); +}); + +describe("deleteCredential", () => { + test("DELETEs the credential id", async () => { + const calls = stubFetch(() => new Response(null, { status: 204 })); + await deleteCredential("tnt_1", "cred_1"); + expect(calls[0]?.path).toBe("/api/tenants/tnt_1/credentials/cred_1"); + expect(calls[0]?.init?.method).toBe("DELETE"); + }); +});