import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { handleError, handleSuccess, handleNonCriticalError, AppError, getErrorMessage } from '@/lib/errorHandler'; import { supabase } from '@/lib/supabaseClient'; import { Loader2, Mail, CheckCircle2, AlertCircle } from 'lucide-react'; import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha'; import { useTheme } from '@/components/theme/ThemeProvider'; import { notificationService } from '@/lib/notificationService'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { validateEmailNotDisposable } from '@/lib/emailValidation'; const emailSchema = z.object({ currentPassword: z.string().min(1, 'Current password is required'), newEmail: z.string().email('Please enter a valid email address'), confirmEmail: z.string().email('Please enter a valid email address'), }).refine((data) => data.newEmail === data.confirmEmail, { message: "Email addresses don't match", path: ["confirmEmail"], }); type EmailFormData = z.infer; type Step = 'verification' | 'success'; interface EmailChangeDialogProps { open: boolean; onOpenChange: (open: boolean) => void; currentEmail: string; userId: string; } export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) { const { theme } = useTheme(); const [step, setStep] = useState('verification'); const [loading, setLoading] = useState(false); const [captchaToken, setCaptchaToken] = useState(''); const [captchaKey, setCaptchaKey] = useState(0); const form = useForm({ resolver: zodResolver(emailSchema), defaultValues: { currentPassword: '', newEmail: '', confirmEmail: '', }, }); const handleClose = () => { if (loading) return; onOpenChange(false); setTimeout(() => { setStep('verification'); form.reset(); setCaptchaToken(''); setCaptchaKey(prev => prev + 1); }, 300); }; const onSubmit = async (data: EmailFormData) => { if (!captchaToken) { handleError( new AppError('Please complete the CAPTCHA verification.', 'CAPTCHA_REQUIRED'), { action: 'Change email', userId, metadata: { step: 'captcha_validation' } } ); return; } if (data.newEmail.toLowerCase() === currentEmail.toLowerCase()) { handleError( new AppError('The new email is the same as your current email.', 'SAME_EMAIL'), { action: 'Change email', userId, metadata: { currentEmail, newEmail: data.newEmail } } ); return; } // Phase 4: AAL2 check for security-critical operations const { data: { session } } = await supabase.auth.getSession(); if (session) { // Check if user has MFA enrolled const { data: factorsData } = await supabase.auth.mfa.listFactors(); const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false; if (hasMFA) { const jwt = session.access_token; const payload = JSON.parse(atob(jwt.split('.')[1])); const currentAal = payload.aal || 'aal1'; if (currentAal !== 'aal2') { handleError( new AppError( 'Please verify your identity with MFA first', 'AAL2_REQUIRED' ), { action: 'Change email', userId, metadata: { step: 'aal2_check' } } ); sessionStorage.setItem('mfa_step_up_required', 'true'); sessionStorage.setItem('mfa_intended_path', '/settings?tab=security'); window.location.href = '/auth'; return; } } } setLoading(true); try { // Step 1: Validate email is not disposable const emailValidation = await validateEmailNotDisposable(data.newEmail); if (!emailValidation.valid) { handleError( new AppError( emailValidation.reason || "Please use a permanent email address", 'DISPOSABLE_EMAIL' ), { action: 'Validate email', userId, metadata: { email: data.newEmail } } ); setLoading(false); return; } // Step 2: Reauthenticate with current password const { error: signInError } = await supabase.auth.signInWithPassword({ email: currentEmail, password: data.currentPassword, options: { captchaToken } }); if (signInError) { // Reset CAPTCHA on authentication failure setCaptchaToken(''); setCaptchaKey(prev => prev + 1); throw signInError; } // Step 3: Update email address // Supabase will send verification emails to both old and new addresses const { error: updateError } = await supabase.auth.updateUser({ email: data.newEmail }); if (updateError) throw updateError; // Step 4: Novu subscriber will be updated automatically after both emails are confirmed // This happens in the useAuth hook when the email change is fully verified // Step 5: Log the email change attempt supabase.from('admin_audit_log').insert({ admin_user_id: userId, target_user_id: userId, action: 'email_change_initiated', details: { old_email: currentEmail, new_email: data.newEmail, timestamp: new Date().toISOString(), } }).then(({ error }) => { if (error) { handleNonCriticalError(error, { action: 'Log email change audit', userId, metadata: { oldEmail: currentEmail, newEmail: data.newEmail, auditType: 'email_change_initiated' } }); } }); // Step 6: Send security notifications (non-blocking) if (notificationService.isEnabled()) { notificationService.trigger({ workflowId: 'security-alert', subscriberId: userId, payload: { alert_type: 'email_change_initiated', old_email: currentEmail, new_email: data.newEmail, timestamp: new Date().toISOString(), } }).catch(error => { handleNonCriticalError(error, { action: 'Send email change notification', userId, metadata: { notificationType: 'security-alert', alertType: 'email_change_initiated' } }); }); } handleSuccess( 'Email change initiated', 'Check both email addresses for confirmation links.' ); setStep('success'); } catch (error: unknown) { const errorMsg = getErrorMessage(error); const hasMessage = error instanceof Error || (typeof error === 'object' && error !== null && 'message' in error); const hasStatus = typeof error === 'object' && error !== null && 'status' in error; const errorMessage = hasMessage ? (error as { message: string }).message : ''; const errorStatus = hasStatus ? (error as { status: number }).status : 0; if (errorMessage.includes('rate limit') || errorStatus === 429) { handleError( new AppError( 'Please wait a few minutes before trying again.', 'RATE_LIMIT', 'Too many email change attempts' ), { action: 'Change email', userId, metadata: { currentEmail, newEmail: data.newEmail } } ); } else if (errorMessage.includes('Invalid login credentials')) { handleError( new AppError( 'The password you entered is incorrect.', 'INVALID_PASSWORD', 'Incorrect password during email change' ), { action: 'Verify password', userId } ); } else { handleError( error, { action: 'Change email', userId, metadata: { currentEmail, newEmail: data.newEmail, errorType: error instanceof Error ? error.constructor.name : 'Unknown' } } ); } } finally { setLoading(false); } }; return ( Change Email Address {step === 'verification' ? ( 'Enter your current password and new email address to proceed.' ) : ( 'Verification emails have been sent.' )} {step === 'verification' ? (
Current email: {currentEmail} ( Current Password * )} /> ( New Email Address * )} /> ( Confirm New Email * )} />
setCaptchaToken('')} onExpire={() => setCaptchaToken('')} siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} theme={theme === 'dark' ? 'dark' : 'light'} size="normal" />
) : (

Verification Required

We've sent verification emails to both your current email address ({currentEmail}) and your new email address ({form.getValues('newEmail')}).

Important: You must click the confirmation link in BOTH emails to complete the email change. Your email address will not change until both verifications are confirmed.
)}
); }