/fenjaops: add Remove button for non-admin invites
- 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>
This commit is contained in:
parent
06fb151550
commit
2d06f8e513
6 changed files with 130 additions and 4 deletions
|
|
@ -43,7 +43,7 @@ Single-process Express app bound to `127.0.0.1:3000`. Nginx is the only public i
|
|||
|
||||
`bifrost_joins` logs every click of the final "Join Project Bifrost" CTA — one row per click (auto-increment `id`, `email`, `clicked_at`, `session_id`). Writes come from `POST /api/bifrost-join` (behind `requireAuth`); reads come from `bin/joins.js`. See OPERATIONS.md for admin usage.
|
||||
|
||||
**Hidden admin page** at `/fenjaops` (deliberately obscure URL, not `/admin`) — gated by `requireAuth` + `requireAdmin` (`is_admin` column on `invites`). Non-admins get a plain 404 so the URL's existence isn't leaked. Files live in `admin/` at the repo root (outside `public/` and `protected/` so only the explicit route reaches them). Admins can create **non-admin** invites from the page (`POST /api/fenjaops/invites` → stores `is_admin=0`, audit trail records the acting admin's email in `invited_by`). Promotion to admin and removal of invites stay **CLI-only** via `bin/invite.js admin add|remove|list` — a web session compromise cannot escalate the invite list. Internal code keeps the word "admin" (middleware, files, CLI); only the public URL is obscured.
|
||||
**Hidden admin page** at `/fenjaops` (deliberately obscure URL, not `/admin`) — gated by `requireAuth` + `requireAdmin` (`is_admin` column on `invites`). Non-admins get a plain 404 so the URL's existence isn't leaked. Files live in `admin/` at the repo root (outside `public/` and `protected/` so only the explicit route reaches them). Admins can **create** non-admin invites (`POST /api/fenjaops/invites`, stores `is_admin=0`, audit trail records the acting admin in `invited_by`) and **remove** non-admin invites from the page (`DELETE /api/fenjaops/invites/:email`; rejects removing admins or oneself, also kills any active sessions for the deleted email). Admin promotion / demotion stays CLI-only (`bin/invite.js admin add|remove|list`) so a web session compromise cannot escalate or lock everyone out. Internal code keeps the word "admin" (middleware, files, CLI); only the public URL is obscured.
|
||||
|
||||
**Rate limiting** (`src/middleware.js`) is a SQLite-backed sliding window keyed per-IP — 5 code requests/hour, 20 verify attempts/hour. Nginx adds another layer via a `limit_req_zone` declared in `/etc/nginx/nginx.conf`.
|
||||
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ These things define the security model. Breaking any of them is a regression eve
|
|||
- **No inline `<script>` in any HTML** — CSP is strict (`script-src 'self'`). All JS is in separate files.
|
||||
- **Node binds to `127.0.0.1` only.** Nginx is the single ingress.
|
||||
- **`/etc/fenja/env` is minimal** (`PORT`, `PUBLIC_ORIGIN`, `NODE_ENV`), owned `root:fenja` mode 640, never in `/opt/fenja/`. Adding secrets back is a security review.
|
||||
- **No web endpoint can set `is_admin=1` or delete an invite.** The admin page exposes `POST /api/fenjaops/invites` for creating *non-admin* invites only — the handler ignores any `is_admin` field in the body and always stores `0`. Admin promotion (`setInviteAdmin`) and invite deletion (`deleteInvite`) are reachable only via `bin/invite.js` on the VPS. This means a compromised admin session can grow the invite list but cannot escalate anyone (including themselves) or evict existing users.
|
||||
- **No web endpoint can set `is_admin=1`, and no web endpoint can delete or demote an admin.** The admin page exposes `POST /api/fenjaops/invites` for creating *non-admin* invites (the handler ignores any `is_admin` field in the body and always stores `0`), and `DELETE /api/fenjaops/invites/:email` for removing *non-admin* invites (the handler refuses if `is_admin=1` or if the target email equals the caller's own email). Admin promotion (`setInviteAdmin`) and admin demotion / self-deletion are reachable only via `bin/invite.js` on the VPS. This means a compromised admin session can grow or prune the non-admin invite list, but it cannot escalate anyone to admin, demote an existing admin, or lock all other admins out.
|
||||
|
||||
If a change forces one of these to move, it's not a local change — it's a security review.
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,36 @@ html, body {
|
|||
color: var(--admin);
|
||||
font-weight: 700;
|
||||
}
|
||||
.t .dim { color: var(--ink-dim); }
|
||||
|
||||
/* Row action buttons (Remove, …). Quiet by default, crimson on hover
|
||||
so the destructive intent reads clearly before the click. */
|
||||
.row-action {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
transition:
|
||||
background 140ms ease,
|
||||
color 140ms ease,
|
||||
box-shadow 140ms ease;
|
||||
}
|
||||
.row-action:hover {
|
||||
color: #fff;
|
||||
background: var(--admin);
|
||||
box-shadow: inset 0 0 0 1px var(--admin);
|
||||
}
|
||||
.row-action:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 12px 2px 0;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ function escapeHtml(s) {
|
|||
}[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');
|
||||
|
|
@ -40,16 +46,69 @@ function renderInvites(rows) {
|
|||
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');
|
||||
|
|
@ -89,9 +148,10 @@ function renderClicks(rows) {
|
|||
|
||||
async function load() {
|
||||
try {
|
||||
const [invitesRes, joinsRes] = await Promise.all([
|
||||
const [invitesRes, joinsRes, meRes] = await Promise.all([
|
||||
fetch('/api/fenjaops/invites', { credentials: 'same-origin' }),
|
||||
fetch('/api/fenjaops/joins', { credentials: 'same-origin' }),
|
||||
fetch('/auth/me', { credentials: 'same-origin' }),
|
||||
]);
|
||||
|
||||
if (!invitesRes.ok || !joinsRes.ok) {
|
||||
|
|
@ -101,6 +161,15 @@ async function load() {
|
|||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<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>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>
|
||||
|
|
|
|||
27
server.js
27
server.js
|
|
@ -132,6 +132,33 @@ app.post('/api/fenjaops/invites', requireAuth, requireAdmin, (req, res) => {
|
|||
return res.status(201).json({ email: normalized, first_name: firstName });
|
||||
});
|
||||
|
||||
// Remove a non-admin invite from the admin UI. Guardrails:
|
||||
// (1) cannot remove an admin row — demote via the CLI first,
|
||||
// so a compromised admin session can't lock everyone out;
|
||||
// (2) cannot remove yourself — prevents self-inflicted lockouts.
|
||||
// Deleting the invite also deletes any active sessions for that
|
||||
// email so the user is kicked out immediately instead of holding
|
||||
// their 30-day cookie.
|
||||
app.delete('/api/fenjaops/invites/:email', requireAuth, requireAdmin, (req, res) => {
|
||||
const raw = decodeURIComponent(req.params.email || '').trim().toLowerCase();
|
||||
if (!raw || !EMAIL_RE.test(raw)) {
|
||||
return res.status(400).json({ error: 'invalid_email' });
|
||||
}
|
||||
if (raw === req.session.email) {
|
||||
return res.status(400).json({ error: 'cannot_remove_self' });
|
||||
}
|
||||
const invite = q.getInvite.get(raw);
|
||||
if (!invite) {
|
||||
return res.status(404).json({ error: 'not_found' });
|
||||
}
|
||||
if (invite.is_admin) {
|
||||
return res.status(403).json({ error: 'cannot_remove_admin' });
|
||||
}
|
||||
q.deleteInvite.run(raw);
|
||||
q.deleteSessionsForEmail.run(raw);
|
||||
return res.status(200).json({ email: raw, deleted: true });
|
||||
});
|
||||
|
||||
app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
|
||||
res.json({
|
||||
clicks: q.listJoins.all(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue