200 lines
6.1 KiB
JavaScript
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();
|
|
}
|