Files
thrilltrack-explorer/src/components/settings/EmailChangeDialog.tsx
2025-10-14 18:00:59 +00:00

329 lines
11 KiB
TypeScript

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 { toast } from 'sonner';
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';
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<typeof emailSchema>;
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<Step>('verification');
const [loading, setLoading] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string>('');
const [captchaKey, setCaptchaKey] = useState(0);
const form = useForm<EmailFormData>({
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.error('CAPTCHA Required', {
description: 'Please complete the CAPTCHA verification.',
});
return;
}
if (data.newEmail.toLowerCase() === currentEmail.toLowerCase()) {
toast.error('Same Email', {
description: 'The new email is the same as your current email.',
});
return;
}
setLoading(true);
try {
// Step 1: Validate email is not disposable
const emailValidation = await validateEmailNotDisposable(data.newEmail);
if (!emailValidation.valid) {
toast.error("Invalid Email", {
description: emailValidation.reason || "Please use a permanent email address"
});
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) console.error('Failed to log email change:', error);
});
// 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 => {
console.error('Failed to send security notification:', error);
});
}
toast.success('Email change initiated', {
description: 'Check both email addresses for confirmation links.',
});
setStep('success');
} catch (error: any) {
console.error('Email change error:', error);
// Handle rate limiting specifically
if (error.message?.includes('rate limit') || error.status === 429) {
toast.error('Too Many Attempts', {
description: 'Please wait a few minutes before trying again.',
});
} else {
toast.error('Failed to change email', {
description: error.message || 'Please try again.',
});
}
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5" />
Change Email Address
</DialogTitle>
<DialogDescription>
{step === 'verification' ? (
'Enter your current password and new email address to proceed.'
) : (
'Verification emails have been sent.'
)}
</DialogDescription>
</DialogHeader>
{step === 'verification' ? (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Current email: <strong>{currentEmail}</strong>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password *</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your current password"
disabled={loading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newEmail"
render={({ field }) => (
<FormItem>
<FormLabel>New Email Address *</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Enter your new email"
disabled={loading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm New Email *</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Confirm your new email"
disabled={loading}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<TurnstileCaptcha
key={captchaKey}
onSuccess={setCaptchaToken}
onError={() => setCaptchaToken('')}
onExpire={() => setCaptchaToken('')}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme={theme === 'dark' ? 'dark' : 'light'}
size="normal"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !captchaToken}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Change Email
</Button>
</DialogFooter>
</form>
</Form>
) : (
<div className="space-y-6">
<div className="flex flex-col items-center justify-center text-center space-y-4 py-6">
<div className="rounded-full bg-primary/10 p-3">
<CheckCircle2 className="w-8 h-8 text-primary" />
</div>
<div className="space-y-2">
<h3 className="font-semibold text-lg">Verification Required</h3>
<p className="text-sm text-muted-foreground max-w-md">
We've sent verification emails to both your current email address (<strong>{currentEmail}</strong>)
and your new email address (<strong>{form.getValues('newEmail')}</strong>).
</p>
</div>
</div>
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>
<strong>Important:</strong> 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.
</AlertDescription>
</Alert>
<DialogFooter>
<Button onClick={handleClose} className="w-full">
Close
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}