7798530528
Codex >= 0.164 sends every request through a shared app-server daemon that outlives its TUIs and keeps the login it started with, so writing a new auth.json changed nothing: open sessions and even new ones stayed on the old (out-of-quota) account. After a switch, restart that CODEX_HOME's daemon when one runs; open TUIs reconnect to it on their own (verified on 0.164.0 with two keys). The response says so (following), and the toast tells the user running sessions follow.
814 lines
29 KiB
TypeScript
814 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 <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
|
|
setBusy(key);
|
|
try {
|
|
const r = await fn();
|
|
toast.success(typeof success === "function" ? success(r) : 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),
|
|
(r) =>
|
|
t(
|
|
r.following ? "toolProfiles.switchFollowing" : "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.plan || identity.subscriptionType].filter(Boolean);
|
|
if (parts.length === 0) return null;
|
|
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
|
|
}
|