685eb748e8
- The update dialog can be closed while an update applies: it keeps running, the page still reloads itself when the new server is up, and a failure re-opens the dialog with its error. - Account pool card: one tab per AI tool (Claude Code, Codex, Cursor CLI, Oh My Pi, and every other installed tool, which says it has no pool), accounts as cards with 5-hour and weekly quota shown as what is LEFT plus their reset times, and the saved profiles of Settings → AI tools → Profiles as cards with "Share to pool". - Cursor account pool (server/lib/cursor-pool.js): Cursor API keys handed to a cursor-agent pane only through tmux -e CURSOR_API_KEY; `agenthub accounts add <name> --tool cursor`. - Pool groups (server/lib/pool-groups.js, /api/accounts/groups): a group's members run only on its accounts of each tool, a user in no group on the accounts no group lists, a pin still wins. account_pins loses its tool CHECK (rebuilt, pins kept); three new tables. - Oh My Pi login sharing can be switched from its pool tab too.
1148 lines
39 KiB
TypeScript
1148 lines
39 KiB
TypeScript
/**
|
|
* @file AccountPoolCard.tsx
|
|
* @description The Users page's account pool card (`GET /api/accounts`), one
|
|
* tab per AI tool so each tool's pool is managed on its own: Claude Code,
|
|
* Codex and Cursor have a pool — the accounts every user's new sessions of that
|
|
* tool are spread over, each with its live panes (and, for Claude/Codex, $ and
|
|
* tokens in the last 5 h / 7 d and quota) — so nobody signs in per user;
|
|
* Oh My Pi has no pool but 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. A tab shows
|
|
* how many accounts its pool holds, and an empty pool says so instead of
|
|
* vanishing. An admin adds an account to the open tab's 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`): 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 that tool's tab and 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, Users } 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 { Checkbox } from "../Checkbox";
|
|
import { AiToolIcon } from "../AiToolIcon";
|
|
import { SettingsSwitchRow } from "../settings/SettingsRow";
|
|
import { PoolGroups, groupsOf } from "./PoolGroups";
|
|
|
|
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[];
|
|
};
|
|
|
|
/** One quota window of an account: % used and when it rolls over. */
|
|
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.
|
|
*/
|
|
function windowsOf(
|
|
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
|
): 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"];
|
|
/** Fixed tabs, always shown; any other installed tool follows them. */
|
|
const FIXED_TABS: { id: string; label: string }[] = [
|
|
{ id: "claude", label: "Claude Code" },
|
|
{ id: "codex", label: "Codex" },
|
|
{ id: "cursor", label: "Cursor CLI" },
|
|
{ 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>(preset?.tool ?? "claude");
|
|
// 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 && !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 => {
|
|
if (isPoolTool(id)) return pool ? String(rowsOf(pool, id).length) : null;
|
|
if (id === "omp") return omp?.available && omp.enabled ? t("pool.omp.badgeOn") : null;
|
|
return null;
|
|
};
|
|
|
|
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>
|
|
<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>
|
|
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
|
<div
|
|
role="tablist"
|
|
aria-label={t("pool.title")}
|
|
className="flex gap-1 overflow-x-auto px-5 pt-3 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>
|
|
{error && (
|
|
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
<div role="tabpanel" id="pool-panel" aria-labelledby={`pool-tab-${current.id}`}>
|
|
{isPoolTool(current.id) ? (
|
|
<PoolPanel
|
|
tool={current.id}
|
|
label={current.label}
|
|
pool={pool}
|
|
isAdmin={isAdmin}
|
|
installed={current.id === "claude" || !!aiTools.byId(current.id)?.installed}
|
|
onAdd={(profile) => setAdding({ tool: current.id as PoolTool, profile })}
|
|
onEdit={setEditing}
|
|
onRemove={setRemoving}
|
|
/>
|
|
) : current.id === "omp" ? (
|
|
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
|
) : (
|
|
<NoPoolPanel id={current.id} label={current.label} />
|
|
)}
|
|
</div>
|
|
{adding && (
|
|
<AddAccountDialog
|
|
tool={adding.tool}
|
|
label={tabs.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 tool's tab: what its pool does, its accounts as cards, the saved profiles to share, and Add. */
|
|
function PoolPanel({
|
|
tool,
|
|
label,
|
|
pool,
|
|
isAdmin,
|
|
installed,
|
|
onAdd,
|
|
onEdit,
|
|
onRemove,
|
|
}: {
|
|
tool: PoolTool;
|
|
label: string;
|
|
pool: AccountPool | null;
|
|
isAdmin: boolean;
|
|
installed: boolean;
|
|
/** Add an account; `profile` = share that saved profile into the pool. */
|
|
onAdd: (profile?: string) => void;
|
|
onEdit: (r: Row) => void;
|
|
onRemove: (r: Row) => void;
|
|
}) {
|
|
const { t } = useTranslation("users");
|
|
const rows = pool ? rowsOf(pool, tool) : [];
|
|
const grouped = (pool?.groups ?? []).length > 0;
|
|
const addButton = isAdmin && (
|
|
<button type="button" onClick={() => onAdd()} className="btn-primary">
|
|
<Plus className="w-4 h-4" /> {t("pool.addFor", { tool: label })}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap items-start gap-3 px-5 py-3">
|
|
<p className="flex-1 min-w-[16rem] text-xs text-fg-secondary leading-relaxed">
|
|
{t(`pool.about.${tool}`)}
|
|
{!installed && (
|
|
<span className="ml-1 text-status-warning">
|
|
{t("pool.notInstalled", { tool: label })}
|
|
</span>
|
|
)}
|
|
</p>
|
|
{rows.length > 0 && addButton}
|
|
</div>
|
|
{pool && rows.length === 0 && (
|
|
<div className="flex flex-col items-center gap-3 px-5 pb-6 pt-2 text-center">
|
|
<p className="max-w-md text-sm text-fg-muted">{t("pool.emptyFor", { tool: label })}</p>
|
|
{addButton}
|
|
</div>
|
|
)}
|
|
{rows.length > 0 && (
|
|
<div className="grid gap-3 px-5 pb-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
{rows.map((r) => (
|
|
<AccountCard
|
|
key={r.name}
|
|
row={r}
|
|
pool={pool!}
|
|
grouped={grouped}
|
|
isAdmin={isAdmin}
|
|
onEdit={() => onEdit(r)}
|
|
onRemove={() => onRemove(r)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{isAdmin && SOURCES[tool].includes("profile") && (
|
|
<SavedProfiles tool={tool} pool={pool} onShare={(name) => onAdd(name)} />
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** One pool account: who it is, how loaded, which groups and users run on it. */
|
|
function AccountCard({
|
|
row: r,
|
|
pool,
|
|
grouped,
|
|
isAdmin,
|
|
onEdit,
|
|
onRemove,
|
|
}: {
|
|
row: Row;
|
|
pool: AccountPool;
|
|
grouped: boolean;
|
|
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 inGroups = groupsOf(pool, r.tool, r.name);
|
|
return (
|
|
<div
|
|
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
|
r.expired ? "border-status-danger/30 opacity-70" : "border-border"
|
|
}`}
|
|
data-testid="pool-account"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="truncate text-sm font-medium text-fg-primary">{r.name}</div>
|
|
{r.email && <div className="truncate text-[11px] text-fg-secondary">{r.email}</div>}
|
|
</div>
|
|
<span className="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
|
{isAdmin && (
|
|
<div className="-mr-1 -mt-1 flex shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={onEdit}
|
|
title={t("pool.edit", { name: r.name })}
|
|
aria-label={t("pool.edit", { name: r.name })}
|
|
className="btn-icon"
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
title={t("pool.remove", { name: r.name })}
|
|
aria-label={t("pool.remove", { name: r.name })}
|
|
className="btn-icon"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</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] bg-status-warning/10 text-status-warning border-status-warning/20"
|
|
title={r.refreshError}
|
|
>
|
|
{t("pool.refreshFailed")}
|
|
</span>
|
|
)}
|
|
{r.full && (
|
|
<span className="badge py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
|
{t("pool.full")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-3 text-xs text-fg-secondary">
|
|
<span className="inline-flex items-center gap-1">
|
|
<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>
|
|
</div>
|
|
{metered && (
|
|
<div className="space-y-1.5" data-testid="pool-quota">
|
|
{r.windows.map((w) => (
|
|
<QuotaBar key={w.key} window={w} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{metered && (
|
|
<div className="text-[11px] tabular-nums text-fg-muted">
|
|
<div>
|
|
{t("pool.spend")}: <span className="text-fg-primary">{fmtCost(r.spend5h)}</span> ·{" "}
|
|
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
|
</div>
|
|
<div>{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}</div>
|
|
</div>
|
|
)}
|
|
{grouped && (
|
|
<div className="flex flex-wrap items-center gap-1 text-[11px]">
|
|
<Users className="h-3 w-3 text-fg-muted" aria-hidden />
|
|
{inGroups.length === 0 ? (
|
|
<span className="text-fg-muted">{t("pool.groups.shared")}</span>
|
|
) : (
|
|
inGroups.map((g) => (
|
|
<span key={g.id} className="badge py-0 text-[10px]">
|
|
{g.name}
|
|
</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 className="mt-auto text-[11px] text-fg-muted">
|
|
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
|
* 0% = used up), with when it resets.
|
|
*/
|
|
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 ${
|
|
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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The tool's saved login profiles (Settings → AI tools → Profiles) as cards:
|
|
* "Share to pool" moves one in (`POST /api/accounts` with `profile`) — it then
|
|
* leaves the switcher, since a login's refresh token may have one holder.
|
|
*/
|
|
function SavedProfiles({
|
|
tool,
|
|
pool,
|
|
onShare,
|
|
}: {
|
|
tool: PoolTool;
|
|
/** Re-read whenever the pool changes: a shared profile is gone from the list. */
|
|
pool: AccountPool | null;
|
|
onShare: (name: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("users");
|
|
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
|
useEffect(() => {
|
|
api.accountPool
|
|
.profiles(tool)
|
|
.then((r) => setProfiles(r.profiles))
|
|
.catch(() => setProfiles([]));
|
|
}, [tool, pool]);
|
|
return (
|
|
<div className="border-t border-border px-5 py-4" data-testid="pool-profiles">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className="flex-1 text-xs font-semibold uppercase tracking-wider text-fg-muted">
|
|
{t("pool.savedProfiles.title")}
|
|
</h3>
|
|
<Link to="/settings/agents#tool-profiles" className="text-xs text-accent hover:underline">
|
|
{t("pool.profilesLink")}
|
|
</Link>
|
|
</div>
|
|
{profiles !== null && profiles.length === 0 && (
|
|
<p className="mt-2 text-xs text-fg-muted">{t("pool.savedProfiles.empty")}</p>
|
|
)}
|
|
{profiles && profiles.length > 0 && (
|
|
<div className="mt-2 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
{profiles.map((p) => (
|
|
<div
|
|
key={p.name}
|
|
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2"
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="truncate text-sm text-fg-primary">{p.label}</div>
|
|
<div className="truncate text-[11px] text-fg-muted">
|
|
{[p.email, p.subscriptionType].filter(Boolean).join(" · ")}
|
|
{p.active ? ` · ${t("pool.savedProfiles.activeHere")}` : ""}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onShare(p.name)}
|
|
className="btn-ghost shrink-0 text-xs"
|
|
aria-label={t("pool.savedProfiles.shareNamed", { name: p.label })}
|
|
>
|
|
<Share2 className="h-3.5 w-3.5" /> {t("pool.savedProfiles.share")}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</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 toggle = (id: number, on: boolean) =>
|
|
setChosen((prev) => {
|
|
const next = new Set(prev);
|
|
if (on) next.add(id);
|
|
else next.delete(id);
|
|
return next;
|
|
});
|
|
|
|
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 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
|
{users === null && (
|
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
|
)}
|
|
{users?.length === 0 && (
|
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
|
)}
|
|
{users?.map((u) => {
|
|
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
|
return (
|
|
<div key={u.id} className="px-3 py-2">
|
|
<Checkbox
|
|
checked={chosen.has(u.id)}
|
|
onChange={(on) => toggle(u.id, on)}
|
|
label={
|
|
<span>
|
|
{u.username}
|
|
{u.disabled && (
|
|
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
|
)}
|
|
{elsewhere && (
|
|
<span className="ml-1.5 text-[11px] text-fg-muted">
|
|
{t("pool.onAccount", { account: elsewhere.account })}
|
|
</span>
|
|
)}
|
|
</span>
|
|
}
|
|
labelClassName="text-sm text-fg-primary"
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</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>
|
|
);
|
|
}
|