// ───────────────────────────────────────────────────────────── // 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) => ` ${escapeHtml(r.email)} ${r.click_count} ${iso(r.first_clicked_at)} ${iso(r.last_clicked_at)} `).join(''); } function renderClicks(rows) { const tbody = document.querySelector('#t-clicks tbody'); const empty = document.getElementById('empty-clicks'); if (!rows.length) { tbody.innerHTML = ''; empty.hidden = false; return; } empty.hidden = true; tbody.innerHTML = rows.map((r) => ` ${iso(r.clicked_at)} ${escapeHtml(r.email)} ${escapeHtml(shortSession(r.session_id))} `).join(''); } // ─── Engagement events ─────────────────────────────────────── // Renders the four event views populated from /api/fenjaops/events: // per-type totals, device breakdown, per-user summary, and the raw // event log. Empty payloads are handled the same way as the joins // tables — hide the table and show the .empty paragraph. function parseMeta(s) { if (!s) return null; try { return JSON.parse(s); } catch { return null; } } function metaCompact(m) { if (!m || typeof m !== 'object') return ''; return Object.entries(m).map(([k, v]) => `${k}=${v}`).join(' '); } function deviceLabel(r) { return [r.device_type, r.os, r.browser].filter(Boolean).join('/') || '?'; } function renderEventTotals(byType) { // byType is an array of {event_type, total, unique_users} rows. // Pivot onto the four stat tiles. Missing types render as 0 rather // than '–' once the fetch has completed. const find = (t) => byType.find((r) => r.event_type === t) || { total: 0, unique_users: 0 }; const login = find('login'); const tlv = find('timeline_view'); document.getElementById('stat-event-logins').textContent = login.total; document.getElementById('stat-event-tlv').textContent = tlv.total; document.getElementById('stat-event-login-users').textContent = login.unique_users; document.getElementById('stat-event-tlv-users').textContent = tlv.unique_users; } function renderEventDevices(rows) { const wrap = document.getElementById('event-devices'); if (!rows || !rows.length) { wrap.innerHTML = ''; return; } wrap.innerHTML = rows.map((r) => `
${escapeHtml(r.device_type)} ${r.n}
`).join(''); } function renderEventSummary(rows) { const tbody = document.querySelector('#t-event-summary tbody'); const empty = document.getElementById('empty-event-summary'); if (!rows.length) { tbody.innerHTML = ''; empty.hidden = false; return; } empty.hidden = true; tbody.innerHTML = rows.map((r) => ` ${escapeHtml(r.email)} ${r.logins} ${r.timeline_views} ${iso(r.last_seen)} `).join(''); } function renderEvents(rows) { const tbody = document.querySelector('#t-events tbody'); const empty = document.getElementById('empty-events'); if (!rows.length) { tbody.innerHTML = ''; empty.hidden = false; return; } empty.hidden = true; tbody.innerHTML = rows.map((r) => ` ${iso(r.occurred_at)} ${escapeHtml(r.event_type)} ${escapeHtml(r.email)} ${escapeHtml(deviceLabel(r))} ${escapeHtml(shortSession(r.session_id))} ${escapeHtml(metaCompact(parseMeta(r.meta)))} `).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();