/** * @file ToolProfilesSettings — Settings → AI tools → Profiles: every AI tool's * login profiles in ONE place, a tab per tool. Claude Code's are cc-profiles * (`/api/cc-config/profiles`, with its quota check and token-owner mismatch * warning — this panel used to be a modal on the Claude Code page); Codex and * Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched * (Antigravity: an OS-keyring login) says why. In server mode the panel links * each Claude/Codex profile to the account pool in Administration → Users, * where an admin moves it in (`?poolAdd=&poolProfile=` opens the * pool's Add dialog on it), and shows how many accounts the pool holds. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { AlertCircle, ArrowRightLeft, Check, Copy, KeyRound, Pencil, Save, ShieldAlert, Terminal, Trash2, } from "lucide-react"; import { api, type AccountPool, type CcAccountIdentity, type CcProfile, type CcProfileUsage, type CcUsageWindow, type ToolProfile, type ToolProfileIdentity, type ToolProfilesTool, } from "../../lib/api"; import { toast } from "../../lib/toast"; import { confirmDialog } from "../../lib/confirm"; import { copyText } from "../../lib/clipboard"; import { useAiTools } from "../../hooks/useAiTools"; import { usePermissions } from "../AuthGate"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { SkeletonRows } from "../config/configPrimitives"; import { AiToolIcon } from "../AiToolIcon"; import { SettingsSection } from "./SettingsRow"; /** The tools the panel covers, in tab order, with the command that signs one in. */ const TOOLS: { id: string; label: string; login: string }[] = [ { id: "claude", label: "Claude Code", login: "claude auth login" }, { id: "codex", label: "Codex", login: "codex login" }, { id: "omp", label: "Oh My Pi", login: "omp (then /login)" }, { id: "antigravity", label: "Antigravity", login: "agy" }, ]; /** Tools the account pool takes (server/routes/accounts.js). */ const POOL_TOOLS = new Set(["claude", "codex"]); const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5"; const btnCls = "h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"; export function ToolProfilesSettings() { const { t } = useTranslation("settings"); const aiTools = useAiTools(); const { local, seesAll } = usePermissions(); const shown = TOOLS.filter((x) => { const tool = aiTools.byId(x.id); if (x.id === "claude") return tool?.enabled !== false; return !!tool && tool.installed && tool.enabled !== false; }); const [tab, setTab] = useState("claude"); const current = shown.find((x) => x.id === tab) ?? shown[0]; const [others, setOthers] = useState(null); const [pool, setPool] = useState(null); const loadOthers = useCallback( () => api.toolProfiles .list() .then((r) => setOthers(r.tools)) .catch(() => setOthers([])), [] ); useEffect(() => { void loadOthers(); }, [loadOthers]); // The pool is a server-mode, staff-only read. useEffect(() => { if (local || !seesAll) return; api.accountPool .list() .then(setPool) .catch(() => setPool(null)); }, [local, seesAll]); if (!current) return null; const poolCount = (tool: string) => pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null; return (
{shown.map((x) => ( ))}
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && ( )} {current.id === "claude" ? ( ) : ( o.tool === current.id) ?? null} loading={others === null} pooled={!local && POOL_TOOLS.has(current.id)} onChanged={loadOthers} /> )}
); } /** "The pool holds N accounts" + the way to it (Administration → Users). */ function PoolLine({ count }: { count: number }) { const { t } = useTranslation("settings"); return (
{t("toolProfiles.poolCount", { count })} {t("toolProfiles.openPool")}
); } /** The link that opens the pool's Add dialog on one saved profile (admins only). */ function MoveToPool({ tool, name }: { tool: string; name: string }) { const { t } = useTranslation("settings"); const { isAdmin } = usePermissions(); if (!isAdmin) return null; return ( {t("toolProfiles.moveToPool")} ); } function LoginHint({ text, command }: { text: string; command: string }) { const { t } = useTranslation("ccConfig"); const [copied, setCopied] = useState(false); return (
{text}
{command}
); } /** A profile's label, renamed in place: click it (or the menu's Rename), type, Enter. */ function LabelEdit({ label, busy, editing, setEditing, onRename, }: { label: string; busy: boolean; editing: boolean; setEditing: (on: boolean) => void; onRename: (next: string) => void; }) { const { t } = useTranslation("ccConfig"); const [draft, setDraft] = useState(label); useEffect(() => { if (editing) setDraft(label); }, [editing, label]); if (editing) { return ( setDraft(e.target.value)} onBlur={() => setEditing(false)} onKeyDown={(e) => { if (e.key === "Escape") setEditing(false); if (e.key !== "Enter") return; const next = draft.trim(); setEditing(false); if (next && next !== label) onRename(next); }} maxLength={60} aria-label={t("profiles.renameLabel")} className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40" /> ); } return ( ); } /** One mutation with a busy key, a toast either way, then a reload. */ function useRunner(load: () => Promise) { const [busy, setBusy] = useState(null); const run = useCallback( async (key: string, fn: () => Promise, success: string) => { setBusy(key); try { await fn(); toast.success(success); await load(); } catch (err: unknown) { toast.error(err); } finally { setBusy(null); } }, [load] ); return { busy, run }; } // ── Codex, Oh My Pi, … ───────────────────────────────────────────────── function OtherProfiles({ tool, label, login, state, loading, pooled, onChanged, }: { tool: string; label: string; login: string; state: ToolProfilesTool | null; loading: boolean; pooled: boolean; onChanged: () => Promise; }) { const { t } = useTranslation("settings"); const { t: tc } = useTranslation("ccConfig"); const { busy, run } = useRunner(onChanged); const [renaming, setRenaming] = useState(null); if (loading) return ; if (!state) return

{t("toolProfiles.noProfiles")}

; if (!state.supported) { return (

{t(`toolProfiles.unsupported.${tool}`, { defaultValue: state.reason ?? "" })}

); } const items = state.items; const activeSaved = items.some((p) => p.active); const remove = async (p: ToolProfile) => { const ok = await confirmDialog({ title: tc("profiles.deleteConfirmTitle", { name: p.label }), message: t("toolProfiles.deleteConfirmBody", { tool: label }), confirmLabel: tc("profiles.deleteButton"), }); if (ok) { void run( `del:${p.name}`, () => api.toolProfiles.remove(tool, p.name), tc("profiles.deleteSuccess", { name: p.label }) ); } }; return (

{t(`toolProfiles.subtitle.${tool}`, { defaultValue: "" })}

{state.error && (
{state.error.message}
)}
{tc("profiles.activeLabel")}
{state.active ? ( <>
{who(state.active)}
) : (
{t("toolProfiles.noActive", { tool: label })}
)} {state.active && !activeSaved && (
{tc("profiles.unsavedActive")}
)}
{items.length === 0 && (
{tc("profiles.empty")}
)} {items.map((p) => (
setRenaming(on ? p.name : null)} onRename={(next) => void run( `rename:${p.name}`, () => api.toolProfiles.rename(tool, p.name, next), tc("profiles.renameSuccess", { name: next }) ) } /> {p.active && } {who(p)} {!p.active && ( )} {pooled && } {!p.active && ( )}
{p.savedAt && (
{tc("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
)}
))}
); } const who = (id: ToolProfileIdentity) => id.email || id.detail || id.kind; function OtherIdentityLine({ identity }: { identity: ToolProfileIdentity }) { const { t } = useTranslation("settings"); const parts = [ t(`toolProfiles.kind.${identity.kind}`, { defaultValue: identity.kind }), identity.email ? identity.detail : null, ].filter(Boolean); return
{parts.join(" · ")}
; } function ActiveBadge() { const { t } = useTranslation("ccConfig"); return ( {t("profiles.activeBadge")} ); } // ── Claude Code ──────────────────────────────────────────────────────── // // Swaps which Anthropic account the local `claude` CLI runs as. Only the two // identity artifacts move (the OAuth blob + `oauthAccount`), so skills, // subagents, memory, plugins, marketplaces and settings all stay exactly where // they are. The dashboard cannot log a new account in (only `claude auth login` // can mint tokens), so adding a profile is always "log in, then snapshot what // the CLI wrote" — a save button on the live account, never a name field: the // server derives the identifier, and the user edits the label in place. The // save button only appears while the live account is unsaved, because one // account is one profile. function ClaudeProfiles({ pooled }: { pooled: boolean }) { const { t } = useTranslation("ccConfig"); const [items, setItems] = useState(null); const [active, setActive] = useState(null); /** `.claude.json`'s account, when it is not the one the live token belongs to. */ const [mismatchEmail, setMismatchEmail] = useState(null); const [error, setError] = useState(null); /** Quota per profile, keyed by `name`, fetched for every profile once the * list arrives: the live account is read from the CLI and never refreshed, * any other profile only when its token has actually expired. */ const [usage, setUsage] = useState>({}); const [usageError, setUsageError] = useState>({}); /** Per profile, so one slow account does not freeze the other rows. */ const [usageBusy, setUsageBusy] = useState>({}); /** Names already auto-fetched, so the effect below fires once per profile. */ const autoFetched = useRef>(new Set()); const ctx = useContextMenu(); const load = useCallback(async () => { try { const r = await api.ccConfig.profiles(); setItems(r.items); setActive(r.active); setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null); setError(null); } catch (err: unknown) { setError(err instanceof Error ? err.message : "unknown"); } }, []); const { busy, run } = useRunner(load); useEffect(() => { void load(); }, [load]); const checkUsage = useCallback(async (name: string) => { setUsageBusy((prev) => ({ ...prev, [name]: true })); setUsageError((prev) => { const next = { ...prev }; delete next[name]; return next; }); try { const result = await api.ccConfig.profileUsage(name); setUsage((prev) => ({ ...prev, [name]: result })); } catch (err: unknown) { setUsageError((prev) => ({ ...prev, [name]: err instanceof Error ? err.message : "unknown error", })); } finally { setUsageBusy((prev) => { const next = { ...prev }; delete next[name]; return next; }); } }, []); // Every profile's quota, once per profile per mount: a retry is the row's // own button, so a failed account is not re-asked on every render. useEffect(() => { if (!items) return; for (const p of items) { if (autoFetched.current.has(p.name)) continue; autoFetched.current.add(p.name); void checkUsage(p.name); } }, [items, checkUsage]); const activeSaved = items?.some((p) => p.active) ?? false; /** How many saved profiles hold the same account as `p`. */ const copies = (p: CcProfile) => items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1; /** Deletable unless it is the ONLY saved copy of the live account. */ const deletable = (p: CcProfile) => !p.active || copies(p) > 1; const [renaming, setRenaming] = useState(null); const switchTo = (p: CcProfile) => void run( `switch:${p.name}`, () => api.ccConfig.activateProfile(p.name), t("profiles.switchSuccess", { name: p.label }) ); const remove = async (p: CcProfile) => { const ok = await confirmDialog({ title: t("profiles.deleteConfirmTitle", { name: p.label }), message: t("profiles.deleteConfirmBody"), confirmLabel: t("profiles.deleteButton"), }); if (!ok) return; void run( `del:${p.name}`, () => api.ccConfig.deleteProfile(p.name), t("profiles.deleteSuccess", { name: p.label }) ); }; const pick = (fn: (p: CcProfile) => void) => () => { const p = ctx.menu!.item; ctx.close(); fn(p); }; return (

{t("profiles.subtitle")}

{t("profiles.warning")}

{mismatchEmail && (

{t("profiles.mismatch", { config: mismatchEmail, owner: active?.email || active?.accountUuid || "?", })}

)} {error && (
{error}
)}
{t("profiles.activeLabel")}
{active ? ( <>
{active.email || active.displayName || active.accountUuid}
) : (
{t("profiles.noActive")}
)} {active && !activeSaved && (
{t("profiles.unsavedActive")}
)}
{items === null && !error && } {items !== null && items.length === 0 && (
{t("profiles.empty")}
)} {items?.map((p) => (
ctx.open(e, p)} className={rowCls} >
setRenaming(on ? p.name : null)} onRename={(next) => void run( `rename:${p.name}`, () => api.ccConfig.renameProfile(p.name, next), t("profiles.renameSuccess", { name: next }) ) } /> {p.active && } {p.email || p.displayName || p.accountUuid} {!p.active && ( )} {pooled && } {deletable(p) && ( )}
void checkUsage(p.name)} /> {p.savedAt && (
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
)}
))} {ctx.menu && ( setRenaming(p.name))}>{t("profiles.rename")} {!ctx.menu.item.active && ( {t("profiles.switchButton")} )} void checkUsage(p.name))} > {t("profiles.usageCheck")} {deletable(ctx.menu.item) && ( <> void remove(p))} > {t("profiles.deleteButton")} )} )}
); } /** * One profile's quota: a button until asked, then the two windows Anthropic * reports — the 5-hour session allowance and the 7-day one. Reading a saved * profile's usage may refresh its OAuth token (rotating it on disk), so it * happens once per mount or click, never on a poll. */ function ProfileUsageLine({ usage, error, busy, disabled, onCheck, }: { usage?: CcProfileUsage; error?: string; busy: boolean; disabled: boolean; onCheck: () => void; }) { const { t } = useTranslation("ccConfig"); const pct = (w: CcUsageWindow | null | undefined) => w && w.utilization !== null ? `${Math.round(w.utilization)}%` : "—"; const resetTitle = (w: CcUsageWindow | null | undefined) => w && w.resetsAt ? t("profiles.usageResets", { time: new Date(w.resetsAt).toLocaleString() }) : undefined; if (error) { return (
{error}
); } if (!usage) { return ( ); } return (
{t("profiles.usageFiveHour")}{" "} {pct(usage.fiveHour)} {t("profiles.usageSevenDay")}{" "} {pct(usage.sevenDay)}
); } /** Org / seat / plan line shared by the active card and each profile row. */ function IdentityLine({ identity }: { identity: CcAccountIdentity }) { const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter( Boolean ); if (parts.length === 0) return null; return
{parts.join(" · ")}
; }