mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:51:13 -05:00
521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/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 { handleError, handleSuccess, AppError, getErrorMessage } from '@/lib/errorHandler';
|
|
import { logger } from '@/lib/logger';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { Loader2, Shield, CheckCircle2 } from 'lucide-react';
|
|
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
|
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
|
|
import { useTheme } from '@/components/theme/ThemeProvider';
|
|
import { passwordSchema } from '@/lib/validation';
|
|
|
|
import { z } from 'zod';
|
|
|
|
type PasswordFormData = z.infer<typeof passwordSchema>;
|
|
|
|
interface PasswordUpdateDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
type Step = 'password' | 'mfa' | 'success';
|
|
|
|
interface ErrorWithCode {
|
|
code?: string;
|
|
status?: number;
|
|
}
|
|
|
|
function isErrorWithCode(error: unknown): error is Error & ErrorWithCode {
|
|
return error instanceof Error && ('code' in error || 'status' in error);
|
|
}
|
|
|
|
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
|
|
const { theme } = useTheme();
|
|
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 [captchaToken, setCaptchaToken] = useState<string>('');
|
|
const [captchaKey, setCaptchaKey] = useState(0);
|
|
const [userId, setUserId] = useState<string>('');
|
|
|
|
const form = useForm<PasswordFormData>({
|
|
resolver: zodResolver(passwordSchema),
|
|
defaultValues: {
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: ''
|
|
}
|
|
});
|
|
|
|
// Check if user has MFA enabled and get user ID
|
|
useEffect(() => {
|
|
if (open) {
|
|
checkMFAStatus();
|
|
supabase.auth.getUser().then(({ data }) => {
|
|
if (data.user) setUserId(data.user.id);
|
|
});
|
|
}
|
|
}, [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: unknown) {
|
|
logger.error('Failed to check MFA status', {
|
|
action: 'check_mfa_status',
|
|
error: error instanceof Error ? error.message : String(error)
|
|
});
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (data: PasswordFormData) => {
|
|
if (!captchaToken) {
|
|
handleError(
|
|
new AppError('Please complete the CAPTCHA verification.', 'CAPTCHA_REQUIRED'),
|
|
{
|
|
action: 'Change password',
|
|
userId,
|
|
metadata: { step: 'captcha_validation' }
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Phase 4: AAL2 check for security-critical operations
|
|
if (hasMFA) {
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
if (session) {
|
|
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 password', 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: 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,
|
|
options: {
|
|
captchaToken
|
|
}
|
|
});
|
|
|
|
if (signInError) {
|
|
// Reset CAPTCHA on authentication failure
|
|
setCaptchaToken('');
|
|
setCaptchaKey(prev => prev + 1);
|
|
|
|
logger.error('Password authentication failed', {
|
|
userId,
|
|
action: 'password_change_auth',
|
|
error: signInError.message,
|
|
errorCode: signInError.code
|
|
});
|
|
|
|
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: unknown) {
|
|
const errorDetails = isErrorWithCode(error) ? {
|
|
errorCode: error.code,
|
|
errorStatus: error.status
|
|
} : {};
|
|
|
|
logger.error('Password change failed', {
|
|
userId,
|
|
action: 'password_change',
|
|
error: getErrorMessage(error),
|
|
...errorDetails
|
|
});
|
|
|
|
const errorMessage = getErrorMessage(error);
|
|
const errorStatus = isErrorWithCode(error) ? error.status : undefined;
|
|
|
|
if (errorMessage?.includes('rate limit') || errorStatus === 429) {
|
|
handleError(
|
|
new AppError(
|
|
'Please wait a few minutes before trying again.',
|
|
'RATE_LIMIT',
|
|
'Too many password change attempts'
|
|
),
|
|
{ action: 'Change password', userId, metadata: { step: 'authentication' } }
|
|
);
|
|
} else if (errorMessage?.includes('Invalid login credentials')) {
|
|
handleError(
|
|
new AppError(
|
|
'The password you entered is incorrect.',
|
|
'INVALID_PASSWORD',
|
|
'Incorrect current password'
|
|
),
|
|
{ action: 'Verify password', userId }
|
|
);
|
|
} else {
|
|
handleError(error, {
|
|
action: 'Change password',
|
|
userId,
|
|
metadata: { step: 'authentication' }
|
|
});
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const verifyMFAAndUpdate = async () => {
|
|
if (totpCode.length !== 6) {
|
|
handleError(
|
|
new AppError('Please enter a valid 6-digit code', 'INVALID_MFA_CODE'),
|
|
{ action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } }
|
|
);
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
// Get the factor ID first
|
|
const factorId = (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || '';
|
|
|
|
if (!factorId) {
|
|
throw new Error('No MFA factor found');
|
|
}
|
|
|
|
// Create challenge
|
|
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
|
factorId
|
|
});
|
|
|
|
if (challengeError) {
|
|
logger.error('MFA challenge creation failed', {
|
|
userId,
|
|
action: 'password_change_mfa_challenge',
|
|
error: challengeError.message,
|
|
errorCode: challengeError.code
|
|
});
|
|
throw challengeError;
|
|
}
|
|
|
|
// Verify TOTP code with correct factorId
|
|
const { error: verifyError } = await supabase.auth.mfa.verify({
|
|
factorId,
|
|
challengeId: challengeData.id,
|
|
code: totpCode
|
|
});
|
|
|
|
if (verifyError) {
|
|
logger.error('MFA verification failed', {
|
|
userId,
|
|
action: 'password_change_mfa',
|
|
error: verifyError.message,
|
|
errorCode: verifyError.code
|
|
});
|
|
throw verifyError;
|
|
}
|
|
|
|
// TOTP verified, now update password
|
|
await updatePasswordWithNonce(newPassword, nonce);
|
|
} catch (error: unknown) {
|
|
logger.error('MFA verification failed', {
|
|
userId,
|
|
action: 'password_change_mfa',
|
|
error: getErrorMessage(error)
|
|
});
|
|
|
|
handleError(
|
|
new AppError(
|
|
getErrorMessage(error) || 'Invalid authentication code',
|
|
'MFA_VERIFICATION_FAILED',
|
|
'TOTP code verification failed'
|
|
),
|
|
{ action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } }
|
|
);
|
|
} 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 invokeWithTracking(
|
|
'trigger-notification',
|
|
{
|
|
workflowId: 'security-alert',
|
|
subscriberId: user.id,
|
|
payload: {
|
|
alert_type: 'password_changed',
|
|
timestamp: new Date().toISOString(),
|
|
device: navigator.userAgent.split(' ')[0]
|
|
}
|
|
},
|
|
user.id
|
|
);
|
|
} catch (notifError) {
|
|
logger.error('Failed to send password change notification', {
|
|
userId: user!.id,
|
|
action: 'password_change_notification',
|
|
error: getErrorMessage(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: unknown) {
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (!loading) {
|
|
onOpenChange(false);
|
|
setStep('password');
|
|
form.reset();
|
|
setTotpCode('');
|
|
setCaptchaToken('');
|
|
setCaptchaKey(prev => prev + 1);
|
|
}
|
|
};
|
|
|
|
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}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Must be 8+ characters with uppercase, lowercase, number, and special character
|
|
</p>
|
|
{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>
|
|
|
|
<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" />}
|
|
{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">Code from Authenticator App</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>
|
|
);
|
|
}
|