feat: admin observability — platform overview, audit log, purge, notifications
- GET /api/admin/overview: platform totals (users, orgs, links, clicks), a 30-day click series, top links, and per-user quota consumption with near-limit flags (org members show pooled usage). - Audit logging wired into all admin mutations: user create/update/delete, password resets, org create/update/delete, purges. Browsable via GET /api/admin/audit and a new Audit Log section in the admin UI. - POST /api/admin/purge hard-deletes soft-deleted links older than N days, freeing their short codes and click history. - Contact-form submissions now fire a WEBHOOK_URL notification. - Fix: deleting a user who owned links or tags failed with a foreign-key 500 — their links/tags are now deliberately orphaned (kept in the DB, unowned) and their API keys removed, matching the UI's wording. - Admin UI: Platform Overview stat cards, quota-usage table with ⚠ NEAR LIMIT badges, audit table, and a purge action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+94
@@ -869,6 +869,16 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px;
|
||||
</div>
|
||||
|
||||
<div id="orgs-list"><div class="empty-state"><p>Loading...</p></div></div>
|
||||
|
||||
<!-- Audit log & maintenance -->
|
||||
<div class="section-head" style="margin-bottom:24px;margin-top:40px">
|
||||
<div><div class="section-title">Audit Log</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Administrative actions on this instance</div></div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn-sm danger" onclick="purgeDeleted()" title="Hard-delete soft-deleted links and free their codes">🗑 Purge deleted links</button>
|
||||
<button class="btn-sm" onclick="loadAudit()">↻</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="audit-list"><div class="empty-state"><p>Loading...</p></div></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
@@ -2224,10 +2234,94 @@ async function doImport() {
|
||||
let allOrgs = [];
|
||||
|
||||
async function loadAdminPanel() {
|
||||
loadAdminOverview();
|
||||
loadAudit();
|
||||
await loadOrgs();
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
// ── Admin — Platform overview & quota usage ────────
|
||||
async function loadAdminOverview() {
|
||||
try {
|
||||
const resp = await authFetch(`${BASE}/api/admin/overview`);
|
||||
if (!resp.ok) return;
|
||||
const d = await resp.json();
|
||||
document.getElementById('astat-users').textContent = d.totals.users.toLocaleString();
|
||||
document.getElementById('astat-orgs').textContent = d.totals.organizations.toLocaleString();
|
||||
document.getElementById('astat-links').textContent = d.totals.active_links.toLocaleString();
|
||||
document.getElementById('astat-clicks30').textContent = d.totals.clicks_30d.toLocaleString();
|
||||
|
||||
const fmtQuota = (used, limit, pct) => limit === null
|
||||
? `${used.toLocaleString()} <span style="opacity:.5">/ ∞</span>`
|
||||
: `${used.toLocaleString()} / ${limit.toLocaleString()} <span style="color:${pct>=100?'#ff5470':pct>=80?'#ffb020':'var(--muted)'}">(${pct}%)</span>`;
|
||||
|
||||
document.getElementById('admin-usage').innerHTML = `
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
|
||||
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
|
||||
<th style="text-align:left;padding:6px 10px">USER</th>
|
||||
<th style="text-align:left;padding:6px 10px">PLAN</th>
|
||||
<th style="text-align:left;padding:6px 10px">ACTIVE LINKS</th>
|
||||
<th style="text-align:left;padding:6px 10px">MONTHLY CLICKS</th>
|
||||
<th style="text-align:left;padding:6px 10px"></th>
|
||||
</tr></thead>
|
||||
<tbody>${d.usage.map(u=>`
|
||||
<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:8px 10px">${escHtml(u.username)}${u.org_name?` <span style="color:var(--muted);font-size:10px">@${escHtml(u.org_name)}</span>`:''}</td>
|
||||
<td style="padding:8px 10px;color:${u.is_admin?'var(--accent)':'var(--muted)'}">${u.plan.toUpperCase()}</td>
|
||||
<td style="padding:8px 10px">${fmtQuota(u.links_used, u.links_limit, u.links_pct)}</td>
|
||||
<td style="padding:8px 10px">${fmtQuota(u.clicks_used, u.clicks_limit, u.clicks_pct)}</td>
|
||||
<td style="padding:8px 10px">${u.near_limit?'<span style="color:#ffb020;font-size:10px;letter-spacing:.06em">⚠ NEAR LIMIT</span>':''}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
// ── Admin — Audit log & maintenance ────────────────
|
||||
async function loadAudit() {
|
||||
const container = document.getElementById('audit-list');
|
||||
try {
|
||||
const resp = await authFetch(`${BASE}/api/admin/audit?limit=100`);
|
||||
if (!resp.ok) { container.innerHTML = '<div class="empty-state"><p>Access denied.</p></div>'; return; }
|
||||
const { entries } = await resp.json();
|
||||
if (!entries.length) { container.innerHTML = '<div class="empty-state"><p>No admin actions recorded yet.</p></div>'; return; }
|
||||
container.innerHTML = `
|
||||
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
|
||||
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
|
||||
<th style="text-align:left;padding:6px 10px">WHEN</th>
|
||||
<th style="text-align:left;padding:6px 10px">ACTOR</th>
|
||||
<th style="text-align:left;padding:6px 10px">ACTION</th>
|
||||
<th style="text-align:left;padding:6px 10px">TARGET</th>
|
||||
<th style="text-align:left;padding:6px 10px">DETAILS</th>
|
||||
</tr></thead>
|
||||
<tbody>${entries.map(e=>`
|
||||
<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:7px 10px;color:var(--muted);white-space:nowrap">${(e.created_at||'').slice(0,16).replace('T',' ')}</td>
|
||||
<td style="padding:7px 10px">${escHtml(e.actor||'—')}</td>
|
||||
<td style="padding:7px 10px;color:var(--accent)">${escHtml(e.action)}</td>
|
||||
<td style="padding:7px 10px">${escHtml(e.target||'')}</td>
|
||||
<td style="padding:7px 10px;color:var(--muted)">${escHtml(e.details||'')}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
} catch(e) { container.innerHTML = '<div class="empty-state"><p>Failed to load audit log.</p></div>'; }
|
||||
}
|
||||
|
||||
async function purgeDeleted() {
|
||||
const days = prompt('Hard-delete links that were soft-deleted more than N days ago.\nTheir short codes become reusable and click history is removed.\n\nN =', '30');
|
||||
if (days === null) return;
|
||||
const n = parseInt(days);
|
||||
if (isNaN(n) || n < 0) { toast('Enter a number of days','error'); return; }
|
||||
if (!confirm(`Permanently purge links deleted more than ${n} day(s) ago? This cannot be undone.`)) return;
|
||||
try {
|
||||
const resp = await authFetch(`${BASE}/api/admin/purge`, {method:'POST', body:JSON.stringify({days:n})});
|
||||
const d = await resp.json();
|
||||
if (!resp.ok) { toast(d.error||'Purge failed','error'); return; }
|
||||
toast(`Purged ${d.purged} link(s)`);
|
||||
loadAudit(); loadAdminOverview();
|
||||
} catch(e) { toast('Network error','error'); }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const container = document.getElementById('users-list');
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user