feat: add duplicate email detection via auth hook with fallback

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

View File

@@ -18,31 +18,137 @@ export function createClient() {
}
/**
* Send magic link to user's email
* Auto-creates account if user doesn't exist
* Sign up with email + password
* Returns data with user and session (if email confirmation is disabled)
*
* Note: If email confirmation is enabled and email already exists,
* Supabase returns success but no user object (security feature)
*/
export async function loginWithMagicLink(email: string) {
export async function signUpWithPassword(email: string, password: string) {
const supabase = createClient()
const { data, error } = await supabase.auth.signInWithOtp({
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
shouldCreateUser: true, // Auto-create account on first login
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) throw error
return {
success: true,
message: 'Check je email voor de magic link!',
data
// DEBUG: Log response to see what Supabase returns when hook throws error
if (process.env.NODE_ENV === 'development') {
console.log('🔍 SignUp Response:', {
hasError: !!error,
errorMessage: error?.message,
errorCode: error?.code,
errorStatus: (error as any)?.status,
hasUser: !!data?.user,
hasSession: !!data?.session,
identities: data?.user?.identities?.length || 0
})
}
if (error) {
// Check for explicit duplicate email errors (from Auth Hook or Supabase)
const errorMessage = error.message?.toLowerCase() || ''
const errorCode = error.code?.toLowerCase() || ''
if (errorMessage.includes('already registered') ||
errorMessage.includes('already exists') ||
errorMessage.includes('al geregistreerd') || // Auth Hook (NL)
errorMessage.includes('emailadres is al') || // Extra check voor hook message
errorCode === 'user_already_registered' ||
(error as any)?.status === 400) { // Hook errors typically have 400 status
// If it's from Auth Hook, preserve the original message
const duplicateError = new Error(error.message || 'Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik "Wachtwoord vergeten?".')
;(duplicateError as any).code = 'user_already_registered'
throw duplicateError
}
throw error
}
// Check if user was created but has no identities (indicates duplicate email)
// This happens when Supabase creates a user object but doesn't actually create the account
// because the email already exists (security feature - email enumeration protection)
if (data.user && (!data.user.identities || data.user.identities.length === 0)) {
// User object exists but no identities = duplicate email detected by Supabase
// Try to login to confirm and get proper error message
const loginAttempt = await supabase.auth.signInWithPassword({
email,
password
})
if (loginAttempt.data?.user) {
// Login succeeded - account already exists!
const duplicateError = new Error('Dit emailadres is al geregistreerd. Je bent nu ingelogd.')
;(duplicateError as any).code = 'user_already_registered'
;(duplicateError as any).data = loginAttempt.data
throw duplicateError
} else {
// Login failed - account exists but password is wrong
// This is the duplicate email case that the hook should have caught
const duplicateError = new Error('Dit emailadres is al geregistreerd. Probeer in te loggen of gebruik "Wachtwoord vergeten?".')
;(duplicateError as any).code = 'user_already_registered'
throw duplicateError
}
}
// If email confirmation is enabled, Supabase doesn't return a user/session
// for new signups (email is sent instead)
// However, if email already exists, Supabase also returns no user but doesn't send email
// Try to detect this by attempting a login
if (!data.user && !data.session) {
// Try to login to check if account already exists
// This is a fallback detection method if the hook doesn't work
const loginAttempt = await supabase.auth.signInWithPassword({
email,
password
})
if (loginAttempt.data?.user) {
// Login succeeded - account already exists!
// This means the hook didn't catch it, but we can detect it client-side
const duplicateError = new Error('Dit emailadres is al geregistreerd. Je bent nu ingelogd.')
;(duplicateError as any).code = 'user_already_registered'
;(duplicateError as any).data = loginAttempt.data // Include session data
throw duplicateError
}
// Login failed - could be new account OR wrong password for existing account
// If it's a wrong password, we can't distinguish from a new account
// (Supabase security feature - email enumeration protection)
// Return the signup data (which has no user/session) and let UI show generic message
// Note: If hook is working, we should have gotten an error above, so this is fallback
}
return data
}
/**
* Login with email + password (for demo accounts)
* Send password reset email
*/
export async function resetPasswordForEmail(email: string) {
const supabase = createClient()
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/callback?next=/update-password`
})
if (error) throw error
return true
}
/**
* Update user password (requires active session)
*/
export async function updateUserPassword(password: string) {
const supabase = createClient()
const { error } = await supabase.auth.updateUser({ password })
if (error) throw error
return true
}
/**
* Login with email + password (for demo accounts and regular users)
*/
export async function loginWithPassword(email: string, password: string) {
const supabase = createClient()