diff --git a/src/databases/officer_db/src/anthropic-accounts/index.ts b/src/databases/officer_db/src/anthropic-accounts/index.ts new file mode 100644 index 00000000..eb271635 --- /dev/null +++ b/src/databases/officer_db/src/anthropic-accounts/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './queries'; diff --git a/src/databases/officer_db/src/anthropic-accounts/queries.ts b/src/databases/officer_db/src/anthropic-accounts/queries.ts new file mode 100644 index 00000000..4e7222ab --- /dev/null +++ b/src/databases/officer_db/src/anthropic-accounts/queries.ts @@ -0,0 +1,147 @@ +import { and, desc, eq, lt } from 'drizzle-orm'; +import { db } from '../db'; +import { decryptSecret, encryptSecret } from '../crypto'; +import { anthropicAccounts } from './schema'; + +/* + A stored Anthropic login, and the two shapes it comes in. + + `StoredAccount` is what an API may return: who it is, how stale, whether it can still be used. It carries + no secret. `revealAccount` is the only way to the credential itself and exists for exactly one caller — + the switch, which writes it back to disk. +*/ + +/** The encryption purpose for one account. Per-account by design; see the schema note. */ +const purposeFor = (accountUuid: string) => `anthropic-account:${accountUuid}`; + +export type StoredAccount = { + accountUuid: string; + emailAccount: string; + orgName: string | null; + plan: string | null; + capturedAt: string; + lastUsedAt: string | null; + refreshExpiresAt: string | null; + /** False once the refresh token is past its expiry: switching would fail, so the UI must offer /login. */ + usable: boolean; +}; + +type SaveParams = { + userId: number; + accountUuid: string; + emailAccount: string; + orgName: string | null; + plan: string | null; + /** The `claudeAiOauth` object, verbatim, as JSON. */ + credentialsJson: string; + /** The `oauthAccount` object, verbatim, as JSON. */ + oauthAccountJson: string; + refreshExpiresAt: Date | null; +}; + +const publish = (row: typeof anthropicAccounts.$inferSelect): StoredAccount => ({ + accountUuid: row.accountUuid, + emailAccount: row.emailAccount, + orgName: row.orgName, + plan: row.plan, + capturedAt: row.capturedAt.toISOString(), + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + refreshExpiresAt: row.refreshExpiresAt?.toISOString() ?? null, + usable: !row.refreshExpiresAt || row.refreshExpiresAt.getTime() > Date.now(), +}); + +/** + * Record (or re-record) one account's credential. + * + * Upsert on `(userId, accountUuid)` rather than insert, because capturing happens every time the current + * account is noticed to have changed — so the common case is refreshing a row that already exists with a + * newer token. `capturedAt` moves; `lastUsedAt` deliberately does not, since capturing is not using. + */ +export async function saveAnthropicAccount(p: SaveParams): Promise { + const purpose = purposeFor(p.accountUuid); + const values = { + userId: p.userId, + accountUuid: p.accountUuid, + emailAccount: p.emailAccount, + orgName: p.orgName, + plan: p.plan, + credentials: encryptSecret(purpose, p.credentialsJson), + oauthAccount: encryptSecret(purpose, p.oauthAccountJson), + refreshExpiresAt: p.refreshExpiresAt, + capturedAt: new Date(), + }; + await db + .insert(anthropicAccounts) + .values(values) + .onConflictDoUpdate({ + target: [anthropicAccounts.userId, anthropicAccounts.accountUuid], + set: { + emailAccount: values.emailAccount, + orgName: values.orgName, + plan: values.plan, + credentials: values.credentials, + oauthAccount: values.oauthAccount, + refreshExpiresAt: values.refreshExpiresAt, + capturedAt: values.capturedAt, + }, + }); +} + +/** Every account this Officer user can switch to, most recently used first. Carries no secret. */ +export async function listAnthropicAccounts(userId: number): Promise { + const rows = await db + .select() + .from(anthropicAccounts) + .where(eq(anthropicAccounts.userId, userId)) + .orderBy(desc(anthropicAccounts.lastUsedAt), desc(anthropicAccounts.capturedAt)); + return rows.map(publish); +} + +/** + * The credential itself, for the switch. Decrypts, so every call is a secret leaving the database. + * + * Returns null for an unknown account AND for one whose refresh token has expired: a caller that wrote an + * expired credential to disk would have signed the owner out with no message, which is the worst of the + * available failures. + */ +export async function revealAnthropicAccount( + userId: number, + accountUuid: string, +): Promise<{ credentialsJson: string; oauthAccountJson: string; emailAccount: string } | null> { + const [row] = await db + .select() + .from(anthropicAccounts) + .where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid))) + .limit(1); + if (!row) return null; + if (row.refreshExpiresAt && row.refreshExpiresAt.getTime() <= Date.now()) return null; + const purpose = purposeFor(row.accountUuid); + return { + credentialsJson: decryptSecret(purpose, row.credentials), + oauthAccountJson: decryptSecret(purpose, row.oauthAccount), + emailAccount: row.emailAccount, + }; +} + +/** Marks the switch. Separate from reveal so a failed write does not claim the account is in use. */ +export async function markAnthropicAccountUsed(userId: number, accountUuid: string): Promise { + await db + .update(anthropicAccounts) + .set({ lastUsedAt: new Date() }) + .where(and(eq(anthropicAccounts.userId, userId), eq(anthropicAccounts.accountUuid, accountUuid))); +} + +/** + * Drop credentials that can no longer be used. + * + * A dead refresh token is not a credential, it is a liability with a thirty-day head start already spent — + * so it goes rather than sitting in the table for ever. Called opportunistically on list, not scheduled: a + * cron for one DELETE is a moving part that can silently stop. + */ +export async function purgeExpiredAnthropicAccounts(userId: number): Promise { + const gone = await db + .delete(anthropicAccounts) + .where(and(eq(anthropicAccounts.userId, userId), lt(anthropicAccounts.refreshExpiresAt, new Date()))) + .returning({ id: anthropicAccounts.id }); + return gone.length; +} diff --git a/src/databases/officer_db/src/anthropic-accounts/schema.ts b/src/databases/officer_db/src/anthropic-accounts/schema.ts new file mode 100644 index 00000000..7873cc20 --- /dev/null +++ b/src/databases/officer_db/src/anthropic-accounts/schema.ts @@ -0,0 +1,72 @@ +import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { users } from '../auth/schema'; + +/** + * The OAuth credential for each Anthropic account the owner signs into, so they can be switched between + * without a browser round trip. + * + * ── Why keep them at all ── + * + * The owner has a personal account and a company one and moves between them during a day — by what they are + * working on, and sometimes because one has hit a limit and the work has to continue. `claude auth login` + * handles the first switch and every switch after it, at the cost of a browser round trip each time, and it + * cannot switch BACK to an account whose credential the CLI has already overwritten. + * + * `ai_provider_usage` already answers "which of my accounts has room". This answers the question that + * immediately follows it: "then use that one". + * + * ── One key per account, not one key for the table ── + * + * `credentials` and `oauthAccount` are encrypted with `encryptSecret('anthropic-account:', …)`, so the + * secret store mints a separate key per Anthropic account. One leaked key is one account rather than all of + * them, which is the same reasoning that split `VAULT_STORE_KEY` into purposes in the first place. The uuid + * comes from Anthropic (`oauthAccount.accountUuid`) so it is stable across a re-login, where the email is + * also stable but is user-editable at the provider. + * + * ── What is stored, and what that is worth to somebody who steals it ── + * + * The whole `claudeAiOauth` object, which includes a REFRESH token — deliberately, because the access token + * lives hours and the refresh token is what makes a switch work a week later. So a row plus the key is + * thirty days of acting as that account. That is a real escalation over holding one credential on disk, it + * was accepted knowingly, and two things reduce it: the per-account key above, and `refreshExpiresAt` being + * a column so a dead credential can be purged rather than sitting here for ever. + * + * ── Owner only, and per Officer user regardless ── + * + * Only the owner's `~/.claude` is switched today; a member's `claude` reads their own home and is untouched. + * The rows are keyed by Officer user anyway, because the same mechanism per home is the obvious extension + * and a table that assumed one user would have to be migrated to get there. + */ +export const anthropicAccounts = pgTable( + 'anthropic_accounts', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** Anthropic's own id for the account. The encryption purpose is derived from this. */ + accountUuid: text('account_uuid').notNull(), + /** What the pill reads, and what `ai_provider_usage.email_account` joins on in practice. */ + emailAccount: text('email_account').notNull(), + /** `Pertento`, or null on a personal account with no organisation. Display only. */ + orgName: text('org_name'), + /** `Team`, `Max 20x`, `Pro`. Display only. */ + plan: text('plan'), + /** `claudeAiOauth` from `.credentials.json`, encrypted. Never logged, never returned by an API. */ + credentials: text('credentials').notNull(), + /** `oauthAccount` from `.claude.json`, encrypted. Identity rather than credential, but it travels with it. */ + oauthAccount: text('oauth_account').notNull(), + /** + * When the refresh token dies. Past this, a switch cannot succeed and the UI must say so instead of + * failing at spawn time — which would look like the agent being broken rather than a sign-in expiring. + */ + refreshExpiresAt: timestamp('refresh_expires_at', { withTimezone: true }), + /** When this credential was captured — how stale the thing you are switching to is. */ + capturedAt: timestamp('captured_at', { withTimezone: true }).notNull().defaultNow(), + /** Last time it was switched TO. Orders the pills by what the owner actually uses. */ + lastUsedAt: timestamp('last_used_at', { withTimezone: true }), + }, + // uniqueIndex, never unique().on() — drizzle-kit mis-diffs named composite unique CONSTRAINTS and + // re-creates them on every push. See src/databases/CLAUDE.md → "Composite keys". + (table) => [uniqueIndex('uq_anthropic_accounts_account').on(table.userId, table.accountUuid)], +); diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 06e93ac9..7ec369a4 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -37,6 +37,7 @@ export * from './chat-session-models'; export * from './chat-notification-prefs'; export * from './nudges'; export * from './ai-provider-usage'; +export * from './anthropic-accounts'; export * from './command-hq'; export * from './dashboards'; export * from './integrations'; diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index de9e9f76..53de81a8 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -34,6 +34,7 @@ export * from './chat-events/schema'; // chat_session_events export * from './chat-session-models/schema'; // chat_session_models export * from './chat-notification-prefs/schema'; // chat_notification_prefs export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account +export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags export * from './agent-panels/schema'; // agent_panels diff --git a/src/servers/api/auth/claude-login.ts b/src/servers/api/auth/claude-login.ts index a13e02f0..af9aeb74 100644 --- a/src/servers/api/auth/claude-login.ts +++ b/src/servers/api/auth/claude-login.ts @@ -1,7 +1,16 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process'; import { spawn } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + listAnthropicAccounts, + markAnthropicAccountUsed, + purgeExpiredAnthropicAccounts, + revealAnthropicAccount, + saveAnthropicAccount, +} from 'officerdb'; import { createRouter } from '../../create-router'; -import { getHomeDir } from '../../data-path'; +import { getHomeDir, getOwnerHomeDir } from '../../data-path'; import { claudeBinIn } from '../../os-user-claude'; import { runAsArgv } from '../../os-user'; @@ -208,7 +217,15 @@ claudeLoginRouter.post('/:id/code', async (ctx) => { if (exit === 'timeout') return ctx.json({ ok: false, error: 'the CLI did not finish in 45s', detail: tail }, 504); if (exit !== 0) return ctx.json({ ok: false, error: `claude auth login exited ${exit}`, detail: tail }, 400); - return ctx.json({ ok: true, detail: tail }); + + // Record what was just signed into, so it can be switched BACK to later without another round trip. Only + // for the owner: a member's credential is in their own home and this module only ever reads the owner's. + if (user.id === 1) await captureCurrentAnthropicAccount(user.id); + const now = user.id === 1 ? await readCurrentAccount() : null; + + // The account is the answer the person opened this for. Two accounts on one machine means "signed in" on + // its own is not enough to act on. + return ctx.json({ ok: true, detail: tail, email: now?.emailAccount ?? null, orgName: now?.orgName ?? null }); }); // DELETE /auth/claude-login/:id — they closed the dialog. @@ -219,3 +236,149 @@ claudeLoginRouter.delete('/:id', (ctx) => { if (p && p.userId === user.id) abandon(id); return ctx.json({ ok: true }); }); + +/* ──────────────────────────────────────────────────────────────────────────── + Switching between accounts that have already been signed into. + + The owner keeps a personal account and a company one and moves between them during a day. `/login` above + handles arriving at an account; this handles going back to one, without a browser round trip, using the + credential kept from last time. + + Owner only, and structurally: `getOwnerHomeDir` ignores the email it is passed, so every path below is + the owner's whatever the caller claims. A member's `claude` reads their own home and is untouched by any + of this — the isolation is the same as it was, and this feature does not widen it. + ──────────────────────────────────────────────────────────────────────────── */ + +/** `.credentials.json` holds one key. `.claude.json` holds a whole config and must be PATCHED, not written. */ +const credentialsPath = () => join(getOwnerHomeDir(''), '.claude', '.credentials.json'); +const configPath = () => join(getOwnerHomeDir(''), '.claude.json'); + +async function readJson(path: string): Promise | null> { + try { + return JSON.parse(await readFile(path, 'utf-8')) as Record; + } catch { + return null; + } +} + +/** + * What the CLI is signed in as right now, read from its own two files. + * + * Both are needed and neither is sufficient: the credential lives in `.claude/.credentials.json` and the + * identity — email, org, account uuid — lives in `.claude.json` beside it. + */ +async function readCurrentAccount(): Promise<{ + accountUuid: string; + emailAccount: string; + orgName: string | null; + plan: string | null; + credentialsJson: string; + oauthAccountJson: string; + refreshExpiresAt: Date | null; +} | null> { + const creds = await readJson(credentialsPath()); + const config = await readJson(configPath()); + const oauth = creds?.claudeAiOauth as Record | undefined; + const account = config?.oauthAccount as Record | undefined; + if (!oauth || !account) return null; + + const accountUuid = typeof account.accountUuid === 'string' ? account.accountUuid : ''; + const emailAccount = typeof account.emailAddress === 'string' ? account.emailAddress : ''; + if (!accountUuid || !emailAccount) return null; + + const sub = typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : ''; + const refreshMs = typeof oauth.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null; + return { + accountUuid, + emailAccount, + orgName: typeof account.organizationName === 'string' ? account.organizationName : null, + plan: sub ? sub[0]!.toUpperCase() + sub.slice(1) : null, + credentialsJson: JSON.stringify(oauth), + oauthAccountJson: JSON.stringify(account), + refreshExpiresAt: refreshMs ? new Date(refreshMs) : null, + }; +} + +/** + * Keep whatever the CLI is currently signed in as. + * + * Called after a `/login` here AND whenever the account list is read, which is what makes an account signed + * into from a TERMINAL switchable too. Without that second call this would only know about logins that + * happened to go through this dialog — a switcher that silently omits half your accounts. + * + * Never throws into a caller: failing to record a credential must not fail the request that noticed it. + */ +export async function captureCurrentAnthropicAccount(userId: number): Promise { + try { + const now = await readCurrentAccount(); + if (!now) return; + await saveAnthropicAccount({ userId, ...now }); + } catch (err) { + console.warn('[claude-auth] could not record the current account:', err instanceof Error ? err.message : err); + } +} + +// GET /claude-auth/accounts — who can be switched to, and who is current. No secrets. +claudeLoginRouter.get('/accounts', async (ctx) => { + const user = ctx.get('user'); + if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403); + + // Record before listing, so an account signed into elsewhere appears the first time the panel is opened + // rather than only after the next login through this dialog. + await captureCurrentAnthropicAccount(user.id); + await purgeExpiredAnthropicAccounts(user.id).catch(() => 0); + + const current = await readCurrentAccount(); + const accounts = (await listAnthropicAccounts(user.id)).map((a) => ({ + ...a, + current: a.accountUuid === current?.accountUuid, + })); + return ctx.json({ accounts }); +}); + +// POST /claude-auth/switch — become one of them. +claudeLoginRouter.post('/switch', async (ctx) => { + const user = ctx.get('user'); + if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403); + + const body = await ctx.req.json().catch(() => ({}) as Record); + const accountUuid = typeof body.accountUuid === 'string' ? body.accountUuid : ''; + if (!accountUuid) return ctx.json({ error: 'no account' }, 400); + + // Keep the outgoing credential before overwriting it, or switching away from an account signed into + // outside this UI loses it — the one-way trip this feature exists to remove. + await captureCurrentAnthropicAccount(user.id); + + const target = await revealAnthropicAccount(user.id, accountUuid); + if (!target) return ctx.json({ error: 'that sign-in has expired — run /login for it again' }, 409); + + try { + // `.credentials.json` is replaced wholesale: it holds this one key and nothing else of ours. + // `.claude.json` is PATCHED — it is the CLI's whole config, and overwriting it would take out every + // project entry, tip counter and preference in it. + const existingCreds = (await readJson(credentialsPath())) ?? {}; + await writeFile( + credentialsPath(), + JSON.stringify({ ...existingCreds, claudeAiOauth: JSON.parse(target.credentialsJson) }), + { + mode: 0o600, + }, + ); + + const config = (await readJson(configPath())) ?? {}; + await writeFile( + configPath(), + JSON.stringify({ ...config, oauthAccount: JSON.parse(target.oauthAccountJson) }, null, 2), + { + mode: 0o600, + }, + ); + } catch (err) { + return ctx.json({ error: `could not write the credential: ${err instanceof Error ? err.message : err}` }, 500); + } + + await markAnthropicAccountUsed(user.id, accountUuid); + // A turn already running keeps the credential it was spawned with. Said here rather than guessed at in + // the UI, because "it did not take effect" and "it takes effect next turn" look identical from outside. + return ctx.json({ ok: true, email: target.emailAccount, appliesTo: 'the next turn, and every new claude' }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx b/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx index 941da781..c9714afc 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/UsagePopover.tsx @@ -1,8 +1,11 @@ import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; import { usePermissions } from 'hooks/usePermissions'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { UsageAccount } from '../useClaudeUsage'; import { currentLimit, formatAge, formatReset, formatTokens, useClaudeUsage, weekdayLabel } from '../useClaudeUsage'; +import type { SwitchableAccount } from '../useAnthropicAccounts'; +import { useAnthropicAccounts } from '../useAnthropicAccounts'; /** * Claude Code's own usage display, on the harness pill — for every account, not just the one signed in. @@ -129,38 +132,60 @@ const Models = ({ account }: { account: UsageAccount }) => { }; /** - * One chip per account, and only when there is more than one. + * One chip per Anthropic account, and clicking one BECOMES it. * - * A tab strip over a single account is chrome that never does anything — the same reasoning that collapses - * the provider strip to a label when there is one harness on offer. + * This used to select which account's stored numbers you were reading, which turned out to be the less + * useful half of the same gesture: having established that the other account has room, what you want is to + * use it. So the chip is now an action, and the panel below it always shows the account you are actually + * signed into. + * + * The spinner is not decoration. A switch rewrites a credential on disk and changes nothing you can see — + * without an indicator the honest reading of a click is "nothing happened", and the second click is somebody + * checking. */ -const AccountTabs = ({ +const AccountSwitcher = ({ accounts, - selected, - onSelect, + isSwitching, + switchingTo, + onSwitch, }: { - accounts: UsageAccount[]; - selected: string; - onSelect: (email: string) => void; + accounts: SwitchableAccount[]; + isSwitching: boolean; + switchingTo: string | null; + onSwitch: (accountUuid: string) => void; }) => (
- {accounts.map((account) => ( - - ))} + {accounts.map((account) => { + const busy = isSwitching && switchingTo === account.accountUuid; + return ( + + ); + })}
); @@ -193,6 +218,7 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) { const enabled = isOwner && harness === 'claude-code'; const [open, setOpen] = useState(false); + const { accounts: switchable, isSwitching, switchingTo, switchTo, switchError } = useAnthropicAccounts(open); const { accounts, allAccounts, isLoading, isRefreshing, isError } = useClaudeUsage(enabled && open); /* @@ -204,8 +230,14 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) { Null means "whichever is current", resolved at render — so the panel opens on the signed-in account without needing an effect to select it once the data arrives. */ - const [selectedEmail, setSelectedEmail] = useState(null); - const selected = accounts.find((a) => a.email === selectedEmail) ?? accounts[0] ?? null; + /* + No selection state any more, deliberately. + + The panel shows the account you are SIGNED INTO, full stop, and `useClaudeUsage` already filters to it. + A selection would let the numbers and the chips disagree — reading one account's percentages with + another's chip lit — and that disagreement is the exact confusion this whole change set out to remove. + */ + const selected = accounts[0] ?? null; const hidden = Math.max(0, allAccounts.length - accounts.length); // The countdowns tick. Only while the panel is open — an interval running behind a closed popover is a @@ -250,6 +282,11 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) { )} {/* Said out loud rather than silently dropped. Phase one shows one account; knowing another exists and is hidden on purpose is different from wondering where it went. */} + {switchError && ( +
+ {switchError instanceof Error ? switchError.message : 'could not switch account'} +
+ )} {hidden > 0 && (
{hidden} other account{hidden === 1 ? '' : 's'} not shown @@ -272,8 +309,13 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) { )}
- {accounts.length > 1 && ( - + {switchable.length > 1 && ( + void switchTo(uuid).catch(() => {})} + /> )} {/* A failure never blanks the panel: the two halves are independent server-side, so a limits problem diff --git a/src/workspaces/officerdev/src/apps/Chat/useAnthropicAccounts.ts b/src/workspaces/officerdev/src/apps/Chat/useAnthropicAccounts.ts new file mode 100644 index 00000000..683157bb --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/useAnthropicAccounts.ts @@ -0,0 +1,65 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useClient } from 'hooks/useClient'; + +/* + The Anthropic accounts this machine's owner can BE, and the act of becoming one. + + Separate from `useClaudeUsage`, which answers a different question. Usage is a measurement — how much room + is left — and is only readable for the account currently signed in. This is identity: who could I be, and + make me them. They meet in the popover, where the numbers belong to whichever pill is lit. +*/ + +export type SwitchableAccount = { + accountUuid: string; + emailAccount: string; + orgName: string | null; + plan: string | null; + capturedAt: string; + lastUsedAt: string | null; + refreshExpiresAt: string | null; + /** False once the refresh token has expired: the pill has to offer `/login` rather than a switch. */ + usable: boolean; + current: boolean; +}; + +const KEY = ['anthropic', 'accounts']; + +export function useAnthropicAccounts(enabled: boolean) { + const client = useClient(); + const queryClient = useQueryClient(); + + const query = useQuery<{ accounts: SwitchableAccount[] }>({ + queryKey: KEY, + queryFn: () => client.get<{ accounts: SwitchableAccount[] }>('/claude-auth/accounts'), + enabled, + // Cheap — two small file reads and one SELECT — and it must not be stale, because a switch made from a + // terminal is exactly the case this is here to notice. + staleTime: 0, + refetchOnWindowFocus: false, + // A member gets a 403 here by design. Retrying it just spends three round trips arriving at the same no. + retry: false, + }); + + const switchTo = useMutation({ + mutationFn: (accountUuid: string) => + client.post<{ ok: boolean; email: string; appliesTo: string }>('/claude-auth/switch', { accountUuid }), + onSuccess: async () => { + // Both, and in this order. The account list decides which pill is lit; the usage numbers belong to + // whichever account that is, and showing the old account's percentages under the new name is the one + // outcome that would make somebody distrust the whole panel. + await queryClient.invalidateQueries({ queryKey: KEY }); + await queryClient.invalidateQueries({ queryKey: ['chat', 'usage'] }); + await queryClient.invalidateQueries({ queryKey: ['chat', 'usage', 'fresh'] }); + }, + }); + + return { + accounts: query.data?.accounts ?? [], + isLoading: query.isLoading, + /** True while a switch is in flight — the panel must say so, or nobody believes it happened. */ + isSwitching: switchTo.isPending, + switchingTo: switchTo.variables ?? null, + switchError: switchTo.error, + switchTo: switchTo.mutateAsync, + }; +}