customer-presentation/admin/admin.js
Arlind Ukshini 107284801b add hidden /fenjaops admin page (read-only) + is_admin invite flag
- new is_admin column on invites (migration 4) with DEFAULT 0
- requireAdmin middleware returns 404 for non-admins so the route's
  existence isn't leaked; path obscured as /fenjaops (not /admin)
- admin/ dir lives outside public/ and protected/; only reachable via
  the explicit gated mount + /api/fenjaops/{invites,joins} endpoints
- bin/invite.js gains `admin add|remove|list` subcommands
- OPERATIONS.md + CLAUDE.md + PROJECT.md document the hidden URL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:29:19 +02:00

121 lines
4 KiB
JavaScript

// ─────────────────────────────────────────────────────────────
// admin/admin.js — pulls invites + joins from the gated
// /api/fenjaops endpoints and renders them into the three tables
// in index.html. Read-only: no mutations, no nav links elsewhere.
//
// 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]));
}
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>' : '';
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>
</tr>`;
}).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) => `
<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('');
}
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 = '<p style="color:var(--admin)">Failed to load admin data — check the server logs.</p>';
console.error('[admin]', err);
}
}
load();