Files
BountyBoard/web/static/js/admin.js
T
etalon e8beb7d4f3 feat: sidebar nav (default), group member management, per-customer ticketing identity mapping
- Navigation: new per-user setting (default "side") renders the page links as a
  left sidebar while keeping theme/bell/avatar/logout in the top-right; falls
  back to a top bar under 760px or when set to "top". Profile selector added.
- Group conversations: record a creatorId; the creator gets a "Manage members"
  button to add/remove participants (new GET/POST/DELETE members endpoints,
  creator-only). Existing groups backfilled.
- Customer ticketing: admin can map each assigned consultant to their username
  in that customer's ticketing system. Per-customer mapping takes precedence
  over the consultant's global ticketingIdentities during sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:05:33 +02:00

402 lines
17 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') };
case 'wekan':
return { username: val('c-wekan-user'), password: val('c-wekan-pass') };
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;
}));
}
// Render one username input per selected consultant, prefilled from the
// customer's saved per-consultant ticketing identity map.
function renderIdentities() {
const wrap = document.getElementById('c-identities-wrap');
const host = document.getElementById('c-identities');
const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
wrap.hidden = false;
host.replaceChildren(...selectedIds.map((id) => {
const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
const row = document.createElement('div');
row.className = 'spread';
row.style.marginTop = '6px';
const label = document.createElement('span');
label.className = 'muted';
label.style.flex = '1';
label.textContent = u.name;
const inp = document.createElement('input');
inp.type = 'text';
inp.dataset.identity = id;
inp.placeholder = 'username in ticketing system';
inp.value = existing[id] || '';
inp.style.flex = '1';
row.append(label, inp);
return row;
}));
}
document.getElementById('c-consultants').addEventListener('change', renderIdentities);
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;
});
renderIdentities();
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),
};
const identities = {};
document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
});
body.consultantIdentities = identities;
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();