ui(pool): pick an account's users from its own Edit

Each account row in the Account pool card has a pencil: tick the users
whose sessions run on it (one moves off any other account, unticking sends
one back to the balancer). PUT /api/accounts/:name/users saves the set in one
transaction, audits each change and switches a Claude login into each added
user's home at once. The card-level switch button and pinned list are gone.
This commit is contained in:
2026-10-19 10:45:54 +07:00
parent 776ad6466f
commit c042adc51b
11 changed files with 305 additions and 232 deletions
+91 -136
View File
@@ -8,16 +8,16 @@
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
* API key or a pasted `auth.json` — or removes one, each time with their own
* password again; an analyst only reads. The secret only goes IN: the server
* never sends it back. An admin can also switch a user to one account
* (`PUT /api/accounts/pins`): that user's sessions skip the balancer, and a
* Claude login goes into their home at once — no restart.
* never sends it back. An account's Edit (pencil) picks the users who run on
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
* a Claude login goes into their home at once — no restart.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { KeyRound, Pin, PinOff, Plus, RefreshCw, Trash2 } from "lucide-react";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
api,
type AccountPin,
@@ -32,6 +32,7 @@ import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
type Row = {
tool: PoolTool;
@@ -78,8 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Row | null>(null);
/** The pin dialog, opened empty or on one existing pin (to change or unpin it). */
const [pinning, setPinning] = useState<{ tool: PoolTool; userId?: number } | null>(null);
const [editing, setEditing] = useState<Row | null>(null);
const load = useCallback(
(force = false) =>
@@ -99,11 +99,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
}, [load]);
const rows = pool ? rowsOf(pool) : [];
const pins = pool
? (["claude", "codex"] as const).flatMap((tool) =>
pinsOf(pool, tool).map((p) => ({ ...p, tool }))
)
: [];
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
return (
@@ -125,15 +120,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
{isAdmin && rows.length > 0 && (
<button
type="button"
onClick={() => setPinning({ tool: "claude" })}
className="btn-secondary"
>
<Pin className="w-4 h-4" /> {t("pool.pinUser")}
</button>
)}
{isAdmin && (
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
<Plus className="w-4 h-4" /> {t("pool.add")}
@@ -220,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</div>
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<td className="px-5 py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => setEditing(r)}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setRemoving(r)}
@@ -238,34 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</table>
</div>
)}
{pins.length > 0 && (
<div className="border-t border-border px-5 py-3" data-testid="account-pins">
<h3 className="text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("pool.pinsTitle")}
</h3>
<ul className="mt-1.5 flex flex-wrap gap-2">
{pins.map((p) => (
<li key={`${p.tool}:${p.userId}`} className="badge gap-1.5 text-xs text-fg-secondary">
<Pin className="h-3 w-3" />
{p.username} → {p.account}
<span className="text-fg-muted">({t(`pool.toolName.${p.tool}`)})</span>
{isAdmin && (
<button
type="button"
onClick={() => setPinning({ tool: p.tool, userId: p.userId })}
title={t("pool.unpin", { name: p.username })}
aria-label={t("pool.unpin", { name: p.username })}
className="text-fg-muted hover:text-fg-primary"
>
<PinOff className="h-3 w-3" />
</button>
)}
</li>
))}
</ul>
</div>
)}
{adding && (
<AddAccountDialog
onClose={() => setAdding(false)}
@@ -287,21 +254,22 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
}}
/>
)}
{pinning && pool && (
<PinUserDialog
{editing && pool && (
<EditAccountDialog
pool={pool}
initial={pinning}
onClose={() => setPinning(null)}
onDone={(user, account, switched) => {
const key = !account
? "unpinned"
: switched === "switched" || switched === "own-login" || switched === "expired"
? switched
: "pinned";
(key === "own-login" || key === "expired" ? toast.info : toast.success)(
t(`pool.pinDone.${key}`, { user, account })
row={editing}
onClose={() => setEditing(null)}
onDone={(r) => {
const left = r.added.filter(
(u) => u.switched === "own-login" || u.switched === "expired"
);
setPinning(null);
toast.success(t("pool.editDone", { name: editing.name }));
for (const u of left) {
toast.info(
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
);
}
setEditing(null);
void load();
}}
/>
@@ -311,25 +279,26 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
}
/**
* Switch a user to one account of a tool, or back to automatic. Opened on an
* existing pin it starts on that user with "Automatic", so one click unpins.
* One account's Edit: tick the users whose sessions run on it. A user already
* on another account of the tool moves here when ticked; unticking one sends
* them back to the least-used account. Saved in one request, password again.
*/
function PinUserDialog({
function EditAccountDialog({
pool,
initial,
row,
onClose,
onDone,
}: {
pool: AccountPool;
initial: { tool: PoolTool; userId?: number };
row: Row;
onClose: () => void;
onDone: (user: string, account: string | null, switched: string | null) => void;
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
}) {
const { t } = useTranslation("users");
const [tool, setTool] = useState<PoolTool>(initial.tool);
const pins = pinsOf(pool, row.tool);
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [userId, setUserId] = useState<number | null>(initial.userId ?? null);
const [account, setAccount] = useState("");
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -341,18 +310,22 @@ function PinUserDialog({
.catch(() => setUsers([]));
}, []);
const accounts = tool === "codex" ? pool.codex : pool.accounts;
const pinOf = (id: number | null, of: PoolTool) =>
pinsOf(pool, of).find((p) => p.userId === id)?.account ?? "";
const ready = userId != null && current.length > 0 && account !== pinOf(userId, tool);
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const toggle = (id: number, on: boolean) =>
setChosen((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const submit = async () => {
if (!ready || busy || userId == null) return;
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
const r = await api.accountPool.pin(tool, userId, account || null, current);
onDone(r.pin.username, r.pin.account, r.switched ?? null);
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
} catch (e) {
setError(errorText(t, e));
} finally {
@@ -363,9 +336,9 @@ function PinUserDialog({
return (
<ConfirmModal
open
title={t("pool.pinTitle")}
message={t("pool.pinHint")}
confirmLabel={t("pool.pinButton")}
title={t("pool.edit", { name: row.name })}
message={t("pool.editHint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
@@ -376,66 +349,48 @@ function PinUserDialog({
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<Field label={t("pool.tool")}>
<select
className="input w-full text-sm"
value={tool}
onChange={(e) => {
const next = e.target.value as PoolTool;
setTool(next);
setAccount(pinOf(userId, next));
}}
aria-label={t("pool.tool")}
>
<option value="claude">Claude Code</option>
<option value="codex">Codex</option>
</select>
</Field>
<Field label={t("pool.user")}>
<select
className="input w-full text-sm"
value={userId ?? ""}
onChange={(e) => {
const id = e.target.value ? Number(e.target.value) : null;
setUserId(id);
setAccount(pinOf(id, tool));
}}
aria-label={t("pool.user")}
>
<option value="">
{users === null ? t("pool.loadingUsers") : t("pool.chooseUser")}
</option>
{users?.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
{u.disabled ? ` (${t("disabled")})` : ""}
</option>
))}
</select>
</Field>
<Field label={t("pool.pinAccount")}>
<select
className="input w-full text-sm"
value={account}
onChange={(e) => setAccount(e.target.value)}
aria-label={t("pool.pinAccount")}
disabled={userId == null}
>
<option value="">{t("pool.noPin")}</option>
{accounts.map((a) => (
<option key={a.name} value={a.name}>
{a.name}
{"email" in a && a.email ? ` — ${a.email}` : ""}
</option>
))}
</select>
</Field>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return (
<div key={u.id} className="px-3 py-2">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => toggle(u.id, on)}
label={
<span>
{u.username}
{u.disabled && (
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
)}
{elsewhere && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.onAccount", { account: elsewhere.account })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"