26aabd6660
- The Users page account pool is one board: a column per pool holding its Claude Code, Codex and Cursor accounts (plan, quota, live panes) and its members. Accounts sit in one pool; drag moves them. Drop on New pool to split one off; drag a pool's only account into another pool to merge (members follow, the emptied pool is deleted on Save). One password Save. - Claude plan label (planLabel in cc-profiles.js) reads rateLimitTier and seatTier: Max 5x/20x, Team Standard/Premium — subscriptionType alone said 'pro' after an upgrade and 'team' for both seats. Shown on profiles, the status bar quota badge and pool cards; Codex pool cards show the ChatGPT plan.
1024 lines
37 KiB
TypeScript
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>
|
|
);
|
|
}
|