26aabd6660
- 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.
1015 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|