feat: welcome desk auth and operator password reset
First visit lands on /welcome instead of burying signup in Settings. Add a host CLI to reset passwords without a session, plus Settings change-password. Refresh as-built design docs and the Unraid operator guide.
This commit is contained in:
@@ -14,7 +14,8 @@
|
||||
"db:init": "node --experimental-strip-types src/db/client.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dealer-flow:harvest": "node --experimental-strip-types scripts/dealer-flow-harvest.ts",
|
||||
"dealer-flow:distill": "node --experimental-strip-types scripts/dealer-flow-distill.ts"
|
||||
"dealer-flow:distill": "node --experimental-strip-types scripts/dealer-flow-distill.ts",
|
||||
"reset-password": "node --experimental-strip-types src/cli/reset-password.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trpc/server": "^11.0.0",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Operator password reset. No session required. Trusted because it needs
|
||||
* filesystem access to the SQLite file.
|
||||
*
|
||||
* node --experimental-strip-types src/cli/reset-password.ts --list
|
||||
* node --experimental-strip-types src/cli/reset-password.ts --email you@x --password 'newpass'
|
||||
* node --experimental-strip-types src/cli/reset-password.ts --email you@x --password 'newpass' --clear-2fa
|
||||
*
|
||||
* Unraid (after this file is in the backend image):
|
||||
* docker compose exec backend node --experimental-strip-types src/cli/reset-password.ts --email you@x --password 'newpass'
|
||||
*/
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { resolve } from "node:path";
|
||||
import { hashPassword } from "../trpc/context.ts";
|
||||
import { resetPassword } from "../admin/admin.ts";
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
const i = process.argv.indexOf(name);
|
||||
if (i < 0 || i + 1 >= process.argv.length) return undefined;
|
||||
return process.argv[i + 1];
|
||||
}
|
||||
|
||||
function has(name: string): boolean {
|
||||
return process.argv.includes(name);
|
||||
}
|
||||
|
||||
const dbPath = arg("--db") ?? process.env.IFLOW_DB_PATH ?? resolve(process.cwd(), "data/investor-flow.db");
|
||||
const email = arg("--email");
|
||||
const password = arg("--password");
|
||||
const listOnly = has("--list");
|
||||
const clear2fa = has("--clear-2fa");
|
||||
|
||||
if (has("--help") || (!listOnly && (!email || !password))) {
|
||||
console.log(`Reset an Investor Flow password from the host (no login required).
|
||||
|
||||
Usage:
|
||||
node --experimental-strip-types src/cli/reset-password.ts --list
|
||||
node --experimental-strip-types src/cli/reset-password.ts --email you@x --password 'newpass' [--clear-2fa]
|
||||
[--db PATH] default: $IFLOW_DB_PATH or ./data/investor-flow.db
|
||||
|
||||
Password must be at least 8 characters. This command does not print the new password.`);
|
||||
process.exit(listOnly || has("--help") ? 0 : 1);
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(dbPath);
|
||||
|
||||
if (listOnly) {
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT email, status, is_admin, is_2fa_enabled FROM users WHERE email NOT LIKE 'anonymous%' ORDER BY created_at",
|
||||
)
|
||||
.all() as Array<{ email: string; status: string; is_admin: number; is_2fa_enabled: number }>;
|
||||
if (rows.length === 0) {
|
||||
console.log("No users.");
|
||||
process.exit(0);
|
||||
}
|
||||
for (const r of rows) {
|
||||
console.log(
|
||||
`${r.email}\t${r.status}\t${r.is_admin ? "admin" : "user"}\t${r.is_2fa_enabled ? "2fa" : "no-2fa"}`,
|
||||
);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if ((password ?? "").length < 8) {
|
||||
console.error("Password must be at least 8 characters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const { userId } = resetPassword(db, null, email!, hashPassword(password!));
|
||||
if (clear2fa) {
|
||||
db.prepare("UPDATE users SET is_2fa_enabled=0, totp_secret=NULL, backup_codes_hashed=NULL WHERE id=?").run(userId);
|
||||
}
|
||||
console.log(`Reset ${email}${clear2fa ? " (2FA cleared)" : ""}. Sign in, then change the password in Settings.`);
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : e);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -71,6 +71,23 @@ test('me returns null unauthenticated; prefs when session resolves', async () =>
|
||||
assert.equal(me?.complexity, 'beginner');
|
||||
});
|
||||
|
||||
test('changePassword updates hash; rejects wrong current password', async () => {
|
||||
const { db, freshCtx } = setup();
|
||||
const signupCtx = freshCtx();
|
||||
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
|
||||
db.prepare('UPDATE users SET status=? WHERE id=?').run('active', userId);
|
||||
const { createSession } = await import('../../trpc/context.ts');
|
||||
const { cookie } = createSession(db, userId);
|
||||
const authed = () => appRouter.createCaller(freshCtx(reqWithCookie(cookie.split(';')[0])));
|
||||
await assert.rejects(
|
||||
() => authed().auth.changePassword({ current: 'wrong', next: 'newpass123' }),
|
||||
(e: { code: string }) => e.code === 'UNAUTHORIZED',
|
||||
);
|
||||
await authed().auth.changePassword({ current: 'password123', next: 'newpass123' });
|
||||
const login = await appRouter.createCaller(freshCtx()).auth.login({ email: 'a@b.co', password: 'newpass123' });
|
||||
assert.ok(login.userId);
|
||||
});
|
||||
|
||||
test('logout clears the session cookie', async () => {
|
||||
const { freshCtx } = setup();
|
||||
const ctx = freshCtx();
|
||||
|
||||
@@ -129,6 +129,16 @@ const authRouter = router({
|
||||
ctx.resHeaders.append('Set-Cookie', clearCookie());
|
||||
return { ok: true };
|
||||
}),
|
||||
changePassword: protectedProcedure
|
||||
.input(z.object({ current: z.string(), next: z.string().min(8) }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
const row = ctx.db.prepare('SELECT pw_hash FROM users WHERE id=?').get(ctx.userId) as { pw_hash: string } | undefined;
|
||||
if (!row || row.pw_hash === 'oauth' || !verifyPassword(input.current, row.pw_hash)) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Current password is wrong.' });
|
||||
}
|
||||
ctx.db.prepare('UPDATE users SET pw_hash=? WHERE id=?').run(hashPassword(input.next), ctx.userId);
|
||||
return { ok: true };
|
||||
}),
|
||||
me: publicProcedure.query(({ ctx }) => {
|
||||
if (!ctx.userId) return null;
|
||||
const u = ctx.db.prepare(
|
||||
|
||||
Reference in New Issue
Block a user