Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 fa74cce3fc chore: v2.8.17 — every AI tool's login profiles in one Settings section, linked to the account pool 2026-10-22 11:11:43 +07:00
nntrivi2001 3113479cd2 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.
2026-10-22 11:11:38 +07:00
31 changed files with 2163 additions and 756 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.16 (2026-10-22) — Oh My Pi login sharing hands each pane a key already listed with the gateway (omp hung on a key minted at start)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.17 (2026-10-22) — every AI tool's login profiles (Claude, Codex, Oh My Pi) in one Settings → AI tools → Profiles section, linked to the account pool._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -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 })) },
},
};
});
@@ -796,9 +796,9 @@ exports[`screen snapshots > Claude Config 1`] = `
<div
class="flex items-center gap-2 justify-end flex-wrap"
>
<button
<a
class="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"
type="button"
href="/settings/agents#tool-profiles"
>
<svg
class="lucide lucide-user-cog w-3.5 h-3.5"
@@ -851,7 +851,7 @@ exports[`screen snapshots > Claude Config 1`] = `
/>
</svg>
Switch profile
</button>
</a>
<button
class="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"
type="button"
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.16 (rebuild client to match)
UI build v2.8.17 (rebuild client to match)
</span>
</p>
</div>
+13 -1
View File
@@ -91,7 +91,7 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Codex `subscriptionType` is the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
@@ -2938,6 +2938,18 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
### AI tool login profiles
```
GET /api/tool-profiles
POST /api/tool-profiles/:tool Body: { name? }
PATCH /api/tool-profiles/:tool Body: { name, label }
POST /api/tool-profiles/:tool/activate Body: { name }
DELETE /api/tool-profiles/:tool Body: { name }
```
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
### AI Proxy profiles
```
+6 -3
View File
@@ -388,7 +388,7 @@ In server mode an admin can do the same on the **Users** page, in the
**Account pool** card: **Add account** and the bin icon to remove one. A Claude
account comes from one of three sources:
- **A saved login**: a profile from Settings → Claude config → Switch account
- **A saved login**: a profile from Settings → AI tools → Profiles
(log in with `claude auth login`, then save it). It is MOVED into the pool, so
the profile leaves the switcher. The account this server's own Claude Code is
logged into can be moved in too. It moves with the login the CLI holds right
@@ -401,8 +401,11 @@ account comes from one of three sources:
using that login where you copied it from.
- **A `claude setup-token` token** (`sk-ant-…`), as with `agenthub accounts add`.
A Codex account is an OpenAI API key, or the whole `~/.codex/auth.json` after
`codex login`.
A Codex account is an OpenAI API key, the whole `~/.codex/auth.json` after
`codex login`, or a saved Codex profile from Settings → AI tools → Profiles
(moved in like a Claude one). Each Claude/Codex profile there has a **Move to
pool** link that opens this dialog on it, and the card links back with
**Profiles in Settings**.
A **login** (the first two sources) works like a Codex ChatGPT login. Claude
Code reads it from no env var, so before a member's pane starts, AgentHub writes
+1 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.14
version: 2.8.17
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.16",
"version": "2.8.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.16",
"version": "2.8.17",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.16",
"version": "2.8.17",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -338,6 +338,42 @@ describe("moving a saved profile in from the Users page", () => {
pool.removeAccount("work");
});
it("moves a saved Codex profile in, and lists Codex profiles without a token", async () => {
const dir = path.join(TMP, ".claude", "agenthub-profiles", "codex");
fs.mkdirSync(dir, { recursive: true });
const idToken = `x.${Buffer.from(JSON.stringify({ email: "cx@example.com" })).toString("base64url")}.s`;
fs.writeFileSync(
path.join(dir, "cx.json"),
JSON.stringify({
name: "cx",
label: "Codex work",
secret: {
tokens: {
id_token: idToken,
access_token: "at-cx",
refresh_token: "rt-cx",
account_id: "a-cx",
},
},
})
);
const list = await call("GET", "/api/accounts/profiles?tool=codex");
assert.deepEqual(
list.body.profiles.map((p) => [p.name, p.label, p.email]),
[["cx", "Codex work", "cx@example.com"]]
);
assert.ok(!JSON.stringify(list.body).includes("rt-cx"));
const moved = await call("POST", "/api/accounts", {
tool: "codex",
profile: "cx",
currentPassword: "admin-pass-1",
});
assert.equal(moved.status, 201);
assert.equal(moved.body.account.kind, "chatgpt");
assert.equal(fs.existsSync(path.join(dir, "cx.json")), false, "the pool is its one holder");
require("../lib/codex-pool").removeAccount("cx");
});
it("takes a pasted .credentials.json once Anthropic names its account", async () => {
const pasted = oauth("p");
owners[pasted.accessToken] = { uuid: "uuid-pasted", email: "p@example.com" };
+257
View File
@@ -0,0 +1,257 @@
/**
* @file Tests for the non-Claude login profiles (tool-profiles.js): saving the
* live Codex login, switching between logins, the guards that keep a switch from
* losing the only copy of one, and Oh My Pi's credential identity.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tool-profiles-"));
process.env.CLAUDE_HOME = path.join(ROOT, ".claude");
process.env.CODEX_HOME = path.join(ROOT, ".codex");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const profiles = require("../lib/tool-profiles");
const AUTH = path.join(process.env.CODEX_HOME, "auth.json");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
const jwt = (claims) => `x.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.sig`;
/** Leave a ChatGPT login on disk the way `codex login` would. */
function login(account, email, refresh = "rt-1") {
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
fs.writeFileSync(
AUTH,
JSON.stringify({
OPENAI_API_KEY: null,
tokens: {
id_token: jwt({
email,
"https://api.openai.com/auth": { chatgpt_account_id: account, chatgpt_plan_type: "plus" },
}),
access_token: "at",
refresh_token: refresh,
account_id: account,
},
last_refresh: "2026-10-01T00:00:00Z",
})
);
}
beforeEach(() => {
fs.rmSync(process.env.CLAUDE_HOME, { recursive: true, force: true });
fs.rmSync(process.env.CODEX_HOME, { recursive: true, force: true });
});
describe("tool-profiles: codex", () => {
it("reports the live login and saves it under a name derived from it", () => {
login("acct-a", "alice@example.com");
assert.deepEqual(profiles.listProfiles("codex").active, {
kind: "chatgpt",
email: "alice@example.com",
detail: "plus",
});
const r = profiles.saveProfile("codex");
assert.equal(r.name, "alice");
const { items } = profiles.listProfiles("codex");
assert.equal(items.length, 1);
assert.equal(items[0].active, true);
assert.equal(items[0].secret, undefined, "never a token in a listing");
const mode = fs.statSync(path.join(profiles.profilesDir("codex"), "alice.json")).mode & 0o777;
assert.equal(mode, 0o600);
});
it("re-saving refreshes the login's own profile; a second name is EDUPLICATE", () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-a", "alice@example.com", "rt-2");
assert.equal(profiles.saveProfile("codex").name, "alice");
assert.equal(profiles.readProfile("codex", "alice").secret.tokens.refresh_token, "rt-2");
assert.throws(() => profiles.saveProfile("codex", "other"), { code: "EDUPLICATE" });
});
it("switches, saving the outgoing login back first", () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-b", "bob@example.com");
profiles.saveProfile("codex");
// Alice's tokens rotated while she was live? Bob is live now; switch to Alice.
const r = profiles.activateProfile("codex", "alice");
assert.equal(r.savedOutgoing, "bob");
const live = JSON.parse(fs.readFileSync(AUTH, "utf8"));
assert.equal(live.tokens.account_id, "acct-a");
const { items } = profiles.listProfiles("codex");
assert.deepEqual(
items.map((p) => [p.name, p.active]),
[
["alice", true],
["bob", false],
]
);
});
it("refuses to switch away from an unsaved login", () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-c", "carol@example.com");
assert.throws(() => profiles.activateProfile("codex", "alice"), { code: "EUNSAVED" });
});
it("will not delete the live login's profile, deletes another", () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-b", "bob@example.com");
profiles.saveProfile("codex");
assert.throws(() => profiles.deleteProfile("codex", "bob"), { code: "EACTIVE" });
profiles.deleteProfile("codex", "alice");
assert.deepEqual(
profiles.listProfiles("codex").items.map((p) => p.name),
["bob"]
);
});
it("renames the label only, and refuses a taken one", () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-b", "bob@example.com");
profiles.saveProfile("codex");
profiles.renameProfile("codex", "alice", "Công việc");
assert.equal(
profiles.listProfiles("codex").items.find((p) => p.name === "alice").label,
"Công việc"
);
assert.throws(() => profiles.renameProfile("codex", "bob", "công việc"), {
code: "ELABELTAKEN",
});
});
it("an API-key login is identified by its key, shown by its last four", () => {
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
fs.writeFileSync(AUTH, JSON.stringify({ OPENAI_API_KEY: "sk-test-abcdefghij1234" }));
const r = profiles.saveProfile("codex");
assert.equal(r.kind, "apikey");
assert.equal(r.detail, "…1234");
});
it("saving with no login is ENOAUTH", () => {
assert.throws(() => profiles.saveProfile("codex"), { code: "ENOAUTH" });
});
});
describe("tool-profiles: tools", () => {
it("lists antigravity as unsupported and refuses it", () => {
const agy = profiles.tools().find((t) => t.tool === "antigravity");
assert.equal(agy.supported, false);
assert.throws(() => profiles.listProfiles("antigravity"), { code: "EUNSUPPORTED" });
assert.throws(() => profiles.listProfiles("nope"), { code: "EBADTOOL" });
});
it("an omp credential is identified by who it is, never by its rotating token", () => {
const a = { provider: "anthropic", credential: { type: "oauth", access: "1", email: "a@x" } };
const b = { provider: "anthropic", credential: { type: "oauth", access: "2", email: "a@x" } };
assert.equal(profiles.ompCredentialId(a), profiles.ompCredentialId(b));
assert.equal(profiles.ompCredentialId(a), "anthropic:a@x");
});
it("a switch hands the adapter the outgoing login so it can restore it", () => {
let disk = { who: "a" };
const writes = [];
profiles.__setAdapter("fake", {
label: "Fake",
read: () => disk,
write: (secret, outgoing) => {
writes.push([secret.who, outgoing && outgoing.who]);
disk = secret;
return [];
},
identify: (s) => ({ key: s.who, kind: "x", email: null, detail: null }),
});
profiles.saveProfile("fake", "a");
disk = { who: "b" };
profiles.saveProfile("fake", "b");
profiles.activateProfile("fake", "a");
assert.deepEqual(writes, [["a", "b"]]);
});
});
describe("tool-profiles: omp", () => {
it("switches through a cleared store + `omp auth import -`, never by argv", () => {
const omp = path.join(ROOT, ".omp");
const db = path.join(omp, "agent", "agent.db");
const state = path.join(ROOT, "omp-state.json");
const argv = path.join(ROOT, "omp-argv.log");
fs.mkdirSync(path.dirname(db), { recursive: true });
const { DatabaseSync } = require("node:sqlite");
const d = new DatabaseSync(db);
d.exec(`CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, data TEXT);
CREATE TABLE auth_credential_blocks (credential_id INTEGER);
CREATE TABLE auth_credential_refresh_leases (credential_id INTEGER);
INSERT INTO auth_credentials (provider, data) VALUES ('anthropic', '{}');`);
d.close();
// A fake omp: export prints the state file, import REPLACES it from stdin
// only when the store was cleared first (as the real one merges).
const bin = path.join(ROOT, "omp");
fs.writeFileSync(
bin,
`#!/usr/bin/env node
const fs = require("fs");
fs.appendFileSync(${JSON.stringify(argv)}, process.argv.slice(2).join(" ") + "\\n");
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(${JSON.stringify(db)});
const rows = db.prepare("SELECT COUNT(*) AS n FROM auth_credentials").get().n;
if (process.argv[3] === "export") {
process.stdout.write(fs.readFileSync(${JSON.stringify(state)}, "utf8"));
} else {
const input = JSON.parse(fs.readFileSync(0, "utf8"));
if (rows) { process.stderr.write("merged into a non-empty store"); process.exit(1); }
fs.writeFileSync(${JSON.stringify(state)}, JSON.stringify(input));
}
`,
{ mode: 0o755 }
);
const set = (email, token) =>
fs.writeFileSync(
state,
JSON.stringify({
version: 1,
credentials: [
{ provider: "anthropic", credential: { type: "oauth", access: token, email } },
],
})
);
process.env.AGENTHUB_OMP_DIR = omp;
process.env.AGENTHUB_OMP_BIN = bin;
try {
set("a@x", "secret-a");
assert.equal(profiles.saveProfile("omp").name, "a");
set("b@x", "secret-b");
profiles.saveProfile("omp");
const r = profiles.activateProfile("omp", "a");
assert.equal(r.savedOutgoing, "b");
assert.equal(
JSON.parse(fs.readFileSync(state, "utf8")).credentials[0].credential.email,
"a@x"
);
assert.ok(!fs.readFileSync(argv, "utf8").includes("secret"), "secrets only on stdin");
const { items, active } = profiles.listProfiles("omp");
assert.equal(active.email, "a@x");
assert.deepEqual(
items.map((p) => [p.name, p.active, p.detail]),
[
["a", true, "anthropic"],
["b", false, "anthropic"],
]
);
} finally {
delete process.env.AGENTHUB_OMP_DIR;
delete process.env.AGENTHUB_OMP_BIN;
}
});
});
+2
View File
@@ -142,6 +142,7 @@ const snapshotsRouter = require("./routes/snapshots");
const authRouter = require("./routes/auth");
const meRouter = require("./routes/me");
const accountsRouter = require("./routes/accounts");
const toolProfilesRouter = require("./routes/tool-profiles");
const usersRouter = require("./routes/users");
const hostRouter = require("./routes/host");
const { auditGuard } = require("./lib/audit");
@@ -209,6 +210,7 @@ function createApp() {
app.use("/api/import", importRouter);
app.use("/api/updates", updatesRouter);
app.use("/api/cc-config", ccConfigRouter);
app.use("/api/tool-profiles", toolProfilesRouter);
app.use("/api/codex-config", codexConfigRouter);
// `/api/ai-routers` is the pre-rename path, kept for a client older than it.
app.use(["/api/ai-proxies", "/api/ai-routers"], aiProxiesRouter);
+3
View File
@@ -63,6 +63,8 @@ const ADMIN_WRITE_PREFIXES = [
"/updates",
"/cc-config",
"/codex-config",
// The AI tools' login profiles: switching one changes the host's own login.
"/tool-profiles",
"/git-credentials",
"/ai-tools",
"/import",
@@ -88,6 +90,7 @@ const ADMIN_WRITE_PREFIXES = [
const STAFF_ONLY_PREFIXES = [
"/cc-config",
"/codex-config",
"/tool-profiles",
"/git-credentials",
"/remote-sources",
"/webhooks",
+454
View File
@@ -0,0 +1,454 @@
/**
* @file tool-profiles.js
* @description Named login profiles for the AI tools other than Claude Code —
* the same idea as cc-profiles.js (which stays Claude's, with its Keychain and
* `.claude.json` rules): snapshot the login a tool's CLI holds right now, and
* swap it back in later, touching nothing but that login.
*
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
* key). Config, sessions, skills and history stay where they are.
* - `omp`: every credential Oh My Pi stores, read with `omp auth export`
* and put back with `omp auth import -` (secrets on stdin, never argv).
* Import MERGES, so a switch first empties omp's own credential tables in
* `~/.omp/agent/agent.db`; a failed import puts the outgoing set back.
* - `antigravity`: not switchable — agy keeps its login in the OS keyring
* (Secret Service), which AgentHub does not read or write.
*
* Profiles live one JSON file each under
* `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (dir 0700, files 0600). They hold
* live tokens, so `listProfiles` returns identity metadata only; the
* token-bearing `readProfile` is for the account pool's move-in, never a route.
* The rules are cc-profiles.js's: one account is one profile (`EDUPLICATE`), a
* switch saves the outgoing login back into its own profile first and refuses
* one that was never saved (`EUNSAVED`), and the only saved copy of the live
* login is not deletable (`EACTIVE`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const crypto = require("node:crypto");
const cp = require("node:child_process");
const { getClaudeHome } = require("./claude-home");
const { getCodexHome } = require("./codex-home");
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
const LABEL_MAX = 60;
const err = (code, message) => Object.assign(new Error(message), { code });
const sha = (text) => crypto.createHash("sha256").update(String(text)).digest("hex");
/** Write JSON at 0600 through a temp file, so a crash never truncates the original. */
function writeJson(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.agenthub-tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
fs.renameSync(tmp, file);
}
function readJson(file) {
let raw;
try {
raw = fs.readFileSync(file, "utf8");
} catch {
return null;
}
try {
return JSON.parse(raw);
} catch {
throw err("EBADJSON", `${file} is not valid JSON - fix it by hand first`);
}
}
// ── Codex ──────────────────────────────────────────────────────────────
const codexAuthFile = () => path.join(getCodexHome(), "auth.json");
/** The id_token's claims, or null. Not signature-checked: a label, not a proof. */
function jwtClaims(token) {
try {
return JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString("utf8"));
} catch {
return null;
}
}
const codex = {
label: "Codex",
read() {
const auth = readJson(codexAuthFile());
return auth && typeof auth === "object" && (auth.tokens || auth.OPENAI_API_KEY) ? auth : null;
},
write(secret) {
writeJson(codexAuthFile(), secret);
return [codexAuthFile()];
},
identify(auth) {
if (auth.tokens && auth.tokens.id_token) {
const c = jwtClaims(auth.tokens.id_token) || {};
const claim = c["https://api.openai.com/auth"] || {};
const account = auth.tokens.account_id || claim.chatgpt_account_id || null;
return {
key: `chatgpt:${account || c.email || sha(auth.tokens.id_token).slice(0, 16)}`,
kind: "chatgpt",
email: c.email || null,
detail: claim.chatgpt_plan_type || null,
};
}
const key = String(auth.OPENAI_API_KEY || "");
return {
key: `apikey:${sha(key).slice(0, 16)}`,
kind: "apikey",
email: null,
detail: key ? `…${key.slice(-4)}` : null,
};
},
};
// ── Oh My Pi ───────────────────────────────────────────────────────────
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
function ompBin() {
const bin = process.env.AGENTHUB_OMP_BIN || require("./ai-tools").detect("omp").path;
if (!bin) throw err("ENOTINSTALLED", "Oh My Pi (omp) is not installed on this machine");
return bin;
}
function ompRun(args, input) {
return cp.execFileSync(ompBin(), args, {
encoding: "utf8",
input,
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
timeout: 20000,
maxBuffer: 8 << 20,
windowsHide: true,
});
}
/** One credential's stable part: who it is, never the rotating token. */
function ompCredentialId(c) {
const cred = (c && c.credential) || {};
const who =
cred.email ||
cred.accountId ||
(cred.type === "oauth" ? "oauth" : sha(JSON.stringify(cred)).slice(0, 12));
return `${c.provider}:${who}`;
}
/**
* Empty omp's credential store: the credentials and what hangs off them (rate
* blocks, refresh leases). omp's own triggers bump `auth_change_revision`, so
* a running omp re-reads. ponytail: this writes omp's schema directly (it has
* no logout command); a schema change there surfaces as an error here, and the
* caller puts the outgoing set back.
*/
function ompClear() {
const file = path.join(ompDir(), "agent", "agent.db");
if (!fs.existsSync(file)) return;
const { DatabaseSync } = require("node:sqlite");
const db = new DatabaseSync(file);
try {
db.exec("PRAGMA busy_timeout = 5000");
db.exec("BEGIN IMMEDIATE");
try {
for (const t of [
"auth_credential_blocks",
"auth_credential_refresh_leases",
"auth_credentials",
]) {
db.exec(`DELETE FROM ${t}`);
}
db.exec("COMMIT");
} catch (e) {
db.exec("ROLLBACK");
throw e;
}
} finally {
db.close();
}
}
const omp = {
label: "Oh My Pi",
read() {
let out;
try {
out = JSON.parse(ompRun(["auth", "export"]));
} catch (e) {
if (e.code === "ENOTINSTALLED") throw e;
throw err("EOMP", `omp auth export failed: ${(e.stderr || e.message || "").trim()}`);
}
return out && Array.isArray(out.credentials) && out.credentials.length > 0 ? out : null;
},
write(secret, outgoing) {
ompClear();
try {
ompRun(["auth", "import", "-"], JSON.stringify(secret));
} catch (e) {
if (outgoing) {
try {
ompRun(["auth", "import", "-"], JSON.stringify(outgoing));
} catch {
/* the outgoing set is still in its profile file */
}
}
throw err("EOMP", `omp auth import failed: ${(e.stderr || e.message || "").trim()}`);
}
return [path.join(ompDir(), "agent", "agent.db")];
},
identify(exp) {
const ids = [...new Set(exp.credentials.map(ompCredentialId))].sort();
const email = exp.credentials.map((c) => c.credential && c.credential.email).find(Boolean);
return {
key: ids.join("|"),
kind: "credentials",
email: email || null,
detail: [...new Set(exp.credentials.map((c) => c.provider))].join(", ") || null,
};
},
};
let ADAPTERS = { codex, omp };
/** Tools listed in the Profiles panel that cannot be switched, and why. */
const UNSUPPORTED = {
antigravity:
"Antigravity keeps its login in the system keyring (Secret Service), which AgentHub does not read or write. Sign in or out inside agy itself.",
};
// ── The store ──────────────────────────────────────────────────────────
function adapter(tool) {
const a = ADAPTERS[tool];
if (!a) {
throw err(
UNSUPPORTED[tool] ? "EUNSUPPORTED" : "EBADTOOL",
UNSUPPORTED[tool] || `no profiles for tool "${tool}"`
);
}
return a;
}
const profilesDir = (tool) => path.join(getClaudeHome(), "agenthub-profiles", tool);
function profileFile(tool, name) {
adapter(tool);
if (typeof name !== "string" || !NAME_RE.test(name)) {
throw err("EBADNAME", "name must be 1-40 chars of letters, digits, dot, dash or underscore");
}
return path.join(profilesDir(tool), `${name}.json`);
}
function normalizeLabel(label) {
const clean = typeof label === "string" ? label.trim() : "";
if (!clean || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
throw err("EBADLABEL", `label must be 1-${LABEL_MAX} characters and no control characters`);
}
return clean;
}
function slugify(text) {
const base = String(text || "")
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32);
return NAME_RE.test(base) ? base : "profile";
}
function freeName(tool, base) {
let name = base;
for (let n = 2; fs.existsSync(path.join(profilesDir(tool), `${name}.json`)); n += 1) {
name = `${base}-${n}`;
}
return name;
}
/** One stored profile WITH its secret, or null. Never for a route. */
function readProfile(tool, name) {
const stored = readJson(profileFile(tool, name));
return stored && typeof stored === "object" && stored.secret ? stored : null;
}
/** Token-free view of the live login, or null when the tool holds none. */
function liveIdentity(tool) {
const a = adapter(tool);
const live = a.read();
return live ? { live, identity: a.identify(live) } : { live: null, identity: null };
}
const publicIdentity = (id) => (id ? { kind: id.kind, email: id.email, detail: id.detail } : null);
/**
* Every stored profile of `tool` plus the login that is live right now.
* @returns `{ tool, items, active }` — `items[].active` marks the profile of
* the live login. Secrets and identity keys are never included.
*/
function listProfiles(tool) {
const { identity: live } = liveIdentity(tool);
let names = [];
try {
names = fs
.readdirSync(profilesDir(tool))
.filter((f) => f.endsWith(".json"))
.map((f) => f.slice(0, -5));
} catch {
/* none saved yet */
}
const items = [];
for (const name of names) {
if (!NAME_RE.test(name)) continue;
let stored;
try {
stored = readProfile(tool, name);
} catch {
continue;
}
if (!stored) continue;
const id = ADAPTERS[tool].identify(stored.secret);
items.push({
name,
label: typeof stored.label === "string" && stored.label.trim() ? stored.label.trim() : name,
savedAt: stored.savedAt || null,
active: Boolean(live) && id.key === live.key,
...publicIdentity(id),
});
}
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
return { tool, items, active: publicIdentity(live) };
}
/** The saved profile holding the same login as `identity`, other than `except`. */
function holderOf(tool, key, except = null) {
for (const p of listProfiles(tool).items) {
if (p.name === except) continue;
const stored = readProfile(tool, p.name);
if (stored && ADAPTERS[tool].identify(stored.secret).key === key) return p;
}
return null;
}
/**
* Snapshot the login `tool` holds right now. Re-saving into the login's own
* profile refreshes it; a second copy under another name is `EDUPLICATE`.
*/
function saveProfile(tool, name) {
const { live, identity } = liveIdentity(tool);
if (!live) {
throw err("ENOAUTH", `${ADAPTERS[tool].label} holds no login on this machine - sign in first`);
}
const explicit = name !== undefined && name !== null;
const readable =
(identity.email || "").split("@")[0] ||
(identity.detail ? `${identity.kind}-${identity.detail}` : identity.kind);
let target;
if (explicit) {
profileFile(tool, name);
target = name;
} else {
const own = holderOf(tool, identity.key);
target = own ? own.name : freeName(tool, slugify(readable));
}
const clash = holderOf(tool, identity.key, target);
if (clash) {
throw err(
"EDUPLICATE",
`this login is already saved as "${clash.label}" (${clash.name}) - rename that profile instead of saving a second copy of it`
);
}
const existing = readProfile(tool, target);
const label = (existing && existing.label) || (explicit ? target : readable);
writeJson(profileFile(tool, target), {
name: target,
label,
savedAt: Date.now(),
secret: live,
});
return { ok: true, name: target, label, ...publicIdentity(identity) };
}
function renameProfile(tool, name, label) {
const stored = readProfile(tool, name);
if (!stored) throw err("ENOTFOUND", `no profile named "${name}"`);
const clean = normalizeLabel(label);
const taken = listProfiles(tool).items.find(
(p) => p.name !== name && p.label.toLowerCase() === clean.toLowerCase()
);
if (taken) throw err("ELABELTAKEN", `another profile is already called "${taken.label}"`);
writeJson(profileFile(tool, name), { ...stored, label: clean });
return { ok: true, name, label: clean };
}
/**
* Make a stored profile the tool's live login. The outgoing login is saved back
* into its own profile first (its tokens may have rotated since), and a switch
* away from a login that was never saved is refused.
*/
function activateProfile(tool, name) {
const a = adapter(tool);
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
const { live, identity } = liveIdentity(tool);
let savedOutgoing = null;
if (live) {
const mine = holderOf(tool, identity.key);
if (!mine) {
throw err(
"EUNSAVED",
"the login in use right now is not saved as a profile - save it first, or it is lost on switch"
);
}
savedOutgoing = saveProfile(tool, mine.name).name;
}
const target = readProfile(tool, name);
const written = a.write(target.secret, live);
return { ok: true, name, savedOutgoing, written };
}
function deleteProfile(tool, name) {
const file = profileFile(tool, name);
if (!readProfile(tool, name)) throw err("ENOTFOUND", `no profile named "${name}"`);
const entry = listProfiles(tool).items.find((p) => p.name === name);
if (entry && entry.active) {
throw err(
"EACTIVE",
"cannot delete the saved copy of the login in use right now - switch away first"
);
}
fs.rmSync(file, { force: true });
return { ok: true, name };
}
/** Remove a profile whatever it holds: the pool took its login (routes/accounts.js). */
function dropProfile(tool, name) {
fs.rmSync(profileFile(tool, name), { force: true });
}
/** The tools this module covers, switchable or not, for the Profiles panel. */
function tools() {
return [
...Object.keys(ADAPTERS).map((id) => ({ tool: id, supported: true })),
...Object.entries(UNSUPPORTED).map(([id, reason]) => ({ tool: id, supported: false, reason })),
];
}
module.exports = {
tools,
listProfiles,
saveProfile,
renameProfile,
activateProfile,
deleteProfile,
// Token-bearing: for routes/accounts.js's move into the pool, never a response.
readProfile,
dropProfile,
// exported for tests
profilesDir,
ompCredentialId,
__setAdapter: (tool, a) => {
ADAPTERS = { ...ADAPTERS, [tool]: a };
},
};
+33 -3
View File
@@ -22,7 +22,9 @@
* restart (`switched`); `PUT /:name/users` (`{tool, userIds}`) is the
* Users page's Edit of one account — exactly those users run on it; `GET /`
* lists the pins under `pins`.
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
* A Codex account may also be a saved Codex `profile` (tool-profiles.js), moved
* in the same way. `GET /profiles[?tool=codex]` lists the saved profiles that
* can be moved in (identity only). Like every Users-page change
* they need the admin's own password again (`currentPassword`), are audited
* (the account NAME only), and refuse in local mode. The secret only goes IN:
* no route answers with it (`/accounts` is in ADMIN_WRITE_PREFIXES).
@@ -35,6 +37,7 @@ const codexPool = require("../lib/codex-pool");
const auth = require("../lib/auth");
const { recordAccountChange } = require("../lib/audit");
const profiles = require("../lib/cc-profiles");
const toolProfiles = require("../lib/tool-profiles");
const pins = require("../lib/account-pins");
const router = Router();
@@ -57,8 +60,19 @@ router.get("/", async (req, res) => {
});
/** Saved switch-user profiles an admin may move into the pool — never a token. */
router.get("/profiles", (_req, res) => {
router.get("/profiles", (req, res) => {
try {
if (req.query.tool === "codex") {
return res.json({
profiles: toolProfiles.listProfiles("codex").items.map((p) => ({
name: p.name,
label: p.label,
email: p.email,
subscriptionType: p.detail,
active: p.active,
})),
});
}
res.json({
profiles: profiles.listProfiles().items.map((p) => ({
name: p.name,
@@ -185,6 +199,20 @@ function fromProfile(name, poolName) {
return account;
}
/**
* Move a saved Codex profile (Settings → AI tools → Profiles) into the pool.
* The profile file goes, as for Claude: the pool is now the login's holder.
* The host's own CODEX_HOME is left as it is — Codex's old refresh token still
* refreshes (codex-pool.js), so the host keeps working on it.
*/
function fromCodexProfile(name, poolName) {
const stored = toolProfiles.readProfile("codex", name);
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
const account = codexPool.addAccount(poolName || name, { auth: stored.secret });
toolProfiles.dropProfile("codex", name);
return account;
}
/** Codex takes an API key, or the text of an auth.json (`codex login`'s). */
function codexSecret(token) {
const text = String(token || "").trim();
@@ -203,7 +231,9 @@ router.post(
poolChange(async (_req, body) => {
const account =
body.tool === "codex"
? codexPool.addAccount(body.name, codexSecret(body.token))
? body.profile
? fromCodexProfile(String(body.profile), body.name)
: codexPool.addAccount(body.name, codexSecret(body.token))
: body.profile
? fromProfile(String(body.profile), body.name)
: body.credentials
+83
View File
@@ -0,0 +1,83 @@
/**
* @file tool-profiles.js
* @description `/api/tool-profiles` — the login profiles of the AI tools other
* than Claude Code (server/lib/tool-profiles.js; Claude's stay under
* `/api/cc-config/profiles`, and Settings → AI tools → Profiles shows both).
* `GET /` lists every covered tool (`supported: false` + `reason` for one that
* cannot be switched); `POST /:tool` saves the live login, `PATCH /:tool`
* renames, `POST /:tool/activate` switches, `DELETE /:tool` deletes — the
* profile name always in the body. Identity metadata only, never a token. The
* writes change the HOST's own login, so `/tool-profiles` is in
* ADMIN_WRITE_PREFIXES and STAFF_ONLY_PREFIXES like `/cc-config`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const profiles = require("../lib/tool-profiles");
const { sameOriginGuard } = require("../lib/same-origin");
const router = Router();
// Every write swaps a live login: never from a cross-site form.
router.use((req, res, next) => (req.method === "GET" ? next() : sameOriginGuard(req, res, next)));
const STATUS = {
EBADNAME: 400,
EBADLABEL: 400,
EBADTOOL: 404,
ENOTFOUND: 404,
EUNSUPPORTED: 409,
ENOTINSTALLED: 409,
EBADJSON: 409,
EACTIVE: 409,
EUNSAVED: 409,
ENOAUTH: 409,
EDUPLICATE: 409,
ELABELTAKEN: 409,
EOMP: 502,
};
const handle = (fn) => async (req, res) => {
try {
res.json(await fn(req));
} catch (e) {
res
.status(STATUS[e.code] || 500)
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
};
router.get(
"/",
handle(() => ({
tools: profiles.tools().map((t) => {
if (!t.supported) return { ...t, items: [], active: null };
try {
return { ...t, ...profiles.listProfiles(t.tool), error: null };
} catch (e) {
// One tool's failure (omp not installed) never hides the others.
return { ...t, items: [], active: null, error: { code: e.code, message: e.message } };
}
}),
}))
);
const name = (req) => (req.body || {}).name;
router.post(
"/:tool",
handle((req) => profiles.saveProfile(req.params.tool, name(req)))
);
router.patch(
"/:tool",
handle((req) => profiles.renameProfile(req.params.tool, name(req), (req.body || {}).label))
);
router.post(
"/:tool/activate",
handle((req) => profiles.activateProfile(req.params.tool, name(req)))
);
router.delete(
"/:tool",
handle((req) => profiles.deleteProfile(req.params.tool, name(req)))
);
module.exports = router;