mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
feat: Implement secure email change functionality
This commit is contained in:
@@ -14,9 +14,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
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 { PhotoUpload } from '@/components/upload/PhotoUpload';
|
||||||
import { notificationService } from '@/lib/notificationService';
|
import { notificationService } from '@/lib/notificationService';
|
||||||
|
import { EmailChangeDialog } from './EmailChangeDialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/),
|
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 [loading, setLoading] = useState(false);
|
||||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [showEmailDialog, setShowEmailDialog] = useState(false);
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string>(profile?.avatar_url || '');
|
const [avatarUrl, setAvatarUrl] = useState<string>(profile?.avatar_url || '');
|
||||||
const [avatarImageId, setAvatarImageId] = useState<string>(profile?.avatar_image_id || '');
|
const [avatarImageId, setAvatarImageId] = useState<string>(profile?.avatar_image_id || '');
|
||||||
|
|
||||||
@@ -271,20 +274,52 @@ export function AccountProfileTab() {
|
|||||||
{/* Account Information */}
|
{/* Account Information */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium">Account Information</h3>
|
<h3 className="text-lg font-medium">Account Information</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg">
|
<div className="space-y-4">
|
||||||
<div>
|
<div className="p-4 bg-muted/50 rounded-lg space-y-4">
|
||||||
<p className="text-sm font-medium">Email</p>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
<div className="flex-1">
|
||||||
</div>
|
<p className="text-sm font-medium">Email Address</p>
|
||||||
<div>
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<p className="text-sm font-medium">Account Created</p>
|
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
{user?.email_confirmed_at ? (
|
||||||
{profile?.created_at ? new Date(profile.created_at).toLocaleDateString() : 'N/A'}
|
<Badge variant="secondary" className="text-xs">Verified</Badge>
|
||||||
</p>
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs">Pending Verification</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowEmailDialog(true)}
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
Change Email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Account Created</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{profile?.created_at ? new Date(profile.created_at).toLocaleDateString() : 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email Change Dialog */}
|
||||||
|
{user && (
|
||||||
|
<EmailChangeDialog
|
||||||
|
open={showEmailDialog}
|
||||||
|
onOpenChange={setShowEmailDialog}
|
||||||
|
currentEmail={user.email || ''}
|
||||||
|
userId={user.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
|
|||||||
318
src/components/settings/EmailChangeDialog.tsx
Normal file
318
src/components/settings/EmailChangeDialog.tsx
Normal file
@@ -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<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 { toast } = useToast();
|
||||||
|
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({
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user