From 2505b274373d1d9de3eaab569796d5be193bc728 Mon Sep 17 00:00:00 2001 From: colinislit Date: Wed, 19 Nov 2025 11:03:46 +0100 Subject: [PATCH] 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 --- AGENTS.md | 39 + app/auth/callback/route.ts | 13 +- app/login/page.tsx | 385 +++--- app/reset-password/page.tsx | 105 ++ app/set-password/page.tsx | 108 ++ app/update-password/page.tsx | 118 ++ components/ui/bento-grid.tsx | 4 +- docs/AUTH_HOOK_SETUP.md | 183 +++ docs/emails/README.md | 52 + docs/emails/confirm-signup.html | 97 ++ docs/emails/reset-password.html | 105 ++ .../bouwplan-auth-flow-complete-v1.0.md | 1055 +++++++++++++++++ .../archive/bouwplan-auth-incremental-v1.0.md | 613 ++++++++++ ...bouwplan-auth-hook-duplicate-email-v1.0.md | 502 ++++++++ lib/auth/client.ts | 134 ++- package.json | 3 +- scripts/apply-migration.ts | 71 ++ scripts/setup-auth-hook.ts | 95 ++ scripts/test-hook-function.ts | 72 ++ scripts/test-signup-direct.ts | 47 + ...251119094908_auth_hook_duplicate_email.sql | 71 ++ 21 files changed, 3671 insertions(+), 201 deletions(-) create mode 100644 AGENTS.md create mode 100644 app/reset-password/page.tsx create mode 100644 app/set-password/page.tsx create mode 100644 app/update-password/page.tsx create mode 100644 docs/AUTH_HOOK_SETUP.md create mode 100644 docs/emails/README.md create mode 100644 docs/emails/confirm-signup.html create mode 100644 docs/emails/reset-password.html create mode 100644 docs/specs/archive/bouwplan-auth-flow-complete-v1.0.md create mode 100644 docs/specs/archive/bouwplan-auth-incremental-v1.0.md create mode 100644 docs/specs/bouwplan-auth-hook-duplicate-email-v1.0.md create mode 100644 scripts/apply-migration.ts create mode 100644 scripts/setup-auth-hook.ts create mode 100644 scripts/test-hook-function.ts create mode 100644 scripts/test-signup-direct.ts create mode 100644 supabase/migrations/20251119094908_auth_hook_duplicate_email.sql diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..56de78f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `app/`: Next.js app router pages, routes, and layout shell; start edits in `app/page.tsx`. +- `components/`: Shared UI building blocks (React + Tailwind variants) used across routes. +- `lib/`: Utilities and integrations (e.g., Supabase client/types in `lib/supabase/`). +- `content/`, `docs/`: Markdown/docs assets; update here before hardcoding copies in `app/`. +- `public/`: Static assets served at `/`; keep optimized exports here. +- `scripts/`: Maintenance helpers (e.g., `scripts/test-contrast.ts`). +- `supabase/`: Database config and migrations (`supabase/migrations/*.sql`). + +## Build, Test, and Development Commands +- `pnpm dev` (or `npm run dev`): Start local server at `http://localhost:3000` with hot reload. +- `pnpm build`: Production bundle; fails on type errors for app and server components. +- `pnpm start`: Run the built app locally. +- `pnpm lint`: Run ESLint with Next.js rules over `app/`, `components/`, `lib/`. +- `pnpm types:generate`: Regenerate Supabase TS types into `lib/supabase/types.ts` (requires project access). + +## Coding Style & Naming Conventions +- Language: TypeScript + React Server/Client Components; follow Next.js app router patterns. +- Formatting: 2-space indentation; prefer single quotes where lint allows; keep imports sorted logically. +- Styling: Tailwind-first; compose variants via `class-variance-authority` and `tailwind-merge`. +- Naming: PascalCase for components, camelCase for helpers, `useX` for hooks, `types.ts` for shared types. +- Keep client components marked with `"use client"` when needed; avoid client code in server contexts. + +## Testing Guidelines +- No dedicated automated test harness yet; rely on `pnpm lint` and manual flows in the browser. +- For data paths, validate Supabase connections with `lib/supabase/test-connection.ts` or local SQL migrations. +- Add route- or component-level checks (e.g., contrast checks via `scripts/test-contrast.ts`) before shipping UI tweaks. + +## Commit & Pull Request Guidelines +- Git history mixes short feature phrases and imperative summaries; keep new commits concise and action-led (e.g., `feat: add login hero` or `fix: align tab spacing`). +- Prefer scoped, single-purpose commits; include why when the change is non-obvious. +- PRs: describe user-facing impact, screenshots for UI changes, reproduction steps for bugs, and link issues/tasks when available. +- Note any Supabase schema changes and the migration file touched; mention if `types:generate` was rerun. + +## Security & Configuration Tips +- Environment: keep secrets in `.env.local`; never commit them. Required keys follow Next.js/Supabase conventions (`NEXT_PUBLIC_` for client-safe values). +- When testing auth/DB flows, ensure Supabase policies (`supabase/migrations/*_policies.sql`) are applied and reviewed. diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 57de98e..8ead357 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -18,9 +18,18 @@ export async function GET(request: NextRequest) { const supabase = await createClient() // Exchange code for session - const { error } = await supabase.auth.exchangeCodeForSession(code) + const { data, error } = await supabase.auth.exchangeCodeForSession(code) + + if (!error && 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) { + // Redirect new users to set-password page + return NextResponse.redirect(new URL('/set-password', request.url)) + } - if (!error) { // Redirect to the specified next URL or default to /clients return NextResponse.redirect(new URL(next, request.url)) } diff --git a/app/login/page.tsx b/app/login/page.tsx index 47e5406..4046b89 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -2,9 +2,10 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' -import { loginWithMagicLink, loginWithPassword } from '@/lib/auth/client' -import { Brain, Zap, Target, FileText, Clock, TrendingDown } from 'lucide-react' +import { loginWithPassword, signUpWithPassword } from '@/lib/auth/client' +import { Brain, Zap, Target, Clock } from 'lucide-react' import { BentoGrid, BentoCard } from '@/components/ui/bento-grid' +import Link from 'next/link' // Bento grid features with different sizes for visual interest const bentoFeatures = [ @@ -56,70 +57,106 @@ const bentoFeatures = [ export default function LoginPage() { const router = useRouter() + const [mode, setMode] = useState<'login' | 'signup'>('login') const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') // Only for signup const [loading, setLoading] = useState(false) const [message, setMessage] = useState<{ type: 'success' | 'error' text: string } | null>(null) - const [showDemoLogin, setShowDemoLogin] = useState(false) - // Magic link login - const handleMagicLinkLogin = async (e: React.FormEvent) => { + // Handle form submission + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setMessage(null) try { - const result = await loginWithMagicLink(email) - setMessage({ - type: 'success', - text: result.message - }) - setEmail('') - } catch (error: any) { - setMessage({ - type: 'error', - text: error.message || 'Er ging iets mis. Probeer opnieuw.' - }) - } finally { - setLoading(false) - } - } + if (mode === 'signup') { + // Signup Flow + if (password !== confirmPassword) { + throw new Error('Wachtwoorden komen niet overeen') + } + + // Sign up + const result = await signUpWithPassword(email, password) + + // Check if user got a session immediately (email confirmation disabled) + if (result.session) { + // User is logged in immediately + setMessage({ + type: 'success', + text: 'Account aangemaakt! Je wordt doorgestuurd...' + }) + setTimeout(() => { + router.push('/epd/clients') + }, 1000) + } else { + // Email confirmation required - user needs to check inbox + setMessage({ + type: 'success', + text: 'Check je inbox voor een verificatie link om je account te activeren.' + }) + } + + } else { + // Login Flow + await loginWithPassword(email, password) + + setMessage({ + type: 'success', + text: 'Ingelogd! Redirect naar EPD...' + }) - // Password login (demo accounts) - const handlePasswordLogin = async (e: React.FormEvent) => { - e.preventDefault() - setLoading(true) - setMessage(null) - - try { - await loginWithPassword(email, password) - setMessage({ - type: 'success', - text: 'Ingelogd! Redirect naar EPD...' - }) - - // Redirect to EPD - setTimeout(() => { - router.push('/epd/clients') - }, 1000) - } catch (error: any) { - setMessage({ - type: 'error', - text: error.message || 'Ongeldige credentials.' - }) - } finally { + // Redirect to EPD + setTimeout(() => { + router.push('/epd/clients') + }, 1000) + } + } catch (error: any) { + // Check for duplicate email error with auto-login + if (error.code === 'user_already_registered' && error.data?.session) { + // User was auto-logged in (client-side detection succeeded) + setMessage({ + type: 'success', + text: 'Dit emailadres bestaat al. Je bent nu ingelogd!' + }) + setTimeout(() => { + router.push('/epd/clients') + }, 1000) + } + // Check for duplicate email error (from Auth Hook or client-side) + else if (error.code === 'user_already_registered') { + setMessage({ + type: 'error', + text: error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen.' + }) + // Switch to login mode after 2 seconds + setTimeout(() => { + setMode('login') + setPassword('') + setConfirmPassword('') + }, 2000) + } + // Generic error handling + else { + setMessage({ + type: 'error', + text: error.message || 'Er ging iets mis. Probeer opnieuw.' + }) + } + } finally { setLoading(false) } } // Quick demo login const handleQuickDemoLogin = async () => { + setMode('login') setEmail('demo@mini-ecd.demo') setPassword('Demo2024!') - setShowDemoLogin(true) // Auto-submit setLoading(true) @@ -187,19 +224,46 @@ export default function LoginPage() { - {/* Right Side - Login Form (40%) */} + {/* Right Side - Login/Signup Form (40%) */}
{/* Header */}

- Login + {mode === 'login' ? 'Welkom terug' : 'Maak een account'}

- Toegang tot EPD prototype + {mode === 'login' + ? 'Log in om toegang te krijgen tot het EPD' + : 'Start vandaag nog met snellere rapportages' + }

+ {/* Mode Toggle */} +
+ + +
+ {/* Message Display */} {message && (
)} - {/* Magic Link Login */} - {!showDemoLogin && ( +
-

- πŸ“§ Login met Magic Link -

- - -
- - 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-slate-400 focus:border-slate-400" - /> -

- Nieuw? Account wordt automatisch aangemaakt! -

-
- - - - - {/* Divider */} -
-
-
-
-
- of -
-
- - {/* Demo Account Toggle */} - - - {/* Quick Demo Button */} - + Email + + 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-slate-400 focus:border-slate-400" + />
- )} - {/* Demo Password Login */} - {showDemoLogin && (
-
-

- 🎯 Demo Account Login -

- -
- - {/* Demo Credentials Info */} -
-

- πŸ“‹ Demo Credentials: -

-
-

Email: demo@mini-ecd.demo

-

Password: Demo2024!

-
-
- -
-
- + 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-slate-400 focus:border-slate-400" + /> + {mode === 'login' && ( +
+ - Email - - setEmail(e.target.value)} - placeholder="demo@mini-ecd.demo" - 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" - /> + Wachtwoord vergeten? +
- -
- - 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" - /> -
- - - + )}
- )} + + {mode === 'signup' && ( +
+ + 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-slate-400 focus:border-slate-400" + /> +
+ )} + + + + + {/* Divider */} +
+
+
+
+
+ of +
+
+ + {/* Quick Demo Button */} + {/* Footer */}

diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx new file mode 100644 index 0000000..0cdb059 --- /dev/null +++ b/app/reset-password/page.tsx @@ -0,0 +1,105 @@ +'use client' + +import { useState } from 'react' +import { resetPasswordForEmail } from '@/lib/auth/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 { + await resetPasswordForEmail(email) + setSent(true) + } catch (err: any) { + setError(err.message || 'Er ging iets mis') + } finally { + setLoading(false) + } + } + + if (sent) { + return ( +

+
+
βœ‰οΈ
+

Email Verstuurd!

+

+ Check je inbox voor de reset link. De link is 1 uur geldig. +

+

+ Niet ontvangen? Check je spam folder. +

+ + ← Terug naar login + +
+
+ ) + } + + return ( +
+
+
+

+ Wachtwoord Vergeten? +

+

+ Geen probleem! Vul je email in en we sturen je een reset link. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ + +
+ +
+ + ← Terug naar login + +
+
+
+ ) +} + diff --git a/app/set-password/page.tsx b/app/set-password/page.tsx new file mode 100644 index 0000000..3b9cd06 --- /dev/null +++ b/app/set-password/page.tsx @@ -0,0 +1,108 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { updateUserPassword } from '@/lib/auth/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 { + await updateUserPassword(password) + router.push('/epd/clients') + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + } + } + + function handleSkip() { + router.push('/epd/clients') + } + + return ( +
+
+

+ Stel een Wachtwoord In (Optioneel) +

+

+ Met een wachtwoord kun je sneller inloggen zonder magic link. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + + + +
+
+
+ ) +} diff --git a/app/update-password/page.tsx b/app/update-password/page.tsx new file mode 100644 index 0000000..e4a4163 --- /dev/null +++ b/app/update-password/page.tsx @@ -0,0 +1,118 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { updateUserPassword } from '@/lib/auth/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 { + await updateUserPassword(password) + setSuccess(true) + setTimeout(() => router.push('/login'), 2000) + } catch (err: any) { + setError(err.message || 'Er ging iets mis') + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+
+
βœ…
+

Wachtwoord Gewijzigd!

+

+ Je wordt doorgestuurd naar de login pagina... +

