Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa74cce3fc | |||
| 3113479cd2 |
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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ĩ
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user