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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user