Files
AgentHub/client/src/components/users/AccountPoolCard.tsx
T
nntrivi2001 26aabd6660 feat(pool): one board of pools with split/merge by drag; plan names from tier and seat
- The Users page account pool is one board: a column per pool holding its
  Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its
  members. Accounts sit in one pool; drag moves them. Drop on New pool to
  split one off; drag a pool's only account into another pool to merge
  (members follow, the emptied pool is deleted on Save). One password Save.
- Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and
  seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said
  'pro' after an upgrade and 'team' for both seats. Shown on profiles, the
  status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
2026-10-28 11:47:19 +07:00

1015 lines
35 KiB
TypeScript

/**
* @file AccountPoolCard.tsx
* @description The Users page's account pool card (`GET /api/accounts`).
* Claude Code, Codex and Cursor accounts live on the POOLS board
* (`PoolGroups`): one column per pool with its accounts of every tool — each
* a card with its live panes (and, for Claude/Codex, $ and tokens in the last
* 5 h / 7 d and quota) — and its members, so nobody signs in per user. The
* tools with no pool get a tab each below it: Oh My Pi can share the host's
* login (the same switch as Settings → AI tools,
* `GET/PUT /api/ai-tools/omp-sharing`, S3 only); every other installed tool
* says that its users sign in themselves. An admin adds an account from the
* header's Add account, picking its tool — for Claude a
* login MOVED in from a saved 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`; for Cursor a
* Cursor API key — 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`, from a `MemberPicker` filtered by role
* or "no group"): their sessions skip the balancer, and 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, Share2, Trash2, UserRound } from "lucide-react";
import {
api,
type AccountPin,
type AccountPool,
type OmpSharingState,
type PoolAccountInput,
type PoolProfile,
type PoolTool,
type ServerUserUsage,
type CcUsageWindow,
} from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsSwitchRow } from "../settings/SettingsRow";
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
import { MemberPicker } from "./MemberPicker";
type Row = {
tool: PoolTool;
name: string;
kind: string;
email: string | null;
refreshError: string | null;
addedAt: string;
expired: boolean;
livePanes: number;
spend5h: number;
spend7d: number;
tokens5h: number;
tokens7d: number;
quotaPercent: number | null;
full: boolean;
/** The 5-hour and weekly windows, as the account last reported them. */
windows: QuotaWindow[];
/** "Max 5x", "Team Premium", "Plus"…; null when the account does not say. */
plan?: string | null;
};
/** One quota window of an account: % used and when it rolls over. */
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
/**
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
* passed is empty again (the reading predates it), as the balancer treats it.
*/
export function windowsOf(
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
): QuotaWindow[] {
const q = a.quota as
| {
fiveHour?: CcUsageWindow | null;
sevenDay?: CcUsageWindow | null;
windows?: { label: string; usedPercent: number; resetsAt: string | null }[];
}
| null
| undefined;
const fromClaude = (w: CcUsageWindow | null | undefined) =>
w ? { used: w.utilization, resetsAt: w.resetsAt } : null;
const fromCodex = (label: string) => {
const w = q?.windows?.find((x) => x.label === label);
return w ? { used: w.usedPercent, resetsAt: w.resetsAt } : null;
};
return (["5h", "wk"] as const).map((key) => {
const w = (key === "5h" ? fromClaude(q?.fiveHour) : fromClaude(q?.sevenDay)) ?? fromCodex(key);
const passed = w?.resetsAt != null && Date.parse(w.resetsAt) <= Date.now();
return {
key,
used: passed ? 0 : (w?.used ?? null),
resetsAt: passed ? null : (w?.resetsAt ?? null),
};
});
}
/** "2h 10m" / "3d 4h" until `iso`, or null once it has passed. */
function untilText(iso: string | null): string | null {
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
if (!(ms > 0)) return null;
const m = Math.floor(ms / 60_000);
const d = Math.floor(m / 1440);
const h = Math.floor((m % 1440) / 60);
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
}
/** The tools that have a pool, in tab order (server/routes/accounts.js). */
const POOL_TOOLS: PoolTool[] = ["claude", "codex", "cursor"];
/** Tabs for the tools with no pool: Oh My Pi always, any other installed tool after it. */
const FIXED_TABS: { id: string; label: string }[] = [{ id: "omp", label: "Oh My Pi" }];
/** Tools whose pool reports spend and quota (Cursor's CLI exposes neither). */
const METERED = new Set<PoolTool>(["claude", "codex"]);
const isPoolTool = (id: string): id is PoolTool => (POOL_TOOLS as string[]).includes(id);
function rowsOf(pool: AccountPool, tool: PoolTool): Row[] {
if (tool === "claude") return pool.accounts.map((a) => ({ ...a, tool, windows: windowsOf(a) }));
const list = tool === "codex" ? pool.codex : (pool.cursor ?? []);
return list.map((a) => ({
...a,
tool,
expired: false,
email: null,
refreshError: null,
windows: tool === "codex" ? windowsOf(a as AccountPool["codex"][number]) : [],
}));
}
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
? t(`pool.errors.${err.code}`)
: err.message;
}
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslation("users");
const aiTools = useAiTools();
const [pool, setPool] = useState<AccountPool | null>(null);
const [omp, setOmp] = useState<OmpSharingState | null>(null);
const [error, setError] = useState<string | null>(null);
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 && isPoolTool(tool) && profile ? { tool, profile } : null;
})();
const [tab, setTab] = useState<string>("omp");
// What Add opens on: a tool, and a saved profile to share into its pool.
const [adding, setAdding] = useState<{ tool: PoolTool; profile?: string } | null>(() =>
isAdmin && preset ? preset : null
);
const closeAdd = () => {
setAdding(null);
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);
const load = useCallback(
(force = false) =>
api.accountPool
.list(force)
.then((r) => {
setPool(r);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[]
);
const [spinning, refresh] = useSpin(() => load(true));
useEffect(() => {
void load();
// Oh My Pi's tab: the host's login shared through omp's own gateway (S3).
api.aiTools
.ompSharing()
.then(setOmp)
.catch(() => setOmp(null));
}, [load]);
// Every other INSTALLED tool gets a tab too, so "no pool" is said, not implied.
const tabs = [
...FIXED_TABS,
...aiTools.tools
.filter((x) => x.installed && !isPoolTool(x.id) && !FIXED_TABS.some((f) => f.id === x.id))
.map((x) => ({ id: x.id, label: x.label })),
];
const current = tabs.find((x) => x.id === tab) ?? FIXED_TABS[0]!;
const badge = (id: string): string | null =>
id === "omp" && omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
const rowOf = (tool: PoolTool, name: string) =>
pool ? rowsOf(pool, tool).find((r) => r.name === name) : undefined;
return (
<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-[14rem]">
<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>
{isAdmin && (
<label className="relative">
<span className="btn-primary pointer-events-none text-xs">
<Plus className="h-4 w-4" /> {t("pool.addAccount")}
</span>
<select
className="absolute inset-0 cursor-pointer opacity-0"
value=""
onChange={(e) => {
if (isPoolTool(e.target.value)) setAdding({ tool: e.target.value });
}}
aria-label={t("pool.addAccount")}
data-testid="pool-add-account"
>
<option value="">{t("pool.addAccount")}</option>
{POOL_TOOL_LABELS.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
</label>
)}
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
title={t("pool.refresh")}
aria-label={t("pool.refresh")}
className="btn-icon"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
{error && (
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
{error}
</p>
)}
{pool && (
<PoolGroups
pool={pool}
isAdmin={isAdmin}
onChanged={() => void load()}
renderAccount={(tool, name) => {
const r = rowOf(tool, name);
return r ? (
<AccountCard
row={r}
pool={pool}
isAdmin={isAdmin}
onEdit={() => setEditing(r)}
onRemove={() => setRemoving(r)}
/>
) : null;
}}
/>
)}
<h3 className="border-t border-border px-5 pt-4 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.otherTools")}
</h3>
<div
role="tablist"
aria-label={t("pool.otherTools")}
className="flex gap-1 overflow-x-auto px-5 pt-2 border-b border-border"
>
{tabs.map((x) => {
const selected = x.id === current.id;
const count = badge(x.id);
return (
<button
key={x.id}
type="button"
role="tab"
id={`pool-tab-${x.id}`}
aria-selected={selected}
aria-controls="pool-panel"
onClick={() => setTab(x.id)}
className={`-mb-px inline-flex shrink-0 items-center gap-1.5 border-b-2 px-3 pb-2.5 pt-1 text-sm transition-colors ${
selected
? "border-accent text-fg-primary font-medium"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
<AiToolIcon tool={x} className="h-4 w-4" />
{x.label}
{count !== null && (
<span
className={`rounded-full px-1.5 text-[10px] tabular-nums ${
selected ? "bg-accent/15 text-accent" : "bg-surface-3 text-fg-muted"
}`}
>
{count}
</span>
)}
</button>
);
})}
</div>
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
{current.id === "omp" ? (
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
) : (
<NoPoolPanel id={current.id} label={current.label} />
)}
</div>
{adding && (
<AddAccountDialog
tool={adding.tool}
label={POOL_TOOL_LABELS.find((x) => x.id === adding.tool)?.label ?? adding.tool}
preset={adding.profile ? { tool: adding.tool, profile: adding.profile } : null}
onClose={closeAdd}
onDone={(name) => {
toast.success(t("pool.done.added", { name }));
closeAdd();
void load();
}}
/>
)}
{removing && (
<RemoveAccountDialog
row={removing}
onClose={() => setRemoving(null)}
onDone={() => {
toast.success(t("pool.done.removed", { name: removing.name }));
setRemoving(null);
void load();
}}
/>
)}
{editing && pool && (
<EditAccountDialog
pool={pool}
row={editing}
onClose={() => setEditing(null)}
onDone={(r) => {
const left = r.added.filter(
(u) => u.switched === "own-login" || u.switched === "expired"
);
toast.success(t("pool.editDone", { name: editing.name }));
for (const u of left) {
toast.info(
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
);
}
setEditing(null);
void load();
}}
/>
)}
</section>
);
}
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
function AccountCard({
row: r,
pool,
isAdmin,
onEdit,
onRemove,
}: {
row: Row;
pool: AccountPool;
isAdmin: boolean;
onEdit: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation("users");
const metered = METERED.has(r.tool);
const users = pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username);
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
return (
<div
className={`group flex flex-col gap-2 rounded-lg border bg-surface-1 p-2.5 shadow-sm transition-[border-color,box-shadow] duration-150 hover:border-accent/40 hover:shadow-md ${
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
}`}
title={t("pool.added", { at: formatDateTime(r.addedAt) })}
data-testid="pool-account"
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-semibold text-fg-primary">{r.name}</span>
{r.plan && (
<span
className="shrink-0 rounded-full bg-accent/10 px-1.5 text-[10px] font-medium leading-4 text-accent"
data-testid="pool-account-plan"
>
{r.plan}
</span>
)}
</div>
{r.email && <div className="truncate text-[11px] text-fg-muted">{r.email}</div>}
</div>
<span className="shrink-0 rounded border border-border px-1 text-[10px] leading-4 text-fg-muted">
{t(`pool.kindShort.${r.kind}`)}
</span>
{isAdmin && (
<div className="-mr-1 -mt-1 flex shrink-0 opacity-60 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<button
type="button"
onClick={onEdit}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon p-1"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title={t("pool.remove", { name: r.name })}
aria-label={t("pool.remove", { name: r.name })}
className="btn-icon p-1 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{(r.expired || r.refreshError || r.full) && (
<div className="flex flex-wrap gap-1">
{r.expired && (
<span className="badge py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
{t("pool.expired")}
</span>
)}
{r.refreshError && !r.expired && (
<span className={`badge py-0 text-[10px] ${warn}`} title={r.refreshError}>
{t("pool.refreshFailed")}
</span>
)}
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{t("pool.full")}</span>}
</div>
)}
{metered && (
<div className="space-y-1" data-testid="pool-quota">
{r.windows.map((w) => (
<QuotaBar key={w.key} window={w} />
))}
</div>
)}
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] tabular-nums text-fg-muted">
<span className="inline-flex items-center gap-1 text-fg-secondary">
<span
className={`h-1.5 w-1.5 rounded-full ${r.livePanes > 0 ? "bg-status-success" : "bg-fg-muted/40"}`}
aria-hidden
/>
{t("pool.liveCount", { count: r.livePanes })}
</span>
{metered && (
<>
<span aria-hidden>·</span>
<span>
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
</span>
<span className="basis-full">
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
</span>
</>
)}
</div>
{users.length > 0 && (
<div className="inline-flex items-center gap-1 text-[11px] text-fg-secondary">
<UserRound className="h-3 w-3" aria-hidden />
{t("pool.pinned", { users: users.join(", ") })}
</div>
)}
</div>
);
}
/**
* One window of an account's quota, shown as what is LEFT (100% = untouched,
* 0% = used up), with when it resets.
*/
export function QuotaBar({ window: w }: { window: QuotaWindow }) {
const { t } = useTranslation("users");
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
const resets = untilText(w.resetsAt);
return (
<div className="text-[11px]">
<div className="flex items-center gap-1.5">
<span className="w-14 shrink-0 text-fg-muted">{t(`pool.window.${w.key}`)}</span>
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-4">
<span
className={`block h-full transition-[width] duration-500 ${
left == null
? ""
: left === 0
? "bg-status-danger"
: left <= 10
? "bg-status-warning"
: "bg-accent-solid"
}`}
style={{ width: `${left ?? 0}%` }}
/>
</span>
<span className="w-9 shrink-0 text-right tabular-nums text-fg-secondary">
{left == null ? "—" : `${left}%`}
</span>
</div>
{resets && (
<div
className="pl-[3.875rem] text-[10px] text-fg-muted"
title={formatDateTime(w.resetsAt!)}
>
{t("pool.resetsIn", { time: resets, at: formatDateTime(w.resetsAt!) })}
</div>
)}
</div>
);
}
/**
* Oh My Pi's tab: no pool, but the host's own omp login can be shared with
* every member's pane through omp's gateway (server/lib/omp-share.js) — the
* same admin switch as Settings → AI tools. Only with per-user accounts (S3).
*/
function OmpPanel({
state,
isAdmin,
onChange,
}: {
state: OmpSharingState | null;
isAdmin: boolean;
onChange: (s: OmpSharingState) => void;
}) {
const { t } = useTranslation("users");
const ts = useTranslation("settings").t;
const [busy, setBusy] = useState(false);
if (!state?.available) {
return (
<div className="flex items-start gap-3 px-5 py-6">
<Share2 className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
<p className="text-sm text-fg-muted leading-relaxed">{t("pool.omp.unavailable")}</p>
</div>
);
}
const error = state.broker.error || state.gateway.error;
const status = !state.enabled
? t("pool.omp.off")
: !state.installed
? ts("ompSharing.notInstalled")
: state.broker.running && state.gateway.running
? ts("ompSharing.running")
: error
? ts("ompSharing.failed", { error })
: ts("ompSharing.starting");
return (
<div className="px-5 py-3 space-y-2">
<p className="text-xs text-fg-secondary leading-relaxed">{t("pool.omp.about")}</p>
{isAdmin ? (
<SettingsSwitchRow
label={ts("ompSharing.switch")}
description={ts("ompSharing.hint")}
checked={state.enabled}
disabled={busy}
onChange={async (on) => {
setBusy(true);
try {
onChange(await api.aiTools.setOmpSharing(on));
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
}}
/>
) : null}
<p data-testid="omp-pool-status" className="pb-2 text-xs text-fg-muted">
{status}
</p>
</div>
);
}
/** Any other tool: no pool — say so, and why for the ones we know. */
function NoPoolPanel({ id, label }: { id: string; label: string }) {
const { t } = useTranslation("users");
return (
<div className="flex items-start gap-3 px-5 py-6">
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" aria-hidden />
<div className="space-y-1">
<p className="text-sm text-fg-secondary">{t("pool.none.title", { tool: label })}</p>
<p className="text-xs text-fg-muted leading-relaxed">
{t(`pool.none.reason.${id}`, {
defaultValue: t("pool.none.reason.default", { tool: label }),
})}
</p>
</div>
</div>
);
}
/**
* One account's Edit: tick the users whose sessions run on it. A user already
* on another account of the tool moves here when ticked; unticking one sends
* them back to the least-used account. Saved in one request, password again.
*/
function EditAccountDialog({
pool,
row,
onClose,
onDone,
}: {
pool: AccountPool;
row: Row;
onClose: () => void;
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
}) {
const { t } = useTranslation("users");
const pins = pinsOf(pool, row.tool);
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const groupOf = (userId: number) =>
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.edit", { name: row.name })}
message={t("pool.editHint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1">
<MemberPicker
users={users}
chosen={chosen}
onChange={setChosen}
groupOf={groupOf}
grouped={(pool.groups ?? []).length > 0}
note={(u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
const group = groupOf(u.id);
return (
<span className="ml-1.5 text-[11px] text-fg-muted">
{[
group && t("pool.groups.inGroup", { group }),
elsewhere && t("pool.onAccount", { account: elsewhere.account }),
]
.filter(Boolean)
.join(" · ")}
</span>
);
}}
/>
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
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"],
cursor: ["token"],
};
function AddAccountDialog({
tool,
label,
preset,
onClose,
onDone,
}: {
/** The open tab's tool: the account is added to its pool. */
tool: PoolTool;
label: string;
/** 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 sources = SOURCES[tool];
const [source, setSource] = useState<ClaudeSource>(sources[0] ?? "token");
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
const [profile, setProfile] = useState(preset?.tool === tool ? preset.profile : "");
const [name, setName] = useState("");
const [secret, setSecret] = useState("");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!sources.includes("profile")) return;
api.accountPool
.profiles(tool)
.then((r) => setProfiles(r.profiles))
.catch(() => setProfiles([]));
}, [tool, sources]);
const fromProfile = source === "profile";
// A profile already names itself: its name is the default.
const ready =
current.length > 0 &&
(fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0);
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
const body: PoolAccountInput =
source === "profile"
? { tool, name: name.trim() || profile, profile }
: tool === "claude" && source === "credentials"
? { tool, name: name.trim(), credentials: secret.trim() }
: { tool, name: name.trim(), token: secret.trim() };
try {
const r = await api.accountPool.add(body, current);
onDone(r.account.name);
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
// The secret box is a textarea for pasted JSON: Enter there is a newline.
if ((e.target as HTMLElement).tagName !== "TEXTAREA" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
// Codex's and Cursor's one non-profile source is their own kind of key.
const secretKind = tool === "claude" ? source : tool;
return (
<ConfirmModal
open
title={t("pool.addFor", { tool: label })}
confirmLabel={t("pool.add")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
{sources.length > 1 && (
<Field
label={t("pool.source")}
hint={t(`pool.sourceHint.${tool !== "claude" && source === "token" ? tool : source}`)}
>
<select
className="input w-full text-sm"
value={source}
onChange={(e) => setSource(e.target.value as ClaudeSource)}
aria-label={t("pool.source")}
>
{sources.map((s) => (
<option key={s} value={s}>
{tool !== "claude" && s === "token"
? t(`pool.secret.${tool}`)
: t(`pool.sources.${s}`)}
</option>
))}
</select>
</Field>
)}
{fromProfile && (
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
<select
className="input w-full text-sm"
value={profile}
onChange={(e) => setProfile(e.target.value)}
aria-label={t("pool.profile")}
>
<option value="">
{profiles === null
? t("pool.loadingProfiles")
: profiles.length === 0
? t("pool.noProfiles")
: t("pool.chooseProfile")}
</option>
{profiles?.map((p) => (
<option key={p.name} value={p.name}>
{p.label}
{p.email ? ` — ${p.email}` : ""}
{p.active ? ` (${t("pool.activeHere")})` : ""}
</option>
))}
</select>
</Field>
)}
<Field
label={t("pool.name")}
hint={fromProfile ? t("pool.nameFromProfile") : t("pool.nameHint")}
>
<input
className="input w-full text-sm"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
aria-label={t("pool.name")}
/>
</Field>
{!fromProfile && (
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
{secretKind === "token" || secretKind === "cursor" ? (
<input
type="password"
className="input w-full font-mono text-sm"
value={secret}
onChange={(e) => setSecret(e.target.value)}
autoComplete="off"
aria-label={t(`pool.secret.${secretKind}`)}
/>
) : (
<textarea
className="input w-full h-24 font-mono text-xs"
value={secret}
onChange={(e) => setSecret(e.target.value)}
autoComplete="off"
spellCheck={false}
aria-label={t(`pool.secret.${secretKind}`)}
/>
)}
</Field>
)}
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function RemoveAccountDialog({
row,
onClose,
onDone,
}: {
row: Row;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation("users");
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!current || busy) return;
setBusy(true);
setError(null);
try {
await api.accountPool.remove(row.tool, row.name, current);
onDone();
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.remove", { name: row.name })}
message={t("pool.removeHint")}
confirmLabel={t("pool.removeButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}