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
@@ -0,0 +1,808 @@
/**
* @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=<tool>&poolProfile=<name>` opens the
* pool's Add dialog on it), and shows how many accounts the pool holds.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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<string>("claude");
const current = shown.find((x) => x.id === tab) ?? shown[0];
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
const [pool, setPool] = useState<AccountPool | null>(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 (
<SettingsSection
id="tool-profiles"
title={t("toolProfiles.title")}
description={t("toolProfiles.description")}
>
<div role="tablist" aria-label={t("toolProfiles.title")} className="flex flex-wrap gap-1.5">
{shown.map((x) => (
<button
key={x.id}
type="button"
role="tab"
aria-selected={x.id === current.id}
onClick={() => setTab(x.id)}
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs ${
x.id === current.id
? "border-accent/40 bg-accent/10 text-accent"
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3"
}`}
>
<AiToolIcon tool={{ id: x.id, label: x.label }} className="h-3.5 w-3.5" />
{x.label}
</button>
))}
</div>
<div role="tabpanel" className="mt-3 space-y-3">
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
<PoolLine count={poolCount(current.id)!} />
)}
{current.id === "claude" ? (
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} />
) : (
<OtherProfiles
key={current.id}
tool={current.id}
label={current.label}
login={current.login}
state={others?.find((o) => o.tool === current.id) ?? null}
loading={others === null}
pooled={!local && POOL_TOOLS.has(current.id)}
onChanged={loadOthers}
/>
)}
</div>
</SettingsSection>
);
}
/** "The pool holds N accounts" + the way to it (Administration → Users). */
function PoolLine({ count }: { count: number }) {
const { t } = useTranslation("settings");
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-fg-secondary">
<KeyRound className="h-3.5 w-3.5 text-fg-muted" />
<span className="flex-1">{t("toolProfiles.poolCount", { count })}</span>
<Link to="/admin#account-pool" className="text-accent hover:underline">
{t("toolProfiles.openPool")}
</Link>
</div>
);
}
/** 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 (
<Link
to={`/admin?${new URLSearchParams({ poolAdd: tool, poolProfile: name })}#account-pool`}
title={t("toolProfiles.moveToPoolHint")}
className="h-7 text-[11px] px-2 rounded-md text-fg-muted hover:text-accent hover:bg-surface-3 inline-flex items-center gap-1"
>
<ArrowRightLeft className="h-3 w-3" />
{t("toolProfiles.moveToPool")}
</Link>
);
}
function LoginHint({ text, command }: { text: string; command: string }) {
const { t } = useTranslation("ccConfig");
const [copied, setCopied] = useState(false);
return (
<div>
<div className="text-[10px] text-fg-muted mb-1">{text}</div>
<div className="rounded-md border border-border bg-surface-2 px-2.5 py-1.5 flex items-center gap-2">
<Terminal className="w-3 h-3 text-fg-muted flex-shrink-0" />
<code className="font-mono text-[11px] text-fg-secondary truncate flex-1">{command}</code>
<button
type="button"
onClick={async () => {
if (await copyText(command, true)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}}
className="text-[10px] font-medium px-1.5 py-1 rounded border border-border bg-surface-1 hover:bg-surface-3 text-fg-secondary hover:text-fg-primary inline-flex items-center gap-1 flex-shrink-0"
title={t("snippet.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
<span>{copied ? t("snippet.copied") : t("snippet.copy")}</span>
</button>
</div>
</div>
);
}
/** 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 (
<input
autoFocus
value={draft}
onChange={(e) => 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 (
<button
disabled={busy}
onClick={() => setEditing(true)}
title={t("profiles.renameHint")}
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
>
{label}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
</button>
);
}
/** One mutation with a busy key, a toast either way, then a reload. */
function useRunner(load: () => Promise<unknown>) {
const [busy, setBusy] = useState<string | null>(null);
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, 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<unknown>;
}) {
const { t } = useTranslation("settings");
const { t: tc } = useTranslation("ccConfig");
const { busy, run } = useRunner(onChanged);
const [renaming, setRenaming] = useState<string | null>(null);
if (loading) return <SkeletonRows n={2} />;
if (!state) return <p className="text-xs text-fg-muted">{t("toolProfiles.noProfiles")}</p>;
if (!state.supported) {
return (
<p className="text-xs text-fg-muted inline-flex items-start gap-1.5">
<ShieldAlert className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{t(`toolProfiles.unsupported.${tool}`, { defaultValue: state.reason ?? "" })}</span>
</p>
);
}
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 (
<div className="space-y-3">
<p className="text-[11px] text-fg-muted leading-relaxed">
{t(`toolProfiles.subtitle.${tool}`, { defaultValue: "" })}
</p>
{state.error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{state.error.message}
</div>
)}
<div className={`${rowCls} space-y-1`}>
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{tc("profiles.activeLabel")}
</div>
{state.active ? (
<>
<div className="text-sm text-fg-primary">{who(state.active)}</div>
<OtherIdentityLine identity={state.active} />
</>
) : (
<div className="text-sm text-fg-muted">{t("toolProfiles.noActive", { tool: label })}</div>
)}
{state.active && !activeSaved && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{tc("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
void run("save", () => api.toolProfiles.save(tool), tc("profiles.saveSuccess"))
}
className={btnCls}
>
<Save className="w-3 h-3" />
{busy === "save" ? tc("profiles.saving") : t("toolProfiles.saveButton")}
</button>
</div>
)}
</div>
{items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
{tc("profiles.empty")}
</div>
)}
{items.map((p) => (
<div key={p.name} data-testid={`profile-row-${tool}-${p.name}`} className={rowCls}>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => 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 && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">{who(p)}</span>
{!p.active && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`switch:${p.name}`,
() => api.toolProfiles.activate(tool, p.name),
t("toolProfiles.switchSuccess", { name: p.label, tool: label })
)
}
className={btnCls}
>
{busy === `switch:${p.name}`
? tc("profiles.switching")
: tc("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool={tool} name={p.name} />}
{!p.active && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={tc("profiles.deleteButton")}
aria-label={tc("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<OtherIdentityLine identity={p} />
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{tc("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
</div>
);
}
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 <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
function ActiveBadge() {
const { t } = useTranslation("ccConfig");
return (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
{t("profiles.activeBadge")}
</span>
);
}
// ── 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<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
/** Per profile, so one slow account does not freeze the other rows. */
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const ctx = useContextMenu<CcProfile>();
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<string | null>(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 (
<div className="space-y-3">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
{mismatchEmail && (
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
)}
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<div className={`${rowCls} space-y-1`}>
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("profiles.activeLabel")}
</div>
{active ? (
<>
<div className="text-sm text-fg-primary">
{active.email || active.displayName || active.accountUuid}
</div>
<IdentityLine identity={active} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
}
className={btnCls}
>
<Save className="w-3 h-3" />
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
</button>
</div>
)}
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("profiles.empty")}
</div>
)}
{items?.map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
className={rowCls}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
label={p.label}
busy={busy !== null}
editing={renaming === p.name}
setEditing={(on) => 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 && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
</button>
)}
{pooled && <MoveToPool tool="claude" name={p.name} />}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
{!ctx.menu.item.active && (
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
{t("profiles.switchButton")}
</MenuItem>
)}
<MenuItem
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
onSelect={pick((p) => void checkUsage(p.name))}
>
{t("profiles.usageCheck")}
</MenuItem>
{deletable(ctx.menu.item) && (
<>
<MenuSeparator />
<MenuItem
danger
disabled={busy !== null}
testId="profile-menu-delete"
onSelect={pick((p) => void remove(p))}
>
{t("profiles.deleteButton")}
</MenuItem>
</>
)}
</ContextMenu>
)}
<LoginHint text={t("profiles.loginHint")} command="claude auth login" />
</div>
);
}
/**
* 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 (
<div className="mt-1 flex items-center gap-2 text-[10px]">
<span className="text-status-danger">{error}</span>
<button
onClick={onCheck}
disabled={disabled}
className="text-fg-muted hover:text-accent disabled:opacity-50"
>
{t("profiles.usageRetry")}
</button>
</div>
);
}
if (!usage) {
return (
<button
onClick={onCheck}
disabled={disabled}
title={t("profiles.usageHint")}
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
</button>
);
}
return (
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
<span title={resetTitle(usage.fiveHour)}>
{t("profiles.usageFiveHour")}{" "}
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
</span>
<span title={resetTitle(usage.sevenDay)}>
{t("profiles.usageSevenDay")}{" "}
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
</span>
<button
onClick={onCheck}
disabled={disabled}
className="hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
</button>
</div>
);
}
/** 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 <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
@@ -0,0 +1,178 @@
/**
* @file Tests for ToolProfilesSettings — Settings → AI tools → Profiles: one
* tab per AI tool. Claude's tab asks for EVERY saved account's quota once (a
* failing account costs the others nothing), and its right-click delete asks
* first and never offers to delete the only copy of the live account; Codex's
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
* switched; and in server mode a profile links into the account pool.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
import { settleConfirm } from "../../../lib/confirm";
import { ToolProfilesSettings } from "../ToolProfilesSettings";
const perms = vi.hoisted(() => ({ isAdmin: true, local: true, seesAll: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../hooks/useAiTools", () => ({
useAiTools: () => ({
byId: (id: string) => ({ id, installed: true, enabled: true }),
}),
}));
const m = vi.hoisted(() => {
const window = (utilization: number) => ({ utilization, resetsAt: "2026-09-18T08:00:00Z" });
return {
window,
cc: {
profiles: vi.fn(),
profileUsage: vi.fn(),
deleteProfile: vi.fn(),
activateProfile: vi.fn(),
saveProfile: vi.fn(),
renameProfile: vi.fn(),
},
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
pool: { list: vi.fn() },
};
});
vi.mock("../../../lib/api", () => ({
api: {
ccConfig: m.cc,
toolProfiles: m.tp,
accountPool: m.pool,
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
},
}));
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function show() {
render(
<MemoryRouter>
<ToolProfilesSettings />
</MemoryRouter>
);
await settle();
await settle();
}
beforeEach(() => {
vi.clearAllMocks();
perms.local = true;
m.cc.profiles.mockResolvedValue({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
],
active: { accountUuid: "u1", email: "a@b.c" },
});
m.cc.profileUsage.mockImplementation(async (name: string) => {
if (name === "spare") throw new Error("this profile's saved login has been superseded");
return { name, refreshed: false, fiveHour: m.window(8), sevenDay: m.window(70) };
});
m.cc.deleteProfile.mockResolvedValue({ ok: true, name: "spare" });
m.tp.list.mockResolvedValue({
tools: [
{
tool: "codex",
supported: true,
items: [
{
name: "a",
label: "Alice",
active: true,
kind: "chatgpt",
email: "a@x",
detail: "plus",
},
{ name: "b", label: "Bob", active: false, kind: "apikey", email: null, detail: "…1234" },
],
active: { kind: "chatgpt", email: "a@x", detail: "plus" },
},
{ tool: "omp", supported: true, items: [], active: null },
{ tool: "antigravity", supported: false, reason: "keyring", items: [], active: null },
],
});
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
});
describe("ToolProfilesSettings — Claude", () => {
it("asks for every saved account's quota once, a failure costing the others nothing", async () => {
await show();
await settle();
expect(m.cc.profileUsage).toHaveBeenCalledWith("work");
expect(m.cc.profileUsage).toHaveBeenCalledWith("spare");
expect(m.cc.profileUsage.mock.calls.filter((c) => c[0] === "work")).toHaveLength(1);
expect(await screen.findByText("8%")).toBeInTheDocument();
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
});
it("deletes from the right-click menu only after confirming", async () => {
const user = userEvent.setup();
await show();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete"));
await settle();
expect(m.cc.deleteProfile).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(m.cc.deleteProfile).toHaveBeenCalledWith("spare");
});
it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup();
await show();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
});
});
describe("ToolProfilesSettings — other tools", () => {
it("switches a Codex login through /api/tool-profiles", async () => {
const user = userEvent.setup();
await show();
await user.click(screen.getByRole("tab", { name: /codex/i }));
const bob = screen.getByTestId("profile-row-codex-b");
expect(bob).toHaveTextContent("…1234");
await user.click(screen.getByRole("button", { name: "Switch to" }));
await settle();
expect(m.tp.activate).toHaveBeenCalledWith("codex", "b");
expect(m.tp.list).toHaveBeenCalledTimes(2);
});
it("says why Antigravity cannot be switched", async () => {
const user = userEvent.setup();
await show();
await user.click(screen.getByRole("tab", { name: /antigravity/i }));
expect(screen.getByText(/system keyring/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
});
});
describe("ToolProfilesSettings — account pool", () => {
it("is not shown in local mode", async () => {
await show();
expect(m.pool.list).not.toHaveBeenCalled();
expect(screen.queryByText(/Move to pool/)).toBeNull();
});
it("in server mode shows the pool's size and links a profile into it", async () => {
perms.local = false;
await show();
expect(screen.getByText(/holds 1 account/)).toBeInTheDocument();
const [link] = screen.getAllByRole("link", { name: /Move to pool/ });
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
});
});
+73 -34
View File
@@ -4,19 +4,23 @@
* Claude and Codex accounts every user's new sessions are spread over, each
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
* per user. An admin adds one — for Claude a login MOVED in from a saved
* switch-user profile (`GET /api/accounts/profiles`), a pasted
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
* API key or a pasted `auth.json` — or removes one, each time with their own
* profile (Settings → AI tools → Profiles, `GET /api/accounts/profiles`), a
* pasted `.credentials.json`, or a `claude setup-token` token; for Codex a
* saved Codex profile (`?tool=codex`), an OpenAI API key or a pasted
* `auth.json` — or removes one, each time with their own
* password again; an analyst only reads. The secret only goes IN: the server
* never sends it back. An account's Edit (pencil) picks the users who run on
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
* a Claude login goes into their home at once — no restart.
* a Claude login goes into their home at once — no restart. A profile's
* "Move to pool" link lands here as `?poolAdd=<tool>&poolProfile=<name>`,
* which opens Add on that profile; the header links back to the profiles.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Link, useSearchParams } from "react-router-dom";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
api,
@@ -77,7 +81,22 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const [pool, setPool] = useState<AccountPool | null>(null);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [params, setParams] = useSearchParams();
// A profile's "Move to pool" (Settings → AI tools → Profiles) lands here.
const preset = (() => {
const tool = params.get("poolAdd");
const profile = params.get("poolProfile");
return (tool === "claude" || tool === "codex") && profile ? { tool, profile } : null;
})() as { tool: PoolTool; profile: string } | null;
const [adding, setAdding] = useState(() => isAdmin && preset !== null);
const closeAdd = () => {
setAdding(false);
if (!preset) return;
const next = new URLSearchParams(params);
next.delete("poolAdd");
next.delete("poolProfile");
setParams(next, { replace: true });
};
const [removing, setRemoving] = useState<Row | null>(null);
const [editing, setEditing] = useState<Row | null>(null);
@@ -102,13 +121,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
<section className="card" data-testid="account-pool">
<section id="account-pool" className="card scroll-mt-24" data-testid="account-pool">
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
<KeyRound className="w-4 h-4 text-fg-muted" />
<div className="flex-1 min-w-0">
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
</div>
<Link to="/settings/agents#tool-profiles" className="btn-ghost text-xs">
{t("pool.profilesLink")}
</Link>
<button
type="button"
onClick={refresh}
@@ -235,10 +257,11 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
)}
{adding && (
<AddAccountDialog
onClose={() => setAdding(false)}
preset={preset}
onClose={closeAdd}
onDone={(name) => {
toast.success(t("pool.done.added", { name }));
setAdding(false);
closeAdd();
void load();
}}
/>
@@ -412,19 +435,27 @@ function EditAccountDialog({
}
type ClaudeSource = "profile" | "credentials" | "token";
/** What each tool's pool account can come from (server/routes/accounts.js). */
const SOURCES: Record<PoolTool, ClaudeSource[]> = {
claude: ["profile", "credentials", "token"],
codex: ["profile", "token"],
};
function AddAccountDialog({
preset,
onClose,
onDone,
}: {
/** A saved profile to move in, from its "Move to pool" link. */
preset?: { tool: PoolTool; profile: string } | null;
onClose: () => void;
onDone: (name: string) => void;
}) {
const { t } = useTranslation("users");
const [tool, setTool] = useState<PoolTool>("claude");
const [tool, setTool] = useState<PoolTool>(preset?.tool ?? "claude");
const [source, setSource] = useState<ClaudeSource>("profile");
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
const [profile, setProfile] = useState("");
const [profile, setProfile] = useState(preset?.profile ?? "");
const [name, setName] = useState("");
const [secret, setSecret] = useState("");
const [current, setCurrent] = useState("");
@@ -432,13 +463,14 @@ function AddAccountDialog({
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setProfiles(null);
api.accountPool
.profiles()
.profiles(tool)
.then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([]));
}, []);
}, [tool]);
const fromProfile = tool === "claude" && source === "profile";
const fromProfile = source === "profile";
// A profile already names itself: its name is the default.
const ready =
current.length > 0 &&
@@ -449,10 +481,10 @@ function AddAccountDialog({
setBusy(true);
setError(null);
const body: PoolAccountInput =
tool === "codex"
? { tool, name: name.trim(), token: secret.trim() }
: source === "profile"
? { tool, name: name.trim() || profile, profile }
source === "profile"
? { tool, name: name.trim() || profile, profile }
: tool === "codex"
? { tool, name: name.trim(), token: secret.trim() }
: source === "credentials"
? { tool, name: name.trim(), credentials: secret.trim() }
: { tool, name: name.trim(), token: secret.trim() };
@@ -491,29 +523,36 @@ function AddAccountDialog({
<select
className="input w-full text-sm"
value={tool}
onChange={(e) => setTool(e.target.value as PoolTool)}
onChange={(e) => {
setTool(e.target.value as PoolTool);
setProfile("");
setSource("profile");
}}
aria-label={t("pool.tool")}
>
<option value="claude">Claude Code</option>
<option value="codex">Codex</option>
</select>
</Field>
{tool === "claude" && (
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
<select
className="input w-full text-sm"
value={source}
onChange={(e) => setSource(e.target.value as ClaudeSource)}
aria-label={t("pool.source")}
>
{(["profile", "credentials", "token"] as const).map((s) => (
<option key={s} value={s}>
{t(`pool.sources.${s}`)}
</option>
))}
</select>
</Field>
)}
<Field
label={t("pool.source")}
hint={t(`pool.sourceHint.${tool === "codex" && source === "token" ? "codex" : source}`)}
>
<select
className="input w-full text-sm"
value={source}
onChange={(e) => setSource(e.target.value as ClaudeSource)}
aria-label={t("pool.source")}
>
{SOURCES[tool].map((s) => (
<option key={s} value={s}>
{tool === "codex" && s === "token"
? t("pool.secret.codex")
: t(`pool.sources.${s}`)}
</option>
))}
</select>
</Field>
{fromProfile && (
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
<select
@@ -13,6 +13,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import type { AccountPool } from "../../../lib/api";
const accountPool = vi.hoisted(() => ({
@@ -30,6 +31,13 @@ vi.mock("../../../lib/api", async (importOriginal) => {
import { AccountPoolCard } from "../AccountPoolCard";
/** The card reads `?poolAdd=` and links to Settings: it needs a router. */
const card = (isAdmin: boolean, url = "/admin") => (
<MemoryRouter initialEntries={[url]}>
<AccountPoolCard isAdmin={isAdmin} />
</MemoryRouter>
);
const POOL: AccountPool = {
accounts: [
{
@@ -91,7 +99,7 @@ beforeEach(() => {
describe("AccountPoolCard", () => {
it("lists every account, and explains an empty pool", async () => {
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
const { unmount } = render(card(false));
expect(await screen.findByText("team-1")).toBeInTheDocument();
expect(screen.getByText("cx-1")).toBeInTheDocument();
expect(screen.getByText("42%")).toBeInTheDocument();
@@ -102,14 +110,14 @@ describe("AccountPoolCard", () => {
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
unmount();
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
render(<AccountPoolCard isAdmin />);
render(card(true));
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
});
it("moves a saved login in, the server's own included", async () => {
accountPool.add.mockResolvedValue({ account: { name: "work" } });
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: /add account/i }));
const dialog = await screen.findByRole("dialog");
@@ -134,7 +142,7 @@ describe("AccountPoolCard", () => {
it("adds a setup-token only with the admin's own password", async () => {
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
render(card(true));
await screen.findByText("team-1");
await user.click(screen.getByRole("button", { name: /add account/i }));
const dialog = await screen.findByRole("dialog");
@@ -159,7 +167,7 @@ describe("AccountPoolCard", () => {
it("removes an account with the admin's own password", async () => {
accountPool.remove.mockResolvedValue({ ok: true });
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
render(card(true));
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
await user.click(screen.getByRole("button", { name: "Remove" }));
@@ -184,7 +192,7 @@ describe("AccountPoolCard", () => {
removed: [{ userId: 8, username: "leo" }],
});
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
render(card(true));
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
@@ -206,4 +214,40 @@ describe("AccountPoolCard", () => {
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
);
});
it("moves a saved Codex profile in, and opens on one from its Move to pool link", async () => {
accountPool.add.mockResolvedValue({ account: { name: "cx" } });
accountPool.profiles.mockImplementation(async (tool: string) =>
tool === "codex"
? {
profiles: [
{ name: "cx", label: "Codex", email: "c@x", subscriptionType: "plus", active: false },
],
}
: { profiles: [] }
);
const user = userEvent.setup();
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
const dialog = await screen.findByRole("dialog");
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
expect(screen.getByLabelText("Tool")).toHaveValue("codex");
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
await user.type(screen.getByLabelText("Your password"), "boss-pass");
const confirm = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Add account"
)!;
await user.click(confirm);
await waitFor(() =>
expect(accountPool.add).toHaveBeenCalledWith(
{ tool: "codex", name: "cx", profile: "cx" },
"boss-pass"
)
);
});
it("links back to the profiles in Settings", async () => {
render(card(false));
const link = await screen.findByRole("link", { name: "Profiles in Settings" });
expect(link.getAttribute("href")).toBe("/settings/agents#tool-profiles");
});
});
+1 -1
View File
@@ -72,7 +72,7 @@
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
"quotaActive": "in use",
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
"quotaProfileExpired": "Sign-in expired: use Check quota in Settings → AI tools → Profiles to refresh it",
"quotaSignInExpires": "Sign-in expires in {{time}}",
"quotaSignInExpired": "Sign-in expired",
"quotaWindow": {
+27
View File
@@ -1385,5 +1385,32 @@
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
"signedOut_one": "Signed out {{count}} other device",
"signedOut_other": "Signed out {{count}} other devices"
},
"toolProfiles": {
"title": "Profiles",
"description": "Every AI tool's saved logins in one place. Switching swaps only the login; config, skills, sessions and history stay as they are.",
"poolCount_one": "The account pool holds {{count}} account for this tool.",
"poolCount_other": "The account pool holds {{count}} accounts for this tool.",
"openPool": "Open the account pool",
"moveToPool": "Move to pool",
"moveToPoolHint": "Administration → Users → Account pool, with this profile chosen. The pool becomes the login's only holder and the profile is removed.",
"saveButton": "Save this login as a profile",
"noActive": "{{tool}} holds no login on this machine.",
"noProfiles": "Could not load the profiles.",
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
"loginHint": "Add another {{tool}} login by signing in from a terminal first:",
"subtitle": {
"codex": "A profile is one Codex login (a ChatGPT account or an API key): the whole auth.json. config.toml, sessions, skills and history stay where they are.",
"omp": "A profile is the full set of logins Oh My Pi stores (every provider), read with `omp auth export` and put back with `omp auth import`. Settings, models and sessions stay where they are. Running omp sessions pick up the switch on their own."
},
"unsupported": {
"antigravity": "Antigravity keeps its login in the system keyring, which AgentHub does not read or write, so it cannot be switched from here. Sign in or out inside agy itself."
},
"kind": {
"chatgpt": "ChatGPT login",
"apikey": "API key",
"credentials": "Logins"
}
}
}
+7 -5
View File
@@ -177,19 +177,20 @@
},
"source": "From",
"sources": {
"profile": "A saved login (Switch account profiles)",
"profile": "A saved profile (Settings → AI tools → Profiles)",
"credentials": "Paste .credentials.json / Keychain",
"token": "claude setup-token"
},
"sourceHint": {
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable."
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable.",
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
},
"profile": "Profile",
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
"profileHint": "Saved in Settings → AI tools → Profiles. The one this server's own CLI is logged into can be moved too: the server keeps running on it.",
"chooseProfile": "Choose a saved login…",
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
"noProfiles": "No saved profiles — save one in Settings → AI tools → Profiles",
"loadingProfiles": "Loading…",
"activeHere": "in use on this server — keeps working",
"nameFromProfile": "Leave blank to use the profile's name",
@@ -216,7 +217,8 @@
"users": "Users on this account",
"noUsers": "No users yet.",
"onAccount": "now on {{account}}",
"editDone": "{{name}} saved"
"editDone": "{{name}} saved",
"profilesLink": "Profiles in Settings"
},
"delete": {
"button": "Delete user",
+1 -1
View File
@@ -72,7 +72,7 @@
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
"quotaActive": "đang dùng",
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Cài đặt → AI tools → Hồ sơ đăng nhập để làm mới",
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
"quotaSignInExpired": "Đăng nhập đã hết hạn",
"quotaWindow": {
+27
View File
@@ -1385,5 +1385,32 @@
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
},
"toolProfiles": {
"title": "Hồ sơ đăng nhập",
"description": "Mọi đăng nhập đã lưu của các AI tool ở một nơi. Khi đổi chỉ phần đăng nhập được hoán đổi; config, skills, phiên làm việc và lịch sử vẫn giữ nguyên.",
"poolCount_one": "Account pool đang có {{count}} tài khoản cho tool này.",
"poolCount_other": "Account pool đang có {{count}} tài khoản cho tool này.",
"openPool": "Mở account pool",
"moveToPool": "Đưa vào pool",
"moveToPoolHint": "Quản trị → Người dùng → Account pool, chọn sẵn hồ sơ này. Pool sẽ là nơi duy nhất giữ đăng nhập này và hồ sơ được xoá.",
"saveButton": "Lưu đăng nhập này thành hồ sơ",
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
"noProfiles": "Không tải được danh sách hồ sơ.",
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
"subtitle": {
"codex": "Mỗi hồ sơ là một đăng nhập Codex (tài khoản ChatGPT hoặc API key): toàn bộ auth.json. config.toml, phiên, skills và lịch sử vẫn giữ nguyên.",
"omp": "Mỗi hồ sơ là toàn bộ các đăng nhập Oh My Pi đang lưu (mọi provider), đọc bằng `omp auth export` và ghi lại bằng `omp auth import`. Settings, model và phiên vẫn giữ nguyên. Các phiên omp đang chạy tự nhận đăng nhập mới."
},
"unsupported": {
"antigravity": "Antigravity lưu đăng nhập trong keyring của hệ điều hành, AgentHub không đọc hay ghi vào đó nên không đổi được từ đây. Hãy đăng nhập/đăng xuất ngay trong agy."
},
"kind": {
"chatgpt": "Đăng nhập ChatGPT",
"apikey": "API key",
"credentials": "Đăng nhập"
}
}
}
+7 -5
View File
@@ -177,19 +177,20 @@
},
"source": "Nguồn",
"sources": {
"profile": "Login đã lưu (profile Switch account)",
"profile": "Hồ sơ đã lưu (Cài đặt → AI tools → Hồ sơ đăng nhập)",
"credentials": "Dán .credentials.json / Keychain",
"token": "claude setup-token"
},
"sourceHint": {
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường."
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường.",
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
},
"profile": "Profile",
"profileHint": "Lưu ở Settings → Claude config → Switch account. Profile mà Claude Code của server đang dùng cũng chuyển được: server vẫn chạy trên nó và tự làm mới cho mọi người.",
"profileHint": "Lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập. Hồ sơ mà CLI của server đang dùng cũng chuyển được: server vẫn chạy trên nó.",
"chooseProfile": "Chọn login đã lưu…",
"noProfiles": "Chưa có login nào — lưu ở Settings → Claude config → Switch account",
"noProfiles": "Chưa có hồ sơ nào — lưu ở Cài đặt → AI tools → Hồ sơ đăng nhập",
"loadingProfiles": "Đang tải…",
"activeHere": "server đang dùng — vẫn chạy tiếp",
"nameFromProfile": "Để trống để dùng tên profile",
@@ -216,7 +217,8 @@
"users": "User dùng tài khoản này",
"noUsers": "Chưa có user nào.",
"onAccount": "đang ở {{account}}",
"editDone": "Đã lưu {{name}}"
"editDone": "Đã lưu {{name}}",
"profilesLink": "Hồ sơ trong Cài đặt"
},
"delete": {
"button": "Xoá user",
+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",
+15 -3
View File
@@ -313,9 +313,21 @@ export const SETTINGS_INDEX: {
to: "?tab=settings",
},
{
section: "agents/claude-code",
titleKey: "ccConfig:profiles.title",
keywords: ["account", "login", "quota", "switch"],
section: "agents",
titleKey: "toolProfiles.title",
keywords: [
"profile",
"account",
"login",
"quota",
"switch",
"codex",
"omp",
"pool",
"hồ sơ",
"tài khoản",
],
to: "#tool-profiles",
},
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
{ section: "agents/codex", titleKey: "codexHooks.title" },
+11 -493
View File
@@ -10,8 +10,10 @@
* concurrent-write races with the live CLI. Its Overview also holds the
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
* The toolbar's Switch profile button links to Settings → AI tools → Profiles
* (`ToolProfilesSettings`), where every AI tool's login profiles live.
* Every card with action buttons (plugins, marketplaces, MCP servers, memory
* files, auth profiles, and the `MdItemList` artifacts) also opens a
* files, and the `MdItemList` artifacts) also opens a
* right-click menu that repeats those buttons — same handlers, same
* confirm path — plus Copy name/path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -61,7 +63,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { useSettingsHref } from "../lib/settingsLink";
import { eventBus } from "../lib/eventBus";
import {
Boxes,
@@ -89,7 +92,6 @@ import {
Trash2,
Plus,
Save,
ShieldAlert,
Lock,
History,
Terminal,
@@ -153,10 +155,6 @@ import type {
CcPluginItem,
CcPluginsResponse,
CcUpdateAllBatch,
CcProfile,
CcAccountIdentity,
CcProfileUsage,
CcUsageWindow,
CcScope,
CcSettingsSource,
CcStatusline,
@@ -450,7 +448,6 @@ export function CcConfig() {
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
const setToast = showToast;
const [backupsOpen, setBackupsOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = useState(false);
const [projectMemoryOpen, setProjectMemoryOpen] = useState(false);
const scopeRef = useRef(scope);
@@ -682,7 +679,6 @@ export function CcConfig() {
setAgenthubKey((k) => k + 1);
}}
onOpenBackups={() => setBackupsOpen(true)}
onOpenProfiles={() => setProfilesOpen(true)}
wsConnected={wsConnected}
/>
@@ -811,7 +807,6 @@ export function CcConfig() {
/>
)}
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
</div>
);
}
@@ -823,20 +818,13 @@ interface HeaderProps {
lastUpdated: Date | null;
onRefresh: () => void;
onOpenBackups: () => void;
onOpenProfiles: () => void;
wsConnected: boolean;
}
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
function Header({
loading,
lastUpdated,
onRefresh,
onOpenBackups,
onOpenProfiles,
wsConnected,
}: HeaderProps) {
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
const { t } = useTranslation("ccConfig");
const settingsHref = useSettingsHref();
const { t: tCommon } = useTranslation("common");
const formatted = lastUpdated
? lastUpdated.toLocaleTimeString(undefined, {
@@ -863,14 +851,14 @@ function Header({
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<button
type="button"
onClick={onOpenProfiles}
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
<Link
to={settingsHref("agents#tool-profiles")}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</button>
</Link>
<button
type="button"
onClick={onOpenBackups}
@@ -4737,476 +4725,6 @@ function CommandSnippet({ command, label }: { command: string; label?: string })
);
}
// ── Auth profiles modal ────────────────────────────────────────────────
//
// 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 — that is the whole point of the feature.
//
// 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" — hence a save button on the live account rather than an add-account
// flow. There is no name field: the server derives the identifier, and the user
// edits the display label in place by clicking it. That keeps the one string
// the user types free of filename rules, so spaces and diacritics are fine.
// The save button only appears while the live account is unsaved, because one
// account is one profile — a second copy would just report itself active too.
function ProfilesModal({
onClose,
onToast,
}: {
onClose: () => void;
onToast: (t: NonNullable<Toast>) => void;
}) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
const [active, setActive] = useState<CcAccountIdentity | null>(null);
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** `name` of the profile whose label is being edited inline, plus its draft. */
const [renaming, setRenaming] = useState<string | null>(null);
const [draft, setDraft] = useState("");
/** Quota per profile, keyed by `name`, fetched for every profile once the
* list arrives — the whole point of the panel is comparing accounts, and one
* button per row made the user do that by hand. The cost is bounded: the
* live account is read from the CLI and never refreshed, and any other
* profile is refreshed only when its token has actually expired, which
* leaves it valid for hours of further opens. */
const [usage, setUsage] = useState<Record<string, CcProfileUsage>>({});
const [usageError, setUsageError] = useState<Record<string, string>>({});
/** Per profile, so one slow account does not freeze the other rows. */
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const ctx = useContextMenu<CcProfile>();
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");
}
}, []);
useEffect(() => {
void load();
}, [load]);
const panelRef = useRef<HTMLDivElement>(null);
const menuOpen = useRef(false);
menuOpen.current = !!ctx.menu;
// An open right-click menu takes this Escape for itself.
useDialog(true, panelRef, () => {
if (!menuOpen.current) onClose();
});
/** Run one profile mutation, then refresh the list. */
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => {
setBusy(key);
try {
await fn();
onToast({ kind: "success", message: success });
await load();
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : "unknown error",
});
} finally {
setBusy(null);
}
},
[load, onToast]
);
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, as soon as there is a list to ask about. Once per
// profile per opening: 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 — a
* redundant copy has to be removable, or an accidental duplicate could
* never be cleaned up. */
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
const startRename = (p: CcProfile) => {
setDraft(p.label);
setRenaming(p.name);
};
const switchTo = (p: CcProfile) =>
void run(
`switch:${p.name}`,
() => api.ccConfig.activateProfile(p.name),
t("profiles.switchSuccess", { name: p.label })
);
// A saved profile holds that account's OAuth credentials — deleting it
// cannot be undone from here, so it asks first.
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 (
<div
className="fixed inset-0 bg-surface-0/70 z-50 flex items-center justify-center p-4"
onClick={onClose}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="cc-profiles-title"
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<UserCog className="w-4 h-4 text-fg-muted" />
<span id="cc-profiles-title" className="text-sm font-medium text-fg-primary flex-1">
{t("profiles.title")}
</span>
<button
type="button"
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border space-y-2">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
{mismatchEmail && (
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
)}
</div>
<div className="overflow-auto p-4 space-y-3">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("profiles.activeLabel")}
</div>
{active ? (
<>
<div className="text-sm text-fg-primary">
{active.email || active.displayName || active.accountUuid}
</div>
<IdentityLine identity={active} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
}
className="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"
>
<Save className="w-3 h-3" />
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
</button>
</div>
)}
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
{t("profiles.empty")}
</div>
)}
{items?.map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
className="rounded-lg border border-border bg-surface-2 px-3 py-2.5"
>
<div className="flex items-center gap-2 flex-wrap">
{renaming === p.name ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => setRenaming(null)}
onKeyDown={(e) => {
if (e.key === "Escape") setRenaming(null);
if (e.key !== "Enter") return;
const next = draft.trim();
setRenaming(null);
if (!next || next === p.label) return;
void run(
`rename:${p.name}`,
() => api.ccConfig.renameProfile(p.name, next),
t("profiles.renameSuccess", { name: 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"
/>
) : (
<button
onClick={() => startRename(p)}
title={t("profiles.renameHint")}
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
>
{p.label}
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
</button>
)}
{p.active && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
{t("profiles.activeBadge")}
</span>
)}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button
disabled={busy !== null}
onClick={() => switchTo(p)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{deletable(p) && (
<button
type="button"
disabled={busy !== null}
onClick={() => void remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
<ProfileUsageLine
usage={usage[p.name]}
error={usageError[p.name]}
busy={!!usageBusy[p.name]}
disabled={busy !== null || !!usageBusy[p.name]}
onCheck={() => void checkUsage(p.name)}
/>
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
<MenuItem onSelect={pick(startRename)}>{t("profiles.rename")}</MenuItem>
{!ctx.menu.item.active && (
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
{t("profiles.switchButton")}
</MenuItem>
)}
<MenuItem
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
onSelect={pick((p) => void checkUsage(p.name))}
>
{t("profiles.usageCheck")}
</MenuItem>
{deletable(ctx.menu.item) && (
<>
<MenuSeparator />
<MenuItem
danger
disabled={busy !== null}
testId="profile-menu-delete"
onSelect={pick((p) => void remove(p))}
>
{t("profiles.deleteButton")}
</MenuItem>
</>
)}
</ContextMenu>
)}
<div>
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
<CommandSnippet command="claude auth login" />
</div>
</div>
</div>
</div>
);
}
/**
* One profile's quota: a button until asked, then the two windows Anthropic
* reports — the 5-hour session allowance and the 7-day one.
*
* Deliberately not fetched with the profile list. Reading a saved profile's
* usage means refreshing its OAuth token, and that rotates the credentials on
* disk (see `server/lib/cc-usage.js`), so it happens once per click and never
* on a render or 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 (
<div className="mt-1 flex items-center gap-2 text-[10px]">
<span className="text-status-danger">{error}</span>
<button
onClick={onCheck}
disabled={disabled}
className="text-fg-muted hover:text-accent disabled:opacity-50"
>
{t("profiles.usageRetry")}
</button>
</div>
);
}
if (!usage) {
return (
<button
onClick={onCheck}
disabled={disabled}
title={t("profiles.usageHint")}
className="mt-1 text-[10px] text-fg-muted hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageCheck")}
</button>
);
}
return (
<div className="mt-1 flex items-center gap-2 text-[10px] text-fg-muted">
<span title={resetTitle(usage.fiveHour)}>
{t("profiles.usageFiveHour")}{" "}
<span className="text-fg-secondary">{pct(usage.fiveHour)}</span>
</span>
<span title={resetTitle(usage.sevenDay)}>
{t("profiles.usageSevenDay")}{" "}
<span className="text-fg-secondary">{pct(usage.sevenDay)}</span>
</span>
<button
onClick={onCheck}
disabled={disabled}
className="hover:text-accent disabled:opacity-50"
>
{busy ? t("profiles.usageChecking") : t("profiles.usageRefresh")}
</button>
</div>
);
}
/** 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 <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
// ── Backups modal ──────────────────────────────────────────────────────
function BackupsModal({ onClose }: { onClose: () => void }) {
+3
View File
@@ -69,6 +69,7 @@ import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -296,6 +297,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<AgentInstructionsSettings />
<SessionDefaultsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* Every AI tool's login profiles, linked to the account pool. */}
<ToolProfilesSettings />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
{/* The host's omp login for members: server mode + S3, an admin's. */}
@@ -1,8 +1,8 @@
/**
* @file Covers the right-click menus on the Claude Code config page: a menu
* item runs the SAME action as the card's button — a plugin's uninstall still
* takes two clicks before it reaches the API, and a profile's delete calls the
* same endpoint the trash button does.
* takes two clicks before it reaches the API. (The profiles' menu moved with
* them to Settings → AI tools: ToolProfilesSettings.test.tsx.)
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -10,7 +10,6 @@ import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { settleConfirm } from "../../lib/confirm";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
@@ -124,41 +123,4 @@ describe("CcConfig — right-click menus", () => {
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "uninstall");
expect(screen.queryByTestId("plugin-menu")).toBeNull();
});
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
await user.click(screen.getByTestId("profile-menu-delete"));
await settle();
// A saved login is never deleted on one click: it asks first.
expect(ccConfigMock.deleteProfile).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
});
it("offers no delete for the only saved copy of the live account", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await user.click(screen.getByRole("button", { name: /profile/i }));
await settle();
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
});
});
@@ -1,151 +0,0 @@
/**
* @file Covers the quota lines in the Claude Code auth-profile modal: opening
* it asks for EVERY saved account's quota, not one row at a time.
*
* The one-at-a-time version made the user click through their own accounts to
* answer the only question the panel exists for — which account still has room.
* The other half is that each row fails on its own: an account whose saved
* login has been superseded must not stop its neighbours from reporting.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
const window = (utilization: number) => ({
utilization,
resetsAt: "2026-09-18T08:00:00Z",
});
return {
window,
overview: r({
roots: {
claudeHome: "/home/.claude",
projectClaudeDir: "/repo/.claude",
projectRoot: "/repo",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 0,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: "", manifestExists: false, plugins: [] }),
marketplaces: r({ marketplaces: [] }),
mcp: r({ mcp: [] }),
hooks: r({ hooks: [] }),
settings: r({ sources: [] }),
keybindings: r({ groups: [] }),
statusline: r({}),
hookScripts: r({ items: [] }),
backups: r({ items: [] }),
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
// The modal's own list: two saved accounts, one of them live.
profiles: r({
items: [
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
],
active: { accountUuid: "u1", email: "a@b.c" },
}),
profileUsage: vi.fn().mockImplementation(async (name: string) => {
if (name === "spare") {
throw new Error("this profile's saved login has been superseded");
}
return {
name,
refreshed: false,
fiveHour: window(8),
sevenDay: window(70),
checkedAt: "2026-09-17T16:00:00Z",
};
}),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CcConfig } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function openProfiles() {
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
await userEvent.click(screen.getByRole("button", { name: /profile/i }));
await settle();
await settle();
}
afterEach(() => vi.clearAllMocks());
describe("CcConfig — auth profile quotas", () => {
it("asks for every saved account's quota when the modal opens", async () => {
await openProfiles();
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("work");
expect(ccConfigMock.profileUsage).toHaveBeenCalledWith("spare");
expect(await screen.findByText("8%")).toBeInTheDocument();
expect(await screen.findByText("70%")).toBeInTheDocument();
});
it("reports the account that failed without costing the others their numbers", async () => {
await openProfiles();
expect(await screen.findByText(/saved login has been superseded/i)).toBeInTheDocument();
expect(screen.getByText("8%")).toBeInTheDocument();
});
it("asks once per profile, however many times the panel re-renders", async () => {
await openProfiles();
await settle();
await settle();
const asked = ccConfigMock.profileUsage.mock.calls.filter((c) => c[0] === "work");
expect(asked).toHaveLength(1);
});
});
@@ -86,6 +86,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
// AI tools → Profiles.
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
},
};
});