mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 02:11:14 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
406
src-old/components/settings/EmailChangeDialog.tsx
Normal file
406
src-old/components/settings/EmailChangeDialog.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
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<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) {
|
||||
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 (
|
||||
<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" loading={loading} loadingText="Changing Email..." disabled={!captchaToken}>
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user