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

1024 lines
37 KiB
TypeScript

/**
* @file PoolGroups.tsx
* @description The account POOLS board (`/api/accounts/groups`,
* server/lib/pool-groups.js — a pool is a group on the server): one column per
* pool holding its accounts of every AI tool (each drawn by the caller, with
* its plan and quota) and its members, plus the shared pool — the members in
* no pool and the accounts no pool lists. An admin drags a member or an
* account from one column to another (an account sits in ONE pool, so a drop
* moves it); drops one on the trailing New pool column to SPLIT it into a new
* pool; and drops a pool's ONLY account into another column to MERGE that pool
* there (its members come along and the emptied pool is deleted on save). A
* column's menu moves an account — or a saved login profile, moved into the
* pool on save — in without dragging; Pick members ticks members by role; a
* pool is renamed in place. Nothing is sent until Save, which asks for the
* admin's own password ONCE and applies every change: profiles moved in
* (`POST /api/accounts`), new pools created, each changed pool set exactly
* (`PATCH`), merged pools deleted (`DELETE`). The trash on a saved pool
* deletes it on its own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Combine, Plus, Trash2, UserPlus, X } from "lucide-react";
import {
api,
type AccountPool,
type PoolGroup,
type PoolProfile,
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { MemberPicker } from "./MemberPicker";
/** Pool tools with their labels, in display order. */
export const POOL_TOOL_LABELS: { id: PoolTool; label: string }[] = [
{ id: "claude", label: "Claude Code" },
{ id: "codex", label: "Codex" },
{ id: "cursor", label: "Cursor CLI" },
];
const TOOLS = POOL_TOOL_LABELS.map((x) => x.id);
const toolLabel = (tool: PoolTool) => POOL_TOOL_LABELS.find((x) => x.id === tool)!.label;
/** Tools whose saved login profiles can be moved into the pool. */
const PROFILE_TOOLS: PoolTool[] = ["claude", "codex"];
/** A pool's account entry that is a saved profile still to be moved in. */
const PROFILE = "profile:";
const DND = "application/x-agenthub-pool";
/** Drop-zone keys of the shared pool and of the trailing New pool column. */
const SHARED = "shared";
const NEW = "new";
/** Account names of `tool` in the pool. */
function accountNames(pool: AccountPool, tool: PoolTool): string[] {
const list = tool === "claude" ? pool.accounts : tool === "codex" ? pool.codex : pool.cursor;
return (list ?? []).map((a) => a.name);
}
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;
}
/** A pool as the board edits it; `id` null = not created yet. */
type Draft = {
key: string;
id: number | null;
name: string;
userIds: number[];
accounts: Record<PoolTool, string[]>;
};
/** A saved pool merged into another: deleted on Save. */
type Removed = { id: number; name: string; into: string };
/** What is being dragged; `from` = the column key it left (null = shared). */
type Drag =
| { kind: "user"; id: number; from: string | null }
| { kind: "account"; tool: PoolTool; name: string; from: string | null };
const fromPool = (pool: AccountPool): Draft[] =>
(pool.groups ?? []).map((g) => ({
key: `g${g.id}`,
id: g.id,
name: g.name,
userIds: g.users.map((u) => u.userId),
accounts: Object.fromEntries(TOOLS.map((x) => [x, [...(g.accounts[x] ?? [])]])) as Record<
PoolTool,
string[]
>,
}));
/** Order-free fingerprint of a draft, to tell what changed. */
const sig = (d: Draft) =>
JSON.stringify([
d.name,
[...d.userIds].sort((a, b) => a - b),
TOOLS.map((x) => [...d.accounts[x]].sort()),
]);
const sigAll = (ds: Draft[]) => ds.map((d) => `${d.key}:${sig(d)}`).join("|");
const accountCount = (d: Pick<Draft, "accounts">) =>
TOOLS.reduce((n, tool) => n + d.accounts[tool].length, 0);
const emptyAccounts = (): Record<PoolTool, string[]> => ({ claude: [], codex: [], cursor: [] });
export function PoolGroups({
pool,
isAdmin,
onChanged,
renderAccount,
}: {
pool: AccountPool;
isAdmin: boolean;
onChanged: () => void;
/** One pool account as a card (plan, quota, live panes, its actions). */
renderAccount: (tool: PoolTool, name: string) => ReactNode;
}) {
const { t } = useTranslation("users");
const [draft, setDraft] = useState<Draft[]>(() => fromPool(pool));
const [removed, setRemoved] = useState<Removed[]>([]);
const base = useRef(pool);
// Set by Save: the next pool read replaces the board whatever it holds.
const saved = useRef(false);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [profiles, setProfiles] = useState<Record<PoolTool, PoolProfile[]>>({
claude: [],
codex: [],
cursor: [],
});
const [dragging, setDragging] = useState<Drag | null>(null);
const [over, setOver] = useState<string | null>(null);
const [picking, setPicking] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<PoolGroup | null>(null);
const seq = useRef(0);
const dirty = removed.length > 0 || sigAll(draft) !== sigAll(fromPool(pool));
// A re-read pool replaces an untouched board; a touched one keeps its edits
// but drops a pool that is gone.
useEffect(() => {
if (base.current === pool) return;
const untouched =
saved.current || (removed.length === 0 && sigAll(draft) === sigAll(fromPool(base.current)));
base.current = pool;
saved.current = false;
if (untouched) {
setDraft(fromPool(pool));
setRemoved([]);
} else {
const ids = new Set((pool.groups ?? []).map((g) => g.id));
setDraft((ds) => ds.filter((d) => d.id === null || ids.has(d.id)));
setRemoved((rs) => rs.filter((r) => ids.has(r.id)));
}
}, [pool]);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
useEffect(() => {
if (!isAdmin) return;
for (const tool of PROFILE_TOOLS) {
api.accountPool
.profiles(tool)
.then((r) => setProfiles((p) => ({ ...p, [tool]: r.profiles })))
.catch(() => {});
}
}, [isAdmin, pool]);
// Everyone the board knows: the user list, or at least each pool's members.
const people = useMemo<ServerUserUsage[]>(() => {
if (users && users.length) return users;
const seen = new Map<number, ServerUserUsage>();
for (const g of pool.groups ?? [])
for (const u of g.users)
seen.set(u.userId, {
id: u.userId,
username: u.username,
role: "developer",
} as ServerUserUsage);
return [...seen.values()];
}, [users, pool.groups]);
const byId = new Map(people.map((u) => [u.id, u]));
const nameOf = (d: Draft) => d.name.trim() || t("pool.board.untitled");
const groupNameOf = (userId: number) => {
const d = draft.find((x) => x.userIds.includes(userId));
return d ? nameOf(d) : null;
};
const profileLabel = (tool: PoolTool, name: string) =>
profiles[tool].find((p) => p.name === name)?.label ?? name;
/** The pool a dragged account would empty — the one a drop merges. */
const mergeSource = (drag: Drag | null): Draft | null => {
if (!drag || drag.kind !== "account" || !drag.from) return null;
const src = draft.find((d) => d.key === drag.from);
return src && accountCount(src) === 1 ? src : null;
};
// ── Edits (staged) ────────────────────────────────────────────────────
const patch = (key: string, fn: (d: Draft) => Draft) =>
setDraft((ds) => ds.map((d) => (d.key === key ? fn(d) : d)));
/** A member is in one pool: take them out of every other. `to` null = shared. */
const moveUsers = (ids: number[], to: string | null) =>
setDraft((ds) =>
ds.map((d) => {
const kept = d.userIds.filter((id) => !ids.includes(id));
return { ...d, userIds: d.key === to ? [...kept, ...ids] : kept };
})
);
/** An account is in one pool too: a move takes it out of every other. */
const moveAccount = (tool: PoolTool, name: string, to: string | null) =>
setDraft((ds) =>
ds.map((d) => {
const kept = d.accounts[tool].filter((n) => n !== name);
return {
...d,
accounts: { ...d.accounts, [tool]: d.key === to ? [...kept, name] : kept },
};
})
);
const nextKey = () => {
seq.current += 1;
return `new${seq.current}`;
};
const newPool = (seed?: Partial<Pick<Draft, "userIds" | "accounts">>) => {
const key = nextKey();
setDraft((ds) => [
...ds,
{ key, id: null, name: "", userIds: [], accounts: emptyAccounts(), ...seed },
]);
return key;
};
/** Split: what was dropped on New pool leaves its column for a pool of its own. */
const split = (drag: Drag) => {
if (drag.kind === "user") {
moveUsers([drag.id], null);
newPool({ userIds: [drag.id] });
} else {
moveAccount(drag.tool, drag.name, null);
newPool({ accounts: { ...emptyAccounts(), [drag.tool]: [drag.name] } });
}
};
/** Merge: `src`'s only account and every member join `to`; `src` goes away. */
const merge = (src: Draft, tool: PoolTool, name: string, to: string | null) => {
const target = to ? draft.find((d) => d.key === to) : null;
setDraft((ds) =>
ds
.filter((d) => d.key !== src.key)
.map((d) =>
d.key === to
? {
...d,
userIds: [...d.userIds, ...src.userIds.filter((id) => !d.userIds.includes(id))],
accounts: { ...d.accounts, [tool]: [...d.accounts[tool], name] },
}
: d
)
);
const into = target ? nameOf(target) : t("pool.groups.shared");
if (src.id !== null) {
const id = src.id;
setRemoved((rs) => [...rs, { id, name: nameOf(src), into }]);
}
toast.info(t("pool.board.merged", { from: nameOf(src), into }));
};
const onDrop = (e: DragEvent, to: string | null) => {
e.preventDefault();
setOver(null);
setDragging(null);
let drag: Drag;
try {
drag = JSON.parse(e.dataTransfer.getData(DND)) as Drag;
} catch {
return;
}
if (to === NEW) return split(drag);
if (drag.from === to) return;
if (drag.kind === "user") return moveUsers([drag.id], to);
const src = mergeSource(drag);
if (src) return merge(src, drag.tool, drag.name, to);
moveAccount(drag.tool, drag.name, to);
};
const dragProps = (drag: Drag) =>
isAdmin
? {
draggable: true,
onDragStart: (e: DragEvent) => {
e.stopPropagation();
e.dataTransfer.setData(DND, JSON.stringify(drag));
e.dataTransfer.effectAllowed = "move";
// After the browser took its drag image, so the image is not faded.
requestAnimationFrame(() => setDragging(drag));
},
onDragEnd: () => {
setDragging(null);
setOver(null);
},
}
: {};
const isDragged = (drag: Drag) =>
dragging !== null &&
dragging.kind === drag.kind &&
(drag.kind === "user"
? dragging.kind === "user" && dragging.id === drag.id
: dragging.kind === "account" && dragging.tool === drag.tool && dragging.name === drag.name);
const dropZone = (key: string | null) =>
isAdmin
? {
onDragOver: (e: DragEvent) => {
if (!e.dataTransfer.types.includes(DND)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setOver(key ?? SHARED);
},
onDragLeave: (e: DragEvent) => {
// Leaving for a child of the same column is still over it.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setOver((o) => (o === (key ?? SHARED) ? null : o));
},
onDrop: (e: DragEvent) => onDrop(e, key),
}
: {};
// ── What Save will do ─────────────────────────────────────────────────
const original = new Map(fromPool(pool).map((d) => [d.key, sig(d)]));
const changedGroups = draft.filter((d) => d.id === null || original.get(d.key) !== sig(d));
const pendingProfiles = [
...new Set(
draft.flatMap((d) =>
TOOLS.flatMap((tool) =>
d.accounts[tool].filter((n) => n.startsWith(PROFILE)).map((n) => `${tool}|${n}`)
)
)
),
];
const unnamed = draft.some((d) => !d.name.trim());
const sharedAccounts = Object.fromEntries(
TOOLS.map((tool) => [
tool,
accountNames(pool, tool).filter((n) => !draft.some((d) => d.accounts[tool].includes(n))),
])
) as Record<PoolTool, string[]>;
const ungrouped = people.filter((u) => groupNameOf(u.id) === null).map((u) => u.id);
const discard = () => {
setDraft(fromPool(pool));
setRemoved([]);
};
const memberChip = (id: number, from: string | null) => {
const u = byId.get(id);
const name = u?.username ?? `#${id}`;
const drag: Drag = { kind: "user", id, from };
return (
<span
key={id}
{...dragProps(drag)}
className={`inline-flex animate-fade-in items-center gap-1.5 rounded-full border border-border bg-surface-1 py-0.5 pl-0.5 text-[11px] text-fg-primary shadow-sm transition-[opacity,border-color] hover:border-accent/50 ${
isAdmin ? "cursor-grab active:cursor-grabbing pr-0.5" : "pr-2"
} ${isDragged(drag) ? "opacity-40" : ""}`}
data-testid="pool-member"
>
<span
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent/15 text-[9px] font-semibold uppercase text-accent"
aria-hidden
>
{name.charAt(0)}
</span>
{name}
{u && <span className="text-[10px] text-fg-muted">{t(`roles.${u.role}`)}</span>}
{isAdmin && from && (
<button
type="button"
onClick={() => moveUsers([id], null)}
aria-label={t("pool.board.toShared", { name })}
title={t("pool.board.toShared", { name })}
className="rounded-full p-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
)}
</span>
);
};
const accountItem = (tool: PoolTool, name: string, from: string | null) => {
const profile = name.startsWith(PROFILE);
const label = profile ? profileLabel(tool, name.slice(PROFILE.length)) : name;
const drag: Drag = { kind: "account", tool, name, from };
return (
<div
key={`${tool}|${name}`}
{...dragProps(drag)}
className={`animate-fade-in transition-opacity ${
isAdmin ? "cursor-grab active:cursor-grabbing" : ""
} ${isDragged(drag) ? "opacity-40" : ""}`}
data-testid="pool-group-account"
>
{profile ? (
<div
title={t("pool.board.profilePending")}
className="flex items-center gap-1.5 rounded-lg border border-dashed border-accent/50 bg-accent/5 px-3 py-2 text-xs text-accent"
>
<AiToolIcon tool={{ id: tool, label: tool }} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">{label}</span>
{isAdmin && from && (
<button
type="button"
onClick={() => moveAccount(tool, name, null)}
aria-label={t("pool.board.removeAccount", { name: label })}
className="rounded p-0.5 text-fg-muted hover:text-fg-primary"
>
<X className="h-3 w-3" />
</button>
)}
</div>
) : (
renderAccount(tool, name)
)}
</div>
);
};
/** A column's accounts, under a heading per tool that has any. */
const accountList = (accounts: Record<PoolTool, string[]>, from: string | null) => {
const tools = TOOLS.filter((tool) => accounts[tool].length > 0);
if (tools.length === 0)
return (
<p className="rounded-lg border border-dashed border-border px-3 py-5 text-center text-[11px] text-fg-muted">
{isAdmin ? t("pool.board.dropAccounts") : t("pool.board.noAccounts")}
</p>
);
return tools.map((tool) => (
<div key={tool} className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
<AiToolIcon tool={{ id: tool, label: tool }} className="h-3.5 w-3.5" />
{toolLabel(tool)}
<span className="rounded-full bg-surface-3 px-1.5 tabular-nums">
{accounts[tool].length}
</span>
</div>
{accounts[tool].map((n) => accountItem(tool, n, from))}
</div>
));
};
/** Move-here menu: accounts in other pools, and saved profiles (pools only). */
const moveMenu = (key: string | null, here: Record<PoolTool, string[]>, name: string) => {
const options = TOOLS.map((tool) => ({
tool,
accounts: accountNames(pool, tool).filter((n) => !here[tool].includes(n)),
profiles: (key && PROFILE_TOOLS.includes(tool) ? profiles[tool] : []).filter(
(p) => !draft.some((d) => d.accounts[tool].includes(PROFILE + p.name))
),
}));
if (!options.some((o) => o.accounts.length || o.profiles.length)) return null;
return (
<select
className="input w-full border-dashed bg-transparent py-1 text-xs text-fg-muted"
value=""
onChange={(e) => {
const [tool, n] = e.target.value.split("|") as [PoolTool, string];
if (!n) return;
const from = draft.find((d) => d.accounts[tool].includes(n))?.key ?? null;
const src = n.startsWith(PROFILE)
? null
: mergeSource({ kind: "account", tool, name: n, from });
if (src && src.key !== key) merge(src, tool, n, key);
else moveAccount(tool, n, key);
}}
aria-label={t("pool.board.addAccount", { name })}
>
<option value="">{t("pool.board.addAccountOption")}</option>
{options.map(
(o) =>
o.accounts.length > 0 && (
<optgroup key={o.tool} label={toolLabel(o.tool)}>
{o.accounts.map((n) => {
const inPool = draft.find((d) => d.accounts[o.tool].includes(n));
return (
<option key={n} value={`${o.tool}|${n}`}>
{n} — {inPool ? nameOf(inPool) : t("pool.groups.shared")}
</option>
);
})}
</optgroup>
)
)}
{options.map(
(o) =>
o.profiles.length > 0 && (
<optgroup
key={`p-${o.tool}`}
label={t("pool.board.profilesOf", { tool: toolLabel(o.tool) })}
>
{o.profiles.map((p) => (
<option key={p.name} value={`${o.tool}|${PROFILE}${p.name}`}>
{p.label}
{p.email ? ` — ${p.email}` : ""}
</option>
))}
</optgroup>
)
)}
</select>
);
};
const counts = (accounts: Record<PoolTool, string[]>, members: number[]) => (
<div className="mt-0.5 flex gap-2 text-[11px] text-fg-muted">
<span>{t("pool.board.accountsCount", { count: accountCount({ accounts }) })}</span>
<span aria-hidden>·</span>
<span>{t("pool.groups.members", { count: members.length })}</span>
</div>
);
const src = mergeSource(dragging);
const column = (
key: string | null,
head: ReactNode,
accounts: Record<PoolTool, string[]>,
members: number[],
testId: string,
name: string
) => {
const zone = key ?? SHARED;
const isOver = over === zone && dragging?.from !== key;
const merging = src && src.key !== key;
return (
<div
{...dropZone(key)}
className={`relative flex flex-col gap-3 rounded-xl border p-3 transition-[border-color,background-color,box-shadow] duration-150 ${
key ? "border-t-2 border-t-accent/60 bg-surface-2" : "bg-surface-1"
} ${
isOver
? "border-accent bg-accent/5 shadow-lg ring-2 ring-accent/30"
: dragging && dragging.from !== key
? "border-dashed border-accent/40"
: "border-border"
}`}
data-testid={testId}
>
{head}
{isOver && merging && (
<div className="flex animate-fade-in items-center gap-1.5 rounded-md bg-accent/10 px-2 py-1 text-[11px] text-accent">
<Combine className="h-3.5 w-3.5" aria-hidden />
{t("pool.board.mergeHint", { from: nameOf(src), into: name })}
</div>
)}
<div className="space-y-3">{accountList(accounts, key)}</div>
{isAdmin && moveMenu(key, accounts, name)}
<div className="border-t border-border/60 pt-2">
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("pool.members.title")}
</div>
<div className="flex flex-wrap gap-1">
{members.length === 0 ? (
<span className="text-[11px] text-fg-muted">
{isAdmin ? t("pool.board.dropMembers") : t("pool.board.noMembers")}
</span>
) : (
members.map((id) => memberChip(id, key))
)}
</div>
</div>
</div>
);
};
return (
<div className="px-5 py-4" data-testid="pool-groups">
<p className="text-xs text-fg-muted">
{t("pool.groups.description")} {isAdmin && t("pool.board.howTo")}
</p>
<div className="mt-3 grid items-start gap-3 md:grid-cols-2 xl:grid-cols-3">
{column(
null,
<div>
<div className="text-sm font-semibold text-fg-primary">{t("pool.groups.shared")}</div>
<div className="text-[11px] text-fg-muted">{t("pool.board.sharedHint")}</div>
{counts(sharedAccounts, ungrouped)}
</div>,
sharedAccounts,
ungrouped,
"pool-shared",
t("pool.groups.shared")
)}
{draft.map((d) => {
const existing = d.id !== null ? (pool.groups ?? []).find((g) => g.id === d.id) : null;
const name = nameOf(d);
return (
<div key={d.key} className="contents">
{column(
d.key,
<div className="flex items-start gap-1">
<div className="flex-1 min-w-0">
{isAdmin ? (
<input
className="w-full rounded border border-transparent bg-transparent px-1 -mx-1 text-sm font-semibold text-fg-primary transition-colors hover:border-border focus:border-accent focus:outline-none"
value={d.name}
autoFocus={d.id === null && !d.name}
onChange={(e) => patch(d.key, (x) => ({ ...x, name: e.target.value }))}
placeholder={t("pool.groups.name")}
aria-label={t("pool.groups.name")}
/>
) : (
<div className="truncate text-sm font-semibold text-fg-primary">{d.name}</div>
)}
<div className="flex items-center gap-2">
{counts(d.accounts, d.userIds)}
{d.id === null && (
<span className="mt-0.5 rounded-full bg-accent/10 px-1.5 text-[10px] text-accent">
{t("pool.board.new")}
</span>
)}
</div>
</div>
{isAdmin && (
<>
<button
type="button"
onClick={() => setPicking(d.key)}
title={t("pool.board.pick", { name })}
aria-label={t("pool.board.pick", { name })}
className="btn-icon"
>
<UserPlus className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
existing
? setDeleting(existing)
: setDraft((ds) => ds.filter((x) => x.key !== d.key))
}
title={t("pool.groups.delete", { name })}
aria-label={t("pool.groups.delete", { name })}
className="btn-icon hover:text-status-danger"
>
<Trash2 className="h-4 w-4" />
</button>
</>
)}
</div>,
d.accounts,
d.userIds,
"pool-group",
name
)}
</div>
);
})}
{isAdmin && (
<button
type="button"
{...dropZone(NEW)}
onClick={() => newPool()}
aria-label={t("pool.groups.new")}
className={`flex min-h-[10rem] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-4 text-center transition-all duration-150 ${
over === NEW
? "border-accent bg-accent/10 text-accent shadow-lg ring-2 ring-accent/30"
: dragging
? "border-accent/50 bg-accent/5 text-accent"
: "border-border text-fg-muted hover:border-accent/50 hover:text-fg-secondary"
}`}
data-testid="pool-new"
>
<Plus className="h-6 w-6" aria-hidden />
<span className="text-sm font-medium">{t("pool.groups.new")}</span>
<span className="max-w-[16rem] text-[11px] opacity-80">
{dragging ? t("pool.board.newDrop") : t("pool.board.newHint")}
</span>
</button>
)}
</div>
{isAdmin && dirty && (
<div
className="sticky bottom-3 z-10 mt-3 flex animate-slide-up flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-surface-1/95 px-3 py-2 shadow-xl backdrop-blur"
data-testid="pool-board-save"
>
<span className="flex-1 text-xs text-fg-secondary">
{t("pool.board.pending", {
count: changedGroups.length + pendingProfiles.length + removed.length,
})}
{unnamed && (
<span className="ml-2 text-status-warning">{t("pool.board.needsName")}</span>
)}
</span>
<button type="button" onClick={discard} className="btn-ghost text-xs">
{t("pool.board.discard")}
</button>
<button
type="button"
disabled={unnamed}
onClick={() => setSaving(true)}
className="btn-primary text-xs"
>
{t("pool.save")}
</button>
</div>
)}
{picking && (
<PickMembersDialog
users={users}
group={draft.find((d) => d.key === picking)!}
groupOf={groupNameOf}
onClose={() => setPicking(null)}
onDone={(ids) => {
const d = draft.find((x) => x.key === picking)!;
const added = ids.filter((id) => !d.userIds.includes(id));
setDraft((ds) =>
ds.map((x) =>
x.key === picking
? { ...x, userIds: ids }
: { ...x, userIds: x.userIds.filter((id) => !added.includes(id)) }
)
);
setPicking(null);
}}
/>
)}
{saving && (
<SaveBoardDialog
draft={draft}
changed={changedGroups}
profiles={pendingProfiles}
removed={removed}
profileLabel={profileLabel}
onClose={() => setSaving(false)}
onProgress={(d, r) => {
setDraft(d);
setRemoved(r);
}}
onDone={() => {
toast.success(t("pool.board.saved"));
setSaving(false);
saved.current = true;
onChanged();
}}
/>
)}
{deleting && (
<DeleteGroupDialog
group={deleting}
onClose={() => setDeleting(null)}
onDone={() => {
toast.success(t("pool.groups.deleted", { name: deleting.name }));
setDeleting(null);
onChanged();
}}
/>
)}
</div>
);
}
/** A column's Pick members: tick by role or "No group"; staged on the board. */
function PickMembersDialog({
users,
group,
groupOf,
onClose,
onDone,
}: {
users: ServerUserUsage[] | null;
group: Draft;
groupOf: (userId: number) => string | null;
onClose: () => void;
onDone: (ids: number[]) => void;
}) {
const { t } = useTranslation("users");
const [chosen, setChosen] = useState<Set<number>>(() => new Set(group.userIds));
const name = group.name || t("pool.board.untitled");
return (
<ConfirmModal
open
title={t("pool.board.pick", { name })}
message={t("pool.board.pickHint")}
confirmLabel={t("pool.board.apply")}
cancelLabel={t("common:cancel")}
destructive={false}
onConfirm={() => onDone([...chosen])}
onCancel={onClose}
>
<div className="mt-3">
<MemberPicker
users={users}
chosen={chosen}
onChange={setChosen}
groupOf={groupOf}
grouped
note={(u) => {
const g = groupOf(u.id);
return g && g !== group.name ? (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.groups.inGroup", { group: g })}
</span>
) : null;
}}
/>
</div>
</ConfirmModal>
);
}
/**
* Save: the admin's password once, then every staged change in order —
* profiles moved into the pool, new pools created, changed pools set, merged
* pools deleted. What already went through is written back into the board, so
* a retry after a failure never moves a profile in twice, makes a pool again
* or deletes one twice.
*/
function SaveBoardDialog({
draft,
changed,
profiles,
removed,
profileLabel,
onClose,
onProgress,
onDone,
}: {
draft: Draft[];
changed: Draft[];
/** `tool|profile:name` entries to move into the pool first. */
profiles: string[];
/** Saved pools merged into another, deleted last. */
removed: Removed[];
profileLabel: (tool: PoolTool, name: string) => string;
onClose: () => void;
onProgress: (d: Draft[], removed: Removed[]) => 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);
let work = draft.map((d) => ({
...d,
userIds: [...d.userIds],
accounts: { ...d.accounts },
}));
const todo = new Set(changed.map((d) => d.key));
let gone = [...removed];
try {
for (const entry of profiles) {
const [tool, ref] = entry.split("|") as [PoolTool, string];
const profile = ref.slice(PROFILE.length);
const r = await api.accountPool.add({ tool, name: profile, profile }, current);
work = work.map((d) => ({
...d,
accounts: {
...d.accounts,
[tool]: d.accounts[tool].map((n) => (n === ref ? r.account.name : n)),
},
}));
}
for (const d of work) {
if (!todo.has(d.key)) continue;
let id = d.id;
if (id === null) {
id = (await api.accountPool.createGroup(d.name.trim(), current)).group.id;
const made = id;
work = work.map((x) => (x.key === d.key ? { ...x, id: made } : x));
}
await api.accountPool.updateGroup(
id,
{
...(d.id !== null ? { name: d.name.trim() } : {}),
userIds: d.userIds,
accounts: Object.fromEntries(TOOLS.map((tool) => [tool, d.accounts[tool]])),
},
current
);
todo.delete(d.key);
}
// Last: the merged pools' members already moved into their new pool.
for (const r of removed) {
await api.accountPool.deleteGroup(r.id, current);
gone = gone.filter((x) => x.id !== r.id);
}
onDone();
} catch (e) {
onProgress(work, gone);
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.board.saveTitle")}
message={t("pool.board.saveHint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<ul className="mt-3 space-y-1 text-xs text-fg-secondary" data-testid="pool-board-summary">
{profiles.map((entry) => {
const [tool, ref] = entry.split("|") as [PoolTool, string];
return (
<li key={entry}>
{t("pool.board.summaryProfile", {
name: profileLabel(tool, ref.slice(PROFILE.length)),
})}
</li>
);
})}
{removed.map((r) => (
<li key={`m${r.id}`}>{t("pool.board.summaryMerge", { from: r.name, into: r.into })}</li>
))}
{changed.map((d) => (
<li key={d.key}>
{t(d.id === null ? "pool.board.summaryNew" : "pool.board.summaryGroup", {
name: d.name.trim(),
count: d.userIds.length,
accounts: TOOLS.reduce((n, tool) => n + d.accounts[tool].length, 0),
})}
</li>
))}
</ul>
<label className="mt-3 block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
<span className="mt-0.5 block text-[11px] text-fg-muted">{t("yourPasswordHint")}</span>
</label>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
}
function DeleteGroupDialog({
group,
onClose,
onDone,
}: {
group: PoolGroup;
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.deleteGroup(group.id, current);
onDone();
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.groups.delete", { name: group.name })}
message={t("pool.groups.deleteHint")}
confirmLabel={t("pool.removeButton")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!current}
onConfirm={() => void submit()}
onCancel={onClose}
>
<label className="mt-3 block">
<span className="text-xs font-medium text-fg-secondary">{t("yourPassword")}</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</label>
{error && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error}
</p>
)}
</ConfirmModal>
);
}