feat: admin controls

This commit is contained in:
Zoe
2026-10-15 10:07:41 -05:00
parent b6f4d85ed7
commit ff7797d3f8
41 changed files with 612 additions and 427 deletions
+42 -47
View File
@@ -1,65 +1,60 @@
/**
* Prints a one-time recovery link for an account. Meant for whoever runs the
* server when nobody can sign in as an admin anymore:
*
* bun run recover <email>
*
* It only talks to the database, the server does not have to be running. The
* link works like the ones admins make under Settings → Users: it lets the
* account set a new password, deletes its saved API keys, turns two-factor
* off and signs every device out.
*
* Kept free of imports from the app so it also runs in the production image.
* The link has to be made the same way as in server/utils/recovery.ts.
*/
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { Pool } from 'pg';
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { Pool } from "pg";
const LIFETIME_MS = 24 * 60 * 60 * 1000;
// Standalone so operators can recover an account without running the server.
const main = async (): Promise<number> => {
const email = process.argv[2]?.trim().toLowerCase();
if (!email) {
console.error('Usage: bun run recover <email>');
console.error("Usage: bun run recover <email>");
return 1;
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/veridian',
connectionString: process.env.DATABASE_URL || "postgres://postgres:password@localhost:5432/veridian",
});
const client = await pool.connect();
try {
await client.query("BEGIN");
// Same user-row lock as the web issuance and redemption endpoints.
const found = await client.query<{ id: string; name: string }>(
"SELECT id, name FROM users WHERE lower(email) = $1 FOR UPDATE", [email],
);
const user = found.rows[0];
if (!user) {
await client.query("ROLLBACK");
console.error(`No account with the email ${email}.`);
return 1;
}
const found = await pool.query('SELECT id, name FROM users WHERE lower(email) = $1', [email]);
const user = found.rows[0] as { id: string; name: string } | undefined;
const token = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(token).digest("hex");
const expiresAt = new Date(Date.now() + LIFETIME_MS);
if (!user) {
console.error(`No account with the email ${email}.`);
await pool.end();
await client.query("DELETE FROM recovery_links WHERE user_id = $1", [user.id]);
await client.query(
"INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)",
[randomUUID(), tokenHash, user.id, expiresAt],
);
await client.query("COMMIT");
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? "http://localhost:3000").replace(/\/$/, "");
console.log(`Recovery link for ${user.name} <${email}>:`);
console.log("");
console.log(` ${baseUrl}/auth/recover/${token}`);
console.log("");
console.log("It works once and expires in 24 hours. Using it deletes the saved API keys");
console.log("of this account, turns two-factor off and signs every device out.");
return 0;
} catch (error) {
await client.query("ROLLBACK");
console.error("Failed to create recovery link:", error);
return 1;
} finally {
client.release();
await pool.end();
}
const token = randomBytes(32).toString('base64url');
const tokenHash = createHash('sha256').update(token).digest('hex');
const expiresAt = new Date(Date.now() + LIFETIME_MS);
// only one link works at a time
await pool.query('DELETE FROM recovery_links WHERE user_id = $1', [user.id]);
await pool.query(
'INSERT INTO recovery_links (id, token_hash, user_id, reset_security, expires_at) VALUES ($1, $2, $3, true, $4)',
[randomUUID(), tokenHash, user.id, expiresAt],
);
await pool.end();
const baseUrl = (process.env.NUXT_PUBLIC_URL ?? 'http://localhost:3000').replace(/\/$/, '');
console.log(`Recovery link for ${user.name} <${email}>:`);
console.log('');
console.log(` ${baseUrl}/auth/recover/${token}`);
console.log('');
console.log('It works once and expires in 24 hours. Using it deletes the saved API keys');
console.log('of this account, turns two-factor off and signs every device out.');
return 0;
};
process.exit(await main());