feat: v2.7.6 — edit users in one form, Settings → Account, display names, copy over plain HTTP
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
||||
* with its live panes, $ in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
||||
* switch-user profile (`GET /api/accounts/profiles`), a pasted
|
||||
* `.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.
|
||||
* @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, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type AccountPool,
|
||||
type PoolAccountInput,
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
} from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
type Row = {
|
||||
tool: PoolTool;
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
refreshError: string | null;
|
||||
addedAt: string;
|
||||
expired: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
|
||||
function rowsOf(pool: AccountPool): Row[] {
|
||||
return [
|
||||
...pool.accounts.map((a) => ({ ...a, tool: "claude" as const })),
|
||||
...pool.codex.map((a) => ({
|
||||
...a,
|
||||
tool: "codex" as const,
|
||||
expired: false,
|
||||
email: null,
|
||||
refreshError: null,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [removing, setRemoving] = useState<Row | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
api.accountPool
|
||||
.list(force)
|
||||
.then((r) => {
|
||||
setPool(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(() => load(true));
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const rows = pool ? rowsOf(pool) : [];
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="account-pool">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<KeyRound className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("pool.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">{t("pool.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
title={t("pool.refresh")}
|
||||
aria-label={t("pool.refresh")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> {t("pool.add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{pool && rows.length === 0 && (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("pool.empty")}</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("pool.account")}</th>
|
||||
<th className={th}>{t("pool.tool")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.livePanes")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.quota")}</th>
|
||||
<th className={`${th} text-right`}>{t("pool.spend")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.tool}:${r.name}`} className={r.expired ? "opacity-60" : ""}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="text-sm font-medium text-fg-primary">{r.name}</div>
|
||||
{r.email && <div className="text-[11px] text-fg-secondary">{r.email}</div>}
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{t(`pool.kind.${r.kind}`)}
|
||||
{r.expired && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("pool.expired")}
|
||||
</span>
|
||||
)}
|
||||
{r.refreshError && !r.expired && (
|
||||
<span
|
||||
className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={r.refreshError}
|
||||
>
|
||||
{t("pool.refreshFailed")}
|
||||
</span>
|
||||
)}
|
||||
{r.full && (
|
||||
<span className="badge ml-1.5 py-0 text-[10px] bg-status-warning/10 text-status-warning border-status-warning/20">
|
||||
{t("pool.full")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.livePanes}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">{fmtCost(r.spend5h)}</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d) })}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRemoving(r)}
|
||||
title={t("pool.remove", { name: r.name })}
|
||||
aria-label={t("pool.remove", { name: r.name })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddAccountDialog
|
||||
onClose={() => setAdding(false)}
|
||||
onDone={(name) => {
|
||||
toast.success(t("pool.done.added", { name }));
|
||||
setAdding(false);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{removing && (
|
||||
<RemoveAccountDialog
|
||||
row={removing}
|
||||
onClose={() => setRemoving(null)}
|
||||
onDone={() => {
|
||||
toast.success(t("pool.done.removed", { name: removing.name }));
|
||||
setRemoving(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ClaudeSource = "profile" | "credentials" | "token";
|
||||
|
||||
function AddAccountDialog({
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onDone: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [tool, setTool] = useState<PoolTool>("claude");
|
||||
const [source, setSource] = useState<ClaudeSource>("profile");
|
||||
const [profiles, setProfiles] = useState<PoolProfile[] | null>(null);
|
||||
const [profile, setProfile] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.accountPool
|
||||
.profiles()
|
||||
.then((r) => setProfiles(r.profiles))
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
|
||||
const fromProfile = tool === "claude" && source === "profile";
|
||||
// A profile already names itself: its name is the default.
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(fromProfile ? profile.length > 0 : name.trim().length > 0 && secret.trim().length > 0);
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body: PoolAccountInput =
|
||||
tool === "codex"
|
||||
? { tool, name: name.trim(), token: secret.trim() }
|
||||
: source === "profile"
|
||||
? { tool, name: name.trim() || profile, profile }
|
||||
: source === "credentials"
|
||||
? { tool, name: name.trim(), credentials: secret.trim() }
|
||||
: { tool, name: name.trim(), token: secret.trim() };
|
||||
try {
|
||||
const r = await api.accountPool.add(body, current);
|
||||
onDone(r.account.name);
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// The secret box is a textarea for pasted JSON: Enter there is a newline.
|
||||
if ((e.target as HTMLElement).tagName !== "TEXTAREA" && isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
const secretKind = tool === "codex" ? "codex" : source;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.add")}
|
||||
confirmLabel={t("pool.add")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
<Field label={t("pool.tool")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value as PoolTool)}
|
||||
aria-label={t("pool.tool")}
|
||||
>
|
||||
<option value="claude">Claude Code</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
</Field>
|
||||
{tool === "claude" && (
|
||||
<Field label={t("pool.source")} hint={t(`pool.sourceHint.${source}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as ClaudeSource)}
|
||||
aria-label={t("pool.source")}
|
||||
>
|
||||
{(["profile", "credentials", "token"] as const).map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`pool.sources.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{fromProfile && (
|
||||
<Field label={t("pool.profile")} hint={t("pool.profileHint")}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
aria-label={t("pool.profile")}
|
||||
>
|
||||
<option value="">
|
||||
{profiles === null
|
||||
? t("pool.loadingProfiles")
|
||||
: profiles.length === 0
|
||||
? t("pool.noProfiles")
|
||||
: t("pool.chooseProfile")}
|
||||
</option>
|
||||
{profiles?.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.label}
|
||||
{p.email ? ` — ${p.email}` : ""}
|
||||
{p.active ? ` (${t("pool.activeHere")})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field
|
||||
label={t("pool.name")}
|
||||
hint={fromProfile ? t("pool.nameFromProfile") : t("pool.nameHint")}
|
||||
>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("pool.name")}
|
||||
/>
|
||||
</Field>
|
||||
{!fromProfile && (
|
||||
<Field label={t(`pool.secret.${secretKind}`)} hint={t(`pool.secretHint.${secretKind}`)}>
|
||||
{secretKind === "token" ? (
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full font-mono text-sm"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t(`pool.secret.${secretKind}`)}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className="input w-full h-24 font-mono text-xs"
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={t(`pool.secret.${secretKind}`)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveAccountDialog({
|
||||
row,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
row: Row;
|
||||
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.remove(row.tool, row.name, current);
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={t("pool.remove", { name: row.name })}
|
||||
message={t("pool.removeHint")}
|
||||
confirmLabel={t("pool.removeButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* @file UserEditDialog.tsx
|
||||
* @description The Users page's ONE account editor (admin only): profile
|
||||
* (display name, email), access (role, active/disabled — neither for your own
|
||||
* account), a new password (typed or generated, copyable), the workspaces the
|
||||
* account owns (remove, add from every workspace), and "sign out everywhere".
|
||||
* Every change is staged and sent as ONE `PATCH /api/users/:id` carrying only
|
||||
* what changed, confirmed with the admin's OWN password (`currentPassword`) —
|
||||
* the server validates the whole change before it applies any of it. A
|
||||
* refusal is shown inside the dialog, which stays open.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { Switch } from "../Switch";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export const ROLE_BADGE: Record<Role, string> = {
|
||||
admin: "bg-accent/15 text-accent border-accent/20",
|
||||
analyst: "bg-status-warning/10 text-status-warning border-status-warning/20",
|
||||
developer: "bg-surface-4 text-fg-secondary border-border",
|
||||
};
|
||||
|
||||
/** A random password of `n` characters, no look-alikes (0/O, 1/l/I). */
|
||||
export function generatePassword(n = 16): string {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789-_!@#%";
|
||||
const bytes = new Uint32Array(n);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => chars[b % chars.length]).join("");
|
||||
}
|
||||
|
||||
type OwnedWorkspace = { id: number; title: string; cwd: string };
|
||||
|
||||
export function UserEditDialog({
|
||||
user,
|
||||
self,
|
||||
users,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
user: ServerUserUsage;
|
||||
/** The account being edited is the signed-in admin's own. */
|
||||
self: boolean;
|
||||
users: ServerUserUsage[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panelRef, onClose);
|
||||
|
||||
const [displayName, setDisplayName] = useState(user.displayName ?? "");
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [role, setRole] = useState<Role>(user.role);
|
||||
const [active, setActive] = useState(!user.disabled);
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [removed, setRemoved] = useState<Set<number>>(new Set());
|
||||
const [added, setAdded] = useState<number[]>([]);
|
||||
const [pick, setPick] = useState("");
|
||||
const [signOut, setSignOut] = useState(false);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.get(user.id)
|
||||
.then((r) => setOwned(r.workspaces))
|
||||
.catch(() => setOwned([]));
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, [user.id]);
|
||||
|
||||
const ownerName = (id: number) => {
|
||||
const u = users.find((x) => x.id === id);
|
||||
return u ? u.displayName || u.username : `#${id}`;
|
||||
};
|
||||
const ownedIds = useMemo(() => new Set((owned ?? []).map((w) => w.id)), [owned]);
|
||||
const addable = (lanes ?? []).filter((l) => !ownedIds.has(l.id) && !added.includes(l.id));
|
||||
const laneLabel = (id: number) => {
|
||||
const l = (lanes ?? []).find((x) => x.id === id);
|
||||
return l ? l.title || l.cwd : `#${id}`;
|
||||
};
|
||||
|
||||
const patch: UserPatch = {};
|
||||
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
|
||||
if (norm(displayName) !== (user.displayName ?? null)) patch.displayName = norm(displayName);
|
||||
if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
|
||||
if (!self && role !== user.role) patch.role = role;
|
||||
if (!self && active === user.disabled) patch.disabled = !active;
|
||||
if (password) patch.password = password;
|
||||
if (added.length) patch.addWorkspaces = added;
|
||||
if (removed.size) patch.removeWorkspaces = [...removed];
|
||||
if (signOut) patch.signOut = true;
|
||||
const changed = Object.keys(patch).length > 0;
|
||||
// No minimum length (server/lib/auth.js) — a blank field keeps the password.
|
||||
const ready = changed && current.length > 0 && !busy;
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.update(user.id, patch, current);
|
||||
onDone(t("done.updated", { name: user.displayName || user.username }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
const shown = user.displayName || user.username;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="user-edit-title"
|
||||
className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-sm font-semibold uppercase text-accent"
|
||||
>
|
||||
{shown.slice(0, 2)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 id="user-edit-title" className="text-base font-semibold text-fg-primary">
|
||||
{t("edit.title", { name: user.displayName || user.username })}
|
||||
</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
|
||||
{t(`roles.${user.role}`)}
|
||||
</span>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
</span>
|
||||
)}
|
||||
{user.disabled && (
|
||||
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common:close")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5 overflow-y-auto px-5 py-4">
|
||||
<dl
|
||||
className="grid grid-cols-2 gap-3 rounded-lg border border-border bg-surface-2 p-3 text-xs sm:grid-cols-3"
|
||||
data-testid="user-edit-info"
|
||||
>
|
||||
<Info label={t("edit.created")}>
|
||||
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
|
||||
</Info>
|
||||
<Info label={t("edit.lastLogin")}>
|
||||
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t("edit.never")}
|
||||
</Info>
|
||||
<Info label={t("edit.logins")}>{user.logins ?? 0}</Info>
|
||||
<Info label={t("workspaces")}>{user.workspaces}</Info>
|
||||
<Info label={t("sessions")}>{user.sessions}</Info>
|
||||
<Info label={t("cost30d")}>{fmtCost(user.last30d.cost)}</Info>
|
||||
</dl>
|
||||
|
||||
<Section title={t("edit.profile")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t("displayName")} hint={t("displayNameHint")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={100}
|
||||
aria-label={t("displayName")}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("email")}>
|
||||
<input
|
||||
type="email"
|
||||
className="input w-full text-sm"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={254}
|
||||
autoComplete="off"
|
||||
aria-label={t("email")}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.access")}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label={t("role")} hint={self ? t("edit.selfRole") : t(`roleHint.${role}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={role}
|
||||
disabled={self}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
aria-label={t("role")}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-fg-secondary">{t("edit.status")}</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
checked={active}
|
||||
onChange={setActive}
|
||||
disabled={self}
|
||||
label={t("edit.active")}
|
||||
/>
|
||||
<span className="text-sm text-fg-secondary">
|
||||
{active ? t("edit.active") : t("disabled")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-0.5 block text-[11px] text-fg-muted">
|
||||
{self ? t("edit.selfDisable") : t("edit.statusHint")}
|
||||
</span>
|
||||
{!active && !user.disabled && (
|
||||
<p className="mt-1 text-[11px] text-status-danger">
|
||||
{t("dialog.disableBody", { name: user.displayName || user.username })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.password")}>
|
||||
<Field label={t("newPassword")} hint={t("edit.passwordHint")}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
className="input w-full font-mono text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="new-password"
|
||||
aria-label={t("newPassword")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("edit.hide") : t("edit.show")}
|
||||
title={showPassword ? t("edit.hide") : t("edit.show")}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
disabled={!password}
|
||||
onClick={() => void copyText(password)}
|
||||
aria-label={t("edit.copy")}
|
||||
title={t("edit.copy")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
onClick={() => {
|
||||
setPassword(generatePassword());
|
||||
setShowPassword(true);
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> {t("edit.generate")}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<Section title={t("workspaces")}>
|
||||
{owned === null ? (
|
||||
<p className="text-xs text-fg-muted">{t("edit.loading")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid="user-edit-workspaces">
|
||||
{owned.length === 0 && added.length === 0 && (
|
||||
<li className="text-xs text-fg-muted">{t("edit.noOwned")}</li>
|
||||
)}
|
||||
{owned.map((w) => {
|
||||
const gone = removed.has(w.id);
|
||||
return (
|
||||
<li
|
||||
key={w.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${gone ? "text-fg-muted line-through" : "text-fg-primary"}`}
|
||||
title={w.cwd}
|
||||
>
|
||||
{w.title || w.cwd}
|
||||
</span>
|
||||
{gone ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent hover:underline"
|
||||
onClick={() =>
|
||||
setRemoved((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(w.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("edit.undo")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label={t("edit.removeWorkspace", { name: w.title || w.cwd })}
|
||||
title={t("edit.removeWorkspaceHint")}
|
||||
onClick={() => setRemoved((s) => new Set(s).add(w.id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{added.map((id) => (
|
||||
<li
|
||||
key={`add-${id}`}
|
||||
className="flex items-center gap-2 rounded-md border border-accent/30 bg-accent/5 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-primary">{laneLabel(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
aria-label={t("edit.undo")}
|
||||
onClick={() => setAdded((a) => a.filter((x) => x !== id))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{addable.length > 0 && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={pick}
|
||||
onChange={(e) => setPick(e.target.value)}
|
||||
aria-label={t("edit.addWorkspace")}
|
||||
>
|
||||
<option value="">{t("edit.chooseWorkspace")}</option>
|
||||
{addable.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{(l.title || l.cwd) +
|
||||
" — " +
|
||||
(l.owner_id == null
|
||||
? t("noOwner")
|
||||
: t("ownedBy", { name: ownerName(l.owner_id) }))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost whitespace-nowrap text-xs"
|
||||
disabled={!pick}
|
||||
onClick={() => {
|
||||
setAdded((a) => [...a, Number(pick)]);
|
||||
setPick("");
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> {t("edit.addWorkspace")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(added.length > 0 || removed.size > 0) && (
|
||||
<p className="mt-1 text-[11px] text-fg-muted">
|
||||
{t("dialog.workspaceBody", { name: user.displayName || user.username })}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t("edit.sessions")}>
|
||||
<Checkbox
|
||||
checked={signOut}
|
||||
onChange={setSignOut}
|
||||
tone="warning"
|
||||
label={t("edit.signOut")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">
|
||||
{t("edit.signOutHint", { n: user.logins ?? 0 })}
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-border bg-surface-2/40 px-5 py-4">
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!changed && (
|
||||
<span className="mr-auto text-xs text-fg-muted">{t("edit.noChanges")}</span>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="btn-ghost">
|
||||
{t("common:cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={!ready}
|
||||
aria-busy={busy}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Info({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[11px] text-fg-muted">{label}</dt>
|
||||
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file AccountPoolCard.test.tsx
|
||||
* @description The Users page's account pool card: lists the Claude and Codex
|
||||
* accounts with their live panes and quota, says what an empty pool means, an
|
||||
* admin adds an account only with their own password typed (the token goes
|
||||
* in once and is not shown back) — a saved login moved in (the server's own
|
||||
* included), or a setup-token — and removes one the same way, and an analyst
|
||||
* sees no add or remove control.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { AccountPool } from "../../../lib/api";
|
||||
|
||||
const accountPool = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
add: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
profiles: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { accountPool } };
|
||||
});
|
||||
|
||||
import { AccountPoolCard } from "../AccountPoolCard";
|
||||
|
||||
const POOL: AccountPool = {
|
||||
accounts: [
|
||||
{
|
||||
name: "team-1",
|
||||
kind: "login",
|
||||
email: "team1@example.com",
|
||||
refreshError: null,
|
||||
addedAt: "2026-10-01T00:00:00Z",
|
||||
expiresAt: null,
|
||||
expired: false,
|
||||
livePanes: 2,
|
||||
spend5h: 1.5,
|
||||
spend7d: 12,
|
||||
quotaPercent: 42,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
codex: [
|
||||
{
|
||||
name: "cx-1",
|
||||
kind: "apikey",
|
||||
addedAt: "2026-10-02T00:00:00Z",
|
||||
livePanes: 0,
|
||||
spend5h: 0,
|
||||
spend7d: 0,
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
},
|
||||
],
|
||||
blockPercent: 90,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||
accountPool.list.mockResolvedValue(POOL);
|
||||
accountPool.profiles.mockResolvedValue({
|
||||
profiles: [
|
||||
{
|
||||
name: "work",
|
||||
label: "Work",
|
||||
email: "w@example.com",
|
||||
subscriptionType: "max",
|
||||
active: false,
|
||||
},
|
||||
{ name: "me", label: "Me", email: "me@example.com", subscriptionType: "pro", active: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("AccountPoolCard", () => {
|
||||
it("lists every account, and explains an empty pool", async () => {
|
||||
const { unmount } = render(<AccountPoolCard isAdmin={false} />);
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
unmount();
|
||||
accountPool.list.mockResolvedValue({ accounts: [], codex: [], blockPercent: 90 });
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
expect(await screen.findByText(/no accounts yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves a saved login in, the server's own included", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "work" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
const select = await screen.findByLabelText("Profile");
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: /Work/ })).toBeInTheDocument());
|
||||
expect(screen.getByRole("option", { name: /Me/ })).toBeEnabled();
|
||||
await user.selectOptions(select, "work");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "work", profile: "work" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a setup-token only with the admin's own password", async () => {
|
||||
accountPool.add.mockResolvedValue({ account: { name: "team-2" } });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await screen.findByText("team-1");
|
||||
await user.click(screen.getByRole("button", { name: /add account/i }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.selectOptions(screen.getByLabelText("From"), "token");
|
||||
await user.type(screen.getByLabelText("Name"), "team-2");
|
||||
await user.type(screen.getByLabelText("Token"), "sk-ant-oat01-xyz");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add account"
|
||||
)!;
|
||||
expect(confirm).toBeDisabled();
|
||||
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm).toBeEnabled();
|
||||
await user.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(accountPool.add).toHaveBeenCalledWith(
|
||||
{ tool: "claude", name: "team-2", token: "sk-ant-oat01-xyz" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("removes an account with the admin's own password", async () => {
|
||||
accountPool.remove.mockResolvedValue({ ok: true });
|
||||
const user = userEvent.setup();
|
||||
render(<AccountPoolCard isAdmin />);
|
||||
await user.click(await screen.findByRole("button", { name: "Remove cx-1" }));
|
||||
await user.type(await screen.findByLabelText("Your password"), "boss-pass");
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
await waitFor(() =>
|
||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file UserEditDialog.test.tsx
|
||||
* @description The admin's account editor: Save stays off until something
|
||||
* changed AND the admin typed their own password; one PATCH carries only the
|
||||
* changed fields (profile, role, disable, workspace add/remove, sign-out); an
|
||||
* admin editing their own row cannot touch role or state; Generate fills a
|
||||
* 16-character password.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api: { users, lanes } };
|
||||
});
|
||||
|
||||
import { UserEditDialog, generatePassword } from "../UserEditDialog";
|
||||
|
||||
const usage = { cost: 0, tokens: 0 };
|
||||
const mia: ServerUserUsage = {
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
displayName: null,
|
||||
email: null,
|
||||
createdAt: "2026-10-01T00:00:00.000Z",
|
||||
workspaces: 1,
|
||||
sessions: 3,
|
||||
last7d: usage,
|
||||
last30d: usage,
|
||||
logins: 2,
|
||||
lastLoginAt: null,
|
||||
};
|
||||
|
||||
function open(user = mia, self = false) {
|
||||
const onDone = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<UserEditDialog user={user} self={self} users={[mia]} onClose={onClose} onDone={onDone} />
|
||||
);
|
||||
return { onDone, onClose };
|
||||
}
|
||||
|
||||
const save = () => screen.getByRole("button", { name: "Save" });
|
||||
|
||||
beforeEach(() => {
|
||||
users.get.mockReset().mockResolvedValue({
|
||||
user: mia,
|
||||
workspaces: [{ id: 5, title: "api", cwd: "/w/api" }],
|
||||
});
|
||||
users.update.mockReset().mockResolvedValue({ user: mia });
|
||||
lanes.list.mockReset().mockResolvedValue({
|
||||
lanes: [
|
||||
{ id: 5, title: "api", cwd: "/w/api", owner_id: 2 },
|
||||
{ id: 7, title: "web", cwd: "/w/web", owner_id: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserEditDialog", () => {
|
||||
it("needs a change and the admin's own password before Save", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
expect(save().hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save().hasAttribute("disabled")).toBe(true); // nothing changed yet
|
||||
await ue.type(screen.getByLabelText("Display name"), "Mia");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("sends one PATCH with only what changed", async () => {
|
||||
const ue = userEvent.setup();
|
||||
const { onDone } = open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
|
||||
await ue.selectOptions(screen.getByLabelText("Role"), "analyst");
|
||||
await ue.click(screen.getByRole("switch", { name: "Active" }));
|
||||
await ue.click(screen.getByRole("button", { name: "Remove api" }));
|
||||
await ue.selectOptions(screen.getByLabelText("Add workspace"), "7");
|
||||
await ue.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await ue.click(screen.getByRole("checkbox", { name: "Sign out everywhere" }));
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(save());
|
||||
await waitFor(() =>
|
||||
expect(users.update).toHaveBeenCalledWith(
|
||||
2,
|
||||
{
|
||||
email: "mia@x.vn",
|
||||
role: "analyst",
|
||||
disabled: true,
|
||||
addWorkspaces: [7],
|
||||
removeWorkspaces: [5],
|
||||
signOut: true,
|
||||
},
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
expect(onDone).toHaveBeenCalledWith("mia updated");
|
||||
});
|
||||
|
||||
it("never lets an admin change their own role or disable themselves", async () => {
|
||||
open({ ...mia, role: "admin" }, true);
|
||||
await screen.findByText("api");
|
||||
expect(screen.getByLabelText("Role").hasAttribute("disabled")).toBe(true);
|
||||
expect(screen.getByRole("switch", { name: "Active" }).hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a short new password: there is no minimum length", async () => {
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("New password"), "abc");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("generates a 16-character password", async () => {
|
||||
expect(generatePassword()).toHaveLength(16);
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.click(screen.getByRole("button", { name: /Generate/ }));
|
||||
expect((screen.getByLabelText("New password") as HTMLInputElement).value).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user