diff --git a/CHANGELOG.md b/CHANGELOG.md index b879b98d..aa392fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//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 diff --git a/CLAUDE.md b/CLAUDE.md index f4d61c6b..c556b3c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//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//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 ` +
+
+
+
+ + + +

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.

+ + +
Loading…
+

Users share the host OS account: this separates workspaces, it + does not sandbox users from each other. Pair with Docker cases for isolation.

+

+ + + `; + 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 ${esc(username)} (shown once, copy it now): + ${esc(otp)} + + `; + 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 ? ' (you)' : ''; + const role = `${u.role}`; + const status = u.disabled + ? 'disabled' + : 'enabled'; + const pwFlag = u.mustChangePassword ? ' · must-change-pw' : ''; + return ` + ${esc(u.username)}${you} + ${role} + ${status}${pwFlag} + ${u.canBypassPermissions ? 'yes' : 'no'} + ${st.liveSessions ?? 0} live · ${st.activeSessions ?? 0} logins · + + ${fmtDate(u.lastLoginAt)} + + + + + + + + + `; + }) + .join(''); + table.innerHTML = ` + + + + ${rows}
UserRoleStatusBypassActivityLast login
`; + 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) => ` +
  • + ${esc(c.name)} + ${fmtDate(c.modifiedAt)} + ${c.liveSessions ? `${c.liveSessions} live session(s)` : ''} + +
  • ` + ) + .join(''); + cell.innerHTML = `
    +
    ${esc(data.dir || '')}
    + ${items ? `
      ${items}
    ` : 'No case folders yet.'} +
    `; + 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 }; })(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 286844d3..e2e21891 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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, () => { diff --git a/src/web/public/index.html b/src/web/public/index.html index e07668f9..59386cb3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -87,6 +87,10 @@
    + `, { 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'); + }); });