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:
2026-10-13 17:10:29 +07:00
parent 9c573f1e3c
commit 0c2f49876d
74 changed files with 5347 additions and 392 deletions
+25 -4
View File
@@ -6,7 +6,8 @@
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
* someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
* previous user's data. `useAuth()` gives the sidebar the user, `logout`, and
* `updateUser` (Settings → Account's saved display name shows at once);
* `usePermissions()` says what that user's role may do, so pages hide the rest.
* Once an account is known (local mode is one), its own UI preferences are
* loaded into this browser (`lib/userPrefs.ts`).
@@ -16,6 +17,7 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
@@ -30,9 +32,17 @@ import { applySystemTheme } from "../hooks/useTheme";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
import { hydrate } from "../lib/userPrefs";
type AuthValue = { state: AuthState; logout: () => Promise<void> };
type AuthValue = {
state: AuthState;
logout: () => Promise<void>;
updateUser: (patch: Partial<AuthUser>) => void;
};
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
const AuthContext = createContext<AuthValue>({
state: { mode: "local" },
logout: async () => {},
updateUser: () => {},
});
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
export const useAuth = () => useContext(AuthContext);
@@ -134,6 +144,15 @@ export function AuthGate({ children }: { children: ReactNode }) {
if (account) void hydrate();
}, [account]);
// Stable, so a page may list it as an effect dependency.
const updateUser = useCallback(
(patch: Partial<AuthUser>) =>
setState((s) =>
s && s.mode === "server" && s.user ? { ...s, user: { ...s.user, ...patch } } : s
),
[]
);
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
@@ -142,7 +161,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
await api.auth.logout().catch(() => {});
setState({ mode: "server", user: null });
};
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
return (
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
);
}
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
+1 -5
View File
@@ -12,7 +12,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState, type Ref } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react";
import { api } from "../lib/api";
@@ -23,15 +23,12 @@ export function PathPickButton({
start,
onPick,
showLabel = false,
buttonRef,
}: {
kind?: "dir" | "file";
/** The field's current value — the picker opens there. */
start?: string;
onPick: (path: string) => void;
showLabel?: boolean;
/** Lets a field beside it open the same picker (a click on an empty path). */
buttonRef?: Ref<HTMLButtonElement>;
}) {
const { t } = useTranslation(["lanes"]);
const [busy, setBusy] = useState(false);
@@ -55,7 +52,6 @@ export function PathPickButton({
return (
<>
<button
ref={buttonRef}
type="button"
onClick={pick}
disabled={busy}
+1 -1
View File
@@ -552,7 +552,7 @@ export function Sidebar({
type="button"
onClick={() => void auth.logout()}
title={`${t("common:auth.signedInAs", {
name: auth.state.user.username,
name: auth.state.user.displayName || auth.state.user.username,
role: auth.state.user.role,
})} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
+9 -5
View File
@@ -10,7 +10,8 @@
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
* analyst: EVERY workspace, grouped under its owner (`owner_name`; yours
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
* else `owner_name`; yours
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
@@ -60,6 +61,9 @@ const DestructiveLaneModal = lazy(() =>
/** The workspace (and attached run) the Workspace page shows, from its URL. */
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
/** An owner as the tree shows it: their display name, else their username. */
const ownerShown = (l: Lane): string => l.owner_display_name || l.owner_name || "";
export function selectedFrom(pathname: string, search: string) {
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
const p = new URLSearchParams(search);
@@ -150,7 +154,7 @@ export function WorkspaceTree() {
// Stable: each owner's workspaces keep the stored order inside the group.
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
return [...list].sort(
(a, b) => rank(a) - rank(b) || (a.owner_name ?? "").localeCompare(b.owner_name ?? "")
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
);
}, [lanes, order, toolFilter, grouped, meId]);
const q = query.trim().toLowerCase();
@@ -338,8 +342,8 @@ export function WorkspaceTree() {
lane.owner_id == null
? t("tree.noOwner")
: lane.owner_id === meId
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
: (lane.owner_name ?? `#${lane.owner_id}`);
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
: ownerShown(lane) || `#${lane.owner_id}`;
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
return (
@@ -366,7 +370,7 @@ export function WorkspaceTree() {
aria-hidden
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
>
{lane.owner_name?.[0] ?? "?"}
{ownerShown(lane)[0] ?? "?"}
</span>
<span className="truncate">{ownerLabel}</span>
<span className="ml-auto shrink-0 tabular-nums">
@@ -68,7 +68,7 @@ describe("PathPickButton", () => {
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
await userEvent.click(screen.getByRole("button"));
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false);
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
expect(within(dialog).queryByText("Select this folder")).toBeNull();
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
@@ -30,7 +30,7 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
}) as unknown as Lane;
const lanes = [
lane(1, 3, "bob", "Bob app"),
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
lane(2, null, null, "Old"),
lane(3, ME.id, "ana", "Mine"),
lane(4, 1, "admin", "Admin site"),
@@ -113,6 +113,14 @@ describe("WorkspaceTree for an analyst", () => {
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
});
it("names an owner by their display name when they set one", async () => {
renderTree();
await screen.findByTestId("workspace-row-1");
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
});
it("folds a group and remembers it", async () => {
const user = userEvent.setup();
renderTree();
@@ -951,9 +951,16 @@ function RunningStep({
}
/** Under a turn that ended: how long it took and the output it made. */
/** An option's label carries the model's " (Recommended)" marker into the
* answer the CLI records; the user's pick reads without it (every picked
* label of a multi-select answer too). */
function stripRecommended(answer: string | undefined): string | undefined {
return answer?.replace(/\s*\(recommended\)/gi, "").trim();
}
/** An AskUserQuestion: each question with the answer the user picked or
* typed (the result's `answers`), "declined" when they refused it, no answer
* yet while the CLI still asks. */
* typed (the result's `answers`, minus the " (Recommended)" marker),
* "declined" when they refused it, no answer yet while the CLI still asks. */
function AskCard({
block,
result,
@@ -972,7 +979,7 @@ function AskCard({
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
>
{questions.map((q, i) => {
const answer = result?.answers?.[q];
const answer = stripRecommended(result?.answers?.[q]);
return (
<div key={i}>
<div className="flex items-start gap-1.5 text-fg-secondary">
@@ -2,7 +2,7 @@
* @file MessageList.ask.test.tsx
* @description An AskUserQuestion call is not folded into the collapsed work
* line: the chat shows it as its own card — each question with the answer the
* user picked or typed, "declined" when they refused it, nothing yet while the
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
* CLI still waits.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -67,6 +67,25 @@ describe("an AskUserQuestion card", () => {
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
});
it("drops the (Recommended) marker from a picked answer", () => {
render(
<MessageList
loading={false}
messages={[
user("go"),
ask("q1", ["Commit?", "Tools?"]),
result("q1", {
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
}),
]}
/>
);
const card = screen.getByTestId("chat-ask");
expect(within(card).getByText("Yes")).toBeTruthy();
expect(within(card).getByText("Lint, Test")).toBeTruthy();
expect(within(card).queryByText(/Recommended/)).toBeNull();
});
it("says declined when refused, and shows no answer while still asked", () => {
const { rerender } = render(
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
+4 -15
View File
@@ -328,7 +328,6 @@ export function AddLaneModal({
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const browseRef = useRef<HTMLButtonElement>(null);
useDialog(open, panelRef, handleCancel, cancelRef);
// A summary on screen means the lane already exists and a step failed, so
@@ -461,14 +460,9 @@ export function AddLaneModal({
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
</label>
<div className="flex gap-1.5" data-tour="add-lane-folder">
{/* A click into the path opens the picker, as Browse does (the
OS's own where it can be); typing still works from the keyboard. */}
<div
className="min-w-0 flex-1"
onClick={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
}}
>
{/* A click into the path only focuses it — the picker opens from
Browse alone (#77). */}
<div className="min-w-0 flex-1">
<CwdAutocomplete
inputId="add-lane-repo"
value={sourceRepo}
@@ -476,12 +470,7 @@ export function AddLaneModal({
suggestions={cwdSuggestions}
/>
</div>
<PathPickButton
start={sourceRepo}
onPick={setSourceRepo}
showLabel
buttonRef={browseRef}
/>
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
</div>
<p className="mt-1 text-[10px] text-fg-muted">
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
@@ -8,6 +8,10 @@
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
* A click selects, a double-click or Enter opens a folder (picks a file),
* Backspace goes up and typing jumps to a name.
* An admin with a Linux account of their own (S3; the listing carries
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
* browser to the host account's home, where a workspace runs as the shared
* host account. It is never remembered: every open starts at the admin's own.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -25,6 +29,7 @@ import {
FolderPlus,
Home,
Pencil,
Server,
Star,
} from "lucide-react";
import { api } from "../../lib/api";
@@ -127,22 +132,26 @@ function BrowseDialog({
// parent) — breadcrumbs above it are not links.
const [floor, setFloor] = useState<string | null>(null);
const [home, setHome] = useState<string | null>(null);
// An admin's "Server folder" tick (S3): browse the host account's home.
const [hostHome, setHostHome] = useState<string | null>(null);
const [onHost, setOnHost] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const crumbRef = useRef<HTMLDivElement>(null);
const typeAhead = useRef({ text: "", at: 0 });
const load = async (
target?: string,
{ push = true, hidden = showHidden, select = null as string | null } = {}
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
) => {
setError(null);
try {
const next = await api.lanes.browse(target, kind === "file", hidden);
const next = await api.lanes.browse(target, kind === "file", hidden, host);
setListing(next);
setSelected(select);
setFilter("");
setEditing(false);
if (next.home) setHome(next.home);
setHostHome(next.hostHome ?? null);
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
if (push) {
setHistory((h) => {
@@ -162,7 +171,8 @@ function BrowseDialog({
if (!open) return;
setHistory({ stack: [], at: -1 });
setCreating(false);
void load(initialPath);
setOnHost(false);
void load(initialPath, { host: false });
// Only re-run when the modal actually opens - not on every initialPath
// keystroke in the field behind it.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -315,6 +325,13 @@ function BrowseDialog({
}
};
// Switch between the admin's own home and the host account's, and open it.
const toggleHost = () => {
const next = !onHost;
setOnHost(next);
void load(undefined, { host: next });
};
const toggleHidden = () => {
const next = !showHidden;
setShowHidden(next);
@@ -659,6 +676,21 @@ function BrowseDialog({
<Eye className="h-3.5 w-3.5" />
{t("browse.showHidden")}
</label>
{hostHome && (
<label
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
title={t("browse.hostFolderHint", { path: hostHome })}
>
<input
type="checkbox"
checked={onHost}
onChange={toggleHost}
data-testid="browse-host-folder"
/>
<Server className="h-3.5 w-3.5" />
{t("browse.hostFolder")}
</label>
)}
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
{t("browse.hint")}
</span>
@@ -584,17 +584,15 @@ describe("AddLaneModal — folder browse", () => {
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("opens the picker on every click into the path field", async () => {
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
renderModal();
const user = userEvent.setup();
const field = document.getElementById("add-lane-repo")!;
await user.click(field);
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
await user.click(field);
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
expect(field).toHaveFocus();
expect(api.lanes.pickPath).not.toHaveBeenCalled();
expect(api.lanes.browse).not.toHaveBeenCalled();
});
it("falls back to the in-app browser when no native picker is available", async () => {
@@ -630,7 +628,7 @@ describe("AddLaneModal — folder browse", () => {
expect(await screen.findByText("projects")).toBeInTheDocument();
await user.dblClick(screen.getByText("projects"));
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false);
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false, false);
});
it("Escape closes only the folder browser, not the whole modal", async () => {
@@ -72,6 +72,32 @@ const option = (dialog: HTMLElement, name: string) =>
within(dialog).findByRole("option", { name: new RegExp(name) });
describe("FolderBrowseModal", () => {
it("opens an admin in their own home; the Server folder tick opens the host's", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.browse).mockImplementation(async (p, _f, _h, host) => ({
...listing(p || (host ? "/home/u" : "/srv/ws"), false),
home: host ? "/home/u" : "/srv/ws",
hostHome: "/home/u",
}));
const dialog = await open();
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, false)
);
const tick = within(dialog).getByTestId("browse-host-folder");
expect(tick).not.toBeChecked();
await user.click(tick);
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith(undefined, false, false, true)
);
expect(tick).toBeChecked();
});
it("shows no Server folder tick without a host home in the listing", async () => {
const dialog = await open();
expect((await within(dialog).findAllByRole("option")).length).toBeGreaterThan(0);
expect(within(dialog).queryByTestId("browse-host-folder")).toBeNull();
});
it("click selects, double-click opens, Select picks the selection else the folder", async () => {
const onSelect = vi.fn();
const dialog = await open(onSelect);
@@ -105,11 +131,11 @@ describe("FolderBrowseModal", () => {
await option(dialog, "web");
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false)
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false, false)
);
await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ }));
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false)
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false, false)
);
// Above the user's own folder (it answered no parent) nothing is a link.
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
@@ -129,7 +155,9 @@ describe("FolderBrowseModal", () => {
it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => {
const dialog = await open(vi.fn(), "/home/u/proj");
await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ }));
await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false));
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false, false)
);
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
await option(dialog, "notes");
await userEvent.keyboard("{Control>}l{/Control}");
@@ -137,7 +165,7 @@ describe("FolderBrowseModal", () => {
await userEvent.clear(field);
await userEvent.type(field, "/home/u/proj/web{Enter}");
await waitFor(() =>
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false)
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false, false)
);
});
+11 -2
View File
@@ -8,8 +8,9 @@
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
* an attached image or text file through `GET /api/run/drop/:name`, an image
* the session read anywhere else through `GET /api/sessions/:id/image`. Anything
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
* text file it read or wrote anywhere else through `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
* `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
* or the project's); a built-in skill has no file and shows by name only.
@@ -106,6 +107,14 @@ function useContent(path: string, scope: PreviewScope): Content {
// An image it read elsewhere (a screenshot under ~/.cache): served
// only because this session's own Read named it.
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
} else if (scope.sessionId && /^(?:\/|[A-Za-z]:\/)/.test(file)) {
// A text file it read or wrote elsewhere (a report under ~/Documents):
// served only because this session's own Read/Write/Edit named it.
show(
api.sessions
.readFile(scope.sessionId, path)
.then((f) => ({ kind: "text", text: f.text, truncated: f.truncated }))
);
} else setContent({ kind: "none" });
return () => {
live = false;
+8 -4
View File
@@ -32,7 +32,8 @@
* (OSC 52 — tmux's default `set-clipboard external` passes its own
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
* READ the user's clipboard). While the socket is down a "Reconnecting…"
* READ the user's clipboard) — over plain HTTP too, through `writeClipboard`'s
* textarea fallback. While the socket is down a "Reconnecting…"
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
* most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
@@ -59,6 +60,7 @@ import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
@@ -85,9 +87,11 @@ const clipboardProvider: IClipboardProvider = {
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
// clipboard. An empty write (a "clear") is ignored.
writeText: (_selection, text) => {
// Silent and best effort: no toast per copy, and on a non-secure origin
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
// Silent and best effort: no toast per copy. On a non-secure origin
// (`http://<lan-ip>`, no Clipboard API) writeClipboard falls back to the
// textarea copy, which the browser allows while the drag that made the
// selection still counts as the user's gesture.
if (text) void writeClipboard(text);
},
};
@@ -4,7 +4,7 @@
* pane's folder through the lane's git file read, a memory file under
* `~/.claude` through the config pages' read, a dropped text file through the
* drop route, an image read outside the folder through the session's image
* route, a Windows path like a POSIX one, anything else by path only; the
* route, a text file read or written outside it through the session's file route, a Windows path like a POSIX one, anything else by path only; the
* list dialog previewing the file picked in it; and the skills dialog finding
* each skill's SKILL.md by name.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -31,7 +31,14 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}),
gitRawUrl: () => "",
},
sessions: { readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}` },
sessions: {
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
readFile: vi.fn((_id: string, p: string) =>
p.endsWith(".md")
? Promise.resolve({ text: "# Audit report", truncated: false })
: Promise.reject(new Error("404"))
),
},
ccConfig: {
skills: vi.fn().mockResolvedValue({
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
@@ -110,9 +117,16 @@ describe("FilePreview", () => {
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
it("shows only the path of a file it may not read", () => {
it("reads a text file it wrote outside the folder through the session", async () => {
const report = "/home/a/Documents/audit/REPORT_ssh_ec2.md";
render(<FilePreview path={report} scope={scope} onClose={() => {}} />);
expect(await screen.findByText("Audit report")).toBeTruthy();
expect(vi.mocked(api.sessions.readFile)).toHaveBeenCalledWith("s1", report);
});
it("shows only the path of a file it may not read", async () => {
render(<FilePreview path="/etc/hosts" scope={scope} onClose={() => {}} />);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
expect(await screen.findByText("No preview for this file here")).toBeTruthy();
});
});
@@ -87,6 +87,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
skills: [],
mcp: [],
}),
readFile: r({ text: "", truncated: false }),
},
lanes: {
gitFile: r({
@@ -355,7 +356,9 @@ describe("PaneChat — the activity panel", () => {
);
const first = render(pane());
fireEvent.click(
within(await screen.findByTestId("chat-outline")).getByRole("button", { name: "Hide side panel" })
within(await screen.findByTestId("chat-outline")).getByRole("button", {
name: "Hide side panel",
})
);
first.unmount();
render(pane());
@@ -0,0 +1,352 @@
/**
* @file AccountSettings.tsx
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
* admin's to change, on the Users page — never here.
* @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 { LogOut, RefreshCw } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unknown) => {
const err = e as Error & { code?: string };
return err.code && t(`users:errors.${err.code}`, { defaultValue: "" })
? t(`users:errors.${err.code}`)
: err.message;
};
export function AccountSettings() {
const { t } = useTranslation("settings");
const [account, setAccount] = useState<ServerAccount | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const { updateUser } = useAuth();
const apply = useCallback(
(r: MyAccount) => {
if (r.mode !== "server") return;
setAccount(r);
// The sidebar shows the display name: let it follow a save at once.
updateUser({ displayName: r.user.displayName, email: r.user.email });
},
[updateUser]
);
const load = useCallback(() => {
setLoadError(null);
api.me
.account()
.then(apply)
.catch((e: Error) => setLoadError(e.message));
}, [apply]);
useEffect(load, [load]);
if (loadError) {
return (
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
<button type="button" className="btn-ghost text-xs" onClick={load}>
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
</button>
</div>
);
}
if (!account) {
return <div className="card h-40 animate-pulse" data-testid="account-loading" />;
}
return (
<div className="space-y-6">
<ProfileCard account={account} />
<ProfileForm account={account} onSaved={apply} />
<PasswordForm />
<SignOutOthers logins={account.logins} onDone={load} />
</div>
);
}
function ProfileCard({ account }: { account: ServerAccount }) {
const { t } = useTranslation("settings");
const { user } = account;
const shown = user.displayName || user.username;
return (
<section className="card p-5" data-testid="account-card">
<div className="flex items-center gap-4">
<span
aria-hidden
className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-lg font-semibold uppercase text-accent"
>
{shown.slice(0, 2)}
</span>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
<p className="truncate text-xs text-fg-muted">
@{user.username}
{user.email ? ` · ${user.email}` : ""}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
{t(`users:roles.${user.role}`)}
</span>
<span className="text-[11px] text-fg-muted">{t(`users:roleHint.${user.role}`)}</span>
</div>
</div>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
<Stat label={t("account.memberSince")}>
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
</Stat>
<Stat label={t("account.workspaces")}>{account.workspaces}</Stat>
<Stat label={t("account.sessions")}>{account.sessions}</Stat>
<Stat label={t("account.logins")}>{account.logins}</Stat>
<Stat label={t("account.lastLogin")}>
{account.lastLoginAt ? formatDateTime(account.lastLoginAt) : "—"}
</Stat>
</dl>
<p className="mt-3 text-[11px] text-fg-muted">{t("account.roleNote")}</p>
</section>
);
}
function ProfileForm({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const [displayName, setDisplayName] = useState(account.user.displayName ?? "");
const [email, setEmail] = useState(account.user.email ?? "");
const [busy, setBusy] = useState(false);
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
const body: { displayName?: string | null; email?: string | null } = {};
if (norm(displayName) !== (account.user.displayName ?? null))
body.displayName = norm(displayName);
if (norm(email) !== (account.user.email ?? null)) body.email = norm(email);
const changed = Object.keys(body).length > 0;
const save = async () => {
if (!changed || busy) return;
setBusy(true);
try {
onSaved(await api.me.updateAccount(body));
toast.success(t("account.profileSaved"));
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void save();
}
};
return (
<SettingsSection
id="account-profile"
title={t("account.profile")}
description={t("account.profileDescription")}
>
<div className="grid gap-3 sm:grid-cols-2" onKeyDown={onKeyDown}>
<Field label={t("users:displayName")}>
<input
className="input w-full text-sm"
value={displayName}
maxLength={100}
onChange={(e) => setDisplayName(e.target.value)}
aria-label={t("users:displayName")}
/>
</Field>
<Field label={t("users:email")}>
<input
type="email"
className="input w-full text-sm"
value={email}
maxLength={254}
onChange={(e) => setEmail(e.target.value)}
aria-label={t("users:email")}
/>
</Field>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!changed || busy}
onClick={() => void save()}
>
{t("account.save")}
</button>
</div>
</SettingsSection>
);
}
function PasswordForm() {
const { t } = useTranslation("settings");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [again, setAgain] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const mismatch = again.length > 0 && again !== next;
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.me.changePassword(current, next);
setCurrent("");
setNext("");
setAgain("");
toast.success(t("account.passwordChanged"));
} catch (e) {
setError(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<SettingsSection
id="account-password"
title={t("account.password")}
description={t("account.passwordDescription")}
>
<div className="grid gap-3 sm:grid-cols-3" onKeyDown={onKeyDown}>
<Field label={t("account.currentPassword")}>
<input
type="password"
className="input w-full text-sm"
value={current}
autoComplete="current-password"
onChange={(e) => setCurrent(e.target.value)}
aria-label={t("account.currentPassword")}
/>
</Field>
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={next}
autoComplete="new-password"
onChange={(e) => setNext(e.target.value)}
aria-label={t("account.newPassword")}
/>
</Field>
<Field label={t("account.confirmPassword")}>
<input
type="password"
className="input w-full text-sm"
value={again}
autoComplete="new-password"
onChange={(e) => setAgain(e.target.value)}
aria-label={t("account.confirmPassword")}
/>
</Field>
</div>
{(mismatch || error) && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error ?? t("account.mismatch")}
</p>
)}
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!ready}
onClick={() => void submit()}
>
{t("account.changePassword")}
</button>
</div>
</SettingsSection>
);
}
function SignOutOthers({ logins, onDone }: { logins: number; onDone: () => void }) {
const { t } = useTranslation("settings");
const [busy, setBusy] = useState(false);
const run = async () => {
const ok = await confirmDialog({
title: t("account.signOutOthersConfirm"),
message: t("account.signOutOthersBody"),
confirmLabel: t("account.signOutOthers"),
cancelLabel: t("common:cancel"),
destructive: true,
});
if (!ok) return;
setBusy(true);
try {
const r = await api.me.signOutOthers();
toast.success(t("account.signedOut", { count: r.ended }));
onDone();
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
return (
<SettingsSection
id="account-sessions"
title={t("account.sessionsTitle")}
description={t("account.sessionsDescription", { n: logins })}
>
<button
type="button"
className="btn-ghost text-status-danger disabled:opacity-50"
disabled={busy || logins <= 1}
onClick={() => void run()}
>
<LogOut className="h-4 w-4" /> {t("account.signOutOthers")}
</button>
</SettingsSection>
);
}
function Stat({ 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,108 @@
/**
* @file AccountSettings.test.tsx
* @description Settings → Account: shows the account's own information, saves
* only a changed display name/email, refuses a password change whose two new
* passwords differ, sends the current + new password, and asks before signing
* the other devices out.
* @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";
const me = vi.hoisted(() => ({
account: vi.fn(),
updateAccount: vi.fn(),
changePassword: vi.fn(),
signOutOthers: vi.fn(),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { me } };
});
const confirm = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog: confirm }));
import { AccountSettings } from "../AccountSettings";
const account = {
mode: "server" as const,
user: {
id: 2,
username: "mia",
role: "developer" as const,
disabled: false,
displayName: "Mia Tran",
email: null,
createdAt: "2026-10-01T00:00:00.000Z",
},
workspaces: 3,
sessions: 12,
logins: 2,
lastLoginAt: null,
};
beforeEach(() => {
for (const fn of Object.values(me)) fn.mockReset();
me.account.mockResolvedValue(account);
me.updateAccount.mockResolvedValue(account);
me.changePassword.mockResolvedValue({ ok: true });
me.signOutOthers.mockResolvedValue({ ended: 1 });
confirm.mockReset().mockResolvedValue(true);
});
describe("AccountSettings", () => {
it("shows the account's own information", async () => {
render(<AccountSettings />);
const card = await screen.findByTestId("account-card");
expect(card.textContent).toContain("Mia Tran");
expect(card.textContent).toContain("@mia");
expect(card.textContent).toContain("Developer");
expect(card.textContent).toContain("12");
});
it("saves only a changed field", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Email"), "mia@x.vn");
await ue.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(me.updateAccount).toHaveBeenCalledWith({ email: "mia@x.vn" }));
});
it("refuses two different new passwords", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-999");
expect(screen.getByText("The two new passwords differ.")).toBeTruthy();
const button = screen.getByRole("button", { name: "Change password" });
expect(button.hasAttribute("disabled")).toBe(true);
expect(me.changePassword).not.toHaveBeenCalled();
});
it("changes the password with the current one", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.type(screen.getByLabelText("Current password"), "old-pass-1");
await ue.type(screen.getByLabelText("New password"), "new-pass-123");
await ue.type(screen.getByLabelText("Confirm new password"), "new-pass-123");
await ue.click(screen.getByRole("button", { name: "Change password" }));
await waitFor(() =>
expect(me.changePassword).toHaveBeenCalledWith("old-pass-1", "new-pass-123")
);
});
it("asks before signing the other devices out", async () => {
const ue = userEvent.setup();
render(<AccountSettings />);
await screen.findByTestId("account-card");
await ue.click(screen.getByRole("button", { name: "Sign out other devices" }));
expect(confirm).toHaveBeenCalled();
await waitFor(() => expect(me.signOutOthers).toHaveBeenCalled());
});
});
@@ -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);
});
});