feat: add password reset flow

This commit is contained in:
Tero Halla-aho 2025-11-24 18:55:07 +02:00
parent 6d74532cbf
commit fb1489e8f0
9 changed files with 244 additions and 2 deletions

View file

@ -41,6 +41,7 @@
- Listing creation form captures address details, coordinates, amenities (incl. EV/AC), and cover image choice. - 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). - 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. - 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. - HTTPS redirect middleware applied to staging/prod ingress.
- FI/EN localization with navbar language toggle; UI strings translated; Approvals link shows pending count badge. - 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. - Soft rejection/removal states for users/listings with timestamps; owner listing removal; login redirects home; listing visibility hides removed/not-published.

View file

@ -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';

View file

@ -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';

54
app/auth/forgot/page.tsx Normal file
View file

@ -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<string | null>(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 (
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
<h1>{t('forgotTitle')}</h1>
<p style={{ color: '#cbd5e1', marginTop: 6 }}>{t('forgotLead')}</p>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12, marginTop: 14 }}>
<label>
{t('emailLabel')}
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<button className="button" type="submit" disabled={loading}>
{loading ? t('submittingListing') : t('forgotSubmit')}
</button>
</form>
{sent ? <p style={{ marginTop: 12, color: 'green' }}>{t('forgotSuccess')}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
</main>
);
}

View file

@ -58,6 +58,11 @@ export default function LoginPage() {
{loading ? t('loggingIn') : t('loginButton')} {loading ? t('loggingIn') : t('loginButton')}
</button> </button>
</form> </form>
<p style={{ marginTop: 12 }}>
<a href="/auth/forgot" className="button secondary">
{t('forgotCta')}
</a>
</p>
{success ? <p style={{ marginTop: 12, color: 'green' }}>{t('loginSuccess')}</p> : null} {success ? <p style={{ marginTop: 12, color: 'green' }}>{t('loginSuccess')}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null} {error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
</main> </main>

68
app/auth/reset/page.tsx Normal file
View file

@ -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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<main className="panel" style={{ maxWidth: 480, margin: '40px auto' }}>
<h1>{t('resetTitle')}</h1>
<p style={{ color: '#cbd5e1', marginTop: 6 }}>{t('resetLead')}</p>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 12, marginTop: 14 }}>
<label>
{t('passwordLabel')}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
</label>
<button className="button" type="submit" disabled={loading}>
{loading ? t('saving') : t('resetSubmit')}
</button>
</form>
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
{!token ? <p style={{ marginTop: 12, color: '#f59e0b' }}>{t('resetMissingToken')}</p> : null}
</main>
);
}

View file

@ -1,13 +1,21 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState, type SVGProps } from 'react';
import { useI18n } from './I18nProvider'; import { useI18n } from './I18nProvider';
type SessionUser = { id: string; email: string; role: string; status: string }; type SessionUser = { id: string; email: string; role: string; status: string };
function Icon({ name }: { name: 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<SVGSVGElement> = {
width: 16,
height: 16,
stroke: 'currentColor',
fill: 'none',
strokeWidth: 1.6,
strokeLinecap: 'round',
strokeLinejoin: 'round',
};
switch (name) { switch (name) {
case 'profile': case 'profile':
return ( return (

View file

@ -43,6 +43,18 @@ const allMessages = {
registerButton: 'Register', registerButton: 'Register',
registering: 'Submitting…', registering: 'Submitting…',
registerSuccess: 'Registration successful. Check your email for a verification link.', 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', pendingAdminTitle: 'Admin: pending items',
pendingUsersTitle: 'Pending users', pendingUsersTitle: 'Pending users',
pendingListingsTitle: 'Pending listings', pendingListingsTitle: 'Pending listings',
@ -211,6 +223,18 @@ const allMessages = {
registerButton: 'Rekisteröidy', registerButton: 'Rekisteröidy',
registering: 'Lähetetään…', registering: 'Lähetetään…',
registerSuccess: 'Rekisteröinti onnistui. Tarkista sähköpostisi vahvistuslinkin vuoksi.', 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', pendingAdminTitle: 'Ylläpito: tarkastettavat',
pendingUsersTitle: 'Odottavat käyttäjät', pendingUsersTitle: 'Odottavat käyttäjät',
pendingListingsTitle: 'Odottavat kohteet', pendingListingsTitle: 'Odottavat kohteet',

View file

@ -78,3 +78,10 @@ export async function sendVerificationEmail(to: string, link: string) {
const html = `<p>Please verify your email by clicking <a href="${link}">this link</a>.</p><p>If you did not request this, you can ignore this email.</p>`; const html = `<p>Please verify your email by clicking <a href="${link}">this link</a>.</p><p>If you did not request this, you can ignore this email.</p>`;
return sendMail({ to, subject, text, html }); 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 = `<p>We received a request to reset your password.</p><p><a href="${link}">Reset your password</a></p><p>If you did not request this, you can ignore this email.</p>`;
return sendMail({ to, subject, text, html });
}