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 = `
+
+
+
+
+ You must change your password before continuing.
+
+
+
+
+
+
+
+
+
+
`;
+ 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 = `
+ | User | Flags | Usage | |
+ ${rows}
`;
+ 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);
+ });
+});