f1a90e120e
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own picture with no user id, and the host stats never carried avatarAt, so every card fell back to letters. The groups now carry avatarAt and the card passes userId. The user editor's header, the pool board's member chips and the pool member picker drew hand-made initials; they use UserAvatar too.
1026 lines
37 KiB
TypeScript
1026 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";
|
|
import { UserAvatar } from "./UserAvatar";
|
|
|
|
/** 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"
|
|
>
|
|
<UserAvatar
|
|
name={name}
|
|
userId={id}
|
|
avatarAt={u?.avatarAt}
|
|
letters={1}
|
|
className="h-4 w-4 text-[9px]"
|
|
/>
|
|
{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>
|
|
);
|
|
}
|