184 lines
5.2 KiB
JavaScript
184 lines
5.2 KiB
JavaScript
#!/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 "<new 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;
|
|
});
|