mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
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:
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 };
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user