metatroncubeswdev c309581df0 Initial commit: Quorla social media manager
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>
2026-10-19 13:02:29 -04:00

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 };
}