mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- **Admin Panel for multi-user mode.** Admins in multi-user mode now get a prominent Admin Panel button at the top of the page (header, admin-only; the template ships it hidden and `admin-ui.js` reveals it after identity boot; hidden on phones per the mobile header policy, where user management stays reachable via App Settings > Users). It opens a full Admin Panel modal: a users table with role, enabled/disabled status, bypass-permissions grant, live sessions, active logins, case count, and last login; per-user actions for Promote/Demote, Enable/Disable, Grant/Revoke bypass, Reset password (copyable one-time password), Force logout, and Delete (with an optional "also delete their files" step); and a proper add-user form (role, optional password, bypass checkbox) replacing the old prompt() flow. Each user's cases open in a drawer listing their case folders (modified date, live-session badge) with per-folder delete. Two new admin endpoints back this: `GET /api/admin/users/:username/cases` and `DELETE /api/admin/users/:username/cases/:caseName`, guarded like `deleteUserSpace` (symlinks refused, realpath confined to the user's space, folders in use by a live session refused with 409, audit-logged). The panel and the App Settings Users tab live-refresh on the SSE `admin:usersChanged` event (now wired in app.js). New coverage in `test/admin-routes.test.ts` (list/delete, traversal + symlink refusal, non-admin 403) and `test/admin-ui.test.ts` (button reveal gating, panel render, case drawer); verified end to end against a live multi-user instance with curl and Playwright.
|
||||
|
||||
**Also in this release:** README/docs synced with 1.6.0 (remote SSH cases, session manager, permissions) and fixed installer prompts when run via `curl | bash`.
|
||||
|
||||
**Recap of the recent feature line, for readers catching up:**
|
||||
- **Multi-user mode (shipped 1.5.0, opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`).** Named users with scrypt-hashed passwords, per-user case spaces under `~/codeman-users/<name>/cases`, and full ownership scoping of sessions, cases, cron jobs, scheduled runs, search, file previews, and SSE/WS streams. Non-admin users default to Claude's classifier-guarded `--permission-mode auto`; shell mode, cron `launchCommand`, and skip-permissions bypass switches require the per-user `canBypassPermissions` grant (now toggleable from the Admin Panel). Admin API with one-time passwords, last-admin invariants, and an append-only audit log; self-service `/api/me` password change; `codeman users add|passwd|list|rm` CLI. Off by default is byte-identical to single-user. Note: multi-user separates workspaces for a trusted team; it is not a security boundary (all sessions share the host OS account), so pair it with Docker cases for real isolation.
|
||||
- **Docker cases (shipped 1.4.0/1.4.1).** A case can run inside an isolated per-case container (any of the five CLI backends), with one-click "Run in Docker" quick-create, durable in-container tmux that survives Codeman restarts and resumes conversations after container stops, hardened container creation (cap-drop ALL, no-new-privileges, non-root, memory/pid limits, never privileged, never the docker socket), commit-safe seeded credentials, config-drift detection, GPU passthrough, and portable export/import bundles to move a whole case between machines.
|
||||
- **1.6.0 highlights.** Remote SSH cases with durable remote tmux (survives SSH drops, auto-reconnect, shared multi-client attach, discover + attach with detach-not-kill); the Cmd+K session palette and unified Session Manager with pinning, cross-device tab order, and first/last prompt search; full-scrollback replay; and the multi-user permission downgrade now threading through to remote launch/attach.
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -60,7 +60,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.6.0 (must match `package.json`)
|
||||
**Version**: 1.6.1 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -140,14 +140,14 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| **Attachments** | `src/attachment-registry.ts`, `src/attachment-magic.ts`, `src/generated-artifact-attachments.ts` (Codex `Saved to:` artifacts), `src/session-attachment-history.ts`, `src/document-preview-cache.ts`, `src/document-thumbnailer.ts`, `src/document-conversion-limiter.ts`, `src/config/attachment-guard.ts` | See Key Patterns |
|
||||
| **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`, the CLAUDE.md scaffold generated into new cases) | |
|
||||
| **Web** | `src/web/server.ts` ★, `src/web/sse-events.ts`, `src/web/routes/*.ts` (20 route modules + barrel; `session-routes.ts` ★), `src/web/route-helpers.ts`, `src/web/ports/*.ts`, `src/web/middleware/auth.ts`, `src/web/schemas.ts`, `src/web/self-update.ts`, `src/web/plan-usage-latest.ts`, `src/web/ws-connection-registry.ts` (per-tab WS supersede), `src/web/heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` (HEIC→JPEG off-thread) | |
|
||||
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 9 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 18 domain files (incl. `workflow-run.ts`, `search.ts`, `cron.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
| **Frontend** | `src/web/public/app.js` (~4K lines, core) + 6 infra modules (`constants.js`, `mobile-handlers.js`, `voice-input.js`, `notification-manager.js`, `keyboard-accessory.js`, `sanitize-html.js` — DOMPurify mXSS allowlist, COD-56) + 10 domain modules (`terminal-ui.js`, `respawn-ui.js`, `ralph-panel.js`, `orchestrator-panel.js`, `ultracode-panel.js`, `cron-ui.js`, `settings-ui.js`, `panels-ui.js`, `admin-ui.js`, `session-ui.js`) + 6 feature modules (`ralph-wizard.js`, `api-client.js`, `subagent-windows.js`, `ultracode-windows.js`, `input-cjk.js`, `image-input.js`) + `sw.js` | `ultracode-windows.js` = floating run windows w/ tab connector lines (additional to the dock panel) |
|
||||
| **Types** | `src/types/index.ts` (barrel) → 19 domain files (incl. `workflow-run.ts`, `search.ts`, `cron.ts`, `user.ts`); also `src/types.ts` root re-export | See `@fileoverview` in index.ts |
|
||||
|
||||
★ = Large, central file (>50KB) — read its `@fileoverview` first. All files have `@fileoverview` JSDoc — read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`.
|
||||
|
||||
**Local packages**: `packages/xterm-zerolag-input/` — local echo overlay for xterm.js; single-source, bundled to the gitignored `vendor/xterm-zerolag-input.js` and consumed by `app.js` (see Gotchas). `packages/gesture-control/` (`codeman-gesture-control`) — hand-tracking overlay source; built to `src/web/public/gesture/gesture-codeman.js` via `npm run build:gesture` (see Frontend → Gesture control).
|
||||
|
||||
**Config**: `src/config/` — 15 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
**Config**: `src/config/` — 16 files, no barrel (`index.ts`) exists; import from the specific file.
|
||||
|
||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver` (CLI path resolution), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority).
|
||||
|
||||
@@ -198,7 +198,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Cross-session search** (COD-113/#133): `GET /api/search?q=&types=&limit=` federates an **in-memory** search across all live sessions — session metadata (name/workingDir/id), run-summary events, and per-session attachment-history file entries (workspace-relative path only; the server-private `externalPath` is never read). Pure core `searchSources()` in `search-service.ts` (substring-matches with hard per-type caps — no regex, so no ReDoS; no filesystem reads, so no traversal); `harvestSources()` in `search-routes.ts` gathers the in-memory sources. `SearchQuerySchema` bounds `q` (1–200), allowlists `types` (`session,event,file`), clamps `limit` (1–60). Returns the `{success,data}` envelope. Frontend: history-panel search box in `terminal-ui.js`. Types: `src/types/search.ts`.
|
||||
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; branch `feat/multiuser-mode`, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **Not a security boundary at the agent layer** — every session still runs as the SAME OS account; this separates WORKSPACES, it does not sandbox users (Docker cases are the isolation story). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users/<name>/cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
|
||||
**Multi-user mode** (opt-in `--multiuser` / `CODEMAN_MULTIUSER=1`, OFF by default; shipped 1.5.0 via PR #161, design `docs/multi-user-plan.md`): named users with individually scrypt-hashed passwords in `~/.codeman/users.json` (via `src/user-store.ts`: atomic 0600 write, short-TTL cache, SERIALIZED read-modify-write so a fire-and-forget `touchLastLogin` can't clobber a concurrent route write, last-admin invariants). Gated everywhere by `isMultiUserMode()` (`src/config/multiuser.ts`); when OFF, behavior is byte-identical to single-user (all scoping helpers short-circuit). ⚠️ **Not a security boundary at the agent layer** — every session still runs as the SAME OS account; this separates WORKSPACES, it does not sandbox users (Docker cases are the isolation story). Auth: a PARALLEL async branch in `middleware/auth.ts` (single-user branch untouched) verifies `username:password` against the store, mints identity-carrying cookies (`AuthSessionRecord` gains `username`/`role`/`mustChangePassword`), decorates `req.authUser` (Fastify augmentation; single-user leaves it undefined and the ownership helpers default to a synthetic admin), enforces a per-username failure bucket + the `mustChangePassword` lockbox. Ownership threads through `Session.owner` (stamped from `req.authUser`/`job.owner` at every `new Session()`, round-tripped via `MuxSession.owner` on recovery); `findSessionOrFail(ctx,id,req)` does a NOT_FOUND owner check; list endpoints + `getLightState` + SSE (`deriveSseHint` routes session-scoped events by owner, fail-closed; machine-level + host-plan telemetry admin-only) + WS + search + file-preview all filter by owner. §6.3 permission policy: non-granted users are forced to `--permission-mode auto` (via `resolveClaudeModeForUser` at all spawn sites, incl. one-shots because `buildPromptArgs` now respects the session mode), and shell mode / cron `launchCommand` require the `canBypassPermissions` grant. Cases live in per-user `~/codeman-users/<name>/cases` (`resolveCasesDir`); a non-admin's `workingDir` is realpath-confined there; host CRUD is admin-only. Admin API `src/web/routes/admin-routes.ts` (`/api/admin/users*`, one-time passwords, audit log `admin-audit.jsonl`) + self-service `/api/me` + `/api/me/password` (`me-routes.ts`); frontend `public/admin-ui.js` (identity boot, change-password modal + interceptor, admin Users tab, and the header **Admin Panel** button `#adminPanelBtn`: ships `btn-admin-panel--hidden`, revealed for admins in multi-user mode, phone-hidden via mobile.css; opens the full Admin Panel modal with user CRUD, per-user permission toggles, and case-folder list/delete via `GET/DELETE /api/admin/users/:username/cases[/:caseName]`; live-refreshes on SSE `admin:usersChanged`, wired in app.js). CLI `codeman users add|passwd|list|rm`. Per-user session cap via `sessionCapacityState`/`sessionCapacityMessage`. Tests: `test/user-store.test.ts`, `test/multiuser-auth.test.ts`, `test/ownership-scoping.test.ts`, `test/admin-routes.test.ts`, `test/admin-ui.test.ts`.
|
||||
|
||||
**Away digest** (COD-41/#136): `GET /api/away-digest?range=&since=&until=&lastViewed=` aggregates "what happened while you were away" from the lifecycle log + run-summary events + live sessions + daily token stats + recently-completed subagents into needs-attention/completed/still-running/idle/informational sections. Pure aggregator in `web/away-digest.ts` (`resolveAwayDigestRange()` validates the window — `since-last-visit`/`1h`/`today`/`24h`/`custom`, server-local TZ; `buildAwayDigest()` classifies). Header-button modal in `panels-ui.js` (button hidden on phones — regression-guarded). ⚠️ Returns `{success:true,digest}` (a legacy raw-ish shape, consistent with the other raw GET handlers in `system-routes.ts` — `{entries}`/`{config}`/`{files}`/`getSystemStats()`); frontend + tests read `.digest`. Subagent lookback is a fixed 60-min window regardless of range.
|
||||
|
||||
@@ -208,7 +208,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData).
|
||||
|
||||
**Command palette + shortcut registry** (COD-151/153/157/192, #146): `Ctrl/Cmd/Alt+K` opens the session palette (fuzzy search over live sessions; "Browse all sessions" → the Session Manager modal backed by `GET /api/sessions/unified`); the quick-start case `<select>` is fronted by a searchable picker (`buildCasePickerOptions`/`formatCasePickerLabel` — remote cases render `name @ hostId`). Shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js; overrides persist under `settings.shortcutOverrides` via `saveAppSettingsToStorage`); App Settings → Shortcuts renders capture/disable rows; `Ctrl+?` opens the registry-driven overlay (footer links to the full `#helpModal` reference). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM — keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over.
|
||||
|
||||
@@ -256,7 +256,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
### API Routes
|
||||
|
||||
~188 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (6, multi-user `/api/admin/users*`), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~190 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (14, incl. attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.1",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+303
-6
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* @fileoverview Multi-user frontend: identity boot, admin Users panel, and the
|
||||
* change-password flow. Self-contained (builds its own DOM) so it needs no
|
||||
* index.html surgery beyond the script tag and integrates with the existing App
|
||||
* Settings modal by injecting a "Users" tab (admins in multi-user mode only).
|
||||
* @fileoverview Multi-user frontend: identity boot, admin Users panel, the
|
||||
* change-password flow, and the full Admin Panel modal (user CRUD, per-user
|
||||
* permissions, case-folder management) opened by the header Admin Panel button
|
||||
* (#adminPanelBtn, revealed for admins in multi-user mode). Self-contained
|
||||
* (builds its own DOM) so it needs no index.html surgery beyond the script tag
|
||||
* and button; integrates with the existing App Settings modal by injecting a
|
||||
* "Users" tab (admins in multi-user mode only). Live-refreshes on the SSE
|
||||
* admin:usersChanged event (wired in app.js → window.codemanAdmin.onUsersChanged).
|
||||
*
|
||||
* @dependency app.js (window.app), settings-ui.js (App Settings modal + tab switch)
|
||||
* @loadorder after settings-ui.js / ultracode-panel.js, before session-ui.js
|
||||
@@ -123,7 +127,10 @@
|
||||
content.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
<span>
|
||||
<button class="btn btn-sm" id="adminOpenPanel">Open Admin Panel</button>
|
||||
<button class="btn btn-sm" id="adminAddUser">+ Add user</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="form-hint">Users share the host account; this separates workspaces, it does not sandbox
|
||||
users from each other. Pair with Docker cases for isolation.</p>
|
||||
@@ -133,6 +140,7 @@
|
||||
// Render whenever the tab is shown (the shared switchSettingsTab toggles it).
|
||||
btn.addEventListener('click', renderUsers);
|
||||
content.querySelector('#adminAddUser').onclick = addUserFlow;
|
||||
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
@@ -233,6 +241,292 @@
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
// ── Admin Panel (big header-button modal) ─────────────────────────────────
|
||||
let apModal = null;
|
||||
let apUsersCache = [];
|
||||
const apOpenDrawers = new Set(); // usernames with an expanded case-folder drawer
|
||||
|
||||
function fmtDate(ts) {
|
||||
return ts ? new Date(ts).toLocaleString() : 'never';
|
||||
}
|
||||
function cssEsc(s) {
|
||||
return window.CSS && window.CSS.escape ? window.CSS.escape(s) : String(s).replace(/"/g, '\\"');
|
||||
}
|
||||
function apSetMsg(t) {
|
||||
const m = document.getElementById('apMsg');
|
||||
if (m) m.textContent = t || '';
|
||||
}
|
||||
|
||||
function buildAdminPanel() {
|
||||
if (apModal) return apModal;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'modal';
|
||||
el.id = 'adminPanelModal';
|
||||
el.style.zIndex = '3000';
|
||||
el.innerHTML = `
|
||||
<div class="modal-content" style="max-width:940px;width:min(96vw,940px)">
|
||||
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;gap:12px">
|
||||
<h2 style="display:flex;align-items:center;gap:8px;margin:0">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Admin Panel</h2>
|
||||
<span id="apIdentity" style="color:var(--text-muted,#888);font-size:.85em"></span>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height:70vh;overflow-y:auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<strong>Users</strong>
|
||||
<button class="btn btn-sm btn-primary" id="apAddToggle">+ Add user</button>
|
||||
</div>
|
||||
<div id="apAddForm" style="display:none;border:1px solid var(--border,#333);border-radius:8px;padding:10px;margin-bottom:10px">
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
|
||||
<div class="form-row" style="margin:0"><label>Username</label>
|
||||
<input id="apNewName" class="form-input" placeholder="lowercase a-z 0-9 _ -" style="width:170px"></div>
|
||||
<div class="form-row" style="margin:0"><label>Role</label>
|
||||
<select id="apNewRole" class="form-input" style="width:110px">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select></div>
|
||||
<div class="form-row" style="margin:0"><label>Password (optional)</label>
|
||||
<input id="apNewPw" type="password" class="form-input" placeholder="blank = one-time pw"
|
||||
style="width:170px" autocomplete="new-password"></div>
|
||||
<label style="display:flex;align-items:center;gap:5px;white-space:nowrap;margin-bottom:6px">
|
||||
<input type="checkbox" id="apNewBypass"> allow bypass permissions</label>
|
||||
<button class="btn btn-sm btn-primary" id="apCreateUser" style="margin-bottom:2px">Create</button>
|
||||
</div>
|
||||
<p class="form-hint" style="margin:6px 0 0">Without a password a one-time password is generated and shown
|
||||
once; the user must change it on first login. "Bypass" allows shell sessions, cron launch commands, and
|
||||
skip-permissions agents.</p>
|
||||
</div>
|
||||
<div id="apOtp" style="display:none;border:1px solid var(--accent,#38b6f0);border-radius:8px;padding:10px;margin-bottom:10px"></div>
|
||||
<div id="apTable">Loading…</div>
|
||||
<p class="form-hint" style="margin-top:10px">Users share the host OS account: this separates workspaces, it
|
||||
does not sandbox users from each other. Pair with Docker cases for isolation.</p>
|
||||
<p id="apMsg" style="min-height:1.2em;color:var(--text-muted,#888)"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" id="apClose">Close</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#apClose').onclick = () => (el.style.display = 'none');
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target === el) el.style.display = 'none';
|
||||
});
|
||||
el.querySelector('#apAddToggle').onclick = () => {
|
||||
const f = el.querySelector('#apAddForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
if (f.style.display === '') f.querySelector('#apNewName').focus();
|
||||
};
|
||||
el.querySelector('#apCreateUser').onclick = createUserFromForm;
|
||||
apModal = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
function showOneTimePassword(username, otp) {
|
||||
const box = document.getElementById('apOtp');
|
||||
if (!box) return;
|
||||
box.style.display = '';
|
||||
box.innerHTML = `One-time password for <strong>${esc(username)}</strong> (shown once, copy it now):
|
||||
<code style="user-select:all;font-size:1.05em;margin:0 8px">${esc(otp)}</code>
|
||||
<button class="btn btn-xs" id="apOtpCopy">Copy</button>
|
||||
<button class="btn btn-xs" id="apOtpDismiss">Dismiss</button>`;
|
||||
box.querySelector('#apOtpCopy').onclick = () => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(otp).then(() => apSetMsg('Password copied to clipboard.'));
|
||||
}
|
||||
};
|
||||
box.querySelector('#apOtpDismiss').onclick = () => {
|
||||
box.style.display = 'none';
|
||||
box.innerHTML = '';
|
||||
};
|
||||
}
|
||||
|
||||
async function createUserFromForm() {
|
||||
const name = (document.getElementById('apNewName').value || '').trim().toLowerCase();
|
||||
const role = document.getElementById('apNewRole').value;
|
||||
const pw = document.getElementById('apNewPw').value;
|
||||
const bypass = document.getElementById('apNewBypass').checked;
|
||||
if (!name) return apSetMsg('Enter a username.');
|
||||
const body = { username: name, role };
|
||||
if (pw) body.password = pw;
|
||||
if (bypass) body.canBypassPermissions = true;
|
||||
const r = await apiSend('POST', '/api/admin/users', body);
|
||||
if (!r.ok) return apSetMsg((r.body && r.body.error) || 'Create failed.');
|
||||
document.getElementById('apNewName').value = '';
|
||||
document.getElementById('apNewPw').value = '';
|
||||
document.getElementById('apNewBypass').checked = false;
|
||||
apSetMsg(`Created ${name}.`);
|
||||
if (r.data && r.data.oneTimePassword) showOneTimePassword(name, r.data.oneTimePassword);
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
async function renderPanel() {
|
||||
const table = document.getElementById('apTable');
|
||||
if (!table) return;
|
||||
let users;
|
||||
try {
|
||||
users = await apiGet('/api/admin/users');
|
||||
} catch {
|
||||
table.innerHTML = 'Failed to load users.';
|
||||
return;
|
||||
}
|
||||
apUsersCache = users;
|
||||
const meName = (window.__codemanUser || {}).username;
|
||||
const rows = users
|
||||
.map((u) => {
|
||||
const st = u.stats || {};
|
||||
const you = u.username === meName ? ' <span style="color:var(--accent,#38b6f0)">(you)</span>' : '';
|
||||
const role = `<span style="font-weight:600;color:${
|
||||
u.role === 'admin' ? 'var(--accent,#38b6f0)' : 'var(--text-muted,#888)'
|
||||
}">${u.role}</span>`;
|
||||
const status = u.disabled
|
||||
? '<span style="color:var(--red,#c33)">disabled</span>'
|
||||
: '<span style="color:var(--accent-soft,#4b9)">enabled</span>';
|
||||
const pwFlag = u.mustChangePassword ? ' · must-change-pw' : '';
|
||||
return `<tr data-u="${esc(u.username)}">
|
||||
<td><strong>${esc(u.username)}</strong>${you}</td>
|
||||
<td>${role}</td>
|
||||
<td>${status}${pwFlag}</td>
|
||||
<td>${u.canBypassPermissions ? 'yes' : 'no'}</td>
|
||||
<td style="white-space:nowrap">${st.liveSessions ?? 0} live · ${st.activeSessions ?? 0} logins ·
|
||||
<button class="btn btn-xs" data-act="cases">${st.caseCount ?? 0} cases</button></td>
|
||||
<td style="font-size:.85em;color:var(--text-muted,#888)">${fmtDate(u.lastLoginAt)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn btn-xs" data-act="role">${u.role === 'admin' ? 'Demote' : 'Promote'}</button>
|
||||
<button class="btn btn-xs" data-act="disabled">${u.disabled ? 'Enable' : 'Disable'}</button>
|
||||
<button class="btn btn-xs" data-act="bypass">${u.canBypassPermissions ? 'Revoke bypass' : 'Grant bypass'}</button>
|
||||
<button class="btn btn-xs" data-act="reset">Reset pw</button>
|
||||
<button class="btn btn-xs" data-act="logout">Logout</button>
|
||||
<button class="btn btn-xs" data-act="delete" style="color:var(--red,#c33)">Delete</button>
|
||||
</td></tr>
|
||||
<tr data-drawer="${esc(u.username)}" style="display:none"><td colspan="7"></td></tr>`;
|
||||
})
|
||||
.join('');
|
||||
table.innerHTML = `<table style="width:100%;border-collapse:collapse" class="admin-users">
|
||||
<thead><tr>
|
||||
<th align="left">User</th><th align="left">Role</th><th align="left">Status</th>
|
||||
<th align="left">Bypass</th><th align="left">Activity</th><th align="left">Last login</th><th></th>
|
||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
table.querySelectorAll('button[data-act]').forEach((b) => {
|
||||
const username = b.closest('tr').dataset.u;
|
||||
b.onclick = () => {
|
||||
if (b.dataset.act === 'cases') return toggleCaseDrawer(username);
|
||||
return panelAction(
|
||||
username,
|
||||
b.dataset.act,
|
||||
apUsersCache.find((x) => x.username === username)
|
||||
);
|
||||
};
|
||||
});
|
||||
// Re-open drawers that were expanded before this refresh.
|
||||
for (const name of [...apOpenDrawers]) {
|
||||
if (users.some((u) => u.username === name)) void renderCaseDrawer(name);
|
||||
else apOpenDrawers.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
async function panelAction(username, act, u) {
|
||||
const path = `/api/admin/users/${encodeURIComponent(username)}`;
|
||||
if (act === 'role') {
|
||||
const r = await apiSend('PATCH', path, { role: u.role === 'admin' ? 'user' : 'admin' });
|
||||
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'disabled') {
|
||||
const r = await apiSend('PATCH', path, { disabled: !u.disabled });
|
||||
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'bypass') {
|
||||
const r = await apiSend('PATCH', path, { canBypassPermissions: !u.canBypassPermissions });
|
||||
apSetMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.');
|
||||
} else if (act === 'reset') {
|
||||
if (!window.confirm(`Reset ${username}'s password? They must set a new one on next login.`)) return;
|
||||
const r = await apiSend('POST', `${path}/reset-password`);
|
||||
if (r.ok && r.data && r.data.oneTimePassword) showOneTimePassword(username, r.data.oneTimePassword);
|
||||
else if (!r.ok) apSetMsg((r.body && r.body.error) || 'Reset failed.');
|
||||
} else if (act === 'logout') {
|
||||
const r = await apiSend('POST', `${path}/logout`);
|
||||
apSetMsg(r.ok ? `Revoked ${(r.data && r.data.revoked) || 0} login session(s) for ${username}.` : 'Failed.');
|
||||
} else if (act === 'delete') {
|
||||
if (!window.confirm(`Delete user "${username}"? Their live sessions are killed and logins revoked.`)) return;
|
||||
const deleteSpace = window.confirm(
|
||||
`Also delete ${username}'s files (their cases/workspace folder)?\nOK = delete files too, Cancel = keep files on disk.`
|
||||
);
|
||||
const r = await apiSend('DELETE', path, { deleteSpace });
|
||||
apSetMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
}
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
async function toggleCaseDrawer(username) {
|
||||
if (apOpenDrawers.has(username)) {
|
||||
apOpenDrawers.delete(username);
|
||||
const row = apModal && apModal.querySelector(`tr[data-drawer="${cssEsc(username)}"]`);
|
||||
if (row) row.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
apOpenDrawers.add(username);
|
||||
await renderCaseDrawer(username);
|
||||
}
|
||||
|
||||
async function renderCaseDrawer(username) {
|
||||
const row = apModal && apModal.querySelector(`tr[data-drawer="${cssEsc(username)}"]`);
|
||||
if (!row) return;
|
||||
row.style.display = '';
|
||||
const cell = row.firstElementChild;
|
||||
cell.innerHTML = 'Loading folders…';
|
||||
let data;
|
||||
try {
|
||||
data = await apiGet(`/api/admin/users/${encodeURIComponent(username)}/cases`);
|
||||
} catch {
|
||||
cell.innerHTML = 'Failed to load case folders.';
|
||||
return;
|
||||
}
|
||||
const items = (data.cases || [])
|
||||
.map(
|
||||
(c) => `
|
||||
<li style="display:flex;gap:10px;align-items:center;padding:2px 0">
|
||||
<code>${esc(c.name)}</code>
|
||||
<span style="color:var(--text-muted,#888);font-size:.85em">${fmtDate(c.modifiedAt)}</span>
|
||||
${c.liveSessions ? `<span style="color:var(--yellow,#ca0)">${c.liveSessions} live session(s)</span>` : ''}
|
||||
<button class="btn btn-xs" data-case="${esc(c.name)}"
|
||||
${c.liveSessions ? 'disabled title="In use by a live session"' : ''}>Delete</button>
|
||||
</li>`
|
||||
)
|
||||
.join('');
|
||||
cell.innerHTML = `<div style="padding:6px 4px 6px 16px">
|
||||
<div style="color:var(--text-muted,#888);font-size:.85em;margin-bottom:4px">${esc(data.dir || '')}</div>
|
||||
${items ? `<ul style="list-style:none;margin:0;padding:0">${items}</ul>` : 'No case folders yet.'}
|
||||
</div>`;
|
||||
cell.querySelectorAll('button[data-case]').forEach((b) => {
|
||||
b.onclick = async () => {
|
||||
const name = b.dataset.case;
|
||||
if (!window.confirm(`Permanently delete ${username}'s case folder "${name}" and ALL files in it?`)) return;
|
||||
const r = await apiSend(
|
||||
'DELETE',
|
||||
`/api/admin/users/${encodeURIComponent(username)}/cases/${encodeURIComponent(name)}`
|
||||
);
|
||||
apSetMsg(r.ok ? `Deleted folder ${name}.` : (r.body && r.body.error) || 'Delete failed.');
|
||||
renderPanel();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openAdminPanel() {
|
||||
const me = window.__codemanUser || {};
|
||||
if (!me.multiUser || me.role !== 'admin') return;
|
||||
const el = buildAdminPanel();
|
||||
el.querySelector('#apIdentity').textContent = `signed in as ${me.username} (admin)`;
|
||||
apSetMsg('');
|
||||
el.style.display = 'flex';
|
||||
renderPanel();
|
||||
}
|
||||
|
||||
/** SSE admin:usersChanged: live-refresh whichever admin views are visible. */
|
||||
function onUsersChanged() {
|
||||
if (apModal && apModal.style.display === 'flex') renderPanel();
|
||||
const tab = document.getElementById('settings-users');
|
||||
if (tab && !tab.classList.contains('hidden')) renderUsers();
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
installInterceptor();
|
||||
@@ -247,6 +541,9 @@
|
||||
if (window.__codemanUser.mustChangePassword) openChangePassword(true);
|
||||
if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') {
|
||||
injectUsersTab();
|
||||
// Reveal the big header Admin Panel button (template ships it hidden).
|
||||
const btn = document.getElementById('adminPanelBtn');
|
||||
if (btn) btn.classList.remove('btn-admin-panel--hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,5 +553,5 @@
|
||||
boot();
|
||||
}
|
||||
|
||||
window.codemanAdmin = { openChangePassword, renderUsers };
|
||||
window.codemanAdmin = { openChangePassword, renderUsers, openAdminPanel, onUsersChanged };
|
||||
})();
|
||||
|
||||
@@ -1479,6 +1479,10 @@ class CodemanApp {
|
||||
console.error('[SSE] docker container recreated:', err);
|
||||
}
|
||||
});
|
||||
// Multi-user admin: live-refresh whichever admin views (panel/Users tab) are open.
|
||||
addListener(SSE_EVENTS.ADMIN_USERS_CHANGED, () => {
|
||||
window.codemanAdmin?.onUsersChanged?.();
|
||||
});
|
||||
// Base image auto-build on first Docker case (build-on-first-use). A single
|
||||
// multi-minute event; surface start/finish so the Run spinner is explained.
|
||||
addListener(SSE_EVENTS.DOCKER_IMAGE_BUILD_STARTED, () => {
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
<div class="solo-session-title" id="soloSessionTitle" style="display: none;" aria-live="polite"></div>
|
||||
|
||||
<div class="header-right" id="headerRight">
|
||||
<button class="btn-admin-panel btn-admin-panel--hidden" id="adminPanelBtn" onclick="window.codemanAdmin.openAdminPanel()" title="Admin Panel (multi-user administration)" aria-label="Open admin panel">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
<span>Admin Panel</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-solo-redock" id="soloRedockBtn" style="display: none;" onclick="window.close()" title="Re-dock to dashboard (close window)" aria-label="Re-dock session to dashboard">⊞</button>
|
||||
<button class="tunnel-indicator" id="tunnelIndicator" style="display: none;" onclick="app.toggleTunnelPanel()" title="Cloudflare Tunnel" aria-label="Tunnel status">
|
||||
<span class="tunnel-dot"></span>
|
||||
|
||||
@@ -474,6 +474,13 @@ html.mobile-init .file-browser-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* The big labeled Admin Panel button is desktop-only (admin-gated, revealed by
|
||||
admin-ui.js). On phones admins still reach user management via App Settings →
|
||||
Users, so the cramped header stays minimal. */
|
||||
.btn-admin-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Mobile voice input button in toolbar — initially hidden via inline style,
|
||||
shown by VoiceInput._showButtons() clearing the inline display */
|
||||
.btn-voice-mobile {
|
||||
|
||||
@@ -1002,6 +1002,43 @@ body {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Admin Panel header button: only shown to admins in multi-user mode (marker
|
||||
class removed by admin-ui.js after identity boot). Deliberately BIG and
|
||||
prominent: it is the entry point to user/permission management. */
|
||||
.btn-admin-panel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 14px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||
color: var(--accent-ink);
|
||||
border: none;
|
||||
border-radius: var(--btn-radius);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
transition: all var(--transition-smooth);
|
||||
}
|
||||
|
||||
.btn-admin-panel:hover {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 65%, transparent);
|
||||
}
|
||||
|
||||
.btn-admin-panel--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Admin user tables (Admin Panel modal + the App Settings Users tab). */
|
||||
.admin-users th,
|
||||
.admin-users td {
|
||||
padding: 5px 12px 5px 0;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text-muted) 20%, transparent);
|
||||
}
|
||||
|
||||
/* Multi-monitor header button: hidden by default (opt-in via App Settings →
|
||||
Display → "Header Displays"). The server strips this class at render when the
|
||||
setting is on; the client toggles it live on save. */
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
* POST /api/admin/users/:username/reset-password
|
||||
* POST /api/admin/users/:username/logout
|
||||
* DELETE /api/admin/users/:username
|
||||
* GET /api/admin/users/:username/cases (list the user's case folders)
|
||||
* DELETE /api/admin/users/:username/cases/:caseName (delete one case folder)
|
||||
*
|
||||
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { readdirSync, promises as fsp } from 'node:fs';
|
||||
import { isAbsolute, join, relative } from 'node:path';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
|
||||
import {
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
normalizeUsername,
|
||||
readUsers,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
@@ -50,6 +54,10 @@ const UpdateUserSchema = z.object({
|
||||
});
|
||||
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
|
||||
|
||||
// Case-folder names: a single path segment, no separators, no leading dot (hidden
|
||||
// dirs like .claude are infrastructure, not cases), so '.'/'..' are excluded too.
|
||||
const SAFE_CASE_NAME = /^[^./\\][^/\\]{0,127}$/;
|
||||
|
||||
/** Map a UserStoreError's code onto the API error code + status. */
|
||||
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
|
||||
if (err instanceof UserStoreError) {
|
||||
@@ -201,4 +209,90 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
// Count live sessions whose workingDir sits inside `dir` (any owner: a folder
|
||||
// in use by ANYONE must not be deleted out from under a running agent).
|
||||
const liveSessionsInside = (dir: string): number => {
|
||||
let n = 0;
|
||||
for (const s of ctx.sessions.values()) {
|
||||
const rel = relative(dir, s.workingDir || '');
|
||||
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) n++;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
app.get('/api/admin/users/:username/cases', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const username = normalizeUsername((req.params as { username: string }).username);
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
const dir = userCasesDir(username);
|
||||
let cases: { name: string; modifiedAt: number; liveSessions: number }[] = [];
|
||||
try {
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
cases = await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map(async (e) => {
|
||||
const p = join(dir, e.name);
|
||||
const st = await fsp.stat(p).catch(() => null);
|
||||
return { name: e.name, modifiedAt: st ? Math.floor(st.mtimeMs) : 0, liveSessions: liveSessionsInside(p) };
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
/* no cases dir yet */
|
||||
}
|
||||
cases.sort((a, b) => b.modifiedAt - a.modifiedAt);
|
||||
return { success: true, data: { dir, cases } };
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:username/cases/:caseName', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const params = req.params as { username: string; caseName: string };
|
||||
const username = normalizeUsername(params.username);
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
if (!SAFE_CASE_NAME.test(params.caseName)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
// Same guard rails as deleteUserSpace: never follow a symlink, and the
|
||||
// realpath must stay strictly inside the user's cases dir.
|
||||
const root = userCasesDir(username);
|
||||
const target = join(root, params.caseName);
|
||||
let lst;
|
||||
try {
|
||||
lst = await fsp.lstat(target);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'No such case folder');
|
||||
}
|
||||
if (lst.isSymbolicLink() || !lst.isDirectory()) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Refusing to delete: not a plain directory');
|
||||
}
|
||||
const realRoot = await fsp.realpath(root).catch(() => root);
|
||||
const realTarget = await fsp.realpath(target);
|
||||
const rel = relative(realRoot, realTarget);
|
||||
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case folder escapes the user space');
|
||||
}
|
||||
const inUse = liveSessionsInside(target) || liveSessionsInside(realTarget);
|
||||
if (inUse > 0) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.CONFLICT,
|
||||
`Case folder is in use by ${inUse} live session(s), close them first`
|
||||
);
|
||||
}
|
||||
await fsp.rm(realTarget, { recursive: true, force: true });
|
||||
audit(req, 'user.case-delete', username, { caseName: params.caseName });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { username, caseName: params.caseName } };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,3 +145,58 @@ describe('admin API', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin case-folder API', () => {
|
||||
const kim = { Authorization: basic('kim', 'kimpass1234') };
|
||||
|
||||
it('lists a user case folders (admin only, hidden dirs excluded)', async () => {
|
||||
// Fresh regular user with a known password (joe's was reset above).
|
||||
const created = await fetch(url('/api/admin/users'), {
|
||||
method: 'POST',
|
||||
headers: admin,
|
||||
body: JSON.stringify({ username: 'kim', role: 'user', password: 'kimpass1234' }),
|
||||
});
|
||||
expect(created.status).toBe(200);
|
||||
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', 'proj1'), { recursive: true });
|
||||
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', '.hidden'), { recursive: true });
|
||||
|
||||
const forbidden = await fetch(url('/api/admin/users/kim/cases'), { headers: kim });
|
||||
expect(forbidden.status).toBe(403);
|
||||
|
||||
const res = await fetch(url('/api/admin/users/kim/cases'), { headers: adminNoBody });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data.cases.map((c: { name: string }) => c.name)).toEqual(['proj1']);
|
||||
expect(data.cases[0].liveSessions).toBe(0);
|
||||
});
|
||||
|
||||
it('404s for an unknown user', async () => {
|
||||
const res = await fetch(url('/api/admin/users/ghost/cases'), { headers: adminNoBody });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('deletes a case folder, refusing unsafe names and symlinks', async () => {
|
||||
// Traversal-shaped name: rejected before any filesystem access.
|
||||
const bad = await fetch(url('/api/admin/users/kim/cases/..%2Fescape'), {
|
||||
method: 'DELETE',
|
||||
headers: adminNoBody,
|
||||
});
|
||||
expect([400, 404]).toContain(bad.status);
|
||||
|
||||
// A symlinked "case" is refused, never followed.
|
||||
await fs.mkdir(path.join(spacesDir, 'outside'), { recursive: true });
|
||||
await fs.symlink(path.join(spacesDir, 'outside'), path.join(spacesDir, 'kim', 'cases', 'link'));
|
||||
const sl = await fetch(url('/api/admin/users/kim/cases/link'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(sl.status).toBe(400);
|
||||
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
|
||||
|
||||
// A real folder is deleted.
|
||||
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(del.status).toBe(200);
|
||||
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
|
||||
|
||||
// Deleting it again 404s.
|
||||
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
||||
expect(gone.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ function resp(status: number, body: unknown) {
|
||||
async function bootWith(me: Record<string, unknown>) {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<button id="adminPanelBtn" class="btn-admin-panel btn-admin-panel--hidden"></button>
|
||||
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only' }
|
||||
@@ -70,6 +71,65 @@ describe('admin-ui boot', () => {
|
||||
// Forced: the cancel button is hidden.
|
||||
expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('reveals the header Admin Panel button for a multi-user admin only', async () => {
|
||||
const hidden = (w: Window) =>
|
||||
w.document.getElementById('adminPanelBtn')!.classList.contains('btn-admin-panel--hidden');
|
||||
const a = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
expect(hidden(a.win)).toBe(false);
|
||||
const b = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
expect(hidden(b.win)).toBe(true);
|
||||
const c = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false });
|
||||
expect(hidden(c.win)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin panel modal', () => {
|
||||
it('opens for an admin, renders users, and shows the case-folder drawer', async () => {
|
||||
const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false });
|
||||
win.fetch = (async (path: string) => {
|
||||
if (path === '/api/admin/users')
|
||||
return resp(200, {
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
username: 'root',
|
||||
role: 'admin',
|
||||
disabled: false,
|
||||
mustChangePassword: false,
|
||||
canBypassPermissions: true,
|
||||
createdAt: 1,
|
||||
lastLoginAt: 2,
|
||||
stats: { liveSessions: 1, activeSessions: 2, caseCount: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === '/api/admin/users/root/cases')
|
||||
return resp(200, {
|
||||
success: true,
|
||||
data: { dir: '/tmp/spaces/root/cases', cases: [{ name: 'proj1', modifiedAt: 3, liveSessions: 0 }] },
|
||||
});
|
||||
return resp(200, { success: true });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
|
||||
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
const modal = win.document.getElementById('adminPanelModal') as HTMLElement;
|
||||
expect(modal).toBeTruthy();
|
||||
expect(modal.style.display).toBe('flex');
|
||||
expect(modal.querySelector('#apTable')!.textContent).toContain('root');
|
||||
|
||||
(modal.querySelector('button[data-act="cases"]') as HTMLButtonElement).click();
|
||||
for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
expect(modal.textContent).toContain('proj1');
|
||||
expect(modal.textContent).toContain('/tmp/spaces/root/cases');
|
||||
});
|
||||
|
||||
it('does NOT open for a regular user', async () => {
|
||||
const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false });
|
||||
(win as unknown as { codemanAdmin: { openAdminPanel: () => void } }).codemanAdmin.openAdminPanel();
|
||||
expect(win.document.getElementById('adminPanelModal')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.html wiring', () => {
|
||||
@@ -80,4 +140,9 @@ describe('index.html wiring', () => {
|
||||
expect(admin).toBeGreaterThan(settings);
|
||||
expect(session).toBeGreaterThan(admin);
|
||||
});
|
||||
|
||||
it('ships the header Admin Panel button hidden by default', () => {
|
||||
expect(INDEX_HTML).toContain('id="adminPanelBtn"');
|
||||
expect(INDEX_HTML).toContain('btn-admin-panel--hidden');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user