- New endpoint: DELETE /api/fenjaops/invites/:email, behind
requireAuth + requireAdmin. Guardrails:
* refuses if the target email is an admin (demote first via
bin/invite.js admin remove) — preserves the invariant that
a compromised admin session can't lock everyone out;
* refuses if the target email equals the caller's own —
prevents self-inflicted lockouts from the UI;
* deletes active sessions for the target email so the user
is kicked out immediately instead of holding their 30-day
cookie.
- Admin page: Invites table gains an "Action" column. Non-admin,
non-self rows show a Remove button (quiet ink outline; crimson
on hover to cue destructive intent). Admin and self rows show
an em-dash. Click → browser confirm() → DELETE → load() to
refresh counts + tables.
- admin.js fetches /auth/me alongside the other payloads so
render can compare each row's email against the viewer's.
- PROJECT.md and CLAUDE.md updated: the "no web deletion"
invariant is narrowed to "no web deletion of admins or self"
to reflect the new capability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
76 lines
2.6 KiB
HTML
76 lines
2.6 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<meta name="robots" content="noindex, nofollow" />
|
||
<title>Admin — Fenja AI</title>
|
||
<link rel="stylesheet" href="/fenjaops/admin.css" />
|
||
</head>
|
||
<body>
|
||
<header class="masthead">
|
||
<h1>Fenja AI <span class="dim">— Admin</span></h1>
|
||
</header>
|
||
|
||
<section class="panel">
|
||
<h2>Invite a new user <span class="dim">— non-admin only</span></h2>
|
||
<form class="invite-form" id="invite-form" novalidate>
|
||
<label>
|
||
<span class="lbl">Email</span>
|
||
<input type="email" name="email" autocomplete="off" required />
|
||
</label>
|
||
<label>
|
||
<span class="lbl">First name <span class="opt">(optional)</span></span>
|
||
<input type="text" name="first_name" maxlength="64" autocomplete="off" />
|
||
</label>
|
||
<button type="submit">Send invite</button>
|
||
<p class="form-msg" id="invite-msg" hidden></p>
|
||
</form>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Stats</h2>
|
||
<div class="stats" id="stats">
|
||
<div class="stat"><span class="stat-k">Total clicks</span><span class="stat-v" id="stat-clicks">–</span></div>
|
||
<div class="stat"><span class="stat-k">Unique users</span><span class="stat-v" id="stat-unique">–</span></div>
|
||
<div class="stat"><span class="stat-k">Invites</span><span class="stat-v" id="stat-invites">–</span></div>
|
||
<div class="stat"><span class="stat-k">Admins</span><span class="stat-v" id="stat-admins">–</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Per-user join summary</h2>
|
||
<table class="t" id="t-summary">
|
||
<thead><tr>
|
||
<th>Email</th><th class="num">Clicks</th><th>First click</th><th>Last click</th>
|
||
</tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
<p class="empty" id="empty-summary" hidden>No join clicks yet.</p>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Invites</h2>
|
||
<table class="t" id="t-invites">
|
||
<thead><tr>
|
||
<th>Email</th><th>Name</th><th>Invited</th><th>By</th><th class="num">Admin</th><th class="num">Action</th>
|
||
</tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
<p class="empty" id="empty-invites" hidden>No invites yet.</p>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<h2>Raw join log <span class="dim">(newest first)</span></h2>
|
||
<table class="t" id="t-clicks">
|
||
<thead><tr>
|
||
<th>When</th><th>Email</th><th class="mono">Session</th>
|
||
</tr></thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
<p class="empty" id="empty-clicks" hidden>No clicks logged yet.</p>
|
||
</section>
|
||
|
||
<script src="/fenjaops/admin.js" defer></script>
|
||
</body>
|
||
</html>
|