feat: add duplicate email detection via auth hook with fallback

- Add before-user-created hook function for server-side duplicate email detection
- Implement fallback detection using identities array check (Supabase limitation)
- Update signUpWithPassword to detect duplicate emails via hook errors or empty identities
- Add error handling in login page to show duplicate email errors and auto-switch to login mode
- Add auth hook setup documentation and test scripts
- Add password reset and update password flows
- Add email templates for signup confirmation and password reset
This commit is contained in:
colinislit
2025-11-19 11:03:46 +01:00
parent 114e7d200d
commit 2505b27437
21 changed files with 3671 additions and 201 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,613 @@
# 🚀 Bouwplan — Auth Flow Incrementele Uitbreiding
**Projectnaam:** Mini EPD - Auth Features Toevoegen
**Versie:** v1.0 (Incrementeel)
**Datum:** 18-01-2025
**Auteur:** Colin (met Claude Code)
**Scope:** Alleen TOEVOEGEN wat ontbreekt (geen refactor van bestaande code)
---
## 1. Wat Hebben We Al? ✅
**Bestaande Features (BLIJVEN ZOALS ZE ZIJN):**
- ✅ Login page met split-screen design (bento grid + form)
- ✅ Magic Link login (signup + login in één)
- ✅ Password login (demo accounts)
- ✅ Quick demo button (auto-fill + submit)
- ✅ Toggle tussen magic/demo
- ✅ Success/error messaging
- ✅ Auth callback handler (`/auth/callback`)
- ✅ Logout handler (`/auth/logout`)
- ✅ Middleware (route protection)
- ✅ Supabase integration
**Wat Ontbreekt:**
- ❌ Password reset flow (forgot password)
- ❌ Set password optie (na magic link signup)
- ❌ "Wachtwoord vergeten?" link op login page
---
## 2. Wat Gaan We TOEVOEGEN?
### **Minimale Toevoegingen (Production-Ready Auth):**
| Feature | Wat | Waarom | Tijd |
|---------|-----|--------|------|
| Password Reset | Forgot password → email → new password | Must-have voor production | 2 uur |
| Set Password | Optioneel password instellen na magic link | Power users willen sneller inloggen | 1 uur |
| Links Toevoegen | "Wachtwoord vergeten?" link in login form | UX improvement | 10 min |
**Totaal: ~3 uur werk**
---
## 3. Epics & Stories (Minimaal)
| Epic ID | Titel | Wat Doen We? | Stories | Tijd |
|---------|-------|--------------|---------|------|
| E1 | Password Reset Flow | 2 nieuwe pagina's + 1 email template | 3 | 2 uur |
| E2 | Set Password (Optional) | 1 nieuwe pagina + link in onboarding | 2 | 1 uur |
| E3 | UX Polish | Link toevoegen + testing | 1 | 15 min |
**Totaal: 3 uur werk, 6 stories**
---
## 4. Epic 1 — Password Reset Flow
**Wat:** Users kunnen vergeten wachtwoord resetten via email.
| Story ID | Wat Bouwen? | Bestanden | Story Points |
|----------|-------------|-----------|--------------|
| E1.S1 | Reset request page | `app/reset-password/page.tsx` (NIEUW) | 2 |
| E1.S2 | Update password page | `app/update-password/page.tsx` (NIEUW) | 3 |
| E1.S3 | Email template configureren | Supabase dashboard (config) | 1 |
### E1.S1 - Reset Request Page
**Nieuw bestand:** `app/reset-password/page.tsx`
```typescript
'use client'
import { useState } from 'react'
import { supabase } from '@/lib/supabase/client'
import Link from 'next/link'
export default function ResetPasswordPage() {
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
async function handleResetRequest(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/update-password`
})
if (error) throw error
setSent(true)
} catch (err: any) {
setError(err.message || 'Er ging iets mis')
} finally {
setLoading(false)
}
}
if (sent) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm text-center space-y-4">
<div className="text-5xl mb-4"></div>
<h1 className="text-2xl font-bold text-slate-900">Email Verstuurd!</h1>
<p className="text-slate-600">
Check je inbox voor de reset link. De link is 1 uur geldig.
</p>
<p className="text-sm text-slate-500">
Niet ontvangen? Check je spam folder.
</p>
<Link
href="/login"
className="inline-block mt-4 text-teal-600 hover:text-teal-700 font-medium"
>
Terug naar login
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 mb-2">
Wachtwoord Vergeten?
</h1>
<p className="text-slate-600">
Geen probleem! Vul je email in en we sturen je een reset link.
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
{error}
</div>
)}
<form onSubmit={handleResetRequest} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="jouw@email.nl"
required
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Verzenden...' : 'Stuur Reset Link'}
</button>
</form>
<div className="mt-6 text-center">
<Link
href="/login"
className="text-sm text-slate-600 hover:text-slate-900"
>
Terug naar login
</Link>
</div>
</div>
</div>
)
}
```
### E1.S2 - Update Password Page
**Nieuw bestand:** `app/update-password/page.tsx`
```typescript
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { supabase } from '@/lib/supabase/client'
export default function UpdatePasswordPage() {
const router = useRouter()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
async function handleUpdatePassword(e: React.FormEvent) {
e.preventDefault()
setError('')
// Validation
if (password !== confirmPassword) {
setError('Wachtwoorden komen niet overeen')
return
}
if (password.length < 8) {
setError('Wachtwoord moet minimaal 8 tekens zijn')
return
}
setLoading(true)
try {
const { error } = await supabase.auth.updateUser({
password
})
if (error) throw error
setSuccess(true)
setTimeout(() => router.push('/login'), 2000)
} catch (err: any) {
setError(err.message || 'Er ging iets mis')
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm text-center space-y-4">
<div className="text-5xl mb-4"></div>
<h1 className="text-2xl font-bold text-slate-900">Wachtwoord Gewijzigd!</h1>
<p className="text-slate-600">
Je wordt doorgestuurd naar de login pagina...
</p>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 mb-2">
Nieuw Wachtwoord Instellen
</h1>
<p className="text-slate-600">
Kies een sterk wachtwoord (minimaal 8 tekens).
</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
{error}
</div>
)}
<form onSubmit={handleUpdatePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Nieuw Wachtwoord
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Bevestig Wachtwoord
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
required
minLength={8}
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? 'Wachtwoord Wijzigen...' : 'Wachtwoord Wijzigen'}
</button>
</form>
</div>
</div>
)
}
```
### E1.S3 - Email Template Configureren
**Waar:** Supabase Dashboard → Authentication → Email Templates → Reset Password
**Template:**
```html
<h2>Wachtwoord Resetten</h2>
<p>Je hebt een wachtwoord reset aangevraagd voor je Mini EPD account.</p>
<a href="{{ .ConfirmationURL }}"
style="display: inline-block; background: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 16px 0;">
Reset Wachtwoord
</a>
<p style="color: #64748b; font-size: 14px;">
Deze link is 1 uur geldig. Heb je deze reset niet aangevraagd? Negeer deze email.
</p>
```
---
## 5. Epic 2 — Set Password (Optional)
**Wat:** Na magic link signup kunnen users een password instellen.
| Story ID | Wat Bouwen? | Bestanden | Story Points |
|----------|-------------|-----------|--------------|
| E2.S1 | Set password page | `app/set-password/page.tsx` (NIEUW) | 2 |
| E2.S2 | Link toevoegen | Update `app/auth/callback/route.ts` | 1 |
### E2.S1 - Set Password Page
**Nieuw bestand:** `app/set-password/page.tsx`
```typescript
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { supabase } from '@/lib/supabase/client'
export default function SetPasswordPage() {
const router = useRouter()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSetPassword(e: React.FormEvent) {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Wachtwoorden komen niet overeen')
return
}
if (password.length < 8) {
setError('Wachtwoord minimaal 8 tekens')
return
}
setLoading(true)
try {
const { error } = await supabase.auth.updateUser({ password })
if (error) throw error
router.push('/epd/clients')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
function handleSkip() {
router.push('/epd/clients')
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
<h1 className="text-2xl font-bold text-slate-900 mb-2">
Stel een Wachtwoord In (Optioneel)
</h1>
<p className="text-slate-600 mb-6">
Met een wachtwoord kun je sneller inloggen zonder magic link.
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
{error}
</div>
)}
<form onSubmit={handleSetPassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Wachtwoord
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 tekens"
minLength={8}
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Bevestig Wachtwoord
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
minLength={8}
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg disabled:opacity-50"
>
{loading ? 'Instellen...' : 'Wachtwoord Instellen'}
</button>
<button
type="button"
onClick={handleSkip}
className="w-full text-slate-600 hover:text-slate-900 text-sm font-medium py-2"
>
Sla over (blijf magic link gebruiken)
</button>
</form>
</div>
</div>
)
}
```
### E2.S2 - Update Callback Handler
**Bestand:** `app/auth/callback/route.ts`
**Wat wijzigen:**
```typescript
// TOEVOEGEN aan einde van success flow:
if (data.user) {
// Check if new user (first login)
const isNewUser = new Date(data.user.created_at).getTime() ===
new Date(data.user.last_sign_in_at || '').getTime()
if (isNewUser) {
// Optie: redirect naar set-password voor nieuwe users
return NextResponse.redirect(`${requestUrl.origin}/set-password`)
}
// Bestaande redirect naar dashboard
return NextResponse.redirect(`${requestUrl.origin}/epd/clients`)
}
```
---
## 6. Epic 3 — UX Polish
**Wat:** Kleine verbeteringen aan bestaande login page.
| Story ID | Wat Doen? | Bestand | Story Points |
|----------|-----------|---------|--------------|
| E3.S1 | "Wachtwoord vergeten?" link toevoegen | `app/login/page.tsx` | 1 |
### E3.S1 - Forgot Password Link
**Bestand:** `app/login/page.tsx` (line ~346)
**TOEVOEGEN na password input field:**
```typescript
{/* Password Input */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
/>
{/* NIEUW: Forgot password link */}
<div className="mt-1 text-right">
<a
href="/reset-password"
className="text-xs text-slate-600 hover:text-teal-600"
>
Wachtwoord vergeten?
</a>
</div>
</div>
```
---
## 7. Checklist - Wat Moet Er Gebeuren?
### **Nieuwe Bestanden Aanmaken:**
- [ ] `app/reset-password/page.tsx` (E1.S1)
- [ ] `app/update-password/page.tsx` (E1.S2)
- [ ] `app/set-password/page.tsx` (E2.S1)
### **Bestaande Bestanden Wijzigen:**
- [ ] `app/login/page.tsx` - Voeg "Wachtwoord vergeten?" link toe (E3.S1)
- [ ] `app/auth/callback/route.ts` - Optionele redirect naar set-password (E2.S2)
### **Supabase Configuratie:**
- [ ] Email template configureren (E1.S3)
- [ ] Test reset email wordt verstuurd
### **Testing:**
- [ ] Test forgot password flow (request → email → reset)
- [ ] Test set password na magic link signup
- [ ] Test alle links werken
- [ ] Test error handling (wrong email, weak password, etc.)
---
## 8. Implementatie Volgorde
**Stap 1:** Password Reset (E1) - 2 uur
1. Maak `reset-password/page.tsx`
2. Maak `update-password/page.tsx`
3. Configureer email template in Supabase
4. Test hele flow
**Stap 2:** Set Password (E2) - 1 uur
1. Maak `set-password/page.tsx`
2. Update `auth/callback/route.ts`
3. Test flow
**Stap 3:** UX Polish (E3) - 15 min
1. Voeg "Wachtwoord vergeten?" link toe
2. Final testing
**Totaal: ~3 uur werk**
---
## 9. Testing Checklist
| Test Case | Scenario | Expected |
|-----------|----------|----------|
| TC1 | Click "Wachtwoord vergeten?" | Redirect naar /reset-password |
| TC2 | Submit valid email for reset | Email sent, success message |
| TC3 | Click reset link in email | Redirect naar /update-password |
| TC4 | Set new password (valid) | Success, redirect to login |
| TC5 | Set new password (mismatch) | Error: "Wachtwoorden komen niet overeen" |
| TC6 | Set new password (too short) | Error: "Minimaal 8 tekens" |
| TC7 | Magic link signup (new user) | Redirect naar /set-password |
| TC8 | Skip set password | Redirect naar /epd/clients |
| TC9 | Set password after signup | Success, redirect to dashboard |
---
## 10. Wat NIET Doen
**Bestaande login page NIET refactoren**
**GEEN nieuwe UI components maken** (gebruik bestaande styling)
**GEEN email verification** (nice-to-have, niet kritiek)
**GEEN OAuth/SSO** (overkill voor prototype)
**GEEN complexe state management** (useState is prima)
---
## 11. Definition of Done
**E1-E3 compleet wanneer:**
- 3 nieuwe pagina's werken (reset-password, update-password, set-password)
- "Wachtwoord vergeten?" link zichtbaar op login page
- Email template geconfigureerd in Supabase
- Alle flows getest en werkend
- Geen breaking changes aan bestaande auth
- Git commit: `feat: Add password reset and set password flows`
---
**Status:** ⏳ Ready for Implementation
**Geschatte Tijd:** 3 uur
**Next Step:** Maak eerst `app/reset-password/page.tsx`