+
+
+ ) + } + + return ( +
+
+
+

+ Nieuw Wachtwoord Instellen +

+

+ Kies een sterk wachtwoord (minimaal 8 tekens). +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ ) +} + diff --git a/components/ui/bento-grid.tsx b/components/ui/bento-grid.tsx index 342a957..ed41032 100644 --- a/components/ui/bento-grid.tsx +++ b/components/ui/bento-grid.tsx @@ -35,7 +35,7 @@ const BentoCard = ({ name: string; className: string; background: ReactNode; - Icon: any; + Icon?: any; description: string; href: string; cta: string; @@ -53,7 +53,7 @@ const BentoCard = ({ >
{background}
- + {Icon && }

{name}

diff --git a/docs/AUTH_HOOK_SETUP.md b/docs/AUTH_HOOK_SETUP.md new file mode 100644 index 0000000..477388c --- /dev/null +++ b/docs/AUTH_HOOK_SETUP.md @@ -0,0 +1,183 @@ +# Auth Hook Setup Guide + +## Overzicht + +Deze hook detecteert duplicate emails VOOR een user wordt aangemaakt, +waardoor gebruikers direct feedback krijgen als hun email al geregistreerd is. + +**Voordelen:** +- βœ… Server-side validatie (kan niet omzeild worden) +- βœ… Duidelijke foutmeldingen voor gebruikers +- βœ… Betrouwbaar (werkt ongeacht password) +- βœ… Case-insensitive email matching +- βœ… Email normalisatie (lowercase + trim) + +## Setup (Eerste Keer) + +### Stap 1: Deploy Migration + +**Optie A: Via Supabase Dashboard (Aanbevolen)** + +1. Ga naar: [Supabase Dashboard β†’ SQL Editor](https://supabase.com/dashboard/project/dqugbrpwtisgyxscpefg/sql) +2. Open het migration bestand: `supabase/migrations/20251119094908_auth_hook_duplicate_email.sql` +3. Kopieer de volledige inhoud +4. Plak in de SQL Editor +5. Klik "RUN" om de functie aan te maken + +**Optie B: Via Supabase CLI (Als geconfigureerd)** + +```bash +npx supabase db push +``` + +### Stap 2: Verificatie & Instructies + +Run het setup script om te verifiΓ«ren dat de functie bestaat: + +```bash +pnpm run setup:auth-hook +``` + +Dit script: +- βœ… Checkt of de functie bestaat +- πŸ“‹ Geeft instructies voor Dashboard configuratie +- πŸ”— Biedt directe links naar relevante Dashboard pagina's + +### Stap 3: Configureer Hook Link + +**⚠️ Deze stap moet handmatig via Dashboard** (Supabase ondersteunt dit nog niet via API): + +1. Ga naar: [Supabase Dashboard β†’ Auth β†’ Hooks](https://supabase.com/dashboard/project/dqugbrpwtisgyxscpefg/auth/hooks) +2. Klik **"Add a new hook"** of **"Enable Hooks"** +3. Vul in: + - **Hook Type:** "Send a hook on before a user is created" (`before-user-created`) + - **Select hook:** "Postgres Function" + - **Schema:** `public` + - **Function Name:** `hook_check_duplicate_email` +4. Klik **"Create hook"** of **"Save"** + +### Stap 4: Test + +Test de hook door: + +1. Ga naar je signup pagina: `http://localhost:3000/login` +2. Probeer te registreren met een **bestaand** emailadres (bijv. `demo@mini-ecd.demo`) +3. Je zou een error moeten zien: _"Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik 'Wachtwoord vergeten?'."_ +4. Probeer te registreren met een **nieuw** emailadres +5. Dit zou normaal moeten werken (verificatie email verzonden) + +## Test Cases + +| Test Case | Scenario | Expected Result | +|-----------|----------|-----------------| +| TC1 | Signup met nieuw email | βœ… Account aangemaakt, email verzonden | +| TC2 | Signup met bestaand email | ❌ Error: "Dit emailadres is al geregistreerd..." | +| TC3 | Signup met bestaand email (case variant: `Email@Example.com`) | ❌ Error (case-insensitive match) | +| TC4 | Signup met lege/NULL email | ❌ Error: "Email adres is verplicht." | +| TC5 | Hook disabled β†’ signup met bestaand email | ⚠️ Oude gedrag (geen error, maar ook geen email) | + +## Herhaalbaarheid + +- βœ… **Functie code** staat in migrations (version controlled) +- ⚠️ **Hook link** moet per omgeving handmatig worden geconfigureerd +- βœ… **Documentatie** staat in Git +- βœ… **Setup script** voor validatie en instructies + +## Technische Details + +### Wat Doet de Hook? + +De `hook_check_duplicate_email` functie: + +1. Ontvangt signup event van Supabase Auth +2. Haalt email adres uit event payload +3. Valideert email (niet NULL/empty) +4. Normaliseert email (lowercase + trim) +5. Checkt of email al bestaat in `auth.users` table (case-insensitive) +6. Als email bestaat β†’ return error object +7. Als email nieuw is β†’ return empty object (allow signup) + +### Security + +- **Security Definer:** Functie draait met elevated permissions +- **Search Path:** Expliciet ingesteld op `public, auth` voor veilige schema access +- **Permissions:** Alleen `supabase_auth_admin` kan de functie uitvoeren +- **Email Enumeration Protection:** Werkt samen met bestaande email confirmation + +### Performance + +- ⚑ Direct database check (geen extra HTTP calls) +- ⚑ Indexed lookup op `auth.users.email` +- ⚑ Minimale overhead (< 10ms typisch) + +## Toekomstige Verbeteringen + +Zodra Supabase Management API Auth Hooks ondersteunt, kunnen we: + +- [ ] Hook link volledig automatiseren +- [ ] Setup script uitbreiden met API calls +- [ ] CI/CD pipeline voor hook configuratie +- [ ] Automated tests voor hook functionaliteit + +## Flexibiliteit + +De functie is geschreven in standaard PostgreSQL, waardoor: + +- βœ… Werkt met elke auth provider die Postgres functies ondersteunt +- βœ… Makkelijk te migreren naar andere auth systemen +- βœ… Geen vendor lock-in voor de logica zelf + +## Troubleshooting + +### "Function does not exist" error + +**Probleem:** De hook functie is niet aangemaakt in de database. + +**Oplossing:** +1. Controleer of migration is uitgevoerd via Dashboard of CLI +2. Run `pnpm run setup:auth-hook` voor verificatie +3. Check Supabase logs voor SQL errors + +### Hook lijkt niet te werken + +**Probleem:** Signup met bestaand email geeft geen error. + +**Mogelijke oorzaken:** +1. Hook link niet geconfigureerd in Dashboard β†’ Ga naar Auth β†’ Hooks +2. Hook is disabled β†’ Check hook status in Dashboard +3. Email confirmation staat uit β†’ Check Auth β†’ Email Templates + +**Verificatie:** +```sql +-- Check of functie bestaat +SELECT routine_name +FROM information_schema.routines +WHERE routine_schema = 'public' +AND routine_name = 'hook_check_duplicate_email'; + +-- Test functie handmatig +SELECT hook_check_duplicate_email('{"user": {"email": "demo@mini-ecd.demo"}}'::jsonb); +``` + +### Wrong error message + +**Probleem:** Error message klopt niet of is in het Engels. + +**Oplossing:** +1. Check of je de laatste versie van de migration hebt gebruikt +2. Update functie via SQL Editor met correcte error messages +3. Rebuild client error handling (`app/login/page.tsx`) + +## Related Documentation + +- [Supabase Auth Hooks Documentation](https://supabase.com/docs/guides/auth/auth-hooks) +- [Bouwplan: Auth Hook Implementation](./bouwplan-auth-hook-duplicate-email-v1.0.md) +- [Main README](../README.md) + +## Support + +Voor vragen of problemen: +1. Check deze documentatie +2. Check Supabase logs in Dashboard +3. Run `pnpm run setup:auth-hook` voor diagnostics +4. Review `supabase/migrations/20251119094908_auth_hook_duplicate_email.sql` diff --git a/docs/emails/README.md b/docs/emails/README.md new file mode 100644 index 0000000..b33bcc0 --- /dev/null +++ b/docs/emails/README.md @@ -0,0 +1,52 @@ +# Email Templates voor Supabase + +Deze directory bevat HTML email templates die gebruikt kunnen worden in Supabase Authentication emails. + +## Beschikbare Templates + +### 1. `confirm-signup.html` +**Gebruik:** Email bevestiging voor nieuwe gebruikersregistratie + +**Supabase variabelen:** +- `{{ .ConfirmationURL }}` - De bevestigingslink die de gebruiker moet klikken + +**Waar te gebruiken in Supabase:** +- Authentication β†’ Email Templates β†’ Confirm signup + +--- + +### 2. `reset-password.html` +**Gebruik:** Wachtwoord reset email + +**Supabase variabelen:** +- `{{ .ConfirmationURL }}` - De reset link die de gebruiker moet klikken + +**Waar te gebruiken in Supabase:** +- Authentication β†’ Email Templates β†’ Reset Password + +--- + +## Hoe te gebruiken in Supabase + +1. Log in op je Supabase Dashboard +2. Ga naar **Authentication** β†’ **Email Templates** +3. Selecteer het juiste template type (Confirm signup of Reset Password) +4. Kopieer de volledige HTML inhoud van het corresponderende `.html` bestand +5. Plak deze in het Supabase email template veld +6. Klik op **Save** + +## Design Kenmerken + +- **Brand kleur:** Teal (#0D9488) - consistent met AI Speedrun branding +- **Responsive:** Werkt op desktop en mobiel +- **Email client compatibiliteit:** Gebruikt table-based layout voor maximale compatibiliteit +- **Toegankelijkheid:** Goede contrast ratios en duidelijke call-to-action buttons +- **Nederlandse taal:** Alle teksten zijn in het Nederlands + +## Aanpassingen + +Als je de templates wilt aanpassen: +- Kleuren kunnen worden aangepast in de inline styles (zoek naar `#0D9488` voor de primary kleur) +- Teksten kunnen direct worden aangepast in de HTML +- Let op: Supabase gebruikt Go template syntax (`{{ .VariableName }}`), dus behoud deze variabelen + diff --git a/docs/emails/confirm-signup.html b/docs/emails/confirm-signup.html new file mode 100644 index 0000000..d7a55d7 --- /dev/null +++ b/docs/emails/confirm-signup.html @@ -0,0 +1,97 @@ + + + + + + + Bevestig je account - AI Speedrun + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+

+ Welkom bij AI Speedrun +

+
+

+ Leuk dat je meekijkt met deze speedrun! +

+ +

+ Klik op de onderstaande knop om je emailadres te bevestigen en je account te activeren: +

+ + + + + + +
+ + Bevestig je account + +
+ + +

+ Werkt de knop niet? Kopieer en plak deze link in je browser: +

+

+ {{ .ConfirmationURL }} +

+ + +
+

+ Beveiliging: Deze link is 24 uur geldig. Als je deze email niet hebt aangevraagd, kun je deze negeren. +

+
+
+

+ AI Speedrun - Software on Demand
+ Build in Public door AI Speedrun +

+
+ + + + + + +
+

+ Je ontvangt deze email omdat je je hebt geregistreerd voor AI Speedrun. +

+
+ +
+ + + diff --git a/docs/emails/reset-password.html b/docs/emails/reset-password.html new file mode 100644 index 0000000..8e1eeaf --- /dev/null +++ b/docs/emails/reset-password.html @@ -0,0 +1,105 @@ + + + + + + + Wachtwoord opnieuw instellen - AI Speedrun + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+

+ Wachtwoord opnieuw instellen +

+
+

+ Je hebt een verzoek gedaan om je wachtwoord opnieuw in te stellen voor je AI Speedrun account. +

+ +

+ Klik op de onderstaande knop om een nieuw wachtwoord in te stellen: +

+ + + + + + +
+ + Wachtwoord opnieuw instellen + +
+ + +

+ Werkt de knop niet? Kopieer en plak deze link in je browser: +

+

+ {{ .ConfirmationURL }} +

+ + +
+

+ Belangrijk: Als je deze email niet hebt aangevraagd, kun je deze veilig negeren. Je wachtwoord blijft ongewijzigd. +

+
+ + +

+ Deze link is 1 uur geldig. Na deze tijd moet je een nieuw verzoek indienen. +

+
+

+ Vragen over je account? Neem contact met ons op via support@aispeedrun.nl +

+

+ AI Speedrun - Software on Demand
+ Build in Public door AI Speedrun +

+
+ + + + + + +
+

+ Je ontvangt deze email omdat er een wachtwoord reset is aangevraagd voor je account. +

+
+ +
+ + + diff --git a/docs/specs/archive/bouwplan-auth-flow-complete-v1.0.md b/docs/specs/archive/bouwplan-auth-flow-complete-v1.0.md new file mode 100644 index 0000000..3be9394 --- /dev/null +++ b/docs/specs/archive/bouwplan-auth-flow-complete-v1.0.md @@ -0,0 +1,1055 @@ +# πŸš€ Bouwplan β€” Volledige Auth Flow + Demo Account + +**Projectnaam:** Mini EPD Demo Platform - Complete Authenticatie Systeem +**Versie:** v1.0 +**Datum:** 18-01-2025 +**Auteur:** Colin (met Claude Code) +**Scope:** Production-ready auth flow met signup, login, password reset + demo account convenience + +--- + +## 1. Doel en Context + +🎯 **Doel:** Een complete, production-ready authenticatie flow bouwen die professionele development skills showcaset, met een demo account voor quick access. + +πŸ“˜ **Toelichting:** + +**Portfolio Waarde:** +- Demonstreert full-stack auth expertise +- Security best practices (password hashing, email verification, rate limiting) +- Professional UX (duidelijke error states, loading indicators, success feedback) +- Production-ready mindset (edge cases, error handling, rollback scenarios) + +**Feature Scope:** +1. βœ… **Signup Flow** - Magic Link (passwordless onboarding) +2. βœ… **Login Flow** - Email + Password (returning users) +3. βœ… **Password Reset** - Forgot password β†’ Email β†’ New password +4. βœ… **Email Verification** - Confirm email na signup +5. βœ… **Demo Account** - One-click quick access +6. βœ… **Session Management** - Auto-refresh, secure cookies, logout + +**User Journeys:** +- **Recruiter/Prospect:** Quick Demo β†’ Impressed β†’ Account aanmaken +- **New User:** Email β†’ Magic Link β†’ Account created β†’ Onboarding +- **Returning User:** Email + Password β†’ Dashboard +- **Forgot Password:** Reset link β†’ New password β†’ Login + +--- + +## 2. Uitgangspunten + +### 2.1 Technische Stack + +**Bestaand (blijft):** +- Next.js 16.0.1 + React 19 + TypeScript +- Supabase Auth (backend) +- Tailwind CSS (styling) +- Vercel (hosting) + +**Nieuw (toe te voegen):** +- Zod voor form validation +- React Hook Form (optioneel - betere UX) +- Email templates (Supabase) + +### 2.2 Projectkaders + +- **Tijd:** 8-10 uur voor volledige implementatie + testing +- **Team:** 1 developer (zelfstandig uit te voeren) +- **Breaking changes:** GEEN - Bestaande auth blijft werken tijdens migratie +- **Deployment:** Phased rollout (feature flags mogelijk) +- **Security:** Production standards (OWASP top 10) + +### 2.3 Programmeer Uitgangspunten + +**Code Quality:** +- βœ… **DRY:** Herbruikbare auth componenten en hooks +- βœ… **KISS:** Gebruik Supabase built-in features waar mogelijk +- βœ… **SOC:** Auth logic gescheiden van UI components +- βœ… **YAGNI:** Geen OAuth/SSO (kan later), focus op email auth + +**Security Principles:** +- Passwords NOOIT in plain text (Supabase handled dit) +- Rate limiting op sensitive endpoints +- HTTPS only (Vercel default) +- Secure session cookies (HTTP-only, SameSite) +- Input validation (email format, password strength) +- CSRF protection (Next.js built-in) + +**UX Principles:** +- Loading states op alle async operaties +- Clear error messages (user-friendly, geen technical jargon) +- Success feedback (toasts/messages) +- Keyboard accessible (tab order, focus states) +- Mobile responsive (touch-friendly buttons) + +--- + +## 3. Epics & Stories Overzicht + +| Epic ID | Titel | Doel | Status | Stories | Geschatte Tijd | +|---------|-------|------|--------|---------|----------------| +| E1 | Login/Signup Page Refactor | Unified auth page met tabs/modes | ⏳ To Do | 4 | 2 uur | +| E2 | Signup Flow (Magic Link) | Passwordless account creation | ⏳ To Do | 3 | 2 uur | +| E3 | Login Flow (Password) | Returning user login | ⏳ To Do | 2 | 1 uur | +| E4 | Password Reset Flow | Forgot password β†’ reset email β†’ update | ⏳ To Do | 4 | 2.5 uur | +| E5 | Email Verification | Confirm email after signup | ⏳ To Do | 2 | 1 uur | +| E6 | Demo Account | Quick access button | ⏳ To Do | 2 | 1 uur | +| E7 | Testing & Polish | All flows tested, edge cases handled | ⏳ To Do | 3 | 1.5 uur | + +**Totale schatting:** 9-11 uur werk + +--- + +## 4. Epics & Stories (Uitwerking) + +### Epic 1 β€” Login/Signup Page Refactor + +**Epic Doel:** Unified auth page die signup, login en demo ondersteunt met duidelijke modes. + +| Story ID | Beschrijving | Acceptatiecriteria | Status | Story Points | +|----------|--------------|---------------------|--------|--------------| +| E1.S1 | Design nieuwe page structuur | Wireframe + component breakdown | ⏳ | 1 | +| E1.S2 | Implementeer mode switcher | Toggle tussen "Nieuw account" / "Inloggen" | ⏳ | 2 | +| E1.S3 | Basis form components | Email input, password input, submit button | ⏳ | 2 | +| E1.S4 | Error/success messaging | Toast/banner component voor feedback | ⏳ | 2 | + +**Technical Notes:** + +**E1.S1 - Page Structuur:** +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Mini EPD - Professioneel Elektronisch β”‚ +β”‚ PatiΓ«nten Dossier β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ β”‚ +β”‚ [🎭 Quick Demo - Bekijk Prototype] β”‚ ← Prominent, secundair +β”‚ β”‚ +β”‚ ────── of ────── β”‚ +β”‚ β”‚ +β”‚ ( ) Nieuw account aanmaken β”‚ ← Radio/Tab +β”‚ (β€’) Inloggen β”‚ +β”‚ β”‚ +β”‚ πŸ“§ Email β”‚ +β”‚ [_________________________] β”‚ +β”‚ β”‚ +β”‚ πŸ”’ Wachtwoord β”‚ ← Alleen bij "Inloggen" +β”‚ [_________________________] β”‚ +β”‚ β”‚ +β”‚ [Inloggen] of [Account Aanmaken] β”‚ ← Dynamic button text +β”‚ β”‚ +β”‚ Wachtwoord vergeten? [Reset] β”‚ ← Link naar /reset-password +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**E1.S2 - Mode State Management:** +```typescript +type AuthMode = 'signup' | 'login' + +const [mode, setMode] = useState('login') // Default = login + +// Dynamic UI based on mode +const showPasswordField = mode === 'login' +const buttonText = mode === 'login' ? 'Inloggen' : 'Account Aanmaken' +const submitHandler = mode === 'login' ? handleLogin : handleSignup +``` + +**E1.S3 - Form Components:** +```typescript +// Input component met validation states + + + +``` + +**E1.S4 - Messaging System:** +```typescript +// Toast/banner component +type Message = { + type: 'success' | 'error' | 'info' + text: string + duration?: number +} + +// Usage examples: +showMessage({ + type: 'success', + text: 'Check je email voor de magic link!' +}) + +showMessage({ + type: 'error', + text: 'Email of wachtwoord incorrect' +}) +``` + +--- + +### Epic 2 β€” Signup Flow (Magic Link) + +**Epic Doel:** Passwordless signup via magic link in email (Supabase OTP). + +| Story ID | Beschrijving | Acceptatiecriteria | Status | Story Points | +|----------|--------------|---------------------|--------|--------------| +| E2.S1 | Implementeer signup handler | Email submit β†’ Supabase OTP β†’ Success message | ⏳ | 3 | +| E2.S2 | Email callback handler | Magic link β†’ account created β†’ redirect dashboard | ⏳ | 2 | +| E2.S3 | Custom email template | Branded email met clear CTA | ⏳ | 2 | + +**Technical Notes:** + +**E2.S1 - Signup Handler:** +```typescript +// app/login/page.tsx + +async function handleSignup(email: string) { + setLoading(true) + + try { + const { data, error } = await supabase.auth.signInWithOtp({ + email, + options: { + emailRedirectTo: `${window.location.origin}/auth/callback`, + shouldCreateUser: true, + data: { + // Optional: Store extra user metadata + source: 'web_signup', + timestamp: new Date().toISOString(), + } + } + }) + + if (error) throw error + + setMessage({ + type: 'success', + text: `Check je email (${email}) voor de magic link!` + }) + + // Optional: Track analytics + trackEvent('signup_initiated', { email }) + + } catch (error) { + setMessage({ + type: 'error', + text: error.message || 'Signup mislukt. Probeer opnieuw.' + }) + } finally { + setLoading(false) + } +} +``` + +**E2.S2 - Callback Handler:** +```typescript +// app/auth/callback/route.ts (EXISTING - update if needed) + +export async function GET(request: Request) { + const requestUrl = new URL(request.url) + const code = requestUrl.searchParams.get('code') + + if (code) { + const supabase = createServerClient() + + const { data, error } = await supabase.auth.exchangeCodeForSession(code) + + if (!error && data.user) { + // Check if this is first login (new user) + const isNewUser = data.user.created_at === data.user.last_sign_in_at + + if (isNewUser) { + // Redirect to onboarding + return NextResponse.redirect(`${requestUrl.origin}/onboarding`) + } else { + // Redirect to dashboard + return NextResponse.redirect(`${requestUrl.origin}/epd/clients`) + } + } + } + + // Error fallback + return NextResponse.redirect(`${requestUrl.origin}/login?error=auth_failed`) +} +``` + +**E2.S3 - Email Template (Supabase Dashboard):** +```html + + +

Welkom bij Mini EPD!

+ +

Je hebt een account aangemaakt. Klik op de knop hieronder om je email te bevestigen en in te loggen:

+ + + Bevestig Email & Login + + +

+ Deze link is 1 uur geldig. Heb je deze email niet aangevraagd? Negeer deze email. +

+ +

+ Mini EPD - Professioneel Elektronisch PatiΓ«nten Dossier +

+``` + +--- + +### Epic 3 β€” Login Flow (Password) + +**Epic Doel:** Returning users kunnen inloggen met email + wachtwoord. + +| Story ID | Beschrijving | Acceptatiecriteria | Status | Story Points | +|----------|--------------|---------------------|--------|--------------| +| E3.S1 | Implementeer login handler | Email + password β†’ Supabase auth β†’ redirect | ⏳ | 2 | +| E3.S2 | Set password flow | Na magic link signup β†’ optie om password te setten | ⏳ | 3 | + +**Technical Notes:** + +**E3.S1 - Login Handler:** +```typescript +// app/login/page.tsx + +async function handleLogin(email: string, password: string) { + setLoading(true) + + // Validation + if (!email || !password) { + setMessage({ + type: 'error', + text: 'Vul email en wachtwoord in' + }) + setLoading(false) + return + } + + try { + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password + }) + + if (error) throw error + + setMessage({ + type: 'success', + text: 'Ingelogd! Redirect naar dashboard...' + }) + + // Track analytics + trackEvent('login_success', { method: 'password' }) + + // Redirect after 500ms + setTimeout(() => { + router.push('/epd/clients') + }, 500) + + } catch (error) { + setMessage({ + type: 'error', + text: 'Email of wachtwoord incorrect' + }) + + // Track failed login (rate limiting check) + trackEvent('login_failed', { email }) + + } finally { + setLoading(false) + } +} +``` + +**E3.S2 - Set Password Flow:** +```typescript +// app/set-password/page.tsx (NEW) + +'use client' + +export default function SetPasswordPage() { + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [loading, setLoading] = useState(false) + const router = useRouter() + + async function handleSetPassword() { + // Validation + if (password !== confirmPassword) { + setMessage({ type: 'error', text: 'Wachtwoorden komen niet overeen' }) + return + } + + if (password.length < 8) { + setMessage({ type: 'error', text: 'Wachtwoord moet minimaal 8 tekens zijn' }) + return + } + + setLoading(true) + + try { + const { error } = await supabase.auth.updateUser({ + password + }) + + if (error) throw error + + setMessage({ + type: 'success', + text: 'Wachtwoord ingesteld! Je kunt nu inloggen met email en wachtwoord.' + }) + + setTimeout(() => router.push('/epd/clients'), 2000) + + } catch (error) { + setMessage({ type: 'error', text: error.message }) + } finally { + setLoading(false) + } + } + + return ( +
+
+

Stel een wachtwoord in

+

Gebruik dit wachtwoord om later in te loggen

+ + + + + + + + +
+
+ ) +} +``` + +--- + +### Epic 4 β€” Password Reset Flow + +**Epic Doel:** Users kunnen vergeten wachtwoord resetten via email link. + +| Story ID | Beschrijving | Acceptatiecriteria | Status | Story Points | +|----------|--------------|---------------------|--------|--------------| +| E4.S1 | Reset request page | Email input β†’ Send reset link | ⏳ | 2 | +| E4.S2 | Reset email template | Branded email met reset link | ⏳ | 1 | +| E4.S3 | Reset password page | New password form + validation | ⏳ | 3 | +| E4.S4 | Update password handler | Supabase update + redirect | ⏳ | 2 | + +**Technical Notes:** + +**E4.S1 - Reset Request Page:** +```typescript +// app/reset-password/page.tsx + +'use client' + +export default function ResetPasswordPage() { + const [email, setEmail] = useState('') + const [loading, setLoading] = useState(false) + const [sent, setSent] = useState(false) + + async function handleResetRequest() { + if (!email) { + setMessage({ type: 'error', text: 'Vul je email in' }) + return + } + + setLoading(true) + + try { + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}/update-password` + }) + + if (error) throw error + + setSent(true) + setMessage({ + type: 'success', + text: `Reset link verstuurd naar ${email}. Check je inbox!` + }) + + } catch (error) { + setMessage({ type: 'error', text: error.message }) + } finally { + setLoading(false) + } + } + + if (sent) { + return ( +
+

Email verstuurd! βœ‰οΈ

+

Check je inbox voor de reset link.

+

+ Niet ontvangen? Check je spam folder of + +

+
+ ) + } + + return ( +
+
+

Wachtwoord Vergeten

+

Vul je email in en we sturen je een reset link.

+ + + + + + + ← Terug naar login + +
+
+ ) +} +``` + +**E4.S2 - Reset Email Template:** +```html + + +

