3113479cd2
Settings → AI tools → Profiles holds one tab per tool: Claude Code (the cc-profiles panel, moved out of the Claude Code page's modal — its button now links here), Codex (the whole $CODEX_HOME/auth.json) and Oh My Pi (`omp auth export` / `omp auth import -`, secrets on stdin; omp's credential tables are emptied first because import merges, and a failed import puts the outgoing set back). Antigravity is listed as not switchable: agy keeps its login in the OS keyring. server/lib/tool-profiles.js + /api/tool-profiles keep cc-profiles' rules (one login = one profile, the outgoing login is re-saved before a switch, an unsaved one refuses it, the live login's profile is not deletable) and never return a token; /tool-profiles joins ADMIN_WRITE_PREFIXES and STAFF_ONLY_PREFIXES, writes are same-origin only. The account pool takes a saved Codex profile too (moved in like a Claude one, GET /api/accounts/profiles?tool=codex). Each Claude/Codex profile has a "Move to pool" link (/admin?poolAdd=&poolProfile= opens the pool's Add dialog on it), Settings shows the pool's size, and the pool card links back.
809 lines
29 KiB
TypeScript
809 lines
29 KiB
TypeScript
/**
|
|
* @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>;
|
|
}
|