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.
|
- 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.
|
||||||
|
|
|
||||||
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')}
|
{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
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';
|
'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 (
|
||||||
|
|
|
||||||
24
lib/i18n.ts
24
lib/i18n.ts
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue