Dashboard (content library, publishing queue with approvals, inbox, team management, AI runs), Meta connector, branded PDF reports, agent skills, and Docker packaging (Dockerfile, docker-compose with Caddy HTTPS, backup and path-relocation scripts). Client data, live data and secrets are excluded via .gitignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
7.3 KiB
JavaScript
176 lines
7.3 KiB
JavaScript
// 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 };
|
|
}
|