import { useState, useEffect } from 'react'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { handleError, handleSuccess, AppError, getErrorMessage } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; import { supabase } from '@/integrations/supabase/client'; import { Loader2, Shield, CheckCircle2 } from 'lucide-react'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha'; import { useTheme } from '@/components/theme/ThemeProvider'; import { passwordSchema } from '@/lib/validation'; import { z } from 'zod'; type PasswordFormData = z.infer; interface PasswordUpdateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess: () => void; } type Step = 'password' | 'mfa' | 'success'; interface ErrorWithCode { code?: string; status?: number; } function isErrorWithCode(error: unknown): error is Error & ErrorWithCode { return error instanceof Error && ('code' in error || 'status' in error); } export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) { const { theme } = useTheme(); const [step, setStep] = useState('password'); const [loading, setLoading] = useState(false); const [nonce, setNonce] = useState(''); const [newPassword, setNewPassword] = useState(''); const [hasMFA, setHasMFA] = useState(false); const [totpCode, setTotpCode] = useState(''); const [captchaToken, setCaptchaToken] = useState(''); const [captchaKey, setCaptchaKey] = useState(0); const [userId, setUserId] = useState(''); const form = useForm({ resolver: zodResolver(passwordSchema), defaultValues: { currentPassword: '', newPassword: '', confirmPassword: '' } }); // Check if user has MFA enabled and get user ID useEffect(() => { if (open) { checkMFAStatus(); supabase.auth.getUser().then(({ data }) => { if (data.user) setUserId(data.user.id); }); } }, [open]); const checkMFAStatus = async () => { try { const { data, error } = await supabase.auth.mfa.listFactors(); if (error) throw error; const hasVerifiedTotp = data?.totp?.some(factor => factor.status === 'verified') || false; setHasMFA(hasVerifiedTotp); } catch (error: unknown) { logger.error('Failed to check MFA status', { action: 'check_mfa_status', error: error instanceof Error ? error.message : String(error) }); } }; const onSubmit = async (data: PasswordFormData) => { if (!captchaToken) { handleError( new AppError('Please complete the CAPTCHA verification.', 'CAPTCHA_REQUIRED'), { action: 'Change password', userId, metadata: { step: 'captcha_validation' } } ); return; } // Phase 4: AAL2 check for security-critical operations if (hasMFA) { const { data: { session } } = await supabase.auth.getSession(); if (session) { 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 password', 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: Reauthenticate with current password to get a nonce const { error: signInError } = await supabase.auth.signInWithPassword({ email: (await supabase.auth.getUser()).data.user?.email || '', password: data.currentPassword, options: { captchaToken } }); if (signInError) { // Reset CAPTCHA on authentication failure setCaptchaToken(''); setCaptchaKey(prev => prev + 1); logger.error('Password authentication failed', { userId, action: 'password_change_auth', error: signInError.message, errorCode: signInError.code }); throw signInError; } // Step 2: Generate nonce for secure password update const { data: sessionData } = await supabase.auth.getSession(); if (!sessionData.session) throw new Error('No active session'); const generatedNonce = sessionData.session.access_token.substring(0, 32); setNonce(generatedNonce); setNewPassword(data.newPassword); // If user has MFA, require TOTP verification if (hasMFA) { setStep('mfa'); } else { // No MFA, proceed with password update await updatePasswordWithNonce(data.newPassword, generatedNonce); } } catch (error: unknown) { const errorDetails = isErrorWithCode(error) ? { errorCode: error.code, errorStatus: error.status } : {}; logger.error('Password change failed', { userId, action: 'password_change', error: getErrorMessage(error), ...errorDetails }); const errorMessage = getErrorMessage(error); const errorStatus = isErrorWithCode(error) ? error.status : undefined; if (errorMessage?.includes('rate limit') || errorStatus === 429) { handleError( new AppError( 'Please wait a few minutes before trying again.', 'RATE_LIMIT', 'Too many password change attempts' ), { action: 'Change password', userId, metadata: { step: 'authentication' } } ); } else if (errorMessage?.includes('Invalid login credentials')) { handleError( new AppError( 'The password you entered is incorrect.', 'INVALID_PASSWORD', 'Incorrect current password' ), { action: 'Verify password', userId } ); } else { handleError(error, { action: 'Change password', userId, metadata: { step: 'authentication' } }); } } finally { setLoading(false); } }; const verifyMFAAndUpdate = async () => { if (totpCode.length !== 6) { handleError( new AppError('Please enter a valid 6-digit code', 'INVALID_MFA_CODE'), { action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } } ); return; } setLoading(true); try { // Get the factor ID first const factorId = (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || ''; if (!factorId) { throw new Error('No MFA factor found'); } // Create challenge const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({ factorId }); if (challengeError) { logger.error('MFA challenge creation failed', { userId, action: 'password_change_mfa_challenge', error: challengeError.message, errorCode: challengeError.code }); throw challengeError; } // Verify TOTP code with correct factorId const { error: verifyError } = await supabase.auth.mfa.verify({ factorId, challengeId: challengeData.id, code: totpCode }); if (verifyError) { logger.error('MFA verification failed', { userId, action: 'password_change_mfa', error: verifyError.message, errorCode: verifyError.code }); throw verifyError; } // TOTP verified, now update password await updatePasswordWithNonce(newPassword, nonce); } catch (error: unknown) { logger.error('MFA verification failed', { userId, action: 'password_change_mfa', error: getErrorMessage(error) }); handleError( new AppError( getErrorMessage(error) || 'Invalid authentication code', 'MFA_VERIFICATION_FAILED', 'TOTP code verification failed' ), { action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } } ); } finally { setLoading(false); } }; const updatePasswordWithNonce = async (password: string, nonceValue: string) => { try { // Step 2: Update password const { error: updateError } = await supabase.auth.updateUser({ password }); if (updateError) throw updateError; // Step 3: Log audit trail const { data: { user } } = await supabase.auth.getUser(); if (user) { await supabase.from('admin_audit_log').insert({ admin_user_id: user.id, target_user_id: user.id, action: 'password_changed', details: { timestamp: new Date().toISOString(), method: hasMFA ? 'password_with_mfa' : 'password_only', user_agent: navigator.userAgent } }); // Step 4: Send security notification try { await invokeWithTracking( 'trigger-notification', { workflowId: 'security-alert', subscriberId: user.id, payload: { alert_type: 'password_changed', timestamp: new Date().toISOString(), device: navigator.userAgent.split(' ')[0] } }, user.id ); } catch (notifError) { logger.error('Failed to send password change notification', { userId: user!.id, action: 'password_change_notification', error: getErrorMessage(notifError) }); // Don't fail the password update if notification fails } } setStep('success'); form.reset(); // Auto-close after 2 seconds setTimeout(() => { onOpenChange(false); onSuccess(); setStep('password'); setTotpCode(''); }, 2000); } catch (error: unknown) { throw error; } }; const handleClose = () => { if (!loading) { onOpenChange(false); setStep('password'); form.reset(); setTotpCode(''); setCaptchaToken(''); setCaptchaKey(prev => prev + 1); } }; return ( {step === 'password' && ( <> Change Password Enter your current password and choose a new one. {hasMFA && 'You will need to verify with your authenticator app.'}
{form.formState.errors.currentPassword && (

{form.formState.errors.currentPassword.message}

)}

Must be 8+ characters with uppercase, lowercase, number, and special character

{form.formState.errors.newPassword && (

{form.formState.errors.newPassword.message}

)}
{form.formState.errors.confirmPassword && (

{form.formState.errors.confirmPassword.message}

)}
setCaptchaToken('')} onExpire={() => setCaptchaToken('')} siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY} theme={theme === 'dark' ? 'dark' : 'light'} size="normal" />
)} {step === 'mfa' && ( <> Verify Your Identity Enter the 6-digit code from your authenticator app to complete the password change.
)} {step === 'success' && ( <> Password Updated Your password has been successfully changed. A security notification has been sent to your email. )}
); }