#!/usr/bin/env node /* ═══════════════════════════════════════════════════════════════ ADMIN ACCOUNT CLI The only way an account comes into existence. Run it on the box, against the live database: cd /srv/ngu-api DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js add you@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js list DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js passwd you@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js disable them@ngu.org DB_PATH=/var/lib/ngu/ngu.db node src/admin-cli.js enable them@ngu.org add takes --role=viewer for read-only, --name="Full Name". Press enter at the password prompt and it generates one and prints it once. Changing or disabling a password also drops that person's live sessions, so "disable" takes effect now rather than in 30 days. ═══════════════════════════════════════════════════════════════ */ import { createInterface } from "node:readline"; import { randomBytes } from "node:crypto"; import { openDatabase, migrate } from "./db.js"; import { hashPassword, destroyAllSessionsFor } from "./auth.js"; const MIN_PASSWORD = 12; /* ── Prompt, with echo suppressed for secrets ────────────────── */ function ask(question, { hidden = false } = {}) { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true, }); rl.muted = false; rl._writeToOutput = function (text) { if (!rl.muted) rl.output.write(text); }; rl.question(question, (answer) => { if (hidden) rl.output.write("\n"); rl.close(); resolve(answer); }); rl.muted = hidden; }); } async function readPassword() { const first = await ask("Password (enter to generate): ", { hidden: true }); if (first === "") { const generated = randomBytes(12).toString("base64url"); console.log(`\nGenerated password: ${generated}`); console.log("Copy it now — it isn't stored anywhere readable.\n"); return generated; } if (first.length < MIN_PASSWORD) { fail(`Password must be at least ${MIN_PASSWORD} characters.`); } const second = await ask("Again: ", { hidden: true }); if (first !== second) fail("Passwords didn't match."); return first; } function fail(message) { console.error(`✗ ${message}`); process.exit(1); } /* ── Commands ────────────────────────────────────────────────── */ function findUser(db, email) { return db .prepare("SELECT id, email, name, role, is_active FROM admin_users WHERE email = ?") .get(email); } async function add(db, email, flags) { if (findUser(db, email)) fail(`${email} already exists. Use passwd to change it.`); const role = flags.role ?? "admin"; if (!["admin", "viewer"].includes(role)) fail("Role must be admin or viewer."); const password = await readPassword(); db.prepare( `INSERT INTO admin_users (email, name, password_hash, role) VALUES (?, ?, ?, ?)`, ).run(email, flags.name ?? null, hashPassword(password), role); console.log(`✓ ${email} created as ${role}`); } async function passwd(db, email) { const user = findUser(db, email); if (!user) fail(`No account for ${email}.`); const password = await readPassword(); db.prepare("UPDATE admin_users SET password_hash = ? WHERE id = ?").run( hashPassword(password), user.id, ); destroyAllSessionsFor(db, user.id); console.log(`✓ password changed for ${email}, existing sessions ended`); } function setActive(db, email, active) { const user = findUser(db, email); if (!user) fail(`No account for ${email}.`); db.prepare("UPDATE admin_users SET is_active = ? WHERE id = ?").run( active ? 1 : 0, user.id, ); if (!active) destroyAllSessionsFor(db, user.id); console.log(`✓ ${email} ${active ? "enabled" : "disabled"}`); } function list(db) { const rows = db .prepare( `SELECT u.email, u.name, u.role, u.is_active, u.last_login_at, (SELECT COUNT(*) FROM sessions s WHERE s.user_id = u.id AND s.expires_at > datetime('now')) AS sessions FROM admin_users u ORDER BY u.email`, ) .all(); if (rows.length === 0) { console.log("No accounts yet. Create one with: admin-cli.js add you@ngu.org"); return; } for (const r of rows) { const state = r.is_active ? r.role : "disabled"; const seen = r.last_login_at ?? "never"; console.log( `${r.email.padEnd(32)} ${state.padEnd(9)} last login ${seen.padEnd(20)} ${r.sessions} session(s)`, ); } } /* ── Entry ───────────────────────────────────────────────────── */ const [command, ...rest] = process.argv.slice(2); const flags = {}; const positional = []; for (const arg of rest) { const match = /^--([^=]+)=(.*)$/.exec(arg); if (match) flags[match[1]] = match[2]; else positional.push(arg); } const email = positional[0]?.trim().toLowerCase(); const db = await openDatabase(process.env.DB_PATH ?? "./ngu.db"); migrate(db); // so a fresh database gets the tables before we use them try { switch (command) { case "add": if (!email) fail("Usage: admin-cli.js add email@example.com"); await add(db, email, flags); break; case "passwd": if (!email) fail("Usage: admin-cli.js passwd email@example.com"); await passwd(db, email); break; case "disable": setActive(db, email, false); break; case "enable": setActive(db, email, true); break; case "list": list(db); break; default: console.log("Commands: add, passwd, disable, enable, list"); process.exit(command ? 1 : 0); } } finally { db.close(); }