/* Users tab of #/admin (optional user management): table with filters, * per-row actions, and a detail panel with the mail delivery timeline and * audit log. Mounted by admin.js after its admin check. Every dynamic value * goes through escapeHtml. * Deep link: #/admin?tab=users&status=&role=&q=&bouncing=1&id= (read on * mount, written back with replaceState so filter changes add no history * entries). Every filter, bouncing included, is applied by the server. */ (function () { 'use strict'; var filters = { status: '', role: '', q: '', bouncing: false }; var openId = null; var loadSeq = 0; var mountSeq = 0; var STATUSES = ['pending', 'active', 'disabled']; var ROLES = ['user', 'admin']; function fmt(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleString(); } catch (_) { return iso; } } function say(text, ok) { CSAuth.say('umMsg', text, ok); } function netErr() { say('Network error, try again.', false); } function chip(event, reason) { var e = String(event || 'sent'); return '' + escapeHtml(e.replace(/_/g, ' ')) + ''; } // Only known filter values and a numeric id are taken from the address bar. function readHash(hash) { var p = new URLSearchParams(String(hash || '').split('?')[1] || ''); var pick = function (v, allowed) { return allowed.indexOf(v) !== -1 ? v : ''; }; var id = p.get('id') || ''; return { status: pick(p.get('status'), STATUSES), role: pick(p.get('role'), ROLES), q: p.get('q') || '', bouncing: p.get('bouncing') === '1', id: /^\d+$/.test(id) ? id : '' }; } function hashFor(f, id) { var p = new URLSearchParams(); p.set('tab', 'users'); if (f.status) p.set('status', f.status); if (f.role) p.set('role', f.role); if (f.q) p.set('q', f.q); if (f.bouncing) p.set('bouncing', '1'); if (id) p.set('id', id); return '#/admin?' + p.toString(); } function syncHash() { var h = hashFor(filters, openId); if (h !== location.hash) history.replaceState(null, '', h); } // Mirrors the server guards (admin_users_handlers.go): disable only active, // enable only disabled, role change not for pending, no self/config-admin removal. function actionsFor(u, me) { me = me || {}; var b = function (act, label) { return ''; }; var self = u.id === me.id; var out = [b('detail', 'Details')]; if (u.status === 'pending') out.push(b('activate', 'Activate'), b('resend', 'Resend mail')); if (u.status === 'active' && !self && !u.configAdmin) out.push(b('disable', 'Disable')); if (u.status === 'disabled') out.push(b('enable', 'Enable')); if (u.status !== 'pending' && !u.configAdmin) out.push(u.role === 'admin' ? b('demote', 'Make user') : b('promote', 'Make admin')); if (!self && !u.configAdmin) out.push(b('delete', 'Delete')); return '
' + out.join('') + '
'; } function rowHtml(u, me) { var mail = u.lastMail ? chip(u.lastMail.lastEvent, u.lastMail.lastReason) : '—'; if (u.emailBouncing) mail += ' bouncing'; return '' + '' + escapeHtml(u.displayName) + (u.configAdmin ? ' config' : '') + '' + '' + escapeHtml(u.email) + '' + '' + escapeHtml(u.role) + '' + '' + escapeHtml(u.status) + '' + (u.activatedManually ? ' manual' : '') + '' + '' + mail + '' + '' + escapeHtml(fmt(u.createdAt)) + '' + '' + escapeHtml(fmt(u.lastLoginAt)) + '' + '' + actionsFor(u, me) + ''; } function detailHtml(d) { var html = '' + '

' + escapeHtml(d.user.displayName) + ' — ' + escapeHtml(d.user.email) + '

' + '

' + escapeHtml(String((d.sessions || []).length)) + ' active session(s). "Opened" depends on tracking pixels: some mail apps load them automatically, others block them.

' + '

Mail

    '; (d.mail || []).forEach(function (m) { html += '
  1. ' + escapeHtml(m.purpose) + ' to ' + escapeHtml(m.to) + ', ' + escapeHtml(fmt(m.sentAt)) + ' ' + chip(m.lastEvent, m.lastReason) + ' '; if (m.events && m.events.length) { html += ''; } html += '
  2. '; }); html += '

Audit

    '; (d.audit || []).forEach(function (a) { html += '
  1. ' + escapeHtml(fmt(a.at)) + ': ' + escapeHtml(a.action) + (a.actorUserId != null ? ' by #' + escapeHtml(String(a.actorUserId)) : '') + '
  2. '; }); return html + '
'; } function load() { var q = new URLSearchParams(); if (filters.status) q.set('status', filters.status); if (filters.role) q.set('role', filters.role); if (filters.q) q.set('q', filters.q); if (filters.bouncing) q.set('bouncing', '1'); var seq = ++loadSeq; return CSAuth.request('GET', '/api/admin/users?' + q.toString()).then(function (r) { var body = document.getElementById('umBody'); if (!body || seq !== loadSeq) return; if (!r.ok) { say(CSAuth.errText(r), false); return; } var me = CSAuth.user(); var html = ''; // The server caps the list at 1000 rows, so a full tbody rebuild is bounded. r.data.forEach(function (u) { html += rowHtml(u, me); }); body.innerHTML = html || 'No users match.'; if (openId) showDetail(openId); }).catch(netErr); } function closeDetail() { openId = null; var el = document.getElementById('umDetail'); if (el) el.hidden = true; syncHash(); } // focus: opened by the admin (not a deep link or a list reload), so move // keyboard focus to the panel heading. function showDetail(id, focus) { openId = id; syncHash(); return CSAuth.request('GET', '/api/admin/users/' + encodeURIComponent(id)).then(function (r) { var el = document.getElementById('umDetail'); if (!el || openId !== id) return; if (!r.ok) { say(CSAuth.errText(r), false); closeDetail(); return; } el.innerHTML = detailHtml(r.data); el.hidden = false; if (focus) { var h = el.querySelector('h3'); if (h) h.focus(); } }).catch(netErr); } var confirmText = { activate: 'Activate this account without mail confirmation? The address will stay unverified.', disable: 'Disable this account? The user is logged out everywhere.', delete: 'Delete this account permanently?', demote: 'Remove admin rights from this user?' }; var endpoints = { activate: ['POST', '/activate'], resend: ['POST', '/resend-activation'], disable: ['POST', '/disable'], enable: ['POST', '/enable'], delete: ['DELETE', ''], promote: ['POST', '/role', { role: 'admin' }], demote: ['POST', '/role', { role: 'user' }] }; function onAction(e) { var btn = e.target.closest('button[data-act]'); if (!btn) return; var act = btn.getAttribute('data-act'); var id = btn.getAttribute('data-id'); if (act === 'detail') { showDetail(id, true); return; } if (act === 'close') { var was = openId; closeDetail(); // ids are numeric (server ids, readHash), safe in the selector. var opener = was && document.querySelector('button[data-act="detail"][data-id="' + was + '"]'); if (opener) opener.focus(); return; } if (act === 'refresh') { CSAuth.request('POST', '/api/admin/users/' + encodeURIComponent(id) + '/mail/' + encodeURIComponent(btn.getAttribute('data-mail')) + '/refresh') .then(function (r) { say(r.ok ? 'Mail status refreshed.' : CSAuth.errText(r), r.ok); load(); }).catch(netErr); return; } if (confirmText[act] && !confirm(confirmText[act])) return; var ep = endpoints[act]; CSAuth.request(ep[0], '/api/admin/users/' + encodeURIComponent(id) + ep[1], ep[2]).then(function (r) { say(r.ok ? 'Done.' : CSAuth.errText(r), r.ok); if (act === 'delete' && r.ok && openId === id) closeDetail(); var me = CSAuth.user(); if (r.ok && (act === 'promote' || act === 'demote') && me && String(me.id) === id) { // Own role changed: refresh so admin-only UI disappears. return CSAuth.refreshMe().then(function () { if (!CSAuth.isAdmin()) location.hash = '#/account'; else load(); }); } return load(); }).catch(netErr); } function mount(container) { var h = readHash(location.hash); filters = { status: h.status, role: h.role, q: h.q, bouncing: h.bouncing }; openId = h.id || null; container.innerHTML = '
' + '
' + '' + '' + '' + '' + '

' + '
' + '' + '' + '
NameEmailRoleStatusLast mailCreatedLast loginActions
' + '
'; document.getElementById('umQ').value = filters.q; document.getElementById('umStatus').value = filters.status; document.getElementById('umRole').value = filters.role; document.getElementById('umBouncing').checked = filters.bouncing; var token = ++mountSeq; var deb = debounce(function () { var q = document.getElementById('umQ'); if (token !== mountSeq || !q) return; filters.q = q.value.trim(); syncHash(); load(); }, 250); document.getElementById('umQ').addEventListener('input', deb); document.getElementById('umStatus').addEventListener('change', function (e) { filters.status = e.target.value; syncHash(); load(); }); document.getElementById('umRole').addEventListener('change', function (e) { filters.role = e.target.value; syncHash(); load(); }); document.getElementById('umBouncing').addEventListener('change', function (e) { filters.bouncing = !!e.target.checked; syncHash(); load(); }); container.querySelector('.um-users').addEventListener('click', onAction); return load(); } function unmount() { openId = null; loadSeq++; mountSeq++; } window.CSAdminUsers = { mount: mount, unmount: unmount, _test: { actionsFor: actionsFor, rowHtml: rowHtml, detailHtml: detailHtml, readHash: readHash, hashFor: hashFor } }; })();