// ───────────────────────────────────────────────────────────── // 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])); } 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' : ''; return ` ${escapeHtml(r.email)} ${escapeHtml(r.first_name || '')} ${when} ${escapeHtml(r.invited_by || '')} ${admin} `; }).join(''); } 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(''); } async function load() { try { const [invitesRes, joinsRes] = await Promise.all([ fetch('/api/fenjaops/invites', { credentials: 'same-origin' }), fetch('/api/fenjaops/joins', { credentials: 'same-origin' }), ]); if (!invitesRes.ok || !joinsRes.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; } const invites = await invitesRes.json(); const joins = await joinsRes.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); } 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();