feat(settings): every AI tool's login profiles in one Settings section, linked to the account pool

Settings → AI tools → Profiles holds one tab per tool: Claude Code (the
cc-profiles panel, moved out of the Claude Code page's modal — its button
now links here), Codex (the whole $CODEX_HOME/auth.json) and Oh My Pi
(`omp auth export` / `omp auth import -`, secrets on stdin; omp's credential
tables are emptied first because import merges, and a failed import puts
the outgoing set back). Antigravity is listed as not switchable: agy keeps
its login in the OS keyring.

server/lib/tool-profiles.js + /api/tool-profiles keep cc-profiles' rules
(one login = one profile, the outgoing login is re-saved before a switch,
an unsaved one refuses it, the live login's profile is not deletable) and
never return a token; /tool-profiles joins ADMIN_WRITE_PREFIXES and
STAFF_ONLY_PREFIXES, writes are same-origin only.

The account pool takes a saved Codex profile too (moved in like a Claude
one, GET /api/accounts/profiles?tool=codex). Each Claude/Codex profile has
a "Move to pool" link (/admin?poolAdd=&poolProfile= opens the pool's Add
dialog on it), Settings shows the pool's size, and the pool card links back.
This commit is contained in:
2026-10-22 11:11:38 +07:00
parent 4eb7734ab9
commit 3113479cd2
26 changed files with 2154 additions and 747 deletions
+54 -1
View File
@@ -699,6 +699,29 @@ export type PoolProfile = {
subscriptionType: string | null;
active: boolean;
};
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
export type ToolProfileIdentity = {
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
kind: string;
email: string | null;
/** codex: the ChatGPT plan or `…1234` of a key; omp: its providers. */
detail: string | null;
};
export type ToolProfile = ToolProfileIdentity & {
name: string;
label: string;
savedAt: number | null;
active: boolean;
};
export type ToolProfilesTool = {
tool: string;
supported: boolean;
/** Why a tool cannot be switched (`supported: false`). */
reason?: string;
items: ToolProfile[];
active: ToolProfileIdentity | null;
error?: { code?: string; message: string } | null;
};
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
export type PoolAccountInput = {
tool: PoolTool;
@@ -1096,6 +1119,33 @@ export const api = {
body: JSON.stringify({ laneId, currentPassword }),
}),
},
/** Login profiles of Codex, Oh My Pi… (Claude's: `ccConfig.profiles`). */
toolProfiles: {
/** GET /api/tool-profiles — every covered tool, switchable or not. */
list: () => request<{ tools: ToolProfilesTool[] }>("/tool-profiles"),
/** POST /api/tool-profiles/:tool — save the login the tool holds now. */
save: (tool: string) =>
request<{ ok: true; name: string; label: string }>(
`/tool-profiles/${encodeURIComponent(tool)}`,
{ method: "POST", body: JSON.stringify({}) }
),
rename: (tool: string, name: string, label: string) =>
request<{ ok: true; name: string; label: string }>(
`/tool-profiles/${encodeURIComponent(tool)}`,
{ method: "PATCH", body: JSON.stringify({ name, label }) }
),
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
activate: (tool: string, name: string) =>
request<{ ok: true; name: string; savedOutgoing: string | null }>(
`/tool-profiles/${encodeURIComponent(tool)}/activate`,
{ method: "POST", body: JSON.stringify({ name }) }
),
remove: (tool: string, name: string) =>
request<{ ok: true; name: string }>(`/tool-profiles/${encodeURIComponent(tool)}`, {
method: "DELETE",
body: JSON.stringify({ name }),
}),
},
/** The Claude/Codex account pool every member's new panes run on
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
accountPool: {
@@ -1103,7 +1153,10 @@ export const api = {
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
/** POST /api/accounts — admin, with their OWN password again. */
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
profiles: (tool: PoolTool = "claude") =>
request<{ profiles: PoolProfile[] }>(
tool === "codex" ? "/accounts/profiles?tool=codex" : "/accounts/profiles"
),
add: (body: PoolAccountInput, currentPassword: string) =>
request<{ account: { name: string } }>("/accounts", {
method: "POST",