feat: add duplicate email detection via auth hook with fallback
- Add before-user-created hook function for server-side duplicate email detection - Implement fallback detection using identities array check (Supabase limitation) - Update signUpWithPassword to detect duplicate emails via hook errors or empty identities - Add error handling in login page to show duplicate email errors and auto-switch to login mode - Add auth hook setup documentation and test scripts - Add password reset and update password flows - Add email templates for signup confirmation and password reset
This commit is contained in:
39
AGENTS.md
Normal file
39
AGENTS.md
Normal file
@@ -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.
|
||||||
@@ -18,9 +18,18 @@ export async function GET(request: NextRequest) {
|
|||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
|
|
||||||
// Exchange code for session
|
// 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
|
// Redirect to the specified next URL or default to /clients
|
||||||
return NextResponse.redirect(new URL(next, request.url))
|
return NextResponse.redirect(new URL(next, request.url))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { loginWithMagicLink, loginWithPassword } from '@/lib/auth/client'
|
import { loginWithPassword, signUpWithPassword } from '@/lib/auth/client'
|
||||||
import { Brain, Zap, Target, FileText, Clock, TrendingDown } from 'lucide-react'
|
import { Brain, Zap, Target, Clock } from 'lucide-react'
|
||||||
import { BentoGrid, BentoCard } from '@/components/ui/bento-grid'
|
import { BentoGrid, BentoCard } from '@/components/ui/bento-grid'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
// Bento grid features with different sizes for visual interest
|
// Bento grid features with different sizes for visual interest
|
||||||
const bentoFeatures = [
|
const bentoFeatures = [
|
||||||
@@ -56,70 +57,106 @@ const bentoFeatures = [
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const [mode, setMode] = useState<'login' | 'signup'>('login')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('') // Only for signup
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: 'success' | 'error'
|
type: 'success' | 'error'
|
||||||
text: string
|
text: string
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [showDemoLogin, setShowDemoLogin] = useState(false)
|
|
||||||
|
|
||||||
// Magic link login
|
// Handle form submission
|
||||||
const handleMagicLinkLogin = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setMessage(null)
|
setMessage(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await loginWithMagicLink(email)
|
if (mode === 'signup') {
|
||||||
setMessage({
|
// Signup Flow
|
||||||
type: 'success',
|
if (password !== confirmPassword) {
|
||||||
text: result.message
|
throw new Error('Wachtwoorden komen niet overeen')
|
||||||
})
|
}
|
||||||
setEmail('')
|
|
||||||
} catch (error: any) {
|
// Sign up
|
||||||
setMessage({
|
const result = await signUpWithPassword(email, password)
|
||||||
type: 'error',
|
|
||||||
text: error.message || 'Er ging iets mis. Probeer opnieuw.'
|
// Check if user got a session immediately (email confirmation disabled)
|
||||||
})
|
if (result.session) {
|
||||||
} finally {
|
// User is logged in immediately
|
||||||
setLoading(false)
|
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)
|
// Redirect to EPD
|
||||||
const handlePasswordLogin = async (e: React.FormEvent) => {
|
setTimeout(() => {
|
||||||
e.preventDefault()
|
router.push('/epd/clients')
|
||||||
setLoading(true)
|
}, 1000)
|
||||||
setMessage(null)
|
}
|
||||||
|
} catch (error: any) {
|
||||||
try {
|
// Check for duplicate email error with auto-login
|
||||||
await loginWithPassword(email, password)
|
if (error.code === 'user_already_registered' && error.data?.session) {
|
||||||
setMessage({
|
// User was auto-logged in (client-side detection succeeded)
|
||||||
type: 'success',
|
setMessage({
|
||||||
text: 'Ingelogd! Redirect naar EPD...'
|
type: 'success',
|
||||||
})
|
text: 'Dit emailadres bestaat al. Je bent nu ingelogd!'
|
||||||
|
})
|
||||||
// Redirect to EPD
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
router.push('/epd/clients')
|
||||||
router.push('/epd/clients')
|
}, 1000)
|
||||||
}, 1000)
|
}
|
||||||
} catch (error: any) {
|
// Check for duplicate email error (from Auth Hook or client-side)
|
||||||
setMessage({
|
else if (error.code === 'user_already_registered') {
|
||||||
type: 'error',
|
setMessage({
|
||||||
text: error.message || 'Ongeldige credentials.'
|
type: 'error',
|
||||||
})
|
text: error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen.'
|
||||||
} finally {
|
})
|
||||||
|
// 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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick demo login
|
// Quick demo login
|
||||||
const handleQuickDemoLogin = async () => {
|
const handleQuickDemoLogin = async () => {
|
||||||
|
setMode('login')
|
||||||
setEmail('demo@mini-ecd.demo')
|
setEmail('demo@mini-ecd.demo')
|
||||||
setPassword('Demo2024!')
|
setPassword('Demo2024!')
|
||||||
setShowDemoLogin(true)
|
|
||||||
|
|
||||||
// Auto-submit
|
// Auto-submit
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -187,19 +224,46 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Side - Login Form (40%) */}
|
{/* Right Side - Login/Signup Form (40%) */}
|
||||||
<div className="md:w-2/5 bg-white p-8 md:p-12 flex flex-col justify-center">
|
<div className="md:w-2/5 bg-white p-8 md:p-12 flex flex-col justify-center">
|
||||||
<div className="w-full max-w-md mx-auto">
|
<div className="w-full max-w-md mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
|
<h2 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
|
||||||
Login
|
{mode === 'login' ? 'Welkom terug' : 'Maak een account'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-slate-600">
|
<p className="text-slate-600">
|
||||||
Toegang tot EPD prototype
|
{mode === 'login'
|
||||||
|
? 'Log in om toegang te krijgen tot het EPD'
|
||||||
|
: 'Start vandaag nog met snellere rapportages'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex bg-slate-100 p-1 rounded-lg mb-6">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
||||||
|
mode === 'login'
|
||||||
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('login')}
|
||||||
|
>
|
||||||
|
Inloggen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${
|
||||||
|
mode === 'signup'
|
||||||
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('signup')}
|
||||||
|
>
|
||||||
|
Registreren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Message Display */}
|
{/* Message Display */}
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<div
|
||||||
@@ -213,149 +277,106 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Magic Link Login */}
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{!showDemoLogin && (
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">
|
<label
|
||||||
📧 Login met Magic Link
|
htmlFor="email"
|
||||||
</h3>
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
|
||||||
<form onSubmit={handleMagicLinkLogin} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="email"
|
|
||||||
className="block text-sm font-medium text-slate-700 mb-1"
|
|
||||||
>
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="jouw@email.nl"
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
Nieuw? Account wordt automatisch aangemaakt!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading ? 'Verzenden...' : 'Stuur Magic Link'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="relative my-6">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<div className="w-full border-t border-slate-300" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-sm">
|
|
||||||
<span className="px-2 bg-white text-slate-500">of</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Demo Account Toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDemoLogin(true)}
|
|
||||||
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 font-medium py-2.5 px-4 rounded-lg transition-colors"
|
|
||||||
>
|
>
|
||||||
🎯 Login met Demo Account
|
Email
|
||||||
</button>
|
</label>
|
||||||
|
<input
|
||||||
{/* Quick Demo Button */}
|
id="email"
|
||||||
<button
|
type="email"
|
||||||
onClick={handleQuickDemoLogin}
|
value={email}
|
||||||
disabled={loading}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="mt-3 w-full bg-amber-50 hover:bg-amber-100 text-amber-800 text-sm font-medium py-2 px-4 rounded-lg border border-amber-200 transition-colors disabled:opacity-50"
|
placeholder="jouw@email.nl"
|
||||||
>
|
required
|
||||||
⚡ Snelle Demo Login
|
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"
|
||||||
</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Demo Password Login */}
|
|
||||||
{showDemoLogin && (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<label
|
||||||
<h3 className="text-lg font-semibold text-slate-900">
|
htmlFor="password"
|
||||||
🎯 Demo Account Login
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
</h3>
|
>
|
||||||
<button
|
Wachtwoord
|
||||||
onClick={() => {
|
</label>
|
||||||
setShowDemoLogin(false)
|
<input
|
||||||
setPassword('')
|
id="password"
|
||||||
}}
|
type="password"
|
||||||
className="text-sm text-slate-600 hover:text-slate-900"
|
value={password}
|
||||||
>
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
← Terug
|
placeholder="••••••••"
|
||||||
</button>
|
required
|
||||||
</div>
|
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"
|
||||||
{/* Demo Credentials Info */}
|
/>
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
|
{mode === 'login' && (
|
||||||
<p className="text-sm font-medium text-amber-800 mb-2">
|
<div className="mt-1 text-right">
|
||||||
📋 Demo Credentials:
|
<Link
|
||||||
</p>
|
href="/reset-password"
|
||||||
<div className="text-xs text-amber-700 font-mono space-y-1">
|
className="text-xs text-slate-500 hover:text-teal-600"
|
||||||
<p>Email: demo@mini-ecd.demo</p>
|
|
||||||
<p>Password: Demo2024!</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handlePasswordLogin} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="demo-email"
|
|
||||||
className="block text-sm font-medium text-slate-700 mb-1"
|
|
||||||
>
|
>
|
||||||
Email
|
Wachtwoord vergeten?
|
||||||
</label>
|
</Link>
|
||||||
<input
|
|
||||||
id="demo-email"
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="block text-sm font-medium text-slate-700 mb-1"
|
|
||||||
>
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading ? 'Inloggen...' : 'Login'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{mode === 'signup' && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="block text-sm font-medium text-slate-700 mb-1"
|
||||||
|
>
|
||||||
|
Bevestig Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? 'Laden...'
|
||||||
|
: mode === 'login' ? 'Inloggen' : 'Registreren'
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-slate-300" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-slate-500">of</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Demo Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleQuickDemoLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-amber-50 hover:bg-amber-100 text-amber-800 text-sm font-medium py-2 px-4 rounded-lg border border-amber-200 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span>⚡</span>
|
||||||
|
Demo Account Proberen
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<p className="text-center text-xs text-slate-500 mt-8">
|
<p className="text-center text-xs text-slate-500 mt-8">
|
||||||
|
|||||||
105
app/reset-password/page.tsx
Normal file
105
app/reset-password/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm text-center space-y-4">
|
||||||
|
<div className="text-5xl mb-4">✉️</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Email Verstuurd!</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Check je inbox voor de reset link. De link is 1 uur geldig.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Niet ontvangen? Check je spam folder.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-block mt-4 text-teal-600 hover:text-teal-700 font-medium"
|
||||||
|
>
|
||||||
|
← Terug naar login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
|
Wachtwoord Vergeten?
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Geen probleem! Vul je email in en we sturen je een reset link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleResetRequest} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="jouw@email.nl"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Verzenden...' : 'Stuur Reset Link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
← Terug naar login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
108
app/set-password/page.tsx
Normal file
108
app/set-password/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
|
Stel een Wachtwoord In (Optioneel)
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 mb-6">
|
||||||
|
Met een wachtwoord kun je sneller inloggen zonder magic link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min. 8 tekens"
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Bevestig Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Instellen...' : 'Wachtwoord Instellen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="w-full text-slate-600 hover:text-slate-900 text-sm font-medium py-2"
|
||||||
|
>
|
||||||
|
Sla over (blijf magic link gebruiken)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
118
app/update-password/page.tsx
Normal file
118
app/update-password/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm text-center space-y-4">
|
||||||
|
<div className="text-5xl mb-4">✅</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Wachtwoord Gewijzigd!</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Je wordt doorgestuurd naar de login pagina...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
|
Nieuw Wachtwoord Instellen
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Kies een sterk wachtwoord (minimaal 8 tekens).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleUpdatePassword} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Nieuw Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Bevestig Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Wachtwoord Wijzigen...' : 'Wachtwoord Wijzigen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const BentoCard = ({
|
|||||||
name: string;
|
name: string;
|
||||||
className: string;
|
className: string;
|
||||||
background: ReactNode;
|
background: ReactNode;
|
||||||
Icon: any;
|
Icon?: any;
|
||||||
description: string;
|
description: string;
|
||||||
href: string;
|
href: string;
|
||||||
cta: string;
|
cta: string;
|
||||||
@@ -53,7 +53,7 @@ const BentoCard = ({
|
|||||||
>
|
>
|
||||||
<div>{background}</div>
|
<div>{background}</div>
|
||||||
<div className="pointer-events-none z-10 flex transform-gpu flex-col gap-1 p-6 transition-all duration-300 group-hover:-translate-y-10">
|
<div className="pointer-events-none z-10 flex transform-gpu flex-col gap-1 p-6 transition-all duration-300 group-hover:-translate-y-10">
|
||||||
<Icon className="h-12 w-12 origin-left transform-gpu text-neutral-700 transition-all duration-300 ease-in-out group-hover:scale-75" />
|
{Icon && <Icon className="h-12 w-12 origin-left transform-gpu text-neutral-700 transition-all duration-300 ease-in-out group-hover:scale-75" />}
|
||||||
<h3 className="text-xl font-semibold text-neutral-700 dark:text-neutral-300">
|
<h3 className="text-xl font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
183
docs/AUTH_HOOK_SETUP.md
Normal file
183
docs/AUTH_HOOK_SETUP.md
Normal file
@@ -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`
|
||||||
52
docs/emails/README.md
Normal file
52
docs/emails/README.md
Normal file
@@ -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
|
||||||
|
|
||||||
97
docs/emails/confirm-signup.html
Normal file
97
docs/emails/confirm-signup.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Bevestig je account - AI Speedrun</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td {font-family: Arial, sans-serif !important;}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #F8FAFC; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F8FAFC;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<!-- Main Container -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 40px 30px; text-align: center; border-bottom: 1px solid #E2E8F0;">
|
||||||
|
<h1 style="margin: 0; font-size: 24px; font-weight: 600; color: #0F172A; line-height: 1.3;">
|
||||||
|
Welkom bij AI Speedrun
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 40px 30px;">
|
||||||
|
<p style="margin: 0 0 20px; font-size: 16px; line-height: 1.6; color: #475569;">
|
||||||
|
Leuk dat je meekijkt met deze speedrun!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 30px; font-size: 16px; line-height: 1.6; color: #475569;">
|
||||||
|
Klik op de onderstaande knop om je emailadres te bevestigen en je account te activeren:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 0 0 30px;">
|
||||||
|
<a href="{{ .ConfirmationURL }}" style="display: inline-block; padding: 14px 32px; background-color: #0D9488; color: #FFFFFF; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px; line-height: 1.5;">
|
||||||
|
Bevestig je account
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Alternative Link -->
|
||||||
|
<p style="margin: 0 0 20px; font-size: 14px; line-height: 1.6; color: #64748B;">
|
||||||
|
Werkt de knop niet? Kopieer en plak deze link in je browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 30px; font-size: 12px; line-height: 1.6; color: #0D9488; word-break: break-all;">
|
||||||
|
{{ .ConfirmationURL }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Security Note -->
|
||||||
|
<div style="padding: 16px; background-color: #F0FDFA; border-left: 3px solid #0D9488; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #0F766E;">
|
||||||
|
<strong>Beveiliging:</strong> Deze link is 24 uur geldig. Als je deze email niet hebt aangevraagd, kun je deze negeren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px 40px 40px; text-align: center; border-top: 1px solid #E2E8F0; background-color: #F8FAFC;">
|
||||||
|
<p style="margin: 0; font-size: 12px; line-height: 1.6; color: #94A3B8;">
|
||||||
|
AI Speedrun - Software on Demand<br>
|
||||||
|
Build in Public door <a href="https://ikbenlit.nl" style="color: #0D9488; text-decoration: none;">AI Speedrun</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Bottom Spacing -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px 0; text-align: center;">
|
||||||
|
<p style="margin: 0; font-size: 11px; line-height: 1.5; color: #94A3B8;">
|
||||||
|
Je ontvangt deze email omdat je je hebt geregistreerd voor AI Speedrun.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
105
docs/emails/reset-password.html
Normal file
105
docs/emails/reset-password.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="nl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>Wachtwoord opnieuw instellen - AI Speedrun</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style type="text/css">
|
||||||
|
body, table, td {font-family: Arial, sans-serif !important;}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #F8FAFC; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #F8FAFC;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<!-- Main Container -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 40px 30px; text-align: center; border-bottom: 1px solid #E2E8F0;">
|
||||||
|
<h1 style="margin: 0; font-size: 24px; font-weight: 600; color: #0F172A; line-height: 1.3;">
|
||||||
|
Wachtwoord opnieuw instellen
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px 40px 30px;">
|
||||||
|
<p style="margin: 0 0 20px; font-size: 16px; line-height: 1.6; color: #475569;">
|
||||||
|
Je hebt een verzoek gedaan om je wachtwoord opnieuw in te stellen voor je AI Speedrun account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 30px; font-size: 16px; line-height: 1.6; color: #475569;">
|
||||||
|
Klik op de onderstaande knop om een nieuw wachtwoord in te stellen:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 0 0 30px;">
|
||||||
|
<a href="{{ .ConfirmationURL }}" style="display: inline-block; padding: 14px 32px; background-color: #0D9488; color: #FFFFFF; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px; line-height: 1.5;">
|
||||||
|
Wachtwoord opnieuw instellen
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Alternative Link -->
|
||||||
|
<p style="margin: 0 0 20px; font-size: 14px; line-height: 1.6; color: #64748B;">
|
||||||
|
Werkt de knop niet? Kopieer en plak deze link in je browser:
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0 0 30px; font-size: 12px; line-height: 1.6; color: #0D9488; word-break: break-all;">
|
||||||
|
{{ .ConfirmationURL }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Security Warning -->
|
||||||
|
<div style="padding: 16px; background-color: #FEF2F2; border-left: 3px solid #DC2626; border-radius: 4px;">
|
||||||
|
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #991B1B;">
|
||||||
|
<strong>Belangrijk:</strong> Als je deze email niet hebt aangevraagd, kun je deze veilig negeren. Je wachtwoord blijft ongewijzigd.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiry Note -->
|
||||||
|
<p style="margin: 20px 0 0; font-size: 14px; line-height: 1.6; color: #64748B;">
|
||||||
|
Deze link is 1 uur geldig. Na deze tijd moet je een nieuw verzoek indienen.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 30px 40px 40px; text-align: center; border-top: 1px solid #E2E8F0; background-color: #F8FAFC;">
|
||||||
|
<p style="margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: #64748B;">
|
||||||
|
Vragen over je account? Neem contact met ons op via <a href="mailto:support@aispeedrun.nl" style="color: #0D9488; text-decoration: none;">support@aispeedrun.nl</a>
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; font-size: 12px; line-height: 1.6; color: #94A3B8;">
|
||||||
|
AI Speedrun - Software on Demand<br>
|
||||||
|
Build in Public door <a href="https://ikbenlit.nl" style="color: #0D9488; text-decoration: none;">AI Speedrun</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Bottom Spacing -->
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 20px 0; text-align: center;">
|
||||||
|
<p style="margin: 0; font-size: 11px; line-height: 1.5; color: #94A3B8;">
|
||||||
|
Je ontvangt deze email omdat er een wachtwoord reset is aangevraagd voor je account.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
1055
docs/specs/archive/bouwplan-auth-flow-complete-v1.0.md
Normal file
1055
docs/specs/archive/bouwplan-auth-flow-complete-v1.0.md
Normal file
File diff suppressed because it is too large
Load Diff
613
docs/specs/archive/bouwplan-auth-incremental-v1.0.md
Normal file
613
docs/specs/archive/bouwplan-auth-incremental-v1.0.md
Normal file
@@ -0,0 +1,613 @@
|
|||||||
|
# 🚀 Bouwplan — Auth Flow Incrementele Uitbreiding
|
||||||
|
|
||||||
|
**Projectnaam:** Mini EPD - Auth Features Toevoegen
|
||||||
|
**Versie:** v1.0 (Incrementeel)
|
||||||
|
**Datum:** 18-01-2025
|
||||||
|
**Auteur:** Colin (met Claude Code)
|
||||||
|
**Scope:** Alleen TOEVOEGEN wat ontbreekt (geen refactor van bestaande code)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Wat Hebben We Al? ✅
|
||||||
|
|
||||||
|
**Bestaande Features (BLIJVEN ZOALS ZE ZIJN):**
|
||||||
|
- ✅ Login page met split-screen design (bento grid + form)
|
||||||
|
- ✅ Magic Link login (signup + login in één)
|
||||||
|
- ✅ Password login (demo accounts)
|
||||||
|
- ✅ Quick demo button (auto-fill + submit)
|
||||||
|
- ✅ Toggle tussen magic/demo
|
||||||
|
- ✅ Success/error messaging
|
||||||
|
- ✅ Auth callback handler (`/auth/callback`)
|
||||||
|
- ✅ Logout handler (`/auth/logout`)
|
||||||
|
- ✅ Middleware (route protection)
|
||||||
|
- ✅ Supabase integration
|
||||||
|
|
||||||
|
**Wat Ontbreekt:**
|
||||||
|
- ❌ Password reset flow (forgot password)
|
||||||
|
- ❌ Set password optie (na magic link signup)
|
||||||
|
- ❌ "Wachtwoord vergeten?" link op login page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Wat Gaan We TOEVOEGEN?
|
||||||
|
|
||||||
|
### **Minimale Toevoegingen (Production-Ready Auth):**
|
||||||
|
|
||||||
|
| Feature | Wat | Waarom | Tijd |
|
||||||
|
|---------|-----|--------|------|
|
||||||
|
| Password Reset | Forgot password → email → new password | Must-have voor production | 2 uur |
|
||||||
|
| Set Password | Optioneel password instellen na magic link | Power users willen sneller inloggen | 1 uur |
|
||||||
|
| Links Toevoegen | "Wachtwoord vergeten?" link in login form | UX improvement | 10 min |
|
||||||
|
|
||||||
|
**Totaal: ~3 uur werk**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Epics & Stories (Minimaal)
|
||||||
|
|
||||||
|
| Epic ID | Titel | Wat Doen We? | Stories | Tijd |
|
||||||
|
|---------|-------|--------------|---------|------|
|
||||||
|
| E1 | Password Reset Flow | 2 nieuwe pagina's + 1 email template | 3 | 2 uur |
|
||||||
|
| E2 | Set Password (Optional) | 1 nieuwe pagina + link in onboarding | 2 | 1 uur |
|
||||||
|
| E3 | UX Polish | Link toevoegen + testing | 1 | 15 min |
|
||||||
|
|
||||||
|
**Totaal: 3 uur werk, 6 stories**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Epic 1 — Password Reset Flow
|
||||||
|
|
||||||
|
**Wat:** Users kunnen vergeten wachtwoord resetten via email.
|
||||||
|
|
||||||
|
| Story ID | Wat Bouwen? | Bestanden | Story Points |
|
||||||
|
|----------|-------------|-----------|--------------|
|
||||||
|
| E1.S1 | Reset request page | `app/reset-password/page.tsx` (NIEUW) | 2 |
|
||||||
|
| E1.S2 | Update password page | `app/update-password/page.tsx` (NIEUW) | 3 |
|
||||||
|
| E1.S3 | Email template configureren | Supabase dashboard (config) | 1 |
|
||||||
|
|
||||||
|
### E1.S1 - Reset Request Page
|
||||||
|
|
||||||
|
**Nieuw bestand:** `app/reset-password/page.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleResetRequest(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${window.location.origin}/update-password`
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
setSent(true)
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Er ging iets mis')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm text-center space-y-4">
|
||||||
|
<div className="text-5xl mb-4">✉️</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Email Verstuurd!</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Check je inbox voor de reset link. De link is 1 uur geldig.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Niet ontvangen? Check je spam folder.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-block mt-4 text-teal-600 hover:text-teal-700 font-medium"
|
||||||
|
>
|
||||||
|
← Terug naar login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
|
Wachtwoord Vergeten?
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Geen probleem! Vul je email in en we sturen je een reset link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleResetRequest} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="jouw@email.nl"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Verzenden...' : 'Stuur Reset Link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-sm text-slate-600 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
← Terug naar login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### E1.S2 - Update Password Page
|
||||||
|
|
||||||
|
**Nieuw bestand:** `app/update-password/page.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
export default function UpdatePasswordPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
async function handleUpdatePassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Wachtwoorden komen niet overeen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Wachtwoord moet minimaal 8 tekens zijn')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.updateUser({
|
||||||
|
password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
setSuccess(true)
|
||||||
|
setTimeout(() => router.push('/login'), 2000)
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Er ging iets mis')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm text-center space-y-4">
|
||||||
|
<div className="text-5xl mb-4">✅</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Wachtwoord Gewijzigd!</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Je wordt doorgestuurd naar de login pagina...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
|
Nieuw Wachtwoord Instellen
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600">
|
||||||
|
Kies een sterk wachtwoord (minimaal 8 tekens).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleUpdatePassword} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Nieuw Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Bevestig Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Wachtwoord Wijzigen...' : 'Wachtwoord Wijzigen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### E1.S3 - Email Template Configureren
|
||||||
|
|
||||||
|
**Waar:** Supabase Dashboard → Authentication → Email Templates → Reset Password
|
||||||
|
|
||||||
|
**Template:**
|
||||||
|
```html
|
||||||
|
<h2>Wachtwoord Resetten</h2>
|
||||||
|
|
||||||
|
<p>Je hebt een wachtwoord reset aangevraagd voor je Mini EPD account.</p>
|
||||||
|
|
||||||
|
<a href="{{ .ConfirmationURL }}"
|
||||||
|
style="display: inline-block; background: #0d9488; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 16px 0;">
|
||||||
|
Reset Wachtwoord
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p style="color: #64748b; font-size: 14px;">
|
||||||
|
Deze link is 1 uur geldig. Heb je deze reset niet aangevraagd? Negeer deze email.
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Epic 2 — Set Password (Optional)
|
||||||
|
|
||||||
|
**Wat:** Na magic link signup kunnen users een password instellen.
|
||||||
|
|
||||||
|
| Story ID | Wat Bouwen? | Bestanden | Story Points |
|
||||||
|
|----------|-------------|-----------|--------------|
|
||||||
|
| E2.S1 | Set password page | `app/set-password/page.tsx` (NIEUW) | 2 |
|
||||||
|
| E2.S2 | Link toevoegen | Update `app/auth/callback/route.ts` | 1 |
|
||||||
|
|
||||||
|
### E2.S1 - Set Password Page
|
||||||
|
|
||||||
|
**Nieuw bestand:** `app/set-password/page.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { supabase } from '@/lib/supabase/client'
|
||||||
|
|
||||||
|
export default function SetPasswordPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function handleSetPassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Wachtwoorden komen niet overeen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Wachtwoord minimaal 8 tekens')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.updateUser({ password })
|
||||||
|
if (error) throw error
|
||||||
|
router.push('/epd/clients')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSkip() {
|
||||||
|
router.push('/epd/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
||||||
|
<div className="max-w-md w-full bg-white p-8 rounded-lg shadow-sm">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
|
Stel een Wachtwoord In (Optioneel)
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 mb-6">
|
||||||
|
Met een wachtwoord kun je sneller inloggen zonder magic link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSetPassword} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Min. 8 tekens"
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Bevestig Wachtwoord
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-600 hover:bg-teal-700 text-white font-medium py-2.5 px-4 rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Instellen...' : 'Wachtwoord Instellen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSkip}
|
||||||
|
className="w-full text-slate-600 hover:text-slate-900 text-sm font-medium py-2"
|
||||||
|
>
|
||||||
|
Sla over (blijf magic link gebruiken)
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2.S2 - Update Callback Handler
|
||||||
|
|
||||||
|
**Bestand:** `app/auth/callback/route.ts`
|
||||||
|
|
||||||
|
**Wat wijzigen:**
|
||||||
|
```typescript
|
||||||
|
// TOEVOEGEN aan einde van success flow:
|
||||||
|
|
||||||
|
if (data.user) {
|
||||||
|
// Check if new user (first login)
|
||||||
|
const isNewUser = new Date(data.user.created_at).getTime() ===
|
||||||
|
new Date(data.user.last_sign_in_at || '').getTime()
|
||||||
|
|
||||||
|
if (isNewUser) {
|
||||||
|
// Optie: redirect naar set-password voor nieuwe users
|
||||||
|
return NextResponse.redirect(`${requestUrl.origin}/set-password`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestaande redirect naar dashboard
|
||||||
|
return NextResponse.redirect(`${requestUrl.origin}/epd/clients`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Epic 3 — UX Polish
|
||||||
|
|
||||||
|
**Wat:** Kleine verbeteringen aan bestaande login page.
|
||||||
|
|
||||||
|
| Story ID | Wat Doen? | Bestand | Story Points |
|
||||||
|
|----------|-----------|---------|--------------|
|
||||||
|
| E3.S1 | "Wachtwoord vergeten?" link toevoegen | `app/login/page.tsx` | 1 |
|
||||||
|
|
||||||
|
### E3.S1 - Forgot Password Link
|
||||||
|
|
||||||
|
**Bestand:** `app/login/page.tsx` (line ~346)
|
||||||
|
|
||||||
|
**TOEVOEGEN na password input field:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{/* Password Input */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2.5 border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
{/* NIEUW: Forgot password link */}
|
||||||
|
<div className="mt-1 text-right">
|
||||||
|
<a
|
||||||
|
href="/reset-password"
|
||||||
|
className="text-xs text-slate-600 hover:text-teal-600"
|
||||||
|
>
|
||||||
|
Wachtwoord vergeten?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Checklist - Wat Moet Er Gebeuren?
|
||||||
|
|
||||||
|
### **Nieuwe Bestanden Aanmaken:**
|
||||||
|
- [ ] `app/reset-password/page.tsx` (E1.S1)
|
||||||
|
- [ ] `app/update-password/page.tsx` (E1.S2)
|
||||||
|
- [ ] `app/set-password/page.tsx` (E2.S1)
|
||||||
|
|
||||||
|
### **Bestaande Bestanden Wijzigen:**
|
||||||
|
- [ ] `app/login/page.tsx` - Voeg "Wachtwoord vergeten?" link toe (E3.S1)
|
||||||
|
- [ ] `app/auth/callback/route.ts` - Optionele redirect naar set-password (E2.S2)
|
||||||
|
|
||||||
|
### **Supabase Configuratie:**
|
||||||
|
- [ ] Email template configureren (E1.S3)
|
||||||
|
- [ ] Test reset email wordt verstuurd
|
||||||
|
|
||||||
|
### **Testing:**
|
||||||
|
- [ ] Test forgot password flow (request → email → reset)
|
||||||
|
- [ ] Test set password na magic link signup
|
||||||
|
- [ ] Test alle links werken
|
||||||
|
- [ ] Test error handling (wrong email, weak password, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Implementatie Volgorde
|
||||||
|
|
||||||
|
**Stap 1:** Password Reset (E1) - 2 uur
|
||||||
|
1. Maak `reset-password/page.tsx`
|
||||||
|
2. Maak `update-password/page.tsx`
|
||||||
|
3. Configureer email template in Supabase
|
||||||
|
4. Test hele flow
|
||||||
|
|
||||||
|
**Stap 2:** Set Password (E2) - 1 uur
|
||||||
|
1. Maak `set-password/page.tsx`
|
||||||
|
2. Update `auth/callback/route.ts`
|
||||||
|
3. Test flow
|
||||||
|
|
||||||
|
**Stap 3:** UX Polish (E3) - 15 min
|
||||||
|
1. Voeg "Wachtwoord vergeten?" link toe
|
||||||
|
2. Final testing
|
||||||
|
|
||||||
|
**Totaal: ~3 uur werk**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Testing Checklist
|
||||||
|
|
||||||
|
| Test Case | Scenario | Expected |
|
||||||
|
|-----------|----------|----------|
|
||||||
|
| TC1 | Click "Wachtwoord vergeten?" | Redirect naar /reset-password |
|
||||||
|
| TC2 | Submit valid email for reset | Email sent, success message |
|
||||||
|
| TC3 | Click reset link in email | Redirect naar /update-password |
|
||||||
|
| TC4 | Set new password (valid) | Success, redirect to login |
|
||||||
|
| TC5 | Set new password (mismatch) | Error: "Wachtwoorden komen niet overeen" |
|
||||||
|
| TC6 | Set new password (too short) | Error: "Minimaal 8 tekens" |
|
||||||
|
| TC7 | Magic link signup (new user) | Redirect naar /set-password |
|
||||||
|
| TC8 | Skip set password | Redirect naar /epd/clients |
|
||||||
|
| TC9 | Set password after signup | Success, redirect to dashboard |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Wat NIET Doen
|
||||||
|
|
||||||
|
❌ **Bestaande login page NIET refactoren**
|
||||||
|
❌ **GEEN nieuwe UI components maken** (gebruik bestaande styling)
|
||||||
|
❌ **GEEN email verification** (nice-to-have, niet kritiek)
|
||||||
|
❌ **GEEN OAuth/SSO** (overkill voor prototype)
|
||||||
|
❌ **GEEN complexe state management** (useState is prima)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Definition of Done
|
||||||
|
|
||||||
|
✅ **E1-E3 compleet wanneer:**
|
||||||
|
- 3 nieuwe pagina's werken (reset-password, update-password, set-password)
|
||||||
|
- "Wachtwoord vergeten?" link zichtbaar op login page
|
||||||
|
- Email template geconfigureerd in Supabase
|
||||||
|
- Alle flows getest en werkend
|
||||||
|
- Geen breaking changes aan bestaande auth
|
||||||
|
- Git commit: `feat: Add password reset and set password flows`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status:** ⏳ Ready for Implementation
|
||||||
|
**Geschatte Tijd:** 3 uur
|
||||||
|
**Next Step:** Maak eerst `app/reset-password/page.tsx`
|
||||||
502
docs/specs/bouwplan-auth-hook-duplicate-email-v1.0.md
Normal file
502
docs/specs/bouwplan-auth-hook-duplicate-email-v1.0.md
Normal file
@@ -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`
|
||||||
|
|
||||||
@@ -18,31 +18,137 @@ export function createClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send magic link to user's email
|
* Sign up with email + password
|
||||||
* Auto-creates account if user doesn't exist
|
* 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 supabase = createClient()
|
||||||
|
const { data, error } = await supabase.auth.signUp({
|
||||||
const { data, error } = await supabase.auth.signInWithOtp({
|
|
||||||
email,
|
email,
|
||||||
|
password,
|
||||||
options: {
|
options: {
|
||||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
emailRedirectTo: `${window.location.origin}/auth/callback`
|
||||||
shouldCreateUser: true, // Auto-create account on first login
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) throw error
|
// DEBUG: Log response to see what Supabase returns when hook throws error
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
return {
|
console.log('🔍 SignUp Response:', {
|
||||||
success: true,
|
hasError: !!error,
|
||||||
message: 'Check je email voor de magic link!',
|
errorMessage: error?.message,
|
||||||
data
|
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) {
|
export async function loginWithPassword(email: string, password: string) {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"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": {
|
"dependencies": {
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
|||||||
71
scripts/apply-migration.ts
Normal file
71
scripts/apply-migration.ts
Normal file
@@ -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)
|
||||||
95
scripts/setup-auth-hook.ts
Normal file
95
scripts/setup-auth-hook.ts
Normal file
@@ -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)
|
||||||
72
scripts/test-hook-function.ts
Normal file
72
scripts/test-hook-function.ts
Normal file
@@ -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)
|
||||||
47
scripts/test-signup-direct.ts
Normal file
47
scripts/test-signup-direct.ts
Normal file
@@ -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)
|
||||||
@@ -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).';
|
||||||
Reference in New Issue
Block a user