Wachtwoord Resetten

+ +

Je hebt een wachtwoord reset aangevraagd voor je Mini EPD account.

+ + + Reset Wachtwoord + + +

+ Deze link is 1 uur geldig. Heb je deze reset niet aangevraagd? Negeer deze email - je wachtwoord blijft ongewijzigd. +

+``` + +**E4.S3 - Update Password Page:** +```typescript +// app/update-password/page.tsx + +'use client' + +export default function UpdatePasswordPage() { + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [loading, setLoading] = useState(false) + const router = useRouter() + + async function handleUpdatePassword() { + // Validation + if (password !== confirmPassword) { + setMessage({ type: 'error', text: 'Wachtwoorden komen niet overeen' }) + return + } + + if (password.length < 8) { + setMessage({ type: 'error', text: 'Wachtwoord minimaal 8 tekens' }) + return + } + + setLoading(true) + + try { + const { error } = await supabase.auth.updateUser({ + password + }) + + if (error) throw error + + setMessage({ + type: 'success', + text: 'Wachtwoord gewijzigd! Je kunt nu inloggen.' + }) + + setTimeout(() => router.push('/login'), 2000) + + } catch (error) { + setMessage({ type: 'error', text: error.message }) + } finally { + setLoading(false) + } + } + + return ( +
+
+

Nieuw Wachtwoord Instellen

+ + + + + + +
+
+ ) +} +``` + +--- + +### Epic 5 β€” Email Verification + +**Epic Doel:** Confirm email na signup (security + anti-spam). + +| Story ID | Beschrijving | Acceptatiecriteria | Status | Story Points | +|----------|--------------|---------------------|--------|--------------| +| E5.S1 | Enable email confirmation | Supabase settings + email template | ⏳ | 1 | +| E5.S2 | Unverified state handling | Block app access until confirmed | ⏳ | 2 | + +**Technical Notes:** + +**E5.S1 - Enable Confirmation:** +```bash +# Supabase Dashboard: +# Authentication β†’ Settings β†’ Email Auth +# βœ… Enable email confirmations +# βœ… Secure email change +``` + +**E5.S2 - Verification Check:** +```typescript +// middleware.ts (UPDATE) + +export async function middleware(request: NextRequest) { + const supabase = createServerClient() + const { data: { user } } = await supabase.auth.getUser() + + // Check if email is verified + if (user && !user.email_confirmed_at) { + // Redirect to verification notice page + return NextResponse.redirect(new URL('/verify-email', request.url)) + } + + // Rest of middleware logic... +} +``` + +```typescript +// app/verify-email/page.tsx (NEW) + +export default function VerifyEmailPage() { + const [resending, setResending] = useState(false) + + async function resendVerification() { + setResending(true) + // Trigger resend via Supabase + await supabase.auth.resend({ + type: 'signup', + email: user.email + }) + setResending(false) + } + + return ( +
+
+

Email Verificatie Vereist

+

Check je inbox voor de verificatie link.

