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>
344 lines
12 KiB
JavaScript
344 lines
12 KiB
JavaScript
// ─────────────────────────────────────────────────────────────
|
||
// 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 ? '<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();
|