${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();
}
// ── 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 = `
Admin Panel
Users
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.
Loading…
Users share the host OS account: this separates workspaces, it
does not sandbox users from each other. Pair with Docker cases for isolation.
`;
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 ${esc(username)} (shown once, copy it now):
${esc(otp)}
`;
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 ? ' (you)' : '';
const role = `${u.role}`;
const status = u.disabled
? 'disabled'
: 'enabled';
const pwFlag = u.mustChangePassword ? ' · must-change-pw' : '';
return `
${esc(u.username)}${you}
${role}
${status}${pwFlag}
${u.canBypassPermissions ? 'yes' : 'no'}
${st.liveSessions ?? 0} live · ${st.activeSessions ?? 0} logins ·
${fmtDate(u.lastLoginAt)}
`;
})
.join('');
table.innerHTML = `
User
Role
Status
Bypass
Activity
Last login
${rows}
`;
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) => `
${esc(c.name)}${fmtDate(c.modifiedAt)}
${c.liveSessions ? `${c.liveSessions} live session(s)` : ''}
`
)
.join('');
cell.innerHTML = `
${esc(data.dir || '')}
${items ? `
${items}
` : 'No case folders yet.'}
`;
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 };
})();