Add password reset flow
Users with a confirmed email can now reset a forgotten password via emailed single-use links. Backend - New PasswordResetToken entity (hash-only, 15-minute TTL). - POST /api/auth/forgot-password always returns 200, never disclosing whether an email is registered. Internally only emits a reset email when a user exists with EmailConfirmedAt set, and burns any existing outstanding tokens for that user before issuing a new one. - POST /api/auth/reset-password validates the token, rotates the password hash, and consumes the token. Single-use, expiry-checked. - Both endpoints are rate-limited (forgot 5/hr, reset 10/15min) per the same partitioning the login/register endpoints already use. - Reset email template added; uses AppBaseUrl plumbed in the previous PR. Frontend - /forgot-password page (email field, generic confirmation message regardless of whether the email is registered). - /reset-password page reads ?token=, validates the new password client-side, posts to the API, then redirects to /login. - "Forgot your password?" link added under the login form. Tests - 9 new integration tests cover the happy path, single-use enforcement, expired/unknown tokens, short-password rejection, silent 200 for unknown email, no email for unconfirmed users, and outstanding-token invalidation when a fresh request is made.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let email = $state('');
|
||||
let submitted = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function handleSubmit() {
|
||||
error = '';
|
||||
loading = true;
|
||||
try {
|
||||
await api('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
submitted = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh items-center justify-center bg-gray-50 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-4xl font-bold text-primary">YesChef</h1>
|
||||
<p class="mt-2 text-gray-500">Reset your password</p>
|
||||
</div>
|
||||
|
||||
{#if submitted}
|
||||
<div class="rounded-lg bg-primary/5 p-4 text-sm text-gray-700">
|
||||
<p class="font-medium text-primary">Check your inbox</p>
|
||||
<p class="mt-1">
|
||||
If an account with that email exists, we've sent a link to reset your password.
|
||||
The link expires in 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-6 text-center text-sm">
|
||||
<a href="/login" class="font-medium text-primary">Back to sign in</a>
|
||||
</p>
|
||||
{:else}
|
||||
<form onsubmit={e => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email"
|
||||
required
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-danger">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-primary py-3 text-lg font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
Remembered it?
|
||||
<a href="/login" class="font-medium text-primary">Sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,6 +150,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{#if mode === 'login'}
|
||||
<p class="mt-2 text-center text-sm">
|
||||
<a href="/forgot-password" class="text-gray-500 hover:text-primary">Forgot your password?</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let token = $state<string | null>(null);
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
let done = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
token = page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
error = 'This link is missing its reset token. Use the link from your email.';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = '';
|
||||
if (!token) return;
|
||||
if (newPassword.length < 6) {
|
||||
error = 'Password must be at least 6 characters.';
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
error = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
await api('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, newPassword })
|
||||
});
|
||||
done = true;
|
||||
setTimeout(() => goto('/login'), 1500);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Could not reset password';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh items-center justify-center bg-gray-50 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-4xl font-bold text-primary">YesChef</h1>
|
||||
<p class="mt-2 text-gray-500">Choose a new password</p>
|
||||
</div>
|
||||
|
||||
{#if done}
|
||||
<div class="rounded-lg bg-primary/5 p-4 text-sm">
|
||||
<p class="font-medium text-primary">Password updated</p>
|
||||
<p class="mt-1 text-gray-600">Redirecting you to sign in…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={e => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="New password"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-danger">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !token}
|
||||
class="w-full rounded-lg bg-primary py-3 text-lg font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Set new password'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user