/* 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(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 += '
';
});
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 = '