customer-presentation/admin/admin.js
Arlind Ukshini 9f742928d5 fenjaops: surface engagement events on admin page
Adds GET /api/fenjaops/events and three new panels: per-type totals
+ device breakdown, per-user summary (logins / timeline views / last
seen), and a raw event log capped at the newest 500 rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:08:33 +02:00

344 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────
// 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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[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 ? '<span class="badge">Admin</span>' : '';
// 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 = '<span class="dim">—</span>';
if (!r.is_admin && r.email !== currentAdminEmail) {
action = `<button type="button" class="row-action row-action--delete"
data-email="${escapeHtml(r.email)}">Remove</button>`;
}
return `<tr>
<td>${escapeHtml(r.email)}</td>
<td>${escapeHtml(r.first_name || '')}</td>
<td class="when">${when}</td>
<td>${escapeHtml(r.invited_by || '')}</td>
<td class="num">${admin}</td>
<td class="num">${action}</td>
</tr>`;
}).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) => `
<tr>
<td>${escapeHtml(r.email)}</td>
<td class="num">${r.click_count}</td>
<td class="when">${iso(r.first_clicked_at)}</td>
<td class="when">${iso(r.last_clicked_at)}</td>
</tr>
`).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) => `
<tr>
<td class="when">${iso(r.clicked_at)}</td>
<td>${escapeHtml(r.email)}</td>
<td class="mono">${escapeHtml(shortSession(r.session_id))}</td>
</tr>
`).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) => `
<div class="stat">
<span class="stat-k">${escapeHtml(r.device_type)}</span>
<span class="stat-v">${r.n}</span>
</div>
`).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) => `
<tr>
<td>${escapeHtml(r.email)}</td>
<td class="num">${r.logins}</td>
<td class="num">${r.timeline_views}</td>
<td class="when">${iso(r.last_seen)}</td>
</tr>
`).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) => `
<tr>
<td class="when">${iso(r.occurred_at)}</td>
<td>${escapeHtml(r.event_type)}</td>
<td>${escapeHtml(r.email)}</td>
<td>${escapeHtml(deviceLabel(r))}</td>
<td class="mono">${escapeHtml(shortSession(r.session_id))}</td>
<td>${escapeHtml(metaCompact(parseMeta(r.meta)))}</td>
</tr>
`).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 = '<p style="color:var(--admin)">Failed to load admin data — check the server logs.</p>';
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();