Files
Codeman/src/web/public/admin-ui.js
T
Codeman maintainer 45ad9de89e feat(settings): rebuild App Settings as a rail over one scrolling document
The modal had grown to 8 tabs that wrapped onto two rows on desktop and
became a horizontal scroller on phones, with a "Display" mega-tab holding
11 sections and ~35 controls. Local Echo sat 60% down it, and the model
settings were split across two tabs whose three controls fought each
other (the 1M Opus toggle's own hint said it was "ignored when a Claude
Model is selected above").

Replaced with a left rail that is a TABLE OF CONTENTS over one scrolling
document: every section stays mounted, the rail follows the scroll, and
find-in-page works across the whole thing. Nine sections:

  Terminal & Input (Local Echo is the first row of the first section)
  Appearance, Header & Panels, Models, Agents & CLIs,
  Notifications, Voice, Shortcuts, System

Models are now one page. The picker is a card grid of BASE models with a
single "1M context window" switch; context becomes a property of the
chosen model and composes back into `claudeModel` as `base + [1m]`, which
retires the precedence trap. Thinking effort is a segmented control on
the same page, and the old Models tab (task routing) becomes a collapsed
Advanced block under it.

The 12 header-button toggles and the 8 panel toggles become chip grids,
which is most of the old Display tab reclaimed. Rows now say whether a
setting is per-device or synced, stated once per group.

Phones drop the rail for a sticky jump pill that names the current
section and opens a jump list, move Save into the header (the bottom
action bar cost 60px), and render groups as one inset rounded list with
hairline dividers instead of a stack of bordered cards.

Load and save are untouched: every control keeps its id, so
openAppSettings()/saveAppSettings() work as before. Model cards and the
effort segment are views over hidden <select>s that stay the source of
truth. test/app-settings-structure.test.ts pins that contract, plus the
rail hooks admin-ui.js injects the multi-user Users section into.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 03:59:43 +02:00

573 lines
28 KiB
JavaScript

/**
* @fileoverview Multi-user frontend: identity boot, admin Users panel, the
* change-password flow, and the full Admin Panel modal (user CRUD, per-user
* permissions, case-folder management) opened by the header Admin Panel button
* (#adminPanelBtn, revealed for admins in multi-user mode). Self-contained
* (builds its own DOM) so it needs no index.html surgery beyond the script tag
* and button; integrates with the existing App Settings modal by injecting a
* "Users" tab (admins in multi-user mode only). Live-refreshes on the SSE
* admin:usersChanged event (wired in app.js → window.codemanAdmin.onUsersChanged).
*
* @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) ──────────────
// The settings modal is a rail (table of contents) over ONE scrolling
// document, so this appends a rail entry plus a real section rather than a
// tab button plus a hidden panel.
function injectUsersTab() {
const modal = document.getElementById('appSettingsModal');
if (!modal || modal.querySelector('[data-section="settings-users"]')) return;
const rail = modal.querySelector('.set-rail-items');
const body = modal.querySelector('.set-doc');
if (!rail || !body) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'set-rail-item';
btn.dataset.section = 'settings-users';
btn.innerHTML =
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg><span>Users</span>';
rail.appendChild(btn);
const content = document.createElement('section');
content.className = 'set-section';
content.id = 'settings-users';
content.dataset.label = 'Users';
content.innerHTML = `
<div class="set-section-head">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/></svg>
<h2>Users</h2>
</div>
<p class="set-section-blurb">Users share the host account; this separates workspaces, it does not sandbox
users from each other. Pair with Docker cases for isolation.</p>
<div class="set-group">
<div class="set-group-head"><h4>Accounts</h4></div>
<div class="set-group-body">
<div class="set-row">
<div class="set-row-text"><span class="set-row-label">Manage users</span></div>
<div class="set-row-actions">
<button class="btn-toolbar btn-sm" id="adminOpenPanel">Open Admin Panel</button>
<button class="btn-toolbar btn-sm" id="adminAddUser">+ Add user</button>
</div>
</div>
<div id="adminUsersTable"></div>
<p id="adminUsersMsg" style="min-height:1.2em;color:var(--text-muted)"></p>
</div>
</div>`;
body.appendChild(content);
// Render whenever the entry is used (the shared switchSettingsTab scrolls to it).
btn.addEventListener('click', renderUsers);
content.querySelector('#adminAddUser').onclick = addUserFlow;
content.querySelector('#adminOpenPanel').onclick = openAdminPanel;
}
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();
}
// ── Admin Panel (big header-button modal) ─────────────────────────────────
let apModal = null;
let apUsersCache = [];
const apOpenDrawers = new Set(); // usernames with an expanded case-folder drawer
function fmtDate(ts) {
return ts ? new Date(ts).toLocaleString() : 'never';
}
function cssEsc(s) {
return window.CSS && window.CSS.escape ? window.CSS.escape(s) : String(s).replace(/"/g, '\\"');
}
function apSetMsg(t) {
const m = document.getElementById('apMsg');
if (m) m.textContent = t || '';
}
function buildAdminPanel() {
if (apModal) return apModal;
const el = document.createElement('div');
el.className = 'modal';
el.id = 'adminPanelModal';
el.style.zIndex = '3000';
el.innerHTML = `
<div class="modal-content" style="max-width:940px;width:min(96vw,940px)">
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;gap:12px">
<h2 style="display:flex;align-items:center;gap:8px;margin:0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Admin Panel</h2>
<span id="apIdentity" style="color:var(--text-muted,#888);font-size:.85em"></span>
</div>
<div class="modal-body" style="max-height:70vh;overflow-y:auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong>Users</strong>
<button class="btn btn-sm btn-primary" id="apAddToggle">+ Add user</button>
</div>
<div id="apAddForm" style="display:none;border:1px solid var(--border,#333);border-radius:8px;padding:10px;margin-bottom:10px">
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div class="form-row" style="margin:0"><label>Username</label>
<input id="apNewName" class="form-input" placeholder="lowercase a-z 0-9 _ -" style="width:170px"></div>
<div class="form-row" style="margin:0"><label>Role</label>
<select id="apNewRole" class="form-input" style="width:110px">
<option value="user">user</option>
<option value="admin">admin</option>
</select></div>
<div class="form-row" style="margin:0"><label>Password (optional)</label>
<input id="apNewPw" type="password" class="form-input" placeholder="blank = one-time pw"
style="width:170px" autocomplete="new-password"></div>
<label style="display:flex;align-items:center;gap:5px;white-space:nowrap;margin-bottom:6px">
<input type="checkbox" id="apNewBypass"> allow bypass permissions</label>
<button class="btn btn-sm btn-primary" id="apCreateUser" style="margin-bottom:2px">Create</button>
</div>
<p class="form-hint" style="margin:6px 0 0">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.</p>
</div>
<div id="apOtp" style="display:none;border:1px solid var(--accent,#38b6f0);border-radius:8px;padding:10px;margin-bottom:10px"></div>
<div id="apTable">Loading…</div>
<p class="form-hint" style="margin-top:10px">Users share the host OS account: this separates workspaces, it
does not sandbox users from each other. Pair with Docker cases for isolation.</p>
<p id="apMsg" style="min-height:1.2em;color:var(--text-muted,#888)"></p>
</div>
<div class="modal-footer">
<button class="btn" id="apClose">Close</button>
</div>
</div>`;
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 <strong>${esc(username)}</strong> (shown once, copy it now):
<code style="user-select:all;font-size:1.05em;margin:0 8px">${esc(otp)}</code>
<button class="btn btn-xs" id="apOtpCopy">Copy</button>
<button class="btn btn-xs" id="apOtpDismiss">Dismiss</button>`;
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 ? ' <span style="color:var(--accent,#38b6f0)">(you)</span>' : '';
const role = `<span style="font-weight:600;color:${
u.role === 'admin' ? 'var(--accent,#38b6f0)' : 'var(--text-muted,#888)'
}">${u.role}</span>`;
const status = u.disabled
? '<span style="color:var(--red,#c33)">disabled</span>'
: '<span style="color:var(--accent-soft,#4b9)">enabled</span>';
const pwFlag = u.mustChangePassword ? ' · must-change-pw' : '';
return `<tr data-u="${esc(u.username)}">
<td><strong>${esc(u.username)}</strong>${you}</td>
<td>${role}</td>
<td>${status}${pwFlag}</td>
<td>${u.canBypassPermissions ? 'yes' : 'no'}</td>
<td style="white-space:nowrap">${st.liveSessions ?? 0} live · ${st.activeSessions ?? 0} logins ·
<button class="btn btn-xs" data-act="cases">${st.caseCount ?? 0} cases</button></td>
<td style="font-size:.85em;color:var(--text-muted,#888)">${fmtDate(u.lastLoginAt)}</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="logout">Logout</button>
<button class="btn btn-xs" data-act="delete" style="color:var(--red,#c33)">Delete</button>
</td></tr>
<tr data-drawer="${esc(u.username)}" style="display:none"><td colspan="7"></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">Role</th><th align="left">Status</th>
<th align="left">Bypass</th><th align="left">Activity</th><th align="left">Last login</th><th></th>
</tr></thead><tbody>${rows}</tbody></table>`;
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) => `
<li style="display:flex;gap:10px;align-items:center;padding:2px 0">
<code>${esc(c.name)}</code>
<span style="color:var(--text-muted,#888);font-size:.85em">${fmtDate(c.modifiedAt)}</span>
${c.liveSessions ? `<span style="color:var(--yellow,#ca0)">${c.liveSessions} live session(s)</span>` : ''}
<button class="btn btn-xs" data-case="${esc(c.name)}"
${c.liveSessions ? 'disabled title="In use by a live session"' : ''}>Delete</button>
</li>`
)
.join('');
cell.innerHTML = `<div style="padding:6px 4px 6px 16px">
<div style="color:var(--text-muted,#888);font-size:.85em;margin-bottom:4px">${esc(data.dir || '')}</div>
${items ? `<ul style="list-style:none;margin:0;padding:0">${items}</ul>` : 'No case folders yet.'}
</div>`;
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();
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();
// 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');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
window.codemanAdmin = { openChangePassword, renderUsers, openAdminPanel, onUsersChanged };
})();