+ + + + ← Terug naar login +
+
+ ) +} +``` + +--- + +### Epic 6 β€” Demo Account + +**Epic Doel:** Quick demo access button voor recruiters/prospects. + +| Story ID | Beschrijving | Acceptatiecriteria | Status | Story Points | +|----------|--------------|---------------------|--------|--------------| +| E6.S1 | QuickDemoButton component | One-click demo login | ⏳ | 2 | +| E6.S2 | Demo mode indicator | Banner in EPD app | ⏳ | 1 | + +**Technical Notes:** + +**E6.S1 - Quick Demo Button:** +```typescript +// app/login/components/quick-demo-button.tsx + +export function QuickDemoButton() { + const [loading, setLoading] = useState(false) + const router = useRouter() + + async function handleQuickDemo() { + setLoading(true) + + try { + const { error } = await supabase.auth.signInWithPassword({ + email: 'demo@mini-ecd.demo', + password: 'Demo2024!' + }) + + if (error) throw error + + trackEvent('demo_access') + router.push('/epd/clients?demo=true') + + } catch (error) { + setMessage({ type: 'error', text: 'Demo login mislukt' }) + } finally { + setLoading(false) + } + } + + return ( + + ) +} +``` + +**E6.S2 - Demo Banner:** +```typescript +// app/epd/components/demo-banner.tsx + +export function DemoBanner() { + const searchParams = useSearchParams() + const isDemo = searchParams.get('demo') === 'true' + + if (!isDemo) return null + + return ( +
+
+ + 🎭 Je bekijkt het prototype met demo data + + + Maak gratis account β†’ + +
+
+ ) +} +``` + +--- + +### Epic 7 β€” Testing & Polish + +**Epic Doel:** Alle flows grondig testen + edge cases + UX polish. + +| Story ID | Beschrijving | Acceptatiecriteria | Status | Story Points | +|----------|--------------|---------------------|--------|--------------| +| E7.S1 | Test alle happy flows | Signup, login, reset, demo werken | ⏳ | 2 | +| E7.S2 | Test error scenarios | Network errors, invalid input, rate limits | ⏳ | 2 | +| E7.S3 | UX polish | Loading states, animations, responsive | ⏳ | 2 | + +**Test Cases:** + +| Test ID | Flow | Scenario | Expected Result | +|---------|------|----------|-----------------| +| TC1 | Signup | Valid email β†’ Magic link | Success message, email sent | +| TC2 | Signup | Invalid email format | Validation error | +| TC3 | Signup | Email already exists | "Account bestaat al - probeer in te loggen" | +| TC4 | Login | Valid credentials | Redirect to dashboard | +| TC5 | Login | Invalid password | "Email of wachtwoord incorrect" | +| TC6 | Login | Unverified email | Redirect to verify-email page | +| TC7 | Password Reset | Valid email | Reset link sent | +| TC8 | Password Reset | Email not found | Still show success (security) | +| TC9 | Password Reset | Weak new password | Validation error "Min 8 characters" | +| TC10 | Demo | Quick demo button | Instant login, redirect with ?demo=true | +| TC11 | Session | Auto-refresh token | Session stays valid 24h+ | +| TC12 | Logout | Click logout | Session cleared, redirect to login | + +--- + +## 5. Kwaliteit & Testplan + +### Security Checklist + +- [ ] Passwords hashed (Supabase handles) +- [ ] Rate limiting on sensitive endpoints +- [ ] HTTPS only (Vercel default) +- [ ] HTTP-only secure cookies +- [ ] Input validation (email, password strength) +- [ ] CSRF protection (Next.js built-in) +- [ ] No sensitive data in client-side code +- [ ] Error messages don't leak info ("Email or password incorrect" not "Email not found") + +### Performance Checklist + +- [ ] Auth state loaded < 500ms +- [ ] Form submissions < 2s response +- [ ] Optimistic UI updates +- [ ] Debounced email validation +- [ ] Code splitting (lazy load auth pages) + +### UX Checklist + +- [ ] Clear loading states (spinners) +- [ ] Success feedback (toasts/messages) +- [ ] Error messages user-friendly +- [ ] Keyboard accessible (tab order) +- [ ] Mobile responsive (touch targets) +- [ ] Focus management (auto-focus first input) +- [ ] Password visibility toggle +- [ ] Password strength indicator + +--- + +## 6. Routes & File Structure + +### New Routes + +``` +app/ +β”œβ”€β”€ login/page.tsx # Main auth page (signup/login/demo) +β”œβ”€β”€ reset-password/page.tsx # Request reset link +β”œβ”€β”€ update-password/page.tsx # Set new password (from email) +β”œβ”€β”€ set-password/page.tsx # Set password after magic link signup +β”œβ”€β”€ verify-email/page.tsx # Email verification notice +β”œβ”€β”€ onboarding/page.tsx # First-time user onboarding (optional) +β”œβ”€β”€ auth/ +β”‚ β”œβ”€β”€ callback/route.ts # Magic link callback (EXISTING) +β”‚ └── logout/route.ts # Logout handler (EXISTING) +``` + +### Components + +``` +components/auth/ +β”œβ”€β”€ auth-form.tsx # Main form component +β”œβ”€β”€ quick-demo-button.tsx # Demo access button +β”œβ”€β”€ password-strength.tsx # Password strength indicator +β”œβ”€β”€ auth-message.tsx # Success/error message component +└── mode-switcher.tsx # Toggle between signup/login +``` + +--- + +## 7. Supabase Configuration + +### Email Templates (To Configure) + +1. **Confirm Signup** (Magic Link) +2. **Reset Password** +3. **Email Change Confirmation** + +### Settings (To Enable) + +```bash +# Supabase Dashboard β†’ Authentication β†’ Settings + +βœ… Enable email confirmations +βœ… Enable email change confirmations +βœ… Secure email change (require password) + +# Email Auth Settings +Rate limit: 4 emails per hour (default) +Confirmation expiry: 1 hour +``` + +### RLS Policies (Verify) + +```sql +-- Users can only see their own data +CREATE POLICY "Users can view own data" ON profiles + FOR SELECT USING (auth.uid() = user_id); + +-- Users can update own profile +CREATE POLICY "Users can update own data" ON profiles + FOR UPDATE USING (auth.uid() = user_id); +``` + +--- + +## 8. Risico's & Mitigatie + +| Risico | Kans | Impact | Mitigatie | +|--------|------|--------|-----------| +| Email delivery issues | Middel | Hoog | - Demo account fallback
- "Resend email" buttons
- Clear "check spam" messaging | +| Rate limiting blocks users | Laag | Middel | - Increase Supabase limits
- Clear error message with wait time | +| Password reset abuse | Middel | Laag | - Rate limiting (built-in)
- Track suspicious activity | +| Email enumeration | Middel | Laag | - Same message for existing/non-existing emails
- No "email not found" errors | +| Session hijacking | Laag | Hoog | - HTTP-only cookies
- Short token expiry
- Secure flag on cookies | +| Broken magic links | Laag | Middel | - 24h expiry (reasonable)
- Clear error page
- "Request new link" option | + +--- + +## 9. Definition of Done + +**All Epics (E1-E7) zijn compleet wanneer:** + +βœ… **Functional Requirements:** +- Signup flow werkt (magic link β†’ account created) +- Login flow werkt (email + password β†’ dashboard) +- Password reset flow werkt (request β†’ email β†’ new password) +- Email verification enforced (unverified users blocked) +- Demo account accessible (one-click) +- Session management werkt (auto-refresh, logout) + +βœ… **Security Requirements:** +- All OWASP top 10 addressed +- No sensitive data in client code +- Rate limiting active +- Secure cookies configured + +βœ… **UX Requirements:** +- All flows tested on mobile + desktop +- Loading states smooth +- Error messages clear +- Keyboard accessible +- No console errors + +βœ… **Code Quality:** +- TypeScript strict mode (no `any`) +- Reusable components extracted +- Error handling comprehensive +- Code commented where complex + +βœ… **Documentation:** +- README updated with auth flow docs +- Environment variables documented +- Supabase setup guide created + +βœ… **Deployment:** +- Tested on staging environment +- Production deployment successful +- Smoke tests passed +- Rollback plan ready + +--- + +## 10. Implementatie Volgorde + +**Aanbevolen phased rollout:** + +### **Phase 1: Foundation (3-4 uur)** +1. E1.S1-S4: Refactor login page +2. E2.S1-S2: Signup flow (magic link) +3. E3.S1: Login flow (password) + +**Checkpoint:** Basic signup + login werkt + +### **Phase 2: Password Management (2-3 uur)** +4. E4.S1-S4: Password reset flow +5. E3.S2: Set password after signup + +**Checkpoint:** Complete password lifecycle + +### **Phase 3: Security & Demo (2 uur)** +6. E5.S1-S2: Email verification +7. E6.S1-S2: Demo account + banner + +**Checkpoint:** Production-ready security + +### **Phase 4: Polish (1-2 uur)** +8. E7.S1-S3: Testing + UX polish + +**Checkpoint:** All flows tested, ready to showcase + +**Total: 8-11 uur** + +--- + +## 11. Referenties + +**Mission Control Documents:** +- **PRD:** `docs/specs/prd-mini-ecd-v1.2.md` +- **TO:** `docs/specs/to-mini-ecd-v1_2.md` + +**External Resources:** +- Supabase Auth Docs: https://supabase.com/docs/guides/auth +- Next.js SSR Auth: https://supabase.com/docs/guides/auth/server-side +- OWASP Top 10: https://owasp.org/www-project-top-ten/ + +**Code References:** +- Login page: `app/login/page.tsx` (MAJOR REFACTOR) +- Auth callback: `app/auth/callback/route.ts` (MINOR UPDATE) +- Middleware: `middleware.ts` (UPDATE - email verification check) +- Auth client: `lib/auth/client.ts` (EXTEND) + +--- + +## 12. Versiehistorie + +| Versie | Datum | Auteur | Wijziging | +|--------|-------|--------|-----------| +| v1.0 | 18-01-2025 | Colin | InitiΓ«le versie - Volledige auth flow bouwplan | + +--- + +**Status:** ⏳ Ready for Implementation +**Next Steps:** Start met E1.S1 (Design nieuwe page structuur) +**Portfolio Value:** πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯ (High - showcases full-stack auth expertise) diff --git a/docs/specs/archive/bouwplan-auth-incremental-v1.0.md b/docs/specs/archive/bouwplan-auth-incremental-v1.0.md new file mode 100644 index 0000000..1ff695d --- /dev/null +++ b/docs/specs/archive/bouwplan-auth-incremental-v1.0.md @@ -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 ( +
+
+
βœ‰οΈ
+

Email Verstuurd!

+

+ Check je inbox voor de reset link. De link is 1 uur geldig. +

+

+ Niet ontvangen? Check je spam folder. +

+ + ← Terug naar login + +
+
+ ) + } + + return ( +
+
+
+

+ Wachtwoord Vergeten? +

+

+ Geen probleem! Vul je email in en we sturen je een reset link. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ + +
+ +
+ + ← Terug naar login + +
+
+
+ ) +} +``` + +### 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 ( +
+
+
βœ…
+

Wachtwoord Gewijzigd!

+

+ Je wordt doorgestuurd naar de login pagina... +

+
+
+ ) + } + + return ( +
+
+
+

+ Nieuw Wachtwoord Instellen +

+

+ Kies een sterk wachtwoord (minimaal 8 tekens). +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+
+
+ ) +} +``` + +### E1.S3 - Email Template Configureren + +**Waar:** Supabase Dashboard β†’ Authentication β†’ Email Templates β†’ Reset Password + +**Template:** +```html +

Wachtwoord Resetten

+ +

Je hebt een wachtwoord reset aangevraagd voor je Mini EPD account.

+ + + Reset Wachtwoord + + +

+ Deze link is 1 uur geldig. Heb je deze reset niet aangevraagd? Negeer deze email. +

