// ─────────────────────────────────────────────────────────────
// admin/admin.js — pulls invites + joins from the gated
// /api/fenjaops endpoints and renders them. Also wires up the
// "Invite a new user" form (POST /api/fenjaops/invites), which
// only creates non-admin invites. Promoting to admin stays on
// the CLI (bin/invite.js admin add) by design.
//
// Note: the public URL path is `/fenjaops` (not `/admin`) as a
// small obscurity measure. Internal names stay as "admin".
// ─────────────────────────────────────────────────────────────
function iso(ms) {
return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + 'Z';
}
function shortSession(id) {
if (!id) return '';
return id.slice(0, 10) + '…';
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
}[c]));
}
// Email of the currently-authenticated admin, set by load() from
// /auth/me. Used to suppress the "Remove" button on the viewer's
// own row so they can't accidentally delete themselves from the UI
// (the server also rejects this, but hiding the control is clearer).
let currentAdminEmail = null;
function renderInvites(rows) {
const tbody = document.querySelector('#t-invites tbody');
const empty = document.getElementById('empty-invites');
if (!rows.length) {
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((r) => {
const when = new Date(r.invited_at).toISOString().slice(0, 10);
const admin = r.is_admin ? 'Admin' : '';
// Action cell: "Remove" button for non-admin rows that aren't
// the viewer's own. Admin rows require a CLI demote first (see
// the server's cannot_remove_admin guard) — the column shows
// a dash instead. Self row: also a dash.
let action = '—';
if (!r.is_admin && r.email !== currentAdminEmail) {
action = ``;
}
return `
${escapeHtml(r.email)}
${escapeHtml(r.first_name || '')}
${when}
${escapeHtml(r.invited_by || '')}
${admin}
${action}
`;
}).join('');
}
// Event delegation — one listener on the table, dispatched to the
// clicked row-action button. Avoids re-binding every re-render.
(function wireInviteActions() {
const table = document.getElementById('t-invites');
if (!table) return;
table.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.row-action--delete');
if (!btn) return;
const email = btn.dataset.email;
if (!email) return;
if (!window.confirm(`Remove invite for ${email}?\n\nThis also kicks them out of any active session.`)) return;
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'Removing…';
try {
const res = await fetch('/api/fenjaops/invites/' + encodeURIComponent(email), {
method: 'DELETE',
credentials: 'same-origin',
});
if (res.status === 200) {
load(); // refresh the whole board — simplest + shows new counts
return;
}
const payload = await res.json().catch(() => ({}));
const msg = {
invalid_email: 'That email is invalid.',
cannot_remove_self: 'You cannot remove your own admin account.',
cannot_remove_admin:'Demote this admin via the CLI first (bin/invite.js admin remove).',
not_found: 'That invite is already gone.',
}[payload.error] || `Remove failed (HTTP ${res.status}).`;
alert(msg);
btn.disabled = false;
btn.textContent = original;
} catch (err) {
console.error('[admin] remove invite', err);
alert('Network error — see server logs.');
btn.disabled = false;
btn.textContent = original;
}
});
})();
function renderSummary(rows) {
const tbody = document.querySelector('#t-summary tbody');
const empty = document.getElementById('empty-summary');
if (!rows.length) {
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((r) => `
`).join('');
}
async function load() {
try {
const [invitesRes, joinsRes, eventsRes, meRes] = await Promise.all([
fetch('/api/fenjaops/invites', { credentials: 'same-origin' }),
fetch('/api/fenjaops/joins', { credentials: 'same-origin' }),
fetch('/api/fenjaops/events', { credentials: 'same-origin' }),
fetch('/auth/me', { credentials: 'same-origin' }),
]);
if (!invitesRes.ok || !joinsRes.ok || !eventsRes.ok) {
// Session expired or admin flag revoked while the page was open —
// bounce to the front page rather than leaving stale tables.
window.location.href = '/';
return;
}
// Capture the viewer's own email so renderInvites() can suppress
// the delete button on their own row. Non-fatal if this fetch
// fails — worst case the user sees a Remove button on their own
// row and the server's cannot_remove_self guard catches the click.
if (meRes.ok) {
const me = await meRes.json().catch(() => ({}));
currentAdminEmail = (me.email || '').toLowerCase() || null;
}
const invites = await invitesRes.json();
const joins = await joinsRes.json();
const events = await eventsRes.json();
document.getElementById('stat-clicks').textContent = joins.total_clicks;
document.getElementById('stat-unique').textContent = joins.unique_users;
document.getElementById('stat-invites').textContent = invites.length;
document.getElementById('stat-admins').textContent = invites.filter((r) => r.is_admin).length;
renderInvites(invites);
renderSummary(joins.summary);
renderClicks(joins.clicks);
renderEventTotals(events.by_type);
renderEventDevices(events.device_breakdown);
renderEventSummary(events.summary);
renderEvents(events.events);
} catch (err) {
// Network failure — show a soft error rather than a blank page.
const stats = document.getElementById('stats');
stats.innerHTML = '
Failed to load admin data — check the server logs.
';
console.error('[admin]', err);
}
}
const ERROR_COPY = {
invalid_email: 'That email address is not valid.',
invalid_first_name: 'First name is invalid.',
first_name_too_long: 'First name is too long (max 64 characters).',
already_invited: 'That email is already on the invite list.',
};
function setupInviteForm() {
const form = document.getElementById('invite-form');
const msg = document.getElementById('invite-msg');
if (!form || !msg) return;
function show(text, cls) {
msg.textContent = text;
msg.classList.remove('ok', 'err');
msg.classList.add(cls);
msg.hidden = false;
}
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
msg.hidden = true;
const data = new FormData(form);
const email = String(data.get('email') || '').trim();
const firstName = String(data.get('first_name') || '').trim();
const body = { email };
if (firstName) body.first_name = firstName;
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const res = await fetch('/api/fenjaops/invites', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
const payload = await res.json().catch(() => ({}));
if (res.status === 201) {
show(`Invited ${payload.email}.`, 'ok');
form.reset();
load();
return;
}
if (res.status === 401 || res.status === 404) {
window.location.href = '/';
return;
}
const code = payload.error || 'error';
show(ERROR_COPY[code] || `Error: ${code}`, 'err');
} catch (err) {
console.error('[admin] invite submit', err);
show('Network error — check server logs.', 'err');
} finally {
btn.disabled = false;
}
});
}
load();
setupInviteForm();