Files
BountyBoard/web/static/js/admin.js
T
etalon 458ba6a7ee 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>
2026-06-12 18:57:10 +02:00

364 lines
15 KiB
JavaScript

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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
const loaders = {
customers: loadCustomers,
users: loadUsers,
settings: loadSettings,
status: loadStatus,
audit: () => loadAudit(true),
};
loadCustomers();