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:
@@ -23,9 +23,8 @@ make while you work here as Codex.
|
|||||||
- `scripts/` for hook/install/import/cleanup utilities.
|
- `scripts/` for hook/install/import/cleanup utilities.
|
||||||
|
|
||||||
## Validation expectations
|
## Validation expectations
|
||||||
- Backend changes: run `npm run test:server` when possible.
|
- 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__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
|
||||||
- Frontend changes: run `npm run test:client` when possible.
|
- 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.
|
||||||
- Full suites take 5–6 minutes, so while iterating run only what the change touches: `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). 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.
|
|
||||||
- If any check is skipped, report it explicitly.
|
- If any check is skipped, report it explicitly.
|
||||||
|
|
||||||
## Safety expectations
|
## Safety expectations
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# 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
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- 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).
|
- **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.
|
- **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-<name>`, home `<USERS_ROOT>/<name>`; 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-<name> <tmux|git|gh|write-as>` 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.
|
- **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-<name>`, home `<USERS_ROOT>/<name>`; 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-<name> <tmux|git|gh|write-as>` 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.
|
- Preserve existing behavior unless explicitly asked to change it.
|
||||||
- Prefer minimal, reversible diffs.
|
- Prefer minimal, reversible diffs.
|
||||||
- Never silently weaken safety controls around destructive actions.
|
- 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 <command>` — terminal access to the full dashboard surface (`bin/agenthub.js`; `agenthub help` lists commands)
|
- CLI (after setup): `agenthub <command>` — terminal access to the full dashboard surface (`bin/agenthub.js`; `agenthub help` lists commands)
|
||||||
|
|
||||||
## Testing and verification policy
|
## Testing and verification policy
|
||||||
- Backend changes: run `npm run test:server` before finishing.
|
- **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__/<name>.test.js` (the test files for, or naming, each module you changed). Frontend: in `client/`, `NODE_ENV=test npx vitest related <changed files> --run`. Docs-only changes run no tests.
|
||||||
- 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.
|
- 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.
|
||||||
- Full suites take 5–6 minutes — while iterating run only what the change touches (`npm run test:server -- server/__tests__/<name>.test.js`; in `client/` `NODE_ENV=test npx vitest related <changed files> --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).
|
- If a UI change is intentional, review the snapshot diff and regenerate baselines with `cd client && npx vitest run -u <test file>`; never blindly update snapshots to make tests pass.
|
||||||
- If you cannot run a verification step, state exactly what was not run and why.
|
- If you cannot run a verification step, state exactly what was not run and why.
|
||||||
|
|
||||||
## Change guidelines by area
|
## Change guidelines by area
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
* 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`
|
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
* (`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.
|
* `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
|
* Once an account is known (local mode is one), its own UI preferences are
|
||||||
* loaded into this browser (`lib/userPrefs.ts`).
|
* loaded into this browser (`lib/userPrefs.ts`).
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
@@ -30,9 +32,17 @@ import { applySystemTheme } from "../hooks/useTheme";
|
|||||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||||
import { hydrate } from "../lib/userPrefs";
|
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). */
|
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||||
export const useAuth = () => useContext(AuthContext);
|
export const useAuth = () => useContext(AuthContext);
|
||||||
@@ -134,6 +144,15 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
if (account) void hydrate();
|
if (account) void hydrate();
|
||||||
}, [account]);
|
}, [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) return null;
|
||||||
if (state.mode === "server" && !state.user) {
|
if (state.mode === "server" && !state.user) {
|
||||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||||
@@ -142,7 +161,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
|||||||
await api.auth.logout().catch(() => {});
|
await api.auth.logout().catch(() => {});
|
||||||
setState({ mode: "server", user: null });
|
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 }) {
|
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
@@ -23,15 +23,12 @@ export function PathPickButton({
|
|||||||
start,
|
start,
|
||||||
onPick,
|
onPick,
|
||||||
showLabel = false,
|
showLabel = false,
|
||||||
buttonRef,
|
|
||||||
}: {
|
}: {
|
||||||
kind?: "dir" | "file";
|
kind?: "dir" | "file";
|
||||||
/** The field's current value — the picker opens there. */
|
/** The field's current value — the picker opens there. */
|
||||||
start?: string;
|
start?: string;
|
||||||
onPick: (path: string) => void;
|
onPick: (path: string) => void;
|
||||||
showLabel?: boolean;
|
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 { t } = useTranslation(["lanes"]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -55,7 +52,6 @@ export function PathPickButton({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ export function Sidebar({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void auth.logout()}
|
onClick={() => void auth.logout()}
|
||||||
title={`${t("common:auth.signedInAs", {
|
title={`${t("common:auth.signedInAs", {
|
||||||
name: auth.state.user.username,
|
name: auth.state.user.displayName || auth.state.user.username,
|
||||||
role: auth.state.user.role,
|
role: auth.state.user.role,
|
||||||
})} — ${t("common:auth.logout")}`}
|
})} — ${t("common:auth.logout")}`}
|
||||||
aria-label={t("common:auth.logout")}
|
aria-label={t("common:auth.logout")}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
* 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
|
* 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
|
* 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
|
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
* 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 (and attached run) the Workspace page shows, from its URL. */
|
||||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
/** 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) {
|
export function selectedFrom(pathname: string, search: string) {
|
||||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||||
const p = new URLSearchParams(search);
|
const p = new URLSearchParams(search);
|
||||||
@@ -150,7 +154,7 @@ export function WorkspaceTree() {
|
|||||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
// 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);
|
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||||
return [...list].sort(
|
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]);
|
}, [lanes, order, toolFilter, grouped, meId]);
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -338,8 +342,8 @@ export function WorkspaceTree() {
|
|||||||
lane.owner_id == null
|
lane.owner_id == null
|
||||||
? t("tree.noOwner")
|
? t("tree.noOwner")
|
||||||
: lane.owner_id === meId
|
: lane.owner_id === meId
|
||||||
? t("tree.ownerYou", { name: lane.owner_name ?? `#${lane.owner_id}` })
|
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
|
||||||
: (lane.owner_name ?? `#${lane.owner_id}`);
|
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||||
return (
|
return (
|
||||||
@@ -366,7 +370,7 @@ export function WorkspaceTree() {
|
|||||||
aria-hidden
|
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"
|
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>
|
||||||
<span className="truncate">{ownerLabel}</span>
|
<span className="truncate">{ownerLabel}</span>
|
||||||
<span className="ml-auto shrink-0 tabular-nums">
|
<span className="ml-auto shrink-0 tabular-nums">
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe("PathPickButton", () => {
|
|||||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||||
await userEvent.click(screen.getByRole("button"));
|
await userEvent.click(screen.getByRole("button"));
|
||||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
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();
|
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||||
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/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;
|
}) as unknown as Lane;
|
||||||
|
|
||||||
const lanes = [
|
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(2, null, null, "Old"),
|
||||||
lane(3, ME.id, "ana", "Mine"),
|
lane(3, ME.id, "ana", "Mine"),
|
||||||
lane(4, 1, "admin", "Admin site"),
|
lane(4, 1, "admin", "Admin site"),
|
||||||
@@ -113,6 +113,14 @@ describe("WorkspaceTree for an analyst", () => {
|
|||||||
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
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 () => {
|
it("folds a group and remembers it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTree();
|
renderTree();
|
||||||
|
|||||||
@@ -951,9 +951,16 @@ function RunningStep({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Under a turn that ended: how long it took and the output it made. */
|
/** 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
|
/** An AskUserQuestion: each question with the answer the user picked or
|
||||||
* typed (the result's `answers`), "declined" when they refused it, no answer
|
* typed (the result's `answers`, minus the " (Recommended)" marker),
|
||||||
* yet while the CLI still asks. */
|
* "declined" when they refused it, no answer yet while the CLI still asks. */
|
||||||
function AskCard({
|
function AskCard({
|
||||||
block,
|
block,
|
||||||
result,
|
result,
|
||||||
@@ -972,7 +979,7 @@ function AskCard({
|
|||||||
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
|
className="space-y-2 rounded-xl border border-border px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
{questions.map((q, i) => {
|
{questions.map((q, i) => {
|
||||||
const answer = result?.answers?.[q];
|
const answer = stripRecommended(result?.answers?.[q]);
|
||||||
return (
|
return (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<div className="flex items-start gap-1.5 text-fg-secondary">
|
<div className="flex items-start gap-1.5 text-fg-secondary">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file MessageList.ask.test.tsx
|
* @file MessageList.ask.test.tsx
|
||||||
* @description An AskUserQuestion call is not folded into the collapsed work
|
* @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
|
* 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.
|
* CLI still waits.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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();
|
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", () => {
|
it("says declined when refused, and shows no answer while still asked", () => {
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
||||||
|
|||||||
@@ -328,7 +328,6 @@ export function AddLaneModal({
|
|||||||
const titleId = useId();
|
const titleId = useId();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||||
const browseRef = useRef<HTMLButtonElement>(null);
|
|
||||||
useDialog(open, panelRef, handleCancel, cancelRef);
|
useDialog(open, panelRef, handleCancel, cancelRef);
|
||||||
|
|
||||||
// A summary on screen means the lane already exists and a step failed, so
|
// 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")}
|
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||||
{/* A click into the path opens the picker, as Browse does (the
|
{/* A click into the path only focuses it — the picker opens from
|
||||||
OS's own where it can be); typing still works from the keyboard. */}
|
Browse alone (#77). */}
|
||||||
<div
|
<div className="min-w-0 flex-1">
|
||||||
className="min-w-0 flex-1"
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") browseRef.current?.click();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CwdAutocomplete
|
<CwdAutocomplete
|
||||||
inputId="add-lane-repo"
|
inputId="add-lane-repo"
|
||||||
value={sourceRepo}
|
value={sourceRepo}
|
||||||
@@ -476,12 +470,7 @@ export function AddLaneModal({
|
|||||||
suggestions={cwdSuggestions}
|
suggestions={cwdSuggestions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PathPickButton
|
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||||
start={sourceRepo}
|
|
||||||
onPick={setSourceRepo}
|
|
||||||
showLabel
|
|
||||||
buttonRef={browseRef}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-[10px] text-fg-muted">
|
<p className="mt-1 text-[10px] text-fg-muted">
|
||||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
* 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),
|
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||||||
* Backspace goes up and typing jumps to a name.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
Home,
|
Home,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Server,
|
||||||
Star,
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
@@ -127,22 +132,26 @@ function BrowseDialog({
|
|||||||
// parent) — breadcrumbs above it are not links.
|
// parent) — breadcrumbs above it are not links.
|
||||||
const [floor, setFloor] = useState<string | null>(null);
|
const [floor, setFloor] = useState<string | null>(null);
|
||||||
const [home, setHome] = 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 listRef = useRef<HTMLDivElement>(null);
|
||||||
const crumbRef = useRef<HTMLDivElement>(null);
|
const crumbRef = useRef<HTMLDivElement>(null);
|
||||||
const typeAhead = useRef({ text: "", at: 0 });
|
const typeAhead = useRef({ text: "", at: 0 });
|
||||||
|
|
||||||
const load = async (
|
const load = async (
|
||||||
target?: string,
|
target?: string,
|
||||||
{ push = true, hidden = showHidden, select = null as string | null } = {}
|
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
|
||||||
) => {
|
) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const next = await api.lanes.browse(target, kind === "file", hidden);
|
const next = await api.lanes.browse(target, kind === "file", hidden, host);
|
||||||
setListing(next);
|
setListing(next);
|
||||||
setSelected(select);
|
setSelected(select);
|
||||||
setFilter("");
|
setFilter("");
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
if (next.home) setHome(next.home);
|
if (next.home) setHome(next.home);
|
||||||
|
setHostHome(next.hostHome ?? null);
|
||||||
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||||
if (push) {
|
if (push) {
|
||||||
setHistory((h) => {
|
setHistory((h) => {
|
||||||
@@ -162,7 +171,8 @@ function BrowseDialog({
|
|||||||
if (!open) return;
|
if (!open) return;
|
||||||
setHistory({ stack: [], at: -1 });
|
setHistory({ stack: [], at: -1 });
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
void load(initialPath);
|
setOnHost(false);
|
||||||
|
void load(initialPath, { host: false });
|
||||||
// Only re-run when the modal actually opens - not on every initialPath
|
// Only re-run when the modal actually opens - not on every initialPath
|
||||||
// keystroke in the field behind it.
|
// keystroke in the field behind it.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 toggleHidden = () => {
|
||||||
const next = !showHidden;
|
const next = !showHidden;
|
||||||
setShowHidden(next);
|
setShowHidden(next);
|
||||||
@@ -659,6 +676,21 @@ function BrowseDialog({
|
|||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
{t("browse.showHidden")}
|
{t("browse.showHidden")}
|
||||||
</label>
|
</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">
|
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||||
{t("browse.hint")}
|
{t("browse.hint")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -584,17 +584,15 @@ describe("AddLaneModal — folder browse", () => {
|
|||||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the picker on every click into the path field", async () => {
|
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
|
||||||
vi.mocked(api.lanes.pickPath).mockResolvedValue({ path: "/Users/tester/app" });
|
|
||||||
renderModal();
|
renderModal();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const field = document.getElementById("add-lane-repo")!;
|
const field = document.getElementById("add-lane-repo")!;
|
||||||
|
|
||||||
await user.click(field);
|
await user.click(field);
|
||||||
await waitFor(() => expect(field).toHaveValue("/Users/tester/app"));
|
expect(field).toHaveFocus();
|
||||||
await user.click(field);
|
expect(api.lanes.pickPath).not.toHaveBeenCalled();
|
||||||
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||||
expect(api.lanes.pickPath).toHaveBeenLastCalledWith("dir", "/Users/tester/app");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
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();
|
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||||
|
|
||||||
await user.dblClick(screen.getByText("projects"));
|
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 () => {
|
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) });
|
within(dialog).findByRole("option", { name: new RegExp(name) });
|
||||||
|
|
||||||
describe("FolderBrowseModal", () => {
|
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 () => {
|
it("click selects, double-click opens, Select picks the selection else the folder", async () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const dialog = await open(onSelect);
|
const dialog = await open(onSelect);
|
||||||
@@ -105,11 +131,11 @@ describe("FolderBrowseModal", () => {
|
|||||||
await option(dialog, "web");
|
await option(dialog, "web");
|
||||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
|
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
|
||||||
await waitFor(() =>
|
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 userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ }));
|
||||||
await waitFor(() =>
|
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.
|
// Above the user's own folder (it answered no parent) nothing is a link.
|
||||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
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 () => {
|
it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => {
|
||||||
const dialog = await open(vi.fn(), "/home/u/proj");
|
const dialog = await open(vi.fn(), "/home/u/proj");
|
||||||
await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ }));
|
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 userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||||
await option(dialog, "notes");
|
await option(dialog, "notes");
|
||||||
await userEvent.keyboard("{Control>}l{/Control}");
|
await userEvent.keyboard("{Control>}l{/Control}");
|
||||||
@@ -137,7 +165,7 @@ describe("FolderBrowseModal", () => {
|
|||||||
await userEvent.clear(field);
|
await userEvent.clear(field);
|
||||||
await userEvent.type(field, "/home/u/proj/web{Enter}");
|
await userEvent.type(field, "/home/u/proj/web{Enter}");
|
||||||
await waitFor(() =>
|
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)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
* `GET /api/lanes/:id/git/file` (or `/git/raw` for an image), a file under
|
* `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`,
|
* `~/.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
|
* 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
|
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
||||||
* else (or an image that route refuses) shows its path only. A Windows path's `\` counts as `/` throughout.
|
* 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
|
* `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
|
* 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.
|
* 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
|
// An image it read elsewhere (a screenshot under ~/.cache): served
|
||||||
// only because this session's own Read named it.
|
// only because this session's own Read named it.
|
||||||
setContent({ kind: "image", src: api.sessions.readImageUrl(scope.sessionId, path) });
|
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" });
|
} else setContent({ kind: "none" });
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
|
|||||||
@@ -32,7 +32,8 @@
|
|||||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
* (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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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 { useTheme } from "../../hooks/useTheme";
|
||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { writeClipboard } from "../../lib/clipboard";
|
||||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
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
|
// tmux names no selection (`52;;<b64>`), a CLI usually `c`: both are the
|
||||||
// clipboard. An empty write (a "clear") is ignored.
|
// clipboard. An empty write (a "clear") is ignored.
|
||||||
writeText: (_selection, text) => {
|
writeText: (_selection, text) => {
|
||||||
// Silent and best effort: no toast per copy, and on a non-secure origin
|
// Silent and best effort: no toast per copy. On a non-secure origin
|
||||||
// (no Clipboard API, no user gesture for a fallback) it simply does nothing.
|
// (`http://<lan-ip>`, no Clipboard API) writeClipboard falls back to the
|
||||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
// 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
|
* 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
|
* `~/.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
|
* 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
|
* list dialog previewing the file picked in it; and the skills dialog finding
|
||||||
* each skill's SKILL.md by name.
|
* each skill's SKILL.md by name.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -31,7 +31,14 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
gitRawUrl: () => "",
|
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: {
|
ccConfig: {
|
||||||
skills: vi.fn().mockResolvedValue({
|
skills: vi.fn().mockResolvedValue({
|
||||||
items: [{ scope: "user", name: "xlsx", file: "/h/.claude/skills/xlsx/SKILL.md" }],
|
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();
|
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={() => {}} />);
|
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: [],
|
skills: [],
|
||||||
mcp: [],
|
mcp: [],
|
||||||
}),
|
}),
|
||||||
|
readFile: r({ text: "", truncated: false }),
|
||||||
},
|
},
|
||||||
lanes: {
|
lanes: {
|
||||||
gitFile: r({
|
gitFile: r({
|
||||||
@@ -355,7 +356,9 @@ describe("PaneChat — the activity panel", () => {
|
|||||||
);
|
);
|
||||||
const first = render(pane());
|
const first = render(pane());
|
||||||
fireEvent.click(
|
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();
|
first.unmount();
|
||||||
render(pane());
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
"browse.recent": "Recent",
|
"browse.recent": "Recent",
|
||||||
"browse.selectFile": "Select file",
|
"browse.selectFile": "Select file",
|
||||||
"browse.showHidden": "Show hidden",
|
"browse.showHidden": "Show hidden",
|
||||||
|
"browse.hostFolder": "Server folder",
|
||||||
|
"browse.hostFolderHint": "Browse the server account's own folder ({{path}}). A workspace there runs as the shared server account, which every admin uses — not as your own.",
|
||||||
"browse.up": "Up one level (Backspace)",
|
"browse.up": "Up one level (Backspace)",
|
||||||
"browse.workspaces": "Workspaces",
|
"browse.workspaces": "Workspaces",
|
||||||
"browse.title": "Browse for a folder",
|
"browse.title": "Browse for a folder",
|
||||||
|
|||||||
@@ -626,6 +626,7 @@
|
|||||||
"title": "AI Proxy",
|
"title": "AI Proxy",
|
||||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||||
"add": "Add profile",
|
"add": "Add profile",
|
||||||
|
"config9router": "Config 9router",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||||
@@ -820,6 +821,10 @@
|
|||||||
"data": "Data"
|
"data": "Data"
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
|
"account": {
|
||||||
|
"title": "Account",
|
||||||
|
"description": "Your profile, password and sign-ins on this server."
|
||||||
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"description": "Language, colour mode and theme, and the corner pet."
|
"description": "Language, colour mode and theme, and the corner pet."
|
||||||
@@ -1044,5 +1049,33 @@
|
|||||||
"chooseFile": "Choose file",
|
"chooseFile": "Choose file",
|
||||||
"fileTooBig": "{{name}} is {{size}} characters — instructions hold at most {{max}}",
|
"fileTooBig": "{{name}} is {{size}} characters — instructions hold at most {{max}}",
|
||||||
"appliesTo": "Applies to"
|
"appliesTo": "Applies to"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"loadFailed": "Could not load your account: {{message}}",
|
||||||
|
"memberSince": "Member since",
|
||||||
|
"workspaces": "Workspaces",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"logins": "Active sign-ins",
|
||||||
|
"lastLogin": "Last sign-in",
|
||||||
|
"roleNote": "Your role and access are set by an admin on the Users page.",
|
||||||
|
"profile": "Profile",
|
||||||
|
"profileDescription": "How your name appears to others on this server.",
|
||||||
|
"save": "Save",
|
||||||
|
"profileSaved": "Profile saved",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordDescription": "Changing it keeps this browser signed in and signs you out everywhere else.",
|
||||||
|
"currentPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm new password",
|
||||||
|
"mismatch": "The two new passwords differ.",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"passwordChanged": "Password changed",
|
||||||
|
"sessionsTitle": "Sign-ins",
|
||||||
|
"sessionsDescription": "You are signed in on {{n}} browser(s), this one included.",
|
||||||
|
"signOutOthers": "Sign out other devices",
|
||||||
|
"signOutOthersConfirm": "Sign out every other device?",
|
||||||
|
"signOutOthersBody": "Every browser signed in to your account except this one is signed out.",
|
||||||
|
"signedOut_one": "Signed out {{count}} other device",
|
||||||
|
"signedOut_other": "Signed out {{count}} other devices"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"disabled": "{{name}} disabled",
|
"disabled": "{{name}} disabled",
|
||||||
"enabled": "{{name}} enabled",
|
"enabled": "{{name}} enabled",
|
||||||
"password": "{{name}}'s password reset",
|
"password": "{{name}}'s password reset",
|
||||||
"workspace": "Workspace given to {{name}}"
|
"workspace": "Workspace given to {{name}}",
|
||||||
|
"updated": "{{name}} updated"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"EREAUTH": "Your password is wrong.",
|
"EREAUTH": "Your password is wrong.",
|
||||||
@@ -64,12 +65,127 @@
|
|||||||
"ESELF": "You cannot change your own role or disable yourself.",
|
"ESELF": "You cannot change your own role or disable yourself.",
|
||||||
"EEXISTS": "That username is taken.",
|
"EEXISTS": "That username is taken.",
|
||||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||||
"EBADPASSWORD": "Password cannot be empty."
|
"EBADPASSWORD": "Password cannot be empty.",
|
||||||
|
"EBADEMAIL": "That email address does not look valid.",
|
||||||
|
"EBADFIELD": "One of the values is not valid.",
|
||||||
|
"ENOLANE": "That workspace no longer exists.",
|
||||||
|
"ENOTOWNED": "That workspace is not this user's any more.",
|
||||||
|
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
||||||
|
"EBADROLE": "Unknown role.",
|
||||||
|
"ELOCALMODE": "Accounts are managed in server mode only."
|
||||||
},
|
},
|
||||||
"readOnly": "Analysts can view accounts but not change them.",
|
"readOnly": "Analysts can view accounts but not change them.",
|
||||||
"auditTitle": "Audit log",
|
"auditTitle": "Audit log",
|
||||||
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||||
"auditEmpty": "Nothing recorded yet.",
|
"auditEmpty": "Nothing recorded yet.",
|
||||||
"activeCount": "{{n}} active",
|
"activeCount": "{{n}} active",
|
||||||
"cost30d": "Cost · 30 days"
|
"cost30d": "Cost · 30 days",
|
||||||
|
"displayName": "Display name",
|
||||||
|
"displayNameHint": "Shown beside the username. Optional.",
|
||||||
|
"email": "Email",
|
||||||
|
"edit": {
|
||||||
|
"button": "Edit user",
|
||||||
|
"title": "Edit {{name}}",
|
||||||
|
"created": "Created",
|
||||||
|
"lastLogin": "Last sign-in",
|
||||||
|
"never": "Never",
|
||||||
|
"logins": "Active sign-ins",
|
||||||
|
"profile": "Profile",
|
||||||
|
"access": "Access",
|
||||||
|
"status": "Status",
|
||||||
|
"active": "Active",
|
||||||
|
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
|
||||||
|
"selfRole": "You cannot change your own role.",
|
||||||
|
"selfDisable": "You cannot disable yourself.",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordHint": "Leave blank to keep the current one. A new one signs them out everywhere.",
|
||||||
|
"show": "Show password",
|
||||||
|
"hide": "Hide password",
|
||||||
|
"copy": "Copy password",
|
||||||
|
"generate": "Generate",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"noOwned": "Owns no workspace.",
|
||||||
|
"undo": "Undo",
|
||||||
|
"removeWorkspace": "Remove {{name}}",
|
||||||
|
"removeWorkspaceHint": "The workspace is left with no owner",
|
||||||
|
"addWorkspace": "Add workspace",
|
||||||
|
"chooseWorkspace": "Choose a workspace…",
|
||||||
|
"sessions": "Sign-ins",
|
||||||
|
"signOut": "Sign out everywhere",
|
||||||
|
"signOutHint": "Ends this user's {{n}} active sign-in(s); they sign in again with their password.",
|
||||||
|
"noChanges": "No changes yet."
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"title": "Account pool",
|
||||||
|
"description": "Claude and Codex accounts every user's new sessions run on — the least-used one is picked, and a login is written into the user's home by the server, so nobody signs in per user.",
|
||||||
|
"refresh": "Re-read quotas",
|
||||||
|
"add": "Add account",
|
||||||
|
"empty": "No accounts yet. Until you add one, each user's session needs that user's own sign-in.",
|
||||||
|
"account": "Account",
|
||||||
|
"tool": "Tool",
|
||||||
|
"livePanes": "Live",
|
||||||
|
"quota": "Quota",
|
||||||
|
"spend": "5 hours",
|
||||||
|
"spend7d": "{{cost}} in 7 days",
|
||||||
|
"added": "added {{at}}",
|
||||||
|
"expired": "expired",
|
||||||
|
"full": "full",
|
||||||
|
"kind": {
|
||||||
|
"token": "Claude · setup-token",
|
||||||
|
"login": "Claude · login",
|
||||||
|
"apikey": "Codex · API key",
|
||||||
|
"chatgpt": "Codex · ChatGPT"
|
||||||
|
},
|
||||||
|
"name": "Name",
|
||||||
|
"nameHint": "Any label you will recognise: letters, digits, '.', '_' or '-'",
|
||||||
|
"remove": "Remove {{name}}",
|
||||||
|
"removeHint": "New sessions stop using this account. Sessions already running on it keep running.",
|
||||||
|
"removeButton": "Remove",
|
||||||
|
"done": {
|
||||||
|
"added": "{{name}} added to the pool",
|
||||||
|
"removed": "{{name}} removed from the pool"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"EREAUTH": "Your password is wrong.",
|
||||||
|
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||||
|
"EBADNAME": "Name: 1–64 letters, digits, '.', '_' or '-'.",
|
||||||
|
"EBADTOKEN": "That is not a valid token for this tool.",
|
||||||
|
"EEXISTS": "An account with that name is already in the pool.",
|
||||||
|
"ENOACCOUNT": "That account is no longer in the pool.",
|
||||||
|
"ELOCALMODE": "In local mode the pool is changed from the host's shell (agenthub accounts).",
|
||||||
|
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
||||||
|
"ENOTFOUND": "That saved login no longer exists.",
|
||||||
|
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||||
|
"EUPSTREAM": "Anthropic could not be reached. Try again."
|
||||||
|
},
|
||||||
|
"source": "From",
|
||||||
|
"sources": {
|
||||||
|
"profile": "A saved login (Switch account profiles)",
|
||||||
|
"credentials": "Paste .credentials.json / Keychain",
|
||||||
|
"token": "claude setup-token"
|
||||||
|
},
|
||||||
|
"sourceHint": {
|
||||||
|
"profile": "The login is moved into the pool: the profile leaves the switcher, and the server writes it into each user's home when their session starts.",
|
||||||
|
"credentials": "From ~/.claude/.credentials.json, or on a Mac `security find-generic-password -s \"Claude Code-credentials\" -w`. Do not keep using that login where you copied it from — two copies log each other out.",
|
||||||
|
"token": "A one-year token from `claude setup-token`, given to every session as an environment variable."
|
||||||
|
},
|
||||||
|
"profile": "Profile",
|
||||||
|
"profileHint": "Saved in Settings → Claude config → Switch account. The one this server's own Claude Code is logged into can be moved too: the server keeps running on it and refreshes it for everyone.",
|
||||||
|
"chooseProfile": "Choose a saved login…",
|
||||||
|
"noProfiles": "No saved logins — save one in Settings → Claude config → Switch account",
|
||||||
|
"loadingProfiles": "Loading…",
|
||||||
|
"activeHere": "in use on this server — keeps working",
|
||||||
|
"nameFromProfile": "Leave blank to use the profile's name",
|
||||||
|
"secret": {
|
||||||
|
"credentials": "Credentials JSON",
|
||||||
|
"token": "Token",
|
||||||
|
"codex": "API key or auth.json"
|
||||||
|
},
|
||||||
|
"secretHint": {
|
||||||
|
"credentials": "The whole file: {\"claudeAiOauth\": {…}}",
|
||||||
|
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||||
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||||
|
},
|
||||||
|
"refreshFailed": "refresh failed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
"browse.recent": "Gần đây",
|
"browse.recent": "Gần đây",
|
||||||
"browse.selectFile": "Chọn tệp",
|
"browse.selectFile": "Chọn tệp",
|
||||||
"browse.showHidden": "Hiện mục ẩn",
|
"browse.showHidden": "Hiện mục ẩn",
|
||||||
|
"browse.hostFolder": "Thư mục máy chủ",
|
||||||
|
"browse.hostFolderHint": "Duyệt thư mục của tài khoản máy chủ ({{path}}). Workspace ở đó chạy bằng tài khoản máy chủ dùng chung cho mọi admin — không phải tài khoản riêng của bạn.",
|
||||||
"browse.up": "Lên một cấp (Backspace)",
|
"browse.up": "Lên một cấp (Backspace)",
|
||||||
"browse.workspaces": "Workspace",
|
"browse.workspaces": "Workspace",
|
||||||
"browse.title": "Duyệt chọn thư mục",
|
"browse.title": "Duyệt chọn thư mục",
|
||||||
|
|||||||
@@ -626,6 +626,7 @@
|
|||||||
"title": "AI Proxy",
|
"title": "AI Proxy",
|
||||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||||
"add": "Thêm profile",
|
"add": "Thêm profile",
|
||||||
|
"config9router": "Config 9router",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"remove": "Xoá",
|
"remove": "Xoá",
|
||||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||||
@@ -820,6 +821,10 @@
|
|||||||
"data": "Dữ liệu"
|
"data": "Dữ liệu"
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
|
"account": {
|
||||||
|
"title": "Tài khoản",
|
||||||
|
"description": "Hồ sơ, mật khẩu và các phiên đăng nhập của bạn trên server này."
|
||||||
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Giao diện",
|
"title": "Giao diện",
|
||||||
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
|
"description": "Ngôn ngữ, chế độ màu và theme, và linh vật ở góc màn hình."
|
||||||
@@ -1044,5 +1049,33 @@
|
|||||||
"chooseFile": "Chọn file",
|
"chooseFile": "Chọn file",
|
||||||
"fileTooBig": "{{name}} dài {{size}} ký tự — hướng dẫn tối đa {{max}}",
|
"fileTooBig": "{{name}} dài {{size}} ký tự — hướng dẫn tối đa {{max}}",
|
||||||
"appliesTo": "Áp dụng cho"
|
"appliesTo": "Áp dụng cho"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||||
|
"memberSince": "Tham gia từ",
|
||||||
|
"workspaces": "Workspace",
|
||||||
|
"sessions": "Session",
|
||||||
|
"logins": "Phiên đăng nhập",
|
||||||
|
"lastLogin": "Đăng nhập gần nhất",
|
||||||
|
"roleNote": "Vai trò và quyền truy cập của bạn do admin đặt ở trang Người dùng.",
|
||||||
|
"profile": "Hồ sơ",
|
||||||
|
"profileDescription": "Tên của bạn hiển thị với người khác trên server này.",
|
||||||
|
"save": "Lưu",
|
||||||
|
"profileSaved": "Đã lưu hồ sơ",
|
||||||
|
"password": "Mật khẩu",
|
||||||
|
"passwordDescription": "Đổi mật khẩu giữ trình duyệt này đăng nhập và đăng xuất bạn ở mọi nơi khác.",
|
||||||
|
"currentPassword": "Mật khẩu hiện tại",
|
||||||
|
"newPassword": "Mật khẩu mới",
|
||||||
|
"confirmPassword": "Nhập lại mật khẩu mới",
|
||||||
|
"mismatch": "Hai mật khẩu mới không khớp.",
|
||||||
|
"changePassword": "Đổi mật khẩu",
|
||||||
|
"passwordChanged": "Đã đổi mật khẩu",
|
||||||
|
"sessionsTitle": "Phiên đăng nhập",
|
||||||
|
"sessionsDescription": "Bạn đang đăng nhập trên {{n}} trình duyệt, tính cả trình duyệt này.",
|
||||||
|
"signOutOthers": "Đăng xuất các thiết bị khác",
|
||||||
|
"signOutOthersConfirm": "Đăng xuất mọi thiết bị khác?",
|
||||||
|
"signOutOthersBody": "Mọi trình duyệt đang đăng nhập tài khoản của bạn, trừ trình duyệt này, sẽ bị đăng xuất.",
|
||||||
|
"signedOut_one": "Đã đăng xuất {{count}} thiết bị khác",
|
||||||
|
"signedOut_other": "Đã đăng xuất {{count}} thiết bị khác"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@
|
|||||||
"disabled": "Đã khoá {{name}}",
|
"disabled": "Đã khoá {{name}}",
|
||||||
"enabled": "Đã mở khoá {{name}}",
|
"enabled": "Đã mở khoá {{name}}",
|
||||||
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
||||||
"workspace": "Đã giao workspace cho {{name}}"
|
"workspace": "Đã giao workspace cho {{name}}",
|
||||||
|
"updated": "Đã cập nhật {{name}}"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||||
@@ -64,12 +65,127 @@
|
|||||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||||
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
||||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||||
"EBADPASSWORD": "Mật khẩu không được để trống."
|
"EBADPASSWORD": "Mật khẩu không được để trống.",
|
||||||
|
"EBADEMAIL": "Địa chỉ email không hợp lệ.",
|
||||||
|
"EBADFIELD": "Có một giá trị không hợp lệ.",
|
||||||
|
"ENOLANE": "Workspace đó không còn tồn tại.",
|
||||||
|
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
||||||
|
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
||||||
|
"EBADROLE": "Vai trò không hợp lệ.",
|
||||||
|
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server."
|
||||||
},
|
},
|
||||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||||
"auditTitle": "Nhật ký audit",
|
"auditTitle": "Nhật ký audit",
|
||||||
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
"auditDescription": "Các thay đổi tài khoản thực hiện ở đây, và mỗi lần admin hoặc analyst mở phiên của một developer.",
|
||||||
"auditEmpty": "Chưa ghi nhận gì.",
|
"auditEmpty": "Chưa ghi nhận gì.",
|
||||||
"activeCount": "{{n}} đang hoạt động",
|
"activeCount": "{{n}} đang hoạt động",
|
||||||
"cost30d": "Chi phí · 30 ngày"
|
"cost30d": "Chi phí · 30 ngày",
|
||||||
|
"displayName": "Tên hiển thị",
|
||||||
|
"displayNameHint": "Hiện cạnh tên đăng nhập. Không bắt buộc.",
|
||||||
|
"email": "Email",
|
||||||
|
"edit": {
|
||||||
|
"button": "Sửa người dùng",
|
||||||
|
"title": "Sửa {{name}}",
|
||||||
|
"created": "Ngày tạo",
|
||||||
|
"lastLogin": "Đăng nhập gần nhất",
|
||||||
|
"never": "Chưa bao giờ",
|
||||||
|
"logins": "Phiên đăng nhập",
|
||||||
|
"profile": "Hồ sơ",
|
||||||
|
"access": "Quyền truy cập",
|
||||||
|
"status": "Trạng thái",
|
||||||
|
"active": "Đang hoạt động",
|
||||||
|
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
|
||||||
|
"selfRole": "Bạn không thể đổi vai trò của chính mình.",
|
||||||
|
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
|
||||||
|
"password": "Mật khẩu",
|
||||||
|
"passwordHint": "Để trống để giữ mật khẩu hiện tại. Mật khẩu mới sẽ đăng xuất họ ở mọi nơi.",
|
||||||
|
"show": "Hiện mật khẩu",
|
||||||
|
"hide": "Ẩn mật khẩu",
|
||||||
|
"copy": "Sao chép mật khẩu",
|
||||||
|
"generate": "Tạo ngẫu nhiên",
|
||||||
|
"loading": "Đang tải…",
|
||||||
|
"noOwned": "Chưa sở hữu workspace nào.",
|
||||||
|
"undo": "Hoàn tác",
|
||||||
|
"removeWorkspace": "Bỏ {{name}}",
|
||||||
|
"removeWorkspaceHint": "Workspace sẽ không còn chủ sở hữu",
|
||||||
|
"addWorkspace": "Thêm workspace",
|
||||||
|
"chooseWorkspace": "Chọn một workspace…",
|
||||||
|
"sessions": "Phiên đăng nhập",
|
||||||
|
"signOut": "Đăng xuất ở mọi nơi",
|
||||||
|
"signOutHint": "Kết thúc {{n}} phiên đăng nhập đang hoạt động của người này; họ đăng nhập lại bằng mật khẩu.",
|
||||||
|
"noChanges": "Chưa có thay đổi."
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"title": "Pool tài khoản",
|
||||||
|
"description": "Các tài khoản Claude và Codex mà phiên mới của mọi user chạy trên đó — tự chọn tài khoản ít tải nhất, server ghi login vào home của user, không ai phải đăng nhập riêng.",
|
||||||
|
"refresh": "Đọc lại quota",
|
||||||
|
"add": "Thêm tài khoản",
|
||||||
|
"empty": "Chưa có tài khoản nào. Khi chưa thêm, phiên của mỗi người dùng cần chính người đó đăng nhập.",
|
||||||
|
"account": "Tài khoản",
|
||||||
|
"tool": "Công cụ",
|
||||||
|
"livePanes": "Đang chạy",
|
||||||
|
"quota": "Quota",
|
||||||
|
"spend": "5 giờ",
|
||||||
|
"spend7d": "{{cost}} trong 7 ngày",
|
||||||
|
"added": "thêm lúc {{at}}",
|
||||||
|
"expired": "hết hạn",
|
||||||
|
"full": "đầy",
|
||||||
|
"kind": {
|
||||||
|
"token": "Claude · setup-token",
|
||||||
|
"login": "Claude · login",
|
||||||
|
"apikey": "Codex · API key",
|
||||||
|
"chatgpt": "Codex · ChatGPT"
|
||||||
|
},
|
||||||
|
"name": "Tên",
|
||||||
|
"nameHint": "Nhãn tuỳ ý để bạn nhận ra: chữ, số, '.', '_' hoặc '-'",
|
||||||
|
"remove": "Xoá {{name}}",
|
||||||
|
"removeHint": "Phiên mới sẽ không dùng tài khoản này nữa. Các phiên đang chạy trên nó vẫn chạy tiếp.",
|
||||||
|
"removeButton": "Xoá",
|
||||||
|
"done": {
|
||||||
|
"added": "Đã thêm {{name}} vào pool",
|
||||||
|
"removed": "Đã xoá {{name}} khỏi pool"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||||
|
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
|
||||||
|
"EBADNAME": "Tên: 1–64 ký tự chữ, số, '.', '_' hoặc '-'.",
|
||||||
|
"EBADTOKEN": "Token không hợp lệ cho công cụ này.",
|
||||||
|
"EEXISTS": "Đã có tài khoản trùng tên trong pool.",
|
||||||
|
"ENOACCOUNT": "Tài khoản đó không còn trong pool.",
|
||||||
|
"ELOCALMODE": "Ở chế độ local, pool được thay đổi từ shell của máy (agenthub accounts).",
|
||||||
|
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
||||||
|
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||||
|
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||||
|
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
|
||||||
|
},
|
||||||
|
"source": "Nguồn",
|
||||||
|
"sources": {
|
||||||
|
"profile": "Login đã lưu (profile Switch account)",
|
||||||
|
"credentials": "Dán .credentials.json / Keychain",
|
||||||
|
"token": "claude setup-token"
|
||||||
|
},
|
||||||
|
"sourceHint": {
|
||||||
|
"profile": "Login được chuyển vào pool: profile rời khỏi trình đổi tài khoản, và server ghi nó vào home của từng user khi phiên của họ bắt đầu.",
|
||||||
|
"credentials": "Lấy từ ~/.claude/.credentials.json, hoặc trên Mac: `security find-generic-password -s \"Claude Code-credentials\" -w`. Đừng dùng tiếp login đó ở nơi đã copy — hai bản sẽ làm nhau bị đăng xuất.",
|
||||||
|
"token": "Token 1 năm từ `claude setup-token`, đưa vào mọi phiên qua biến môi trường."
|
||||||
|
},
|
||||||
|
"profile": "Profile",
|
||||||
|
"profileHint": "Lưu ở Settings → Claude config → Switch account. Profile mà Claude Code của server đang dùng cũng chuyển được: server vẫn chạy trên nó và tự làm mới cho mọi người.",
|
||||||
|
"chooseProfile": "Chọn login đã lưu…",
|
||||||
|
"noProfiles": "Chưa có login nào — lưu ở Settings → Claude config → Switch account",
|
||||||
|
"loadingProfiles": "Đang tải…",
|
||||||
|
"activeHere": "server đang dùng — vẫn chạy tiếp",
|
||||||
|
"nameFromProfile": "Để trống để dùng tên profile",
|
||||||
|
"secret": {
|
||||||
|
"credentials": "Credentials JSON",
|
||||||
|
"token": "Token",
|
||||||
|
"codex": "API key hoặc auth.json"
|
||||||
|
},
|
||||||
|
"secretHint": {
|
||||||
|
"credentials": "Toàn bộ file: {\"claudeAiOauth\": {…}}",
|
||||||
|
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||||
|
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||||
|
},
|
||||||
|
"refreshFailed": "làm mới lỗi"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file clipboard.test.ts
|
||||||
|
* @description `writeClipboard` / `copyText`: the async Clipboard API when the
|
||||||
|
* origin has one, else the hidden-textarea `execCommand("copy")` — the only
|
||||||
|
* path on `http://<lan-ip>`, where a terminal selection must still reach the
|
||||||
|
* user's clipboard — and the focus goes back to where it was.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { copyText, writeClipboard } from "../clipboard";
|
||||||
|
|
||||||
|
const original = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||||
|
const setClipboard = (value: unknown) =>
|
||||||
|
Object.defineProperty(navigator, "clipboard", { value, configurable: true });
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (original) Object.defineProperty(navigator, "clipboard", original);
|
||||||
|
else delete (navigator as { clipboard?: unknown }).clipboard;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("writeClipboard", () => {
|
||||||
|
it("uses the Clipboard API when the origin has one", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
setClipboard({ writeText });
|
||||||
|
expect(await writeClipboard("hello")).toBe(true);
|
||||||
|
expect(writeText).toHaveBeenCalledWith("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to execCommand on a non-secure origin, and gives the focus back", async () => {
|
||||||
|
setClipboard(undefined);
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
let copied = "";
|
||||||
|
const exec = vi.fn((cmd: string) => {
|
||||||
|
copied = document.querySelector("textarea")?.value ?? "";
|
||||||
|
return cmd === "copy";
|
||||||
|
});
|
||||||
|
Object.defineProperty(document, "execCommand", { value: exec, configurable: true });
|
||||||
|
expect(await writeClipboard("from tmux")).toBe(true);
|
||||||
|
expect(exec).toHaveBeenCalledWith("copy");
|
||||||
|
expect(copied).toBe("from tmux");
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
expect(document.querySelector("textarea")).toBeNull();
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copyText reports a failed copy", async () => {
|
||||||
|
setClipboard(undefined);
|
||||||
|
Object.defineProperty(document, "execCommand", {
|
||||||
|
value: vi.fn(() => false),
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
expect(await copyText("x", true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+144
-8
@@ -540,7 +540,88 @@ export type ServerUserUsage = AuthUser & {
|
|||||||
sessions: number;
|
sessions: number;
|
||||||
last7d: { cost: number; tokens: number };
|
last7d: { cost: number; tokens: number };
|
||||||
last30d: { cost: number; tokens: number };
|
last30d: { cost: number; tokens: number };
|
||||||
|
/** Active login sessions (cookies not expired). */
|
||||||
|
logins?: number;
|
||||||
|
lastLoginAt?: string | null;
|
||||||
};
|
};
|
||||||
|
/** GET /api/users/:id — one account plus the workspaces it owns. */
|
||||||
|
export type UserDetail = {
|
||||||
|
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
|
||||||
|
workspaces: { id: number; title: string; cwd: string }[];
|
||||||
|
};
|
||||||
|
/** PATCH /api/users/:id — every field optional; only what changed is sent. */
|
||||||
|
export type UserPatch = {
|
||||||
|
displayName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
role?: AuthUser["role"];
|
||||||
|
disabled?: boolean;
|
||||||
|
password?: string;
|
||||||
|
addWorkspaces?: number[];
|
||||||
|
removeWorkspaces?: number[];
|
||||||
|
signOut?: true;
|
||||||
|
};
|
||||||
|
/** GET /api/me/account — the signed-in account's own information. */
|
||||||
|
export type MyAccount =
|
||||||
|
| { mode: "local" }
|
||||||
|
| {
|
||||||
|
mode: "server";
|
||||||
|
user: AuthUser & { disabled: boolean };
|
||||||
|
workspaces: number;
|
||||||
|
sessions: number;
|
||||||
|
logins: number;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
};
|
||||||
|
/** One Claude pool account as `GET /api/accounts` reports it — never its token. */
|
||||||
|
export type PoolAccount = {
|
||||||
|
name: string;
|
||||||
|
/** `token` = a setup-token; `login` = a real login placed in members' homes. */
|
||||||
|
kind: "token" | "login";
|
||||||
|
email: string | null;
|
||||||
|
/** A login the server could not refresh (`ESTALE`: log in again). */
|
||||||
|
refreshError: string | null;
|
||||||
|
addedAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
expired: boolean;
|
||||||
|
livePanes: number;
|
||||||
|
spend5h: number;
|
||||||
|
spend7d: number;
|
||||||
|
quotaPercent: number | null;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
/** One Codex pool account — an API key or a ChatGPT login, never the secret. */
|
||||||
|
export type CodexPoolAccount = {
|
||||||
|
name: string;
|
||||||
|
kind: "apikey" | "chatgpt";
|
||||||
|
addedAt: string;
|
||||||
|
livePanes: number;
|
||||||
|
spend5h: number;
|
||||||
|
spend7d: number;
|
||||||
|
quotaPercent: number | null;
|
||||||
|
full: boolean;
|
||||||
|
};
|
||||||
|
export type AccountPool = {
|
||||||
|
accounts: PoolAccount[];
|
||||||
|
codex: CodexPoolAccount[];
|
||||||
|
blockPercent: number;
|
||||||
|
};
|
||||||
|
export type PoolTool = "claude" | "codex";
|
||||||
|
/** A saved switch-user profile that can be moved into the pool (no token). */
|
||||||
|
export type PoolProfile = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
email: string | null;
|
||||||
|
subscriptionType: string | null;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
/** What `POST /api/accounts` takes: exactly one of `profile`, `credentials`, `token`. */
|
||||||
|
export type PoolAccountInput = {
|
||||||
|
tool: PoolTool;
|
||||||
|
name: string;
|
||||||
|
profile?: string;
|
||||||
|
credentials?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** A staff read of a member's session (`GET /api/users/audit`). */
|
/** A staff read of a member's session (`GET /api/users/audit`). */
|
||||||
export type AuditEntry = {
|
export type AuditEntry = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -550,10 +631,21 @@ export type AuditEntry = {
|
|||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
reader: string | null;
|
reader: string | null;
|
||||||
owner: string | null;
|
owner: string | null;
|
||||||
|
/** Their display names, when set — shown instead of the usernames. */
|
||||||
|
reader_display?: string | null;
|
||||||
|
owner_display?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Who is using the dashboard: local mode has no login at all. */
|
/** Who is using the dashboard: local mode has no login at all. */
|
||||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "developer" };
|
export type AuthUser = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: "admin" | "analyst" | "developer";
|
||||||
|
/** Optional profile fields — absent from an older server's answers. */
|
||||||
|
displayName?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||||
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
|
||||||
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
export type UserPrefs = { prefs: Record<string, string>; onboardedAt: string | null };
|
||||||
@@ -702,6 +794,19 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
/** POST /api/me/onboarded — the first-run setup is done (or skipped). */
|
||||||
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
onboarded: () => request<UserPrefs>("/me/onboarded", { method: "POST" }),
|
||||||
|
/** GET /api/me/account — this account's own information (server mode). */
|
||||||
|
account: () => request<MyAccount>("/me/account"),
|
||||||
|
/** PATCH /api/me/account — this account's display name and email. */
|
||||||
|
updateAccount: (body: { displayName?: string | null; email?: string | null }) =>
|
||||||
|
request<MyAccount>("/me/account", { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
|
/** POST /api/me/password — this login stays, every other one ends. */
|
||||||
|
changePassword: (currentPassword: string, newPassword: string) =>
|
||||||
|
request<{ ok: true }>("/me/password", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ currentPassword, newPassword }),
|
||||||
|
}),
|
||||||
|
/** POST /api/me/sign-out-others — end every login of this account but this one. */
|
||||||
|
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
|
||||||
},
|
},
|
||||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||||
users: {
|
users: {
|
||||||
@@ -721,12 +826,11 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ ...body, currentPassword }),
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
}),
|
}),
|
||||||
/** PATCH /api/users/:id — role and/or disabled (never your own). */
|
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
|
||||||
update: (
|
get: (id: number) => request<UserDetail>(`/users/${id}`),
|
||||||
id: number,
|
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
|
||||||
body: { role?: AuthUser["role"]; disabled?: boolean },
|
* sign-out in ONE change (never your own role or disabling yourself). */
|
||||||
currentPassword: string
|
update: (id: number, body: UserPatch, currentPassword: string) =>
|
||||||
) =>
|
|
||||||
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
|
request<{ user: AuthUser & { disabled: boolean } }>(`/users/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ ...body, currentPassword }),
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
@@ -744,6 +848,26 @@ export const api = {
|
|||||||
body: JSON.stringify({ laneId, currentPassword }),
|
body: JSON.stringify({ laneId, currentPassword }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
/** The Claude/Codex account pool every member's new panes run on
|
||||||
|
* (`server/routes/accounts.js`). Tokens only go IN: no answer has one. */
|
||||||
|
accountPool: {
|
||||||
|
/** GET /api/accounts — admin/analyst; `force` re-reads every quota. */
|
||||||
|
list: (force = false) => request<AccountPool>(`/accounts${force ? "?force=1" : ""}`),
|
||||||
|
/** POST /api/accounts — admin, with their OWN password again. */
|
||||||
|
/** GET /api/accounts/profiles — saved switch-user logins that can be moved in. */
|
||||||
|
profiles: () => request<{ profiles: PoolProfile[] }>("/accounts/profiles"),
|
||||||
|
add: (body: PoolAccountInput, currentPassword: string) =>
|
||||||
|
request<{ account: { name: string } }>("/accounts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...body, currentPassword }),
|
||||||
|
}),
|
||||||
|
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
|
||||||
|
remove: (tool: PoolTool, name: string, currentPassword: string) =>
|
||||||
|
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({ tool, currentPassword }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||||
/** Self-update status: whether this install is a git clone and, if so,
|
/** Self-update status: whether this install is a git clone and, if so,
|
||||||
* whether the tracked upstream/origin remote is ahead. Backs the "update
|
* whether the tracked upstream/origin remote is ahead. Backs the "update
|
||||||
@@ -934,6 +1058,12 @@ export const api = {
|
|||||||
* wherever it lies (local mode only), for an `<img>`. */
|
* wherever it lies (local mode only), for an `<img>`. */
|
||||||
readImageUrl: (id: string, path: string) =>
|
readImageUrl: (id: string, path: string) =>
|
||||||
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
|
`${BASE}/sessions/${encodeURIComponent(id)}/image?${new URLSearchParams({ path })}`,
|
||||||
|
/** GET /api/sessions/:id/file — a text file the session itself read or
|
||||||
|
* wrote, wherever it lies (an output outside the workspace). */
|
||||||
|
readFile: (id: string, path: string) =>
|
||||||
|
request<{ text: string; truncated: boolean }>(
|
||||||
|
`/sessions/${encodeURIComponent(id)}/file?${new URLSearchParams({ path })}`
|
||||||
|
),
|
||||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||||
* workspace, deletes nothing). */
|
* workspace, deletes nothing). */
|
||||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||||
@@ -2415,6 +2545,8 @@ export const api = {
|
|||||||
aiProxies: {
|
aiProxies: {
|
||||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||||
|
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||||
|
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||||
/** POST /api/ai-proxies - save a new profile. */
|
/** POST /api/ai-proxies - save a new profile. */
|
||||||
create: (body: AiProxyInput) =>
|
create: (body: AiProxyInput) =>
|
||||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||||
@@ -2975,16 +3107,20 @@ export const api = {
|
|||||||
* @returns `{ path, parent, entries, home }` — `parent` is null at the
|
* @returns `{ path, parent, entries, home }` — `parent` is null at the
|
||||||
* root, `home` is the folder the browser's Home place opens.
|
* root, `home` is the folder the browser's Home place opens.
|
||||||
*/
|
*/
|
||||||
browse: (path?: string, files?: boolean, hidden?: boolean) => {
|
browse: (path?: string, files?: boolean, hidden?: boolean, host?: boolean) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (path) q.set("path", path);
|
if (path) q.set("path", path);
|
||||||
if (files) q.set("files", "1");
|
if (files) q.set("files", "1");
|
||||||
if (hidden) q.set("hidden", "1");
|
if (hidden) q.set("hidden", "1");
|
||||||
|
if (host) q.set("host", "1");
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return request<{
|
return request<{
|
||||||
path: string;
|
path: string;
|
||||||
parent: string | null;
|
parent: string | null;
|
||||||
home?: string;
|
home?: string;
|
||||||
|
/** An admin with their own Linux account (S3): the host account's
|
||||||
|
* home, opened by `host=1` instead of theirs. */
|
||||||
|
hostHome?: string;
|
||||||
entries: {
|
entries: {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
+20
-11
@@ -4,7 +4,9 @@
|
|||||||
* Uses the async Clipboard API when it exists and falls back to a hidden
|
* Uses the async Clipboard API when it exists and falls back to a hidden
|
||||||
* textarea + `execCommand("copy")`, because `navigator.clipboard` is undefined
|
* textarea + `execCommand("copy")`, because `navigator.clipboard` is undefined
|
||||||
* on a non-secure origin such as `http://<lan-ip>:4820`. Reports the outcome as
|
* on a non-secure origin such as `http://<lan-ip>:4820`. Reports the outcome as
|
||||||
* a toast, so a copy is never silent.
|
* a toast, so a copy is never silent. `writeClipboard(text)` is the same two
|
||||||
|
* paths with no toast, for a copy the user did not click (a terminal
|
||||||
|
* selection); it hands the focus back to wherever it was.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -12,6 +14,8 @@ import i18n from "../i18n";
|
|||||||
import { toast } from "./toast";
|
import { toast } from "./toast";
|
||||||
|
|
||||||
function fallbackCopy(text: string): boolean {
|
function fallbackCopy(text: string): boolean {
|
||||||
|
// Selecting the textarea moves the focus: give it back (a terminal keeps typing).
|
||||||
|
const was = document.activeElement as HTMLElement | null;
|
||||||
const ta = document.createElement("textarea");
|
const ta = document.createElement("textarea");
|
||||||
ta.value = text;
|
ta.value = text;
|
||||||
ta.setAttribute("readonly", "");
|
ta.setAttribute("readonly", "");
|
||||||
@@ -25,25 +29,30 @@ function fallbackCopy(text: string): boolean {
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
ta.remove();
|
ta.remove();
|
||||||
|
was?.focus?.({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Copy `text` silently: the Clipboard API, else the textarea path. */
|
||||||
|
export async function writeClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* fall through to the textarea path */
|
||||||
|
}
|
||||||
|
return fallbackCopy(text);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy text to the clipboard and toast the result.
|
* Copy text to the clipboard and toast the result.
|
||||||
* @param quiet Skip the success toast (the caller shows its own feedback).
|
* @param quiet Skip the success toast (the caller shows its own feedback).
|
||||||
* @returns Whether the copy succeeded.
|
* @returns Whether the copy succeeded.
|
||||||
*/
|
*/
|
||||||
export async function copyText(text: string, quiet = false): Promise<boolean> {
|
export async function copyText(text: string, quiet = false): Promise<boolean> {
|
||||||
let ok = false;
|
const ok = await writeClipboard(text);
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
ok = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall through to the textarea path */
|
|
||||||
}
|
|
||||||
if (!ok) ok = fallbackCopy(text);
|
|
||||||
if (!ok) toast.error(i18n.t("common:copyFailed"));
|
if (!ok) toast.error(i18n.t("common:copyFailed"));
|
||||||
else if (!quiet) toast.success(i18n.t("common:copied"));
|
else if (!quiet) toast.success(i18n.t("common:copied"));
|
||||||
return ok;
|
return ok;
|
||||||
|
|||||||
@@ -35,6 +35,26 @@ export const SETTINGS_INDEX: {
|
|||||||
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
||||||
adminOnly?: boolean;
|
adminOnly?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
|
{
|
||||||
|
section: "account",
|
||||||
|
titleKey: "account.profile",
|
||||||
|
descKey: "account.profileDescription",
|
||||||
|
keywords: ["profile", "email", "display name", "hồ sơ", "tên hiển thị", "tài khoản"],
|
||||||
|
to: "#account-profile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "account",
|
||||||
|
titleKey: "account.password",
|
||||||
|
descKey: "account.passwordDescription",
|
||||||
|
keywords: ["password", "mật khẩu", "đổi mật khẩu"],
|
||||||
|
to: "#account-password",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "account",
|
||||||
|
titleKey: "account.sessionsTitle",
|
||||||
|
keywords: ["sign out", "logout", "devices", "đăng xuất", "thiết bị"],
|
||||||
|
to: "#account-sessions",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
section: "appearance",
|
section: "appearance",
|
||||||
titleKey: "language.title",
|
titleKey: "language.title",
|
||||||
|
|||||||
@@ -2666,6 +2666,8 @@ export interface Lane {
|
|||||||
owner_id?: number | null;
|
owner_id?: number | null;
|
||||||
/** The owner's username (`users.username`); null with no owner. */
|
/** The owner's username (`users.username`); null with no owner. */
|
||||||
owner_name?: string | null;
|
owner_name?: string | null;
|
||||||
|
/** The owner's display name (`users.display_name`); null when none is set. */
|
||||||
|
owner_display_name?: string | null;
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
kind: "managed" | "adopted";
|
kind: "managed" | "adopted";
|
||||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
* their own and every profile they add is personal; an admin chooses when
|
* their own and every profile they add is personal; an admin chooses when
|
||||||
* adding; an analyst only reads.
|
* adding; an analyst only reads.
|
||||||
*
|
*
|
||||||
|
* **Config 9router** (beside Add profile) opens 9router's own UI in a new tab
|
||||||
|
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||||
|
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||||
|
* It is shown only when the server finds a `9router` binary installed.
|
||||||
|
*
|
||||||
* **Test** asks the endpoint itself, once as Claude Code would
|
* **Test** asks the endpoint itself, once as Claude Code would
|
||||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||||
* "Saved" and "works" are different claims, and the gap between them is only
|
* "Saved" and "works" are different claims, and the gap between them is only
|
||||||
@@ -36,7 +41,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
|
import { Plus, Pencil, Trash2, Lock, ExternalLink } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AiProxy,
|
type AiProxy,
|
||||||
@@ -50,6 +55,14 @@ import { copyText } from "../lib/clipboard";
|
|||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import { useAuth } from "../components/AuthGate";
|
import { useAuth } from "../components/AuthGate";
|
||||||
|
|
||||||
|
const NINE_ROUTER_PORT = 20128;
|
||||||
|
|
||||||
|
/** 9router's UI on the machine serving this page (it listens on every interface). */
|
||||||
|
export const nineRouterUrl = (loc: Pick<Location, "hostname"> = window.location): string => {
|
||||||
|
const host = loc.hostname.includes(":") ? `[${loc.hostname}]` : loc.hostname;
|
||||||
|
return `http://${host}:${NINE_ROUTER_PORT}`;
|
||||||
|
};
|
||||||
|
|
||||||
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
const TIERS: (keyof AiProxyModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||||
|
|
||||||
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
|
type FormState = Omit<AiProxyInput, "apiKey" | "models" | "behavesAs" | "scope"> & {
|
||||||
@@ -125,6 +138,14 @@ export function AiProxyConfig() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const [nineRouter, setNineRouter] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
api.aiProxies
|
||||||
|
.nineRouter()
|
||||||
|
.then((r) => setNineRouter(r.installed))
|
||||||
|
.catch(() => setNineRouter(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Any edit to the form invalidates the last verdict — see `testResult`.
|
// Any edit to the form invalidates the last verdict — see `testResult`.
|
||||||
useEffect(() => setTestResult(null), [form]);
|
useEffect(() => setTestResult(null), [form]);
|
||||||
|
|
||||||
@@ -238,14 +259,27 @@ export function AiProxyConfig() {
|
|||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
{/* The page header above already names the section. */}
|
{/* The page header above already names the section. */}
|
||||||
<p className="max-w-2xl text-xs text-fg-muted">{t("aiProxy.description")}</p>
|
<p className="max-w-2xl text-xs text-fg-muted">{t("aiProxy.description")}</p>
|
||||||
<button
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
type="button"
|
{nineRouter && (
|
||||||
onClick={startAdd}
|
<a
|
||||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
href={nineRouterUrl()}
|
||||||
>
|
target="_blank"
|
||||||
<Plus className="h-3.5 w-3.5" />
|
rel="noopener noreferrer"
|
||||||
{t("aiProxy.add")}
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||||
</button>
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
{t("aiProxy.config9router")}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startAdd}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("aiProxy.add")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
* clears), and one page per section. A `#anchor` in the URL scrolls to the
|
* clears), and one page per section. A `#anchor` in the URL scrolls to the
|
||||||
* element with that `id` or `data-settings-anchor` and briefly highlights it.
|
* element with that `id` or `data-settings-anchor` and briefly highlights it.
|
||||||
* Back returns to the page before Settings when there is one, else /workspace;
|
* Back returns to the page before Settings when there is one, else /workspace;
|
||||||
* an unknown section shows a skeleton until the tool list loads, then toasts
|
* In server mode a signed-in account gets **Account** first (its own
|
||||||
|
* information, display name/email, password and sign-ins —
|
||||||
|
* `AccountSettings`); local mode has no account and no such section.
|
||||||
|
* An unknown section shows a skeleton until the tool list loads, then toasts
|
||||||
* and falls back to Appearance (which also holds the language and the pet;
|
* and falls back to Appearance (which also holds the language and the pet;
|
||||||
* `general` and `pet` redirect there). Small pages share one: System is the old
|
* `general` and `pet` redirect there). Small pages share one: System is the old
|
||||||
* About (`about` redirects), Git holds git logins and worktree suggestions
|
* About (`about` redirects), Git holds git logins and worktree suggestions
|
||||||
@@ -48,6 +51,7 @@ import {
|
|||||||
Router,
|
Router,
|
||||||
Search,
|
Search,
|
||||||
Server,
|
Server,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
import { useSettingsHref } from "../lib/settingsLink";
|
import { useSettingsHref } from "../lib/settingsLink";
|
||||||
@@ -57,6 +61,7 @@ import { AiToolConfig } from "./AiToolConfig";
|
|||||||
import { AiProxyConfig } from "./AiProxyConfig";
|
import { AiProxyConfig } from "./AiProxyConfig";
|
||||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||||
|
import { AccountSettings } from "../components/settings/AccountSettings";
|
||||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||||
@@ -73,7 +78,7 @@ import {
|
|||||||
import { Skeleton } from "../components/Skeleton";
|
import { Skeleton } from "../components/Skeleton";
|
||||||
import { AiToolIcon } from "../components/AiToolIcon";
|
import { AiToolIcon } from "../components/AiToolIcon";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { usePermissions } from "../components/AuthGate";
|
import { useAuth, usePermissions } from "../components/AuthGate";
|
||||||
import { PageTabs } from "../components/PageTabs";
|
import { PageTabs } from "../components/PageTabs";
|
||||||
|
|
||||||
const GROUPS = ["general", "agents", "data"] as const;
|
const GROUPS = ["general", "agents", "data"] as const;
|
||||||
@@ -237,12 +242,25 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
|||||||
}, [location.key, location.hash]);
|
}, [location.key, location.hash]);
|
||||||
|
|
||||||
const { isAdmin } = usePermissions();
|
const { isAdmin } = usePermissions();
|
||||||
|
const authState = useAuth().state;
|
||||||
|
const signedIn = authState.mode === "server" && !!authState.user;
|
||||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||||
const s = (key: string) => ({
|
const s = (key: string) => ({
|
||||||
title: t(`settingsNav.section.${key}.title`),
|
title: t(`settingsNav.section.${key}.title`),
|
||||||
description: t(`settingsNav.section.${key}.description`),
|
description: t(`settingsNav.section.${key}.description`),
|
||||||
});
|
});
|
||||||
const allSections: Section[] = [
|
const allSections: Section[] = [
|
||||||
|
...(signedIn
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "account",
|
||||||
|
group: "general" as const,
|
||||||
|
Icon: UserRound,
|
||||||
|
...s("account"),
|
||||||
|
render: () => <AccountSettings />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: "appearance",
|
id: "appearance",
|
||||||
group: "general",
|
group: "general",
|
||||||
|
|||||||
+90
-195
@@ -4,9 +4,12 @@
|
|||||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||||
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
|
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
|
||||||
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
|
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
|
||||||
* an admin — the account changes: add a user, change a role, disable/enable,
|
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||||
* reset a password, give a workspace. Every change asks for the admin's OWN
|
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||||
* password again (the server refuses without it); an analyst only reads.
|
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||||
|
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the
|
||||||
|
* server refuses without it); an analyst only reads. A row shows the display
|
||||||
|
* name and email under the username when set.
|
||||||
* Laid out like the other list pages: icon header, a row of totals
|
* Laid out like the other list pages: icon header, a row of totals
|
||||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -19,20 +22,15 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Eye,
|
Eye,
|
||||||
FolderInput,
|
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
KeyRound,
|
|
||||||
Lock,
|
|
||||||
LockOpen,
|
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
|
Pencil,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Shield,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users as UsersIcon,
|
Users as UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||||
import type { Lane } from "../lib/types";
|
|
||||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { isSubmitEnter } from "../lib/keys";
|
import { isSubmitEnter } from "../lib/keys";
|
||||||
@@ -40,14 +38,13 @@ import { useSpin } from "../hooks/useSpin";
|
|||||||
import { useAuth } from "../components/AuthGate";
|
import { useAuth } from "../components/AuthGate";
|
||||||
import { ConfirmModal } from "../components/ConfirmModal";
|
import { ConfirmModal } from "../components/ConfirmModal";
|
||||||
import { StatCard } from "../components/StatCard";
|
import { StatCard } from "../components/StatCard";
|
||||||
|
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||||
|
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||||
import { TableRowSkeleton } from "../components/Skeleton";
|
import { TableRowSkeleton } from "../components/Skeleton";
|
||||||
|
|
||||||
type Role = AuthUser["role"];
|
type Role = AuthUser["role"];
|
||||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||||
|
|
||||||
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
|
|
||||||
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
|
|
||||||
|
|
||||||
export function Users() {
|
export function Users() {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
@@ -57,7 +54,8 @@ export function Users() {
|
|||||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -114,11 +112,7 @@ export function Users() {
|
|||||||
{t("common:refresh")}
|
{t("common:refresh")}
|
||||||
</button>
|
</button>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button type="button" onClick={() => setAdding(true)} className="btn-primary">
|
||||||
type="button"
|
|
||||||
onClick={() => setDialog({ kind: "add" })}
|
|
||||||
className="btn-primary"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-4 h-4" /> {t("add")}
|
<UserPlus className="w-4 h-4" /> {t("add")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -209,11 +203,19 @@ export function Users() {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
|
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
|
||||||
>
|
>
|
||||||
{u.username.slice(0, 2)}
|
{(u.displayName || u.username).slice(0, 2)}
|
||||||
</span>
|
|
||||||
<span className="truncate text-sm font-medium text-fg-primary">
|
|
||||||
{u.username}
|
|
||||||
</span>
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-fg-primary">
|
||||||
|
{u.displayName || u.username}
|
||||||
|
{u.displayName && (
|
||||||
|
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{u.email && (
|
||||||
|
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{self && (
|
{self && (
|
||||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||||
{t("you")}
|
{t("you")}
|
||||||
@@ -244,37 +246,8 @@ export function Users() {
|
|||||||
<UsageCell usage={u.last30d} />
|
<UsageCell usage={u.last30d} />
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||||
<RowAction
|
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
|
||||||
label={t("changeRole")}
|
<Pencil className="h-4 w-4" />
|
||||||
disabled={self}
|
|
||||||
onClick={() => setDialog({ kind: "role", user: u })}
|
|
||||||
>
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
</RowAction>
|
|
||||||
<RowAction
|
|
||||||
label={t("resetPassword")}
|
|
||||||
onClick={() => setDialog({ kind: "password", user: u })}
|
|
||||||
>
|
|
||||||
<KeyRound className="h-4 w-4" />
|
|
||||||
</RowAction>
|
|
||||||
<RowAction
|
|
||||||
label={t("assignWorkspace")}
|
|
||||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
|
||||||
>
|
|
||||||
<FolderInput className="h-4 w-4" />
|
|
||||||
</RowAction>
|
|
||||||
<RowAction
|
|
||||||
label={u.disabled ? t("enable") : t("disable")}
|
|
||||||
disabled={self}
|
|
||||||
onClick={() =>
|
|
||||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{u.disabled ? (
|
|
||||||
<LockOpen className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Lock className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</RowAction>
|
</RowAction>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
@@ -285,6 +258,8 @@ export function Users() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccountPoolCard isAdmin={isAdmin} />
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
<ScrollText className="w-4 h-4 text-fg-muted" />
|
<ScrollText className="w-4 h-4 text-fg-muted" />
|
||||||
@@ -310,9 +285,11 @@ export function Users() {
|
|||||||
{formatDateTime(a.at)}
|
{formatDateTime(a.at)}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
|
<span className="w-44 flex-shrink-0 truncate text-fg-secondary">
|
||||||
<span className="font-medium text-fg-primary">{a.reader}</span>
|
<span className="font-medium text-fg-primary">
|
||||||
|
{a.reader_display || a.reader}
|
||||||
|
</span>
|
||||||
<span className="mx-1.5 text-fg-muted">→</span>
|
<span className="mx-1.5 text-fg-muted">→</span>
|
||||||
{a.owner}
|
{a.owner_display || a.owner}
|
||||||
</span>
|
</span>
|
||||||
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
|
<span className="badge py-0 px-1.5 text-[10px] font-mono bg-surface-4 text-fg-muted border-border">
|
||||||
{a.method}
|
{a.method}
|
||||||
@@ -326,14 +303,25 @@ export function Users() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{dialog && (
|
{adding && (
|
||||||
<AccountDialog
|
<AddUserDialog
|
||||||
dialog={dialog}
|
onClose={() => setAdding(false)}
|
||||||
users={users ?? []}
|
|
||||||
onClose={() => setDialog(null)}
|
|
||||||
onDone={(message) => {
|
onDone={(message) => {
|
||||||
toast.success(message);
|
toast.success(message);
|
||||||
setDialog(null);
|
setAdding(false);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<UserEditDialog
|
||||||
|
user={editing}
|
||||||
|
self={editing.id === me.id}
|
||||||
|
users={users ?? []}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onDone={(message) => {
|
||||||
|
toast.success(message);
|
||||||
|
setEditing(null);
|
||||||
void load();
|
void load();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -342,12 +330,6 @@ export function Users() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 period's cost, with its tokens underneath. */
|
/** A period's cost, with its tokens underneath. */
|
||||||
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
@@ -384,71 +366,31 @@ function RowAction({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One account change: its own fields, then the admin's password to confirm it. */
|
/** A new account: username, password, role, then the admin's own password. */
|
||||||
function AccountDialog({
|
function AddUserDialog({
|
||||||
dialog,
|
|
||||||
users,
|
|
||||||
onClose,
|
onClose,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
dialog: Dialog;
|
|
||||||
users: ServerUserUsage[];
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: (message: string) => void;
|
onDone: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("users");
|
const { t } = useTranslation("users");
|
||||||
const { kind, user } = dialog;
|
|
||||||
const name = user?.username ?? "";
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [role, setRole] = useState<Role>(user?.role ?? "developer");
|
const [role, setRole] = useState<Role>("developer");
|
||||||
const [laneId, setLaneId] = useState<number | null>(null);
|
|
||||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
|
||||||
const [current, setCurrent] = useState("");
|
const [current, setCurrent] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const ready = current.length > 0 && username.trim().length > 0 && password.length > 0;
|
||||||
if (kind !== "workspace") return;
|
|
||||||
api.lanes
|
|
||||||
.list()
|
|
||||||
.then((r) => setLanes(r.lanes))
|
|
||||||
.catch(() => setLanes([]));
|
|
||||||
}, [kind]);
|
|
||||||
|
|
||||||
const ready =
|
|
||||||
current.length > 0 &&
|
|
||||||
(kind === "add"
|
|
||||||
? username.trim().length > 0 && password.length > 0
|
|
||||||
: kind === "password"
|
|
||||||
? password.length > 0
|
|
||||||
: kind === "workspace"
|
|
||||||
? laneId !== null
|
|
||||||
: kind === "role"
|
|
||||||
? role !== user?.role
|
|
||||||
: true);
|
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!ready || busy) return;
|
if (!ready || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
if (kind === "add") {
|
const r = await api.users.create({ username: username.trim(), password, role }, current);
|
||||||
const r = await api.users.create({ username: username.trim(), password, role }, current);
|
onDone(t("done.created", { name: r.user.displayName || r.user.username }));
|
||||||
onDone(t("done.created", { name: r.user.username }));
|
|
||||||
} else if (kind === "role" && user) {
|
|
||||||
await api.users.update(user.id, { role }, current);
|
|
||||||
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
|
|
||||||
} else if ((kind === "disable" || kind === "enable") && user) {
|
|
||||||
await api.users.update(user.id, { disabled: kind === "disable" }, current);
|
|
||||||
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
|
|
||||||
} else if (kind === "password" && user) {
|
|
||||||
await api.users.setPassword(user.id, password, current);
|
|
||||||
onDone(t("done.password", { name }));
|
|
||||||
} else if (kind === "workspace" && user && laneId !== null) {
|
|
||||||
await api.users.assignWorkspace(user.id, laneId, current);
|
|
||||||
onDone(t("done.workspace", { name }));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error & { code?: string };
|
const err = e as Error & { code?: string };
|
||||||
setError(
|
setError(
|
||||||
@@ -461,20 +403,6 @@ function AccountDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const titles: Record<DialogKind, string> = {
|
|
||||||
add: t("dialog.add"),
|
|
||||||
role: t("dialog.role", { name }),
|
|
||||||
disable: t("dialog.disable", { name }),
|
|
||||||
enable: t("dialog.enable", { name }),
|
|
||||||
password: t("dialog.password", { name }),
|
|
||||||
workspace: t("dialog.workspace", { name }),
|
|
||||||
};
|
|
||||||
const messages: Partial<Record<DialogKind, string>> = {
|
|
||||||
disable: t("dialog.disableBody", { name }),
|
|
||||||
password: t("dialog.passwordBody", { name }),
|
|
||||||
workspace: t("dialog.workspaceBody", { name }),
|
|
||||||
};
|
|
||||||
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
|
|
||||||
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
|
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (isSubmitEnter(e)) {
|
if (isSubmitEnter(e)) {
|
||||||
@@ -486,81 +414,48 @@ function AccountDialog({
|
|||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open
|
open
|
||||||
title={titles[kind]}
|
title={t("dialog.add")}
|
||||||
message={messages[kind]}
|
confirmLabel={t("add")}
|
||||||
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
|
|
||||||
cancelLabel={t("common:cancel")}
|
cancelLabel={t("common:cancel")}
|
||||||
destructive={kind === "disable"}
|
|
||||||
busy={busy}
|
busy={busy}
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
onConfirm={() => void submit()}
|
onConfirm={() => void submit()}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
>
|
>
|
||||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||||
{kind === "add" && (
|
<Field label={t("username")} hint={t("usernameHint")}>
|
||||||
<Field label={t("username")} hint={t("usernameHint")}>
|
<input
|
||||||
<input
|
className="input w-full text-sm"
|
||||||
className="input w-full text-sm"
|
value={username}
|
||||||
value={username}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
autoComplete="off"
|
||||||
autoComplete="off"
|
aria-label={t("username")}
|
||||||
aria-label={t("username")}
|
/>
|
||||||
/>
|
</Field>
|
||||||
</Field>
|
<Field label={t("password")} hint={t("passwordHint")}>
|
||||||
)}
|
<input
|
||||||
{(kind === "add" || kind === "password") && (
|
type="password"
|
||||||
<Field label={kind === "add" ? t("password") : t("newPassword")} hint={t("passwordHint")}>
|
className="input w-full text-sm"
|
||||||
<input
|
value={password}
|
||||||
type="password"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="input w-full text-sm"
|
autoComplete="new-password"
|
||||||
value={password}
|
aria-label={t("password")}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
/>
|
||||||
autoComplete="new-password"
|
</Field>
|
||||||
aria-label={kind === "add" ? t("password") : t("newPassword")}
|
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
|
||||||
/>
|
<select
|
||||||
</Field>
|
className="input w-full text-sm"
|
||||||
)}
|
value={role}
|
||||||
{(kind === "add" || kind === "role") && (
|
onChange={(e) => setRole(e.target.value as Role)}
|
||||||
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
|
aria-label={t("role")}
|
||||||
<select
|
>
|
||||||
className="input w-full text-sm"
|
{ROLES.map((r) => (
|
||||||
value={role}
|
<option key={r} value={r}>
|
||||||
onChange={(e) => setRole(e.target.value as Role)}
|
{t(`roles.${r}`)}
|
||||||
aria-label={t("role")}
|
</option>
|
||||||
>
|
))}
|
||||||
{ROLES.map((r) => (
|
</select>
|
||||||
<option key={r} value={r}>
|
</Field>
|
||||||
{t(`roles.${r}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
{kind === "workspace" && (
|
|
||||||
<Field label={t("workspace")}>
|
|
||||||
{lanes && lanes.length === 0 ? (
|
|
||||||
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
className="input w-full text-sm"
|
|
||||||
value={laneId ?? ""}
|
|
||||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
|
||||||
aria-label={t("workspace")}
|
|
||||||
>
|
|
||||||
<option value="" />
|
|
||||||
{(lanes ?? []).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>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { AiProxyConfig } from "../AiProxyConfig";
|
import { AiProxyConfig, nineRouterUrl } from "../AiProxyConfig";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../lib/confirm";
|
||||||
import type { AuthState } from "../../lib/api";
|
import type { AuthState } from "../../lib/api";
|
||||||
@@ -28,6 +28,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
test: vi.fn(),
|
test: vi.fn(),
|
||||||
|
nineRouter: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
|||||||
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
|
vi.mocked(api.aiProxies.create).mockReset().mockResolvedValue(nineRouter);
|
||||||
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
|
vi.mocked(api.aiProxies.update).mockReset().mockResolvedValue(nineRouter);
|
||||||
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
|
vi.mocked(api.aiProxies.remove).mockReset().mockResolvedValue({ removed: "9router" });
|
||||||
|
vi.mocked(api.aiProxies.nineRouter).mockReset().mockResolvedValue({ installed: false });
|
||||||
vi.mocked(api.aiProxies.test)
|
vi.mocked(api.aiProxies.test)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
@@ -83,6 +85,27 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AiProxyConfig", () => {
|
describe("AiProxyConfig", () => {
|
||||||
|
it("shows Config 9router beside Add profile only when 9router is installed", async () => {
|
||||||
|
vi.mocked(api.aiProxies.nineRouter).mockResolvedValue({ installed: true });
|
||||||
|
render(<AiProxyConfig />);
|
||||||
|
const link = await screen.findByRole("link", { name: /aiProxy\.config9router/ });
|
||||||
|
// The dashboard's own host (jsdom's is localhost), not a fixed localhost.
|
||||||
|
expect(link).toHaveAttribute("href", `http://${window.location.hostname}:20128`);
|
||||||
|
expect(link).toHaveAttribute("target", "_blank");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points Config 9router at the host the dashboard was opened on", () => {
|
||||||
|
expect(nineRouterUrl({ hostname: "10.1.13.7" })).toBe("http://10.1.13.7:20128");
|
||||||
|
expect(nineRouterUrl({ hostname: "::1" })).toBe("http://[::1]:20128");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Config 9router when 9router is not installed", async () => {
|
||||||
|
render(<AiProxyConfig />);
|
||||||
|
await screen.findByText("Native (Claude / Codex)");
|
||||||
|
await waitFor(() => expect(api.aiProxies.nineRouter).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByRole("link", { name: /aiProxy\.config9router/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("lists the built-in native profile with no edit or delete control", async () => {
|
it("lists the built-in native profile with no edit or delete control", async () => {
|
||||||
render(<AiProxyConfig />);
|
render(<AiProxyConfig />);
|
||||||
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
expect(await screen.findByText("Native (Claude / Codex)")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
* @description The Users page: local mode and a member are sent home; an
|
* @description The Users page: local mode and a member are sent home; an
|
||||||
* analyst reads the table with no account controls; an admin adds a user only
|
* analyst reads the table with no account controls; an admin adds a user only
|
||||||
* with their own password typed (the confirm stays disabled until then), sees
|
* with their own password typed (the confirm stays disabled until then), sees
|
||||||
* a wrong password reported in the dialog, and cannot change their own role or
|
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
|
||||||
* disable themselves.
|
* separate role/password/workspace/disable buttons any more), and sees the
|
||||||
|
* display name and email under a username.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ const users = vi.hoisted(() => ({
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
setPassword: vi.fn(),
|
setPassword: vi.fn(),
|
||||||
assignWorkspace: vi.fn(),
|
assignWorkspace: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
@@ -46,6 +48,9 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
users,
|
users,
|
||||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||||
|
accountPool: {
|
||||||
|
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -69,6 +74,10 @@ beforeEach(() => {
|
|||||||
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
|
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
|
||||||
});
|
});
|
||||||
users.audit.mockResolvedValue({ entries: [] });
|
users.audit.mockResolvedValue({ entries: [] });
|
||||||
|
users.get.mockImplementation(async (id: number) => ({
|
||||||
|
user: { id, username: "x", role: "developer", disabled: false },
|
||||||
|
workspaces: [],
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Users page", () => {
|
describe("Users page", () => {
|
||||||
@@ -119,15 +128,15 @@ describe("Users page", () => {
|
|||||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a wrong password in the dialog and keeps it open", async () => {
|
it("reports a wrong password in the edit dialog and keeps it open", async () => {
|
||||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||||
users.setPassword.mockRejectedValue(
|
users.update.mockRejectedValue(
|
||||||
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
|
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
|
||||||
);
|
);
|
||||||
const ue = userEvent.setup();
|
const ue = userEvent.setup();
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("mia");
|
await screen.findByText("mia");
|
||||||
await ue.click(screen.getAllByRole("button", { name: "Reset password" })[1]!);
|
await ue.click(screen.getAllByRole("button", { name: "Edit user" })[1]!);
|
||||||
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
|
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
|
||||||
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
|
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
|
||||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||||
@@ -135,18 +144,26 @@ describe("Users page", () => {
|
|||||||
"Your password is wrong."
|
"Your password is wrong."
|
||||||
);
|
);
|
||||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||||
expect(users.setPassword).toHaveBeenCalledWith(2, "mia-new-pass-1", "wrong");
|
expect(users.update).toHaveBeenCalledWith(2, { password: "mia-new-pass-1" }, "wrong");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never offers an admin a role change or disable on their own row", async () => {
|
it("offers one Edit per row and no separate account buttons", async () => {
|
||||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("mia");
|
await screen.findByText("mia");
|
||||||
const [ownRole, miaRole] = screen.getAllByRole("button", { name: "Change role" });
|
expect(screen.getAllByRole("button", { name: "Edit user" })).toHaveLength(2);
|
||||||
const [ownLock, miaLock] = screen.getAllByRole("button", { name: "Disable" });
|
for (const name of ["Change role", "Reset password", "Give a workspace", "Disable"])
|
||||||
expect(ownRole!.hasAttribute("disabled")).toBe(true);
|
expect(screen.queryByRole("button", { name })).toBeNull();
|
||||||
expect(ownLock!.hasAttribute("disabled")).toBe(true);
|
});
|
||||||
expect(miaRole!.hasAttribute("disabled")).toBe(false);
|
|
||||||
expect(miaLock!.hasAttribute("disabled")).toBe(false);
|
it("shows the display name first, with @username beside it, and the email", async () => {
|
||||||
|
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||||
|
users.list.mockResolvedValue({
|
||||||
|
users: [{ ...row(2, "mia", "developer"), displayName: "Mia Tran", email: "mia@x.vn" }],
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
expect(await screen.findByText("Mia Tran")).toBeTruthy();
|
||||||
|
expect(screen.getByText("@mia")).toBeTruthy();
|
||||||
|
expect(screen.getByText("mia@x.vn")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.7.5 (rebuild client to match)
|
UI build v2.7.6 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+31
-3
@@ -85,12 +85,14 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
|||||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||||
|
|
||||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (both `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (both `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||||
|
|
||||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `PATCH /api/users/:id` (`{role?, disabled?}`), `POST /api/users/:id/password` (`{password}`; that user's logins end) and `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) and `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||||
|
|
||||||
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
|
||||||
|
|
||||||
|
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name.
|
||||||
|
|
||||||
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
|
||||||
|
|
||||||
| Route | Answer |
|
| Route | Answer |
|
||||||
@@ -100,6 +102,10 @@ An admin changes accounts with `POST /api/users` (`{username, password, role}`
|
|||||||
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
|
| `GET /api/me/instructions` | `{instructions, max, tools}` — `tools` lists the AI tool ids whose new panes receive them (`["claude","codex"]`; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, `""` when none); `401 EAUTH` with no login |
|
||||||
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
|
| `PUT /api/me/instructions` `{instructions: "<markdown>" \| null}` | replaced, the same shape back; blank or `null` clears; `400 EBADFIELD` not a string, `413 ETOOBIG` over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below |
|
||||||
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
| `POST /api/me/onboarded` | the first-run setup is done or skipped (kept from the first call) |
|
||||||
|
| `GET /api/me/account` | Settings → Account. Server mode: `{mode: "server", user: {id, username, role, disabled, displayName, email, createdAt}, workspaces, sessions, logins, lastLoginAt}`; local mode `{mode: "local"}` |
|
||||||
|
| `PATCH /api/me/account` `{displayName?, email?}` | the account's own profile (`""`/`null` clears), the same shape back; `400 EBADFIELD` (display name over 100 characters or with control characters) / `EBADEMAIL` |
|
||||||
|
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays. `403 EREAUTH` wrong current password (`429 ETOOMANY` after 10), `400 EBADPASSWORD` |
|
||||||
|
| `POST /api/me/sign-out-others` | `{ended}` — every login of the account but this one ends |
|
||||||
|
|
||||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.pet`, `agenthub.pet.size`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||||
|
|
||||||
@@ -240,7 +246,12 @@ those at the top of a non-admin's own folder), every entry carries its `mtime`
|
|||||||
(ms) and the answer its `home` (the browser's Home place). In server mode with
|
(ms) and the answer its `home` (the browser's Home place). In server mode with
|
||||||
`AGENTHUB_USERS_ROOT` set (under S3, else server.conf's `USERS_ROOT`), a
|
`AGENTHUB_USERS_ROOT` set (under S3, else server.conf's `USERS_ROOT`), a
|
||||||
developer's browser opens on their own folder (`parent: null` there) and any
|
developer's browser opens on their own folder (`parent: null` there) and any
|
||||||
path whose real path leaves it is `403 EFOLDER`. The browser's **New folder**
|
path whose real path leaves it is `403 EFOLDER`. An admin is held to no
|
||||||
|
folder, but one with a Linux account of their own (S3) opens on THEIR home
|
||||||
|
(`/srv/agenthub/users/<name>`) and the answer carries `hostHome` (the host
|
||||||
|
account's home); `host=1` opens that instead — the browser's **Server folder**
|
||||||
|
tick, unticked at every open, because a workspace there runs as the host
|
||||||
|
account every admin shares. The browser's **New folder**
|
||||||
is `POST /api/lanes/browse/mkdir` (`{parent, name}` → `201 {path}`): one plain
|
is `POST /api/lanes/browse/mkdir` (`{parent, name}` → `201 {path}`): one plain
|
||||||
folder name inside an existing folder, `400 EBADNAME`/`ENOTFOUND`,
|
folder name inside an existing folder, `400 EBADNAME`/`ENOTFOUND`,
|
||||||
`409 EEXISTS`; a non-admin only inside their own folder, and in server mode
|
`409 EEXISTS`; a non-admin only inside their own folder, and in server mode
|
||||||
@@ -1619,6 +1630,20 @@ curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/hom
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### Get a Text File the Session Read or Wrote
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sessions/:id/file?path=<absolute path>
|
||||||
|
```
|
||||||
|
|
||||||
|
`{ "text": "...", "truncated": false }` for a text file the session's own `Read`, `Write`, `Edit`, `MultiEdit` or `NotebookEdit` named (a `PostToolUse` event of this session whose `tool_input.file_path` or `notebook_path` equals `path` exactly) — how a Chat pane's Outputs/Files preview shows a file outside the workspace folder (a report written to `~/Documents`). At most the first 512 KB (`truncated: true` beyond). 400 `EBADPATH` for a relative path; 403 `ESECRET` for a credential by name (`isSecretConfigPath` — `.credentials.json`, `auth.json`, AgentHub's own stores and data dir — or a `settings.local.json`), judged on the path and on what it resolves to; 415 `EBINARY` for a file with a NUL byte in its first 8 KB; 404 for an unknown session, a path the session never touched, or a non-regular or missing file. 403 `EUNSUPPORTED` and the S3 member rules are the same as for `/image` above.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:4820/api/sessions/sess_abc123/file?path=/home/me/Documents/REPORT.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### Get Session Stats
|
#### Get Session Stats
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -2665,6 +2690,7 @@ Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source:
|
|||||||
|
|
||||||
```
|
```
|
||||||
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
||||||
|
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
||||||
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
||||||
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||||
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
||||||
@@ -2685,6 +2711,8 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
|
|||||||
|
|
||||||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-proxies/` (dir `0700`, files `0600`); the first read after the rename moves an existing `agenthub-routers/` there and leaves a symlink at the old name, so an older build on the same CLAUDE_HOME still finds them. An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-proxies/` (dir `0700`, files `0600`); the first read after the rename moves an existing `agenthub-routers/` there and leaves a symlink at the old name, so an older build on the same CLAUDE_HOME still finds them. An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||||||
|
|
||||||
|
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
||||||
|
|
||||||
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
||||||
|
|
||||||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||||||
|
|||||||
+1
-1
@@ -400,7 +400,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **Resume interrupted sessions** — when a session's pane dies under it (the machine shut down, tmux was killed, the CLI crashed), its run is recorded `interrupted` instead of `abandoned`, and every page of the dashboard opens the **Resume interrupted sessions?** dialog (`client/src/components/lanes/InterruptedSessionsPrompt.tsx`, from `GET /api/run/interrupted`). Each session is listed with its workspace, tool, name or first prompt and when it stopped, all ticked: **Resume** starts each ticked one in its workspace's first free tab (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, with the run's model, effort and permission mode — never `bypassPermissions`) and opens the first; **Don't resume** (also ×, Escape, the backdrop) resumes none. Either answer dismisses the whole list (`POST /api/run/interrupted/dismiss`), so it is asked once; a resume that failed stays listed. What counts as interrupted: a pane the dashboard did not close (Stop, ×, Quit are `killed`), not a Terminal tab, with a known session — and for Claude, not one the user ended themselves (`SessionEnd` reason `prompt_input_exit`/`logout`). Codex and other tools report no such reason, so their every vanished pane counts; a Codex pane without its opt-in hooks has no known session and is never listed. A session resumed by any other path drops off the list. A run younger than 20 s is never judged dead (a tmux list is a cached answer, and another dashboard on the same database may not have listed a brand-new pane yet). In server mode each user is offered only their own workspaces' sessions.
|
- **Resume interrupted sessions** — when a session's pane dies under it (the machine shut down, tmux was killed, the CLI crashed), its run is recorded `interrupted` instead of `abandoned`, and every page of the dashboard opens the **Resume interrupted sessions?** dialog (`client/src/components/lanes/InterruptedSessionsPrompt.tsx`, from `GET /api/run/interrupted`). Each session is listed with its workspace, tool, name or first prompt and when it stopped, all ticked: **Resume** starts each ticked one in its workspace's first free tab (`POST /api/lanes/:id/start {slot: "auto", resumeSessionId}`, with the run's model, effort and permission mode — never `bypassPermissions`) and opens the first; **Don't resume** (also ×, Escape, the backdrop) resumes none. Either answer dismisses the whole list (`POST /api/run/interrupted/dismiss`), so it is asked once; a resume that failed stays listed. What counts as interrupted: a pane the dashboard did not close (Stop, ×, Quit are `killed`), not a Terminal tab, with a known session — and for Claude, not one the user ended themselves (`SessionEnd` reason `prompt_input_exit`/`logout`). Codex and other tools report no such reason, so their every vanished pane counts; a Codex pane without its opt-in hooks has no known session and is never listed. A session resumed by any other path drops off the list. A run younger than 20 s is never judged dead (a tmux list is a cached answer, and another dashboard on the same database may not have listed a brand-new pane yet). In server mode each user is offered only their own workspaces' sessions.
|
||||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Changes panel, with that run's tab active. The sidebar's workspace row always opens the workspace's home, live tabs or not (a live tab opens from its session row); the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
||||||
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
- **Untracked worktrees** are no longer listed in the sidebar; Settings → Git → **Worktrees** (`showUntracked`, hidden sources `claude` = `<repo>/.claude/worktrees/*` and `agenthub` = `LANES_ROOT`) now decides which of them the home's Add folder suggests.
|
||||||
- **No workspaces yet** — the page shows no banner: a workspace is added with the **+** beside Workspaces in the sidebar (`workspace-tree-add`; folded to a rail, the tree icon unfolds it first) or from a terminal with `agenthub workspace add --cwd $(pwd)`. In the Add-workspace modal, a click into the folder field opens the same picker as **Browse**: the OS's own dialog for a browser on the dashboard's machine (`POST /api/lanes/pick-path`), the in-app folder browser for any other browser — one behind a proxy included (`EREMOTE`); typing a path still works from the keyboard.
|
- **No workspaces yet** — the page shows no banner: a workspace is added with the **+** beside Workspaces in the sidebar (`workspace-tree-add`; folded to a rail, the tree icon unfolds it first) or from a terminal with `agenthub workspace add --cwd $(pwd)`. In the Add-workspace modal, the folder field is typed into (with suggestions); only **Browse** opens a picker — the OS's own dialog for a browser on the dashboard's machine (`POST /api/lanes/pick-path`), the in-app folder browser for any other browser, one behind a proxy included (`EREMOTE`). A click into the field only focuses it.
|
||||||
- **Reordering** — drag a row and drop it anywhere in the list (a line shows where it lands, by the pointer's position — on another row, on a session list, above the first or below the last), or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub workspaces` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
- **Reordering** — drag a row and drop it anywhere in the list (a line shows where it lands, by the pointer's position — on another row, on a session list, above the first or below the last), or press **Alt+↑/↓** with a row focused. The order is a **per-viewer preference**, not a workspace fact: it persists to `localStorage` (`agenthub.workspace.laneOrder`) and writes nothing server-side — `GET /api/lanes` and `agenthub workspaces` keep answering in id order, and the order does not follow you to another browser. A workspace the stored order has never seen lands at the END; a stored id whose workspace is gone is skipped.
|
||||||
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
- **Renaming a workspace** — double-click its sidebar row (or Rename in the row menu) to edit in place. **Enter** saves via `PATCH /api/lanes/:id` with `{title}`, **Escape** and clicking away both cancel, and saving an empty name clears the title, which puts the folder name back in its place. Renaming is display-only — a managed workspace's branch name was slugified from its title once, at creation.
|
||||||
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub workspace mcp sync` and `agenthub workspace sync-base`, and a readme refresh is a skill typed into a session.
|
- **No header row** — the session view has no header of its own: the console's tab bar is it (status, the workspace's name and the back arrow are gone — the name is on the sidebar row and the home). Only a workspace with an installed pipeline template shows its time-on-stage, pipeline picker and feature picker above the console; there are no stage chips — the declared stage and the `auto: <stage>` detection both render in the PipelineMap. While one pane is shown, its split right/down menus sit at the right end of the tab bar; each offers **New terminal** and the same AI tools as the `+` menu. The detail block (the workspace card, the PipelineMap, the proof gallery) is always shown, and Delete lives in the sidebar row menu. There is no Tools menu any more: MCP sync and the sync-base check are the CLI's `agenthub workspace mcp sync` and `agenthub workspace sync-base`, and a readme refresh is a skill typed into a session.
|
||||||
|
|||||||
+71
-3
@@ -115,7 +115,8 @@ their own workspaces (`useWorkLanes`); other users' work is read on Sessions,
|
|||||||
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
|
Analytics and Users, where Resume, Fork, Snapshot and "Open in workspace" are
|
||||||
hidden for a session that is not theirs. The sidebar's workspace list shows an
|
hidden for a session that is not theirs. The sidebar's workspace list shows an
|
||||||
admin or analyst EVERY workspace, grouped under its owner (each lane carries
|
admin or analyst EVERY workspace, grouped under its owner (each lane carries
|
||||||
`owner_name`; yours first, no owner last, each group folds): for an analyst
|
`owner_name` and `owner_display_name`; the group shows the display name when
|
||||||
|
the owner set one, else the username; yours first, no owner last, each group folds): for an analyst
|
||||||
another user's workspace there is read only — no menu, rename or new session,
|
another user's workspace there is read only — no menu, rename or new session,
|
||||||
no live tabs, and a session opens its analysis page (`/sessions/<id>`). An analyst's alert feed
|
no live tabs, and a session opens its analysis page (`/sessions/<id>`). An analyst's alert feed
|
||||||
(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
|
(`GET /api/alerts`) carries `can_ack` on each alert — true only for their own
|
||||||
@@ -259,13 +260,36 @@ settings file that lacks the variable falls back to `USERS_ROOT` in
|
|||||||
## Accounts
|
## Accounts
|
||||||
|
|
||||||
An admin manages accounts on the **Users** page (sidebar; admins and analysts
|
An admin manages accounts on the **Users** page (sidebar; admins and analysts
|
||||||
see it, an analyst only reads): add a user, change a role, disable or enable,
|
see it, an analyst only reads): **Add user**, and one **Edit** form per account
|
||||||
reset a password, give a workspace. Every change asks for the admin's OWN
|
that changes, in a single save, its display name and email, role,
|
||||||
|
active/disabled, password (typed or generated), the workspaces it owns (give
|
||||||
|
one, or take one back — it is then left with no owner) and "sign out
|
||||||
|
everywhere". Every field is checked before any is written, so a bad email or
|
||||||
|
a workspace the user does not own changes nothing. Every change asks for the admin's OWN
|
||||||
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
|
password again — the server refuses without it (`EREAUTH`, and `ETOOMANY`
|
||||||
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
|
after 10 wrong tries in 15 minutes) — an admin can never demote or disable
|
||||||
themselves (`ESELF`), and each change is written to the audit log. In local
|
themselves (`ESELF`), and each change is written to the audit log. In local
|
||||||
mode these routes refuse (`ELOCALMODE`): there is no login to check.
|
mode these routes refuse (`ELOCALMODE`): there is no login to check.
|
||||||
|
|
||||||
|
Wherever the dashboard names a person — the sidebar's sign-out tooltip, the
|
||||||
|
workspace list's owner groups, the Users page and its audit log, the Edit user
|
||||||
|
form — it shows their display name when one is set, else the username (the
|
||||||
|
Users page keeps `@username` beside a display name). Logging in, the CLI and
|
||||||
|
the Linux account always use the username.
|
||||||
|
|
||||||
|
Under S3 an admin's folder browser opens on their own home too; ticking
|
||||||
|
**Server folder** opens the host account's home (`/home/<host>`), where a
|
||||||
|
workspace runs as the host account that every admin shares. The tick is never
|
||||||
|
remembered, so the default is always the admin's own account.
|
||||||
|
|
||||||
|
Every signed-in user has **Settings → Account**: their username, role, member
|
||||||
|
since, workspace and session counts and active logins; their own display name
|
||||||
|
and email; **Change password** (the current password is required, the login
|
||||||
|
it is changed from stays and every other one ends); and **Sign out other
|
||||||
|
devices**. These are taken from the login cookie only — an agent in a pane,
|
||||||
|
which acts as its workspace owner through its pane token, cannot change its
|
||||||
|
owner's password or logins.
|
||||||
|
|
||||||
The same from the host's shell:
|
The same from the host's shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -296,6 +320,43 @@ agenthub accounts list # live panes, $ in the last 5 h / 7 d, quota, stat
|
|||||||
agenthub accounts remove team-1
|
agenthub accounts remove team-1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In server mode an admin can do the same on the **Users** page, in the
|
||||||
|
**Account pool** card: **Add account** and the bin icon to remove one. A Claude
|
||||||
|
account comes from one of three sources:
|
||||||
|
|
||||||
|
- **A saved login**: a profile from Settings → Claude config → Switch account
|
||||||
|
(log in with `claude auth login`, then save it). It is MOVED into the pool, so
|
||||||
|
the profile leaves the switcher. The account this server's own Claude Code is
|
||||||
|
logged into can be moved in too. It moves with the login the CLI holds right
|
||||||
|
now, keeps working on the server, and the server's own `.credentials.json`
|
||||||
|
becomes one more home the pool refreshes and writes into, keeping its other
|
||||||
|
keys. Only a login kept in the macOS Keychain is refused (`EACTIVE`). Removing
|
||||||
|
that account later leaves the server logged in.
|
||||||
|
- **A pasted `.credentials.json`**: from `~/.claude/.credentials.json`, or on a
|
||||||
|
Mac `security find-generic-password -s "Claude Code-credentials" -w`. Stop
|
||||||
|
using that login where you copied it from.
|
||||||
|
- **A `claude setup-token` token** (`sk-ant-…`), as with `agenthub accounts add`.
|
||||||
|
|
||||||
|
A Codex account is an OpenAI API key, or the whole `~/.codex/auth.json` after
|
||||||
|
`codex login`.
|
||||||
|
|
||||||
|
A **login** (the first two sources) works like a Codex ChatGPT login. Claude
|
||||||
|
Code reads it from no env var, so before a member's pane starts, AgentHub writes
|
||||||
|
it as that member's `~/.claude/.credentials.json`, as the member (0600). It is
|
||||||
|
never written into a host pane's home, never over a login the member made
|
||||||
|
themselves, and never a second account into a home where a pane of theirs runs
|
||||||
|
on one. Host panes therefore get only setup-token accounts. The refresh token of
|
||||||
|
a login is single use, so the server is the one that refreshes it, before it
|
||||||
|
expires (`claude-pool.refreshLogins`, checked every 60 s; `DASHBOARD_POOL_REFRESH_MS=0` turns it off). It then writes the new pair into every
|
||||||
|
home it placed the login in, and Claude Code picks up tokens another process
|
||||||
|
refreshed. A pair a member's Claude refreshed first is copied back only after
|
||||||
|
Anthropic confirms it is the same account. Removing the account deletes it from
|
||||||
|
the homes that still hold AgentHub's copy. Each
|
||||||
|
change asks for the admin's own password again and is written to the audit log
|
||||||
|
by account name. The token only goes in; the page, like `agenthub accounts list`,
|
||||||
|
never shows it again. An analyst sees the card read-only; a developer does not
|
||||||
|
see it. Every user shares the pool: a user created later needs no setup of their own.
|
||||||
|
|
||||||
How a new session picks its account (`server/lib/claude-pool.js`):
|
How a new session picks its account (`server/lib/claude-pool.js`):
|
||||||
|
|
||||||
1. An account whose last quota reading is at or above 90% in the 5-hour or
|
1. An account whose last quota reading is at or above 90% in the 5-hour or
|
||||||
@@ -308,6 +369,13 @@ How a new session picks its account (`server/lib/claude-pool.js`):
|
|||||||
- The pane gets the token through tmux `-e CLAUDE_CODE_OAUTH_TOKEN=…`. That
|
- The pane gets the token through tmux `-e CLAUDE_CODE_OAUTH_TOKEN=…`. That
|
||||||
token outranks the machine's own `/login` for that process only.
|
token outranks the machine's own `/login` for that process only.
|
||||||
- A session on an AI Proxy keeps the proxy's key and does not use the pool.
|
- A session on an AI Proxy keeps the proxy's key and does not use the pool.
|
||||||
|
- With one Linux user per account (S3), a member's home starts empty, and a
|
||||||
|
fresh Claude Code shows its "Select login method" screen even with the token
|
||||||
|
set. So before a member's pane starts on a pool account or an AI Proxy,
|
||||||
|
AgentHub adds `hasCompletedOnboarding: true` to that member's
|
||||||
|
`~/.claude.json`, written as the member, with every other key kept
|
||||||
|
(`run-as.markClaudeOnboarded`). With an empty pool and no proxy, a member's
|
||||||
|
pane still needs that member's own `/login`.
|
||||||
- A token more than a year old is skipped.
|
- A token more than a year old is skipped.
|
||||||
- A resumed session picks an account again, like a new one.
|
- A resumed session picks an account again, like a new one.
|
||||||
- Quota is read with each account's own token, in the background, at most every
|
- Quota is read with each account's own token, in the background, at most every
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ metadata:
|
|||||||
|
|
||||||
**2026-10-05 v2.0.1 RELEASED:** b0cf213 Windows AI-CLI detection (which() tries PATHEXT, skips npm sh shim; spawnTarget = shell:true + quoted path for .cmd in ai-tool-jobs/versions) + c067abc bump, tag, forge release 544. NOT verified on a real Windows box — user (machine kingg, Win11) must /agenthub-update then Refresh in Settings → Agents.
|
**2026-10-05 v2.0.1 RELEASED:** b0cf213 Windows AI-CLI detection (which() tries PATHEXT, skips npm sh shim; spawnTarget = shell:true + quoted path for .cmd in ai-tool-jobs/versions) + c067abc bump, tag, forge release 544. NOT verified on a real Windows box — user (machine kingg, Win11) must /agenthub-update then Refresh in Settings → Agents.
|
||||||
|
|
||||||
**2026-10-05 v2.0.0 RELEASED:** 198fbb7 (all pending work) + 094d9c4 bump pushed to origin main, tag v2.0.0, forge release 543 (EN+VI notes). docs/plans/analytics-parity/ deliberately left UNTRACKED: it names Claude-Code-Agent-Monitor, which repo-links.test.js forbids in tracked files. Pre-commit hook runs the full suite (~4 min) — run git commit in background.
|
**2026-10-05 v2.0.0 RELEASED:** 198fbb7 (all pending work) + 094d9c4 bump pushed to origin main, tag v2.0.0, forge release 543 (EN+VI notes). docs/plans/analytics-parity/ deliberately left UNTRACKED: it names the retired repository, which repo-links.test.js forbids in tracked files. Pre-commit hook runs the full suite (~4 min) — run git commit in background.
|
||||||
|
|
||||||
**2026-10-05 (65fe680 daily cost by spend day — token_usage_hourly; ad6e801 right-click menus everywhere + status bar Workspace $/Session $ + long-term DB safety; unpushed):** user first chose to keep all event data; on 2026-10-05 (later) REVERSED for payloads only: trim events.data at insert + one-time sweep (whole files/long output dropped, kept in transcripts), still no row prune and no VACUUM, transcript snapshots kept. See [[analytics-parity-plan]]. Live DB 1.2 GB (events 1.08 GB) + 346 MB WAL until a dashboard runs ad6e801.
|
**2026-10-05 (65fe680 daily cost by spend day — token_usage_hourly; ad6e801 right-click menus everywhere + status bar Workspace $/Session $ + long-term DB safety; unpushed):** user first chose to keep all event data; on 2026-10-05 (later) REVERSED for payloads only: trim events.data at insert + one-time sweep (whole files/long output dropped, kept in transcripts), still no row prune and no VACUUM, transcript snapshots kept. See [[analytics-parity-plan]]. Live DB 1.2 GB (events 1.08 GB) + 346 MB WAL until a dashboard runs ad6e801.
|
||||||
|
|
||||||
|
|||||||
+388
-6
@@ -3,7 +3,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Agent Dashboard for Claude Code API
|
title: Agent Dashboard for Claude Code API
|
||||||
version: 2.7.5
|
version: 2.7.6
|
||||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||||
contact:
|
contact:
|
||||||
name: Nguyễn Ngọc Trí Vĩ
|
name: Nguyễn Ngọc Trí Vĩ
|
||||||
@@ -4803,6 +4803,37 @@ components:
|
|||||||
- developer
|
- developer
|
||||||
disabled:
|
disabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
MyAccount:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- mode
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- local
|
||||||
|
- server
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/AuthUser'
|
||||||
|
workspaces:
|
||||||
|
type: integer
|
||||||
|
sessions:
|
||||||
|
type: integer
|
||||||
|
logins:
|
||||||
|
type: integer
|
||||||
|
description: Unexpired logins of the account
|
||||||
|
lastLoginAt:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
AuthMe:
|
AuthMe:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -11182,6 +11213,43 @@ paths:
|
|||||||
description: EDIALOG — the dialog program failed.
|
description: EDIALOG — the dialog program failed.
|
||||||
'501':
|
'501':
|
||||||
description: ENODIALOG — no desktop session or no dialog program.
|
description: ENODIALOG — no desktop session or no dialog program.
|
||||||
|
/api/lanes/browse/mkdir:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Lanes
|
||||||
|
summary: New folder, from the in-app folder browser
|
||||||
|
description: Makes ONE folder `name` (no `/`, `\`, NUL, `.` or `..`; ≤ 255 bytes) inside the existing folder `parent` — never `-p`, never over an existing entry. A non-admin makes one only inside their own folder, by real path (`EFOLDER`), and in server mode a non-admin with no folder of their own makes none. Under S3 a folder in a member's home is made as that member.
|
||||||
|
operationId: makeBrowseFolder
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- parent
|
||||||
|
- name
|
||||||
|
properties:
|
||||||
|
parent:
|
||||||
|
type: string
|
||||||
|
description: Absolute path of an existing folder.
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: '`{path}` — the folder made.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
'400':
|
||||||
|
description: EBADNAME, EBADPATH, or ENOTFOUND (no such parent folder).
|
||||||
|
'403':
|
||||||
|
description: EBADORIGIN, or EFOLDER (outside the caller's own folder).
|
||||||
|
'409':
|
||||||
|
description: EEXISTS (the name is taken) or EMEMBERGONE.
|
||||||
/api/lanes/ensure:
|
/api/lanes/ensure:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -13927,6 +13995,139 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/me/account:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: getMyAccount
|
||||||
|
summary: 'Settings → Account: the signed-in account (local mode: {mode: "local"})'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Account
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MyAccount'
|
||||||
|
'401':
|
||||||
|
description: EAUTH
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
patch:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: updateMyAccount
|
||||||
|
summary: The account's own display name and email (""/null clears). Login cookie only, audited
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 100
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 254
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/MyAccount'
|
||||||
|
'400':
|
||||||
|
description: EBADFIELD / EBADEMAIL
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: EAUTH — no login cookie (a pane token is refused)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/me/password:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: changeMyPassword
|
||||||
|
summary: 'Change the account''s own password: the current one required (throttled); this login stays, every other ends'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- currentPassword
|
||||||
|
- newPassword
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Changed ({ok: true})'
|
||||||
|
'400':
|
||||||
|
description: EBADPASSWORD
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'401':
|
||||||
|
description: EAUTH — no login cookie (a pane token is refused)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH (current password wrong) / ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/me/sign-out-others:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: signOutMyOtherLogins
|
||||||
|
summary: End every login of the account but this one; answers {ended}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Ended
|
||||||
|
'401':
|
||||||
|
description: EAUTH — no login cookie (a pane token is refused)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/users:
|
/api/users:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@@ -13992,7 +14193,7 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
minLength: 8
|
minLength: 1
|
||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -14037,11 +14238,37 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/users/{id}:
|
/api/users/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: getServerUser
|
||||||
|
summary: One account with its logins and the workspaces it owns (admin/analyst)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Account
|
||||||
|
'403':
|
||||||
|
description: EADMIN — a developer
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOUSER
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
patch:
|
patch:
|
||||||
tags:
|
tags:
|
||||||
- Auth
|
- Auth
|
||||||
operationId: updateServerUser
|
operationId: updateServerUser
|
||||||
summary: 'Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)'
|
summary: 'Admin (Edit user): display name, email, role, disabled, a new password, workspaces given or taken back, sign out everywhere — every field validated before any is written. Never your own role or disable (ESELF)'
|
||||||
parameters:
|
parameters:
|
||||||
- name: id
|
- name: id
|
||||||
in: path
|
in: path
|
||||||
@@ -14057,6 +14284,14 @@ paths:
|
|||||||
required:
|
required:
|
||||||
- currentPassword
|
- currentPassword
|
||||||
properties:
|
properties:
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 100
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
maxLength: 254
|
||||||
role:
|
role:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
@@ -14065,6 +14300,21 @@ paths:
|
|||||||
- developer
|
- developer
|
||||||
disabled:
|
disabled:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
addWorkspaces:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
removeWorkspaces:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
signOut:
|
||||||
|
type: boolean
|
||||||
|
enum:
|
||||||
|
- true
|
||||||
currentPassword:
|
currentPassword:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
@@ -14078,7 +14328,7 @@ paths:
|
|||||||
user:
|
user:
|
||||||
$ref: '#/components/schemas/AuthUser'
|
$ref: '#/components/schemas/AuthUser'
|
||||||
'400':
|
'400':
|
||||||
description: EBADPASSWORD / EBADROLE / EBADFIELD
|
description: EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -14096,7 +14346,7 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
'409':
|
'409':
|
||||||
description: ESELF — your own role or disable
|
description: ESELF / ENOTOWNED / EOWNERFOLDER
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -14131,7 +14381,7 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
minLength: 8
|
minLength: 1
|
||||||
currentPassword:
|
currentPassword:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
@@ -14282,6 +14532,12 @@ paths:
|
|||||||
owner:
|
owner:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
reader_display:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
owner_display:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
'403':
|
'403':
|
||||||
description: EADMIN — a developer
|
description: EADMIN — a developer
|
||||||
content:
|
content:
|
||||||
@@ -14368,6 +14624,132 @@ paths:
|
|||||||
nullable: true
|
nullable: true
|
||||||
full:
|
full:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: addPoolAccount
|
||||||
|
summary: 'Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tool
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
tool:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claude
|
||||||
|
- codex
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Defaults to the profile's name
|
||||||
|
profile:
|
||||||
|
type: string
|
||||||
|
description: 'Claude: a saved switch-user profile, moved into the pool'
|
||||||
|
credentials:
|
||||||
|
type: string
|
||||||
|
description: 'Claude: the text of a .credentials.json'
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: 'Claude: a setup-token; Codex: an API key or auth.json text'
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: '`{account: {name, addedAt, …}}` — no token'
|
||||||
|
'400':
|
||||||
|
description: EBADTOOL, EBADNAME, EBADTOKEN
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'409':
|
||||||
|
description: EEXISTS — the name or account is taken; EACTIVE — the host's CLI uses that profile
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — too many wrong passwords
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'502':
|
||||||
|
description: EUPSTREAM — Anthropic could not be reached
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
/api/accounts/profiles:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: listPoolProfiles
|
||||||
|
summary: Saved switch-user profiles an admin may move into the Claude pool (no token)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: '`{profiles: [{name, label, email, subscriptionType, active}]}`'
|
||||||
|
/api/accounts/{name}:
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- Auth
|
||||||
|
operationId: removePoolAccount
|
||||||
|
summary: 'Admin: remove an account from the pool, with their own password again'
|
||||||
|
parameters:
|
||||||
|
- name: name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- tool
|
||||||
|
- currentPassword
|
||||||
|
properties:
|
||||||
|
tool:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- claude
|
||||||
|
- codex
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: '`{ok: true}`'
|
||||||
|
'403':
|
||||||
|
description: EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'404':
|
||||||
|
description: ENOACCOUNT
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
'429':
|
||||||
|
description: ETOOMANY — too many wrong passwords
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
/api/auth/me:
|
/api/auth/me:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.5",
|
"version": "2.7.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.5",
|
"version": "2.7.6",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.7.5",
|
"version": "2.7.6",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,9 +129,15 @@ chmod 0755 /opt/agenthub/libexec/member-hook
|
|||||||
|
|
||||||
# ── sudoers: run-as members only (a fixed command list), and three root commands ──
|
# ── sudoers: run-as members only (a fixed command list), and three root commands ──
|
||||||
TMP_SUDOERS=$(mktemp)
|
TMP_SUDOERS=$(mktemp)
|
||||||
|
# `!requiretty` matters only where a distro turns requiretty ON (older RHEL);
|
||||||
|
# sudo-rs (Ubuntu 25.10+) has no such setting and rejects the line, so it is
|
||||||
|
# written only when this machine's visudo accepts it.
|
||||||
|
NOTTY=""
|
||||||
|
printf 'Defaults:%s !requiretty\n' "$HOST" >"$TMP_SUDOERS"
|
||||||
|
visudo -cf "$TMP_SUDOERS" >/dev/null 2>&1 && NOTTY="Defaults:$HOST !requiretty"
|
||||||
{
|
{
|
||||||
echo "# AgentHub server mode (install-system). Members are never in sudoers."
|
echo "# AgentHub server mode (install-system). Members are never in sudoers."
|
||||||
echo "Defaults:$HOST !requiretty"
|
[ -z "$NOTTY" ] || echo "$NOTTY"
|
||||||
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
|
echo "$HOST ALL=(%agenthub-members) NOPASSWD:SETENV: $TMUX_BIN, $GIT_BIN${GH_BIN:+, $GH_BIN}, /opt/agenthub/libexec/write-as, /opt/agenthub/tools/bin/claude"
|
||||||
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
|
echo "$HOST ALL=(root) NOPASSWD: /opt/agenthub/sbin/agenthub-provision"
|
||||||
for a in start stop restart enable disable; do
|
for a in start stop restart enable disable; do
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ const env = {
|
|||||||
// Nor run the developer's real agy (it may update itself) or send their
|
// Nor run the developer's real agy (it may update itself) or send their
|
||||||
// real Cursor, Grok, Kimi or OpenCode login for the quota.
|
// real Cursor, Grok, Kimi or OpenCode login for the quota.
|
||||||
AGENTHUB_AGY_USAGE: "0",
|
AGENTHUB_AGY_USAGE: "0",
|
||||||
|
// Nor refresh (spend) a real Claude pool login: its refresh token is single use.
|
||||||
|
DASHBOARD_POOL_REFRESH_MS: "0",
|
||||||
XDG_CONFIG_HOME: tmuxDir,
|
XDG_CONFIG_HOME: tmuxDir,
|
||||||
XDG_DATA_HOME: tmuxDir,
|
XDG_DATA_HOME: tmuxDir,
|
||||||
GROK_HOME: tmuxDir,
|
GROK_HOME: tmuxDir,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file ai-proxies-9router.test.js
|
||||||
|
* @description `GET /api/ai-proxies/9router` — reports whether a `9router`
|
||||||
|
* binary is on the server's PATH (the AI Proxy page shows its Config 9router
|
||||||
|
* link only then), and never runs it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ai-proxies-9router-"));
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
|
||||||
|
const express = require("express");
|
||||||
|
const router = require("../routes/ai-proxies");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const ORIGINAL_PATH = process.env.PATH;
|
||||||
|
const BIN = path.join(TMP, "bin");
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(BIN, { recursive: true });
|
||||||
|
const app = express();
|
||||||
|
app.use("/api/ai-proxies", router);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
server = app.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
process.env.PATH = ORIGINAL_PATH;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const installed = async () =>
|
||||||
|
(await (await fetch(`${BASE}/api/ai-proxies/9router`)).json()).installed;
|
||||||
|
|
||||||
|
describe("GET /api/ai-proxies/9router", () => {
|
||||||
|
it("is false when no 9router binary is on PATH", async () => {
|
||||||
|
process.env.PATH = path.join(TMP, "empty");
|
||||||
|
assert.equal(await installed(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is true once an executable 9router is on PATH", async () => {
|
||||||
|
const bin = path.join(BIN, "9router");
|
||||||
|
fs.writeFileSync(bin, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||||
|
process.env.PATH = BIN;
|
||||||
|
assert.equal(await installed(), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3121,6 +3121,48 @@ describe("GET /api/sessions/:id/outline", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/sessions/:id/file", () => {
|
||||||
|
it("serves only a text file the session itself read or wrote", async () => {
|
||||||
|
const sid = "read-file-" + Date.now();
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ah-read-file-"));
|
||||||
|
const report = path.join(dir, "REPORT.md");
|
||||||
|
const other = path.join(dir, "other.md");
|
||||||
|
const bin = path.join(dir, "blob.dat");
|
||||||
|
const cred = path.join(dir, ".credentials.json");
|
||||||
|
const link = path.join(dir, "notes.md");
|
||||||
|
fs.writeFileSync(report, "# Report");
|
||||||
|
fs.writeFileSync(other, "other");
|
||||||
|
fs.writeFileSync(bin, Buffer.from([1, 0, 2]));
|
||||||
|
fs.writeFileSync(cred, "{}");
|
||||||
|
fs.symlinkSync(cred, link);
|
||||||
|
const tool = (tool_name, file_path) =>
|
||||||
|
post("/api/hooks/event", {
|
||||||
|
hook_type: "PostToolUse",
|
||||||
|
data: { session_id: sid, tool_name, tool_input: { file_path }, tool_response: {} },
|
||||||
|
});
|
||||||
|
await post("/api/hooks/event", { hook_type: "SessionStart", data: { session_id: sid } });
|
||||||
|
await tool("Write", report);
|
||||||
|
await tool("Read", bin);
|
||||||
|
await tool("Read", cred);
|
||||||
|
await tool("Edit", link);
|
||||||
|
const url = (p) => `/api/sessions/${sid}/file?path=${encodeURIComponent(p)}`;
|
||||||
|
|
||||||
|
const ok = await fetch(url(report));
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.deepEqual(ok.body, { text: "# Report", truncated: false });
|
||||||
|
|
||||||
|
// Never touched, binary, a credential (by name or through a link), relative, no session.
|
||||||
|
assert.equal((await fetch(url(other))).status, 404);
|
||||||
|
assert.equal((await fetch(url(bin))).status, 415);
|
||||||
|
assert.equal((await fetch(url(cred))).status, 403);
|
||||||
|
assert.equal((await fetch(url(link))).status, 403);
|
||||||
|
assert.equal((await fetch(url("REPORT.md"))).status, 400);
|
||||||
|
assert.equal((await fetch(`/api/sessions/nope-file/file?path=${report}`)).status, 404);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
assert.equal((await fetch(url(report))).status, 404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GET /api/sessions/:id/image", () => {
|
describe("GET /api/sessions/:id/image", () => {
|
||||||
it("serves only an image the session itself read", async () => {
|
it("serves only an image the session itself read", async () => {
|
||||||
const sid = "read-image-" + Date.now();
|
const sid = "read-image-" + Date.now();
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* @file claude-pool-login.test.js
|
||||||
|
* @description The Claude pool's `login` kind (claude-pool.js): a real login
|
||||||
|
* is written into a MEMBER's `~/.claude/.credentials.json` as the member
|
||||||
|
* (write-as, played by fixtures/fake-sudo.js) and never offered to a host
|
||||||
|
* pane or over a member's own login; the server refreshes it ahead of expiry
|
||||||
|
* and pushes the new pair into every home it placed it in; a pair a member's
|
||||||
|
* Claude refreshed first is copied back only when Anthropic says it is the
|
||||||
|
* same account; removing the account takes it out of those homes; and the
|
||||||
|
* Users-page route moves a saved switch-user profile in — the one this
|
||||||
|
* machine's own Claude Code is logged into with its LIVE login, the host's
|
||||||
|
* home then being one more placement the server refreshes.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "claude-pool-login-")));
|
||||||
|
const USERS = path.join(TMP, "users");
|
||||||
|
const HOME = path.join(USERS, "mia");
|
||||||
|
const CREDS = path.join(HOME, ".claude", ".credentials.json");
|
||||||
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||||
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
process.env.DASHBOARD_POOL_REFRESH_MS = "0";
|
||||||
|
process.env.AGENTHUB_CC_KEYCHAIN = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const linux = require("../lib/linux-accounts");
|
||||||
|
const runAs = require("../lib/run-as");
|
||||||
|
const pool = require("../lib/claude-pool");
|
||||||
|
const usage = require("../lib/cc-usage");
|
||||||
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||||
|
const sudo = fakeSudo();
|
||||||
|
|
||||||
|
const H = 3600e3;
|
||||||
|
const oauth = (tag, expiresIn = 8 * H) => ({
|
||||||
|
accessToken: `sk-ant-oat01-access-${tag}`,
|
||||||
|
refreshToken: `sk-ant-ort01-refresh-${tag}`,
|
||||||
|
expiresAt: Date.now() + expiresIn,
|
||||||
|
scopes: ["user:inference"],
|
||||||
|
subscriptionType: "max",
|
||||||
|
});
|
||||||
|
const ACCOUNT = { accountUuid: "uuid-team", emailAddress: "team@example.com" };
|
||||||
|
const diskCreds = () => JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth;
|
||||||
|
|
||||||
|
let acct;
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const exchanges = [];
|
||||||
|
let owners = {};
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(HOME, { recursive: true });
|
||||||
|
const uid = process.getuid();
|
||||||
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
process.env.AGENTHUB_SERVER_CONF,
|
||||||
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||||
|
);
|
||||||
|
linux.__resetConfCache();
|
||||||
|
runAs.__resetCache();
|
||||||
|
linux.__setRunImpl(() => "ok");
|
||||||
|
runAs.__setExecImpl(sudo.exec);
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
await auth.createUser("boss", "admin-pass-1", "admin");
|
||||||
|
await auth.createUser("mia", "member-pass-1", "developer");
|
||||||
|
acct = runAs.accountForPath(HOME);
|
||||||
|
assert.ok(acct, "mia is a provisioned member");
|
||||||
|
// No network: Anthropic's answers are played here.
|
||||||
|
usage.fetchUsage = async () => ({});
|
||||||
|
usage.exchangeRefresh = async (creds) => {
|
||||||
|
exchanges.push(creds.refreshToken);
|
||||||
|
return oauth(`r${exchanges.length}`);
|
||||||
|
};
|
||||||
|
usage.fetchOwner = async (token) => owners[token] || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(path.join(HOME, ".claude"), { recursive: true, force: true });
|
||||||
|
fs.rmSync(path.join(HOME, ".claude.json"), { force: true });
|
||||||
|
fs.rmSync(path.join(TMP, ".claude", "agenthub-accounts"), { recursive: true, force: true });
|
||||||
|
exchanges.length = 0;
|
||||||
|
owners = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a pool login", () => {
|
||||||
|
it("goes into a member's home as the member, never to a host pane", () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT, userID: "u-1" });
|
||||||
|
assert.equal(pool.forSpawn(null), null, "a host pane keeps the host's own login");
|
||||||
|
const spawn = pool.forSpawn(acct);
|
||||||
|
assert.equal(spawn.name, "team");
|
||||||
|
assert.deepEqual(spawn.env, {}, "no token in the pane's env");
|
||||||
|
spawn.place();
|
||||||
|
assert.equal(diskCreds().refreshToken, oauth("a").refreshToken);
|
||||||
|
assert.equal(fs.statSync(CREDS).mode & 0o777, 0o600);
|
||||||
|
const state = JSON.parse(fs.readFileSync(path.join(HOME, ".claude.json"), "utf8"));
|
||||||
|
assert.equal(state.hasCompletedOnboarding, true);
|
||||||
|
assert.equal(state.oauthAccount.emailAddress, "team@example.com");
|
||||||
|
assert.ok(sudo.calls.some((c) => c.includes(CREDS) && c.includes("private")));
|
||||||
|
assert.ok(
|
||||||
|
!sudo.calls.flat().some((x) => String(x).includes("sk-ant")),
|
||||||
|
"no secret in sudo argv"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is never written over a member's own login", () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
|
fs.mkdirSync(path.dirname(CREDS), { recursive: true });
|
||||||
|
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("mine") }));
|
||||||
|
assert.equal(pool.forSpawn(acct), null);
|
||||||
|
assert.equal(diskCreds().refreshToken, oauth("mine").refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is refreshed by the server ahead of expiry and pushed into the home", async () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a", 10 * 60e3), account: ACCOUNT });
|
||||||
|
pool.forSpawn(acct).place();
|
||||||
|
await pool.refreshLogins();
|
||||||
|
assert.deepEqual(exchanges, [oauth("a").refreshToken], "the server spent the refresh token");
|
||||||
|
assert.equal(pool.__read("team").oauth.refreshToken, oauth("r1").refreshToken);
|
||||||
|
assert.equal(diskCreds().refreshToken, oauth("r1").refreshToken, "the home got the new pair");
|
||||||
|
await pool.refreshLogins();
|
||||||
|
assert.equal(exchanges.length, 1, "a fresh login is not refreshed again");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies a home's own refresh back only when it is the same account", async () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
|
pool.forSpawn(acct).place();
|
||||||
|
// The member's Claude refreshed first.
|
||||||
|
const theirs = oauth("cli", 9 * H);
|
||||||
|
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: theirs }));
|
||||||
|
owners[theirs.accessToken] = { uuid: "uuid-team" };
|
||||||
|
await pool.refreshLogins();
|
||||||
|
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken);
|
||||||
|
// A login of another account is the member's own: dropped, never written over.
|
||||||
|
const other = oauth("other", 10 * H);
|
||||||
|
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: other }));
|
||||||
|
owners[other.accessToken] = { uuid: "uuid-someone-else" };
|
||||||
|
await pool.refreshLogins();
|
||||||
|
assert.equal(pool.__read("team").oauth.refreshToken, theirs.refreshToken);
|
||||||
|
assert.deepEqual(pool.__read("team").placed, {});
|
||||||
|
assert.equal(diskCreds().refreshToken, other.refreshToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is taken out of the homes it is still ours in when removed", () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
|
pool.forSpawn(acct).place();
|
||||||
|
pool.removeAccount("team");
|
||||||
|
assert.equal(fs.existsSync(CREDS), false);
|
||||||
|
assert.equal(pool.__read("team"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second copy of one Claude account", () => {
|
||||||
|
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
|
||||||
|
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
|
||||||
|
code: "EEXISTS",
|
||||||
|
});
|
||||||
|
assert.throws(() => pool.addLoginAccount("x", { oauth: { accessToken: "a" } }), {
|
||||||
|
code: "EBADTOKEN",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("moving a saved profile in from the Users page", () => {
|
||||||
|
const profileDir = path.join(TMP, ".claude", "agenthub-profiles");
|
||||||
|
const writeProfile = (name, tag, uuid) => {
|
||||||
|
fs.mkdirSync(profileDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(profileDir, `${name}.json`),
|
||||||
|
JSON.stringify({
|
||||||
|
name,
|
||||||
|
label: name,
|
||||||
|
credentials: oauth(tag),
|
||||||
|
account: { accountUuid: uuid, emailAddress: `${name}@example.com` },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function call(method, p, body) {
|
||||||
|
const login = await fetch(BASE + "/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "boss", password: "admin-pass-1" }),
|
||||||
|
});
|
||||||
|
const cookie = login.headers.get("set-cookie").split(";")[0];
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", cookie },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("moves it into the pool — the account this machine runs as with its LIVE login", async () => {
|
||||||
|
writeProfile("work", "w", "uuid-work");
|
||||||
|
writeProfile("me", "m-saved", "uuid-me");
|
||||||
|
// This machine's own Claude Code is logged in as "me", and has refreshed
|
||||||
|
// since the profile was saved: only the live pair still works.
|
||||||
|
const hostCreds = path.join(TMP, ".claude", ".credentials.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
hostCreds,
|
||||||
|
JSON.stringify({ claudeAiOauth: oauth("m-live", 10 * 60e3), mcpOAuth: { keep: 1 } })
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
`${path.join(TMP, ".claude")}.json`,
|
||||||
|
JSON.stringify({ oauthAccount: { accountUuid: "uuid-me" } })
|
||||||
|
);
|
||||||
|
const list = await call("GET", "/api/accounts/profiles");
|
||||||
|
assert.deepEqual(
|
||||||
|
list.body.profiles.map((p) => [p.name, p.active]),
|
||||||
|
[
|
||||||
|
["me", true],
|
||||||
|
["work", false],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.ok(!JSON.stringify(list.body).includes("sk-ant"));
|
||||||
|
const moved = await call("POST", "/api/accounts", {
|
||||||
|
tool: "claude",
|
||||||
|
profile: "work",
|
||||||
|
currentPassword: "admin-pass-1",
|
||||||
|
});
|
||||||
|
assert.equal(moved.status, 201);
|
||||||
|
assert.equal(moved.body.account.name, "work");
|
||||||
|
assert.ok(!JSON.stringify(moved.body).includes("sk-ant"));
|
||||||
|
assert.equal(pool.__read("work").kind, "login");
|
||||||
|
assert.equal(fs.existsSync(path.join(profileDir, "work.json")), false, "one holder only");
|
||||||
|
|
||||||
|
const mine = await call("POST", "/api/accounts", {
|
||||||
|
tool: "claude",
|
||||||
|
profile: "me",
|
||||||
|
currentPassword: "admin-pass-1",
|
||||||
|
});
|
||||||
|
assert.equal(mine.status, 201);
|
||||||
|
const rec = pool.__read("me");
|
||||||
|
assert.equal(
|
||||||
|
rec.oauth.refreshToken,
|
||||||
|
oauth("m-live").refreshToken,
|
||||||
|
"the live pair, not the saved one"
|
||||||
|
);
|
||||||
|
assert.deepEqual(Object.keys(rec.placed), ["@host"]);
|
||||||
|
assert.equal(fs.existsSync(path.join(profileDir, "me.json")), false);
|
||||||
|
|
||||||
|
// The server refreshes it and pushes the new pair into the host's own file,
|
||||||
|
// keeping its other keys; removing the account leaves the host logged in.
|
||||||
|
await pool.refreshLogins();
|
||||||
|
const host = JSON.parse(fs.readFileSync(hostCreds, "utf8"));
|
||||||
|
assert.equal(host.claudeAiOauth.refreshToken, pool.__read("me").oauth.refreshToken);
|
||||||
|
assert.notEqual(host.claudeAiOauth.refreshToken, oauth("m-live").refreshToken);
|
||||||
|
assert.deepEqual(host.mcpOAuth, { keep: 1 });
|
||||||
|
assert.equal(fs.statSync(hostCreds).mode & 0o777, 0o600);
|
||||||
|
pool.removeAccount("me");
|
||||||
|
assert.ok(fs.existsSync(hostCreds), "the host keeps its own login");
|
||||||
|
pool.removeAccount("work");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes a pasted .credentials.json once Anthropic names its account", async () => {
|
||||||
|
const pasted = oauth("p");
|
||||||
|
owners[pasted.accessToken] = { uuid: "uuid-pasted", email: "p@example.com" };
|
||||||
|
const r = await call("POST", "/api/accounts", {
|
||||||
|
tool: "claude",
|
||||||
|
name: "pasted",
|
||||||
|
credentials: JSON.stringify({ claudeAiOauth: pasted }),
|
||||||
|
currentPassword: "admin-pass-1",
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
assert.equal(pool.__read("pasted").account.accountUuid, "uuid-pasted");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @file server-mode-account-pool.test.js
|
||||||
|
* @description The Claude/Codex account pool from the Users page: only an
|
||||||
|
* admin may add or remove an account, each change needs the admin's OWN
|
||||||
|
* password again (EREAUTH), the token only goes in — no answer (the add, the
|
||||||
|
* list) ever carries it — the audit log names the account, not its secret, and
|
||||||
|
* in local mode the routes refuse.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-account-pool-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const claudePool = require("../lib/claude-pool");
|
||||||
|
const codexPool = require("../lib/codex-pool");
|
||||||
|
|
||||||
|
// The quota read would go to the network; the pool's list must not.
|
||||||
|
require("../lib/cc-usage").fetchUsage = async () => ({});
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const BOSS_PASS = "admin-pass-1";
|
||||||
|
const TOKEN = `sk-ant-oat01-${"a".repeat(40)}`;
|
||||||
|
const KEY = `sk-proj-${"b".repeat(40)}`;
|
||||||
|
|
||||||
|
async function call(method, p, { cookie, body } = {}) {
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json", ...(cookie ? { cookie } : {}) },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await fetch(BASE + "/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
await auth.createUser("boss", BOSS_PASS, "admin");
|
||||||
|
await auth.createUser("ana", "analyst-pass-1", "analyst");
|
||||||
|
await auth.createUser("mia", "member-pass-1", "developer");
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the account pool from the Users page", () => {
|
||||||
|
it("lets an admin add a Claude account, never answering with its token", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const r = await call("POST", "/api/accounts", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "claude", name: "team-1", token: TOKEN, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
assert.equal(r.body.account.name, "team-1");
|
||||||
|
assert.ok(!JSON.stringify(r.body).includes(TOKEN));
|
||||||
|
assert.equal(claudePool.pickAccount().token, TOKEN, "new panes run on it");
|
||||||
|
const list = await call("GET", "/api/accounts", { cookie: boss });
|
||||||
|
assert.deepEqual(
|
||||||
|
list.body.accounts.map((a) => a.name),
|
||||||
|
["team-1"]
|
||||||
|
);
|
||||||
|
assert.ok(!JSON.stringify(list.body).includes(TOKEN));
|
||||||
|
const audit = db.prepare("SELECT path FROM audit_log ORDER BY id DESC LIMIT 1").get();
|
||||||
|
assert.match(audit.path, /add claude account team-1/);
|
||||||
|
assert.ok(!audit.path.includes(TOKEN));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a Codex API key, and removes accounts again", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const add = await call("POST", "/api/accounts", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "codex", name: "cx-1", token: KEY, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(add.status, 201);
|
||||||
|
assert.equal(add.body.account.kind, "apikey");
|
||||||
|
assert.ok(!JSON.stringify(add.body).includes(KEY));
|
||||||
|
assert.equal(codexPool.__read("cx-1").key, KEY);
|
||||||
|
const del = await call("DELETE", "/api/accounts/cx-1", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "codex", currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
assert.equal(codexPool.__read("cx-1"), null);
|
||||||
|
const gone = await call("DELETE", "/api/accounts/cx-1", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "codex", currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(gone.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a wrong password, a bad token, a taken name and an unknown tool", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const add = (body) =>
|
||||||
|
call("POST", "/api/accounts", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { tool: "claude", name: "team-2", token: TOKEN, currentPassword: BOSS_PASS, ...body },
|
||||||
|
});
|
||||||
|
assert.equal((await add({ currentPassword: "nope" })).body.error.code, "EREAUTH");
|
||||||
|
assert.equal((await add({ token: "not-a-token" })).body.error.code, "EBADTOKEN");
|
||||||
|
assert.equal((await add({ name: "team-1" })).body.error.code, "EEXISTS");
|
||||||
|
assert.equal((await add({ tool: "cursor" })).body.error.code, "EBADTOOL");
|
||||||
|
assert.equal((await add({ name: "../x" })).body.error.code, "EBADNAME");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is an admin's only: an analyst reads it, a developer neither reads nor writes", async () => {
|
||||||
|
const ana = await login("ana", "analyst-pass-1");
|
||||||
|
assert.equal((await call("GET", "/api/accounts", { cookie: ana })).status, 200);
|
||||||
|
const w = await call("POST", "/api/accounts", {
|
||||||
|
cookie: ana,
|
||||||
|
body: { tool: "claude", name: "x", token: TOKEN, currentPassword: "analyst-pass-1" },
|
||||||
|
});
|
||||||
|
assert.equal(w.status, 403);
|
||||||
|
const mia = await login("mia", "member-pass-1");
|
||||||
|
assert.equal((await call("GET", "/api/accounts", { cookie: mia })).status, 403);
|
||||||
|
const d = await call("DELETE", "/api/accounts/team-1", {
|
||||||
|
cookie: mia,
|
||||||
|
body: { tool: "claude", currentPassword: "member-pass-1" },
|
||||||
|
});
|
||||||
|
assert.equal(d.status, 403);
|
||||||
|
assert.ok(claudePool.pickAccount(), "team-1 is still there");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* @file server-mode-user-edit.test.js
|
||||||
|
* @description The Users page's Edit user form (`GET`/`PATCH /api/users/:id`)
|
||||||
|
* and Settings → Account (`/api/me/account`, `/api/me/password`,
|
||||||
|
* `/api/me/sign-out-others`): one PATCH validates every field before any is
|
||||||
|
* written, sets display name/email/role/password/workspaces/sign-out together
|
||||||
|
* with the admin's own password, never takes a workspace from a third user; a
|
||||||
|
* user edits their own profile, changes their own password only with the old
|
||||||
|
* one (keeping the login they changed it from), signs out other devices, and a
|
||||||
|
* pane token may not do any of it.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-edit-"));
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||||
|
|
||||||
|
const { createApp, startServer } = require("../index");
|
||||||
|
const { db } = require("../db");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let BASE;
|
||||||
|
const ids = {};
|
||||||
|
const BOSS_PASS = "admin-pass-1";
|
||||||
|
|
||||||
|
async function call(method, p, { cookie, body, headers } = {}) {
|
||||||
|
const res = await fetch(BASE + p, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(cookie ? { cookie } : {}),
|
||||||
|
...(headers || {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
const res = await fetch(BASE + "/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lane = (cwd, owner = null) =>
|
||||||
|
Number(
|
||||||
|
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(cwd, owner)
|
||||||
|
.lastInsertRowid
|
||||||
|
);
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
server = await startServer(createApp(), 0);
|
||||||
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||||
|
ids.mia = (await auth.createUser("mia", "member-pass-1", "developer")).id;
|
||||||
|
ids.leo = (await auth.createUser("leo", "member-pass-2", "developer")).id;
|
||||||
|
ids.free = lane("/w/free");
|
||||||
|
ids.miaLane = lane("/w/mia", ids.mia);
|
||||||
|
ids.leoLane = lane("/w/leo", ids.leo);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
server?.close();
|
||||||
|
fs.rmSync(TMP, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edit user (PATCH /api/users/:id)", () => {
|
||||||
|
it("reads one account with its workspaces and logins, never a hash", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
await login("mia", "member-pass-1");
|
||||||
|
const r = await call("GET", `/api/users/${ids.mia}`, { cookie: boss });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.user.username, "mia");
|
||||||
|
assert.equal(r.body.user.logins, 1);
|
||||||
|
assert.ok(r.body.user.lastLoginAt);
|
||||||
|
assert.ok(r.body.user.createdAt);
|
||||||
|
assert.deepEqual(
|
||||||
|
r.body.workspaces.map((w) => w.id),
|
||||||
|
[ids.miaLane]
|
||||||
|
);
|
||||||
|
assert.ok(!JSON.stringify(r.body).includes("scrypt"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies profile, role, workspaces and password in one request", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const r = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: {
|
||||||
|
currentPassword: BOSS_PASS,
|
||||||
|
displayName: " Mia Tran ",
|
||||||
|
email: "mia@example.com",
|
||||||
|
role: "analyst",
|
||||||
|
password: "mia-new-pass",
|
||||||
|
addWorkspaces: [ids.free],
|
||||||
|
removeWorkspaces: [ids.miaLane],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200, JSON.stringify(r.body));
|
||||||
|
assert.equal(r.body.user.displayName, "Mia Tran");
|
||||||
|
assert.equal(r.body.user.email, "mia@example.com");
|
||||||
|
assert.equal(r.body.user.role, "analyst");
|
||||||
|
assert.equal(r.body.user.logins, 0, "a new password ends the user's logins");
|
||||||
|
const owners = db
|
||||||
|
.prepare("SELECT id, owner_id FROM lanes WHERE id IN (?, ?)")
|
||||||
|
.all(ids.free, ids.miaLane);
|
||||||
|
assert.deepEqual(Object.fromEntries(owners.map((o) => [o.id, o.owner_id])), {
|
||||||
|
[ids.free]: ids.mia,
|
||||||
|
[ids.miaLane]: null,
|
||||||
|
});
|
||||||
|
assert.ok(await login("mia", "mia-new-pass"));
|
||||||
|
const audit = db
|
||||||
|
.prepare("SELECT path FROM audit_log WHERE owner_id = ? ORDER BY id DESC LIMIT 1")
|
||||||
|
.get(ids.mia).path;
|
||||||
|
assert.match(audit, /display name, email, role analyst/);
|
||||||
|
assert.ok(!audit.includes("mia-new-pass"), "the audit never carries the password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing when one field is bad", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const before = db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo);
|
||||||
|
for (const [body, code] of [
|
||||||
|
[{ displayName: "Leo", email: "not-an-email" }, "EBADEMAIL"],
|
||||||
|
[{ displayName: "Leo", password: "" }, "EBADPASSWORD"],
|
||||||
|
[{ displayName: "Leo", role: "root" }, "EBADROLE"],
|
||||||
|
[{ displayName: "Leo", addWorkspaces: [99999] }, "ENOLANE"],
|
||||||
|
[{ displayName: "Leo", addWorkspaces: ["1"] }, "EBADFIELD"],
|
||||||
|
[{ displayName: "Leo", signOut: "yes" }, "EBADFIELD"],
|
||||||
|
// Never takes a workspace from a third user.
|
||||||
|
[{ displayName: "Leo", removeWorkspaces: [ids.free] }, "ENOTOWNED"],
|
||||||
|
]) {
|
||||||
|
const r = await call("PATCH", `/api/users/${ids.leo}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { ...body, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(r.body?.error?.code, code, JSON.stringify(body));
|
||||||
|
}
|
||||||
|
assert.deepEqual(db.prepare("SELECT * FROM users WHERE id = ?").get(ids.leo), before);
|
||||||
|
assert.equal(
|
||||||
|
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.free).owner_id,
|
||||||
|
ids.mia
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs the admin's own password, and keeps ESELF", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const noPass = await call("PATCH", `/api/users/${ids.leo}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { displayName: "x" },
|
||||||
|
});
|
||||||
|
assert.equal(noPass.body.error.code, "EREAUTH");
|
||||||
|
for (const body of [{ role: "developer" }, { disabled: true }]) {
|
||||||
|
const r = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { ...body, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(r.body.error.code, "ESELF");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs a user out everywhere, but an admin editing themselves keeps this login", async () => {
|
||||||
|
await login("leo", "member-pass-2");
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const other = await login("boss", BOSS_PASS);
|
||||||
|
const r = await call("PATCH", `/api/users/${ids.leo}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { signOut: true, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(r.body.user.logins, 0);
|
||||||
|
const self = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||||
|
cookie: boss,
|
||||||
|
body: { signOut: true, currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(self.status, 200);
|
||||||
|
assert.equal((await call("GET", "/api/me/account", { cookie: boss })).status, 200);
|
||||||
|
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("display names reach the workspace list and the audit log", () => {
|
||||||
|
it("carries owner_display_name beside owner_name, and audit display names", async () => {
|
||||||
|
const boss = await login("boss", BOSS_PASS);
|
||||||
|
const lanes = (await call("GET", "/api/lanes", { cookie: boss })).body.lanes;
|
||||||
|
const free = lanes.find((l) => l.id === ids.free);
|
||||||
|
assert.equal(free.owner_name, "mia");
|
||||||
|
assert.equal(free.owner_display_name, "Mia Tran");
|
||||||
|
const audit = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
|
||||||
|
const row = audit.find((a) => a.owner === "mia");
|
||||||
|
assert.equal(row.owner_display, "Mia Tran");
|
||||||
|
assert.equal(row.reader, "boss");
|
||||||
|
assert.equal(row.reader_display, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Settings → Account (/api/me)", () => {
|
||||||
|
it("shows the account, and edits its own display name and email", async () => {
|
||||||
|
const leo = await login("leo", "member-pass-2");
|
||||||
|
const g = await call("GET", "/api/me/account", { cookie: leo });
|
||||||
|
assert.equal(g.body.mode, "server");
|
||||||
|
assert.equal(g.body.user.username, "leo");
|
||||||
|
assert.equal(g.body.workspaces, 1);
|
||||||
|
assert.equal(g.body.logins, 1);
|
||||||
|
const p = await call("PATCH", "/api/me/account", {
|
||||||
|
cookie: leo,
|
||||||
|
body: { displayName: "Leo N", email: "" },
|
||||||
|
});
|
||||||
|
assert.equal(p.status, 200);
|
||||||
|
assert.equal(p.body.user.displayName, "Leo N");
|
||||||
|
assert.equal(p.body.user.email, null);
|
||||||
|
const bad = await call("PATCH", "/api/me/account", { cookie: leo, body: { email: "x@" } });
|
||||||
|
assert.equal(bad.body.error.code, "EBADEMAIL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes its own password only with the old one, keeping this login", async () => {
|
||||||
|
const leo = await login("leo", "member-pass-2");
|
||||||
|
const other = await login("leo", "member-pass-2");
|
||||||
|
const wrong = await call("POST", "/api/me/password", {
|
||||||
|
cookie: leo,
|
||||||
|
body: { currentPassword: "nope", newPassword: "leo-new-pass" },
|
||||||
|
});
|
||||||
|
assert.equal(wrong.body.error.code, "EREAUTH");
|
||||||
|
const ok = await call("POST", "/api/me/password", {
|
||||||
|
cookie: leo,
|
||||||
|
body: { currentPassword: "member-pass-2", newPassword: "leo-new-pass" },
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal((await call("GET", "/api/me/account", { cookie: leo })).status, 200);
|
||||||
|
assert.equal((await call("GET", "/api/me/account", { cookie: other })).status, 401);
|
||||||
|
assert.ok(await login("leo", "leo-new-pass"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs out other devices", async () => {
|
||||||
|
const a = await login("leo", "leo-new-pass");
|
||||||
|
const b = await login("leo", "leo-new-pass");
|
||||||
|
const r = await call("POST", "/api/me/sign-out-others", { cookie: a });
|
||||||
|
assert.ok(r.body.ended >= 1);
|
||||||
|
assert.equal((await call("GET", "/api/me/account", { cookie: a })).status, 200);
|
||||||
|
assert.equal((await call("GET", "/api/me/account", { cookie: b })).status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a pane token: an agent may not change its owner's login", async () => {
|
||||||
|
const pane = auth.newPaneToken();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO dashboard_runs (id, mode, cwd, status, lane_id, pane_token_hash)
|
||||||
|
VALUES (?, 'interactive', '/w/leo', 'running', ?, ?)`
|
||||||
|
).run(`agenthub-lane-${ids.leoLane}`, ids.leoLane, pane.hash);
|
||||||
|
const r = await call("POST", "/api/me/sign-out-others", {
|
||||||
|
headers: { [auth.PANE_HEADER]: pane.token },
|
||||||
|
});
|
||||||
|
assert.notEqual(r.status, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* @file server-s3-onboarded.test.js
|
||||||
|
* @description S3: a member's Claude pane on a pool account (or an AI Proxy)
|
||||||
|
* must not stop at the first-run "Select login method" screen a fresh home
|
||||||
|
* shows even with CLAUDE_CODE_OAUTH_TOKEN set — `run-as.markClaudeOnboarded`
|
||||||
|
* adds `hasCompletedOnboarding: true` to the member's `~/.claude.json`, AS the
|
||||||
|
* member (write-as, played by fixtures/fake-sudo.js), keeping every other key
|
||||||
|
* and leaving a file it cannot trust alone.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, beforeEach, after } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-s3-onboarded-")));
|
||||||
|
const USERS = path.join(TMP, "users");
|
||||||
|
const HOME = path.join(USERS, "mia");
|
||||||
|
const FILE = path.join(HOME, ".claude.json");
|
||||||
|
process.env.AGENTHUB_SERVER_CONF = path.join(TMP, "server.conf");
|
||||||
|
process.env.AGENTHUB_PASSWD = path.join(TMP, "passwd");
|
||||||
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||||
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||||
|
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
|
||||||
|
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const linux = require("../lib/linux-accounts");
|
||||||
|
const runAs = require("../lib/run-as");
|
||||||
|
const { fakeSudo } = require("./fixtures/fake-sudo");
|
||||||
|
const sudo = fakeSudo();
|
||||||
|
let acct;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(HOME, { recursive: true });
|
||||||
|
// The member's uid is this test's own, so its files pass the owner check.
|
||||||
|
const uid = process.getuid();
|
||||||
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, `ah-mia:x:${uid}:${uid}::/:/bin/sh\n`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
process.env.AGENTHUB_SERVER_CONF,
|
||||||
|
`HOST_USER=h\nHOST_UID=1\nUSERS_ROOT=${USERS}\nPORT=4820\n`
|
||||||
|
);
|
||||||
|
linux.__resetConfCache();
|
||||||
|
runAs.__resetCache();
|
||||||
|
linux.__setRunImpl(() => "ok");
|
||||||
|
runAs.__setExecImpl(sudo.exec);
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
await auth.createUser("mia", "member-pass-1", "developer");
|
||||||
|
acct = runAs.accountForPath(HOME);
|
||||||
|
assert.ok(acct, "mia is a provisioned member");
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
delete process.env.AGENTHUB_MODE;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fs.rmSync(FILE, { force: true });
|
||||||
|
sudo.calls.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("markClaudeOnboarded", () => {
|
||||||
|
it("creates ~/.claude.json, 0600, as the member", () => {
|
||||||
|
assert.equal(runAs.markClaudeOnboarded(acct), true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), { hasCompletedOnboarding: true });
|
||||||
|
assert.equal(fs.statSync(FILE).mode & 0o777, 0o600);
|
||||||
|
assert.ok(sudo.calls.some((c) => c.includes(FILE) && c.includes("private")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds only the flag, keeping the member's own state", () => {
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ theme: "light", projects: { "/x": {} } }));
|
||||||
|
assert.equal(runAs.markClaudeOnboarded(acct), true);
|
||||||
|
assert.deepEqual(JSON.parse(fs.readFileSync(FILE, "utf8")), {
|
||||||
|
theme: "light",
|
||||||
|
projects: { "/x": {} },
|
||||||
|
hasCompletedOnboarding: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nothing when the member is already onboarded", () => {
|
||||||
|
fs.writeFileSync(FILE, JSON.stringify({ hasCompletedOnboarding: true }));
|
||||||
|
assert.equal(runAs.markClaudeOnboarded(acct), false);
|
||||||
|
assert.equal(sudo.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a file that is not JSON, or not a regular file, alone", () => {
|
||||||
|
fs.writeFileSync(FILE, "{ half-written");
|
||||||
|
assert.equal(runAs.markClaudeOnboarded(acct), false);
|
||||||
|
assert.equal(fs.readFileSync(FILE, "utf8"), "{ half-written");
|
||||||
|
fs.rmSync(FILE);
|
||||||
|
fs.mkdirSync(FILE);
|
||||||
|
assert.equal(runAs.markClaudeOnboarded(acct), false);
|
||||||
|
fs.rmdirSync(FILE);
|
||||||
|
assert.equal(sudo.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never writes through a link the member left at the name", () => {
|
||||||
|
const target = path.join(TMP, "elsewhere.json");
|
||||||
|
fs.writeFileSync(target, "{}");
|
||||||
|
fs.symlinkSync(target, FILE);
|
||||||
|
runAs.markClaudeOnboarded(acct);
|
||||||
|
assert.equal(fs.readFileSync(target, "utf8"), "{}");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -226,6 +226,39 @@ describe("a developer browses and lists branches only in their own folder", () =
|
|||||||
assert.equal(admin.status, 200);
|
assert.equal(admin.status, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("an admin starts in their own home, and opens the host's only with host=1", async () => {
|
||||||
|
// No Linux account: the host's home, and no Server folder tick.
|
||||||
|
const plain = await call("boss", "GET", "/api/lanes/browse");
|
||||||
|
assert.equal(plain.status, 200);
|
||||||
|
assert.equal(plain.body.path, os.homedir());
|
||||||
|
assert.equal(plain.body.hostHome, undefined);
|
||||||
|
// With one (ah-boss, home <USERS>/boss): their own home first.
|
||||||
|
const BOSS = path.join(USERS, "boss");
|
||||||
|
fs.mkdirSync(path.join(BOSS, "work"), { recursive: true });
|
||||||
|
const passwd = fs.readFileSync(process.env.AGENTHUB_PASSWD, "utf8");
|
||||||
|
const uid = process.getuid();
|
||||||
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd + `ah-boss:x:${uid}:${uid}::/:/bin/sh\n`);
|
||||||
|
runAs.__resetCache();
|
||||||
|
try {
|
||||||
|
const own = await call("boss", "GET", "/api/lanes/browse");
|
||||||
|
assert.equal(own.status, 200, JSON.stringify(own.body));
|
||||||
|
assert.equal(own.body.path, BOSS);
|
||||||
|
assert.equal(own.body.home, BOSS);
|
||||||
|
assert.equal(own.body.hostHome, os.homedir());
|
||||||
|
assert.ok(own.body.parent, "an admin is not held to their home");
|
||||||
|
const host = await call("boss", "GET", "/api/lanes/browse?host=1");
|
||||||
|
assert.equal(host.body.path, os.homedir());
|
||||||
|
assert.equal(host.body.home, os.homedir());
|
||||||
|
// A developer's host=1 changes nothing: still their own folder.
|
||||||
|
const mia = await call("mia", "GET", "/api/lanes/browse?host=1");
|
||||||
|
assert.equal(mia.body.path, MIA);
|
||||||
|
assert.equal(mia.body.hostHome, undefined);
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(process.env.AGENTHUB_PASSWD, passwd);
|
||||||
|
runAs.__resetCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("hidden=1 shows dot entries, never the logins at the top of their home", async () => {
|
it("hidden=1 shows dot entries, never the logins at the top of their home", async () => {
|
||||||
fs.mkdirSync(path.join(MIA, ".claude"), { recursive: true });
|
fs.mkdirSync(path.join(MIA, ".claude"), { recursive: true });
|
||||||
fs.mkdirSync(path.join(MIA, "proj", ".cfg"), { recursive: true });
|
fs.mkdirSync(path.join(MIA, "proj", ".cfg"), { recursive: true });
|
||||||
|
|||||||
@@ -1815,6 +1815,15 @@ try {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An account's own display name and email (the Users page's Edit user and
|
||||||
|
// Settings → Account). Additive; after the rebuild above, which predates them.
|
||||||
|
for (const col of ["display_name", "email"]) {
|
||||||
|
try {
|
||||||
|
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
|
||||||
|
} catch {
|
||||||
|
db.prepare(`ALTER TABLE users ADD COLUMN ${col} TEXT`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
migrationLock.exec("COMMIT");
|
migrationLock.exec("COMMIT");
|
||||||
migrationLock.close();
|
migrationLock.close();
|
||||||
|
|||||||
@@ -772,6 +772,18 @@ function startBackgroundServices() {
|
|||||||
}, HEAL_MS);
|
}, HEAL_MS);
|
||||||
if (healTimer.unref) healTimer.unref();
|
if (healTimer.unref) healTimer.unref();
|
||||||
}
|
}
|
||||||
|
// Claude pool logins (claude-pool.js, kind `login`): the server is the one
|
||||||
|
// holder that refreshes them, ahead of every member home it placed one in,
|
||||||
|
// and pushes the new pair there. Off with DASHBOARD_POOL_REFRESH_MS=0.
|
||||||
|
const POOL_REFRESH_MS = Number(process.env.DASHBOARD_POOL_REFRESH_MS ?? 60_000);
|
||||||
|
if (Number.isFinite(POOL_REFRESH_MS) && POOL_REFRESH_MS > 0) {
|
||||||
|
const poolTimer = setInterval(() => {
|
||||||
|
require("./lib/claude-pool")
|
||||||
|
.refreshLogins()
|
||||||
|
.catch((err) => console.warn("claude pool refresh failed:", err.message));
|
||||||
|
}, POOL_REFRESH_MS);
|
||||||
|
if (poolTimer.unref) poolTimer.unref();
|
||||||
|
}
|
||||||
// Another dashboard on the same database (hooks reach only one of them)
|
// Another dashboard on the same database (hooks reach only one of them)
|
||||||
// moving a lane: re-broadcast it here. One pragma per beat when nothing
|
// moving a lane: re-broadcast it here. One pragma per beat when nothing
|
||||||
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
|
// changed. Off with DASHBOARD_LANE_SYNC_MS=0.
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ function listAudit(limit = 100) {
|
|||||||
const n = Math.min(Math.max(Number(limit) || 100, 1), 1000);
|
const n = Math.min(Math.max(Number(limit) || 100, 1), 1000);
|
||||||
return sql(
|
return sql(
|
||||||
`SELECT a.id, a.at, a.method, a.path, a.session_id,
|
`SELECT a.id, a.at, a.method, a.path, a.session_id,
|
||||||
r.username AS reader, o.username AS owner
|
r.username AS reader, o.username AS owner,
|
||||||
|
r.display_name AS reader_display, o.display_name AS owner_display
|
||||||
FROM audit_log a
|
FROM audit_log a
|
||||||
LEFT JOIN users r ON r.id = a.user_id
|
LEFT JOIN users r ON r.id = a.user_id
|
||||||
LEFT JOIN users o ON o.id = a.owner_id
|
LEFT JOIN users o ON o.id = a.owner_id
|
||||||
|
|||||||
+105
-4
@@ -18,6 +18,10 @@
|
|||||||
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
* - Accounts are made from the host's shell (`agenthub users …`) or by an
|
||||||
* admin on the Users page (`server/routes/users.js`), where every change
|
* admin on the Users page (`server/routes/users.js`), where every change
|
||||||
* needs the admin's own password again (`confirmPassword`, throttled).
|
* needs the admin's own password again (`confirmPassword`, throttled).
|
||||||
|
* An account also carries an optional display name and email
|
||||||
|
* (`validateProfile`), which its owner edits in Settings → Account without
|
||||||
|
* that step-up; changing their OWN password there needs the old one and
|
||||||
|
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||||||
*
|
*
|
||||||
* This is login, not isolation: every member still sees every workspace and
|
* This is login, not isolation: every member still sees every workspace and
|
||||||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||||||
@@ -60,6 +64,8 @@ const ADMIN_WRITE_PREFIXES = [
|
|||||||
"/reports",
|
"/reports",
|
||||||
"/pets",
|
"/pets",
|
||||||
"/users",
|
"/users",
|
||||||
|
// The Claude/Codex account pool: tokens every member's panes run on.
|
||||||
|
"/accounts",
|
||||||
// Alert rules and budgets fire for every user's sessions.
|
// Alert rules and budgets fire for every user's sessions.
|
||||||
"/alerts/rules",
|
"/alerts/rules",
|
||||||
];
|
];
|
||||||
@@ -129,7 +135,57 @@ function validatePassword(password) {
|
|||||||
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
||||||
|
|
||||||
const publicUser = (u) =>
|
const publicUser = (u) =>
|
||||||
u && { id: u.id, username: u.username, role: u.role, disabled: Boolean(u.disabled) };
|
u && {
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
role: u.role,
|
||||||
|
disabled: Boolean(u.disabled),
|
||||||
|
displayName: u.display_name ?? null,
|
||||||
|
email: u.email ?? null,
|
||||||
|
createdAt: u.created_at ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_DISPLAY_NAME = 100;
|
||||||
|
const MAX_EMAIL = 254;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile fields present in `body`, normalized: trimmed, `""`/`null` ⇒
|
||||||
|
* null (clearing is legitimate). Fields absent from `body` are absent from the
|
||||||
|
* result, so a caller writes only what was sent. Throws EBADFIELD/EBADEMAIL.
|
||||||
|
*/
|
||||||
|
function validateProfile(body) {
|
||||||
|
const out = {};
|
||||||
|
if (body.displayName !== undefined) {
|
||||||
|
const v = body.displayName;
|
||||||
|
if (v !== null && typeof v !== "string") throw err("EBADFIELD", "displayName: a string");
|
||||||
|
const n = (v || "").trim();
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
if (n.length > MAX_DISPLAY_NAME || /[\u0000-\u001f\u007f]/.test(n)) {
|
||||||
|
throw err("EBADFIELD", `displayName: at most ${MAX_DISPLAY_NAME} printable characters`);
|
||||||
|
}
|
||||||
|
out.display_name = n || null;
|
||||||
|
}
|
||||||
|
if (body.email !== undefined) {
|
||||||
|
const v = body.email;
|
||||||
|
if (v !== null && typeof v !== "string") throw err("EBADEMAIL", "email: a string");
|
||||||
|
const e = (v || "").trim();
|
||||||
|
if (e && (e.length > MAX_EMAIL || !EMAIL_RE.test(e))) {
|
||||||
|
throw err("EBADEMAIL", "email: not a valid address");
|
||||||
|
}
|
||||||
|
out.email = e || null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write `validateProfile`'s result for one account. */
|
||||||
|
function setProfile(userId, fields) {
|
||||||
|
for (const [col, value] of Object.entries(fields)) {
|
||||||
|
// `col` is one of validateProfile's two fixed names, never request text.
|
||||||
|
if (col !== "display_name" && col !== "email") continue;
|
||||||
|
sql(`UPDATE users SET ${col} = ? WHERE id = ?`).run(value, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function listUsers() {
|
function listUsers() {
|
||||||
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
|
return sql("SELECT * FROM users ORDER BY id").all().map(publicUser);
|
||||||
@@ -166,12 +222,33 @@ function requireUser(username) {
|
|||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** New password; every login of that user ends. */
|
/** New password; every login of that user ends — but `keepToken`'s, when
|
||||||
async function setPassword(username, password) {
|
* the user changed their own password from that login. */
|
||||||
|
async function setPassword(username, password, { keepToken } = {}) {
|
||||||
const u = requireUser(username);
|
const u = requireUser(username);
|
||||||
validatePassword(password);
|
validatePassword(password);
|
||||||
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
||||||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
endSessions(u.id, keepToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
|
||||||
|
function endSessions(userId, exceptToken) {
|
||||||
|
if (exceptToken) {
|
||||||
|
return sql("DELETE FROM auth_sessions WHERE user_id = ? AND token_hash != ?").run(
|
||||||
|
userId,
|
||||||
|
sha256(exceptToken)
|
||||||
|
).changes;
|
||||||
|
}
|
||||||
|
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
|
||||||
|
function loginStats(userId) {
|
||||||
|
const r = sql(
|
||||||
|
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||||
|
WHERE user_id = ? AND expires_at > ?`
|
||||||
|
).get(userId, new Date().toISOString());
|
||||||
|
return { logins: r.n, lastLoginAt: r.last || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRole(username, role) {
|
function setRole(username, role) {
|
||||||
@@ -232,6 +309,23 @@ function assignWorkspace(laneId, username) {
|
|||||||
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
if (r.changes === 0) throw err("ENOLANE", `no workspace ${laneId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Take a workspace back from the user who owns it: it is left with no owner
|
||||||
|
* (only staff see it) until it is given to someone. ENOTOWNED when `username`
|
||||||
|
* does not own it — this never takes one from a third user. */
|
||||||
|
function unassignWorkspace(laneId, username) {
|
||||||
|
const u = requireUser(username);
|
||||||
|
const lane = sql("SELECT owner_id FROM lanes WHERE id = ?").get(Number(laneId));
|
||||||
|
if (!lane) throw err("ENOLANE", `no workspace ${laneId}`);
|
||||||
|
if (lane.owner_id !== u.id) {
|
||||||
|
throw err("ENOTOWNED", `workspace ${laneId} is not ${u.username}'s`);
|
||||||
|
}
|
||||||
|
sql("UPDATE lanes SET owner_id = NULL WHERE id = ? AND owner_id = ?").run(Number(laneId), u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The workspaces `userId` owns: `[{id, title, cwd}]`. */
|
||||||
|
const ownedWorkspaces = (userId) =>
|
||||||
|
sql("SELECT id, title, cwd FROM lanes WHERE owner_id = ? ORDER BY id").all(userId);
|
||||||
|
|
||||||
const hasAdmin = () =>
|
const hasAdmin = () =>
|
||||||
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
|
Boolean(sql("SELECT 1 FROM users WHERE role = 'admin' AND disabled = 0").get());
|
||||||
|
|
||||||
@@ -510,6 +604,13 @@ module.exports = {
|
|||||||
setRole,
|
setRole,
|
||||||
setDisabled,
|
setDisabled,
|
||||||
assignWorkspace,
|
assignWorkspace,
|
||||||
|
unassignWorkspace,
|
||||||
|
ownedWorkspaces,
|
||||||
|
validateProfile,
|
||||||
|
setProfile,
|
||||||
|
validatePassword,
|
||||||
|
endSessions,
|
||||||
|
loginStats,
|
||||||
hasAdmin,
|
hasAdmin,
|
||||||
userFromRequest,
|
userFromRequest,
|
||||||
login,
|
login,
|
||||||
|
|||||||
+20
-13
@@ -108,25 +108,31 @@ async function refreshProfile(name) {
|
|||||||
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
|
throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fresh = await exchangeRefresh(creds);
|
||||||
|
profiles.writeProfile(name, { ...stored, credentials: fresh });
|
||||||
|
return fresh.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One refresh-token exchange: `creds` (a `claudeAiOauth` block) with its new
|
||||||
|
* access token, refresh token and expiry. The CALLER must persist the result —
|
||||||
|
* Anthropic retires the refresh token it was handed (see `refreshProfile`).
|
||||||
|
*/
|
||||||
|
async function exchangeRefresh(creds) {
|
||||||
const data = await postJson(TOKEN_URL, {
|
const data = await postJson(TOKEN_URL, {
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: creds.refreshToken,
|
refresh_token: creds.refreshToken,
|
||||||
client_id: CLIENT_ID,
|
client_id: CLIENT_ID,
|
||||||
});
|
});
|
||||||
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
|
if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token");
|
||||||
|
return {
|
||||||
profiles.writeProfile(name, {
|
...creds,
|
||||||
...stored,
|
accessToken: data.access_token,
|
||||||
credentials: {
|
// Both are rotated by the exchange; keeping the old refresh token here
|
||||||
...creds,
|
// would pin the profile to a credential that no longer works.
|
||||||
accessToken: data.access_token,
|
refreshToken: data.refresh_token || creds.refreshToken,
|
||||||
// Both are rotated by the exchange; keeping the old refresh token here
|
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
|
||||||
// would pin the profile to a credential that no longer works.
|
};
|
||||||
refreshToken: data.refresh_token || creds.refreshToken,
|
|
||||||
expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return data.access_token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two windows the UI reports, flattened out of a response that also
|
/** The two windows the UI reports, flattened out of a response that also
|
||||||
@@ -245,6 +251,7 @@ module.exports = {
|
|||||||
fetchOwner,
|
fetchOwner,
|
||||||
liveOwner,
|
liveOwner,
|
||||||
refreshProfile,
|
refreshProfile,
|
||||||
|
exchangeRefresh,
|
||||||
CLIENT_ID,
|
CLIENT_ID,
|
||||||
USAGE_URL,
|
USAGE_URL,
|
||||||
TOKEN_URL,
|
TOKEN_URL,
|
||||||
|
|||||||
+323
-14
@@ -21,6 +21,28 @@
|
|||||||
* The quota is read with the account's own token in the background, at most
|
* The quota is read with the account's own token in the background, at most
|
||||||
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
* every `QUOTA_TTL_MS`; a token the usage endpoint refuses simply has no
|
||||||
* reading, and ranking falls back to spend.
|
* reading, and ranking falls back to spend.
|
||||||
|
*
|
||||||
|
* Two kinds of account:
|
||||||
|
* - `token`: a `claude setup-token` token, given to the pane as
|
||||||
|
* `CLAUDE_CODE_OAUTH_TOKEN` (any pane, host or member).
|
||||||
|
* - `login`: a real Claude login (`claudeAiOauth` + `oauthAccount`), moved
|
||||||
|
* in from a saved switch-user profile (cc-profiles.js) or pasted from a
|
||||||
|
* `.credentials.json`. Claude Code reads it from no env var, so — like a
|
||||||
|
* Codex ChatGPT login — it is written as `~/.claude/.credentials.json`
|
||||||
|
* into a MEMBER's own home (S3), as the member (write-as, 0600), just
|
||||||
|
* before the pane starts: never into a host pane's home (the host's own
|
||||||
|
* login — unless that login itself was moved in, `hostHolds`, and the
|
||||||
|
* host's home is then one more placement), never over a login AgentHub
|
||||||
|
* did not place, never a second
|
||||||
|
* account into a home where a live pane of that member runs on one.
|
||||||
|
* Its refresh token is SINGLE USE, so the server is the one that
|
||||||
|
* refreshes it (`refreshLogins`, `REFRESH_AHEAD_MS` before expiry) and
|
||||||
|
* pushes the new pair into every home it placed the login in — Claude
|
||||||
|
* Code re-reads its credentials before refreshing and takes tokens
|
||||||
|
* "another process landed". A home whose Claude refreshed first is copied
|
||||||
|
* back, but only after Anthropic confirms the token is the same account
|
||||||
|
* (`fetchOwner`): a member could otherwise plant a login every other
|
||||||
|
* member's panes would get.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -34,6 +56,8 @@ const TOKEN_LIFE_MS = 365 * 864e5;
|
|||||||
const QUOTA_TTL_MS = 5 * 60 * 1000;
|
const QUOTA_TTL_MS = 5 * 60 * 1000;
|
||||||
const QUOTA_FRESH_MS = 30 * 60 * 1000;
|
const QUOTA_FRESH_MS = 30 * 60 * 1000;
|
||||||
const BLOCK_PERCENT = 90;
|
const BLOCK_PERCENT = 90;
|
||||||
|
const REFRESH_AHEAD_MS = 30 * 60 * 1000;
|
||||||
|
const FUTURE_SLACK_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const err = (code, message) => Object.assign(new Error(message), { code });
|
const err = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
|
||||||
@@ -47,15 +71,33 @@ function validName(name) {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isOauth = (o) =>
|
||||||
|
Boolean(o) &&
|
||||||
|
typeof o.accessToken === "string" &&
|
||||||
|
o.accessToken.length > 0 &&
|
||||||
|
typeof o.refreshToken === "string" &&
|
||||||
|
o.refreshToken.length > 0;
|
||||||
|
|
||||||
function readAccount(name) {
|
function readAccount(name) {
|
||||||
try {
|
try {
|
||||||
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
const a = JSON.parse(fs.readFileSync(fileOf(name), "utf8"));
|
||||||
return typeof a.token === "string" ? a : null;
|
if (a.kind === "login") return isOauth(a.oauth) ? a : null;
|
||||||
|
return typeof a.token === "string" ? { ...a, kind: "token" } : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rewrite one record (0600, through a temp file). */
|
||||||
|
function save(record) {
|
||||||
|
const tmp = `${fileOf(record.name)}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, fileOf(record.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The secret the usage endpoint takes for `a`. */
|
||||||
|
const bearerOf = (a) => (a.kind === "login" ? a.oauth.accessToken : a.token);
|
||||||
|
|
||||||
function allAccounts() {
|
function allAccounts() {
|
||||||
let names = [];
|
let names = [];
|
||||||
try {
|
try {
|
||||||
@@ -71,8 +113,11 @@ function allAccounts() {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresAt = (a) => new Date(Date.parse(a.addedAt) + TOKEN_LIFE_MS).toISOString();
|
// A setup-token lives a year; a login lives as long as its refresh token works.
|
||||||
const expired = (a) => Date.parse(expiresAt(a)) <= Date.now();
|
const expiresAt = (a) =>
|
||||||
|
a.kind === "login" ? null : new Date(Date.parse(a.addedAt) + TOKEN_LIFE_MS).toISOString();
|
||||||
|
const expired = (a) =>
|
||||||
|
a.kind === "login" ? a.refreshError === "ESTALE" : Date.parse(expiresAt(a)) <= Date.now();
|
||||||
|
|
||||||
/** Add an account; refuses a name already in the pool. */
|
/** Add an account; refuses a name already in the pool. */
|
||||||
function addAccount(name, token) {
|
function addAccount(name, token) {
|
||||||
@@ -93,13 +138,200 @@ function addAccount(name, token) {
|
|||||||
return { name: n, addedAt: record.addedAt, expiresAt: expiresAt(record) };
|
return { name: n, addedAt: record.addedAt, expiresAt: expiresAt(record) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a real login (`claudeAiOauth`, plus the `oauthAccount`/`userID` Claude
|
||||||
|
* Code keeps in `.claude.json`); refuses a name, or an account, already in the
|
||||||
|
* pool. The caller hands over its copy: two holders of one refresh token log
|
||||||
|
* each other out, so a source that keeps using it must not be offered — with
|
||||||
|
* one exception, `hostHolds`: the login this machine's own Claude Code runs
|
||||||
|
* on, whose home then becomes one more placement (`HOST`) the server
|
||||||
|
* refreshes and pushes into like any member's.
|
||||||
|
*/
|
||||||
|
function addLoginAccount(name, { oauth, account = null, userID = null, hostHolds = false } = {}) {
|
||||||
|
const n = validName(name);
|
||||||
|
if (!isOauth(oauth)) {
|
||||||
|
throw err(
|
||||||
|
"EBADTOKEN",
|
||||||
|
"not a Claude login — it needs claudeAiOauth with an accessToken and a refreshToken"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (readAccount(n)) throw err("EEXISTS", `account ${n} is already in the pool`);
|
||||||
|
const uuid = account?.accountUuid;
|
||||||
|
const twin =
|
||||||
|
uuid && allAccounts().find((a) => a.kind === "login" && a.account?.accountUuid === uuid);
|
||||||
|
if (twin) throw err("EEXISTS", `that Claude account is already in the pool as ${twin.name}`);
|
||||||
|
fs.mkdirSync(poolDir(), { recursive: true, mode: 0o700 });
|
||||||
|
fs.chmodSync(poolDir(), 0o700);
|
||||||
|
const record = {
|
||||||
|
name: n,
|
||||||
|
kind: "login",
|
||||||
|
oauth,
|
||||||
|
account,
|
||||||
|
userID,
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
placed: hostHolds ? { [HOST]: oauth.refreshToken } : {},
|
||||||
|
};
|
||||||
|
fs.writeFileSync(fileOf(n), JSON.stringify(record, null, 2), { mode: 0o600, flag: "wx" });
|
||||||
|
quotaCache.delete(n);
|
||||||
|
return { name: n, kind: "login", addedAt: record.addedAt, email: account?.emailAddress ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
function removeAccount(name) {
|
function removeAccount(name) {
|
||||||
const n = validName(name);
|
const n = validName(name);
|
||||||
if (!readAccount(n)) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
const a = readAccount(n);
|
||||||
|
if (!a) throw err("ENOACCOUNT", `no account ${n} in the pool`);
|
||||||
|
// Take the login back out of every home it is still ours in.
|
||||||
|
if (a.kind === "login") {
|
||||||
|
for (const [member, acct] of placedHomes(a)) {
|
||||||
|
// The host keeps its own login: removing it would log this machine out.
|
||||||
|
if (acct.host || homeCreds(acct)?.refreshToken !== a.placed[member]) continue;
|
||||||
|
try {
|
||||||
|
runAs().writeAs(acct, credsPath(acct), "remove");
|
||||||
|
} catch {
|
||||||
|
/* best effort: the member can still /logout */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fs.rmSync(fileOf(n));
|
fs.rmSync(fileOf(n));
|
||||||
quotaCache.delete(n);
|
quotaCache.delete(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── A member's .credentials.json (kind `login`) ────────────────────────────
|
||||||
|
|
||||||
|
const runAs = () => require("./run-as");
|
||||||
|
/** The placement key of this machine's own Claude Code (`hostHolds`). */
|
||||||
|
const HOST = "@host";
|
||||||
|
const HOST_ACCT = { name: HOST, host: true };
|
||||||
|
const credsPath = (acct) =>
|
||||||
|
acct.host
|
||||||
|
? path.join(getClaudeHome(), ".credentials.json")
|
||||||
|
: path.join(acct.home, ".claude", ".credentials.json");
|
||||||
|
|
||||||
|
/** The whole `.credentials.json` of a placement (a member's read guarded), or null. */
|
||||||
|
function readCredsFile(acct) {
|
||||||
|
try {
|
||||||
|
const text = acct.host
|
||||||
|
? fs.readFileSync(credsPath(acct), "utf8")
|
||||||
|
: runAs().readFileSafe(credsPath(acct), "utf8", { max: 1 << 20 });
|
||||||
|
const c = JSON.parse(text);
|
||||||
|
return c && typeof c === "object" && !Array.isArray(c) ? c : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `claudeAiOauth` of a placement, or null. */
|
||||||
|
function homeCreds(acct) {
|
||||||
|
const c = readCredsFile(acct);
|
||||||
|
return c && typeof c.claudeAiOauth === "object" ? c.claudeAiOauth : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `[key, account]` for every home `a` is placed in that still runs as its member (or the host). */
|
||||||
|
function placedHomes(a) {
|
||||||
|
const out = [];
|
||||||
|
for (const member of Object.keys(a.placed || {})) {
|
||||||
|
if (member === HOST) {
|
||||||
|
out.push([HOST, HOST_ACCT]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const acct = runAs().accountForUser(member);
|
||||||
|
if (acct) out.push([member, acct]);
|
||||||
|
} catch {
|
||||||
|
/* a disabled member's home is left as it is */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether `member`'s credentials are still the pair AgentHub last wrote there for `a`. */
|
||||||
|
const ownsHome = (a, member, disk) =>
|
||||||
|
a.kind === "login" && Boolean(disk) && disk.refreshToken === a.placed?.[member];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write `a`'s login into a placement — a member's home as the member, the
|
||||||
|
* host's directly (0600, through a temp file) — keeping every other key of
|
||||||
|
* the file (`mcpOAuth`…), and remember the pair.
|
||||||
|
*/
|
||||||
|
function writeLogin(a, acct) {
|
||||||
|
const text = `${JSON.stringify({ ...(readCredsFile(acct) || {}), claudeAiOauth: a.oauth }, null, 2)}\n`;
|
||||||
|
if (acct.host) {
|
||||||
|
const tmp = `${credsPath(acct)}.agenthub-tmp`;
|
||||||
|
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, credsPath(acct));
|
||||||
|
} else {
|
||||||
|
runAs().mkdirAs(acct, path.dirname(credsPath(acct)));
|
||||||
|
runAs().writeAs(acct, credsPath(acct), "private", text);
|
||||||
|
}
|
||||||
|
return { ...a, placed: { ...(a.placed || {}), [acct.name]: a.oauth.refreshToken } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshing = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep every login alive, run on a beat: copy back a home's newer pair (only
|
||||||
|
* once Anthropic says it is the same account), refresh what expires within
|
||||||
|
* `REFRESH_AHEAD_MS` — here, the one holder that may spend the refresh
|
||||||
|
* token — and push the newest pair into every home it is placed in. A home
|
||||||
|
* whose credentials are no longer ours (the member logged in themselves, or
|
||||||
|
* out) is dropped from the placement and never written again.
|
||||||
|
*/
|
||||||
|
async function refreshLogins() {
|
||||||
|
const usage = require("./cc-usage");
|
||||||
|
for (const stale of allAccounts().filter((x) => x.kind === "login")) {
|
||||||
|
if (refreshing.has(stale.name)) continue;
|
||||||
|
refreshing.add(stale.name);
|
||||||
|
try {
|
||||||
|
let a = stale;
|
||||||
|
const homes = placedHomes(a);
|
||||||
|
// 1. A home whose own Claude refreshed first holds the only live pair.
|
||||||
|
for (const [member, acct] of homes) {
|
||||||
|
const disk = homeCreds(acct);
|
||||||
|
if (!disk || ownsHome(a, member, disk)) continue;
|
||||||
|
const newer =
|
||||||
|
isOauth(disk) &&
|
||||||
|
Number(disk.expiresAt) > Number(a.oauth.expiresAt) &&
|
||||||
|
Number(disk.expiresAt) <= Date.now() + 24 * 3600e3 + FUTURE_SLACK_MS;
|
||||||
|
const owner = newer ? await usage.fetchOwner(disk.accessToken) : null;
|
||||||
|
const placed = { ...a.placed };
|
||||||
|
if (owner && owner.uuid === a.account?.accountUuid) {
|
||||||
|
a = { ...a, oauth: { ...a.oauth, ...disk } };
|
||||||
|
placed[member] = disk.refreshToken;
|
||||||
|
} else {
|
||||||
|
delete placed[member]; // their own login now: never ours to write over
|
||||||
|
}
|
||||||
|
a = { ...a, placed };
|
||||||
|
save(a);
|
||||||
|
}
|
||||||
|
// 2. The server refreshes ahead of every home.
|
||||||
|
if (!(Number(a.oauth.expiresAt) > Date.now() + REFRESH_AHEAD_MS)) {
|
||||||
|
try {
|
||||||
|
a = { ...a, oauth: await usage.exchangeRefresh(a.oauth), refreshError: null };
|
||||||
|
} catch (e) {
|
||||||
|
a = { ...a, refreshError: e.code || "EUPSTREAM" };
|
||||||
|
}
|
||||||
|
save(a);
|
||||||
|
quotaCache.delete(a.name);
|
||||||
|
}
|
||||||
|
// 3. Every home still ours gets the newest pair.
|
||||||
|
for (const [member, acct] of placedHomes(a)) {
|
||||||
|
const disk = homeCreds(acct);
|
||||||
|
if (!ownsHome(a, member, disk) || disk.refreshToken === a.oauth.refreshToken) continue;
|
||||||
|
try {
|
||||||
|
a = writeLogin(a, acct);
|
||||||
|
save(a);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[claude-pool] ${a.name} → ${member}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[claude-pool] ${stale.name}: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
refreshing.delete(stale.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Quota, read in the background ──────────────────────────────────────────
|
// ── Quota, read in the background ──────────────────────────────────────────
|
||||||
|
|
||||||
/** name → { at, fiveHour?, sevenDay?, error? } */
|
/** name → { at, fiveHour?, sevenDay?, error? } */
|
||||||
@@ -117,7 +349,7 @@ async function refreshQuotas({ force = false } = {}) {
|
|||||||
}
|
}
|
||||||
inFlight.add(a.name);
|
inFlight.add(a.name);
|
||||||
try {
|
try {
|
||||||
const u = await fetchUsage(a.token);
|
const u = await fetchUsage(bearerOf(a));
|
||||||
quotaCache.set(a.name, { at: Date.now(), ...u });
|
quotaCache.set(a.name, { at: Date.now(), ...u });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keep the last good windows: a hiccup must not unblock a full account.
|
// Keep the last good windows: a hiccup must not unblock a full account.
|
||||||
@@ -203,6 +435,9 @@ function poolStatus() {
|
|||||||
const percent = quotaPercent(a.name);
|
const percent = quotaPercent(a.name);
|
||||||
return {
|
return {
|
||||||
name: a.name,
|
name: a.name,
|
||||||
|
kind: a.kind,
|
||||||
|
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
||||||
|
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
expiresAt: expiresAt(a),
|
expiresAt: expiresAt(a),
|
||||||
expired: expired(a),
|
expired: expired(a),
|
||||||
@@ -223,13 +458,44 @@ function poolStatus() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function liveInHome(name, member) {
|
||||||
* The account a new Claude pane should run on — `{name, token}` — or null when
|
const { db } = require("../db");
|
||||||
* the pool has no usable account. Synchronous (a spawn cannot wait on the
|
return db
|
||||||
* network): it ranks on the cached quota and starts a background re-read.
|
.prepare(
|
||||||
*/
|
`SELECT lane_id FROM dashboard_runs
|
||||||
function pickAccount() {
|
WHERE tool = 'claude' AND account = ? AND status IN ('running', 'spawning')
|
||||||
const usable = allAccounts().filter((a) => !expired(a));
|
AND lane_id IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all(name)
|
||||||
|
.some((r) => {
|
||||||
|
try {
|
||||||
|
return runAs().accountForLane(r.lane_id)?.name === member.name;
|
||||||
|
} catch {
|
||||||
|
return false; // a gone member's lane is not this (enabled) member's
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full record a new Claude pane should run on, or null. `member` = run-as account. */
|
||||||
|
function pick(member) {
|
||||||
|
let usable = allAccounts().filter((a) => !expired(a));
|
||||||
|
if (usable.length === 0) return null;
|
||||||
|
if (!member) {
|
||||||
|
// A host pane's home holds the host's own login.
|
||||||
|
usable = usable.filter((a) => a.kind === "token");
|
||||||
|
} else {
|
||||||
|
const disk = homeCreds(member);
|
||||||
|
const placed = disk && usable.find((a) => ownsHome(a, member.name, disk));
|
||||||
|
if (!placed && disk) {
|
||||||
|
// A login AgentHub did not place there is the member's own: keep it.
|
||||||
|
usable = usable.filter((a) => a.kind === "token");
|
||||||
|
} else {
|
||||||
|
// One .credentials.json per home: while a pane of theirs runs on a
|
||||||
|
// pool login, the next pane stays on that one.
|
||||||
|
const live = usable.find((a) => a.kind === "login" && liveInHome(a.name, member));
|
||||||
|
if (live) return live;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (usable.length === 0) return null;
|
if (usable.length === 0) return null;
|
||||||
refreshQuotas().catch(() => {});
|
refreshQuotas().catch(() => {});
|
||||||
const ranked = usable
|
const ranked = usable
|
||||||
@@ -251,17 +517,60 @@ function pickAccount() {
|
|||||||
}
|
}
|
||||||
return x.a.name.localeCompare(y.a.name);
|
return x.a.name.localeCompare(y.a.name);
|
||||||
});
|
});
|
||||||
const { name, token } = ranked[0].a;
|
return ranked[0].a;
|
||||||
return { name, token };
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account a new Claude pane should run on — `{name, kind, token?}` — or
|
||||||
|
* null when the pool has nothing this pane may use. Synchronous (a spawn
|
||||||
|
* cannot wait on the network): it ranks on the cached quota and starts a
|
||||||
|
* background re-read. A `login` is only ever offered for a member.
|
||||||
|
*/
|
||||||
|
function pickAccount(member = null) {
|
||||||
|
const a = pick(member);
|
||||||
|
if (!a) return null;
|
||||||
|
return a.kind === "login"
|
||||||
|
? { name: a.name, kind: "login" }
|
||||||
|
: { name: a.name, kind: "token", token: a.token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a new Claude pane needs from the pool, or null: `{name, env, place}`.
|
||||||
|
* `env` goes through tmux `-e` (a member's launch file); `place()` runs just
|
||||||
|
* before the pane starts and writes a login into the member's home.
|
||||||
|
*/
|
||||||
|
function forSpawn(member = null) {
|
||||||
|
const a = pick(member);
|
||||||
|
if (!a) return null;
|
||||||
|
if (a.kind === "token") {
|
||||||
|
return { name: a.name, env: { CLAUDE_CODE_OAUTH_TOKEN: a.token }, place: () => {} };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: a.name,
|
||||||
|
env: {},
|
||||||
|
place: () => {
|
||||||
|
const record = readAccount(a.name) || a;
|
||||||
|
save(writeLogin(record, member));
|
||||||
|
runAs().markClaudeOnboarded(member, {
|
||||||
|
...(record.account ? { oauthAccount: record.account } : {}),
|
||||||
|
...(record.userID ? { userID: record.userID } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BLOCK_PERCENT,
|
BLOCK_PERCENT,
|
||||||
|
REFRESH_AHEAD_MS,
|
||||||
addAccount,
|
addAccount,
|
||||||
|
addLoginAccount,
|
||||||
removeAccount,
|
removeAccount,
|
||||||
poolStatus,
|
poolStatus,
|
||||||
pickAccount,
|
pickAccount,
|
||||||
|
forSpawn,
|
||||||
refreshQuotas,
|
refreshQuotas,
|
||||||
|
refreshLogins,
|
||||||
|
__read: readAccount,
|
||||||
poolDir,
|
poolDir,
|
||||||
__quotaCache: quotaCache,
|
__quotaCache: quotaCache,
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-5
@@ -736,10 +736,12 @@ function inferredDisplayStage(lane) {
|
|||||||
return lane.detected_stage;
|
return lane.detected_stage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The owner's username — the staff sidebar groups workspaces by it. */
|
/** The owner's username and display name — the staff sidebar groups
|
||||||
function ownerName(id) {
|
* workspaces by owner and shows the display name when one is set. */
|
||||||
if (id == null) return null;
|
function ownerNames(id) {
|
||||||
return db.prepare("SELECT username FROM users WHERE id = ?").get(id)?.username ?? null;
|
if (id == null) return { owner_name: null, owner_display_name: null };
|
||||||
|
const u = db.prepare("SELECT username, display_name FROM users WHERE id = ?").get(id);
|
||||||
|
return { owner_name: u?.username ?? null, owner_display_name: u?.display_name ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function lanePayload(lane, ageSec = null) {
|
function lanePayload(lane, ageSec = null) {
|
||||||
@@ -755,7 +757,7 @@ function lanePayload(lane, ageSec = null) {
|
|||||||
delete row.instructions;
|
delete row.instructions;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
owner_name: ownerName(lane.owner_id),
|
...ownerNames(lane.owner_id),
|
||||||
pipeline_name: pipeline.name,
|
pipeline_name: pipeline.name,
|
||||||
pipeline_skill: pipeline.skill || "",
|
pipeline_skill: pipeline.skill || "",
|
||||||
pipeline_nodes:
|
pipeline_nodes:
|
||||||
|
|||||||
+19
-4
@@ -463,8 +463,12 @@ function spawnRun(args) {
|
|||||||
// A Claude session on the vendor's own API runs on the least-loaded pool
|
// A Claude session on the vendor's own API runs on the least-loaded pool
|
||||||
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
|
// account, if the admin added any (claude-pool.js); an AI Proxy brings its
|
||||||
// own key, and an empty pool leaves the machine's login in charge.
|
// own key, and an empty pool leaves the machine's login in charge.
|
||||||
|
// A real login (kind `login`) is written into a MEMBER's home by place();
|
||||||
|
// a setup-token goes in as CLAUDE_CODE_OAUTH_TOKEN.
|
||||||
const poolAccount =
|
const poolAccount =
|
||||||
entry.launcher === "claude" && !proxyProfile ? require("./claude-pool").pickAccount() : null;
|
entry.launcher === "claude" && !proxyProfile
|
||||||
|
? require("./claude-pool").forSpawn(require("./run-as").accountForLane(laneId))
|
||||||
|
: null;
|
||||||
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
// Codex likewise, from its own pool (codex-pool.js): an API key through
|
||||||
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
// `-e`, or a ChatGPT login written into the member's CODEX_HOME by place().
|
||||||
const codexPool =
|
const codexPool =
|
||||||
@@ -629,7 +633,7 @@ function spawnRun(args) {
|
|||||||
...proxySpawn.env,
|
...proxySpawn.env,
|
||||||
// The pool account's setup-token: outranks the machine's `/login` for
|
// The pool account's setup-token: outranks the machine's `/login` for
|
||||||
// this process only. Through tmux `-e`, never typed into the pane.
|
// this process only. Through tmux `-e`, never typed into the pane.
|
||||||
...(poolAccount ? { CLAUDE_CODE_OAUTH_TOKEN: poolAccount.token } : {}),
|
...(poolAccount ? poolAccount.env : {}),
|
||||||
...(codexPool ? codexPool.env : {}),
|
...(codexPool ? codexPool.env : {}),
|
||||||
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
// Server mode: the CLI, MCP server and statusline in this pane act as the
|
||||||
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
// workspace's owner with it (server/lib/auth.js userFromPaneToken).
|
||||||
@@ -659,8 +663,19 @@ function spawnRun(args) {
|
|||||||
if (timer.unref) timer.unref();
|
if (timer.unref) timer.unref();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!tmux.hasSession(id)) {
|
// What a pane needs in its account's home before it starts: a Codex pool
|
||||||
|
// login, and — for a member's Claude on a pool account or an AI Proxy — the
|
||||||
|
// onboarding flag, or a fresh home asks for a login the token makes moot.
|
||||||
|
const prepareHome = () => {
|
||||||
codexPool?.place();
|
codexPool?.place();
|
||||||
|
poolAccount?.place();
|
||||||
|
if (member && entry.launcher === "claude" && (poolAccount || proxyProfile)) {
|
||||||
|
require("./run-as").markClaudeOnboarded(member);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tmux.hasSession(id)) {
|
||||||
|
prepareHome();
|
||||||
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
|
tmux.newSession({ name: id, cwd, argv: paneArgv(paneCmd, cwd), env });
|
||||||
record();
|
record();
|
||||||
schedulePaste();
|
schedulePaste();
|
||||||
@@ -671,7 +686,7 @@ function spawnRun(args) {
|
|||||||
// `--resume` and an initial prompt would never be typed, while the API
|
// `--resume` and an initial prompt would never be typed, while the API
|
||||||
// still answered 200. Run the argv in the pane the user already sees
|
// still answered 200. Run the argv in the pane the user already sees
|
||||||
// instead of erroring or opening a second session.
|
// instead of erroring or opening a second session.
|
||||||
codexPool?.place();
|
prepareHome();
|
||||||
tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
|
tmux.sendCommand(id, paneArgv(paneCmd, cwd), env, cwd);
|
||||||
record();
|
record();
|
||||||
schedulePaste();
|
schedulePaste();
|
||||||
|
|||||||
@@ -299,6 +299,36 @@ function scratchFile(cwd, prefix) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a member's Claude Code as onboarded (`hasCompletedOnboarding: true` in
|
||||||
|
* `~/.claude.json`), so a pane that brings its own credentials — a pool
|
||||||
|
* account's `CLAUDE_CODE_OAUTH_TOKEN`, an AI Proxy — starts at the prompt
|
||||||
|
* instead of the first-run "Select login method" screen, which a fresh home
|
||||||
|
* shows even with the token set. Only that key — plus, for a pool login,
|
||||||
|
* `extra` (its `oauthAccount`/`userID`, so Claude Code names the account it
|
||||||
|
* runs as) — is added, AS the member (`write-as private`); a file that is not
|
||||||
|
* the member's own regular file or not JSON is left alone. Best effort: false
|
||||||
|
* when nothing was written.
|
||||||
|
*/
|
||||||
|
function markClaudeOnboarded(acct, extra = {}) {
|
||||||
|
const file = path.join(acct.home, ".claude.json");
|
||||||
|
let state = {};
|
||||||
|
try {
|
||||||
|
state = JSON.parse(readFileSafe(file, "utf8"));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== "ENOENT") return false;
|
||||||
|
}
|
||||||
|
if (!state || typeof state !== "object" || Array.isArray(state)) return false;
|
||||||
|
const next = { ...state, ...extra, hasCompletedOnboarding: true };
|
||||||
|
if (JSON.stringify(next) === JSON.stringify(state)) return false;
|
||||||
|
try {
|
||||||
|
writeAs(acct, file, "private", `${JSON.stringify(next, null, 2)}\n`);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A user's account by AgentHub username (S3, provisioned), or null. */
|
/** A user's account by AgentHub username (S3, provisioned), or null. */
|
||||||
function accountForUser(username) {
|
function accountForUser(username) {
|
||||||
const conf = linux.s3Enabled() ? linux.serverConf() : null;
|
const conf = linux.s3Enabled() ? linux.serverConf() : null;
|
||||||
@@ -541,6 +571,7 @@ module.exports = {
|
|||||||
memberNameForUid,
|
memberNameForUid,
|
||||||
sudoArgv,
|
sudoArgv,
|
||||||
memberPaneEnv,
|
memberPaneEnv,
|
||||||
|
markClaudeOnboarded,
|
||||||
MEMBER_PATH,
|
MEMBER_PATH,
|
||||||
writeFileAs,
|
writeFileAs,
|
||||||
scratchFile,
|
scratchFile,
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
* - POST /api/auth/login (Auth)
|
* - POST /api/auth/login (Auth)
|
||||||
* - POST /api/auth/logout (Auth)
|
* - POST /api/auth/logout (Auth)
|
||||||
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
|
* - GET /api/accounts (Auth) — the Claude and Codex account pools, no keys or tokens
|
||||||
|
* - POST /api/accounts, DELETE /api/accounts/{name} (Auth) — an admin adds/removes one
|
||||||
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
* - GET /api/users (Auth) — every account's usage (admin/analyst)
|
||||||
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
* - GET /api/users/audit (Auth) — staff reads of developers' sessions
|
||||||
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
* - GET/PUT /api/me/prefs, POST /api/me/onboarded (Auth) — the account's own UI preferences
|
||||||
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
* - GET/PUT /api/me/instructions (Auth) — the account's own agent instructions
|
||||||
|
* - GET/PATCH /api/me/account, POST /api/me/password, POST /api/me/sign-out-others
|
||||||
|
* (Auth) — Settings → Account: the account's own profile, password and logins
|
||||||
|
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form
|
||||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||||
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
* server/openapi-extra.js. See docs/SERVER-MODE.md.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -38,6 +42,21 @@ const schemas = {
|
|||||||
username: { type: "string" },
|
username: { type: "string" },
|
||||||
role: { type: "string", enum: ["admin", "analyst", "developer"] },
|
role: { type: "string", enum: ["admin", "analyst", "developer"] },
|
||||||
disabled: { type: "boolean" },
|
disabled: { type: "boolean" },
|
||||||
|
displayName: { type: "string", nullable: true },
|
||||||
|
email: { type: "string", nullable: true },
|
||||||
|
createdAt: { type: "string", nullable: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MyAccount: {
|
||||||
|
type: "object",
|
||||||
|
required: ["mode"],
|
||||||
|
properties: {
|
||||||
|
mode: { type: "string", enum: ["local", "server"] },
|
||||||
|
user: { $ref: "#/components/schemas/AuthUser" },
|
||||||
|
workspaces: { type: "integer" },
|
||||||
|
sessions: { type: "integer" },
|
||||||
|
logins: { type: "integer", description: "Unexpired logins of the account" },
|
||||||
|
lastLoginAt: { type: "string", nullable: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
AuthMe: {
|
AuthMe: {
|
||||||
@@ -88,6 +107,9 @@ const changeResponses = {
|
|||||||
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const accountBody = {
|
||||||
|
"application/json": { schema: { $ref: "#/components/schemas/MyAccount" } },
|
||||||
|
};
|
||||||
const prefsBody = {
|
const prefsBody = {
|
||||||
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
|
"application/json": { schema: { $ref: "#/components/schemas/UserPrefs" } },
|
||||||
};
|
};
|
||||||
@@ -182,6 +204,85 @@ const paths = {
|
|||||||
responses: { 200: { description: "Marked", content: prefsBody }, 401: err("EAUTH") },
|
responses: { 200: { description: "Marked", content: prefsBody }, 401: err("EAUTH") },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/me/account": {
|
||||||
|
get: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "getMyAccount",
|
||||||
|
summary: 'Settings → Account: the signed-in account (local mode: {mode: "local"})',
|
||||||
|
responses: {
|
||||||
|
200: { description: "Account", content: accountBody },
|
||||||
|
401: err("EAUTH"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
patch: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "updateMyAccount",
|
||||||
|
summary:
|
||||||
|
'The account\'s own display name and email (""/null clears). Login cookie only, audited',
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
displayName: { type: "string", nullable: true, maxLength: 100 },
|
||||||
|
email: { type: "string", nullable: true, maxLength: 254 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: "Updated", content: accountBody },
|
||||||
|
400: err("EBADFIELD / EBADEMAIL"),
|
||||||
|
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||||
|
403: err("ELOCALMODE"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/me/password": {
|
||||||
|
post: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "changeMyPassword",
|
||||||
|
summary:
|
||||||
|
"Change the account's own password: the current one required (throttled); this login stays, every other ends",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["currentPassword", "newPassword"],
|
||||||
|
properties: {
|
||||||
|
currentPassword: { type: "string" },
|
||||||
|
newPassword: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: "Changed ({ok: true})" },
|
||||||
|
400: err("EBADPASSWORD"),
|
||||||
|
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||||
|
403: err("EREAUTH (current password wrong) / ELOCALMODE"),
|
||||||
|
429: err("ETOOMANY — 10 wrong passwords in 15 minutes"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/me/sign-out-others": {
|
||||||
|
post: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "signOutMyOtherLogins",
|
||||||
|
summary: "End every login of the account but this one; answers {ended}",
|
||||||
|
responses: {
|
||||||
|
200: { description: "Ended" },
|
||||||
|
401: err("EAUTH — no login cookie (a pane token is refused)"),
|
||||||
|
403: err("ELOCALMODE"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/users": {
|
"/api/users": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
@@ -263,17 +364,38 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/users/{id}": {
|
"/api/users/{id}": {
|
||||||
|
get: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "getServerUser",
|
||||||
|
summary: "One account with its logins and the workspaces it owns (admin/analyst)",
|
||||||
|
parameters: [idParam],
|
||||||
|
responses: {
|
||||||
|
200: { description: "Account" },
|
||||||
|
403: err("EADMIN — a developer"),
|
||||||
|
404: err("ENOUSER"),
|
||||||
|
},
|
||||||
|
},
|
||||||
patch: {
|
patch: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
operationId: "updateServerUser",
|
operationId: "updateServerUser",
|
||||||
summary:
|
summary:
|
||||||
"Admin: change a role and/or disable (ending every login) or enable. Never your own (ESELF)",
|
"Admin (Edit user): display name, email, role, disabled, a new password, workspaces given or taken back, sign out everywhere — every field validated before any is written. Never your own role or disable (ESELF)",
|
||||||
parameters: [idParam],
|
parameters: [idParam],
|
||||||
requestBody: changeBody({
|
requestBody: changeBody({
|
||||||
|
displayName: { type: "string", nullable: true, maxLength: 100 },
|
||||||
|
email: { type: "string", nullable: true, maxLength: 254 },
|
||||||
role: { type: "string", enum: ["admin", "analyst", "developer"] },
|
role: { type: "string", enum: ["admin", "analyst", "developer"] },
|
||||||
disabled: { type: "boolean" },
|
disabled: { type: "boolean" },
|
||||||
|
password: { type: "string", minLength: 1 },
|
||||||
|
addWorkspaces: { type: "array", items: { type: "integer" } },
|
||||||
|
removeWorkspaces: { type: "array", items: { type: "integer" } },
|
||||||
|
signOut: { type: "boolean", enum: [true] },
|
||||||
}),
|
}),
|
||||||
responses: changeResponses,
|
responses: {
|
||||||
|
...changeResponses,
|
||||||
|
400: err("EBADPASSWORD / EBADROLE / EBADFIELD / EBADEMAIL"),
|
||||||
|
409: err("ESELF / ENOTOWNED / EOWNERFOLDER"),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/users/{id}/password": {
|
"/api/users/{id}/password": {
|
||||||
@@ -322,6 +444,8 @@ const paths = {
|
|||||||
session_id: { type: "string", nullable: true },
|
session_id: { type: "string", nullable: true },
|
||||||
reader: { type: "string", nullable: true },
|
reader: { type: "string", nullable: true },
|
||||||
owner: { type: "string", nullable: true },
|
owner: { type: "string", nullable: true },
|
||||||
|
reader_display: { type: "string", nullable: true },
|
||||||
|
owner_display: { type: "string", nullable: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -401,6 +525,89 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
post: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "addPoolAccount",
|
||||||
|
summary:
|
||||||
|
"Admin: add a Claude (saved login, pasted credentials or setup-token) or Codex (API key / auth.json text) account to the pool, with their own password again. The secret is never answered back",
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["tool", "currentPassword"],
|
||||||
|
properties: {
|
||||||
|
tool: { type: "string", enum: ["claude", "codex"] },
|
||||||
|
name: { type: "string", description: "Defaults to the profile's name" },
|
||||||
|
profile: {
|
||||||
|
type: "string",
|
||||||
|
description: "Claude: a saved switch-user profile, moved into the pool",
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
type: "string",
|
||||||
|
description: "Claude: the text of a .credentials.json",
|
||||||
|
},
|
||||||
|
token: {
|
||||||
|
type: "string",
|
||||||
|
description: "Claude: a setup-token; Codex: an API key or auth.json text",
|
||||||
|
},
|
||||||
|
currentPassword: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: { description: "`{account: {name, addedAt, …}}` — no token" },
|
||||||
|
400: err("EBADTOOL, EBADNAME, EBADTOKEN"),
|
||||||
|
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||||
|
409: err(
|
||||||
|
"EEXISTS — the name or account is taken; EACTIVE — the host's CLI uses that profile"
|
||||||
|
),
|
||||||
|
502: err("EUPSTREAM — Anthropic could not be reached"),
|
||||||
|
429: err("ETOOMANY — too many wrong passwords"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/accounts/profiles": {
|
||||||
|
get: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "listPoolProfiles",
|
||||||
|
summary: "Saved switch-user profiles an admin may move into the Claude pool (no token)",
|
||||||
|
responses: {
|
||||||
|
200: { description: "`{profiles: [{name, label, email, subscriptionType, active}]}`" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/accounts/{name}": {
|
||||||
|
delete: {
|
||||||
|
tags: ["Auth"],
|
||||||
|
operationId: "removePoolAccount",
|
||||||
|
summary: "Admin: remove an account from the pool, with their own password again",
|
||||||
|
parameters: [{ name: "name", in: "path", required: true, schema: { type: "string" } }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["tool", "currentPassword"],
|
||||||
|
properties: {
|
||||||
|
tool: { type: "string", enum: ["claude", "codex"] },
|
||||||
|
currentPassword: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: { description: "`{ok: true}`" },
|
||||||
|
403: err("EREAUTH — wrong password; EADMIN — not an admin; ELOCALMODE"),
|
||||||
|
404: err("ENOACCOUNT"),
|
||||||
|
429: err("ETOOMANY — too many wrong passwords"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"/api/auth/me": {
|
"/api/auth/me": {
|
||||||
get: {
|
get: {
|
||||||
|
|||||||
+187
-2
@@ -4,14 +4,31 @@
|
|||||||
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
|
* sees it (server/lib/claude-pool.js): per account its live panes, $ spent in
|
||||||
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
|
* the last 5 h / 7 d, the last quota reading and whether it counts as full;
|
||||||
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
|
* `codex` is the same for the Codex pool (server/lib/codex-pool.js). Never a
|
||||||
* key or a token. `?force=1` re-reads every quota first. Accounts are added and
|
* key or a token. `?force=1` re-reads every quota first.
|
||||||
* removed only from the host's shell (`agenthub accounts …`).
|
*
|
||||||
|
* Accounts are added from the host's shell (`agenthub accounts …`) or, in
|
||||||
|
* server mode, by an admin on the Users page: `POST /` (`{tool, name, …}`) and
|
||||||
|
* `DELETE /:name` (`{tool}`). A Claude account is one of: `profile` — a login
|
||||||
|
* saved by the switch-user profiles (cc-profiles.js), MOVED into the pool (its
|
||||||
|
* single-use refresh token may have only one holder, so the profile is
|
||||||
|
* deleted; the account the host's own CLI is logged into moves in with its
|
||||||
|
* live login and the host's home becomes one more placement the server
|
||||||
|
* refreshes); `credentials` — the text of a `.credentials.json` (or a macOS
|
||||||
|
* Keychain item's JSON); or `token` — a `claude setup-token` token. A Codex
|
||||||
|
* one is an OpenAI API key or the text of an `auth.json` (`token`).
|
||||||
|
* `GET /profiles` lists the saved profiles that can be moved in (identity only). Like every Users-page change
|
||||||
|
* they need the admin's own password again (`currentPassword`), are audited
|
||||||
|
* (the account NAME only), and refuse in local mode. The secret only goes IN:
|
||||||
|
* no route answers with it (`/accounts` is in ADMIN_WRITE_PREFIXES).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { Router } = require("express");
|
const { Router } = require("express");
|
||||||
const pool = require("../lib/claude-pool");
|
const pool = require("../lib/claude-pool");
|
||||||
const codexPool = require("../lib/codex-pool");
|
const codexPool = require("../lib/codex-pool");
|
||||||
|
const auth = require("../lib/auth");
|
||||||
|
const { recordAccountChange } = require("../lib/audit");
|
||||||
|
const profiles = require("../lib/cc-profiles");
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -29,4 +46,172 @@ router.get("/", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Saved switch-user profiles an admin may move into the pool — never a token. */
|
||||||
|
router.get("/profiles", (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
profiles: profiles.listProfiles().items.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
label: p.label,
|
||||||
|
email: p.email,
|
||||||
|
subscriptionType: p.subscriptionType,
|
||||||
|
active: p.active,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: { code: e.code || "EPROFILES", message: e.message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
ENOTFOUND: 404,
|
||||||
|
EACTIVE: 409,
|
||||||
|
ELOCALMODE: 403,
|
||||||
|
EREAUTH: 403,
|
||||||
|
EBADTOOL: 400,
|
||||||
|
EBADNAME: 400,
|
||||||
|
EBADTOKEN: 400,
|
||||||
|
ENOACCOUNT: 404,
|
||||||
|
EEXISTS: 409,
|
||||||
|
ETOOMANY: 429,
|
||||||
|
ESTALE: 409, // a pasted login whose refresh token was already spent
|
||||||
|
EUPSTREAM: 502,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One pool change: server mode, the admin's password confirmed, audited. */
|
||||||
|
function poolChange(handler) {
|
||||||
|
return async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!auth.isServerMode() || !req.user) {
|
||||||
|
throw Object.assign(new Error("the pool is changed from the host's shell in local mode"), {
|
||||||
|
code: "ELOCALMODE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||||
|
if (body.tool !== "claude" && body.tool !== "codex") {
|
||||||
|
throw Object.assign(new Error("tool: claude or codex"), { code: "EBADTOOL" });
|
||||||
|
}
|
||||||
|
await auth.confirmPassword(req.user, body.currentPassword);
|
||||||
|
const out = await handler(req, body);
|
||||||
|
recordAccountChange(req.user.id, null, req.method, out.action);
|
||||||
|
res.status(out.status || 200).json(out.json);
|
||||||
|
} catch (e) {
|
||||||
|
const status = STATUS[e.code];
|
||||||
|
if (!status) return res.status(500).json({ error: { message: e.message } });
|
||||||
|
res.status(status).json({ error: { code: e.code, message: e.message } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pasted `.credentials.json` (or Keychain item) → `{oauth, account}`. The
|
||||||
|
* file names no account, so Anthropic is asked whose token it is (refreshing
|
||||||
|
* an expired one first — what the exchange returns is what gets stored):
|
||||||
|
* the pool copies a member's refresh back only when it is that same account.
|
||||||
|
*/
|
||||||
|
async function pastedLogin(text) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(String(text || ""));
|
||||||
|
} catch {
|
||||||
|
throw Object.assign(new Error("not valid JSON — paste the whole .credentials.json"), {
|
||||||
|
code: "EBADTOKEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let oauth = parsed?.claudeAiOauth || parsed;
|
||||||
|
if (!oauth?.accessToken || !oauth?.refreshToken) return { oauth }; // addLoginAccount says why
|
||||||
|
const usage = require("../lib/cc-usage");
|
||||||
|
let owner = await usage.fetchOwner(oauth.accessToken);
|
||||||
|
if (!owner) {
|
||||||
|
oauth = await usage.exchangeRefresh(oauth);
|
||||||
|
owner = await usage.fetchOwner(oauth.accessToken);
|
||||||
|
}
|
||||||
|
if (!owner) {
|
||||||
|
throw Object.assign(new Error("Anthropic did not accept this login"), { code: "EBADTOKEN" });
|
||||||
|
}
|
||||||
|
return { oauth, account: { accountUuid: owner.uuid, emailAddress: owner.email } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a saved profile into the pool: the pool becomes its one holder. The
|
||||||
|
* account this machine's own Claude Code is logged into moves in with the
|
||||||
|
* LIVE login (the saved copy's refresh token is spent once the CLI refreshed)
|
||||||
|
* and keeps running here: the host's home becomes one more placement the
|
||||||
|
* server refreshes and pushes into. A macOS Keychain login cannot be written
|
||||||
|
* that way (EACTIVE).
|
||||||
|
*/
|
||||||
|
function fromProfile(name, poolName) {
|
||||||
|
const stored = profiles.readProfile(name);
|
||||||
|
if (!stored) throw Object.assign(new Error(`no saved profile ${name}`), { code: "ENOTFOUND" });
|
||||||
|
const active = profiles.listProfiles().items.find((p) => p.name === name)?.active;
|
||||||
|
let oauth = stored.credentials;
|
||||||
|
if (active) {
|
||||||
|
const live = profiles.readActiveAuth();
|
||||||
|
if (live.keychain || !live.oauth) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(
|
||||||
|
"this machine's Claude Code keeps that login in the macOS Keychain — switch it to another profile first"
|
||||||
|
),
|
||||||
|
{ code: "EACTIVE" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
oauth = live.oauth;
|
||||||
|
}
|
||||||
|
const account = pool.addLoginAccount(poolName || name, {
|
||||||
|
oauth,
|
||||||
|
account: stored.account || null,
|
||||||
|
userID: stored.userID || null,
|
||||||
|
hostHolds: Boolean(active),
|
||||||
|
});
|
||||||
|
// The only saved copy of the live account is not deletable (EACTIVE) — and
|
||||||
|
// need not be: removing the file is enough, the pool holds the login now.
|
||||||
|
require("node:fs").rmSync(require("node:path").join(profiles.profilesDir(), `${name}.json`), {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Codex takes an API key, or the text of an auth.json (`codex login`'s). */
|
||||||
|
function codexSecret(token) {
|
||||||
|
const text = String(token || "").trim();
|
||||||
|
if (!text.startsWith("{")) return { apiKey: text };
|
||||||
|
try {
|
||||||
|
return { auth: JSON.parse(text) };
|
||||||
|
} catch {
|
||||||
|
throw Object.assign(new Error("not valid JSON — paste the whole auth.json"), {
|
||||||
|
code: "EBADTOKEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
poolChange(async (_req, body) => {
|
||||||
|
const account =
|
||||||
|
body.tool === "codex"
|
||||||
|
? codexPool.addAccount(body.name, codexSecret(body.token))
|
||||||
|
: body.profile
|
||||||
|
? fromProfile(String(body.profile), body.name)
|
||||||
|
: body.credentials
|
||||||
|
? pool.addLoginAccount(body.name, await pastedLogin(body.credentials))
|
||||||
|
: pool.addAccount(body.name, body.token);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
json: { account },
|
||||||
|
action: `pool: add ${body.tool} account ${account.name}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/:name",
|
||||||
|
poolChange((req, body) => {
|
||||||
|
(body.tool === "claude" ? pool : codexPool).removeAccount(req.params.name);
|
||||||
|
return {
|
||||||
|
json: { ok: true },
|
||||||
|
action: `pool: remove ${body.tool} account ${req.params.name}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -22,14 +22,20 @@
|
|||||||
* `GET /:name` — a route that returned one profile in full would be the one
|
* `GET /:name` — a route that returned one profile in full would be the one
|
||||||
* way an API key could leave this process.
|
* way an API key could leave this process.
|
||||||
*
|
*
|
||||||
|
* `GET /9router` only says whether a `9router` binary is on the server's PATH,
|
||||||
|
* so the page can offer a link to its own config UI; it never runs it.
|
||||||
|
*
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const path = require("node:path");
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
|
||||||
const proxies = require("../lib/ai-proxies");
|
const proxies = require("../lib/ai-proxies");
|
||||||
const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply");
|
const { refreshProfileFiles, removeProfileFiles } = require("../lib/proxy-apply");
|
||||||
const { sameOriginGuard } = require("../lib/same-origin");
|
const { sameOriginGuard } = require("../lib/same-origin");
|
||||||
|
const { which } = require("../lib/ai-tools");
|
||||||
|
const { shellPath } = require("../lib/shell-path");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -74,6 +80,13 @@ router.get("/", (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/ai-proxies/9router — is 9router installed here? `{ installed }`.
|
||||||
|
// A file lookup on PATH only; nothing is spawned.
|
||||||
|
router.get("/9router", (_req, res) => {
|
||||||
|
const pathStr = [shellPath(), process.env.PATH].filter(Boolean).join(path.delimiter);
|
||||||
|
res.json({ installed: Boolean(which("9router", pathStr)) });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
|
// POST /api/ai-proxies — body: { label, baseUrl, apiKey?, model, models?,
|
||||||
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
|
// behavesAs?, scope? }. `scope` is "shared" (an admin's default) or "personal"
|
||||||
// (a member's only choice); local mode has no user and always writes shared.
|
// (a member's only choice); local mode has no user and always writes shared.
|
||||||
|
|||||||
+22
-2
@@ -569,6 +569,10 @@ router.post("/gc", sameOriginGuard, (req, res) => {
|
|||||||
* `hidden=1` lists dot entries too — never those at the top of a non-admin's
|
* `hidden=1` lists dot entries too — never those at the top of a non-admin's
|
||||||
* own folder, where a member's logins live. Each entry carries its `mtime`
|
* own folder, where a member's logins live. Each entry carries its `mtime`
|
||||||
* (ms), and `home` is the folder the browser's Home place opens.
|
* (ms), and `home` is the folder the browser's Home place opens.
|
||||||
|
* An admin with a Linux account of their own (S3) starts in THEIR home, like
|
||||||
|
* any member, and the answer carries `hostHome` (the host account's home);
|
||||||
|
* `host=1` opens that one instead — a workspace there runs as the shared
|
||||||
|
* host account, so it is a deliberate tick in the dialog, never the default.
|
||||||
*/
|
*/
|
||||||
router.get("/browse", (req, res) => {
|
router.get("/browse", (req, res) => {
|
||||||
// A developer browses only their own folder (by real path: a link in it
|
// A developer browses only their own folder (by real path: a link in it
|
||||||
@@ -577,12 +581,22 @@ router.get("/browse", (req, res) => {
|
|||||||
const own = scope.ownFolder(req);
|
const own = scope.ownFolder(req);
|
||||||
const files = req.query.files === "1";
|
const files = req.query.files === "1";
|
||||||
const hidden = req.query.hidden === "1";
|
const hidden = req.query.hidden === "1";
|
||||||
|
// An admin is not held to a folder, but starts in their own home (S3).
|
||||||
|
let adminHome = null;
|
||||||
|
if (!own && req.user?.role === "admin") {
|
||||||
|
try {
|
||||||
|
adminHome = require("../lib/run-as").accountForUser(req.user.username)?.home || null;
|
||||||
|
} catch {
|
||||||
|
// No Linux account for this admin (EMEMBERGONE): the host's home, as before.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const startHome = adminHome && req.query.host !== "1" ? adminHome : own || os.homedir();
|
||||||
const raw =
|
const raw =
|
||||||
typeof req.query.path === "string" && req.query.path.trim()
|
typeof req.query.path === "string" && req.query.path.trim()
|
||||||
? files
|
? files
|
||||||
? startDir(req.query.path)
|
? startDir(req.query.path)
|
||||||
: req.query.path
|
: req.query.path
|
||||||
: own || os.homedir();
|
: startHome;
|
||||||
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
|
const resolved = own ? lanesLib.canonicalCwd(path.resolve(raw)) : path.resolve(raw);
|
||||||
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
|
if (own && !scope.folderAllowed(req, resolved, { allowHome: true })) return refuseFolder(res);
|
||||||
|
|
||||||
@@ -634,7 +648,13 @@ router.get("/browse", (req, res) => {
|
|||||||
|
|
||||||
const top = path.dirname(resolved) === resolved || atOwnTop;
|
const top = path.dirname(resolved) === resolved || atOwnTop;
|
||||||
const parent = top ? null : path.dirname(resolved);
|
const parent = top ? null : path.dirname(resolved);
|
||||||
res.json({ path: resolved, parent, entries, home: own || os.homedir() });
|
res.json({
|
||||||
|
path: resolved,
|
||||||
|
parent,
|
||||||
|
entries,
|
||||||
|
home: startHome,
|
||||||
|
...(adminHome ? { hostHome: os.homedir() } : {}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
|
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
|
||||||
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
|
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
|
||||||
*
|
*
|
||||||
|
* `/account` (server mode) is the signed-in account itself for Settings →
|
||||||
|
* Account: GET its profile, workspace/session counts and logins; PATCH its
|
||||||
|
* display name and email; `POST /password` changes its own password (the old
|
||||||
|
* one required, throttled like a step-up; every OTHER login ends);
|
||||||
|
* `POST /sign-out-others` ends every login but this one. These writes take the
|
||||||
|
* account from the login COOKIE only — a pane token (an agent acting as its
|
||||||
|
* owner) may not change the owner's login. Each change is audited.
|
||||||
|
*
|
||||||
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
* S3 (one Linux user per account): an account's dark/light choice also becomes
|
||||||
* the theme in their own Claude Code settings, written as them.
|
* the theme in their own Claude Code settings, written as them.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -142,6 +150,90 @@ router.post("/onboarded", sameOriginGuard, (req, res) => {
|
|||||||
res.json(read(key));
|
res.json(read(key));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── The account itself (server mode) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const { sessionWhere } = require("../lib/scope");
|
||||||
|
const { recordAccountChange } = require("../lib/audit");
|
||||||
|
|
||||||
|
const ACCOUNT_STATUS = {
|
||||||
|
EBADFIELD: 400,
|
||||||
|
EBADEMAIL: 400,
|
||||||
|
EBADPASSWORD: 400,
|
||||||
|
EREAUTH: 403,
|
||||||
|
ETOOMANY: 429,
|
||||||
|
};
|
||||||
|
|
||||||
|
function accountView(userId) {
|
||||||
|
const row = db.prepare("SELECT * FROM users WHERE id = ?").get(userId);
|
||||||
|
const w = sessionWhere({ user: { id: userId, role: "developer" } });
|
||||||
|
return {
|
||||||
|
mode: "server",
|
||||||
|
user: auth.publicUser(row),
|
||||||
|
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(userId).n,
|
||||||
|
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params).n,
|
||||||
|
...auth.loginStats(userId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A write to the account: the cookie's own user, never a pane token's. */
|
||||||
|
function accountWrite(handler) {
|
||||||
|
return async (req, res) => {
|
||||||
|
if (!auth.isServerMode()) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: { code: "ELOCALMODE", message: "no account in local mode" } });
|
||||||
|
}
|
||||||
|
const user = auth.userFromRequest(req);
|
||||||
|
if (!user) return unauth(res);
|
||||||
|
try {
|
||||||
|
const body = req.body && typeof req.body === "object" ? req.body : {};
|
||||||
|
const { out, action } = await handler(req, user, body);
|
||||||
|
recordAccountChange(user.id, user.id, req.method, `${user.username}: ${action}`);
|
||||||
|
res.json(out);
|
||||||
|
} catch (err) {
|
||||||
|
const status = ACCOUNT_STATUS[err.code];
|
||||||
|
if (!status) return res.status(500).json({ error: { message: err.message } });
|
||||||
|
res.status(status).json({ error: { code: err.code, message: err.message } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/account", (req, res) => {
|
||||||
|
if (!auth.isServerMode()) return res.json({ mode: "local" });
|
||||||
|
const user = req.user || auth.userFromRequest(req);
|
||||||
|
if (!user) return unauth(res);
|
||||||
|
res.json(accountView(user.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
"/account",
|
||||||
|
sameOriginGuard,
|
||||||
|
accountWrite(async (_req, user, body) => {
|
||||||
|
const fields = auth.validateProfile({ displayName: body.displayName, email: body.email });
|
||||||
|
auth.setProfile(user.id, fields);
|
||||||
|
return { out: accountView(user.id), action: "own profile" };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/password",
|
||||||
|
sameOriginGuard,
|
||||||
|
accountWrite(async (req, user, body) => {
|
||||||
|
await auth.confirmPassword(user, body.currentPassword);
|
||||||
|
await auth.setPassword(user.username, body.newPassword, { keepToken: auth.cookieToken(req) });
|
||||||
|
return { out: { ok: true }, action: "own password changed" };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/sign-out-others",
|
||||||
|
sameOriginGuard,
|
||||||
|
accountWrite(async (req, user) => {
|
||||||
|
const ended = auth.endSessions(user.id, auth.cookieToken(req));
|
||||||
|
return { out: { ended }, action: `signed out ${ended} other login(s)` };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
|
/** S3: an account's dark/light mode becomes their Claude Code theme (best effort). */
|
||||||
function followClaudeTheme(req, mode) {
|
function followClaudeTheme(req, mode) {
|
||||||
if (mode !== "dark" && mode !== "light") return;
|
if (mode !== "dark" && mode !== "light") return;
|
||||||
|
|||||||
@@ -543,6 +543,71 @@ router.get("/:id/image", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit
|
||||||
|
// named (`?path=`, exact), for the Chat pane's Outputs/Files preview when it
|
||||||
|
// lies outside the workspace (a report written to ~/Documents, issue #76).
|
||||||
|
// The same authorization as `/image` — that event, never a pushed session,
|
||||||
|
// server mode only with S3 and a member's session only inside their own home
|
||||||
|
// on a file they own — plus: never a credential by name
|
||||||
|
// (`isSecretConfigPath`, a `settings.local.json`), never a binary or a
|
||||||
|
// non-regular file, at most READ_TEXT_MAX bytes (the rest is `truncated`).
|
||||||
|
const READ_TEXT_MAX = 512 * 1024;
|
||||||
|
const sessionTouchedFile = db.prepare(
|
||||||
|
`SELECT 1 FROM events
|
||||||
|
WHERE session_id = ? AND event_type = 'PostToolUse'
|
||||||
|
AND tool_name IN ('Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit')
|
||||||
|
AND (json_extract(data, '$.tool_input.file_path') = ?
|
||||||
|
OR json_extract(data, '$.tool_input.notebook_path') = ?)
|
||||||
|
LIMIT 1`
|
||||||
|
);
|
||||||
|
router.get("/:id/file", async (req, res) => {
|
||||||
|
const fail = (status, code, message) => res.status(status).json({ error: { code, message } });
|
||||||
|
const session = stmts.getSession.get(req.params.id);
|
||||||
|
if (!session) return fail(404, "NOT_FOUND", "Session not found");
|
||||||
|
const s3 = require("../lib/linux-accounts").s3Enabled();
|
||||||
|
if ((isServerMode() && !s3) || String(session.source || "").startsWith("push:")) {
|
||||||
|
return fail(403, "EUNSUPPORTED", "not available for this session");
|
||||||
|
}
|
||||||
|
const file = typeof req.query.path === "string" ? req.query.path : "";
|
||||||
|
if (!path.isAbsolute(file)) return fail(400, "EBADPATH", "not an absolute path");
|
||||||
|
const { isSecretConfigPath } = require("../lib/cc-discovery");
|
||||||
|
const secret = (p) =>
|
||||||
|
isSecretConfigPath(p) || path.basename(p).toLowerCase() === "settings.local.json";
|
||||||
|
if (secret(file)) return fail(403, "ESECRET", "a credential file is never served");
|
||||||
|
const runAs = require("../lib/run-as");
|
||||||
|
const member = s3 ? runAs.ownerForRead(session.cwd) : null;
|
||||||
|
if (member && !require("../lib/hook-auth").inside(member.home, file)) {
|
||||||
|
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||||
|
}
|
||||||
|
if (!sessionTouchedFile.get(session.id, file, file)) {
|
||||||
|
return fail(404, "ENOTFOUND", "the session never touched that file");
|
||||||
|
}
|
||||||
|
let fh;
|
||||||
|
try {
|
||||||
|
// By what it RESOLVES to as well: a link named `notes.md` to a key file.
|
||||||
|
if (secret(await fs.promises.realpath(file))) {
|
||||||
|
return fail(403, "ESECRET", "a credential file is never served");
|
||||||
|
}
|
||||||
|
// NONBLOCK: a FIFO must not hang the event loop.
|
||||||
|
fh = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
||||||
|
const st = await fh.stat();
|
||||||
|
const fileOwner = s3 ? runAs.ownerForRead(file) : null;
|
||||||
|
if (!st.isFile() || (fileOwner && st.uid !== fileOwner.uid)) {
|
||||||
|
return fail(404, "ENOTFOUND", "no such file");
|
||||||
|
}
|
||||||
|
const buf = Buffer.alloc(Math.min(st.size, READ_TEXT_MAX));
|
||||||
|
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
||||||
|
const head = buf.subarray(0, bytesRead);
|
||||||
|
if (head.subarray(0, 8000).includes(0)) return fail(415, "EBINARY", "not a text file");
|
||||||
|
res.set("Cache-Control", "no-store");
|
||||||
|
res.json({ text: head.toString("utf8"), truncated: st.size > READ_TEXT_MAX });
|
||||||
|
} catch {
|
||||||
|
if (!res.headersSent) fail(404, "ENOTFOUND", "no such file");
|
||||||
|
} finally {
|
||||||
|
await fh?.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** The `/:id/stats` body — also frozen into a session snapshot. */
|
/** The `/:id/stats` body — also frozen into a session snapshot. */
|
||||||
function sessionStats(sessionId) {
|
function sessionStats(sessionId) {
|
||||||
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
|
||||||
|
|||||||
+89
-11
@@ -7,8 +7,12 @@
|
|||||||
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
|
||||||
* (server/lib/audit.js).
|
* (server/lib/audit.js).
|
||||||
*
|
*
|
||||||
* The Users page's account changes — `POST /` (create), `PATCH /:id` (role,
|
* `GET /:id` is one account with the workspaces it owns, for the Edit user form.
|
||||||
* disabled), `POST /:id/password` (reset) and `POST /:id/workspaces` (give a
|
*
|
||||||
|
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
|
||||||
|
* user form: display name, email, role, disabled, a new password, workspaces
|
||||||
|
* given and taken back, sign out everywhere — every field validated before any
|
||||||
|
* is applied), `POST /:id/password` (reset) and `POST /:id/workspaces` (give a
|
||||||
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES), each
|
* workspace) — are an admin's only (`/users` is in ADMIN_WRITE_PREFIXES), each
|
||||||
* needs the admin's own password again (`currentPassword`, `confirmPassword`),
|
* needs the admin's own password again (`currentPassword`, `confirmPassword`),
|
||||||
* an admin never demotes or disables themselves (ESELF), every change is
|
* an admin never demotes or disables themselves (ESELF), every change is
|
||||||
@@ -69,6 +73,7 @@ router.get("/", (_req, res) => {
|
|||||||
const w = sessionWhere({ user: { id: u.id, role: "developer" } });
|
const w = sessionWhere({ user: { id: u.id, role: "developer" } });
|
||||||
return {
|
return {
|
||||||
...u,
|
...u,
|
||||||
|
...auth.loginStats(u.id),
|
||||||
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
|
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
|
||||||
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params)
|
sessions: db.prepare(`SELECT COUNT(*) AS n FROM sessions WHERE ${w.where}`).get(...w.params)
|
||||||
.n,
|
.n,
|
||||||
@@ -85,6 +90,8 @@ router.get("/audit", (req, res) => {
|
|||||||
|
|
||||||
const STATUS = {
|
const STATUS = {
|
||||||
EBADUSER: 400,
|
EBADUSER: 400,
|
||||||
|
EBADEMAIL: 400,
|
||||||
|
ENOTOWNED: 409,
|
||||||
EBADPASSWORD: 400,
|
EBADPASSWORD: 400,
|
||||||
EBADROLE: 400,
|
EBADROLE: 400,
|
||||||
EBADFIELD: 400,
|
EBADFIELD: 400,
|
||||||
@@ -132,6 +139,24 @@ function target(req) {
|
|||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.get("/:id", (req, res) => {
|
||||||
|
const u = db.prepare("SELECT * FROM users WHERE id = ?").get(Number(req.params.id));
|
||||||
|
if (!u) return res.status(404).json({ error: { code: "ENOUSER", message: "no such user" } });
|
||||||
|
res.json({
|
||||||
|
user: { ...auth.publicUser(u), ...auth.loginStats(u.id) },
|
||||||
|
workspaces: auth.ownedWorkspaces(u.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A list of workspace ids from the body, or [] when absent. */
|
||||||
|
function laneIds(value, field) {
|
||||||
|
if (value === undefined) return [];
|
||||||
|
if (!Array.isArray(value) || value.some((v) => !Number.isInteger(v) || v <= 0)) {
|
||||||
|
throw Object.assign(new Error(`${field}: a list of workspace ids`), { code: "EBADFIELD" });
|
||||||
|
}
|
||||||
|
return [...new Set(value)];
|
||||||
|
}
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
accountChange(async (_req, body) => {
|
accountChange(async (_req, body) => {
|
||||||
@@ -146,25 +171,78 @@ router.patch(
|
|||||||
accountChange(async (req, body) => {
|
accountChange(async (req, body) => {
|
||||||
const u = target(req);
|
const u = target(req);
|
||||||
const self = u.id === req.user.id;
|
const self = u.id === req.user.id;
|
||||||
const done = [];
|
const ESELF = (msg) => Object.assign(new Error(msg), { code: "ESELF" });
|
||||||
if (body.role !== undefined && body.role !== auth.publicUser(u).role) {
|
|
||||||
if (self)
|
// 1. Validate everything — nothing is written until all of it passes.
|
||||||
throw Object.assign(new Error("you cannot change your own role"), { code: "ESELF" });
|
const profile = auth.validateProfile(body);
|
||||||
auth.setRole(u.username, body.role);
|
const roleChange = body.role !== undefined && body.role !== u.role;
|
||||||
done.push(`role ${body.role}`);
|
if (roleChange) {
|
||||||
|
if (self) throw ESELF("you cannot change your own role");
|
||||||
|
if (!auth.ROLES.includes(body.role)) {
|
||||||
|
throw Object.assign(new Error(`role: one of ${auth.ROLES.join(", ")}`), {
|
||||||
|
code: "EBADROLE",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (body.disabled !== undefined) {
|
if (body.disabled !== undefined) {
|
||||||
if (typeof body.disabled !== "boolean") {
|
if (typeof body.disabled !== "boolean") {
|
||||||
throw Object.assign(new Error("disabled: true or false"), { code: "EBADFIELD" });
|
throw Object.assign(new Error("disabled: true or false"), { code: "EBADFIELD" });
|
||||||
}
|
}
|
||||||
if (self && body.disabled) {
|
if (self && body.disabled) throw ESELF("you cannot disable yourself");
|
||||||
throw Object.assign(new Error("you cannot disable yourself"), { code: "ESELF" });
|
}
|
||||||
|
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
|
||||||
|
if (body.password !== undefined) auth.validatePassword(body.password);
|
||||||
|
if (body.signOut !== undefined && body.signOut !== true) {
|
||||||
|
throw Object.assign(new Error("signOut: true"), { code: "EBADFIELD" });
|
||||||
|
}
|
||||||
|
const add = laneIds(body.addWorkspaces, "addWorkspaces");
|
||||||
|
const remove = laneIds(body.removeWorkspaces, "removeWorkspaces");
|
||||||
|
const runAs = require("../lib/run-as");
|
||||||
|
for (const id of [...add, ...remove]) {
|
||||||
|
const lane = db.prepare("SELECT owner_id, cwd FROM lanes WHERE id = ?").get(id);
|
||||||
|
if (!lane) throw Object.assign(new Error(`no workspace ${id}`), { code: "ENOLANE" });
|
||||||
|
if (remove.includes(id) && lane.owner_id !== u.id) {
|
||||||
|
throw Object.assign(new Error(`workspace ${id} is not ${u.username}'s`), {
|
||||||
|
code: "ENOTOWNED",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
// S3: a workspace in a member's home is theirs alone (EOWNERFOLDER).
|
||||||
|
if (add.includes(id)) runAs.assertOwnerFits(lane.cwd, u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Apply. Disable last: it may stop panes and lock a Linux account
|
||||||
|
// (EPROVISION), and the admin should learn that after the rest held.
|
||||||
|
const done = [];
|
||||||
|
if (Object.keys(profile).length) {
|
||||||
|
auth.setProfile(u.id, profile);
|
||||||
|
if ("display_name" in profile) done.push("display name");
|
||||||
|
if ("email" in profile) done.push("email");
|
||||||
|
}
|
||||||
|
if (roleChange) {
|
||||||
|
auth.setRole(u.username, body.role);
|
||||||
|
done.push(`role ${body.role}`);
|
||||||
|
}
|
||||||
|
for (const id of add) auth.assignWorkspace(id, u.username);
|
||||||
|
for (const id of remove) auth.unassignWorkspace(id, u.username);
|
||||||
|
if (add.length) done.push(`workspace ${add.join(", ")}`);
|
||||||
|
if (remove.length) done.push(`removed workspace ${remove.join(", ")}`);
|
||||||
|
// An admin changing their own password or signing themselves out keeps
|
||||||
|
// the login they are doing it from.
|
||||||
|
const keepToken = self ? auth.cookieToken(req) : undefined;
|
||||||
|
if (body.password !== undefined) {
|
||||||
|
await auth.setPassword(u.username, body.password, { keepToken });
|
||||||
|
done.push("password reset");
|
||||||
|
}
|
||||||
|
if (body.signOut) {
|
||||||
|
auth.endSessions(u.id, keepToken);
|
||||||
|
done.push("signed out everywhere");
|
||||||
|
}
|
||||||
|
if (disabledChange) {
|
||||||
auth.setDisabled(u.username, body.disabled);
|
auth.setDisabled(u.username, body.disabled);
|
||||||
done.push(body.disabled ? "disable" : "enable");
|
done.push(body.disabled ? "disable" : "enable");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
user: auth.publicUser(target(req)),
|
user: { ...auth.publicUser(target(req)), ...auth.loginStats(u.id) },
|
||||||
action: `${u.username}: ${done.join(", ") || "no change"}`,
|
action: `${u.username}: ${done.join(", ") || "no change"}`,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Update docs **in the same change-set (PR/commit) as the code**, before claiming
|
|||||||
- **Tables**: markdown tables stay pipe-balanced (header column count == every row).
|
- **Tables**: markdown tables stay pipe-balanced (header column count == every row).
|
||||||
- **Mermaid**: each edited block still parses (valid `source --> target: label`).
|
- **Mermaid**: each edited block still parses (valid `source --> target: label`).
|
||||||
- **i18n**: every new English string has a `vi` entry in `client/src/i18n/locales/vi/`.
|
- **i18n**: every new English string has a `vi` entry in `client/src/i18n/locales/vi/`.
|
||||||
- **Format/tests**: run `npm run format` (or `prettier --check` on touched files); for any code touched, run the verification from `CLAUDE.md` (`npm run test:server` / `test:client`).
|
- **Format/tests**: run `npm run format` (or `prettier --check` on touched files); for any code touched, run only the related tests per `CLAUDE.md` (`npm run test:server -- <test file>` / `npx vitest related <files> --run`); a full suite only when `CLAUDE.md` says it is needed.
|
||||||
- State exactly which docs were updated and which were intentionally skipped (with reason), mirroring the repo's verification policy.
|
- State exactly which docs were updated and which were intentionally skipped (with reason), mirroring the repo's verification policy.
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|||||||
Reference in New Issue
Block a user