From 0c2f49876d3ca05c48a13ff5223bda1f7f568dbb Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 13 Oct 2026 17:10:29 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.7.6=20=E2=80=94=20edit=20users=20in?= =?UTF-8?q?=20one=20form,=20Settings=20=E2=86=92=20Account,=20display=20na?= =?UTF-8?q?mes,=20copy=20over=20plain=20HTTP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 5 +- CLAUDE.md | 10 +- client/src/components/AuthGate.tsx | 29 +- client/src/components/PathPickButton.tsx | 6 +- client/src/components/Sidebar.tsx | 2 +- client/src/components/WorkspaceTree.tsx | 14 +- .../__tests__/PathPickButton.test.tsx | 2 +- .../__tests__/WorkspaceTree.owners.test.tsx | 10 +- .../components/conversation/MessageList.tsx | 13 +- .../__tests__/MessageList.ask.test.tsx | 21 +- client/src/components/lanes/AddLaneModal.tsx | 19 +- .../components/lanes/FolderBrowseModal.tsx | 38 +- .../lanes/__tests__/AddLaneModal.test.tsx | 12 +- .../__tests__/FolderBrowseModal.test.tsx | 36 +- client/src/components/run/ChatFilePreview.tsx | 13 +- client/src/components/run/TerminalView.tsx | 12 +- .../run/__tests__/ChatFilePreview.test.tsx | 22 +- .../run/__tests__/PaneChat.test.tsx | 5 +- .../components/settings/AccountSettings.tsx | 352 ++++++++++++ .../__tests__/AccountSettings.test.tsx | 108 ++++ .../src/components/users/AccountPoolCard.tsx | 488 +++++++++++++++++ .../src/components/users/UserEditDialog.tsx | 512 ++++++++++++++++++ .../users/__tests__/AccountPoolCard.test.tsx | 155 ++++++ .../users/__tests__/UserEditDialog.test.tsx | 133 +++++ client/src/i18n/locales/en/lanes.json | 2 + client/src/i18n/locales/en/settings.json | 33 ++ client/src/i18n/locales/en/users.json | 122 ++++- client/src/i18n/locales/vi/lanes.json | 2 + client/src/i18n/locales/vi/settings.json | 33 ++ client/src/i18n/locales/vi/users.json | 122 ++++- client/src/lib/__tests__/clipboard.test.ts | 57 ++ client/src/lib/api.ts | 152 +++++- client/src/lib/clipboard.ts | 31 +- client/src/lib/settingsSearch.ts | 20 + client/src/lib/types.ts | 2 + client/src/pages/AiProxyConfig.tsx | 52 +- client/src/pages/SettingsHub.tsx | 22 +- client/src/pages/Users.tsx | 285 +++------- .../pages/__tests__/AiProxyConfig.test.tsx | 25 +- client/src/pages/__tests__/Users.test.tsx | 43 +- .../screens.snapshot.test.tsx.snap | 2 +- docs/API.md | 34 +- docs/LANES.md | 2 +- docs/SERVER-MODE.md | 74 ++- docs/claude-memory/agenthub-release-state.md | 2 +- openapi.yaml | 394 +++++++++++++- package-lock.json | 4 +- package.json | 2 +- scripts/server/install-system.sh | 8 +- scripts/test-server.js | 2 + server/__tests__/ai-proxies-9router.test.js | 57 ++ server/__tests__/api.test.js | 42 ++ server/__tests__/claude-pool-login.test.js | 284 ++++++++++ .../server-mode-account-pool.test.js | 146 +++++ .../__tests__/server-mode-user-edit.test.js | 263 +++++++++ server/__tests__/server-s3-onboarded.test.js | 105 ++++ server/__tests__/server-s3-review2.test.js | 33 ++ server/db.js | 9 + server/index.js | 12 + server/lib/audit.js | 3 +- server/lib/auth.js | 109 +++- server/lib/cc-usage.js | 33 +- server/lib/claude-pool.js | 337 +++++++++++- server/lib/lanes.js | 12 +- server/lib/pty-run.js | 23 +- server/lib/run-as.js | 31 ++ server/openapi-extra/auth.js | 211 +++++++- server/routes/accounts.js | 189 ++++++- server/routes/ai-proxies.js | 13 + server/routes/lanes.js | 24 +- server/routes/me.js | 92 ++++ server/routes/sessions.js | 65 +++ server/routes/users.js | 100 +++- skills/update-project-docs/SKILL.md | 2 +- 74 files changed, 5347 insertions(+), 392 deletions(-) create mode 100644 client/src/components/settings/AccountSettings.tsx create mode 100644 client/src/components/settings/__tests__/AccountSettings.test.tsx create mode 100644 client/src/components/users/AccountPoolCard.tsx create mode 100644 client/src/components/users/UserEditDialog.tsx create mode 100644 client/src/components/users/__tests__/AccountPoolCard.test.tsx create mode 100644 client/src/components/users/__tests__/UserEditDialog.test.tsx create mode 100644 client/src/lib/__tests__/clipboard.test.ts create mode 100644 server/__tests__/ai-proxies-9router.test.js create mode 100644 server/__tests__/claude-pool-login.test.js create mode 100644 server/__tests__/server-mode-account-pool.test.js create mode 100644 server/__tests__/server-mode-user-edit.test.js create mode 100644 server/__tests__/server-s3-onboarded.test.js diff --git a/AGENTS.md b/AGENTS.md index fba97429..f6e0b9fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,9 +23,8 @@ make while you work here as Codex. - `scripts/` for hook/install/import/cleanup utilities. ## Validation expectations -- Backend changes: run `npm run test:server` when possible. -- Frontend changes: run `npm run test:client` when possible. -- Full suites take 5–6 minutes, so while iterating run only what the change touches: `npm run test:server -- server/__tests__/.test.js`, and in `client/` `NODE_ENV=test npx vitest related --run` (or `vitest run `). Run the full suite once before committing, and always when the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib) — the per-screen snapshots catch that. +- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/.test.js`, and in `client/` `NODE_ENV=test npx vitest related --run` (or `vitest run `). Docs-only changes run no tests. +- Run a full suite (`npm run test:server` / `npm run test:client`) only when genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib), a shared server core (`server/db.js`, `server/index.js`, auth/scope, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks. The pre-commit hook already picks related vs. full tests per commit, so do not add an extra full run before committing. - If any check is skipped, report it explicitly. ## Safety expectations diff --git a/CLAUDE.md b/CLAUDE.md index 4474283e..a532fbb3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.7.5 (2026-10-13) — the chat shows each AskUserQuestion with the user's answer; while iterating, run only the tests a change touches._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.7.6 (2026-10-13) — an admin adds and removes Claude/Codex pool accounts on the Users page._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -78,7 +78,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **A headless entry point requires `server/lib/windows-hide.js` before anything reads `child_process`.** The dashboard and its detached helpers have no console on Windows, so every git/npm call without `windowsHide` opened a console window that flashed and stole focus on each poll. `install()` (in `server/index.js`, `plugin-bootstrap.js`, `plugin-update-helper.js`, `cc-update-all-helper.js`, `restart-helper.js`) forces it for every spawn; a new detached script needs the same first line, or a `windowsHide: true` of its own (the statusline's one git call has that). - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable — it never deletes, never adopts a non-member account, and root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. Operator steps: `docs/SERVER-MODE.md` § Operator runbook. -- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that adds or reads one — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`). An empty pool must leave every spawn exactly as before. The ONE exception is a Codex ChatGPT login (`server/lib/codex-pool.js`), which Codex reads from no env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one; `syncBack` copies the newest back. A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. +- **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. - Never silently weaken safety controls around destructive actions. @@ -94,9 +94,9 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - CLI (after setup): `agenthub ` — terminal access to the full dashboard surface (`bin/agenthub.js`; `agenthub help` lists commands) ## Testing and verification policy -- Backend changes: run `npm run test:server` before finishing. -- Frontend changes: run `npm run test:client` when relevant. This includes per-screen render snapshots (`client/src/pages/__tests__/screens.snapshot.test.tsx`). If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u`; never blindly update snapshots to make tests pass. -- Full suites take 5–6 minutes — while iterating run only what the change touches (`npm run test:server -- server/__tests__/.test.js`; in `client/` `NODE_ENV=test npx vitest related --run`), then the full suite once before committing, and always when touching something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib). +- **Run only the tests the change touches — never a full suite by default.** Full suites take 5–6 minutes. Backend: `npm run test:server -- server/__tests__/.test.js` (the test files for, or naming, each module you changed). Frontend: in `client/`, `NODE_ENV=test npx vitest related --run`. Docs-only changes run no tests. +- Run a full suite (`npm run test:server` / `npm run test:client`) ONLY when it is genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib — the per-screen snapshots `client/src/pages/__tests__/screens.snapshot.test.tsx` catch that), a shared server core (`server/db.js`, `server/index.js`, `server/lib/auth.js`/`scope.js`, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks for it. Do not re-run a full suite "once more before committing" — the pre-commit hook (`.husky/pre-commit`) already runs the related tests for a small commit and both full suites for a big or risky one. +- If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u `; never blindly update snapshots to make tests pass. - If you cannot run a verification step, state exactly what was not run and why. ## Change guidelines by area diff --git a/client/src/components/AuthGate.tsx b/client/src/components/AuthGate.tsx index f228c296..857412b3 100644 --- a/client/src/components/AuthGate.tsx +++ b/client/src/components/AuthGate.tsx @@ -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 }; +type AuthValue = { + state: AuthState; + logout: () => Promise; + updateUser: (patch: Partial) => void; +}; -const AuthContext = createContext({ state: { mode: "local" }, logout: async () => {} }); +const AuthContext = createContext({ + 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) => + 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 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 {children}; + return ( + {children} + ); } function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) { diff --git a/client/src/components/PathPickButton.tsx b/client/src/components/PathPickButton.tsx index 02f8a90a..04d2743b 100644 --- a/client/src/components/PathPickButton.tsx +++ b/client/src/components/PathPickButton.tsx @@ -12,7 +12,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ -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; }) { const { t } = useTranslation(["lanes"]); const [busy, setBusy] = useState(false); @@ -55,7 +52,6 @@ export function PathPickButton({ return ( <> + + ); + } + if (!account) { + return
; + } + + return ( +
+ + + + +
+ ); +} + +function ProfileCard({ account }: { account: ServerAccount }) { + const { t } = useTranslation("settings"); + const { user } = account; + const shown = user.displayName || user.username; + return ( +
+
+ + {shown.slice(0, 2)} + +
+

{shown}

+

+ @{user.username} + {user.email ? ` · ${user.email}` : ""} +

+
+ + {t(`users:roles.${user.role}`)} + + {t(`users:roleHint.${user.role}`)} +
+
+
+
+ + {user.createdAt ? formatDateTime(user.createdAt) : "—"} + + {account.workspaces} + {account.sessions} + {account.logins} + + {account.lastLoginAt ? formatDateTime(account.lastLoginAt) : "—"} + +
+

{t("account.roleNote")}

+
+ ); +} + +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 ( + +
+ + setDisplayName(e.target.value)} + aria-label={t("users:displayName")} + /> + + + setEmail(e.target.value)} + aria-label={t("users:email")} + /> + +
+
+ +
+
+ ); +} + +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(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 ( + +
+ + setCurrent(e.target.value)} + aria-label={t("account.currentPassword")} + /> + + + setNext(e.target.value)} + aria-label={t("account.newPassword")} + /> + + + setAgain(e.target.value)} + aria-label={t("account.confirmPassword")} + /> + +
+ {(mismatch || error) && ( +

+ {error ?? t("account.mismatch")} +

+ )} +
+ +
+
+ ); +} + +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 ( + + + + ); +} + +function Stat({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( + + ); +} diff --git a/client/src/components/settings/__tests__/AccountSettings.test.tsx b/client/src/components/settings/__tests__/AccountSettings.test.tsx new file mode 100644 index 00000000..cf640e78 --- /dev/null +++ b/client/src/components/settings/__tests__/AccountSettings.test.tsx @@ -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ĩ + */ + +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>(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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()); + }); +}); diff --git a/client/src/components/users/AccountPoolCard.tsx b/client/src/components/users/AccountPoolCard.tsx new file mode 100644 index 00000000..eb5b0e89 --- /dev/null +++ b/client/src/components/users/AccountPoolCard.tsx @@ -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ĩ + */ + +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(null); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [removing, setRemoving] = useState(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 ( +
+
+ +
+

{t("pool.title")}

+

{t("pool.description")}

+
+ + {isAdmin && ( + + )} +
+ {error && ( +

+ {error} +

+ )} + {pool && rows.length === 0 && ( +

{t("pool.empty")}

+ )} + {rows.length > 0 && ( +
+ + + + + + + + + {isAdmin && } + + + + {rows.map((r) => ( + + + + + + + {isAdmin && ( + + )} + + ))} + +
{t("pool.account")}{t("pool.tool")}{t("pool.livePanes")}{t("pool.quota")}{t("pool.spend")}{t("actions")}
+
{r.name}
+ {r.email &&
{r.email}
} +
+ {t("pool.added", { at: formatDateTime(r.addedAt) })} +
+
+ {t(`pool.kind.${r.kind}`)} + {r.expired && ( + + {t("pool.expired")} + + )} + {r.refreshError && !r.expired && ( + + {t("pool.refreshFailed")} + + )} + {r.full && ( + + {t("pool.full")} + + )} + + {r.livePanes} + + {r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`} + +
{fmtCost(r.spend5h)}
+
+ {t("pool.spend7d", { cost: fmtCost(r.spend7d) })} +
+
+ +
+
+ )} + + {adding && ( + setAdding(false)} + onDone={(name) => { + toast.success(t("pool.done.added", { name })); + setAdding(false); + void load(); + }} + /> + )} + {removing && ( + setRemoving(null)} + onDone={() => { + toast.success(t("pool.done.removed", { name: removing.name })); + setRemoving(null); + void load(); + }} + /> + )} +
+ ); +} + +type ClaudeSource = "profile" | "credentials" | "token"; + +function AddAccountDialog({ + onClose, + onDone, +}: { + onClose: () => void; + onDone: (name: string) => void; +}) { + const { t } = useTranslation("users"); + const [tool, setTool] = useState("claude"); + const [source, setSource] = useState("profile"); + const [profiles, setProfiles] = useState(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(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 ( + void submit()} + onCancel={onClose} + > +
+ + + + {tool === "claude" && ( + + + + )} + {fromProfile && ( + + + + )} + + setName(e.target.value)} + autoComplete="off" + aria-label={t("pool.name")} + /> + + {!fromProfile && ( + + {secretKind === "token" ? ( + setSecret(e.target.value)} + autoComplete="off" + aria-label={t(`pool.secret.${secretKind}`)} + /> + ) : ( +