Files
triqura-ecd/docs/specs/archive/bouwplan-auth-incremental-v1.0.md
colinislit 2505b27437 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
2025-11-19 11:03:46 +01:00

18 KiB

🚀 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

'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

'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:

<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

'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:

// 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

Bestand: app/login/page.tsx (line ~346)

TOEVOEGEN na password input field:

{/* 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