switch Anthropic accounts from the usage pills
The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.
ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.
anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).
One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.
What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.
Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.
.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.
Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.
The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.
Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -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<void> {
|
||||
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<StoredAccount[]> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
const gone = await db
|
||||
.delete(anthropicAccounts)
|
||||
.where(and(eq(anthropicAccounts.userId, userId), lt(anthropicAccounts.refreshExpiresAt, new Date())))
|
||||
.returning({ id: anthropicAccounts.id });
|
||||
return gone.length;
|
||||
}
|
||||
@@ -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:<uuid>', …)`, 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)],
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<Record<string, unknown> | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
|
||||
} 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<string, unknown> | undefined;
|
||||
const account = config?.oauthAccount as Record<string, unknown> | 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<void> {
|
||||
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<string, unknown>);
|
||||
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' });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}) => (
|
||||
<div className="flex items-center gap-1 overflow-x-auto border-t border-border px-2 py-2">
|
||||
{accounts.map((account) => (
|
||||
<button
|
||||
key={account.email}
|
||||
type="button"
|
||||
onClick={() => onSelect(account.email)}
|
||||
// The local part alone: the domain is what the accounts have in common and the part before it is
|
||||
// what tells them apart, and the panel is 320px wide.
|
||||
title={account.email}
|
||||
className={`shrink-0 cursor-pointer rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
account.email === selected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{account.email.split('@')[0]}
|
||||
</button>
|
||||
))}
|
||||
{accounts.map((account) => {
|
||||
const busy = isSwitching && switchingTo === account.accountUuid;
|
||||
return (
|
||||
<button
|
||||
key={account.accountUuid}
|
||||
type="button"
|
||||
// An expired refresh token cannot be switched to, so the chip says why instead of failing on click.
|
||||
disabled={!account.usable || isSwitching || account.current}
|
||||
onClick={() => onSwitch(account.accountUuid)}
|
||||
title={
|
||||
account.current
|
||||
? `${account.emailAccount} — signed in`
|
||||
: account.usable
|
||||
? `Switch to ${account.emailAccount}${account.orgName ? ` (${account.orgName})` : ''}`
|
||||
: `${account.emailAccount} — sign-in expired, run /login`
|
||||
}
|
||||
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
account.current
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: account.usable
|
||||
? 'cursor-pointer text-muted-foreground hover:bg-foreground/10 hover:text-foreground'
|
||||
: 'text-muted-foreground/40 line-through'
|
||||
} ${isSwitching && !busy ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{busy && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{/* The local part alone: the domain is what these accounts have in common and the part before it
|
||||
is what tells them apart, in a 320px panel. */}
|
||||
{account.emailAccount.split('@')[0]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(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 && (
|
||||
<div className="mt-1 text-[11px] text-destructive">
|
||||
{switchError instanceof Error ? switchError.message : 'could not switch account'}
|
||||
</div>
|
||||
)}
|
||||
{hidden > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{hidden} other account{hidden === 1 ? '' : 's'} not shown
|
||||
@@ -272,8 +309,13 @@ export function UsagePopover({ label, harness }: UsagePopoverProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
<AccountTabs accounts={accounts} selected={selected?.email ?? ''} onSelect={setSelectedEmail} />
|
||||
{switchable.length > 1 && (
|
||||
<AccountSwitcher
|
||||
accounts={switchable}
|
||||
isSwitching={isSwitching}
|
||||
switchingTo={switchingTo}
|
||||
onSwitch={(uuid) => void switchTo(uuid).catch(() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A failure never blanks the panel: the two halves are independent server-side, so a limits problem
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user