feat: admin area with customers, users, settings, audit log, service status (phase 5)
- customer CRUD wizard backend: per-system credential shapes validated via
connector construction, AES-256-GCM at rest, write-only over the API
- ticketing connectors (jira/azure_devops/youtrack/demo) with test-connection
- user management: roles, disable (revokes sessions), force password reset,
delete; self-demotion/disable/delete guards
- admin-editable runtime settings ({_id:app}) incl. atomizer URL override
- audit log written on every privileged mutation + filterable list API
- service status panel: mongo/atomizer/work-performer health + latency with
late-bound breaker, sync and jobs status providers
- tabbed admin UI (customers wizard dialog, users table, settings, status, audit)
- compose: mongo nofile ulimit 64000 (1024 default crashed WiredTiger)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,21 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.tabs [aria-selected=true] { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent); }
|
||||
|
||||
dialog {
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 24px; max-width: 90vw; max-height: 90vh; overflow: auto;
|
||||
}
|
||||
dialog::backdrop { background: rgba(0,0,0,0.4); }
|
||||
fieldset {
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
margin: 0 0 16px; padding: 16px;
|
||||
}
|
||||
legend { font-weight: 600; padding: 0 8px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row { flex-direction: column; gap: 0; }
|
||||
.topnav { flex-wrap: wrap; }
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { api, toast } from '/static/js/api.js';
|
||||
|
||||
const errorBox = document.getElementById('error');
|
||||
const showErr = (e) => { errorBox.textContent = e.message || String(e); };
|
||||
|
||||
// ---- tabs ----
|
||||
const tabs = document.querySelectorAll('[role=tab]');
|
||||
tabs.forEach((btn) => btn.addEventListener('click', () => {
|
||||
tabs.forEach((b) => b.setAttribute('aria-selected', b === btn ? 'true' : 'false'));
|
||||
document.querySelectorAll('[role=tabpanel]').forEach((p) => { p.hidden = true; });
|
||||
document.getElementById('tab-' + btn.dataset.tab).hidden = false;
|
||||
loaders[btn.dataset.tab]();
|
||||
}));
|
||||
|
||||
// ---- customers ----
|
||||
const dlg = document.getElementById('customer-dialog');
|
||||
let editingCustomer = null; // null = creating
|
||||
let consultants = [];
|
||||
|
||||
function credsFromForm(type) {
|
||||
switch (type) {
|
||||
case 'jira':
|
||||
return { email: val('c-jira-email'), apiToken: val('c-jira-token') };
|
||||
case 'azure_devops':
|
||||
return { organization: val('c-ado-org'), project: val('c-ado-project'), pat: val('c-ado-pat') };
|
||||
case 'youtrack':
|
||||
return { permanentToken: val('c-yt-token') };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const val = (id) => document.getElementById(id).value.trim();
|
||||
|
||||
function credsEntered(type) {
|
||||
const c = credsFromForm(type);
|
||||
return Object.values(c).some(Boolean);
|
||||
}
|
||||
|
||||
document.getElementById('c-type').addEventListener('change', (e) => {
|
||||
document.querySelectorAll('.creds').forEach((f) => { f.hidden = true; });
|
||||
document.getElementById('creds-' + e.target.value).hidden = false;
|
||||
document.getElementById('f-baseurl').style.display =
|
||||
e.target.value === 'demo' ? 'none' : '';
|
||||
});
|
||||
|
||||
async function loadConsultants() {
|
||||
const res = await api('GET', '/api/v1/admin/users?role=consultant&limit=200');
|
||||
consultants = res.users;
|
||||
const sel = document.getElementById('c-consultants');
|
||||
sel.replaceChildren(...consultants.map((u) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = u.id;
|
||||
o.textContent = `${u.name} <${u.email}>`;
|
||||
return o;
|
||||
}));
|
||||
}
|
||||
|
||||
function openCustomerDialog(customer) {
|
||||
editingCustomer = customer || null;
|
||||
document.getElementById('customer-dialog-title').textContent =
|
||||
customer ? `Edit ${customer.name}` : 'New customer';
|
||||
document.getElementById('c-name').value = customer ? customer.name : '';
|
||||
document.getElementById('c-type').value = customer ? customer.ticketing.type : 'jira';
|
||||
document.getElementById('c-project').value = customer ? customer.ticketing.projectKey : '';
|
||||
document.getElementById('c-baseurl').value = customer ? customer.ticketing.baseUrl : '';
|
||||
document.getElementById('c-poll').value = customer ? customer.ticketing.pollIntervalSec : 60;
|
||||
document.getElementById('c-budget').value = customer ? customer.defaultBudget : 1000;
|
||||
document.querySelectorAll('.creds input').forEach((i) => { i.value = ''; });
|
||||
document.getElementById('c-type').dispatchEvent(new Event('change'));
|
||||
const sel = document.getElementById('c-consultants');
|
||||
[...sel.options].forEach((o) => {
|
||||
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
||||
});
|
||||
document.getElementById('c-test-result').textContent =
|
||||
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
||||
dlg.showModal();
|
||||
}
|
||||
|
||||
document.getElementById('customer-new').addEventListener('click', async () => {
|
||||
try { await loadConsultants(); openCustomerDialog(null); } catch (e) { showErr(e); }
|
||||
});
|
||||
document.getElementById('c-cancel').addEventListener('click', () => dlg.close());
|
||||
|
||||
document.getElementById('c-test').addEventListener('click', async () => {
|
||||
const out = document.getElementById('c-test-result');
|
||||
out.textContent = 'Testing…';
|
||||
const type = val('c-type');
|
||||
try {
|
||||
let res;
|
||||
if (editingCustomer && !credsEntered(type)) {
|
||||
res = await api('POST', `/api/v1/admin/customers/${editingCustomer.id}/test-connection`, {});
|
||||
} else {
|
||||
res = await api('POST', '/api/v1/admin/customers/test-connection', {
|
||||
type, baseUrl: val('c-baseurl'), projectKey: val('c-project'),
|
||||
credentials: credsFromForm(type),
|
||||
});
|
||||
}
|
||||
out.textContent = res.ok ? `✓ Connected (${res.latencyMs} ms)` : `✗ ${res.error}`;
|
||||
out.style.color = res.ok ? 'var(--ok)' : 'var(--err)';
|
||||
} catch (e) { out.textContent = '✗ ' + e.message; out.style.color = 'var(--err)'; }
|
||||
});
|
||||
|
||||
document.getElementById('customer-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const type = val('c-type');
|
||||
const body = {
|
||||
name: val('c-name'),
|
||||
type,
|
||||
baseUrl: val('c-baseurl'),
|
||||
projectKey: val('c-project'),
|
||||
pollIntervalSec: parseInt(val('c-poll'), 10) || 60,
|
||||
defaultBudget: parseFloat(val('c-budget')) || 0,
|
||||
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
||||
};
|
||||
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
||||
try {
|
||||
if (editingCustomer) {
|
||||
body.version = editingCustomer.version;
|
||||
await api('PATCH', `/api/v1/admin/customers/${editingCustomer.id}`, body);
|
||||
} else {
|
||||
await api('POST', '/api/v1/admin/customers', body);
|
||||
}
|
||||
dlg.close();
|
||||
toast('Customer saved.', 'ok');
|
||||
loadCustomers();
|
||||
} catch (err) {
|
||||
if (err.status === 409) toast('Conflict: ' + err.message, 'err');
|
||||
else showErr(err);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/admin/customers?includeArchived=true');
|
||||
const host = document.getElementById('customer-list');
|
||||
host.replaceChildren(...res.customers.map(renderCustomerCard));
|
||||
if (!res.customers.length) {
|
||||
host.textContent = 'No customers yet — create the first one.';
|
||||
}
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
|
||||
function renderCustomerCard(c) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const syncBadge = c.ticketing.lastSyncStatus === 'ok'
|
||||
? `<span class="badge" style="color:var(--ok)">sync ok</span>`
|
||||
: c.ticketing.lastSyncStatus === 'error'
|
||||
? `<span class="badge" style="color:var(--err)" title="${esc(c.ticketing.lastSyncError)}">sync error</span>`
|
||||
: '<span class="badge">never synced</span>';
|
||||
card.innerHTML = `
|
||||
<div class="spread">
|
||||
<h3>${esc(c.name)} ${c.archived ? '<span class="badge">archived</span>' : ''}</h3>
|
||||
<span>${syncBadge}</span>
|
||||
</div>
|
||||
<p class="muted">${esc(c.ticketing.type)} · project ${esc(c.ticketing.projectKey || '—')} ·
|
||||
poll ${c.ticketing.pollIntervalSec}s · budget ${c.defaultBudget} ·
|
||||
${c.consultantIds.length} consultant(s)
|
||||
${c.ticketing.lastSyncAt && !c.ticketing.lastSyncAt.startsWith('0001') ? ' · last sync ' + new Date(c.ticketing.lastSyncAt).toLocaleString() : ''}</p>
|
||||
<div>
|
||||
<button class="btn small" data-act="edit">Edit</button>
|
||||
<button class="btn small" data-act="sync">Sync now</button>
|
||||
<button class="btn small" data-act="archive">${c.archived ? 'Unarchive' : 'Archive'}</button>
|
||||
<button class="btn small danger" data-act="delete">Delete</button>
|
||||
</div>`;
|
||||
card.querySelector('[data-act=edit]').addEventListener('click', async () => {
|
||||
try { await loadConsultants(); openCustomerDialog(c); } catch (e) { showErr(e); }
|
||||
});
|
||||
card.querySelector('[data-act=sync]').addEventListener('click', async () => {
|
||||
try { await api('POST', `/api/v1/admin/customers/${c.id}/sync-now`, {}); toast('Sync queued.', 'ok'); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
card.querySelector('[data-act=archive]').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('PATCH', `/api/v1/admin/customers/${c.id}`, { archived: !c.archived, version: c.version });
|
||||
loadCustomers();
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
card.querySelector('[data-act=delete]').addEventListener('click', async () => {
|
||||
if (!confirm(`Delete customer "${c.name}"? This cannot be undone.`)) return;
|
||||
try { await api('DELETE', `/api/v1/admin/customers/${c.id}`); loadCustomers(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---- users ----
|
||||
let userSearchTimer;
|
||||
document.getElementById('user-search').addEventListener('input', () => {
|
||||
clearTimeout(userSearchTimer);
|
||||
userSearchTimer = setTimeout(loadUsers, 250);
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const q = encodeURIComponent(document.getElementById('user-search').value.trim());
|
||||
const res = await api('GET', `/api/v1/admin/users?q=${q}&limit=200`);
|
||||
const tbody = document.querySelector('#user-table tbody');
|
||||
tbody.replaceChildren(...res.users.map(renderUserRow));
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
|
||||
function renderUserRow(u) {
|
||||
const tr = document.createElement('tr');
|
||||
const roleCell = (role) => {
|
||||
const td = document.createElement('td');
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = u.roles[role];
|
||||
cb.setAttribute('aria-label', `${role} role for ${u.name}`);
|
||||
cb.addEventListener('change', async () => {
|
||||
const roles = { ...u.roles, [role]: cb.checked };
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { roles });
|
||||
u = res.user;
|
||||
toast(`Roles updated for ${u.name}.`, 'ok');
|
||||
} catch (e) { cb.checked = !cb.checked; toast(e.message, 'err'); }
|
||||
});
|
||||
td.appendChild(cb);
|
||||
return td;
|
||||
};
|
||||
const name = document.createElement('td'); name.textContent = u.name;
|
||||
const email = document.createElement('td'); email.textContent = u.email;
|
||||
const status = document.createElement('td');
|
||||
status.textContent = u.disabled ? 'disabled' : 'active';
|
||||
const actions = document.createElement('td');
|
||||
|
||||
const disableBtn = document.createElement('button');
|
||||
disableBtn.className = 'btn small';
|
||||
disableBtn.textContent = u.disabled ? 'Enable' : 'Disable';
|
||||
disableBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await api('PATCH', `/api/v1/admin/users/${u.id}`, { disabled: !u.disabled });
|
||||
u = res.user; status.textContent = u.disabled ? 'disabled' : 'active';
|
||||
disableBtn.textContent = u.disabled ? 'Enable' : 'Disable';
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.className = 'btn small';
|
||||
resetBtn.textContent = 'Force reset';
|
||||
resetBtn.addEventListener('click', async () => {
|
||||
try { await api('PATCH', `/api/v1/admin/users/${u.id}`, { forceReset: true }); toast('Password reset forced.', 'ok'); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'btn small danger';
|
||||
delBtn.textContent = 'Delete';
|
||||
delBtn.addEventListener('click', async () => {
|
||||
if (!confirm(`Delete user ${u.email}?`)) return;
|
||||
try { await api('DELETE', `/api/v1/admin/users/${u.id}`); tr.remove(); }
|
||||
catch (e) { toast(e.message, 'err'); }
|
||||
});
|
||||
actions.append(disableBtn, document.createTextNode(' '), resetBtn, document.createTextNode(' '), delBtn);
|
||||
tr.append(name, email, roleCell('admin'), roleCell('consultant'), roleCell('developer'), status, actions);
|
||||
return tr;
|
||||
}
|
||||
|
||||
// ---- settings ----
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await api('GET', '/api/v1/admin/settings');
|
||||
const s = res.settings;
|
||||
document.getElementById('s-branding').value = s.brandingName;
|
||||
document.getElementById('s-atomizer').value = s.atomizerBaseUrlOverride;
|
||||
document.getElementById('s-poll').value = s.defaultPollIntervalSec;
|
||||
document.getElementById('s-budget').value = s.defaultCustomerBudget;
|
||||
document.getElementById('s-hideclaims').checked = s.hideCompetingClaims;
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
|
||||
document.getElementById('settings-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api('PATCH', '/api/v1/admin/settings', {
|
||||
brandingName: val('s-branding'),
|
||||
atomizerBaseUrlOverride: val('s-atomizer'),
|
||||
defaultPollIntervalSec: parseInt(val('s-poll'), 10),
|
||||
defaultCustomerBudget: parseFloat(val('s-budget')),
|
||||
hideCompetingClaims: document.getElementById('s-hideclaims').checked,
|
||||
});
|
||||
toast('Settings saved.', 'ok');
|
||||
} catch (err) { toast(err.message, 'err'); }
|
||||
});
|
||||
|
||||
// ---- status ----
|
||||
async function loadStatus() {
|
||||
const host = document.getElementById('status-body');
|
||||
host.textContent = 'Loading…';
|
||||
try {
|
||||
const s = await api('GET', '/api/v1/admin/service-status');
|
||||
const ext = (e) => `
|
||||
<div class="spread">
|
||||
<span><strong>${esc(e.name)}</strong> <span class="muted">${esc(e.url)}</span></span>
|
||||
<span>${e.healthy
|
||||
? `<span class="badge" style="color:var(--ok)">healthy · ${e.latencyMs} ms</span>`
|
||||
: `<span class="badge" style="color:var(--err)" title="${esc(e.error || '')}">down</span>`}
|
||||
<span class="badge">breaker: ${esc(e.breaker)}</span></span>
|
||||
</div>`;
|
||||
let workers = '<p class="muted">No sync workers running.</p>';
|
||||
if (Array.isArray(s.syncWorkers) && s.syncWorkers.length) {
|
||||
workers = s.syncWorkers.map((wkr) => `
|
||||
<div class="spread">
|
||||
<span>${esc(wkr.customerName)} <span class="muted">every ${wkr.pollIntervalSec}s</span></span>
|
||||
<span class="badge" style="color:${wkr.lastStatus === 'error' ? 'var(--err)' : 'var(--ok)'}"
|
||||
title="${esc(wkr.lastError || '')}">${esc(wkr.lastStatus || 'pending')}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
host.innerHTML = `
|
||||
<div class="spread"><strong>MongoDB</strong>
|
||||
${s.mongo.healthy ? '<span class="badge" style="color:var(--ok)">healthy</span>'
|
||||
: `<span class="badge" style="color:var(--err)">${esc(s.mongo.error)}</span>`}</div>
|
||||
${ext(s.atomizer)}
|
||||
${ext(s.workPerformer)}
|
||||
<h3>Sync workers</h3>${workers}
|
||||
<h3>Job queue</h3>
|
||||
<p class="muted">${esc(JSON.stringify(s.jobs))}</p>`;
|
||||
} catch (e) { host.textContent = e.message; }
|
||||
}
|
||||
document.getElementById('status-refresh').addEventListener('click', loadStatus);
|
||||
|
||||
// ---- audit ----
|
||||
let auditCursor = '';
|
||||
async function loadAudit(reset = true) {
|
||||
try {
|
||||
const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim());
|
||||
if (reset) auditCursor = '';
|
||||
const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`);
|
||||
auditCursor = res.nextCursor;
|
||||
const rows = res.entries.map((e) => {
|
||||
const tr = document.createElement('tr');
|
||||
const cells = [
|
||||
new Date(e.at).toLocaleString(), e.actorId, e.action,
|
||||
`${e.entity}/${e.entityId}`, e.diff ? JSON.stringify(e.diff) : '',
|
||||
];
|
||||
cells.forEach((c) => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = c;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
const tbody = document.querySelector('#audit-table tbody');
|
||||
if (reset) tbody.replaceChildren(...rows);
|
||||
else tbody.append(...rows);
|
||||
} catch (e) { showErr(e); }
|
||||
}
|
||||
document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));
|
||||
document.getElementById('audit-more').addEventListener('click', () => loadAudit(false));
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
|
||||
const loaders = {
|
||||
customers: loadCustomers,
|
||||
users: loadUsers,
|
||||
settings: loadSettings,
|
||||
status: loadStatus,
|
||||
audit: () => loadAudit(true),
|
||||
};
|
||||
loadCustomers();
|
||||
Reference in New Issue
Block a user