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.
This commit is contained in:
@@ -800,7 +800,7 @@ function ProfileUsageLine({
|
||||
|
||||
/** Org / seat / plan line shared by the active card and each profile row. */
|
||||
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
|
||||
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
|
||||
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(
|
||||
Boolean
|
||||
);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* @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
|
||||
* @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
|
||||
@@ -17,11 +17,11 @@
|
||||
* 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
|
||||
* (`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 that tool's tab and Add on that profile; the header links back
|
||||
* to the profiles.
|
||||
* which opens Add on that profile; the header links back to the profiles.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode }
|
||||
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 { KeyRound, Pencil, Plus, RefreshCw, Share2, Trash2, UserRound } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPin,
|
||||
@@ -47,10 +47,10 @@ 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";
|
||||
import { PoolGroups, POOL_TOOL_LABELS } from "./PoolGroups";
|
||||
import { MemberPicker } from "./MemberPicker";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
@@ -69,6 +69,8 @@ type Row = {
|
||||
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. */
|
||||
@@ -119,13 +121,8 @@ function untilText(iso: string | null): string | null {
|
||||
|
||||
/** 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" },
|
||||
];
|
||||
/** 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"]);
|
||||
|
||||
@@ -166,7 +163,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const profile = params.get("poolProfile");
|
||||
return tool && isPoolTool(tool) && profile ? { tool, profile } : null;
|
||||
})();
|
||||
const [tab, setTab] = useState<string>(preset?.tool ?? "claude");
|
||||
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
|
||||
@@ -208,15 +205,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const tabs = [
|
||||
...FIXED_TABS,
|
||||
...aiTools.tools
|
||||
.filter((x) => x.installed && !FIXED_TABS.some((f) => f.id === x.id))
|
||||
.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 => {
|
||||
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;
|
||||
};
|
||||
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">
|
||||
@@ -229,6 +225,29 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<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}
|
||||
@@ -241,11 +260,37 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
{pool?.groups && <PoolGroups pool={pool} isAdmin={isAdmin} onChanged={() => void load()} />}
|
||||
{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.title")}
|
||||
className="flex gap-1 overflow-x-auto px-5 pt-3 border-b border-border"
|
||||
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;
|
||||
@@ -280,24 +325,8 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
})}
|
||||
</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" ? (
|
||||
{current.id === "omp" ? (
|
||||
<OmpPanel state={omp} isAdmin={isAdmin} onChange={setOmp} />
|
||||
) : (
|
||||
<NoPoolPanel id={current.id} label={current.label} />
|
||||
@@ -306,7 +335,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
tool={adding.tool}
|
||||
label={tabs.find((x) => x.id === adding.tool)?.label ?? 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) => {
|
||||
@@ -351,89 +380,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/** One pool account: who it is and on which plan, how loaded, who is pinned to it. */
|
||||
function AccountCard({
|
||||
row: r,
|
||||
pool,
|
||||
grouped,
|
||||
isAdmin,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
row: Row;
|
||||
pool: AccountPool;
|
||||
grouped: boolean;
|
||||
isAdmin: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
@@ -443,39 +399,52 @@ function AccountCard({
|
||||
const users = pinsOf(pool, r.tool)
|
||||
.filter((p) => p.account === r.name)
|
||||
.map((p) => p.username);
|
||||
const inGroups = groupsOf(pool, r.tool, r.name);
|
||||
const warn = "bg-status-warning/10 text-status-warning border-status-warning/20";
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 rounded-lg border bg-surface-2 p-3 ${
|
||||
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="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 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="badge shrink-0 py-0 text-[10px]">{t(`pool.kindShort.${r.kind}`)}</span>
|
||||
<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">
|
||||
<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"
|
||||
className="btn-icon p-1"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<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"
|
||||
className="btn-icon p-1 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -488,68 +457,47 @@ function AccountCard({
|
||||
</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}
|
||||
>
|
||||
<span className={`badge py-0 text-[10px] ${warn}`} 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>
|
||||
)}
|
||||
{r.full && <span className={`badge py-0 text-[10px] ${warn}`}>{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">
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -568,7 +516,7 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
<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 ${
|
||||
className={`block h-full transition-[width] duration-500 ${
|
||||
left == null
|
||||
? ""
|
||||
: left === 0
|
||||
@@ -596,72 +544,6 @@ export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -777,13 +659,8 @@ function EditAccountDialog({
|
||||
|
||||
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 groupOf = (userId: number) =>
|
||||
(pool.groups ?? []).find((g) => g.users.some((u) => u.userId === userId))?.name ?? null;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
@@ -822,38 +699,28 @@ function EditAccountDialog({
|
||||
>
|
||||
<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 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")}>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* @file MemberPicker.tsx
|
||||
* @description The pool dialogs' member list (an account's Edit, a group's
|
||||
* Edit): filter chips by role (Admin / Developer / Analyst, each with its
|
||||
* count) plus "No group" for the users in no pool group, a search box, a
|
||||
* tick-all for what is shown, and one checkbox per user with their role and
|
||||
* where they are now. Pure UI: the caller owns the ticked set.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import type { AuthUser, ServerUserUsage } from "../../lib/api";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
export const ROLES: AuthUser["role"][] = ["admin", "developer", "analyst"];
|
||||
/** A role, every user, or the users in no pool group. */
|
||||
export type MemberFilter = "all" | "nogroup" | AuthUser["role"];
|
||||
|
||||
/** The chips' filter applied to one user. */
|
||||
export function matchesFilter(
|
||||
u: Pick<ServerUserUsage, "id" | "role">,
|
||||
filter: MemberFilter,
|
||||
groupOf: (userId: number) => string | null
|
||||
): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "nogroup") return groupOf(u.id) === null;
|
||||
return u.role === filter;
|
||||
}
|
||||
|
||||
/** A row of filter chips with counts; `nogroup` only when groups exist. */
|
||||
export function MemberFilterChips({
|
||||
users,
|
||||
filter,
|
||||
onFilter,
|
||||
groupOf,
|
||||
grouped,
|
||||
}: {
|
||||
users: Pick<ServerUserUsage, "id" | "role">[];
|
||||
filter: MemberFilter;
|
||||
onFilter: (f: MemberFilter) => void;
|
||||
groupOf: (userId: number) => string | null;
|
||||
grouped: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const chips: { id: MemberFilter; label: string }[] = [
|
||||
{ id: "all", label: t("pool.members.all") },
|
||||
...ROLES.map((r) => ({ id: r as MemberFilter, label: t(`roles.${r}`) })),
|
||||
...(grouped ? [{ id: "nogroup" as MemberFilter, label: t("pool.members.noGroup") }] : []),
|
||||
];
|
||||
return (
|
||||
<div role="group" aria-label={t("pool.members.filter")} className="flex flex-wrap gap-1">
|
||||
{chips.map((c) => {
|
||||
const n = users.filter((u) => matchesFilter(u, c.id, groupOf)).length;
|
||||
if (n === 0 && c.id !== "all" && c.id !== filter) return null;
|
||||
const on = filter === c.id;
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => onFilter(c.id)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors ${
|
||||
on
|
||||
? "border-accent/50 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
<span className="tabular-nums opacity-70">{n}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberPicker({
|
||||
users,
|
||||
chosen,
|
||||
onChange,
|
||||
groupOf,
|
||||
grouped,
|
||||
note,
|
||||
}: {
|
||||
users: ServerUserUsage[] | null;
|
||||
chosen: Set<number>;
|
||||
onChange: (next: Set<number>) => void;
|
||||
/** The pool group a user is in now, by name, or null. */
|
||||
groupOf: (userId: number) => string | null;
|
||||
/** Whether any pool group exists (shows the "No group" chip). */
|
||||
grouped: boolean;
|
||||
/** Extra text after a user's name (where they run now). */
|
||||
note?: (u: ServerUserUsage) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [filter, setFilter] = useState<MemberFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (users ?? []).filter(
|
||||
(u) =>
|
||||
matchesFilter(u, filter, groupOf) &&
|
||||
(!q ||
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.displayName ?? "").toLowerCase().includes(q))
|
||||
);
|
||||
const allShown = shown.length > 0 && shown.every((u) => chosen.has(u.id));
|
||||
const setMany = (ids: number[], on: boolean) => {
|
||||
const next = new Set(chosen);
|
||||
for (const id of ids) {
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="member-picker">
|
||||
{users && users.length > 0 && (
|
||||
<>
|
||||
<MemberFilterChips
|
||||
users={users}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
groupOf={groupOf}
|
||||
grouped={grouped}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
className="input w-full py-1 pl-7 text-xs"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("pool.members.search")}
|
||||
aria-label={t("pool.members.search")}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={allShown}
|
||||
disabled={shown.length === 0}
|
||||
onChange={(on) =>
|
||||
setMany(
|
||||
shown.map((u) => u.id),
|
||||
on
|
||||
)
|
||||
}
|
||||
label={t("pool.members.tickShown", { count: shown.length })}
|
||||
labelClassName="text-[11px] text-fg-secondary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="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 && users.length > 0 && shown.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.members.noneShown")}</p>
|
||||
)}
|
||||
{shown.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<Checkbox
|
||||
checked={chosen.has(u.id)}
|
||||
onChange={(on) => setMany([u.id], on)}
|
||||
label={
|
||||
<span>
|
||||
{u.username}
|
||||
{u.disabled && <span className="ml-1 text-fg-muted">({t("disabled")})</span>}
|
||||
{note?.(u)}
|
||||
</span>
|
||||
}
|
||||
className="min-w-0 flex-1"
|
||||
labelClassName="text-sm text-fg-primary"
|
||||
/>
|
||||
<span className="badge shrink-0 py-0 text-[10px]">{t(`roles.${u.role}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,24 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: one tab per AI tool — the
|
||||
* Claude, Codex and Cursor pools with their accounts' live panes and quota,
|
||||
* Oh My Pi's login sharing, and a "no pool" note for any other installed
|
||||
* tool; says what an empty pool means per tool, an
|
||||
* @description The Users page's account pool card: the pools board with every
|
||||
* tool's accounts (live panes and quota) and members per pool, then a tab per
|
||||
* tool with no pool — Oh My Pi's login sharing, and a "no pool" note for any
|
||||
* other installed tool; an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||
* it, saved with the admin's own password; the users show on the account.
|
||||
* The board moves members and accounts between pools (drag, or a column's
|
||||
* menu) — an account sits in one pool, so a move takes it out of the other —
|
||||
* splits an account off onto New pool, merges a pool by dragging its only
|
||||
* account into another, adds a saved profile to a pool, shows each account's
|
||||
* plan, and saves every change with ONE password.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
@@ -129,6 +134,28 @@ const OMP = {
|
||||
gateway: { running: false },
|
||||
};
|
||||
|
||||
/** The account names a pool column holds (its move menu names others too). */
|
||||
const held = (col: HTMLElement) =>
|
||||
within(col)
|
||||
.queryAllByTestId("pool-group-account")
|
||||
.map((c) => within(c).queryByText(/^(team|cx)-\d$/)?.textContent);
|
||||
|
||||
/** A drag-and-drop between two elements, through one shared DataTransfer. */
|
||||
function drag(from: Element, to: Element) {
|
||||
const store: Record<string, string> = {};
|
||||
const dataTransfer = {
|
||||
setData: (k: string, v: string) => (store[k] = v),
|
||||
getData: (k: string) => store[k] ?? "",
|
||||
get types() {
|
||||
return Object.keys(store);
|
||||
},
|
||||
effectAllowed: "",
|
||||
};
|
||||
fireEvent.dragStart(from, { dataTransfer });
|
||||
fireEvent.dragOver(to, { dataTransfer });
|
||||
fireEvent.drop(to, { dataTransfer });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
aiTools.list.mockResolvedValue({
|
||||
@@ -147,8 +174,8 @@ beforeEach(() => {
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
users.list.mockResolvedValue({
|
||||
users: [
|
||||
{ id: 7, username: "mia", disabled: false },
|
||||
{ id: 8, username: "leo", disabled: false },
|
||||
{ id: 7, username: "mia", role: "developer", disabled: false },
|
||||
{ id: 8, username: "leo", role: "analyst", disabled: false },
|
||||
],
|
||||
});
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
@@ -166,11 +193,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("shows each tool's pool on its own tab, counted, and explains an empty one", async () => {
|
||||
it("shows every tool's accounts on the board, and a tab per tool with no pool", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = render(card(false));
|
||||
render(card(false));
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("cx-1")).toBeNull();
|
||||
// With no pool made yet, every account sits in the shared pool, by tool.
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
expect(shared).toHaveTextContent("Claude Code2");
|
||||
expect(shared).toHaveTextContent("Codex1");
|
||||
expect(shared).toHaveTextContent("cx-1");
|
||||
const [account, second] = screen.getAllByTestId("pool-account");
|
||||
// Every account of the pool has its card; quota is shown as what is LEFT.
|
||||
expect(second).toHaveTextContent("team-2");
|
||||
@@ -188,39 +219,25 @@ describe("AccountPoolCard", () => {
|
||||
expect(account).toHaveTextContent("$12.00 · 3.4M tokens in 7 days");
|
||||
expect(account).toHaveTextContent("2 live");
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
// Without groups nothing is said about them on an account.
|
||||
expect(screen.queryByText("Shared pool")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^add/i })).toBeNull();
|
||||
// An analyst reads: no add, remove or new pool.
|
||||
expect(screen.queryByTestId("pool-add-account")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
const codexTab = screen.getByRole("tab", { name: /Codex/ });
|
||||
expect(codexTab).toHaveTextContent("1");
|
||||
expect(screen.getByRole("tab", { name: /Claude Code/ })).toHaveTextContent("2");
|
||||
await user.click(codexTab);
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.queryByText("team-1")).toBeNull();
|
||||
// A pool with no account still has its tab, with what that means.
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
expect(screen.getByText(/No Cursor CLI accounts yet/)).toBeInTheDocument();
|
||||
// An installed tool without a pool says so; an uninstalled one gets no tab.
|
||||
expect(screen.queryByRole("button", { name: "New pool" })).toBeNull();
|
||||
// Pool tools have no tab any more; an installed tool without a pool does.
|
||||
expect(screen.queryByRole("tab", { name: /Codex/ })).toBeNull();
|
||||
await user.click(screen.getByRole("tab", { name: /Antigravity/ }));
|
||||
expect(screen.getByText("Antigravity has no account pool")).toBeInTheDocument();
|
||||
expect(screen.getByText(/system keyring/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Goose/ })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(card(true));
|
||||
expect(await screen.findByText(/No Claude Code accounts yet/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add Claude Code account" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds a Cursor API key from the Cursor tab", async () => {
|
||||
it("adds a Cursor API key from Add account", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "cu-1" } });
|
||||
accountPool.list.mockResolvedValue({ ...POOL, cursor: [] });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Cursor CLI/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Add Cursor CLI account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "cursor");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(screen.queryByLabelText("From")).toBeNull();
|
||||
await user.type(screen.getByLabelText("Name"), "cu-1");
|
||||
@@ -238,27 +255,7 @@ describe("AccountPoolCard", () => {
|
||||
expect(accountPool.profiles).not.toHaveBeenCalledWith("cursor");
|
||||
});
|
||||
|
||||
it("shares a saved profile from its card", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(await screen.findByRole("button", { name: "Share Work to the pool" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("work"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(
|
||||
Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === "Add account")!
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the groups as cards and saves a new one with members and accounts", async () => {
|
||||
it("builds pools on the board: members, accounts and profiles moved in, one password", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
@@ -272,28 +269,180 @@ describe("AccountPoolCard", () => {
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 2, name: "Team B" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const groupCard = await screen.findByTestId("pool-group");
|
||||
expect(groupCard).toHaveTextContent("Team A");
|
||||
expect(groupCard).toHaveTextContent("leo");
|
||||
expect(screen.getAllByTestId("pool-account")[0]).toHaveTextContent("Team A");
|
||||
await user.click(screen.getByRole("button", { name: "New group" }));
|
||||
await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Group name"), "Team B");
|
||||
await user.click(await screen.findByRole("checkbox", { name: /leo/ }));
|
||||
expect(screen.getByText("now in Team A")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("checkbox", { name: "cx-1" }));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
expect(teamA).toHaveTextContent("leo");
|
||||
expect(teamA).toHaveTextContent("team-1");
|
||||
await screen.findByText("mia");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "New pool" }));
|
||||
const names = screen.getAllByLabelText("Pool name");
|
||||
await user.type(names[names.length - 1]!, "Team B");
|
||||
const teamB = screen.getAllByTestId("pool-group")[1]!;
|
||||
drag(screen.getByText("mia").closest("[data-testid=pool-member]")!, teamB);
|
||||
expect(teamB).toHaveTextContent("mia");
|
||||
const add = within(teamB).getByLabelText("Move an account into Team B");
|
||||
await user.selectOptions(add, "codex|cx-1");
|
||||
await user.selectOptions(add, "claude|profile:work");
|
||||
expect(held(screen.getByTestId("pool-shared"))).not.toContain("cx-1");
|
||||
expect(screen.getByTestId("pool-board-save")).toHaveTextContent("2 unsaved changes");
|
||||
expect(accountPool.updateGroup).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Move the saved profile Work into the pool");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
2,
|
||||
{ userIds: [8], accounts: { claude: [], codex: ["cx-1"], cursor: [] } },
|
||||
{ userIds: [7], accounts: { claude: ["work"], codex: ["cx-1"], cursor: [] } },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Team B", "boss-pass");
|
||||
// Team A did not change: nothing is sent for it.
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drags accounts between pools — one pool each — and a member back to the shared pool", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const teamA = await screen.findByTestId("pool-group");
|
||||
const shared = screen.getByTestId("pool-shared");
|
||||
const item = (col: HTMLElement, name: string) =>
|
||||
within(col)
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes(name))!;
|
||||
drag(item(shared, "team-2"), teamA);
|
||||
expect(held(teamA)).toEqual(["team-1", "team-2"]);
|
||||
// And back again: an account is in one pool, so it leaves the other.
|
||||
drag(item(teamA, "team-1"), shared);
|
||||
expect(held(teamA)).toEqual(["team-2"]);
|
||||
expect(held(shared)).toContain("team-1");
|
||||
expect(teamA).toHaveTextContent("team-2");
|
||||
await user.click(
|
||||
within(teamA).getByRole("button", { name: "Send leo back to the shared pool" })
|
||||
);
|
||||
expect(shared).toHaveTextContent("leo");
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [],
|
||||
accounts: { claude: ["team-2"], codex: [], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("shows each account's plan on its card", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
accounts: [{ ...POOL.accounts[0]!, plan: "Max 5x" }, POOL.accounts[1]!],
|
||||
});
|
||||
render(card(false));
|
||||
const [first, second] = await screen.findAllByTestId("pool-account");
|
||||
expect(within(first!).getByTestId("pool-account-plan")).toHaveTextContent("Max 5x");
|
||||
expect(within(second!).queryByTestId("pool-account-plan")).toBeNull();
|
||||
});
|
||||
|
||||
it("splits an account off onto New pool, and merges a pool by its only account", async () => {
|
||||
accountPool.list.mockResolvedValue({
|
||||
...POOL,
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Team A",
|
||||
users: [{ userId: 8, username: "leo" }],
|
||||
accounts: { claude: ["team-1"], codex: [], cursor: [] },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Team B",
|
||||
users: [{ userId: 7, username: "mia" }],
|
||||
accounts: { claude: [], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
accountPool.createGroup.mockResolvedValue({ group: { id: 3, name: "Solo" } });
|
||||
accountPool.updateGroup.mockResolvedValue({ group: {} });
|
||||
accountPool.deleteGroup.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
const [teamA, teamB] = await screen.findAllByTestId("pool-group");
|
||||
await screen.findByText("mia");
|
||||
const item = (col: HTMLElement, name: string) =>
|
||||
within(col)
|
||||
.getAllByTestId("pool-group-account")
|
||||
.find((c) => c.textContent?.includes(name))!;
|
||||
// Split: team-2 leaves the shared pool for a new pool of its own.
|
||||
drag(item(screen.getByTestId("pool-shared"), "team-2"), screen.getByTestId("pool-new"));
|
||||
const pools = screen.getAllByTestId("pool-group");
|
||||
expect(pools).toHaveLength(3);
|
||||
expect(held(pools[2]!)).toEqual(["team-2"]);
|
||||
await user.type(within(pools[2]!).getByLabelText("Pool name"), "Solo");
|
||||
// Merge: Team B's only account into Team A brings mia along; Team B goes.
|
||||
drag(item(teamB!, "cx-1"), teamA!);
|
||||
expect(screen.getAllByTestId("pool-group")).toHaveLength(2);
|
||||
expect(held(teamA!)).toEqual(["team-1", "cx-1"]);
|
||||
expect(teamA).toHaveTextContent("mia");
|
||||
|
||||
await user.click(
|
||||
within(screen.getByTestId("pool-board-save")).getByRole("button", { name: "Save" })
|
||||
);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("Merge Team B into Team A: pool Team B is deleted");
|
||||
await user.type(within(dialog).getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => expect(accountPool.deleteGroup).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
1,
|
||||
{
|
||||
name: "Team A",
|
||||
userIds: [8, 7],
|
||||
accounts: { claude: ["team-1"], codex: ["cx-1"], cursor: [] },
|
||||
},
|
||||
"boss-pass"
|
||||
);
|
||||
expect(accountPool.createGroup).toHaveBeenCalledWith("Solo", "boss-pass");
|
||||
expect(accountPool.updateGroup).toHaveBeenCalledWith(
|
||||
3,
|
||||
{ userIds: [], accounts: { claude: ["team-2"], codex: [], cursor: [] } },
|
||||
"boss-pass"
|
||||
);
|
||||
// The members moved before the emptied pool was deleted.
|
||||
const order = (fn: { mock: { invocationCallOrder: number[] } }) =>
|
||||
fn.mock.invocationCallOrder[0]!;
|
||||
expect(order(accountPool.updateGroup)).toBeLessThan(order(accountPool.deleteGroup));
|
||||
});
|
||||
|
||||
it("turns Oh My Pi login sharing on from its tab", async () => {
|
||||
@@ -312,7 +461,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
@@ -337,7 +486,7 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: "Add Claude Code account" }));
|
||||
await user.selectOptions(screen.getByTestId("pool-add-account"), "claude");
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
@@ -362,7 +511,6 @@ describe("AccountPoolCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(card(true));
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("tab", { name: /Codex/ }));
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
@@ -425,7 +573,6 @@ describe("AccountPoolCard", () => {
|
||||
render(card(true, "/admin?poolAdd=codex&poolProfile=cx"));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await waitFor(() => expect(accountPool.profiles).toHaveBeenCalledWith("codex"));
|
||||
expect(screen.getByRole("tab", { name: /Codex/ })).toHaveAttribute("aria-selected", "true");
|
||||
await waitFor(() => expect(screen.getByLabelText("Profile")).toHaveValue("cx"));
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Account pool",
|
||||
"description": "Accounts each tool's new sessions run on, one tab per AI tool. The least-used account is picked (or the one a user is pinned to), so nobody signs in per user.",
|
||||
"description": "Each pool holds accounts of every AI tool (Claude Code, Codex, Cursor) and the members who run on them. A member's new session takes the least-used account of their pool, so nobody signs in per user.",
|
||||
"refresh": "Re-read quotas",
|
||||
"add": "Add account",
|
||||
"account": "Account",
|
||||
@@ -254,20 +254,20 @@
|
||||
"shareNamed": "Share {{name}} to the pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
"description": "Give each team its own accounts: a group's members run only on the group's accounts. Users in no group share the accounts no group lists.",
|
||||
"new": "New group",
|
||||
"title": "Pools",
|
||||
"description": "Members in no pool, and accounts in no pool, make up the shared pool.",
|
||||
"new": "New pool",
|
||||
"empty": "No groups yet — every user shares every account.",
|
||||
"members_one": "{{count}} member",
|
||||
"members_other": "{{count}} members",
|
||||
"edit": "Edit group {{name}}",
|
||||
"delete": "Delete group {{name}}",
|
||||
"deleteHint": "Its members go back to the shared pool. No account is removed.",
|
||||
"delete": "Delete pool {{name}}",
|
||||
"deleteHint": "Its members and accounts go back to the shared pool. No account is removed.",
|
||||
"noAccounts": "none — members sign in themselves",
|
||||
"saved": "Group {{name}} saved",
|
||||
"deleted": "Group {{name}} deleted",
|
||||
"deleted": "Pool {{name}} deleted",
|
||||
"hint": "Tick the members and the accounts they run on. A user belongs to one group, so ticking one moves them here. An account can serve several groups.",
|
||||
"name": "Group name",
|
||||
"name": "Pool name",
|
||||
"memberList": "Members",
|
||||
"inGroup": "now in {{group}}",
|
||||
"accountList": "Accounts",
|
||||
@@ -278,7 +278,57 @@
|
||||
"5h": "5 hours",
|
||||
"wk": "Week"
|
||||
},
|
||||
"resetsIn": "resets in {{time}} · {{at}}"
|
||||
"resetsIn": "resets in {{time}} · {{at}}",
|
||||
"members": {
|
||||
"title": "Members",
|
||||
"filter": "Show members",
|
||||
"all": "All",
|
||||
"noGroup": "No pool",
|
||||
"search": "Search members",
|
||||
"tickShown": "Tick all {{count}}",
|
||||
"noneShown": "No member matches."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Drag an account or a member between pools; drop one on New pool to split it off, or drag a pool's only account into another pool to merge them. Nothing is saved until Save.",
|
||||
"sharedHint": "Members and accounts in no pool",
|
||||
"toShared": "Send {{name}} back to the shared pool",
|
||||
"removeAccount": "Send {{name}} back to the shared pool",
|
||||
"moveHere_one": "Move {{count}} here",
|
||||
"moveHere_other": "Move {{count}} here",
|
||||
"noMembers": "No members",
|
||||
"noSharedAccounts": "No account is left for the shared pool.",
|
||||
"dropMembers": "Drop members here",
|
||||
"untitled": "New pool",
|
||||
"new": "not saved yet",
|
||||
"pick": "Pick members of {{name}}",
|
||||
"pickHint": "Filter by role or by \"No pool\", tick who belongs here. A member is in one pool, so ticking one moves them here. Applied on the board — Save sends it.",
|
||||
"apply": "Apply",
|
||||
"addAccount": "Move an account into {{name}}",
|
||||
"addAccountOption": "+ Move an account or saved profile here…",
|
||||
"profilesOf": "Saved {{tool}} profiles — moved into the pool on Save",
|
||||
"profilePending": "A saved profile: it is moved into the pool when you save",
|
||||
"pending_one": "{{count}} unsaved change",
|
||||
"pending_other": "{{count}} unsaved changes",
|
||||
"needsName": "Every pool needs a name.",
|
||||
"discard": "Discard",
|
||||
"saveTitle": "Save the pools",
|
||||
"saveHint": "Type your own password once to apply every change below.",
|
||||
"summaryProfile": "Move the saved profile {{name}} into the pool",
|
||||
"summaryNew": "Create {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"summaryGroup": "Set {{name}}: {{count}} member(s), {{accounts}} account(s)",
|
||||
"saved": "Pools saved",
|
||||
"dropAccounts": "Drag accounts here",
|
||||
"noAccounts": "No accounts",
|
||||
"accountsCount_one": "{{count}} account",
|
||||
"accountsCount_other": "{{count}} accounts",
|
||||
"mergeHint": "Drop to merge {{from}} into {{into}}",
|
||||
"merged": "{{from}} merged into {{into}} — Save to apply",
|
||||
"summaryMerge": "Merge {{from}} into {{into}}: pool {{from}} is deleted",
|
||||
"newDrop": "Drop here to split it into a pool of its own",
|
||||
"newHint": "Or drag an account or a member here to split it off"
|
||||
},
|
||||
"addAccount": "Add account",
|
||||
"otherTools": "Other AI tools"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
},
|
||||
"pool": {
|
||||
"title": "Pool tài khoản",
|
||||
"description": "Các tài khoản mà phiên mới của từng tool chạy trên đó, mỗi AI tool một tab. Tự chọn tài khoản ít tải nhất (hoặc tài khoản user được gán), không ai phải đăng nhập riêng.",
|
||||
"description": "Mỗi pool chứa tài khoản của mọi AI tool (Claude Code, Codex, Cursor) và các member dùng chúng. Session mới của member chạy trên tài khoản dùng ít nhất trong pool của họ, không ai phải tự đăng nhập.",
|
||||
"refresh": "Đọc lại quota",
|
||||
"add": "Thêm tài khoản",
|
||||
"account": "Tài khoản",
|
||||
@@ -254,20 +254,20 @@
|
||||
"shareNamed": "Chia sẻ {{name}} vào pool"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Nhóm",
|
||||
"description": "Mỗi nhóm có tài khoản riêng: thành viên của nhóm chỉ chạy trên tài khoản của nhóm. User không thuộc nhóm nào dùng chung các tài khoản không nằm trong nhóm nào.",
|
||||
"new": "Tạo nhóm",
|
||||
"title": "Pool",
|
||||
"description": "Member và tài khoản chưa thuộc pool nào nằm trong pool chung.",
|
||||
"new": "Tạo pool",
|
||||
"empty": "Chưa có nhóm — mọi user dùng chung mọi tài khoản.",
|
||||
"members_one": "{{count}} thành viên",
|
||||
"members_other": "{{count}} thành viên",
|
||||
"edit": "Sửa nhóm {{name}}",
|
||||
"delete": "Xoá nhóm {{name}}",
|
||||
"deleteHint": "Các thành viên quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"delete": "Xoá pool {{name}}",
|
||||
"deleteHint": "Member và tài khoản của pool quay về pool chung. Không tài khoản nào bị xoá.",
|
||||
"noAccounts": "chưa có — thành viên tự đăng nhập",
|
||||
"saved": "Đã lưu nhóm {{name}}",
|
||||
"deleted": "Đã xoá nhóm {{name}}",
|
||||
"deleted": "Đã xoá pool {{name}}",
|
||||
"hint": "Tick các thành viên và tài khoản mà họ dùng. Mỗi user thuộc một nhóm, tick một user sẽ chuyển họ sang đây. Một tài khoản có thể dùng cho nhiều nhóm.",
|
||||
"name": "Tên nhóm",
|
||||
"name": "Tên pool",
|
||||
"memberList": "Thành viên",
|
||||
"inGroup": "đang ở {{group}}",
|
||||
"accountList": "Tài khoản",
|
||||
@@ -278,7 +278,57 @@
|
||||
"5h": "5 giờ",
|
||||
"wk": "Tuần"
|
||||
},
|
||||
"resetsIn": "reset sau {{time}} · {{at}}"
|
||||
"resetsIn": "reset sau {{time}} · {{at}}",
|
||||
"members": {
|
||||
"title": "Thành viên",
|
||||
"filter": "Hiện thành viên",
|
||||
"all": "Tất cả",
|
||||
"noGroup": "Chưa có pool",
|
||||
"search": "Tìm thành viên",
|
||||
"tickShown": "Chọn cả {{count}}",
|
||||
"noneShown": "Không có thành viên nào khớp."
|
||||
},
|
||||
"board": {
|
||||
"howTo": "Kéo tài khoản hoặc member giữa các pool; thả vào ô Tạo pool để tách ra, hoặc kéo tài khoản duy nhất của một pool sang pool khác để gộp. Chỉ lưu khi bấm Lưu.",
|
||||
"sharedHint": "Member và tài khoản chưa thuộc pool nào",
|
||||
"toShared": "Đưa {{name}} về pool chung",
|
||||
"removeAccount": "Đưa {{name}} về pool chung",
|
||||
"moveHere_one": "Chuyển {{count}} vào đây",
|
||||
"moveHere_other": "Chuyển {{count}} vào đây",
|
||||
"noMembers": "Chưa có thành viên",
|
||||
"noSharedAccounts": "Không còn tài khoản nào cho pool chung.",
|
||||
"dropMembers": "Thả thành viên vào đây",
|
||||
"untitled": "Pool mới",
|
||||
"new": "chưa lưu",
|
||||
"pick": "Chọn thành viên cho {{name}}",
|
||||
"pickHint": "Lọc theo role hoặc \"Chưa có pool\", tick người thuộc pool này. Mỗi người chỉ ở một pool, tick sẽ chuyển họ vào đây. Áp dụng lên bảng — bấm Lưu mới gửi.",
|
||||
"apply": "Áp dụng",
|
||||
"addAccount": "Chuyển tài khoản vào {{name}}",
|
||||
"addAccountOption": "+ Chuyển tài khoản hoặc profile đã lưu vào đây…",
|
||||
"profilesOf": "Profile {{tool}} đã lưu — chuyển vào pool khi Lưu",
|
||||
"profilePending": "Profile đã lưu: sẽ được chuyển vào pool khi bạn lưu",
|
||||
"pending_one": "{{count}} thay đổi chưa lưu",
|
||||
"pending_other": "{{count}} thay đổi chưa lưu",
|
||||
"needsName": "Pool nào cũng cần có tên.",
|
||||
"discard": "Bỏ thay đổi",
|
||||
"saveTitle": "Lưu các pool",
|
||||
"saveHint": "Nhập mật khẩu của bạn một lần để áp dụng mọi thay đổi bên dưới.",
|
||||
"summaryProfile": "Chuyển profile {{name}} vào pool",
|
||||
"summaryNew": "Tạo {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"summaryGroup": "Cập nhật {{name}}: {{count}} thành viên, {{accounts}} tài khoản",
|
||||
"saved": "Đã lưu các pool",
|
||||
"dropAccounts": "Kéo tài khoản vào đây",
|
||||
"noAccounts": "Chưa có tài khoản",
|
||||
"accountsCount_one": "{{count}} tài khoản",
|
||||
"accountsCount_other": "{{count}} tài khoản",
|
||||
"mergeHint": "Thả để gộp {{from}} vào {{into}}",
|
||||
"merged": "Đã gộp {{from}} vào {{into}} — bấm Lưu để áp dụng",
|
||||
"summaryMerge": "Gộp {{from}} vào {{into}}: xoá pool {{from}}",
|
||||
"newDrop": "Thả vào đây để tách thành pool riêng",
|
||||
"newHint": "Hoặc kéo tài khoản / member vào đây để tách ra"
|
||||
},
|
||||
"addAccount": "Thêm tài khoản",
|
||||
"otherTools": "AI tool khác"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
|
||||
@@ -810,6 +810,8 @@ export type PoolAccount = {
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** Readable plan of a login ("Max 5x", "Team Premium"); null for a setup-token. */
|
||||
plan?: string | null;
|
||||
};
|
||||
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
|
||||
export type CodexPoolAccount = {
|
||||
@@ -828,6 +830,8 @@ export type CodexPoolAccount = {
|
||||
} | null;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
/** A ChatGPT login's plan ("Plus", "Pro", "Team"); null for an API key. */
|
||||
plan?: string | null;
|
||||
};
|
||||
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
|
||||
export type AccountPin = {
|
||||
@@ -4778,6 +4782,8 @@ export interface CcAccountIdentity {
|
||||
organization: string | null;
|
||||
seatTier: string | null;
|
||||
subscriptionType: string | null;
|
||||
/** Readable plan from tier + seat ("Max 5x", "Team Premium"); prefer it. */
|
||||
plan?: string | null;
|
||||
/** Access-token expiry, epoch ms; a switch still works past it (the refresh
|
||||
* token is stored too). */
|
||||
expiresAt: number | null;
|
||||
|
||||
@@ -228,6 +228,8 @@ describe("Users page", () => {
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(false);
|
||||
// The pool board reads the user list too; count the page's own reload.
|
||||
const before = users.list.mock.calls.length;
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
@@ -236,7 +238,7 @@ describe("Users page", () => {
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||
expect(users.list).toHaveBeenCalledTimes(before + 1); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the edit dialog and keeps it open", async () => {
|
||||
|
||||
Reference in New Issue
Block a user