From ac6fe6ef7975a98101bd9d5e08a1e95cfb273331 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Mon, 20 Jul 2026 04:31:30 +0200 Subject: [PATCH] feat(multiuser): phase 5b, frontend (identity boot + admin panel) - public/admin-ui.js (new, self-contained): on boot fetches GET /api/me and stores window.__codemanUser; installs a fetch interceptor that opens a change-password modal on any 403 PASSWORD_CHANGE_REQUIRED (and on boot when mustChangePassword is set); for a multi-user admin, injects a "Users" tab into the existing App Settings modal (create/reset/disable/enable/promote/demote/ grant-bypass/delete with typed confirm + one-time-password reveal). No header button, so the mobile-header policy stays green; nothing renders in single-user mode. - me-routes: GET /api/me returns a `multiUser` flag so the UI distinguishes a single-user admin (no admin UI) from a multi-user admin. - index.html: load admin-ui.js after settings-ui.js, before session-ui.js. Tests: test/admin-ui.test.ts (JSDOM: identity boot, Users-tab injection gating by role/mode, forced change-password modal, script-order wiring). Backend verified end-to-end by test/admin-routes.test.ts against a live server. A full Playwright pass is recommended before merge. Co-Authored-By: Claude Fable 5 --- src/web/public/admin-ui.js | 260 ++++++++++++++++++++++++++++++++++++ src/web/public/index.html | 1 + src/web/routes/me-routes.ts | 7 +- test/admin-ui.test.ts | 83 ++++++++++++ 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 src/web/public/admin-ui.js create mode 100644 test/admin-ui.test.ts diff --git a/src/web/public/admin-ui.js b/src/web/public/admin-ui.js new file mode 100644 index 00000000..aaadc98a --- /dev/null +++ b/src/web/public/admin-ui.js @@ -0,0 +1,260 @@ +/** + * @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). + * + * @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 + * + * In single-user mode GET /api/me returns a synthetic admin with multiUser:false, + * so none of the admin UI is shown and behavior is unchanged. + */ +(function () { + 'use strict'; + + const unwrap = (body) => (body && typeof body === 'object' && 'data' in body ? body.data : body); + + async function apiGet(path) { + const res = await window.fetch(path, { headers: { Accept: 'application/json' } }); + return unwrap(await res.json()); + } + async function apiSend(method, path, body) { + const res = await window.fetch(path, { + method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + let json = null; + try { + json = await res.json(); + } catch { + /* empty body */ + } + return { ok: res.ok, status: res.status, body: json, data: unwrap(json) }; + } + + // ── Change-password modal ───────────────────────────────────────────────── + let cpModal = null; + function buildChangePasswordModal() { + if (cpModal) return cpModal; + const el = document.createElement('div'); + el.className = 'modal'; + el.id = 'changePasswordModal'; + el.style.zIndex = '3100'; + el.innerHTML = ` + `; + document.body.appendChild(el); + el.querySelector('#cpCancel').onclick = () => (el.style.display = 'none'); + el.querySelector('#cpSubmit').onclick = async () => { + const current = el.querySelector('#cpCurrent').value; + const nw = el.querySelector('#cpNew').value; + const confirm = el.querySelector('#cpConfirm').value; + const err = el.querySelector('#cpError'); + err.textContent = ''; + if (nw.length < 8) return (err.textContent = 'New password must be at least 8 characters.'); + if (nw !== confirm) return (err.textContent = 'Passwords do not match.'); + const r = await apiSend('POST', '/api/me/password', { currentPassword: current, newPassword: nw }); + if (!r.ok) return (err.textContent = (r.body && r.body.error) || 'Change failed.'); + el.style.display = 'none'; + if (window.app && window.app.showToast) window.app.showToast('Password changed'); + }; + cpModal = el; + return el; + } + function openChangePassword(forced) { + const el = buildChangePasswordModal(); + el.querySelector('#cpMustNote').style.display = forced ? '' : 'none'; + el.querySelector('#cpCancel').style.display = forced ? 'none' : ''; + el.querySelector('#cpError').textContent = ''; + el.style.display = 'flex'; + } + + // ── Fetch interceptor: surface PASSWORD_CHANGE_REQUIRED ─────────────────── + function installInterceptor() { + const orig = window.fetch; + window.fetch = async function (...args) { + const res = await orig.apply(this, args); + if (res.status === 403) { + try { + const clone = res.clone(); + const j = await clone.json(); + if (j && j.errorCode === 'PASSWORD_CHANGE_REQUIRED') openChangePassword(true); + } catch { + /* not JSON */ + } + } + return res; + }; + } + + // ── Admin Users panel (injected into the App Settings modal) ────────────── + function injectUsersTab() { + const modal = document.getElementById('appSettingsModal'); + if (!modal || modal.querySelector('[data-tab="settings-users"]')) return; + const tabs = modal.querySelector('.modal-tabs'); + const body = modal.querySelector('.modal-body'); + if (!tabs || !body) return; + const btn = document.createElement('button'); + btn.className = 'modal-tab-btn'; + btn.dataset.tab = 'settings-users'; + btn.textContent = 'Users'; + tabs.appendChild(btn); + const content = document.createElement('div'); + content.className = 'modal-tab-content hidden'; + content.id = 'settings-users'; + content.innerHTML = ` +
+ Users + +
+

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

+
+

`; + body.appendChild(content); + // Render whenever the tab is shown (the shared switchSettingsTab toggles it). + btn.addEventListener('click', renderUsers); + content.querySelector('#adminAddUser').onclick = addUserFlow; + } + + function esc(s) { + return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); + } + + async function renderUsers() { + const table = document.getElementById('adminUsersTable'); + if (!table) return; + table.innerHTML = 'Loading…'; + let users; + try { + users = await apiGet('/api/admin/users'); + } catch { + table.innerHTML = 'Failed to load users.'; + return; + } + const rows = users + .map((u) => { + const flags = [ + u.role === 'admin' ? 'admin' : 'user', + u.disabled ? 'disabled' : 'enabled', + u.canBypassPermissions ? 'can-bypass' : '', + u.mustChangePassword ? 'must-change-pw' : '', + ] + .filter(Boolean) + .join(', '); + const st = u.stats || {}; + return ` + ${esc(u.username)} + ${esc(flags)} + ${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases + + + + + + + `; + }) + .join(''); + table.innerHTML = ` + + ${rows}
UserFlagsUsage
`; + table.querySelectorAll('button[data-act]').forEach((b) => { + b.onclick = () => + userAction( + b.closest('tr').dataset.u, + b.dataset.act, + users.find((x) => x.username === b.closest('tr').dataset.u) + ); + }); + } + + function setMsg(t) { + const m = document.getElementById('adminUsersMsg'); + if (m) m.textContent = t || ''; + } + + async function userAction(username, act, u) { + if (act === 'role') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { + role: u.role === 'admin' ? 'user' : 'admin', + }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'disabled') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { disabled: !u.disabled }); + setMsg(r.ok ? `Updated ${username}.` : (r.body && r.body.error) || 'Failed.'); + } else if (act === 'bypass') { + const r = await apiSend('PATCH', `/api/admin/users/${encodeURIComponent(username)}`, { + canBypassPermissions: !u.canBypassPermissions, + }); + setMsg(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', `/api/admin/users/${encodeURIComponent(username)}/reset-password`); + if (r.ok && r.data && r.data.oneTimePassword) { + window.prompt(`One-time password for ${username} (copy it now — shown once):`, r.data.oneTimePassword); + } else setMsg((r.body && r.body.error) || 'Reset failed.'); + } else if (act === 'delete') { + const typed = window.prompt(`Type "${username}" to delete this user. Add " +space" to also delete their files.`); + if (typed !== username && typed !== `${username} +space`) return setMsg('Delete cancelled.'); + const deleteSpace = typed.endsWith(' +space'); + const r = await apiSend('DELETE', `/api/admin/users/${encodeURIComponent(username)}`, { deleteSpace }); + setMsg(r.ok ? `Deleted ${username}.` : (r.body && r.body.error) || 'Delete failed.'); + } + renderUsers(); + } + + async function addUserFlow() { + const username = window.prompt('New username (lowercase, 2-32 chars, [a-z0-9_-]):'); + if (!username) return; + const admin = window.confirm('Make this user an admin? (OK = admin, Cancel = regular user)'); + const r = await apiSend('POST', '/api/admin/users', { username: username.trim(), role: admin ? 'admin' : 'user' }); + if (r.ok && r.data && r.data.oneTimePassword) { + window.prompt(`Created ${username}. One-time password (copy it now — shown once):`, r.data.oneTimePassword); + } else setMsg((r.body && r.body.error) || 'Create failed.'); + renderUsers(); + } + + // ── Boot ────────────────────────────────────────────────────────────────── + async function boot() { + installInterceptor(); + let me = null; + try { + me = await apiGet('/api/me'); + } catch { + /* server may be pre-auth */ + } + window.__codemanUser = me || { username: 'admin', role: 'admin', multiUser: false }; + document.dispatchEvent(new CustomEvent('codeman:me', { detail: window.__codemanUser })); + if (window.__codemanUser.mustChangePassword) openChangePassword(true); + if (window.__codemanUser.multiUser && window.__codemanUser.role === 'admin') { + injectUsersTab(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } + + window.codemanAdmin = { openChangePassword, renderUsers }; +})(); diff --git a/src/web/public/index.html b/src/web/public/index.html index c3a6a7be..28db93d3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2495,6 +2495,7 @@ + diff --git a/src/web/routes/me-routes.ts b/src/web/routes/me-routes.ts index f3f06987..ef1ba082 100644 --- a/src/web/routes/me-routes.ts +++ b/src/web/routes/me-routes.ts @@ -26,10 +26,12 @@ const PasswordChangeSchema = z.object({ }); export function registerMeRoutes(app: FastifyInstance, ctx: AuthPort): void { - // GET /api/me — identity probe. Synthetic admin in single-user mode. + // GET /api/me — identity probe. Synthetic admin in single-user mode. The + // `multiUser` flag lets the frontend distinguish a single-user admin (no admin + // UI) from a real multi-user admin. app.get('/api/me', async (req) => { if (!isMultiUserMode()) { - return { success: true, data: { username: 'admin', role: 'admin', mustChangePassword: false } }; + return { success: true, data: { username: 'admin', role: 'admin', mustChangePassword: false, multiUser: false } }; } const user = getAuthUser(req); const record = await findUser(user.username); @@ -39,6 +41,7 @@ export function registerMeRoutes(app: FastifyInstance, ctx: AuthPort): void { username: user.username, role: user.role, mustChangePassword: !!record?.mustChangePassword, + multiUser: true, }, }; }); diff --git a/test/admin-ui.test.ts b/test/admin-ui.test.ts new file mode 100644 index 00000000..72739ae7 --- /dev/null +++ b/test/admin-ui.test.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview Frontend test for admin-ui.js (multi-user identity boot + admin + * Users tab + change-password modal). Builds a JSDOM window in-test under the + * default node env (constructing the DOM in-test avoids the vitest environment + * comment-directive gotcha) and evaluates the real module against it. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; + +const ADMIN_UI = readFileSync(new URL('../src/web/public/admin-ui.js', import.meta.url), 'utf-8'); +const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8'); + +function resp(status: number, body: unknown) { + const r = { + status, + ok: status >= 200 && status < 300, + json: async () => body, + clone() { + return r; + }, + }; + return r; +} + +async function bootWith(me: Record) { + const dom = new JSDOM( + ` + + `, + { url: 'http://localhost/', runScripts: 'outside-only' } + ); + const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record }; + win.fetch = (async (path: string) => { + if (path === '/api/me') return resp(200, { success: true, data: me }); + if (path === '/api/admin/users') return resp(200, { success: true, data: [] }); + return resp(200, { success: true }); + }) as unknown as typeof fetch; + (win as unknown as { eval: (s: string) => void }).eval(ADMIN_UI); + // Let the async boot() (fetch /api/me → DOM inject) settle. + for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0)); + return { dom, win }; +} + +describe('admin-ui boot', () => { + it('exposes the identity and injects the Users tab for a multi-user admin', async () => { + const { win } = await bootWith({ username: 'root', role: 'admin', multiUser: true, mustChangePassword: false }); + expect(win.__codemanUser).toMatchObject({ username: 'root', role: 'admin', multiUser: true }); + const btn = win.document.querySelector('[data-tab="settings-users"]'); + expect(btn).toBeTruthy(); + expect(win.document.getElementById('settings-users')).toBeTruthy(); + }); + + it('does NOT inject the Users tab for a regular user', async () => { + const { win } = await bootWith({ username: 'joe', role: 'user', multiUser: true, mustChangePassword: false }); + expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy(); + }); + + it('does NOT inject the Users tab in single-user mode', async () => { + const { win } = await bootWith({ username: 'admin', role: 'admin', multiUser: false, mustChangePassword: false }); + expect(win.document.querySelector('[data-tab="settings-users"]')).toBeFalsy(); + }); + + it('shows the change-password modal when mustChangePassword is set', async () => { + const { win } = await bootWith({ username: 'dave', role: 'user', multiUser: true, mustChangePassword: true }); + const modal = win.document.getElementById('changePasswordModal') as HTMLElement | null; + expect(modal).toBeTruthy(); + expect(modal!.style.display).toBe('flex'); + // Forced: the cancel button is hidden. + expect((modal!.querySelector('#cpCancel') as HTMLElement).style.display).toBe('none'); + }); +}); + +describe('index.html wiring', () => { + it('loads admin-ui.js after settings-ui.js and before session-ui.js', () => { + const settings = INDEX_HTML.indexOf('settings-ui.js'); + const admin = INDEX_HTML.indexOf('admin-ui.js'); + const session = INDEX_HTML.indexOf('session-ui.js'); + expect(admin).toBeGreaterThan(settings); + expect(session).toBeGreaterThan(admin); + }); +});