From fb1489e8f0f7456981aa3a8ded6810d5316d2a83 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Mon, 24 Nov 2025 18:55:07 +0200 Subject: [PATCH] feat: add password reset flow --- PROGRESS.md | 1 + app/api/auth/forgot/route.ts | 38 ++++++++++++++++++++ app/api/auth/reset/route.ts | 37 ++++++++++++++++++++ app/auth/forgot/page.tsx | 54 ++++++++++++++++++++++++++++ app/auth/login/page.tsx | 5 +++ app/auth/reset/page.tsx | 68 ++++++++++++++++++++++++++++++++++++ app/components/NavBar.tsx | 12 +++++-- lib/i18n.ts | 24 +++++++++++++ lib/mailer.ts | 7 ++++ 9 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 app/api/auth/forgot/route.ts create mode 100644 app/api/auth/reset/route.ts create mode 100644 app/auth/forgot/page.tsx create mode 100644 app/auth/reset/page.tsx diff --git a/PROGRESS.md b/PROGRESS.md index 258d7b4..f23072b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -41,6 +41,7 @@ - Listing creation form captures address details, coordinates, amenities (incl. EV/AC), and cover image choice. - Documentation moved to `docs/`; PlantUML diagrams rendered to SVG and embedded in docs pages (draw.io sources kept for architecture/infra). - UI polish: navbar buttons gained icons, consistent button sizing, and form fields restyled for alignment. +- Auth: added forgotten password flow (email reset link + reset page). - HTTPS redirect middleware applied to staging/prod ingress. - FI/EN localization with navbar language toggle; UI strings translated; Approvals link shows pending count badge. - Soft rejection/removal states for users/listings with timestamps; owner listing removal; login redirects home; listing visibility hides removed/not-published. diff --git a/app/api/auth/forgot/route.ts b/app/api/auth/forgot/route.ts new file mode 100644 index 0000000..d15535a --- /dev/null +++ b/app/api/auth/forgot/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '../../../../lib/prisma'; +import { randomToken, addHours } from '../../../../lib/tokens'; +import { sendPasswordResetEmail } from '../../../../lib/mailer'; + +const APP_URL = process.env.APP_URL || 'http://localhost:3000'; + +export async function POST(req: Request) { + try { + const body = await req.json(); + const email = String(body.email ?? '').trim().toLowerCase(); + if (!email) { + return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { email }, select: { id: true, emailVerifiedAt: true } }); + if (user) { + const token = randomToken(); + await prisma.verificationToken.create({ + data: { + userId: user.id, + token, + type: 'password_reset', + expiresAt: addHours(2), + }, + }); + const resetUrl = `${APP_URL}/auth/reset?token=${token}`; + await sendPasswordResetEmail(email, resetUrl); + } + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('Forgot password error', error); + return NextResponse.json({ ok: true }); + } +} + +export const dynamic = 'force-dynamic'; diff --git a/app/api/auth/reset/route.ts b/app/api/auth/reset/route.ts new file mode 100644 index 0000000..a1db232 --- /dev/null +++ b/app/api/auth/reset/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { prisma } from '../../../../lib/prisma'; +import { hashPassword } from '../../../../lib/auth'; +import { addHours } from '../../../../lib/tokens'; + +export async function POST(req: Request) { + try { + const body = await req.json(); + const token = String(body.token ?? '').trim(); + const password = String(body.password ?? ''); + + if (!token || !password) { + return NextResponse.json({ error: 'Missing token or password' }, { status: 400 }); + } + if (password.length < 8) { + return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + } + + const record = await prisma.verificationToken.findUnique({ where: { token }, include: { user: true } }); + if (!record || record.type !== 'password_reset' || record.consumedAt || record.expiresAt < new Date()) { + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 }); + } + + const passwordHash = await hashPassword(password); + await prisma.$transaction([ + prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }), + prisma.verificationToken.update({ where: { id: record.id }, data: { consumedAt: new Date(), expiresAt: addHours(-1) } }), + ]); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('Password reset error', error); + return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 }); + } +} + +export const dynamic = 'force-dynamic'; diff --git a/app/auth/forgot/page.tsx b/app/auth/forgot/page.tsx new file mode 100644 index 0000000..3963074 --- /dev/null +++ b/app/auth/forgot/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useState } from 'react'; +import { useI18n } from '../../components/I18nProvider'; + +export default function ForgotPasswordPage() { + const { t } = useI18n(); + const [email, setEmail] = useState(''); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSent(false); + setLoading(true); + try { + const res = await fetch('/api/auth/forgot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || t('forgotError')); + } else { + setSent(true); + } + } catch (err) { + setError(t('forgotError')); + } finally { + setLoading(false); + } + } + + return ( +
+

{t('forgotTitle')}

+

{t('forgotLead')}

+
+ + +
+ {sent ?

{t('forgotSuccess')}

: null} + {error ?

{error}

: null} +
+ ); +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 961ffc5..fe45f11 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -58,6 +58,11 @@ export default function LoginPage() { {loading ? t('loggingIn') : t('loginButton')} +

+ + {t('forgotCta')} + +

{success ?

{t('loginSuccess')}

: null} {error ?

{error}

: null} diff --git a/app/auth/reset/page.tsx b/app/auth/reset/page.tsx new file mode 100644 index 0000000..68e72c6 --- /dev/null +++ b/app/auth/reset/page.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { useI18n } from '../../components/I18nProvider'; + +export default function ResetPasswordPage() { + const { t } = useI18n(); + const searchParams = useSearchParams(); + const [password, setPassword] = useState(''); + const [token, setToken] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const tok = searchParams.get('token') || ''; + setToken(tok); + }, [searchParams]); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setMessage(null); + setError(null); + if (!token) { + setError(t('resetMissingToken')); + return; + } + setLoading(true); + try { + const res = await fetch('/api/auth/reset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, password }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || t('resetError')); + } else { + setMessage(t('resetSuccess')); + setPassword(''); + } + } catch (err) { + setError(t('resetError')); + } finally { + setLoading(false); + } + } + + return ( +
+

{t('resetTitle')}

+

{t('resetLead')}

+
+ + +
+ {message ?

{message}

: null} + {error ?

{error}

: null} + {!token ?

{t('resetMissingToken')}

: null} +
+ ); +} diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index 7778682..ed3d3a8 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -1,13 +1,21 @@ 'use client'; import Link from 'next/link'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, type SVGProps } from 'react'; import { useI18n } from './I18nProvider'; type SessionUser = { id: string; email: string; role: string; status: string }; function Icon({ name }: { name: string }) { - const common = { width: 16, height: 16, stroke: 'currentColor', fill: 'none', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }; + const common: SVGProps = { + width: 16, + height: 16, + stroke: 'currentColor', + fill: 'none', + strokeWidth: 1.6, + strokeLinecap: 'round', + strokeLinejoin: 'round', + }; switch (name) { case 'profile': return ( diff --git a/lib/i18n.ts b/lib/i18n.ts index 81211e0..fcd0f26 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -43,6 +43,18 @@ const allMessages = { registerButton: 'Register', registering: 'Submitting…', registerSuccess: 'Registration successful. Check your email for a verification link.', + forgotTitle: 'Forgot your password?', + forgotLead: 'Enter your email and we will send a reset link.', + forgotSubmit: 'Send reset link', + forgotSuccess: 'If that email exists, a reset link has been sent.', + forgotError: 'Could not send reset link right now.', + resetTitle: 'Reset password', + resetLead: 'Set a new password for your account.', + resetSubmit: 'Set new password', + resetSuccess: 'Password updated. You can log in now.', + resetError: 'Failed to reset password. The link may be invalid or expired.', + resetMissingToken: 'Reset token missing. Please use the link from your email.', + forgotCta: 'Forgot password?', pendingAdminTitle: 'Admin: pending items', pendingUsersTitle: 'Pending users', pendingListingsTitle: 'Pending listings', @@ -211,6 +223,18 @@ const allMessages = { registerButton: 'Rekisteröidy', registering: 'Lähetetään…', registerSuccess: 'Rekisteröinti onnistui. Tarkista sähköpostisi vahvistuslinkin vuoksi.', + forgotTitle: 'Unohditko salasanasi?', + forgotLead: 'Syötä sähköpostisi niin lähetämme palautuslinkin.', + forgotSubmit: 'Lähetä palautuslinkki', + forgotSuccess: 'Jos sähköposti löytyy, palautuslinkki on lähetetty.', + forgotError: 'Linkin lähetys epäonnistui.', + resetTitle: 'Vaihda salasana', + resetLead: 'Aseta uusi salasana tilillesi.', + resetSubmit: 'Aseta uusi salasana', + resetSuccess: 'Salasana vaihdettu. Voit nyt kirjautua sisään.', + resetError: 'Salasanan vaihto epäonnistui. Linkki voi olla vanhentunut.', + resetMissingToken: 'Palautustunniste puuttuu. Käytä sähköpostista saatua linkkiä.', + forgotCta: 'Unohdit salasanan?', pendingAdminTitle: 'Ylläpito: tarkastettavat', pendingUsersTitle: 'Odottavat käyttäjät', pendingListingsTitle: 'Odottavat kohteet', diff --git a/lib/mailer.ts b/lib/mailer.ts index 0ed5915..540dbae 100644 --- a/lib/mailer.ts +++ b/lib/mailer.ts @@ -78,3 +78,10 @@ export async function sendVerificationEmail(to: string, link: string) { const html = `

Please verify your email by clicking this link.

If you did not request this, you can ignore this email.

`; return sendMail({ to, subject, text, html }); } + +export async function sendPasswordResetEmail(to: string, link: string) { + const subject = 'Reset your password for lomavuokraus.fi'; + const text = `We received a request to reset your password.\n\nReset here: ${link}\n\nIf you did not request this, you can ignore this email.`; + const html = `

We received a request to reset your password.

Reset your password

If you did not request this, you can ignore this email.

`; + return sendMail({ to, subject, text, html }); +}