- 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>
109 lines
4 KiB
JavaScript
109 lines
4 KiB
JavaScript
#!/usr/bin/env node
|
|
// ─────────────────────────────────────────────────────────────
|
|
// bin/invite.js — add / remove / list invites, plus grant/revoke
|
|
// admin on existing invites.
|
|
//
|
|
// Usage:
|
|
// node bin/invite.js add <email> [FirstName]
|
|
// node bin/invite.js remove <email>
|
|
// node bin/invite.js list
|
|
// node bin/invite.js admin add <email> # grant admin
|
|
// node bin/invite.js admin remove <email> # revoke admin
|
|
// node bin/invite.js admin list # show all admins
|
|
//
|
|
// The first name is optional. When present it's used on the welcome
|
|
// screen ("Thank you for your interest, Erik."). When absent the
|
|
// welcome screen falls back to anonymous copy. Re-running `add` on
|
|
// an existing email updates the first name only; invited_at and
|
|
// invited_by are preserved.
|
|
//
|
|
// Admin flag gates the hidden /admin surface (see server.js). Admin
|
|
// grant/revoke operates on existing invite rows only — a non-invited
|
|
// email must be invited first, then promoted.
|
|
// ─────────────────────────────────────────────────────────────
|
|
import { q } from '../src/db.js';
|
|
|
|
const [, , cmd, sub, arg3, arg4] = process.argv;
|
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
|
|
function help() {
|
|
console.log('Usage:');
|
|
console.log(' invite add <email> [FirstName]');
|
|
console.log(' invite remove <email>');
|
|
console.log(' invite list');
|
|
console.log(' invite admin add <email>');
|
|
console.log(' invite admin remove <email>');
|
|
console.log(' invite admin list');
|
|
process.exit(1);
|
|
}
|
|
|
|
switch (cmd) {
|
|
case 'add': {
|
|
const emailArg = sub;
|
|
const nameArg = arg3;
|
|
if (!emailArg || !EMAIL_RE.test(emailArg)) help();
|
|
const email = emailArg.trim().toLowerCase();
|
|
const firstName = nameArg ? nameArg.trim() : null;
|
|
q.upsertInvite.run(email, firstName, Date.now(), 'cli');
|
|
if (firstName) {
|
|
console.log(`Invited ${email} (${firstName})`);
|
|
} else {
|
|
console.log(`Invited ${email}`);
|
|
}
|
|
break;
|
|
}
|
|
case 'remove': {
|
|
const emailArg = sub;
|
|
if (!emailArg || !EMAIL_RE.test(emailArg)) help();
|
|
const email = emailArg.trim().toLowerCase();
|
|
const result = q.deleteInvite.run(email);
|
|
console.log(result.changes > 0 ? `Removed ${email}` : `No invite for ${email}`);
|
|
break;
|
|
}
|
|
case 'list': {
|
|
const rows = q.listInvites.all();
|
|
if (rows.length === 0) {
|
|
console.log('(no invites)');
|
|
} else {
|
|
for (const r of rows) {
|
|
const d = new Date(r.invited_at).toISOString().slice(0, 10);
|
|
const name = r.first_name ? ` [${r.first_name}]` : '';
|
|
const by = r.invited_by ? ` (by ${r.invited_by})` : '';
|
|
const admin = r.is_admin ? ' *ADMIN*' : '';
|
|
console.log(` ${d} ${r.email}${name}${by}${admin}`);
|
|
}
|
|
console.log(`\n${rows.length} invite${rows.length === 1 ? '' : 's'} total.`);
|
|
}
|
|
break;
|
|
}
|
|
case 'admin': {
|
|
if (sub === 'add' || sub === 'remove') {
|
|
const emailArg = arg3;
|
|
if (!emailArg || !EMAIL_RE.test(emailArg)) help();
|
|
const email = emailArg.trim().toLowerCase();
|
|
const invite = q.getInvite.get(email);
|
|
if (!invite) {
|
|
console.log(`No invite for ${email} — invite them first with: invite add ${email}`);
|
|
process.exit(1);
|
|
}
|
|
q.setInviteAdmin.run(sub === 'add' ? 1 : 0, email);
|
|
console.log(sub === 'add' ? `Granted admin to ${email}` : `Revoked admin from ${email}`);
|
|
} else if (sub === 'list') {
|
|
const rows = q.listInvites.all().filter((r) => r.is_admin);
|
|
if (rows.length === 0) {
|
|
console.log('(no admins)');
|
|
} else {
|
|
for (const r of rows) {
|
|
const name = r.first_name ? ` [${r.first_name}]` : '';
|
|
console.log(` ${r.email}${name}`);
|
|
}
|
|
console.log(`\n${rows.length} admin${rows.length === 1 ? '' : 's'} total.`);
|
|
}
|
|
} else {
|
|
help();
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
help();
|
|
}
|