#!/usr/bin/env node /** * CLI admin password recovery for ExcaliDash. * * Examples: * node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!" * node scripts/admin-recover.cjs --identifier admin@example.com --generate * * Notes: * - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db). * - Sets the password hash and clears mustResetPassword by default. * - If there are no active admins, this script can promote the target user to ADMIN. */ require("dotenv").config(); const path = require("path"); process.env.DATABASE_URL = process.env.DATABASE_URL || `file:${path.resolve(__dirname, "../prisma/dev.db")}`; const { PrismaClient } = require("../src/generated/client"); const bcrypt = require("bcrypt"); const parseArgs = (argv) => { const args = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) continue; const key = token.slice(2); const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[key] = true; } else { args[key] = next; i += 1; } } return args; }; const generatePassword = () => { // 24 chars base64url-ish const buf = require("crypto").randomBytes(18); return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24); }; const main = async () => { const args = parseArgs(process.argv.slice(2)); const identifier = typeof args.identifier === "string" ? args.identifier.trim() : ""; const providedPassword = typeof args.password === "string" ? args.password : null; const generate = Boolean(args.generate); const setMustReset = Boolean(args["must-reset"]); const activate = Boolean(args.activate); const promote = Boolean(args.promote); const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]); if (!identifier) { console.error("Missing --identifier (email or username)."); process.exitCode = 2; return; } let newPassword = providedPassword; if (!newPassword) { if (!generate) { console.error('Provide --password "" or pass --generate.'); process.exitCode = 2; return; } newPassword = generatePassword(); } if (newPassword.length < 8) { console.error("Password must be at least 8 characters."); process.exitCode = 2; return; } const prisma = new PrismaClient(); try { const activeAdminCount = await prisma.user.count({ where: { role: "ADMIN", isActive: true }, }); const trimmed = identifier.toLowerCase(); const user = await prisma.user.findFirst({ where: { OR: [{ email: trimmed }, { username: identifier }], }, select: { id: true, email: true, username: true, role: true, isActive: true, mustResetPassword: true, }, }); if (!user) { console.error("User not found:", identifier); process.exitCode = 1; return; } const shouldPromote = promote || activeAdminCount === 0; if (user.role !== "ADMIN" && !shouldPromote) { console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user."); console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins."); process.exitCode = 1; return; } const saltRounds = 10; const passwordHash = await bcrypt.hash(newPassword, saltRounds); if (disableLoginRateLimit) { await prisma.systemConfig.upsert({ where: { id: "default" }, update: { authLoginRateLimitEnabled: false }, create: { id: "default", authEnabled: true, registrationEnabled: false, authLoginRateLimitEnabled: false, authLoginRateLimitWindowMs: 15 * 60 * 1000, authLoginRateLimitMax: 20, }, }); } const updated = await prisma.user.update({ where: { id: user.id }, data: { passwordHash, mustResetPassword: setMustReset ? true : false, isActive: activate ? true : user.isActive, role: shouldPromote ? "ADMIN" : user.role, }, select: { id: true, email: true, username: true, role: true, isActive: true, mustResetPassword: true, }, }); console.log("Updated admin account:"); console.log(`- id: ${updated.id}`); console.log(`- email: ${updated.email}`); console.log(`- username: ${updated.username || ""}`); console.log(`- isActive: ${updated.isActive}`); console.log(`- mustResetPassword: ${updated.mustResetPassword}`); console.log(`- role: ${updated.role}`); if (disableLoginRateLimit) { console.log(""); console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false)."); console.log("Remember to re-enable it from the Admin dashboard after you regain access."); } if (generate || !providedPassword) { console.log(""); console.log("New password:"); console.log(newPassword); } else { console.log(""); console.log("Password updated."); } } finally { await prisma.$disconnect().catch(() => {}); } }; main().catch((err) => { console.error("Admin recovery failed:", err); process.exitCode = 1; });