diff --git a/src/components/settings/PasswordUpdateDialog.tsx b/src/components/settings/PasswordUpdateDialog.tsx new file mode 100644 index 00000000..59799c6f --- /dev/null +++ b/src/components/settings/PasswordUpdateDialog.tsx @@ -0,0 +1,363 @@ +import { useState, useEffect } 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 { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast'; +import { supabase } from '@/integrations/supabase/client'; +import { Loader2, Shield, CheckCircle2 } from 'lucide-react'; +import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; + +const passwordSchema = z.object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z.string().min(8, 'Password must be at least 8 characters'), + confirmPassword: z.string() +}).refine(data => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"] +}); + +type PasswordFormData = z.infer; + +interface PasswordUpdateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +type Step = 'password' | 'mfa' | 'success'; + +export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) { + const { toast } = useToast(); + 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 form = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { + currentPassword: '', + newPassword: '', + confirmPassword: '' + } + }); + + // Check if user has MFA enabled + useEffect(() => { + if (open) { + checkMFAStatus(); + } + }, [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) { + console.error('Error checking MFA status:', error); + } + }; + + const onSubmit = async (data: PasswordFormData) => { + 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 + }); + + if (signInError) 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: any) { + toast({ + title: 'Authentication Error', + description: error.message || 'Incorrect password. Please try again.', + variant: 'destructive' + }); + } finally { + setLoading(false); + } + }; + + const verifyMFAAndUpdate = async () => { + if (totpCode.length !== 6) { + toast({ + title: 'Invalid Code', + description: 'Please enter a valid 6-digit code', + variant: 'destructive' + }); + return; + } + + setLoading(true); + try { + // Verify TOTP code + const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({ + factorId: (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || '' + }); + + if (challengeError) throw challengeError; + + const { error: verifyError } = await supabase.auth.mfa.verify({ + factorId: challengeData.id, + challengeId: challengeData.id, + code: totpCode + }); + + if (verifyError) throw verifyError; + + // TOTP verified, now update password + await updatePasswordWithNonce(newPassword, nonce); + } catch (error: any) { + toast({ + title: 'Verification Failed', + description: error.message || 'Invalid authentication code', + variant: 'destructive' + }); + } 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 supabase.functions.invoke('trigger-notification', { + body: { + workflowId: 'security-alert', + subscriberId: user.id, + payload: { + alert_type: 'password_changed', + timestamp: new Date().toISOString(), + device: navigator.userAgent.split(' ')[0] + } + } + }); + } catch (notifError) { + console.error('Failed to send notification:', 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: any) { + throw error; + } + }; + + const handleClose = () => { + if (!loading) { + onOpenChange(false); + setStep('password'); + form.reset(); + setTotpCode(''); + } + }; + + 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} +

+ )} +
+ +
+ + + {form.formState.errors.newPassword && ( +

+ {form.formState.errors.newPassword.message} +

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

+ {form.formState.errors.confirmPassword.message} +

+ )} +
+ + + + + +
+ + )} + + {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. + + + + )} +
+
+ ); +} diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index 913a6808..295a4d63 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -1,70 +1,20 @@ import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import { supabase } from '@/integrations/supabase/client'; -import { Shield, Key, Smartphone, Globe, ExternalLink } from 'lucide-react'; +import { Shield, Key, Smartphone, Globe } from 'lucide-react'; import { TOTPSetup } from '@/components/auth/TOTPSetup'; import { GoogleIcon } from '@/components/icons/GoogleIcon'; import { DiscordIcon } from '@/components/icons/DiscordIcon'; -const passwordSchema = z.object({ - currentPassword: z.string().min(1, 'Current password is required'), - newPassword: z.string().min(8, 'Password must be at least 8 characters'), - confirmPassword: z.string() -}).refine(data => data.newPassword === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"] -}); -type PasswordFormData = z.infer; +import { PasswordUpdateDialog } from './PasswordUpdateDialog'; export function SecurityTab() { - const { - user - } = useAuth(); - const { - toast - } = useToast(); - const [loading, setLoading] = useState(false); - const form = useForm({ - resolver: zodResolver(passwordSchema), - defaultValues: { - currentPassword: '', - newPassword: '', - confirmPassword: '' - } - }); - const onSubmit = async (data: PasswordFormData) => { - if (!user) return; - setLoading(true); - try { - const { - error - } = await supabase.auth.updateUser({ - password: data.newPassword - }); - if (error) throw error; - form.reset(); - toast({ - title: 'Password updated', - description: 'Your password has been successfully changed.' - }); - } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Failed to update password', - variant: 'destructive' - }); - } finally { - setLoading(false); - } - }; + const { user } = useAuth(); + const { toast } = useToast(); + const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const handleSocialLogin = async (provider: 'google' | 'discord') => { try { const { @@ -117,53 +67,40 @@ export function SecurityTab() { email: user?.identities?.find(identity => identity.provider === 'discord')?.identity_data?.email, icon: }]; - return
- {/* Change Password */} -
-
- -

Change Password

-
- - - - - Update your password to keep your account secure. - - - -
-
- - - {form.formState.errors.currentPassword &&

- {form.formState.errors.currentPassword.message} -

} -
+ return ( + <> + { + toast({ + title: 'Password updated', + description: 'Your password has been successfully changed.' + }); + }} + /> -
- - - {form.formState.errors.newPassword &&

- {form.formState.errors.newPassword.message} -

} -
- -
- - - {form.formState.errors.confirmPassword &&

- {form.formState.errors.confirmPassword.message} -

} -
- - - -
-
-
+ + +
@@ -258,5 +195,7 @@ export function SecurityTab() { - ; + + + ); } \ No newline at end of file