From eb7cf85d954ac503c95a82745d52145c22237d24 Mon Sep 17 00:00:00 2001 From: Allan Niemerg <1568680+aniemerg@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:03:41 +0000 Subject: [PATCH 1/6] feat: add credential store settings panel Atrium-Session: 62eec149-4e7f-480b-9827-3485424d4007 Atrium-Harness: codex --- surface/server/src/routes/me.ts | 146 ++++++++++++++++- surface/shared/src/api.ts | 37 +++++ surface/web/src/api.ts | 2 + .../src/components/SettingsSurface.test.tsx | 19 ++- .../web/src/components/SettingsSurface.tsx | 151 +++++++++++++++++- 5 files changed, 350 insertions(+), 5 deletions(-) diff --git a/surface/server/src/routes/me.ts b/surface/server/src/routes/me.ts index bfbd930f7..56512c441 100644 --- a/surface/server/src/routes/me.ts +++ b/surface/server/src/routes/me.ts @@ -24,6 +24,10 @@ import type { WsHub } from '../hub.js'; import { type IronControlAdminClient, atriumPrincipalForeignId, + claudeStaticSecretForeignId, + codexAccountIdSecretForeignId, + codexBearerSecretForeignId, + codexBrokerCredentialForeignId, githubAppUserBrokerCredentialForeignId, } from '../iron-control.js'; import { @@ -35,7 +39,7 @@ import { upsertGitHubInstallationBrokerCredential, } from '../github-iron-control.js'; import { type AgentProfiles, providerFromProfileValue } from '../agent-profiles.js'; -import { CODEX_PROVIDER, type ProviderCredentials } from '../provider-credentials.js'; +import { CODEX_PROVIDER, PROXY_CREDENTIAL_SENTINEL, type ProviderCredentials } from '../provider-credentials.js'; import { PendingOAuthStore } from '../provider-oauth.js'; import type { SessionRuns } from '../session-runs.js'; import { decodeRouteBody, decodeRouteParams, decodeRouteQuery } from '../route-schema.js'; @@ -150,6 +154,132 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void return { providers: await providerCredentials.list(user.id) }; }); + app.get('/api/me/credential-store', async (req, reply) => { + const user = requireUser(req, reply); + if (!user) return; + const query = decodeRouteQuery(WorkspaceQuerySchema, req.query); + const requestedWorkspaceId = typeof query.workspaceId === 'string' ? query.workspaceId : undefined; + const workspaceId = await connections.resolveWorkspaceId(user.id, requestedWorkspaceId); + if (!workspaceId) { + const error = requestedWorkspaceId ? 'workspace_not_found' : 'no_workspace'; + return reply.code(requestedWorkspaceId ? 404 : 403).send({ error, message: 'workspace not found' }); + } + const [providers, connectionList, backingRows] = await Promise.all([ + providerCredentials.list(user.id), + connections.list(user.id, workspaceId), + pool.query<{ provider: string; token_ciphertext: string }>( + `SELECT provider, token_ciphertext + FROM user_provider_credentials + WHERE user_id = $1 + AND provider = ANY($2::text[])`, + [user.id, ['claude-code', 'codex']], + ), + ]); + const backingByProvider = new Map(backingRows.rows.map((row) => [row.provider, row.token_ciphertext])); + const principalForeignId = atriumPrincipalForeignId(workspaceId, user.id); + const providerItems = providers.map((provider) => { + const proxyBacked = backingByProvider.get(provider.provider) === PROXY_CREDENTIAL_SENTINEL; + const codex = provider.provider === 'codex'; + return { + id: `agent:${provider.provider}`, + kind: 'agent_provider', + provider: provider.provider, + label: codex ? 'Codex' : 'Claude Code', + connected: provider.connected, + status: provider.status, + workspaceId, + accountLabel: null, + tokenKind: proxyBacked ? 'oauth_proxy' : provider.connected ? 'local_secret' : null, + backingStore: proxyBacked ? 'iron_control' : provider.connected ? 'atrium_local' : 'unavailable', + active: provider.connected, + ironControl: { + namespace: ironControl.namespace, + principalForeignId, + brokerCredentialId: proxyBacked && codex ? codexBrokerCredentialForeignId(workspaceId, user.id) : null, + staticSecretId: null, + staticSecretForeignId: proxyBacked + ? codex + ? [ + codexBearerSecretForeignId(workspaceId, user.id), + codexAccountIdSecretForeignId(workspaceId, user.id), + ].join(', ') + : claudeStaticSecretForeignId(workspaceId, user.id) + : null, + }, + lastValidatedAt: provider.lastValidatedAt, + lastError: provider.lastError, + updatedAt: provider.updatedAt, + }; + }); + const connectionItems = connectionList.flatMap((connection) => { + const base = connection.identities.length > 0 ? connection.identities : []; + if (base.length === 0) { + return [ + { + id: `connection:${connection.provider}:public-read`, + kind: 'connection_identity', + provider: connection.provider, + label: 'GitHub public read', + connected: connection.connected, + status: connection.status, + workspaceId: connection.workspaceId, + accountLabel: connection.accountLabel, + tokenKind: connection.tokenKind, + backingStore: connection.status === 'public_read' ? 'public_read' : 'unavailable', + active: true, + ironControl: { + namespace: ironControl.namespace, + principalForeignId, + brokerCredentialId: null, + staticSecretId: null, + staticSecretForeignId: null, + }, + lastValidatedAt: connection.lastValidatedAt, + lastError: connection.lastError, + updatedAt: connection.updatedAt, + }, + ]; + } + return base.map((identity) => { + const metadata = identity.metadata ?? {}; + const brokerCredentialId = metadataString(metadata, 'brokerCredentialId'); + const staticSecretId = metadataString(metadata, 'staticSecretId'); + const staticSecretForeignId = metadataString(metadata, 'staticSecretForeignId'); + return { + id: `connection:${connection.provider}:${identity.id}`, + kind: 'connection_identity', + provider: connection.provider, + label: githubCredentialStoreLabel(identity.tokenKind, identity.accountLabel), + connected: identity.connected, + status: identity.status, + workspaceId: identity.workspaceId, + accountLabel: identity.accountLabel, + tokenKind: identity.tokenKind, + backingStore: brokerCredentialId || staticSecretId || staticSecretForeignId ? 'iron_control' : 'unavailable', + active: identity.active, + ironControl: { + namespace: ironControl.namespace, + principalForeignId, + brokerCredentialId, + staticSecretId, + staticSecretForeignId, + }, + lastValidatedAt: identity.lastValidatedAt, + lastError: identity.lastError, + updatedAt: identity.updatedAt, + }; + }); + }); + return { + credentialStore: { + configured: ironControl.configured, + namespace: ironControl.configured ? ironControl.namespace : null, + workspaceId, + items: [...providerItems, ...connectionItems], + }, + }; + }); + app.get('/api/me/connections', async (req, reply) => { const user = requireUser(req, reply); if (!user) return; @@ -948,6 +1078,20 @@ function metadataString(metadata: Record, key: string): string return typeof value === 'string' && value.trim() ? value.trim() : null; } +function githubCredentialStoreLabel(tokenKind: string, accountLabel: string | null): string { + const account = accountLabel ? ` ${accountLabel}` : ''; + switch (tokenKind) { + case 'app_installation': + return `GitHub app installation${account}`; + case 'app_user': + return `GitHub user${account}`; + case 'pat': + return `GitHub PAT${account}`; + default: + return `GitHub${account}`; + } +} + class RouteResponse extends Error { constructor( readonly statusCode: number, diff --git a/surface/shared/src/api.ts b/surface/shared/src/api.ts index 27b8553e3..5c06c63f9 100644 --- a/surface/shared/src/api.ts +++ b/surface/shared/src/api.ts @@ -215,6 +215,39 @@ export interface ConnectionIdentity { updatedAt: string | null; } +export type CredentialStoreItemKind = 'agent_provider' | 'connection_identity'; + +export interface CredentialStoreItem { + id: string; + kind: CredentialStoreItemKind; + provider: ProviderCredentialProvider | ConnectionProvider; + label: string; + connected: boolean; + status: 'connected' | 'needs_auth' | 'public_read' | 'unavailable'; + workspaceId: string | null; + accountLabel: string | null; + tokenKind: string | null; + backingStore: 'atrium_local' | 'iron_control' | 'public_read' | 'unavailable'; + active: boolean; + ironControl: { + namespace: string | null; + principalForeignId: string | null; + brokerCredentialId: string | null; + staticSecretId: string | null; + staticSecretForeignId: string | null; + }; + lastValidatedAt: string | null; + lastError: string | null; + updatedAt: string | null; +} + +export interface CredentialStoreStatus { + configured: boolean; + namespace: string | null; + workspaceId: string | null; + items: CredentialStoreItem[]; +} + export type Api = ReturnType; export type SessionListStatus = 'running' | 'recent' | 'all' | 'archived'; @@ -457,6 +490,10 @@ export function createApi(opts: ApiOptions = {}) { /** `prefs` is absent on servers that predate the user-prefs migration. */ me: () => req<{ user: UserRef; prefs?: UserPrefs }>('/auth/me'), providerCredentials: () => req<{ providers: ProviderCredentialStatus[] }>('/api/me/provider-credentials'), + credentialStore: (workspaceId?: string) => { + const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''; + return req<{ credentialStore: CredentialStoreStatus }>(`/api/me/credential-store${query}`); + }, connections: () => req<{ connections: ConnectionStatus[] }>('/api/me/connections'), connectConnection: (provider: ConnectionProvider, body: Record = {}) => req<{ connection: ConnectionStatus; authorizeUrl?: string }>( diff --git a/surface/web/src/api.ts b/surface/web/src/api.ts index 2be45bed3..3ff632d9a 100644 --- a/surface/web/src/api.ts +++ b/surface/web/src/api.ts @@ -17,6 +17,8 @@ export type { ConnectionIdentity, ConnectionProvider, ConnectionStatus, + CredentialStoreItem, + CredentialStoreStatus, NormalizedEntry, ProviderCredentialProvider, ProviderCredentialStatus, diff --git a/surface/web/src/components/SettingsSurface.test.tsx b/surface/web/src/components/SettingsSurface.test.tsx index 6a070846c..391539e4e 100644 --- a/surface/web/src/components/SettingsSurface.test.tsx +++ b/surface/web/src/components/SettingsSurface.test.tsx @@ -23,6 +23,19 @@ vi.mock('../notify', () => ({ toggleNotifications: vi.fn(async () => 'on'), })); +vi.mock('../api', () => ({ + api: { + credentialStore: vi.fn(async () => ({ + credentialStore: { + configured: false, + namespace: null, + workspaceId: null, + items: [], + }, + })), + }, +})); + function renderSettings() { render(); } @@ -59,10 +72,10 @@ describe('SettingsSurface sections', () => { it('pushes section URLs from the settings navigation', async () => { renderSettings(); - fireEvent.click(screen.getByRole('button', { name: 'Connections' })); + fireEvent.click(screen.getByRole('button', { name: 'Credential Store' })); - await waitFor(() => expect(window.location.pathname).toBe('/settings/connections')); - expect(activeSectionLabel()).toBe('Connections'); + await waitFor(() => expect(window.location.pathname).toBe('/settings/credential-store')); + expect(activeSectionLabel()).toBe('Credential Store'); }); it('updates the active section on back navigation', async () => { diff --git a/surface/web/src/components/SettingsSurface.tsx b/surface/web/src/components/SettingsSurface.tsx index d342e3b17..55ee3ba9a 100644 --- a/surface/web/src/components/SettingsSurface.tsx +++ b/surface/web/src/components/SettingsSurface.tsx @@ -8,7 +8,13 @@ import { type NotificationMessagePref, type ThemeMode, } from '@atrium/surface-client'; -import type { ConnectionStatus, ProviderCredentialStatus } from '../api'; +import { + api, + type ConnectionStatus, + type CredentialStoreItem, + type CredentialStoreStatus, + type ProviderCredentialStatus, +} from '../api'; import { notificationState, toggleNotifications, type NotifyState } from '../notify'; import { navigate, parseInAppRoute, useLocation } from '../router'; import { useTheme } from '../theme'; @@ -69,6 +75,7 @@ const SETTINGS_SECTIONS = [ { slug: 'notifications', label: 'Notifications' }, { slug: 'connections', label: 'Connections' }, { slug: 'agents', label: 'Agents' }, + { slug: 'credential-store', label: 'Credential Store' }, { slug: 'about', label: 'About' }, ] as const; @@ -147,6 +154,8 @@ function SettingsControls({ onConnectCodex?: () => void; }) { const location = useLocation(); + const [credentialStore, setCredentialStore] = useState(null); + const [credentialStoreError, setCredentialStoreError] = useState(null); const route = parseInAppRoute(location.pathname); const { prefs, setPrefs } = useTheme(); const requestedSection = route?.surface === 'settings' ? route.settingsSection : null; @@ -188,6 +197,24 @@ function SettingsControls({ sectionRefs.current[activeSection]?.scrollIntoView?.({ block: 'start' }); }, [activeSection, shouldScrollToSection]); + useEffect(() => { + let cancelled = false; + setCredentialStoreError(null); + void api + .credentialStore() + .then(({ credentialStore }) => { + if (!cancelled) setCredentialStore(credentialStore); + }) + .catch((err) => { + if (cancelled) return; + setCredentialStore(null); + setCredentialStoreError(err instanceof Error ? err.message : 'Could not load credential store'); + }); + return () => { + cancelled = true; + }; + }, []); + return (
@@ -437,6 +464,16 @@ function SettingsControls({ +
+ +
+ + +
Atrium is AGPL-3.0-or-later.{' '} @@ -471,6 +508,118 @@ function SettingsSectionDivider() { return
; } +function CredentialStorePanel({ store, error }: { store: CredentialStoreStatus | null; error: string | null }) { + if (error) { + return
{error}
; + } + if (!store) { + return ( +
Loading credential store...
+ ); + } + return ( +
+
+ + + +
+
+ {store.items.length === 0 ? ( +
No credentials found.
+ ) : ( + store.items.map((item) => ) + )} +
+
+ ); +} + +function CredentialStoreStat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function CredentialStoreRow({ item }: { item: CredentialStoreItem }) { + const refs = credentialStoreRefs(item); + return ( +
+
+ + + {item.label} + + {item.active ? ( + active + ) : null} + + {credentialBackingLabel(item.backingStore)} + +
+
+ + + + +
+ {refs.length > 0 ? ( +
+ {refs.map(([label, value]) => ( + + ))} +
+ ) : null} + {item.lastError ?
{item.lastError}
: null} +
+ ); +} + +function CredentialStoreMeta({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function credentialStoreRefs(item: CredentialStoreItem): Array<[string, string]> { + return [ + ['Principal', item.ironControl.principalForeignId], + ['Broker', item.ironControl.brokerCredentialId], + ['Secret', item.ironControl.staticSecretId], + ['Foreign ID', item.ironControl.staticSecretForeignId], + ].filter((entry): entry is [string, string] => Boolean(entry[1])); +} + +function credentialBackingLabel(backingStore: CredentialStoreItem['backingStore']): string { + switch (backingStore) { + case 'atrium_local': + return 'Atrium local'; + case 'iron_control': + return 'iron-control'; + case 'public_read': + return 'Public read'; + default: + return 'Unavailable'; + } +} + +function formatCredentialTime(value: string | null): string { + if (!value) return 'never'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + function SettingRow({ label, children }: { label: string; children: ReactNode }) { return (
From 20ed73c2bc43a363bc0ac23b40e89d4f0e629a2f Mon Sep 17 00:00:00 2001 From: Allan Niemerg <1568680+aniemerg@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:58:34 +0000 Subject: [PATCH 2/6] fix: stabilize credential store response typing Atrium-Session: 62eec149-4e7f-480b-9827-3485424d4007 Atrium-Harness: codex --- surface/server/src/routes/me.ts | 86 +++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 31 deletions(-) diff --git a/surface/server/src/routes/me.ts b/surface/server/src/routes/me.ts index 56512c441..a56039c74 100644 --- a/surface/server/src/routes/me.ts +++ b/surface/server/src/routes/me.ts @@ -55,6 +55,30 @@ export interface MeRouteDeps extends AppMutationContext { sessionRuns: Pick; } +type CredentialStoreItemJson = { + id: string; + kind: 'agent_provider' | 'connection_identity'; + provider: string; + label: string; + connected: boolean; + status: 'connected' | 'needs_auth' | 'public_read' | 'unavailable'; + workspaceId: string | null; + accountLabel: string | null; + tokenKind: string | null; + backingStore: 'atrium_local' | 'iron_control' | 'public_read' | 'unavailable'; + active: boolean; + ironControl: { + namespace: string | null; + principalForeignId: string | null; + brokerCredentialId: string | null; + staticSecretId: string | null; + staticSecretForeignId: string | null; + }; + lastValidatedAt: string | null; + lastError: string | null; + updatedAt: string | null; +}; + function isPlainObject(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)); } @@ -177,7 +201,7 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void ]); const backingByProvider = new Map(backingRows.rows.map((row) => [row.provider, row.token_ciphertext])); const principalForeignId = atriumPrincipalForeignId(workspaceId, user.id); - const providerItems = providers.map((provider) => { + const providerItems: CredentialStoreItemJson[] = providers.map((provider) => { const proxyBacked = backingByProvider.get(provider.provider) === PROXY_CREDENTIAL_SENTINEL; const codex = provider.provider === 'codex'; return { @@ -211,41 +235,41 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void updatedAt: provider.updatedAt, }; }); - const connectionItems = connectionList.flatMap((connection) => { + const connectionItems: CredentialStoreItemJson[] = []; + for (const connection of connectionList) { const base = connection.identities.length > 0 ? connection.identities : []; if (base.length === 0) { - return [ - { - id: `connection:${connection.provider}:public-read`, - kind: 'connection_identity', - provider: connection.provider, - label: 'GitHub public read', - connected: connection.connected, - status: connection.status, - workspaceId: connection.workspaceId, - accountLabel: connection.accountLabel, - tokenKind: connection.tokenKind, - backingStore: connection.status === 'public_read' ? 'public_read' : 'unavailable', - active: true, - ironControl: { - namespace: ironControl.namespace, - principalForeignId, - brokerCredentialId: null, - staticSecretId: null, - staticSecretForeignId: null, - }, - lastValidatedAt: connection.lastValidatedAt, - lastError: connection.lastError, - updatedAt: connection.updatedAt, + connectionItems.push({ + id: `connection:${connection.provider}:public-read`, + kind: 'connection_identity', + provider: connection.provider, + label: 'GitHub public read', + connected: connection.connected, + status: connection.status, + workspaceId: connection.workspaceId, + accountLabel: connection.accountLabel, + tokenKind: connection.tokenKind, + backingStore: connection.status === 'public_read' ? 'public_read' : 'unavailable', + active: true, + ironControl: { + namespace: ironControl.namespace, + principalForeignId, + brokerCredentialId: null, + staticSecretId: null, + staticSecretForeignId: null, }, - ]; + lastValidatedAt: connection.lastValidatedAt, + lastError: connection.lastError, + updatedAt: connection.updatedAt, + }); + continue; } - return base.map((identity) => { + for (const identity of base) { const metadata = identity.metadata ?? {}; const brokerCredentialId = metadataString(metadata, 'brokerCredentialId'); const staticSecretId = metadataString(metadata, 'staticSecretId'); const staticSecretForeignId = metadataString(metadata, 'staticSecretForeignId'); - return { + connectionItems.push({ id: `connection:${connection.provider}:${identity.id}`, kind: 'connection_identity', provider: connection.provider, @@ -267,9 +291,9 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void lastValidatedAt: identity.lastValidatedAt, lastError: identity.lastError, updatedAt: identity.updatedAt, - }; - }); - }); + }); + } + } return { credentialStore: { configured: ironControl.configured, From 84438bed88f4dead4848a961761a8618e1391215 Mon Sep 17 00:00:00 2001 From: Allan Niemerg <1568680+aniemerg@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:03:59 +0000 Subject: [PATCH 3/6] feat: add static header credentials Atrium-Session: 62eec149-4e7f-480b-9827-3485424d4007 Atrium-Harness: codex --- surface/server/src/iron-control.ts | 21 +++ surface/server/src/routes/me.ts | 166 +++++++++++++++-- surface/shared/src/api.ts | 16 +- surface/web/src/api.ts | 1 + .../src/components/SettingsSurface.test.tsx | 8 + .../web/src/components/SettingsSurface.tsx | 169 ++++++++++++++++-- 6 files changed, 346 insertions(+), 35 deletions(-) diff --git a/surface/server/src/iron-control.ts b/surface/server/src/iron-control.ts index 8045497b7..5dc7c5b3f 100644 --- a/surface/server/src/iron-control.ts +++ b/surface/server/src/iron-control.ts @@ -17,6 +17,17 @@ export interface IronControlSecret { id: string; namespace: string; foreign_id: string; + name?: string; + labels?: Record; + inject_config?: { header?: string; formatter?: string; query_param?: string }; + replace_config?: Record; + rules?: Array<{ + host?: string; + cidr?: string; + http_methods?: string[]; + paths?: string[]; + }>; + updated_at?: string; } export interface IronControlBrokerCredential { @@ -212,6 +223,16 @@ export class IronControlAdminClient { }); } + async listStaticSecrets( + args: { labels?: Record } = {}, + ): Promise { + const params = new URLSearchParams({ namespace: this.namespace }); + for (const [key, value] of Object.entries(args.labels ?? {})) { + params.set(`labels[${key}]`, String(value)); + } + return this.getList(`/api/v1/static_secrets?${params.toString()}`); + } + /** * Register the no-op secret that puts an egress host set on the proxy * allowlist (see `EGRESS_HOST_SETS`). Shape constraints, all load-bearing: diff --git a/surface/server/src/routes/me.ts b/surface/server/src/routes/me.ts index a56039c74..a0bad28fe 100644 --- a/surface/server/src/routes/me.ts +++ b/surface/server/src/routes/me.ts @@ -4,7 +4,7 @@ import { ImportLocalAgentProfileBodySchema, } from '@atrium/surface-client/agentProfiles'; import { normalizePrefs, normalizePrefsPatch } from '@atrium/surface-client/prefs'; -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { Schema } from 'effect'; import { githubConnectionAuditMetadata } from '../connection-audit.js'; @@ -57,7 +57,7 @@ export interface MeRouteDeps extends AppMutationContext { type CredentialStoreItemJson = { id: string; - kind: 'agent_provider' | 'connection_identity'; + kind: 'agent_provider' | 'connection_identity' | 'static_header'; provider: string; label: string; connected: boolean; @@ -65,6 +65,7 @@ type CredentialStoreItemJson = { workspaceId: string | null; accountLabel: string | null; tokenKind: string | null; + scope: string | null; backingStore: 'atrium_local' | 'iron_control' | 'public_read' | 'unavailable'; active: boolean; ironControl: { @@ -139,6 +140,15 @@ const CodexAuthJsonBodySchema = Schema.Struct({ authJson: Schema.optional(Schema.Unknown), }); +const StaticHeaderCredentialBodySchema = Schema.Struct({ + workspaceId: Schema.optional(Schema.Unknown), + name: Schema.optional(Schema.Unknown), + host: Schema.optional(Schema.Unknown), + header: Schema.optional(Schema.Unknown), + secret: Schema.optional(Schema.Unknown), + formatter: Schema.optional(Schema.Unknown), +}); + const AgentProfileParamsSchema = Schema.Struct({ id: Schema.String, }); @@ -178,29 +188,34 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void return { providers: await providerCredentials.list(user.id) }; }); - app.get('/api/me/credential-store', async (req, reply) => { - const user = requireUser(req, reply); - if (!user) return; - const query = decodeRouteQuery(WorkspaceQuerySchema, req.query); - const requestedWorkspaceId = typeof query.workspaceId === 'string' ? query.workspaceId : undefined; - const workspaceId = await connections.resolveWorkspaceId(user.id, requestedWorkspaceId); + const resolveCredentialStoreWorkspace = async ( + userId: string, + requestedWorkspaceId: string | undefined, + reply: FastifyReply, + ): Promise => { + const workspaceId = await connections.resolveWorkspaceId(userId, requestedWorkspaceId); if (!workspaceId) { const error = requestedWorkspaceId ? 'workspace_not_found' : 'no_workspace'; - return reply.code(requestedWorkspaceId ? 404 : 403).send({ error, message: 'workspace not found' }); + reply.code(requestedWorkspaceId ? 404 : 403).send({ error, message: 'workspace not found' }); + return null; } + return workspaceId; + }; + + const loadCredentialStore = async (userId: string, workspaceId: string) => { const [providers, connectionList, backingRows] = await Promise.all([ - providerCredentials.list(user.id), - connections.list(user.id, workspaceId), + providerCredentials.list(userId), + connections.list(userId, workspaceId), pool.query<{ provider: string; token_ciphertext: string }>( `SELECT provider, token_ciphertext FROM user_provider_credentials WHERE user_id = $1 AND provider = ANY($2::text[])`, - [user.id, ['claude-code', 'codex']], + [userId, ['claude-code', 'codex']], ), ]); const backingByProvider = new Map(backingRows.rows.map((row) => [row.provider, row.token_ciphertext])); - const principalForeignId = atriumPrincipalForeignId(workspaceId, user.id); + const principalForeignId = atriumPrincipalForeignId(workspaceId, userId); const providerItems: CredentialStoreItemJson[] = providers.map((provider) => { const proxyBacked = backingByProvider.get(provider.provider) === PROXY_CREDENTIAL_SENTINEL; const codex = provider.provider === 'codex'; @@ -214,20 +229,21 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void workspaceId, accountLabel: null, tokenKind: proxyBacked ? 'oauth_proxy' : provider.connected ? 'local_secret' : null, + scope: provider.provider === 'codex' ? 'chatgpt.com' : 'api.anthropic.com', backingStore: proxyBacked ? 'iron_control' : provider.connected ? 'atrium_local' : 'unavailable', active: provider.connected, ironControl: { namespace: ironControl.namespace, principalForeignId, - brokerCredentialId: proxyBacked && codex ? codexBrokerCredentialForeignId(workspaceId, user.id) : null, + brokerCredentialId: proxyBacked && codex ? codexBrokerCredentialForeignId(workspaceId, userId) : null, staticSecretId: null, staticSecretForeignId: proxyBacked ? codex ? [ - codexBearerSecretForeignId(workspaceId, user.id), - codexAccountIdSecretForeignId(workspaceId, user.id), + codexBearerSecretForeignId(workspaceId, userId), + codexAccountIdSecretForeignId(workspaceId, userId), ].join(', ') - : claudeStaticSecretForeignId(workspaceId, user.id) + : claudeStaticSecretForeignId(workspaceId, userId) : null, }, lastValidatedAt: provider.lastValidatedAt, @@ -249,6 +265,7 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void workspaceId: connection.workspaceId, accountLabel: connection.accountLabel, tokenKind: connection.tokenKind, + scope: 'github.com', backingStore: connection.status === 'public_read' ? 'public_read' : 'unavailable', active: true, ironControl: { @@ -279,6 +296,7 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void workspaceId: identity.workspaceId, accountLabel: identity.accountLabel, tokenKind: identity.tokenKind, + scope: 'github.com', backingStore: brokerCredentialId || staticSecretId || staticSecretForeignId ? 'iron_control' : 'unavailable', active: identity.active, ironControl: { @@ -294,14 +312,110 @@ export function registerMeRoutes(app: FastifyInstance, deps: MeRouteDeps): void }); } } + const customItems: CredentialStoreItemJson[] = ironControl.configured + ? ( + await ironControl.listStaticSecrets({ + labels: { + source: 'atrium', + kind: 'custom-header', + atrium_workspace_id: workspaceId, + atrium_user_id: userId, + }, + }) + ).map((secret): CredentialStoreItemJson => { + const host = secret.rules?.find((rule) => rule.host)?.host ?? null; + const header = secret.inject_config?.header ?? 'header'; + return { + id: `custom:${secret.id}`, + kind: 'static_header', + provider: 'custom', + label: secret.name ?? secret.foreign_id, + connected: true, + status: 'connected', + workspaceId, + accountLabel: null, + tokenKind: `static ${header}`, + scope: host, + backingStore: 'iron_control', + active: true, + ironControl: { + namespace: secret.namespace, + principalForeignId, + brokerCredentialId: null, + staticSecretId: secret.id, + staticSecretForeignId: secret.foreign_id, + }, + lastValidatedAt: null, + lastError: null, + updatedAt: secret.updated_at ?? null, + }; + }) + : []; return { credentialStore: { configured: ironControl.configured, namespace: ironControl.configured ? ironControl.namespace : null, workspaceId, - items: [...providerItems, ...connectionItems], + items: [...providerItems, ...connectionItems, ...customItems], }, }; + }; + + app.get('/api/me/credential-store', async (req, reply) => { + const user = requireUser(req, reply); + if (!user) return; + const query = decodeRouteQuery(WorkspaceQuerySchema, req.query); + const requestedWorkspaceId = typeof query.workspaceId === 'string' ? query.workspaceId : undefined; + const workspaceId = await resolveCredentialStoreWorkspace(user.id, requestedWorkspaceId, reply); + if (!workspaceId) return; + return loadCredentialStore(user.id, workspaceId); + }); + + app.post('/api/me/credential-store/static-header', async (req, reply) => { + const user = requireUser(req, reply); + if (!user) return; + if (!ironControl.configured) { + return reply.code(503).send({ error: 'iron_control_unconfigured', message: 'iron-control is not configured' }); + } + const body = decodeRouteBody(StaticHeaderCredentialBodySchema, req.body); + const requestedWorkspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : undefined; + const workspaceId = await resolveCredentialStoreWorkspace(user.id, requestedWorkspaceId, reply); + if (!workspaceId) return; + const name = stringOrNull(body.name); + const host = normalizeCredentialHost(body.host); + const header = normalizeCredentialHeader(body.header); + const secret = typeof body.secret === 'string' ? body.secret.trim() : ''; + const formatter = body.formatter === 'bearer' ? 'Bearer {{.Value}}' : undefined; + if (!name) return reply.code(400).send({ error: 'bad_request', message: 'Name is required' }); + if (!host) return reply.code(400).send({ error: 'bad_request', message: 'Host must be a bare hostname' }); + if (!header) return reply.code(400).send({ error: 'bad_request', message: 'Header name is invalid' }); + if (!secret) return reply.code(400).send({ error: 'bad_request', message: 'Secret value is required' }); + + const principalForeignId = atriumPrincipalForeignId(workspaceId, user.id); + const principal = await ironControl.upsertPrincipal({ + foreignId: principalForeignId, + name: `Atrium Workspace ${workspaceId} User ${user.id}`, + labels: { source: 'atrium', atrium_workspace_id: workspaceId, atrium_user_id: user.id }, + }); + const staticSecret = await ironControl.upsertInjectSecret({ + foreignId: `custom-header-${workspaceId}-${user.id}-${randomUUID()}`, + name, + header, + ...(formatter ? { formatter } : {}), + host, + source: { kind: 'control_plane', secret }, + labels: { + source: 'atrium', + kind: 'custom-header', + provider: 'custom', + atrium_workspace_id: workspaceId, + atrium_user_id: user.id, + host, + header, + }, + }); + await ironControl.createPrincipalStaticGrant(principal.id, staticSecret.id); + return reply.code(201).send(await loadCredentialStore(user.id, workspaceId)); }); app.get('/api/me/connections', async (req, reply) => { @@ -1102,6 +1216,22 @@ function metadataString(metadata: Record, key: string): string return typeof value === 'string' && value.trim() ? value.trim() : null; } +function normalizeCredentialHost(value: unknown): string | null { + if (typeof value !== 'string') return null; + const host = value.trim().toLowerCase(); + if (!host || host.includes('/') || host.includes(':') || host.includes('@')) return null; + if (!/^[a-z0-9.-]+$/.test(host)) return null; + if (!host.includes('.') || host.startsWith('.') || host.endsWith('.') || host.includes('..')) return null; + return host; +} + +function normalizeCredentialHeader(value: unknown): string | null { + if (typeof value !== 'string') return null; + const header = value.trim(); + if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(header)) return null; + return header; +} + function githubCredentialStoreLabel(tokenKind: string, accountLabel: string | null): string { const account = accountLabel ? ` ${accountLabel}` : ''; switch (tokenKind) { diff --git a/surface/shared/src/api.ts b/surface/shared/src/api.ts index 5c06c63f9..de0953e17 100644 --- a/surface/shared/src/api.ts +++ b/surface/shared/src/api.ts @@ -215,7 +215,7 @@ export interface ConnectionIdentity { updatedAt: string | null; } -export type CredentialStoreItemKind = 'agent_provider' | 'connection_identity'; +export type CredentialStoreItemKind = 'agent_provider' | 'connection_identity' | 'static_header'; export interface CredentialStoreItem { id: string; @@ -227,6 +227,7 @@ export interface CredentialStoreItem { workspaceId: string | null; accountLabel: string | null; tokenKind: string | null; + scope: string | null; backingStore: 'atrium_local' | 'iron_control' | 'public_read' | 'unavailable'; active: boolean; ironControl: { @@ -241,6 +242,14 @@ export interface CredentialStoreItem { updatedAt: string | null; } +export interface CreateStaticHeaderCredentialBody { + name: string; + host: string; + header: string; + secret: string; + formatter?: 'bearer' | undefined; +} + export interface CredentialStoreStatus { configured: boolean; namespace: string | null; @@ -494,6 +503,11 @@ export function createApi(opts: ApiOptions = {}) { const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''; return req<{ credentialStore: CredentialStoreStatus }>(`/api/me/credential-store${query}`); }, + createStaticHeaderCredential: (body: CreateStaticHeaderCredentialBody & { workspaceId?: string }) => + req<{ credentialStore: CredentialStoreStatus }>('/api/me/credential-store/static-header', { + method: 'POST', + body: JSON.stringify(body), + }), connections: () => req<{ connections: ConnectionStatus[] }>('/api/me/connections'), connectConnection: (provider: ConnectionProvider, body: Record = {}) => req<{ connection: ConnectionStatus; authorizeUrl?: string }>( diff --git a/surface/web/src/api.ts b/surface/web/src/api.ts index 3ff632d9a..a02cf868c 100644 --- a/surface/web/src/api.ts +++ b/surface/web/src/api.ts @@ -17,6 +17,7 @@ export type { ConnectionIdentity, ConnectionProvider, ConnectionStatus, + CreateStaticHeaderCredentialBody, CredentialStoreItem, CredentialStoreStatus, NormalizedEntry, diff --git a/surface/web/src/components/SettingsSurface.test.tsx b/surface/web/src/components/SettingsSurface.test.tsx index 391539e4e..94015fff2 100644 --- a/surface/web/src/components/SettingsSurface.test.tsx +++ b/surface/web/src/components/SettingsSurface.test.tsx @@ -25,6 +25,14 @@ vi.mock('../notify', () => ({ vi.mock('../api', () => ({ api: { + createStaticHeaderCredential: vi.fn(async () => ({ + credentialStore: { + configured: false, + namespace: null, + workspaceId: null, + items: [], + }, + })), credentialStore: vi.fn(async () => ({ credentialStore: { configured: false, diff --git a/surface/web/src/components/SettingsSurface.tsx b/surface/web/src/components/SettingsSurface.tsx index 55ee3ba9a..b04d053b1 100644 --- a/surface/web/src/components/SettingsSurface.tsx +++ b/surface/web/src/components/SettingsSurface.tsx @@ -11,6 +11,7 @@ import { import { api, type ConnectionStatus, + type CreateStaticHeaderCredentialBody, type CredentialStoreItem, type CredentialStoreStatus, type ProviderCredentialStatus, @@ -509,32 +510,154 @@ function SettingsSectionDivider() { } function CredentialStorePanel({ store, error }: { store: CredentialStoreStatus | null; error: string | null }) { + const [draft, setDraft] = useState({ + name: '', + host: '', + header: 'Authorization', + secret: '', + formatter: 'bearer', + }); + const [saving, setSaving] = useState(false); + const [panelStore, setPanelStore] = useState(store); + const [formError, setFormError] = useState(null); + useEffect(() => setPanelStore(store), [store]); + const visibleStore = panelStore ?? store; + const canSave = Boolean(draft.name.trim() && draft.host.trim() && draft.header.trim() && draft.secret.trim()); + const updateDraft = ( + key: K, + value: CreateStaticHeaderCredentialBody[K], + ) => setDraft((prev) => ({ ...prev, [key]: value })); + const createCredential = async () => { + if (!canSave || saving) return; + setSaving(true); + setFormError(null); + try { + const { credentialStore } = await api.createStaticHeaderCredential(draft); + setPanelStore(credentialStore); + setDraft({ name: '', host: '', header: 'Authorization', secret: '', formatter: 'bearer' }); + } catch (err) { + setFormError(err instanceof Error ? err.message : 'Could not create credential'); + } finally { + setSaving(false); + } + }; if (error) { return
{error}
; } - if (!store) { + if (!visibleStore) { return (
Loading credential store...
); } return (
+
+
+
+
Add static header credential
+
+ Store a secret in iron-control, inject it into one HTTP header, and grant it to you. +
+
+ Advanced +
+
+ updateDraft('name', value)} + /> + updateDraft('host', value)} + /> + updateDraft('header', value)} + /> + + +
+
+ + {!visibleStore.configured ? ( + iron-control is unavailable + ) : null} + {formError ? {formError} : null} +
+
- - - + + +
- {store.items.length === 0 ? ( + {visibleStore.items.length === 0 ? (
No credentials found.
) : ( - store.items.map((item) => ) + visibleStore.items.map((item) => ) )}
); } +function CredentialInput({ + label, + value, + placeholder, + onChange, +}: { + label: string; + value: string; + placeholder: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + function CredentialStoreStat({ label, value }: { label: string; value: string }) { return (
@@ -564,16 +687,21 @@ function CredentialStoreRow({ item }: { item: CredentialStoreItem }) {
- - + +
{refs.length > 0 ? ( -
- {refs.map(([label, value]) => ( - - ))} -
+
+ + Technical details + +
+ {refs.map(([label, value]) => ( + + ))} +
+
) : null} {item.lastError ?
{item.lastError}
: null}
@@ -591,11 +719,20 @@ function CredentialStoreMeta({ label, value, mono = false }: { label: string; va ); } +function credentialKindLabel(item: CredentialStoreItem): string { + if (item.kind === 'static_header') return item.tokenKind ?? 'Static header'; + if (item.kind === 'agent_provider') return item.backingStore === 'iron_control' ? 'Agent OAuth proxy' : 'Agent token'; + if (item.tokenKind === 'app_installation') return 'GitHub App installation'; + if (item.tokenKind === 'app_user') return 'GitHub user OAuth'; + if (item.tokenKind === 'pat') return 'GitHub PAT'; + return item.tokenKind ?? 'Connection'; +} + function credentialStoreRefs(item: CredentialStoreItem): Array<[string, string]> { return [ - ['Principal', item.ironControl.principalForeignId], - ['Broker', item.ironControl.brokerCredentialId], - ['Secret', item.ironControl.staticSecretId], + ['Actor ID', item.ironControl.principalForeignId], + ['Rotating credential ID', item.ironControl.brokerCredentialId], + ['Injected secret ID', item.ironControl.staticSecretId], ['Foreign ID', item.ironControl.staticSecretForeignId], ].filter((entry): entry is [string, string] => Boolean(entry[1])); } From 874c5437de5721ab0fee8af4cfbb5065ffac4847 Mon Sep 17 00:00:00 2001 From: Allan Niemerg <1568680+aniemerg@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:19:12 +0000 Subject: [PATCH 4/6] fix: format credential store panel Atrium-Session: 62eec149-4e7f-480b-9827-3485424d4007 Atrium-Harness: codex --- surface/web/src/components/SettingsSurface.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/surface/web/src/components/SettingsSurface.tsx b/surface/web/src/components/SettingsSurface.tsx index b04d053b1..8f2bf20a1 100644 --- a/surface/web/src/components/SettingsSurface.tsx +++ b/surface/web/src/components/SettingsSurface.tsx @@ -611,9 +611,7 @@ function CredentialStorePanel({ store, error }: { store: CredentialStoreStatus | > {saving ? 'Adding...' : 'Add credential'} - {!visibleStore.configured ? ( - iron-control is unavailable - ) : null} + {!visibleStore.configured ? iron-control is unavailable : null} {formError ? {formError} : null}
From 0b2c56deaa14be6c3a9f66e9b8e68c42026a7a9f Mon Sep 17 00:00:00 2001 From: Allan Niemerg <1568680+aniemerg@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:22:49 +0000 Subject: [PATCH 5/6] feat: move credential store to standalone surface Atrium-Session: 62eec149-4e7f-480b-9827-3485424d4007 Atrium-Harness: codex --- surface/web/src/Chat.tsx | 43 +++++++- .../src/components/SettingsSurface.test.tsx | 14 ++- .../web/src/components/SettingsSurface.tsx | 102 +++++++++++------- surface/web/src/components/Sidebar.tsx | 33 +++++- surface/web/src/components/icons.tsx | 20 ++++ surface/web/src/router.test.ts | 4 + surface/web/src/router.ts | 5 +- 7 files changed, 176 insertions(+), 45 deletions(-) diff --git a/surface/web/src/Chat.tsx b/surface/web/src/Chat.tsx index 826ff6e58..afa74edc8 100644 --- a/surface/web/src/Chat.tsx +++ b/surface/web/src/Chat.tsx @@ -42,13 +42,23 @@ import { Composer, type ComposerHandle } from './components/Composer'; import { GitHubConnectionDialog } from './components/GitHubConnectionDialog'; import { EntryQuoteApplyContextProvider } from './components/EntryQuoteCard'; import { Kbd, ShortcutsHelp, Tooltip } from './components/a11y'; -import { BotIcon, FileIcon, GearIcon, LockIcon, PhoneIcon, PlayIcon, PlusIcon, SearchIcon } from './components/icons'; +import { + BotIcon, + EyeIcon, + FileIcon, + GearIcon, + LockIcon, + PhoneIcon, + PlayIcon, + PlusIcon, + SearchIcon, +} from './components/icons'; import { Button } from './components/ui'; import { splitMarkdownFrontmatter } from '@atrium/surface-client'; import { MarkupPane, type MarkupPaneMode, type MarkupPaneSource } from './components/MarkupPane'; import { showErrorToast } from './components/Toasts'; import { QuickSwitcher, type QuickSwitcherCommand } from './components/QuickSwitcher'; -import { SettingsSurface } from './components/SettingsSurface'; +import { CredentialStoreSurface, SettingsSurface } from './components/SettingsSurface'; import { Sidebar } from './components/Sidebar'; // === spine additions === Thread strips can open the pane directly on a work tab. import type { SpineOpenSessionOptions } from './components/ThreadPanel'; @@ -1755,6 +1765,10 @@ export function Chat({ goToRoute({ surface: 'settings', channelId: null, sessionId: null, focusSession: false }); }, [goToRoute]); + const openCredentialsSurface = useCallback(() => { + goToRoute({ surface: 'credentials', channelId: null, sessionId: null, focusSession: false }); + }, [goToRoute]); + const openChatSurface = useCallback(() => { const channelId = stateRef.current.activeChannelId; goToRoute({ @@ -2150,6 +2164,15 @@ export function Chat({ icon: , run: openSettingsSurface, }, + { + id: 'open-credentials', + label: 'Open credentials', + subtitle: 'Manage injected secrets and credential routing', + group: 'Workspace', + keywords: ['credentials', 'credential store', 'secrets', 'iron-control', 'centaur'], + icon: , + run: openCredentialsSurface, + }, { id: 'create-channel', label: 'Create channel', @@ -2217,6 +2240,7 @@ export function Chat({ openActivitySurface, openChatSurface, openFilesSurface, + openCredentialsSurface, openSettingsSurface, onFocusAgent, providerCredentials, @@ -2247,6 +2271,7 @@ export function Chat({ // === mentions-activity additions === const showActivitySurface = mainSurface === 'activity'; const showSettingsSurface = mainSurface === 'settings'; + const showCredentialsSurface = mainSurface === 'credentials'; const showNonChatSurface = mainSurface !== 'chat'; const conversationOriginChannel = conversationSession ? (state.channels.find((channel) => channel.id === conversationSession.channelId) ?? null) @@ -2288,6 +2313,10 @@ export function Chat({ openSettingsSurface(); setIsSidebarOpen(false); }, [openSettingsSurface]); + const openCredentialsFromSidebar = useCallback(() => { + openCredentialsSurface(); + setIsSidebarOpen(false); + }, [openCredentialsSurface]); const shell = (
@@ -2309,6 +2338,7 @@ export function Chat({ onOpenFiles={openFilesFromSidebar} // === mentions-activity additions === onOpenActivity={openActivityFromSidebar} + onOpenCredentials={openCredentialsFromSidebar} activityCounts={activityCounts} onOpenSettings={openSettingsFromSidebar} onLogout={onLogout} @@ -2343,6 +2373,8 @@ export function Chat({ aria-label={ showSettingsSurface ? 'Settings' + : showCredentialsSurface + ? 'Credential Store' : showActivitySurface ? 'Inbox' : showFilesSurface @@ -2355,6 +2387,11 @@ export function Chat({ Settings + ) : showCredentialsSurface ? ( + <> + + Credential Store + ) : showActivitySurface ? ( // === mentions-activity additions === <> @@ -2572,6 +2609,8 @@ export function Chat({ onConnectClaude={() => setProviderDialog('claude-code')} onConnectCodex={() => setProviderDialog('codex')} /> + ) : showCredentialsSurface ? ( + ) : showActivitySurface ? ( // === inbox additions === { it('pushes section URLs from the settings navigation', async () => { renderSettings(); - fireEvent.click(screen.getByRole('button', { name: 'Credential Store' })); + fireEvent.click(screen.getByRole('button', { name: 'Connections' })); + + await waitFor(() => expect(window.location.pathname).toBe('/settings/connections')); + expect(activeSectionLabel()).toBe('Connections'); + }); + + it('opens the advanced credential store as a separate page', async () => { + renderSettings(); + + fireEvent.click(screen.getByRole('button', { name: 'Open advanced panel' })); - await waitFor(() => expect(window.location.pathname).toBe('/settings/credential-store')); - expect(activeSectionLabel()).toBe('Credential Store'); + await waitFor(() => expect(window.location.pathname).toBe('/credentials')); }); it('updates the active section on back navigation', async () => { diff --git a/surface/web/src/components/SettingsSurface.tsx b/surface/web/src/components/SettingsSurface.tsx index 8f2bf20a1..0778f104b 100644 --- a/surface/web/src/components/SettingsSurface.tsx +++ b/surface/web/src/components/SettingsSurface.tsx @@ -20,7 +20,7 @@ import { notificationState, toggleNotifications, type NotifyState } from '../not import { navigate, parseInAppRoute, useLocation } from '../router'; import { useTheme } from '../theme'; import { Tooltip } from './a11y'; -import { BellIcon, BellOffIcon } from './icons'; +import { BellIcon, BellOffIcon, EyeIcon, EyeOffIcon } from './icons'; const SOURCE_URL = 'https://github.com/useatrium/atrium'; const LICENSE_URL = `${SOURCE_URL}/blob/master/LICENSE`; @@ -76,7 +76,6 @@ const SETTINGS_SECTIONS = [ { slug: 'notifications', label: 'Notifications' }, { slug: 'connections', label: 'Connections' }, { slug: 'agents', label: 'Agents' }, - { slug: 'credential-store', label: 'Credential Store' }, { slug: 'about', label: 'About' }, ] as const; @@ -155,8 +154,6 @@ function SettingsControls({ onConnectCodex?: () => void; }) { const location = useLocation(); - const [credentialStore, setCredentialStore] = useState(null); - const [credentialStoreError, setCredentialStoreError] = useState(null); const route = parseInAppRoute(location.pathname); const { prefs, setPrefs } = useTheme(); const requestedSection = route?.surface === 'settings' ? route.settingsSection : null; @@ -198,24 +195,6 @@ function SettingsControls({ sectionRefs.current[activeSection]?.scrollIntoView?.({ block: 'start' }); }, [activeSection, shouldScrollToSection]); - useEffect(() => { - let cancelled = false; - setCredentialStoreError(null); - void api - .credentialStore() - .then(({ credentialStore }) => { - if (!cancelled) setCredentialStore(credentialStore); - }) - .catch((err) => { - if (cancelled) return; - setCredentialStore(null); - setCredentialStoreError(err instanceof Error ? err.message : 'Could not load credential store'); - }); - return () => { - cancelled = true; - }; - }, []); - return (
@@ -461,16 +440,13 @@ function SettingsControls({ {codexStatus?.connected ? 'Connected' : 'Connect'} -
- - -
- + + +
@@ -505,6 +481,42 @@ function SettingsControls({ ); } +export function CredentialStoreSurface() { + const [credentialStore, setCredentialStore] = useState(null); + const [credentialStoreError, setCredentialStoreError] = useState(null); + + useEffect(() => { + let cancelled = false; + setCredentialStoreError(null); + void api + .credentialStore() + .then(({ credentialStore }) => { + if (!cancelled) setCredentialStore(credentialStore); + }) + .catch((err) => { + if (cancelled) return; + setCredentialStore(null); + setCredentialStoreError(err instanceof Error ? err.message : 'Could not load credential store'); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

Credential Store

+
+
+
+ +
+
+
+ ); +} + function SettingsSectionDivider() { return
; } @@ -518,6 +530,7 @@ function CredentialStorePanel({ store, error }: { store: CredentialStoreStatus | formatter: 'bearer', }); const [saving, setSaving] = useState(false); + const [showSecret, setShowSecret] = useState(false); const [panelStore, setPanelStore] = useState(store); const [formError, setFormError] = useState(null); useEffect(() => setPanelStore(store), [store]); @@ -593,13 +606,26 @@ function CredentialStorePanel({ store, error }: { store: CredentialStoreStatus |
diff --git a/surface/web/src/components/Sidebar.tsx b/surface/web/src/components/Sidebar.tsx index 546d52b9b..e728864a4 100644 --- a/surface/web/src/components/Sidebar.tsx +++ b/surface/web/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ import { ArchiveRestoreIcon, BellIcon, BellOffIcon, + EyeIcon, FileIcon, GearIcon, LockIcon, @@ -84,6 +85,7 @@ function SidebarImpl({ onOpenFiles, // === mentions-activity additions === onOpenActivity, + onOpenCredentials, activityCounts, onOpenSettings, onLogout, @@ -107,10 +109,11 @@ function SidebarImpl({ onSetPinned?: (channelId: string, pinned: boolean) => void; onCreateChannel: (name: string, isPrivate?: boolean) => Promise; onStartDm: (userIds: string[]) => void; - activeSurface?: 'chat' | 'files' | 'activity' | 'settings'; + activeSurface?: 'chat' | 'files' | 'activity' | 'settings' | 'credentials'; onOpenFiles?: () => void; // === mentions-activity additions === onOpenActivity?: () => void; + onOpenCredentials?: () => void; activityCounts?: ActivityCounts; onOpenSettings?: () => void; onLogout: () => void; @@ -559,6 +562,21 @@ function SidebarImpl({ + + +
@@ -617,6 +635,19 @@ function SidebarImpl({ Inbox {inboxBadge()} +
diff --git a/surface/web/src/components/icons.tsx b/surface/web/src/components/icons.tsx index 4af1baca4..c07a8a409 100644 --- a/surface/web/src/components/icons.tsx +++ b/surface/web/src/components/icons.tsx @@ -75,6 +75,26 @@ export function LockIcon(props: IconProps) { ); } +export function EyeIcon(props: IconProps) { + return ( + + + + + ); +} + +export function EyeOffIcon(props: IconProps) { + return ( + + + + + + + ); +} + export function BellIcon(props: IconProps) { return ( diff --git a/surface/web/src/router.test.ts b/surface/web/src/router.test.ts index 403e496c4..cfbb23c29 100644 --- a/surface/web/src/router.test.ts +++ b/surface/web/src/router.test.ts @@ -44,6 +44,7 @@ describe('router', () => { }); expect(parseInAppRoute('/files')).toMatchObject({ surface: 'files' }); expect(parseInAppRoute('/activity')).toMatchObject({ surface: 'activity' }); + expect(parseInAppRoute('/credentials')).toMatchObject({ surface: 'credentials' }); expect(parseInAppRoute('/agents')).toBeNull(); expect(parseInAppRoute('/settings')).toMatchObject({ surface: 'settings', settingsSection: null }); expect(parseInAppRoute('/settings/connections')).toMatchObject({ @@ -83,6 +84,9 @@ describe('router', () => { ).toBe('/c/ch_1'); expect(routePath({ surface: 'files', channelId: null, sessionId: null, focusSession: false })).toBe('/files'); expect(routePath({ surface: 'activity', channelId: null, sessionId: null, focusSession: false })).toBe('/activity'); + expect(routePath({ surface: 'credentials', channelId: null, sessionId: null, focusSession: false })).toBe( + '/credentials', + ); expect(routePath({ surface: 'settings', channelId: null, sessionId: null, focusSession: false })).toBe('/settings'); expect( routePath({ diff --git a/surface/web/src/router.ts b/surface/web/src/router.ts index c8d6dc02b..621aeb920 100644 --- a/surface/web/src/router.ts +++ b/surface/web/src/router.ts @@ -1,7 +1,7 @@ import { useSyncExternalStore } from 'react'; import { agentPathFromLocationPath, type AgentPathRef } from '@atrium/surface-client/agent-paths'; -export type MainSurface = 'chat' | 'files' | 'activity' | 'settings'; +export type MainSurface = 'chat' | 'files' | 'activity' | 'settings' | 'credentials'; // URL grammar. Paths are places; query params are view modifiers layered on a // place. Every navigational view state must be expressible here so it survives @@ -14,6 +14,7 @@ export type MainSurface = 'chat' | 'files' | 'activity' | 'settings'; // /c/:channelId/members channel + members view // /s/:sessionId legacy session permalink (canonicalizes) // /files /activity surfaces +// /credentials advanced credential store // /settings[/:section] settings, optionally scrolled to a section // // Query params (URL_PARAMS): `agent` (session panel over the current channel), @@ -128,6 +129,7 @@ export function parseInAppRoute(pathname: string): InAppRoute | null { if (pathname === '/') return DEFAULT_ROUTE; if (pathname === '/files') return { ...DEFAULT_ROUTE, surface: 'files' }; if (pathname === '/activity') return { ...DEFAULT_ROUTE, surface: 'activity' }; + if (pathname === '/credentials') return { ...DEFAULT_ROUTE, surface: 'credentials' }; if (pathname === '/settings') return { ...DEFAULT_ROUTE, surface: 'settings' }; const settingsSection = /^\/settings\/([^/]+)$/.exec(pathname); @@ -185,6 +187,7 @@ export function filePathRefFromPath(pathname: string): Exclude Date: Mon, 20 Jul 2026 18:34:55 +0000 Subject: [PATCH 6/6] fix: format credentials surface header Atrium-Session: 62eec149-4e7f-480b-9827-3485424d4007 Atrium-Harness: codex --- surface/web/src/Chat.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/surface/web/src/Chat.tsx b/surface/web/src/Chat.tsx index afa74edc8..8fa3ded5c 100644 --- a/surface/web/src/Chat.tsx +++ b/surface/web/src/Chat.tsx @@ -2375,11 +2375,11 @@ export function Chat({ ? 'Settings' : showCredentialsSurface ? 'Credential Store' - : showActivitySurface - ? 'Inbox' - : showFilesSurface - ? `Files for ${active ? channelLabel(active, me.id) : workspace.name}` - : undefined + : showActivitySurface + ? 'Inbox' + : showFilesSurface + ? `Files for ${active ? channelLabel(active, me.id) : workspace.name}` + : undefined } > {showSettingsSurface ? (