mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 13:51:13 -05:00
feat: Implement secure password update
This commit is contained in:
363
src/components/settings/PasswordUpdateDialog.tsx
Normal file
363
src/components/settings/PasswordUpdateDialog.tsx
Normal file
@@ -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<typeof passwordSchema>;
|
||||
|
||||
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<Step>('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nonce, setNonce] = useState<string>('');
|
||||
const [newPassword, setNewPassword] = useState<string>('');
|
||||
const [hasMFA, setHasMFA] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
|
||||
const form = useForm<PasswordFormData>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{step === 'password' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your current password and choose a new one. {hasMFA && 'You will need to verify with your authenticator app.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Current Password</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...form.register('currentPassword')}
|
||||
placeholder="Enter your current password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{form.formState.errors.currentPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New Password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
{...form.register('newPassword')}
|
||||
placeholder="Enter your new password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{form.formState.errors.newPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
{...form.register('confirmPassword')}
|
||||
placeholder="Confirm your new password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{form.formState.errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{hasMFA ? 'Continue' : 'Update Password'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'mfa' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Verify Your Identity
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the 6-digit code from your authenticator app to complete the password change.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Label htmlFor="totp-code">Authentication Code</Label>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={setTotpCode}
|
||||
disabled={loading}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep('password');
|
||||
setTotpCode('');
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={verifyMFAAndUpdate} disabled={loading || totpCode.length !== 6}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Verify & Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
Password Updated
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your password has been successfully changed. A security notification has been sent to your email.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user