+``` + +--- + +## 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 ( +
+
+

+ Stel een Wachtwoord In (Optioneel) +

+

+ Met een wachtwoord kun je sneller inloggen zonder magic link. +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + + + +
+
+
+ ) +} +``` + +### 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 */} +
+ + 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 */} + +
+``` + +--- + +## 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` diff --git a/docs/specs/bouwplan-auth-hook-duplicate-email-v1.0.md b/docs/specs/bouwplan-auth-hook-duplicate-email-v1.0.md new file mode 100644 index 0000000..5801c00 --- /dev/null +++ b/docs/specs/bouwplan-auth-hook-duplicate-email-v1.0.md @@ -0,0 +1,502 @@ +# πŸ” Bouwplan β€” Auth Hook voor Duplicate Email Detection + +**Projectnaam:** Mini EPD - Auth Hook Implementatie +**Versie:** v1.0 (Code-First Approach) +**Datum:** 18-01-2025 +**Auteur:** Colin (met Claude Code) +**Scope:** Server-side duplicate email detection via Auth Hook - Volledig in code waar mogelijk + +--- + +## 1. Filosofie: Code-First Approach + +**Principe:** +- βœ… Alles wat mogelijk is in code/migrations β†’ komt in code +- βœ… Geen handmatige Dashboard configuratie waar mogelijk +- βœ… Flexibel voor toekomstige auth provider switches +- βœ… Herhaalbaar en version controlled + +**Realiteit:** +- Functie: Volledig in SQL migrations βœ… +- Hook link: Vereist Dashboard configuratie (Supabase limietatie) ⚠️ +- Oplossing: Setup script + duidelijke documentatie + +--- + +## 2. Probleem Statement + +**Huidige Situatie:** +- Bij signup met bestaand emailadres (bijv. `colin.lit@gmail.com`) krijgt gebruiker succesmelding +- Geen email wordt verzonden (omdat account al bestaat) +- Gebruiker denkt dat account is aangemaakt maar krijgt geen verificatie email +- Verwarrende UX + +**Root Cause:** +- Supabase geeft geen error bij duplicate email als "Email confirmation" AAN staat +- Dit is een security feature (email enumeration prevention) +- Client-side detection is onbetrouwbaar (werkt alleen als password correct is) + +**Oplossing:** +- Implementeer `before-user-created` Auth Hook +- Server-side check of email al bestaat in database +- Return custom error message als email al geregistreerd is + +--- + +## 3. Wat Gaan We Bouwen? + +| Component | Wat | Waar | Status | +|-----------|-----|------|--------| +| Postgres Function | Database functie | `supabase/migrations/` | βœ… Volledig in code | +| Setup Script | Automatiseer hook link | `scripts/setup-auth-hook.ts` | βœ… Code-based | +| README | Documentatie | `docs/AUTH_HOOK_SETUP.md` | βœ… Documentatie | +| Error Handling | Client-side updates | `app/login/page.tsx` | βœ… Code | + +**Totaal: ~2.5 uur werk** + +--- + +## 4. Technische Keuze: Postgres Function vs HTTP Edge Function + +### **Optie A: Postgres Function (AANBEVOLEN) βœ…** +**Voordelen:** +- βœ… Geen extra dependencies nodig +- βœ… Direct database access (sneller) +- βœ… Makkelijk te onderhouden (SQL in migrations) +- βœ… Geen extra hosting/configuration +- βœ… Past bij bestaande setup (je hebt al migrations) + +**Nadelen:** +- ⚠️ Moet SQL schrijven (maar is simpel) + +### **Optie B: HTTP Edge Function** +**Voordelen:** +- βœ… TypeScript (bekende taal) +- βœ… Meer flexibiliteit voor complexe logica + +**Nadelen:** +- ❌ Extra setup nodig (Supabase Functions) +- ❌ Extra deployment step +- ❌ Meer complexiteit + +**Beslissing: Postgres Function (Optie A)** + +--- + +## 5. Implementatie Plan + +### **Epic 1: Volledig Code-Based Setup** + +| Story ID | Wat Bouwen? | Bestanden | Story Points | +|----------|-------------|-----------|--------------| +| E1.S1 | Create hook function | `supabase/migrations/YYYYMMDDHHMMSS_auth_hook_duplicate_email.sql` | 2 | +| E1.S2 | Setup script voor hook link | `scripts/setup-auth-hook.ts` | 2 | +| E1.S3 | Documentatie | `docs/AUTH_HOOK_SETUP.md` | 1 | +| E1.S4 | Update client error handling | `app/login/page.tsx` | 1 | + +--- + +## 6. Epic 1 β€” Implementatie Details + +### **E1.S1 - Create Hook Function (Volledig in Migrations)** + +**Bestand:** `supabase/migrations/20250118120000_auth_hook_duplicate_email.sql` + +**Wat doet de functie:** +1. Ontvangt signup event van Supabase Auth +2. Haalt email adres uit event payload +3. Valideert email (niet NULL/empty) +4. Normaliseert email (lowercase + trim) +5. Checkt of email al bestaat in `auth.users` table (case-insensitive) +6. Als email bestaat β†’ return error object +7. Als email nieuw is β†’ return empty object (allow signup) + +**SQL Code:** +```sql +-- ============================================================================ +-- Auth Hook: Duplicate Email Detection +-- ============================================================================ +-- Deze functie wordt aangeroepen VOOR een nieuwe user wordt aangemaakt. +-- Checkt of het emailadres al bestaat en blokkeert signup indien nodig. +-- +-- Hook Type: before-user-created +-- Flexibel: Werkt met elke auth provider die Postgres functies ondersteunt +-- ============================================================================ + +create or replace function public.hook_check_duplicate_email(event jsonb) +returns jsonb +language plpgsql +security definer +set search_path = public, auth +as $$ +declare + user_email text; + email_exists boolean; +begin + -- Extract email from event payload + user_email := event->'user'->>'email'; + + -- Validate email is not null or empty + if user_email is null or trim(user_email) = '' then + return jsonb_build_object( + 'error', jsonb_build_object( + 'message', 'Email adres is verplicht.', + 'http_code', 400 + ) + ); + end if; + + -- Normalize email (lowercase, trim) for consistent checking + user_email := lower(trim(user_email)); + + -- Check if email already exists in auth.users (case-insensitive) + select exists( + select 1 + from auth.users + where lower(email) = user_email + ) into email_exists; + + -- If email exists, reject signup with error + if email_exists then + return jsonb_build_object( + 'error', jsonb_build_object( + 'message', 'Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik "Wachtwoord vergeten?".', + 'http_code', 400 + ) + ); + end if; + + -- Email doesn't exist, allow signup + return '{}'::jsonb; +end; +$$; + +-- Grant execute permission to Supabase Auth service +grant execute + on function public.hook_check_duplicate_email + to supabase_auth_admin; + +-- Revoke from other roles (security) +revoke execute + on function public.hook_check_duplicate_email + from authenticated, anon, public; + +-- Add comment for documentation +comment on function public.hook_check_duplicate_email is + 'Auth hook voor duplicate email detection. Wordt aangeroepen via Supabase Auth Hooks (before-user-created).'; +``` + +**Waarom `set search_path`?** +- Zorgt dat `auth.users` correct wordt gevonden +- Voorkomt "table not found" errors +- Best practice voor security definer functies + +**MVP Verbeteringen:** +- βœ… NULL/empty email check (voorkomt crashes) +- βœ… Email normalisatie (lowercase + trim voor consistentie) +- βœ… Case-insensitive duplicate check (Email@Example.com = email@example.com) + +--- + +### **E1.S2 - Setup Script (Automatiseer Hook Link)** + +**Bestand:** `scripts/setup-auth-hook.ts` + +**Doel:** Automatiseer hook link configuratie waar mogelijk + +```typescript +#!/usr/bin/env tsx +/** + * Setup Script: Configure Auth Hook Link + * + * Dit script configureert de link tussen Supabase Auth Hook en onze Postgres functie. + * + * Helaas ondersteunt Supabase Management API nog geen Auth Hooks configuratie, + * dus dit script geeft instructies voor handmatige configuratie. + * + * In de toekomst kan dit worden geautomatiseerd zodra Supabase API dit ondersteunt. + */ + +import { supabaseAdmin } from '@/lib/supabase/server' + +async function setupAuthHook() { + console.log('πŸ” Auth Hook Setup Script\n') + + // Check if function exists by trying to call it with a test payload + // This is more reliable than querying pg_proc directly + const { error } = await supabaseAdmin.rpc('hook_check_duplicate_email', { + event: JSON.stringify({ + user: { + email: 'test@example.com' + } + }) + }) + + // If function doesn't exist, we'll get a "function does not exist" error + // If it exists but returns an error, that's fine - we just want to check existence + if (error && error.message?.includes('does not exist')) { + console.error('❌ Function niet gevonden:', error.message) + console.log('\nπŸ“ Stap 1: Run eerst de migration:') + console.log(' supabase db push') + console.log(' Of via Supabase Dashboard β†’ SQL Editor') + return + } + + console.log('βœ… Function exists: hook_check_duplicate_email') + console.log('\nπŸ“ Stap 2: Configureer hook link in Supabase Dashboard:') + console.log(' 1. Ga naar: https://supabase.com/dashboard/project/YOUR_PROJECT/auth/hooks') + console.log(' 2. Klik "Add hook"') + console.log(' 3. Selecteer:') + console.log(' - Hook Type: before-user-created') + console.log(' - Hook Name: check-duplicate-email') + console.log(' - Hook Function: hook_check_duplicate_email') + console.log(' - Hook URL: (leeg laten)') + console.log(' 4. Klik "Save"') + console.log('\nπŸ’‘ Tip: Deze stap moet handmatig omdat Supabase Management API') + console.log(' Auth Hooks configuratie nog niet ondersteunt.') + console.log('\nβœ… Setup compleet! Test met: pnpm run test:auth-hook') +} + +setupAuthHook().catch(console.error) +``` + +**Toevoegen aan `package.json`:** +```json +{ + "scripts": { + "setup:auth-hook": "tsx scripts/setup-auth-hook.ts" + } +} +``` + +--- + +### **E1.S3 - Documentatie** + +**Bestand:** `docs/AUTH_HOOK_SETUP.md` + +```markdown +# Auth Hook Setup Guide + +## Overzicht + +Deze hook detecteert duplicate emails VOOR een user wordt aangemaakt, +waardoor gebruikers direct feedback krijgen als hun email al geregistreerd is. + +## Setup (Eerste Keer) + +### Stap 1: Deploy Migration + +\`\`\`bash +# Via Supabase CLI (aanbevolen) +supabase db push + +# Of via Dashboard +# Ga naar SQL Editor β†’ Run migration file +\`\`\` + +### Stap 2: Configureer Hook Link + +**Helaas moet dit handmatig via Dashboard** (Supabase ondersteunt dit nog niet via API): + +1. Ga naar: [Supabase Dashboard β†’ Auth β†’ Hooks](https://supabase.com/dashboard/project/_/auth/hooks) +2. Klik "Add hook" +3. Vul in: + - **Hook Type:** \`before-user-created\` + - **Hook Name:** \`check-duplicate-email\` + - **Hook Function:** \`hook_check_duplicate_email\` + - **Hook URL:** (leeg laten) +4. Klik "Save" + +### Stap 3: Test + +\`\`\`bash +pnpm run setup:auth-hook +\`\`\` + +## Herhaalbaarheid + +- βœ… Functie code staat in migrations (version controlled) +- ⚠️ Hook link moet per omgeving handmatig worden geconfigureerd +- πŸ“ Documentatie staat in Git + +## Toekomstige Verbeteringen + +Zodra Supabase Management API Auth Hooks ondersteunt, kunnen we: +- Hook link volledig automatiseren +- Setup script uitbreiden met API calls +- CI/CD pipeline voor hook configuratie + +## Flexibiliteit + +De functie is geschreven in standaard PostgreSQL, waardoor: +- βœ… Werkt met elke auth provider die Postgres functies ondersteunt +- βœ… Makkelijk te migreren naar andere auth systemen +- βœ… Geen vendor lock-in voor de logica zelf +``` + +--- + +### **E1.S4 - Update Client Error Handling** + +**Bestand:** `app/login/page.tsx` + +**Wat wijzigen:** +- Update error handling om hook error messages te tonen +- Hook errors komen binnen via `error.message` +- Verbeter UX door automatisch naar login mode te switchen bij duplicate email + +**Verbeterde code:** +```typescript +catch (error: any) { + const errorMessage = error.message || 'Er ging iets mis. Probeer opnieuw.' + + // Check if it's a duplicate email error from hook + if (errorMessage.includes('al geregistreerd')) { + setMessage({ + type: 'error', + text: errorMessage + }) + // Switch to login mode after 2 seconds + setTimeout(() => { + setMode('login') + setPassword('') + setConfirmPassword('') + }, 2000) + } else { + setMessage({ + type: 'error', + text: errorMessage + }) + } +} +``` + +--- + +## 7. Implementatie Volgorde + +**Stap 1: Create Migration** (30 min) +1. Maak `supabase/migrations/20250118120000_auth_hook_duplicate_email.sql` +2. Deploy via `supabase db push` of Dashboard + +**Stap 2: Create Setup Script** (30 min) +1. Maak `scripts/setup-auth-hook.ts` +2. Test script: `pnpm run setup:auth-hook` + +**Stap 3: Create Documentation** (15 min) +1. Maak `docs/AUTH_HOOK_SETUP.md` +2. Update main README met link + +**Stap 4: Configure Hook Link** (5 min) +1. Run setup script voor instructies +2. Volg Dashboard stappen + +**Stap 5: Update Client** (15 min) +1. Verbeter error handling +2. Test duplicate email scenario + +**Stap 6: Testing** (30 min) +1. Test signup met nieuw email β†’ moet werken +2. Test signup met bestaand email β†’ moet error geven +3. Test signup met bestaand email + verkeerd password β†’ moet error geven +4. Test signup met bestaand email + correct password β†’ moet error geven (want account bestaat al) + +**Totaal: ~2.5 uur werk** + +--- + +## 8. Testing Checklist + +| Test Case | Scenario | Expected Result | +|-----------|----------|-----------------| +| TC1 | Signup met nieuw email | Account aangemaakt, email verzonden | +| TC2 | Signup met bestaand email | Error: "Dit emailadres is al geregistreerd..." | +| TC3 | Signup met bestaand email (case variant: Email@Example.com) | Error: "Dit emailadres is al geregistreerd..." | +| TC4 | Signup met lege/NULL email | Error: "Email adres is verplicht." | +| TC5 | Hook disabled β†’ signup met bestaand email | Oude gedrag (geen error, maar ook geen email) | + +--- + +## 9. Voordelen van Code-First Aanpak + +βœ… **Version Control**: Functie code staat in Git +βœ… **Herhaalbaar**: Migrations kunnen opnieuw worden gedraaid +βœ… **Documentatie**: Alles staat in code en docs +βœ… **Flexibel**: Makkelijk te migreren naar andere auth providers +βœ… **Team-vriendelijk**: Iedereen ziet wat er gebeurt +βœ… **CI/CD Ready**: Migrations kunnen geautomatiseerd worden +βœ… **Server-side**: Veilig, kan niet worden omzeild door client +βœ… **Betrouwbaar**: Werkt altijd, ongeacht password +βœ… **Duidelijke UX**: Gebruiker krijgt direct feedback +βœ… **Security**: Behoudt email enumeration protection voor andere scenario's +βœ… **Performance**: Direct database check, geen extra HTTP calls + +--- + +## 10. Limitaties & Workarounds + +**Limitaties:** +- ⚠️ Hook link configuratie kan niet volledig in code (Supabase limietatie) +- ⚠️ Setup script geeft alleen instructies (geen API beschikbaar) + +**Workarounds:** +- βœ… Duidelijke documentatie voor handmatige stap +- βœ… Setup script valideert dat functie bestaat +- βœ… Toekomst-proof: zodra API beschikbaar is, kunnen we automatiseren + +--- + +## 11. Alternatieve Aanpakken (Niet Aanbevolen) + +### **Optie X: Email Confirmation UIT zetten** +- ❌ Minder secure (email enumeration mogelijk) +- βœ… Wel makkelijker duplicate detection +- **Niet aanbevolen voor production** + +### **Optie Y: Client-side login check (huidige aanpak)** +- ⚠️ Werkt alleen als password correct is +- ⚠️ Extra API call +- ⚠️ Kan niet onderscheiden tussen "nieuw account" en "verkeerd password" +- **Acceptabel voor prototype, niet voor production** + +--- + +## 12. Definition of Done + +βœ… **Epic 1 compleet wanneer:** +- Postgres functie bestaat en werkt (in migrations) +- Setup script werkt en geeft duidelijke instructies +- Documentatie compleet en up-to-date +- Hook geconfigureerd in Supabase Dashboard +- Test signup met bestaand email geeft error +- Test signup met nieuw email werkt normaal +- Error messages zijn gebruiksvriendelijk +- Geen breaking changes aan bestaande auth flow +- Git commit: `feat: Add auth hook for duplicate email detection` + +--- + +## 13. MVP vs Production + +**Wat zit er in MVP (huidige plan):** +- βœ… Duplicate email detection +- βœ… NULL/empty email validation +- βœ… Case-insensitive matching +- βœ… Email normalisatie (lowercase + trim) +- βœ… Duidelijke error messages +- βœ… Setup script voor validatie + +**Wat komt later (Production - Optioneel):** +- [ ] Rate limiting op hook (voorkom abuse) +- [ ] Logging tabel voor duplicate attempts +- [ ] Analytics: hoeveel duplicate attempts per dag? +- [ ] Monitoring/alerting voor hook failures +- [ ] Custom error messages per scenario + +--- + +**Status:** ⏳ Ready for Implementation +**Geschatte Tijd:** 2.5 uur +**Next Step:** Maak migration file `supabase/migrations/20250118120000_auth_hook_duplicate_email.sql` + diff --git a/lib/auth/client.ts b/lib/auth/client.ts index 2a3f57f..2c28e17 100644 --- a/lib/auth/client.ts +++ b/lib/auth/client.ts @@ -18,31 +18,137 @@ export function createClient() { } /** - * Send magic link to user's email - * Auto-creates account if user doesn't exist + * Sign up with email + password + * Returns data with user and session (if email confirmation is disabled) + * + * Note: If email confirmation is enabled and email already exists, + * Supabase returns success but no user object (security feature) */ -export async function loginWithMagicLink(email: string) { +export async function signUpWithPassword(email: string, password: string) { const supabase = createClient() - - const { data, error } = await supabase.auth.signInWithOtp({ + const { data, error } = await supabase.auth.signUp({ email, + password, options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, - shouldCreateUser: true, // Auto-create account on first login + emailRedirectTo: `${window.location.origin}/auth/callback` } }) - if (error) throw error - - return { - success: true, - message: 'Check je email voor de magic link!', - data + // DEBUG: Log response to see what Supabase returns when hook throws error + if (process.env.NODE_ENV === 'development') { + console.log('πŸ” SignUp Response:', { + hasError: !!error, + errorMessage: error?.message, + errorCode: error?.code, + errorStatus: (error as any)?.status, + hasUser: !!data?.user, + hasSession: !!data?.session, + identities: data?.user?.identities?.length || 0 + }) } + + if (error) { + // Check for explicit duplicate email errors (from Auth Hook or Supabase) + const errorMessage = error.message?.toLowerCase() || '' + const errorCode = error.code?.toLowerCase() || '' + + if (errorMessage.includes('already registered') || + errorMessage.includes('already exists') || + errorMessage.includes('al geregistreerd') || // Auth Hook (NL) + errorMessage.includes('emailadres is al') || // Extra check voor hook message + errorCode === 'user_already_registered' || + (error as any)?.status === 400) { // Hook errors typically have 400 status + // If it's from Auth Hook, preserve the original message + const duplicateError = new Error(error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik "Wachtwoord vergeten?".') + ;(duplicateError as any).code = 'user_already_registered' + throw duplicateError + } + throw error + } + + // Check if user was created but has no identities (indicates duplicate email) + // This happens when Supabase creates a user object but doesn't actually create the account + // because the email already exists (security feature - email enumeration protection) + if (data.user && (!data.user.identities || data.user.identities.length === 0)) { + // User object exists but no identities = duplicate email detected by Supabase + // Try to login to confirm and get proper error message + const loginAttempt = await supabase.auth.signInWithPassword({ + email, + password + }) + + if (loginAttempt.data?.user) { + // Login succeeded - account already exists! + const duplicateError = new Error('Dit emailadres is al geregistreerd. Je bent nu ingelogd.') + ;(duplicateError as any).code = 'user_already_registered' + ;(duplicateError as any).data = loginAttempt.data + throw duplicateError + } else { + // Login failed - account exists but password is wrong + // This is the duplicate email case that the hook should have caught + const duplicateError = new Error('Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik "Wachtwoord vergeten?".') + ;(duplicateError as any).code = 'user_already_registered' + throw duplicateError + } + } + + // If email confirmation is enabled, Supabase doesn't return a user/session + // for new signups (email is sent instead) + // However, if email already exists, Supabase also returns no user but doesn't send email + // Try to detect this by attempting a login + if (!data.user && !data.session) { + // Try to login to check if account already exists + // This is a fallback detection method if the hook doesn't work + const loginAttempt = await supabase.auth.signInWithPassword({ + email, + password + }) + + if (loginAttempt.data?.user) { + // Login succeeded - account already exists! + // This means the hook didn't catch it, but we can detect it client-side + const duplicateError = new Error('Dit emailadres is al geregistreerd. Je bent nu ingelogd.') + ;(duplicateError as any).code = 'user_already_registered' + ;(duplicateError as any).data = loginAttempt.data // Include session data + throw duplicateError + } + + // Login failed - could be new account OR wrong password for existing account + // If it's a wrong password, we can't distinguish from a new account + // (Supabase security feature - email enumeration protection) + // Return the signup data (which has no user/session) and let UI show generic message + // Note: If hook is working, we should have gotten an error above, so this is fallback + } + + return data } /** - * Login with email + password (for demo accounts) + * Send password reset email + */ +export async function resetPasswordForEmail(email: string) { + const supabase = createClient() + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}/auth/callback?next=/update-password` + }) + + if (error) throw error + return true +} + +/** + * Update user password (requires active session) + */ +export async function updateUserPassword(password: string) { + const supabase = createClient() + const { error } = await supabase.auth.updateUser({ password }) + + if (error) throw error + return true +} + +/** + * Login with email + password (for demo accounts and regular users) */ export async function loginWithPassword(email: string, password: string) { const supabase = createClient() diff --git a/package.json b/package.json index 4afa27a..c92318c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build --webpack", "start": "next start", "lint": "eslint", - "types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts" + "types:generate": "npx supabase gen types typescript --project-id dqugbrpwtisgyxscpefg > lib/supabase/types.ts", + "setup:auth-hook": "tsx scripts/setup-auth-hook.ts" }, "dependencies": { "@radix-ui/react-icons": "^1.3.2", diff --git a/scripts/apply-migration.ts b/scripts/apply-migration.ts new file mode 100644 index 0000000..88e6e0d --- /dev/null +++ b/scripts/apply-migration.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env tsx +/** + * Apply Migration Script + * Executes a SQL migration file directly against the Supabase database + */ + +import { createClient } from '@supabase/supabase-js' +import { readFileSync } from 'fs' +import { join } from 'path' + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY! + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('❌ Missing Supabase credentials') + console.error(' Make sure NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } +}) + +async function applyMigration(migrationFile: string) { + console.log(`πŸ”„ Applying migration: ${migrationFile}\n`) + + try { + // Read migration file + const migrationPath = join(process.cwd(), 'supabase/migrations', migrationFile) + const sql = readFileSync(migrationPath, 'utf-8') + + // Execute SQL + const { data, error } = await supabase.rpc('exec_sql', { sql_query: sql }) + + if (error) { + // Try alternative approach - direct query + const statements = sql + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0 && !s.startsWith('--')) + + for (const statement of statements) { + const { error: stmtError } = await supabase.rpc(statement) + if (stmtError) { + console.error('❌ Error executing statement:', stmtError.message) + throw stmtError + } + } + } + + console.log('βœ… Migration applied successfully!') + console.log('\nπŸ“ Next steps:') + console.log(' 1. Run: pnpm run setup:auth-hook') + console.log(' 2. Configure hook link in Supabase Dashboard') + + } catch (error: any) { + console.error('❌ Migration failed:', error.message) + console.log('\nπŸ’‘ Alternative: Apply manually via Supabase Dashboard') + console.log(' 1. Go to: https://supabase.com/dashboard/project/_/sql') + console.log(' 2. Copy contents of:', migrationFile) + console.log(' 3. Paste and run in SQL Editor') + process.exit(1) + } +} + +// Get migration file from command line or use latest +const migrationFile = process.argv[2] || '20251119094908_auth_hook_duplicate_email.sql' +applyMigration(migrationFile) diff --git a/scripts/setup-auth-hook.ts b/scripts/setup-auth-hook.ts new file mode 100644 index 0000000..0d477d6 --- /dev/null +++ b/scripts/setup-auth-hook.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env tsx +/** + * Setup Script: Configure Auth Hook Link + * + * Dit script configureert de link tussen Supabase Auth Hook en onze Postgres functie. + * + * Helaas ondersteunt Supabase Management API nog geen Auth Hooks configuratie, + * dus dit script geeft instructies voor handmatige configuratie. + * + * In de toekomst kan dit worden geautomatiseerd zodra Supabase API dit ondersteunt. + */ + +import { config } from 'dotenv' +import { createClient } from '@supabase/supabase-js' + +// Load environment variables from .env.local +config({ path: '.env.local' }) + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY! + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('❌ Missing environment variables') + console.error(' Make sure .env.local is loaded with:') + console.error(' - NEXT_PUBLIC_SUPABASE_URL') + console.error(' - SUPABASE_SERVICE_ROLE_KEY') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } +}) + +async function setupAuthHook() { + console.log('πŸ” Auth Hook Setup Script\n') + + // Extract project ID from URL + const projectId = supabaseUrl.match(/https:\/\/([^.]+)\.supabase\.co/)?.[1] + + // Check if function exists by trying to call it with a test payload + // This is more reliable than querying pg_proc directly + try { + const testPayload = { + user: { + email: 'test@example.com' + } + } + + const { data, error } = await supabase.rpc('hook_check_duplicate_email', { + event: testPayload + }) + + // If function doesn't exist, we'll get a "function does not exist" error + if (error && error.message?.includes('does not exist')) { + console.error('❌ Function niet gevonden:', error.message) + console.log('\nπŸ“ Stap 1: Run eerst de migration:') + console.log(' 1. Ga naar: https://supabase.com/dashboard/project/' + projectId + '/sql') + console.log(' 2. Open migration file: supabase/migrations/20251119094908_auth_hook_duplicate_email.sql') + console.log(' 3. Kopieer de inhoud en plak in SQL Editor') + console.log(' 4. Klik "RUN" om de functie aan te maken') + console.log('\n Of via CLI (als je Supabase CLI hebt geconfigureerd):') + console.log(' npx supabase db push') + return + } + + console.log('βœ… Function exists: hook_check_duplicate_email') + console.log(' Test call succeeded with response:', data || '{}') + + } catch (error: any) { + console.error('⚠️ Could not verify function:', error.message) + console.log(' Continuing with setup instructions...\n') + } + + console.log('\nπŸ“ Stap 2: Configureer hook link in Supabase Dashboard:') + console.log(' 1. Ga naar: https://supabase.com/dashboard/project/' + projectId + '/auth/hooks') + console.log(' 2. Klik "Add a new hook" of "Enable Hooks"') + console.log(' 3. Selecteer:') + console.log(' - Hook Type: "Send a hook on before a user is created" (before-user-created)') + console.log(' - Select hook: "Postgres Function"') + console.log(' - Schema: "public"') + console.log(' - Function Name: "hook_check_duplicate_email"') + console.log(' 4. Klik "Create hook" of "Save"') + console.log('\nπŸ’‘ Tip: Deze stap moet handmatig omdat Supabase Management API') + console.log(' Auth Hooks configuratie nog niet ondersteunt.') + console.log('\nβœ… Setup compleet na Dashboard configuratie!') + console.log('\nπŸ§ͺ Test de hook:') + console.log(' 1. Ga naar je signup pagina') + console.log(' 2. Probeer te registreren met een bestaand emailadres') + console.log(' 3. Je zou een error moeten zien: "Dit emailadres is al geregistreerd..."') +} + +setupAuthHook().catch(console.error) diff --git a/scripts/test-hook-function.ts b/scripts/test-hook-function.ts new file mode 100644 index 0000000..ca87f75 --- /dev/null +++ b/scripts/test-hook-function.ts @@ -0,0 +1,72 @@ +#!/usr/bin/env tsx +/** + * Test Auth Hook Function + * Tests the hook function directly to verify it works + */ + +import { config } from 'dotenv' +import { createClient } from '@supabase/supabase-js' + +// Load environment variables +config({ path: '.env.local' }) + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY! + +const supabase = createClient(supabaseUrl, supabaseServiceKey, { + auth: { + autoRefreshToken: false, + persistSession: false + } +}) + +async function testHookFunction() { + console.log('πŸ§ͺ Testing Auth Hook Function\n') + + // Test 1: New email (should allow) + console.log('Test 1: New email (should return empty object)') + const test1 = await supabase.rpc('hook_check_duplicate_email', { + event: { user: { email: 'newemail@test.com' } } + }) + console.log('Result:', test1.data) + console.log('Error:', test1.error) + + // Test 2: Existing email (should block) + console.log('\nTest 2: Existing email colin@ikbenlit.nl (should return error)') + const test2 = await supabase.rpc('hook_check_duplicate_email', { + event: { user: { email: 'colin@ikbenlit.nl' } } + }) + console.log('Result:', test2.data) + console.log('Error:', test2.error) + + // Test 3: Case variant (should block) + console.log('\nTest 3: Case variant Colin@IkBenLit.nl (should return error)') + const test3 = await supabase.rpc('hook_check_duplicate_email', { + event: { user: { email: 'Colin@IkBenLit.nl' } } + }) + console.log('Result:', test3.data) + console.log('Error:', test3.error) + + // Test 4: Empty email (should block) + console.log('\nTest 4: Empty email (should return error)') + const test4 = await supabase.rpc('hook_check_duplicate_email', { + event: { user: { email: '' } } + }) + console.log('Result:', test4.data) + console.log('Error:', test4.error) + + // Check if colin@ikbenlit.nl exists + console.log('\nπŸ“‹ Checking if colin@ikbenlit.nl exists in auth.users:') + const { data: users, error: usersError } = await supabase.rpc('exec_sql', { + sql: "SELECT email, created_at FROM auth.users WHERE lower(email) = 'colin@ikbenlit.nl' LIMIT 1" + }) + + if (usersError) { + console.log('Could not query users directly (expected, requires special permissions)') + console.log('Error:', usersError.message) + } else { + console.log('Users found:', users) + } +} + +testHookFunction().catch(console.error) diff --git a/scripts/test-signup-direct.ts b/scripts/test-signup-direct.ts new file mode 100644 index 0000000..006c8f0 --- /dev/null +++ b/scripts/test-signup-direct.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env tsx +/** + * Test Signup Direct + * Test wat Supabase teruggeeft bij duplicate email signup + */ + +import { config } from 'dotenv' +import { createClient } from '@supabase/supabase-js' + +config({ path: '.env.local' }) + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL! +const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + +const supabase = createClient(supabaseUrl, supabaseAnonKey) + +async function testSignup() { + console.log('πŸ§ͺ Testing Direct Signup with Existing Email\n') + console.log('URL:', supabaseUrl) + console.log('Testing with email: colin@ikbenlit.nl\n') + + const { data, error } = await supabase.auth.signUp({ + email: 'colin@ikbenlit.nl', + password: 'TestPassword123!', + options: { + emailRedirectTo: `http://localhost:3000/auth/callback` + } + }) + + console.log('πŸ“Š Response:') + console.log('Data:', JSON.stringify(data, null, 2)) + console.log('\nError:', JSON.stringify(error, null, 2)) + + console.log('\nπŸ“ Analysis:') + if (error) { + console.log('βœ… Hook is working! Error received:', error.message) + } else if (data.user && data.session) { + console.log('⚠️ User created with session (email confirmation disabled)') + } else if (data.user) { + console.log('⚠️ User object returned without session') + } else { + console.log('❌ No error but no user - Hook might not be working') + console.log(' This is the "silent fail" scenario') + } +} + +testSignup().catch(console.error) diff --git a/supabase/migrations/20251119094908_auth_hook_duplicate_email.sql b/supabase/migrations/20251119094908_auth_hook_duplicate_email.sql new file mode 100644 index 0000000..ac3b9b2 --- /dev/null +++ b/supabase/migrations/20251119094908_auth_hook_duplicate_email.sql @@ -0,0 +1,71 @@ +-- ============================================================================ +-- Auth Hook: Duplicate Email Detection +-- ============================================================================ +-- Deze functie wordt aangeroepen VOOR een nieuwe user wordt aangemaakt. +-- Checkt of het emailadres al bestaat en blokkeert signup indien nodig. +-- +-- Hook Type: before-user-created +-- Flexibel: Werkt met elke auth provider die Postgres functies ondersteunt +-- ============================================================================ + +create or replace function public.hook_check_duplicate_email(event jsonb) +returns jsonb +language plpgsql +security definer +set search_path = public, auth +as $$ +declare + user_email text; + email_exists boolean; +begin + -- Extract email from event payload + user_email := event->'user'->>'email'; + + -- Validate email is not null or empty + if user_email is null or trim(user_email) = '' then + return jsonb_build_object( + 'error', jsonb_build_object( + 'message', 'Email adres is verplicht.', + 'http_code', 400 + ) + ); + end if; + + -- Normalize email (lowercase, trim) for consistent checking + user_email := lower(trim(user_email)); + + -- Check if email already exists in auth.users (case-insensitive) + select exists( + select 1 + from auth.users + where lower(email) = user_email + ) into email_exists; + + -- If email exists, reject signup with error + if email_exists then + return jsonb_build_object( + 'error', jsonb_build_object( + 'message', 'Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik "Wachtwoord vergeten?".', + 'http_code', 400 + ) + ); + end if; + + -- Email doesn't exist, allow signup + return '{}'::jsonb; +end; +$$; + +-- Grant execute permission to Supabase Auth service +grant execute + on function public.hook_check_duplicate_email + to supabase_auth_admin; + +-- Revoke from other roles (security) +revoke execute + on function public.hook_check_duplicate_email + from authenticated, anon, public; + +-- Add comment for documentation +comment on function public.hook_check_duplicate_email is + 'Auth hook voor duplicate email detection. Wordt aangeroepen via Supabase Auth Hooks (before-user-created).';