diff --git a/src/components/settings/AccountProfileTab.tsx b/src/components/settings/AccountProfileTab.tsx index 43c0bc18..6268bfb3 100644 --- a/src/components/settings/AccountProfileTab.tsx +++ b/src/components/settings/AccountProfileTab.tsx @@ -14,9 +14,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import { supabase } from '@/integrations/supabase/client'; -import { User, Upload, Trash2 } from 'lucide-react'; +import { User, Upload, Trash2, Mail } from 'lucide-react'; import { PhotoUpload } from '@/components/upload/PhotoUpload'; import { notificationService } from '@/lib/notificationService'; +import { EmailChangeDialog } from './EmailChangeDialog'; +import { Badge } from '@/components/ui/badge'; const profileSchema = z.object({ username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/), @@ -35,6 +37,7 @@ export function AccountProfileTab() { const [loading, setLoading] = useState(false); const [avatarLoading, setAvatarLoading] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showEmailDialog, setShowEmailDialog] = useState(false); const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || ''); const [avatarImageId, setAvatarImageId] = useState(profile?.avatar_image_id || ''); @@ -271,20 +274,52 @@ export function AccountProfileTab() { {/* Account Information */}

Account Information

-
-
-

Email

-

{user?.email}

-
-
-

Account Created

-

- {profile?.created_at ? new Date(profile.created_at).toLocaleDateString() : 'N/A'} -

+
+
+
+
+

Email Address

+
+

{user?.email}

+ {user?.email_confirmed_at ? ( + Verified + ) : ( + Pending Verification + )} +
+
+ +
+ + + +
+

Account Created

+

+ {profile?.created_at ? new Date(profile.created_at).toLocaleDateString() : 'N/A'} +

+
+ {/* Email Change Dialog */} + {user && ( + + )} + {/* Danger Zone */} diff --git a/src/components/settings/EmailChangeDialog.tsx b/src/components/settings/EmailChangeDialog.tsx new file mode 100644 index 00000000..8d398c89 --- /dev/null +++ b/src/components/settings/EmailChangeDialog.tsx @@ -0,0 +1,318 @@ +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 { useToast } from '@/hooks/use-toast'; +import { supabase } from '@/integrations/supabase/client'; +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'; + +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 { toast } = useToast(); + 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) { + toast({ + title: 'CAPTCHA Required', + description: 'Please complete the CAPTCHA verification.', + variant: 'destructive' + }); + return; + } + + if (data.newEmail.toLowerCase() === currentEmail.toLowerCase()) { + toast({ + title: 'Same Email', + description: 'The new email is the same as your current email.', + variant: 'destructive' + }); + return; + } + + setLoading(true); + try { + // Step 1: 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 2: 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 3: Update Novu subscriber (non-blocking) + if (notificationService.isEnabled()) { + notificationService.updateSubscriber({ + subscriberId: userId, + email: data.newEmail, + }).catch(error => { + console.error('Failed to update Novu subscriber:', error); + }); + } + + // Step 4: 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) console.error('Failed to log email change:', error); + }); + + // Step 5: 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 => { + console.error('Failed to send security notification:', error); + }); + } + + setStep('success'); + } catch (error: any) { + console.error('Email change error:', error); + toast({ + title: 'Error', + description: error.message || 'Failed to change email address', + variant: 'destructive' + }); + } 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. + + + + + + +
+ )} +
+
+ ); +}