NGU-Web/server/src/admin-cli.js
2026-09-25 02:36:49 -05:00

200 lines
6.1 KiB
JavaScript

#!/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();
}