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 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-20 04:31:30 +02:00
parent dafe3de185
commit ac6fe6ef79
4 changed files with 349 additions and 2 deletions
+260
View File
@@ -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 = `
<div class="modal-content" style="max-width:420px">
<div class="modal-header"><h2>Change Password</h2></div>
<div class="modal-body">
<p id="cpMustNote" class="form-hint" style="display:none;color:var(--warning,#c80)">
You must change your password before continuing.</p>
<div class="form-row"><label>Current password</label>
<input type="password" id="cpCurrent" class="form-input" autocomplete="current-password"></div>
<div class="form-row"><label>New password (min 8)</label>
<input type="password" id="cpNew" class="form-input" autocomplete="new-password"></div>
<div class="form-row"><label>Confirm new password</label>
<input type="password" id="cpConfirm" class="form-input" autocomplete="new-password"></div>
<p id="cpError" style="color:var(--error,#c33);min-height:1.2em"></p>
</div>
<div class="modal-footer">
<button class="btn" id="cpCancel">Cancel</button>
<button class="btn btn-primary" id="cpSubmit">Change password</button>
</div>
</div>`;
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 = `
<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>
</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>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--muted,#888)"></p>`;
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 `<tr data-u="${esc(u.username)}">
<td>${esc(u.username)}</td>
<td style="font-size:.85em;color:var(--muted,#888)">${esc(flags)}</td>
<td style="font-size:.85em">${st.liveSessions ?? 0} live · ${st.caseCount ?? 0} cases</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="delete">Delete</button>
</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">Flags</th><th align="left">Usage</th><th></th></tr></thead>
<tbody>${rows}</tbody></table>`;
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 };
})();
+1
View File
@@ -2495,6 +2495,7 @@
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+5 -2
View File
@@ -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,
},
};
});
+83
View File
@@ -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<string, unknown>) {
const dom = new JSDOM(
`<!doctype html><body>
<div class="modal" id="appSettingsModal"><div class="modal-tabs"></div><div class="modal-body"></div></div>
</body>`,
{ url: 'http://localhost/', runScripts: 'outside-only' }
);
const win = dom.window as unknown as Window & typeof globalThis & { __codemanUser?: Record<string, unknown> };
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);
});
});