From 8ec43f356180bb807ae2ae158b92a0f6af900752 Mon Sep 17 00:00:00 2001 From: Bonna Moon Date: Mon, 29 Jun 2026 16:44:42 -0500 Subject: [PATCH] Add password recovery page at /recover --- .DS_Store | Bin 8196 -> 8196 bytes scripts/reset-password.ts | 14 ++++ src/app/api/auth/recover/route.ts | 49 ++++++++++++ src/app/login/page.tsx | 5 ++ src/app/recover/page.tsx | 121 ++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 scripts/reset-password.ts create mode 100644 src/app/api/auth/recover/route.ts create mode 100644 src/app/recover/page.tsx diff --git a/.DS_Store b/.DS_Store index da1470ef5ad74f49564ef9a7a048adbc8b8d7858..6d1fa215dd10b908532d3d090baefcc3fb1b0e68 100644 GIT binary patch delta 514 zcmZp1XmOa}FDk>pz`)4BAi%(o&5+7a#E{95&XBjUa2or>2Hwr=94s8dAQij}dJHKH z`3%VrRg)J8Nb{~|U|?SN9}E~6HuDHbGu8_sD`L$YUr0I;;|GP9~VA2Q)7O)f`4y1_m=`JD7Wb25~XC zAY9Cl2J~4DLn^8!29O`@Hp>XEX4Ym0nwbm^fC8X>#i$BFrXxH%`H0AJTM>{dun+UV yhNlDl2NFqQC5LDZaaGrPn$mdP)L)=XY6B0+ciO&9^r?1BRT delta 89 zcmZp1XmOa}FDk~sz`)4BAi&_6lb@WFlb;0S3v4W$#y+uucQZQ&3kRdvW;X$8#?6XC o3z#Rr7hSUXwwMm%#)bmM&Fm82SQt|#zYtTN>?!6270+b^059bli2wiq diff --git a/scripts/reset-password.ts b/scripts/reset-password.ts new file mode 100644 index 0000000..cc56f33 --- /dev/null +++ b/scripts/reset-password.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from "@prisma/client"; +import { hashSync } from "bcryptjs"; + +const db = new PrismaClient(); + +async function main() { + const hash = hashSync("moonbase!", 10); + const result = await db.user.updateMany({ + data: { passwordHash: hash, mustChangePassword: false }, + }); + console.log(`Reset password for ${result.count} user(s). Login with: moonbase!`); +} + +main().catch(console.error).finally(() => db.$disconnect()); diff --git a/src/app/api/auth/recover/route.ts b/src/app/api/auth/recover/route.ts new file mode 100644 index 0000000..17a9ae2 --- /dev/null +++ b/src/app/api/auth/recover/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { hashSync } from "bcryptjs"; +import { db } from "@/lib/db"; + +const schema = z.object({ + secret: z.string().min(1), + username: z.string().min(1), + password: z.string().min(6), +}); + +export async function POST(req: Request) { + const recoverySecret = process.env.RECOVERY_SECRET; + if (!recoverySecret) { + return NextResponse.json({ error: "Recovery not configured" }, { status: 503 }); + } + + try { + const body = await req.json(); + const data = schema.parse(body); + + if (data.secret !== recoverySecret) { + return NextResponse.json({ error: "Invalid recovery secret" }, { status: 403 }); + } + + const user = await db.user.findFirst({ + where: { OR: [{ username: data.username }, { email: data.username }], active: true }, + }); + + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + await db.user.update({ + where: { id: user.id }, + data: { passwordHash: hashSync(data.password, 10), mustChangePassword: false }, + }); + + await db.auditEvent.create({ + data: { action: "auth.recover", entityId: user.id, payload: { username: user.username } }, + }); + + return NextResponse.json({ ok: true, name: user.name }); + } catch (err) { + if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + console.error(err); + return NextResponse.json({ error: "Server error" }, { status: 500 }); + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 06dc449..a79b67e 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -74,6 +74,11 @@ function LoginForm() { +

+ + Forgot password? + +

diff --git a/src/app/recover/page.tsx b/src/app/recover/page.tsx new file mode 100644 index 0000000..abb6396 --- /dev/null +++ b/src/app/recover/page.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function RecoverPage() { + const router = useRouter(); + const [form, setForm] = useState({ secret: "", username: "", password: "", confirm: "" }); + const [status, setStatus] = useState<{ type: "error" | "success"; message: string } | null>(null); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (form.password !== form.confirm) { + setStatus({ type: "error", message: "Passwords don't match." }); + return; + } + setLoading(true); + setStatus(null); + try { + const res = await fetch("/api/auth/recover", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret: form.secret, username: form.username, password: form.password }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error ?? "Failed"); + setStatus({ type: "success", message: `Password reset for ${json.name}. You can now log in.` }); + } catch (err) { + setStatus({ type: "error", message: String(err).replace("Error: ", "") }); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Account Recovery

+

+ Enter the recovery secret from your .env file to reset a password. +

+
+ +
+
+ + setForm(f => ({ ...f, secret: e.target.value }))} + className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="from your .env file" + /> +
+ +
+ + setForm(f => ({ ...f, username: e.target.value }))} + className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + placeholder="bonna" + /> +
+ +
+ + setForm(f => ({ ...f, password: e.target.value }))} + className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ +
+ + setForm(f => ({ ...f, confirm: e.target.value }))} + className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+ + {status && ( +
+ {status.message} +
+ )} + + + + {status?.type === "success" && ( + + )} +
+
+
+ ); +}