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:
Josh Rogers
2026-05-08 22:47:33 -05:00
parent d9ffe18b21
commit af085cfb90
13 changed files with 1285 additions and 0 deletions
@@ -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>