// Dashboard logins, kept in data/dashboard/users.json (scrypt-hashed passwords, never plain text). // Admins manage them from Settings → Team members. On first run any DASHBOARD_USERS from .env // are copied in so existing logins keep working. import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { DATA_DIR } from "./config.js"; const FILE = path.join(DATA_DIR, "users.json"); export const ROLES = ["admin", "member"]; const MIN_PASSWORD = 10; const err = (message, status = 400) => Object.assign(new Error(message), { status }); const now = () => new Date().toISOString(); const normEmail = (e) => String(e || "").trim().toLowerCase(); const tempPassword = () => crypto.randomBytes(9).toString("base64url"); export function hashPassword(password, salt = crypto.randomBytes(16).toString("hex")) { const hash = crypto.scryptSync(password, salt, 64).toString("hex"); return `scrypt$${salt}$${hash}`; } function verifyPassword(password, stored) { const [algo, salt, hash] = String(stored).split("$"); if (algo !== "scrypt" || !salt || !hash) return false; const test = crypto.scryptSync(password, salt, 64); const expected = Buffer.from(hash, "hex"); return expected.length === test.length && crypto.timingSafeEqual(expected, test); } function checkPassword(p) { if (String(p || "").length < MIN_PASSWORD) throw err(`Use a password of at least ${MIN_PASSWORD} characters.`); } function fromEnv() { try { const list = JSON.parse(process.env.DASHBOARD_USERS || "[]"); return (Array.isArray(list) ? list : []).filter((u) => u.email && u.hash).map((u) => ({ id: crypto.randomUUID().slice(0, 8), email: normEmail(u.email), name: u.name || u.email, role: ROLES.includes(u.role) ? u.role : "member", hash: u.hash, active: true, v: 1, mustChange: false, createdAt: now(), createdBy: ".env", })); } catch { console.error("DASHBOARD_USERS in .env is not valid JSON; starting with no logins."); return []; } } function read() { if (!fs.existsSync(FILE)) write(fromEnv()); return JSON.parse(fs.readFileSync(FILE, "utf8")); } function write(users) { const tmp = `${FILE}.tmp`; fs.writeFileSync(tmp, JSON.stringify(users, null, 2), { mode: 0o600 }); fs.renameSync(tmp, FILE); } const publicUser = (u) => ({ id: u.id, email: u.email, name: u.name, role: u.role, active: u.active, mustChange: Boolean(u.mustChange), createdAt: u.createdAt, createdBy: u.createdBy, lastLoginAt: u.lastLoginAt || null, }); function mutate(id, fn) { const users = read(); const u = users.find((x) => x.id === id); if (!u) throw err("User not found.", 404); const out = fn(u, users); u.updatedAt = now(); write(users); return out ?? publicUser(u); } /** At least one active admin must remain after `change` is applied to `target`. */ function keepAnAdmin(users, target, change) { const after = users.map((u) => (u.id === target.id ? { ...u, ...change } : u)); if (!after.some((u) => u.role === "admin" && u.active)) throw err("There must always be at least one active admin."); } export const hasUsers = () => read().length > 0; export const listUsers = () => read().map(publicUser); /** The session's user, or null if they were removed, deactivated, or their sessions were revoked. */ export function sessionUser(id, v) { const u = read().find((x) => x.id === id); return u && u.active && u.v === v ? publicUser(u) : null; } export function authenticate(email, password) { const users = read(); const u = users.find((x) => x.email === normEmail(email)); if (!u || !u.active || !verifyPassword(String(password || ""), u.hash)) return null; u.lastLoginAt = now(); write(users); return { ...publicUser(u), v: u.v }; } /** Create a login. Without a password a temporary one is generated and returned once. */ export function createUser({ email, name = "", role = "member", password } = {}, by = "admin") { email = normEmail(email); if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw err("Enter a valid email address."); if (!ROLES.includes(role)) throw err("Role must be admin or member."); if (password) checkPassword(password); const users = read(); if (users.some((u) => u.email === email)) throw err("Someone with that email already has a login.", 409); const plain = password || tempPassword(); const u = { id: crypto.randomUUID().slice(0, 8), email, name: String(name).trim() || email, role, hash: hashPassword(plain), active: true, v: 1, mustChange: !password, createdAt: now(), createdBy: by, }; users.push(u); write(users); return { user: publicUser(u), tempPassword: password ? null : plain }; } /** Admin edits: name, role, active. Role/active changes sign the person out everywhere. */ export function updateUser(id, patch = {}, actorId) { return mutate(id, (u, users) => { if ("role" in patch && !ROLES.includes(patch.role)) throw err("Role must be admin or member."); if (id === actorId && (patch.active === false || (patch.role && patch.role !== u.role))) throw err("You can't change your own role or deactivate yourself. Ask another admin."); const change = {}; if ("role" in patch) change.role = patch.role; if ("active" in patch) change.active = Boolean(patch.active); keepAnAdmin(users, u, change); if ("name" in patch && String(patch.name).trim()) u.name = String(patch.name).trim(); if ((change.role && change.role !== u.role) || (change.active !== undefined && change.active !== u.active)) u.v++; Object.assign(u, change); }); } /** Admin reset: new temporary password, all of that person's sessions end. */ export function resetPassword(id) { const plain = tempPassword(); const user = mutate(id, (u) => { u.hash = hashPassword(plain); u.mustChange = true; u.v++; }); return { user, tempPassword: plain }; } export function removeUser(id, actorId) { if (id === actorId) throw err("You can't remove yourself. Ask another admin."); const users = read(); const u = users.find((x) => x.id === id); if (!u) throw err("User not found.", 404); keepAnAdmin(users, u, { active: false }); write(users.filter((x) => x.id !== id)); return { ok: true }; } /** Self-service: name. */ export const renameSelf = (id, name) => { if (!String(name || "").trim()) throw err("Enter your name."); return mutate(id, (u) => { u.name = String(name).trim(); }); }; /** Self-service password change. Ends the person's other sessions; returns the new session version. */ export function changeOwnPassword(id, current, next) { checkPassword(next); return mutate(id, (u) => { if (!verifyPassword(String(current || ""), u.hash)) throw err("Your current password is wrong."); if (current === next) throw err("Choose a password different from the current one."); u.hash = hashPassword(next); u.mustChange = false; u.v++; return { ...publicUser(u), v: u.v }; }); } /** CLI (npm run add-user): create or overwrite one login. */ export function upsertFromCli({ email, name, role, password }) { checkPassword(password); const users = read(); const existing = users.find((u) => u.email === normEmail(email)); if (!existing) return createUser({ email, name, role, password }, "command line"); Object.assign(existing, { name: name || existing.name, role, hash: hashPassword(password), active: true, mustChange: false, v: existing.v + 1, updatedAt: now() }); write(users); return { user: publicUser(existing), tempPassword: null }; }