- 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>
121 lines
4 KiB
JavaScript
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) => ({
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
}[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();
|