v1.4 - added admin page and auth
This commit is contained in:
parent
5efdafbb97
commit
1f0aa3078f
29 changed files with 5264 additions and 217 deletions
200
server/src/admin-cli.js
Normal file
200
server/src/admin-cli.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
#!/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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue