feat: add password reset flow
This commit is contained in:
parent
6d74532cbf
commit
fb1489e8f0
9 changed files with 244 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
38
app/api/auth/forgot/route.ts
Normal file
38
app/api/auth/forgot/route.ts
Normal 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';
|
||||
37
app/api/auth/reset/route.ts
Normal file
37
app/api/auth/reset/route.ts
Normal 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
54
app/auth/forgot/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,6 +58,11 @@ export default function LoginPage() {
|
|||
{loading ? t('loggingIn') : t('loginButton')}
|
||||
</button>
|
||||
</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}
|
||||
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}
|
||||
</main>
|
||||
|
|
|
|||
68
app/auth/reset/page.tsx
Normal file
68
app/auth/reset/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<SVGSVGElement> = {
|
||||
width: 16,
|
||||
height: 16,
|
||||
stroke: 'currentColor',
|
||||
fill: 'none',
|
||||
strokeWidth: 1.6,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
};
|
||||
switch (name) {
|
||||
case 'profile':
|
||||
return (
|
||||
|
|
|
|||
24
lib/i18n.ts
24
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',
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue