Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { UserAvatar } from '@/components/ui/user-avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { User, Settings, LogOut } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { useToast } from '@/hooks/use-toast';
import { useAuthModal } from '@/hooks/useAuthModal';
import { getErrorMessage } from '@/lib/errorHandler';
export function AuthButtons() {
const { user, loading: authLoading, signOut } = useAuth();
const { data: profile, isLoading: profileLoading } = useProfile(user?.id);
const navigate = useNavigate();
const { toast } = useToast();
const { openAuthModal } = useAuthModal();
const [loggingOut, setLoggingOut] = useState(false);
const handleSignOut = async () => {
setLoggingOut(true);
try {
await signOut();
toast({
title: "Signed out",
description: "You've been signed out successfully."
});
navigate('/');
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
toast({
variant: "destructive",
title: "Error signing out",
description: errorMsg
});
} finally {
setLoggingOut(false);
}
};
// Show loading skeleton only during initial auth check
if (authLoading) {
return (
<div className="flex gap-2 items-center">
<div className="h-8 w-16 bg-muted animate-pulse rounded" />
<div className="h-8 w-8 bg-muted animate-pulse rounded-full" />
</div>
);
}
if (!user) {
return (
<>
<Button
variant="ghost"
size="sm"
onClick={() => openAuthModal('signin')}
>
Sign In
</Button>
<Button
size="sm"
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
onClick={() => openAuthModal('signup')}
>
Join ThrillWiki
</Button>
</>
);
}
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<UserAvatar
key={profile?.avatar_url || 'no-avatar'}
avatarUrl={profile?.avatar_url}
fallbackText={profile?.display_name || profile?.username || user.email || 'U'}
size="sm"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{profile?.display_name || profile?.username}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut} disabled={loggingOut}>
<LogOut className="mr-2 h-4 w-4" />
<span>{loggingOut ? 'Signing out...' : 'Sign out'}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>;
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { authStorage } from '@/lib/authStorage';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { logger } from '@/lib/logger';
interface AuthDiagnosticsData {
timestamp: string;
storage: {
type: string;
persistent: boolean;
};
session: {
exists: boolean;
user: string | null;
expiresAt: number | null;
error: string | null;
};
network: {
online: boolean;
};
environment: {
url: string;
isIframe: boolean;
cookiesEnabled: boolean;
};
}
export function AuthDiagnostics() {
const [diagnostics, setDiagnostics] = useState<AuthDiagnosticsData | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const runDiagnostics = async () => {
setIsRefreshing(true);
try {
const storageStatus = authStorage.getStorageStatus();
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
const results = {
timestamp: new Date().toISOString(),
storage: storageStatus,
session: {
exists: !!session,
user: session?.user?.email || null,
expiresAt: session?.expires_at || null,
error: sessionError?.message || null,
},
network: {
online: navigator.onLine,
},
environment: {
url: window.location.href,
isIframe: window.self !== window.top,
cookiesEnabled: navigator.cookieEnabled,
}
};
setDiagnostics(results);
logger.debug('Auth diagnostics', { results });
} finally {
setIsRefreshing(false);
}
};
useEffect(() => {
// Run diagnostics on mount if there's a session issue
const checkSession = async () => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
await runDiagnostics();
setIsOpen(true);
}
};
// Only run if not already authenticated
const timer = setTimeout(checkSession, 3000);
return () => clearTimeout(timer);
}, []);
if (!isOpen || !diagnostics) return null;
return (
<Card className="fixed bottom-4 right-4 w-96 z-50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Authentication Diagnostics</span>
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}></Button>
</CardTitle>
<CardDescription>Debug information for session issues</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div>
<strong>Storage Type:</strong>{' '}
<Badge variant={diagnostics.storage.persistent ? 'default' : 'destructive'}>
{diagnostics.storage.type}
</Badge>
</div>
<div>
<strong>Session Status:</strong>{' '}
<Badge variant={diagnostics.session.exists ? 'default' : 'destructive'}>
{diagnostics.session.exists ? 'Active' : 'None'}
</Badge>
</div>
{diagnostics.session.user && (
<div className="text-xs text-muted-foreground">
User: {diagnostics.session.user}
</div>
)}
{diagnostics.session.error && (
<div className="text-xs text-destructive">
Error: {diagnostics.session.error}
</div>
)}
<div>
<strong>Cookies Enabled:</strong>{' '}
<Badge variant={diagnostics.environment.cookiesEnabled ? 'default' : 'destructive'}>
{diagnostics.environment.cookiesEnabled ? 'Yes' : 'No'}
</Badge>
</div>
{diagnostics.environment.isIframe && (
<div className="text-xs text-yellow-600 dark:text-yellow-400">
Running in iframe - storage may be restricted
</div>
)}
<Button onClick={runDiagnostics} loading={isRefreshing} loadingText="Refreshing..." variant="outline" size="sm" className="w-full mt-2">
Refresh Diagnostics
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,682 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { Zap, Mail, Lock, User, Eye, EyeOff } from 'lucide-react';
import { supabase } from '@/lib/supabaseClient';
import { useToast } from '@/hooks/use-toast';
import { handleError, handleNonCriticalError } from '@/lib/errorHandler';
import { TurnstileCaptcha } from './TurnstileCaptcha';
import { notificationService } from '@/lib/notificationService';
import { useCaptchaBypass } from '@/hooks/useCaptchaBypass';
import { MFAChallenge } from './MFAChallenge';
import { verifyMfaUpgrade } from '@/lib/authService';
import { setAuthMethod } from '@/lib/sessionFlags';
import { validateEmailNotDisposable } from '@/lib/emailValidation';
import type { SignInOptions } from '@/types/supabase-auth';
interface AuthModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultTab?: 'signin' | 'signup';
}
export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthModalProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [magicLinkLoading, setMagicLinkLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [captchaKey, setCaptchaKey] = useState(0);
const [signInCaptchaToken, setSignInCaptchaToken] = useState<string | null>(null);
const [signInCaptchaKey, setSignInCaptchaKey] = useState(0);
const [mfaFactorId, setMfaFactorId] = useState<string | null>(null);
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
username: '',
displayName: ''
});
const { requireCaptcha } = useCaptchaBypass();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
};
const handleSignIn = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
if (requireCaptcha && !signInCaptchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
description: "Please complete the CAPTCHA verification."
});
setLoading(false);
return;
}
const tokenToUse = signInCaptchaToken;
setSignInCaptchaToken(null);
try {
const signInOptions: SignInOptions = {
email: formData.email,
password: formData.password,
};
if (tokenToUse) {
signInOptions.options = { captchaToken: tokenToUse };
}
const { data, error } = await supabase.auth.signInWithPassword(signInOptions);
if (error) throw error;
// CRITICAL: Check ban status immediately after successful authentication
const { data: profile } = await supabase
.from('profiles')
.select('banned, ban_reason')
.eq('user_id', data.user.id)
.single();
if (profile?.banned) {
// Sign out immediately
await supabase.auth.signOut();
const reason = profile.ban_reason
? `Reason: ${profile.ban_reason}`
: 'Contact support for assistance.';
toast({
variant: "destructive",
title: "Account Suspended",
description: `Your account has been suspended. ${reason}`,
duration: 10000
});
setLoading(false);
return; // Stop authentication flow
}
// Check if MFA is required (user exists but no session)
if (data.user && !data.session) {
const totpFactor = data.user.factors?.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return;
}
}
// Track auth method for audit logging
setAuthMethod('password');
// Check if MFA step-up is required
const { handlePostAuthFlow } = await import('@/lib/authService');
const postAuthResult = await handlePostAuthFlow(data.session, 'password');
if (postAuthResult.success && postAuthResult.data?.shouldRedirect) {
// Get the TOTP factor ID
const { data: factors } = await supabase.auth.mfa.listFactors();
const totpFactor = factors?.totp?.find(f => f.status === 'verified');
if (totpFactor) {
setMfaFactorId(totpFactor.id);
setLoading(false);
return; // Stay in modal, show MFA challenge
}
}
toast({
title: "Welcome back!",
description: "You've been signed in successfully."
});
// Wait for auth state to propagate before closing
await new Promise(resolve => setTimeout(resolve, 100));
onOpenChange(false);
} catch (error: unknown) {
setSignInCaptchaKey(prev => prev + 1);
handleError(error, {
action: 'Sign In',
metadata: {
method: 'password',
hasCaptcha: !!tokenToUse
// ⚠️ NEVER log: email, password, tokens
}
});
} finally {
setLoading(false);
}
};
const handleMfaSuccess = async () => {
// Verify AAL upgrade was successful
const { data: { session } } = await supabase.auth.getSession();
const verification = await verifyMfaUpgrade(session);
if (!verification.success) {
toast({
variant: "destructive",
title: "MFA Verification Failed",
description: verification.error || "Failed to upgrade session. Please try again."
});
// Force sign out on verification failure
await supabase.auth.signOut();
setMfaFactorId(null);
return;
}
setMfaFactorId(null);
onOpenChange(false);
};
const handleMfaCancel = () => {
setMfaFactorId(null);
setSignInCaptchaKey(prev => prev + 1);
};
const handleSignUp = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
if (formData.password !== formData.confirmPassword) {
toast({
variant: "destructive",
title: "Passwords don't match",
description: "Please make sure your passwords match."
});
setLoading(false);
return;
}
if (formData.password.length < 6) {
toast({
variant: "destructive",
title: "Password too short",
description: "Password must be at least 6 characters long."
});
setLoading(false);
return;
}
if (requireCaptcha && !captchaToken) {
toast({
variant: "destructive",
title: "CAPTCHA required",
description: "Please complete the CAPTCHA verification."
});
setLoading(false);
return;
}
const tokenToUse = captchaToken;
setCaptchaToken(null);
try {
// Validate email is not disposable
const emailValidation = await validateEmailNotDisposable(formData.email);
if (!emailValidation.valid) {
toast({
variant: "destructive",
title: "Invalid Email",
description: emailValidation.reason || "Please use a permanent email address"
});
setCaptchaKey(prev => prev + 1);
setLoading(false);
return;
}
interface SignUpOptions {
email: string;
password: string;
options?: {
captchaToken?: string;
data?: {
username: string;
display_name: string;
};
};
}
const signUpOptions: SignUpOptions = {
email: formData.email,
password: formData.password,
options: {
data: {
username: formData.username,
display_name: formData.displayName
}
}
};
if (tokenToUse) {
signUpOptions.options = {
...signUpOptions.options,
captchaToken: tokenToUse
};
}
const { data, error } = await supabase.auth.signUp(signUpOptions);
if (error) throw error;
if (data.user) {
const userId = data.user.id;
notificationService.createSubscriber({
subscriberId: userId,
email: formData.email,
firstName: formData.username,
data: {
username: formData.username,
}
}).catch(err => {
handleNonCriticalError(err, {
action: 'Register Novu subscriber',
userId,
metadata: {
email: formData.email,
context: 'post_signup'
}
});
});
}
toast({
title: "Welcome to ThrillWiki!",
description: "Please check your email to verify your account."
});
onOpenChange(false);
} catch (error: unknown) {
setCaptchaKey(prev => prev + 1);
handleError(error, {
action: 'Sign Up',
metadata: {
hasCaptcha: !!tokenToUse,
hasUsername: !!formData.username
// ⚠️ NEVER log: email, password, username
}
});
} finally {
setLoading(false);
}
};
const handleMagicLinkSignIn = async (email: string) => {
if (!email) {
toast({
variant: "destructive",
title: "Email required",
description: "Please enter your email address to receive a magic link."
});
return;
}
setMagicLinkLoading(true);
try {
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
});
if (error) throw error;
toast({
title: "Magic link sent!",
description: "Check your email for a sign-in link."
});
onOpenChange(false);
} catch (error: unknown) {
handleError(error, {
action: 'Send Magic Link',
metadata: {
method: 'magic_link'
// ⚠️ NEVER log: email, link
}
});
} finally {
setMagicLinkLoading(false);
}
};
const handleSocialSignIn = async (provider: 'google' | 'discord') => {
try {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: `${window.location.origin}/auth/callback`,
// Request additional scopes for avatar access
scopes: provider === 'google'
? 'email profile'
: 'identify email'
}
});
if (error) throw error;
} catch (error: unknown) {
handleError(error, {
action: 'Social Sign In',
metadata: {
provider,
method: 'oauth'
}
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="text-center text-2xl bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
ThrillWiki
</DialogTitle>
<DialogDescription className="text-center">
Join the ultimate theme park community
</DialogDescription>
</DialogHeader>
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="signin">Sign In</TabsTrigger>
<TabsTrigger value="signup">Sign Up</TabsTrigger>
</TabsList>
<TabsContent value="signin" className="space-y-4 mt-4">
{mfaFactorId ? (
<MFAChallenge
factorId={mfaFactorId}
onSuccess={handleMfaSuccess}
onCancel={handleMfaCancel}
/>
) : (
<>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="modal-signin-email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="modal-signin-email"
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
className="pl-10"
autoComplete="email"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-signin-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="modal-signin-password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="Your password"
value={formData.password}
onChange={handleInputChange}
className="pl-10 pr-10"
autoComplete="current-password"
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
{requireCaptcha && (
<div>
<TurnstileCaptcha
key={signInCaptchaKey}
onSuccess={setSignInCaptchaToken}
onError={() => setSignInCaptchaToken(null)}
onExpire={() => setSignInCaptchaToken(null)}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme="auto"
/>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading || (requireCaptcha && !signInCaptchaToken)}
>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
<div>
<Button
variant="outline"
onClick={() => handleMagicLinkSignIn(formData.email)}
disabled={!formData.email || magicLinkLoading}
className="w-full"
>
<Zap className="w-4 h-4 mr-2" />
{magicLinkLoading ? "Sending..." : "Send Magic Link"}
</Button>
<p className="text-xs text-muted-foreground mt-2 text-center">
Enter your email above and click to receive a sign-in link
</p>
</div>
<div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<Button variant="outline" onClick={() => handleSocialSignIn('google')} className="w-full">
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Google
</Button>
<Button variant="outline" onClick={() => handleSocialSignIn('discord')} className="w-full">
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.19.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</Button>
</div>
</div>
</>
)}
</TabsContent>
<TabsContent value="signup" className="space-y-3 sm:space-y-4 mt-4">
<form onSubmit={handleSignUp} className="space-y-3 sm:space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="modal-username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="modal-username"
name="username"
placeholder="username"
value={formData.username}
onChange={handleInputChange}
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-displayName">Display Name</Label>
<Input
id="modal-displayName"
name="displayName"
placeholder="Display Name"
value={formData.displayName}
onChange={handleInputChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-signup-email">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="modal-signup-email"
name="email"
type="email"
placeholder="your@email.com"
value={formData.email}
onChange={handleInputChange}
className="pl-10"
autoComplete="email"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-signup-password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="modal-signup-password"
name="password"
type={showPassword ? "text" : "password"}
placeholder="Create a password"
value={formData.password}
onChange={handleInputChange}
className="pl-10 pr-10"
autoComplete="new-password"
required
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="modal-confirmPassword">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
id="modal-confirmPassword"
name="confirmPassword"
type={showPassword ? "text" : "password"}
placeholder="Confirm your password"
value={formData.confirmPassword}
onChange={handleInputChange}
className="pl-10"
autoComplete="new-password"
required
/>
</div>
</div>
{requireCaptcha && (
<div>
<TurnstileCaptcha
key={captchaKey}
onSuccess={setCaptchaToken}
onError={() => setCaptchaToken(null)}
onExpire={() => setCaptchaToken(null)}
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
theme="auto"
/>
</div>
)}
<Button
type="submit"
className="w-full"
disabled={loading || (requireCaptcha && !captchaToken)}
>
{loading ? "Creating account..." : "Create Account"}
</Button>
</form>
<div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<Separator />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mt-4">
<Button variant="outline" onClick={() => handleSocialSignIn('google')} className="w-full" type="button">
<svg className="w-4 h-4 mr-2" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
</svg>
Google
</Button>
<Button variant="outline" onClick={() => handleSocialSignIn('discord')} className="w-full" type="button">
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.19.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.210 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg>
Discord
</Button>
</div>
</div>
<p className="text-xs text-center text-muted-foreground">
By signing up, you agree to our{' '}
<a href="/terms" className="underline hover:text-foreground">Terms</a>
{' '}and{' '}
<a href="/privacy" className="underline hover:text-foreground">Privacy Policy</a>
</p>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { MFAChallenge } from './MFAChallenge';
import { Shield, AlertCircle, Loader2 } from 'lucide-react';
import { getEnrolledFactors } from '@/lib/authService';
import { useAuth } from '@/hooks/useAuth';
import { handleError } from '@/lib/errorHandler';
interface AutoMFAVerificationModalProps {
open: boolean;
onSuccess: () => void;
onCancel: () => void;
}
export function AutoMFAVerificationModal({
open,
onSuccess,
onCancel
}: AutoMFAVerificationModalProps) {
const { session } = useAuth();
const [factorId, setFactorId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch enrolled factor automatically when modal opens
useEffect(() => {
if (!open || !session) return;
const fetchFactor = async () => {
setLoading(true);
setError(null);
try {
const factors = await getEnrolledFactors();
if (factors.length === 0) {
setError('No MFA method enrolled. Please set up MFA in settings.');
return;
}
// Use the first verified TOTP factor
const totpFactor = factors.find(f => f.factor_type === 'totp');
if (totpFactor) {
setFactorId(totpFactor.id);
} else {
setError('No valid MFA method found. Please check your security settings.');
}
} catch (err) {
setError('Failed to load MFA settings. Please try again.');
handleError(err, {
action: 'Fetch MFA Factors for Auto-Verification',
metadata: { context: 'AutoMFAVerificationModal' }
});
} finally {
setLoading(false);
}
};
fetchFactor();
}, [open, session]);
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
onCancel();
}
}}
>
<DialogContent
className="sm:max-w-md"
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />
<DialogTitle>Verification Required</DialogTitle>
</div>
<DialogDescription className="text-center">
Your session requires Multi-Factor Authentication to access this area.
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex flex-col items-center justify-center py-8 space-y-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading verification...</p>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center py-6 space-y-3">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-center text-muted-foreground">{error}</p>
</div>
)}
{!loading && !error && factorId && (
<MFAChallenge
factorId={factorId}
onSuccess={onSuccess}
onCancel={onCancel}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from "react";
import { supabase } from "@/lib/supabaseClient";
import { useToast } from "@/hooks/use-toast";
import { handleError } from "@/lib/errorHandler";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { Shield } from "lucide-react";
interface MFAChallengeProps {
factorId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProps) {
const { toast } = useToast();
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const handleVerify = async () => {
if (code.length !== 6) return;
setLoading(true);
try {
// Create fresh challenge for each verification attempt
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({ factorId });
if (challengeError) throw challengeError;
// Immediately verify with fresh challenge
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code: code.trim(),
});
if (error) throw error;
if (data) {
toast({
title: "Welcome back!",
description: "Multi-Factor verification successful.",
});
onSuccess();
}
} catch (error: unknown) {
handleError(error, {
action: 'MFA Verification',
metadata: {
factorId,
codeLength: code.length,
context: 'MFAChallenge'
}
});
setCode("");
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-primary">
<Shield className="w-5 h-5" />
<h3 className="font-semibold">Multi-Factor Authentication</h3>
</div>
<p className="text-sm text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
<div className="space-y-2">
<Label htmlFor="mfa-code">Authentication Code</Label>
<div className="flex justify-center">
<InputOTP maxLength={6} value={code} onChange={setCode} onComplete={handleVerify}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel} className="flex-1" disabled={loading}>
Cancel
</Button>
<Button onClick={handleVerify} className="flex-1" disabled={code.length !== 6 || loading}>
{loading ? "Verifying..." : "Verify"}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Shield } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
export function MFAEnrollmentRequired() {
const navigate = useNavigate();
return (
<Alert variant="destructive" className="my-4">
<Shield className="h-4 w-4" />
<AlertTitle>Multi-Factor Authentication Setup Required</AlertTitle>
<AlertDescription className="mt-2 space-y-3">
<p>
Your role requires Multi-Factor Authentication. Please set up MFA to access this area.
</p>
<Button
onClick={() => navigate('/settings?tab=security')}
size="sm"
>
Set up Multi-Factor Authentication
</Button>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,74 @@
import { useRequireMFA } from '@/hooks/useRequireMFA';
import { AutoMFAVerificationModal } from './AutoMFAVerificationModal';
import { MFAEnrollmentRequired } from './MFAEnrollmentRequired';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { handleError } from '@/lib/errorHandler';
interface MFAGuardProps {
children: React.ReactNode;
}
/**
* Smart MFA guard that automatically shows verification modal or enrollment alert
*
* Usage:
* ```tsx
* <MFAGuard>
* <YourProtectedContent />
* </MFAGuard>
* ```
*/
export function MFAGuard({ children }: MFAGuardProps) {
const { needsEnrollment, needsVerification, loading } = useRequireMFA();
const { verifySession } = useAuth();
const { toast } = useToast();
const handleVerificationSuccess = async () => {
try {
// Refresh the session to get updated AAL level
await verifySession();
toast({
title: 'Verification Successful',
description: 'You can now access this area.',
});
} catch (error: unknown) {
handleError(error, {
action: 'MFA Session Verification',
metadata: { context: 'MFAGuard' }
});
// Still attempt to show content - session might be valid despite refresh error
}
};
const handleVerificationCancel = () => {
// Redirect back to main dashboard
window.location.href = '/';
};
// Show verification modal automatically when needed
if (needsVerification) {
return (
<>
<AutoMFAVerificationModal
open={true}
onSuccess={handleVerificationSuccess}
onCancel={handleVerificationCancel}
/>
{/* Show blurred content behind modal */}
<div className="pointer-events-none opacity-50 blur-sm">
{children}
</div>
</>
);
}
// Show enrollment alert when user hasn't set up MFA
if (needsEnrollment) {
return <MFAEnrollmentRequired />;
}
// User has MFA and is verified - show content
return <>{children}</>;
}

View File

@@ -0,0 +1,295 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabaseClient';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { useToast } from '@/hooks/use-toast';
import { getErrorMessage } from '@/lib/errorHandler';
import { useRequireMFA } from '@/hooks/useRequireMFA';
import { getSessionAAL } from '@/types/supabase-session';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Shield, AlertTriangle } from 'lucide-react';
interface MFARemovalDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
factorId: string;
onSuccess: () => void;
}
export function MFARemovalDialog({ open, onOpenChange, factorId, onSuccess }: MFARemovalDialogProps) {
const { requiresMFA } = useRequireMFA();
const { toast } = useToast();
const [step, setStep] = useState<'password' | 'totp' | 'confirm'>('password');
const [password, setPassword] = useState('');
const [totpCode, setTotpCode] = useState('');
const [loading, setLoading] = useState(false);
// Phase 1: Check AAL2 requirement on dialog open
useEffect(() => {
if (open) {
const checkAalLevel = async (): Promise<void> => {
const { data: { session } } = await supabase.auth.getSession();
const currentAal = getSessionAAL(session);
if (currentAal !== 'aal2') {
toast({
title: 'Multi-Factor Authentication Required',
description: 'Please verify your identity with Multi-Factor Authentication before making security changes',
variant: 'destructive'
});
onOpenChange(false);
}
};
checkAalLevel();
}
}, [open, onOpenChange]);
const handleClose = () => {
setStep('password');
setPassword('');
setTotpCode('');
setLoading(false);
onOpenChange(false);
};
const handlePasswordVerification = async () => {
if (!password.trim()) {
toast({
title: 'Password Required',
description: 'Please enter your password',
variant: 'destructive'
});
return;
}
setLoading(true);
try {
// Get current user email
const { data: { user } } = await supabase.auth.getUser();
if (!user?.email) throw new Error('User email not found');
// Re-authenticate with password
const { error } = await supabase.auth.signInWithPassword({
email: user.email,
password: password
});
if (error) throw error;
toast({
title: 'Password Verified',
description: 'Password verified successfully'
});
setStep('totp');
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const handleTOTPVerification = async () => {
if (!totpCode.trim() || totpCode.length !== 6) {
toast({
title: 'Invalid Code',
description: 'Please enter a valid 6-digit code',
variant: 'destructive'
});
return;
}
setLoading(true);
try {
// Create challenge
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
factorId
});
if (challengeError) throw challengeError;
// Verify TOTP code
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code: totpCode.trim()
});
if (verifyError) throw verifyError;
// Phase 1: Verify session is at AAL2 after TOTP verification
const { data: { session } } = await supabase.auth.getSession();
const currentAal = getSessionAAL(session);
if (currentAal !== 'aal2') {
throw new Error('Session must be at AAL2 to remove MFA');
}
toast({
title: 'Code Verified',
description: 'TOTP code verified successfully'
});
setStep('confirm');
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const handleMFARemoval = async () => {
// Phase 2: Check if user's role requires MFA
if (requiresMFA) {
toast({
title: 'Multi-Factor Authentication Required',
description: 'Your role requires Multi-Factor Authentication and it cannot be disabled',
variant: 'destructive'
});
handleClose();
return;
}
setLoading(true);
try {
// Phase 3: Call edge function instead of direct unenroll
const { data, error, requestId } = await invokeWithTracking(
'mfa-unenroll',
{ factorId },
(await supabase.auth.getUser()).data.user?.id
);
if (error) throw error;
if (data?.error) throw new Error(data.error);
toast({
title: 'Multi-Factor Authentication Disabled',
description: 'Multi-Factor Authentication has been disabled'
});
handleClose();
onSuccess();
} catch (error: unknown) {
toast({
title: 'Error',
description: getErrorMessage(error),
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-destructive" />
Disable Multi-Factor Authentication
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Disabling Multi-Factor Authentication will make your account less secure. You'll need to verify your identity first.
</AlertDescription>
</Alert>
{step === 'password' && (
<div className="space-y-3">
<p className="text-sm">Step 1 of 3: Enter your password to continue</p>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handlePasswordVerification()}
placeholder="Enter your password"
disabled={loading}
/>
</div>
</div>
)}
{step === 'totp' && (
<div className="space-y-3">
<p className="text-sm">Step 2 of 3: Enter your current code</p>
<div className="space-y-2">
<Label htmlFor="totp">Code from Authenticator App</Label>
<Input
id="totp"
type="text"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
onKeyDown={(e) => e.key === 'Enter' && handleTOTPVerification()}
placeholder="000000"
maxLength={6}
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
</div>
)}
{step === 'confirm' && (
<div className="space-y-3">
<p className="text-sm font-semibold">Step 3 of 3: Final confirmation</p>
<p className="text-sm text-muted-foreground">
Are you sure you want to disable Multi-Factor Authentication? This will:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Remove Multi-Factor Authentication protection from your account</li>
<li>Send a security notification email</li>
<li>Log this action in your security history</li>
</ul>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleClose} disabled={loading}>
Cancel
</AlertDialogCancel>
{step === 'password' && (
<Button onClick={handlePasswordVerification} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</Button>
)}
{step === 'totp' && (
<Button onClick={handleTOTPVerification} disabled={loading}>
{loading ? 'Verifying...' : 'Continue'}
</Button>
)}
{step === 'confirm' && (
<AlertDialogAction onClick={handleMFARemoval} disabled={loading} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{loading ? 'Disabling...' : 'Disable Multi-Factor Authentication'}
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,34 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { MFAChallenge } from './MFAChallenge';
import { Shield } from 'lucide-react';
interface MFAStepUpModalProps {
open: boolean;
factorId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function MFAStepUpModal({ open, factorId, onSuccess, onCancel }: MFAStepUpModalProps) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onCancel()}>
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<div className="flex items-center gap-2 justify-center mb-2">
<Shield className="h-6 w-6 text-primary" />
<DialogTitle>Additional Verification Required</DialogTitle>
</div>
<DialogDescription className="text-center">
Your role requires Multi-Factor Authentication. Please verify your identity to continue.
</DialogDescription>
</DialogHeader>
<MFAChallenge
factorId={factorId}
onSuccess={onSuccess}
onCancel={onCancel}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,26 @@
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle } from "lucide-react";
import { authStorage } from "@/lib/authStorage";
import { useEffect, useState } from "react";
export function StorageWarning() {
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
const status = authStorage.getStorageStatus();
setShowWarning(!status.persistent);
}, []);
if (!showWarning) return null;
return (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Storage Restricted</AlertTitle>
<AlertDescription>
Your browser is blocking session storage. You'll need to sign in again if you reload the page.
To fix this, please enable cookies and local storage for this site.
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,331 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { handleError, handleSuccess, handleInfo, handleNonCriticalError, AppError, getErrorMessage } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { useAuth } from '@/hooks/useAuth';
import { useRequireMFA } from '@/hooks/useRequireMFA';
import { supabase } from '@/lib/supabaseClient';
import { Smartphone, Shield, Copy, Eye, EyeOff, Trash2, AlertTriangle } from 'lucide-react';
import { MFARemovalDialog } from './MFARemovalDialog';
import { setStepUpRequired, getAuthMethod } from '@/lib/sessionFlags';
import { useNavigate } from 'react-router-dom';
import type { MFAFactor } from '@/types/auth';
export function TOTPSetup() {
const { user } = useAuth();
const { requiresMFA } = useRequireMFA();
const navigate = useNavigate();
const [factors, setFactors] = useState<MFAFactor[]>([]);
const [loading, setLoading] = useState(false);
const [enrolling, setEnrolling] = useState(false);
const [qrCode, setQrCode] = useState('');
const [secret, setSecret] = useState('');
const [factorId, setFactorId] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [showSecret, setShowSecret] = useState(false);
const [showRemovalDialog, setShowRemovalDialog] = useState(false);
useEffect(() => {
fetchTOTPFactors();
}, [user]);
const fetchTOTPFactors = async () => {
if (!user) return;
try {
const { data, error } = await supabase.auth.mfa.listFactors();
if (error) throw error;
const totpFactors = (data.totp || []).map(factor => ({
id: factor.id,
friendly_name: factor.friendly_name || 'Authenticator App',
factor_type: 'totp' as const,
status: factor.status as 'verified' | 'unverified',
created_at: factor.created_at,
updated_at: factor.updated_at
}));
setFactors(totpFactors);
} catch (error: unknown) {
handleNonCriticalError(error, {
action: 'Fetch TOTP factors',
userId: user?.id,
metadata: { context: 'initial_load' }
});
}
};
const startEnrollment = async () => {
if (!user) return;
setLoading(true);
try {
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator App'
});
if (error) throw error;
setQrCode(data.totp.qr_code);
setSecret(data.totp.secret);
setFactorId(data.id);
setEnrolling(true);
} catch (error: unknown) {
handleError(
new AppError(
getErrorMessage(error) || 'Failed to start TOTP enrollment',
'TOTP_ENROLL_FAILED'
),
{ action: 'Start TOTP enrollment', userId: user?.id }
);
} finally {
setLoading(false);
}
};
const verifyAndEnable = async () => {
if (!factorId || !verificationCode.trim()) {
handleError(
new AppError('Please enter the verification code', 'INVALID_INPUT'),
{ action: 'Verify TOTP', userId: user?.id, metadata: { step: 'code_entry' } }
);
return;
}
setLoading(true);
try {
// Step 1: Create a challenge first
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
factorId
});
if (challengeError) throw challengeError;
// Step 2: Verify using the challengeId from the challenge response
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code: verificationCode.trim()
});
if (verifyError) throw verifyError;
// Check if user signed in via OAuth and trigger step-up flow
const authMethod = getAuthMethod();
const isOAuthUser = authMethod === 'oauth';
if (isOAuthUser) {
setStepUpRequired(true, window.location.pathname);
navigate('/auth/mfa-step-up');
return;
}
handleSuccess(
'Multi-Factor Authentication Enabled',
isOAuthUser
? 'Please verify with your authenticator app to continue.'
: 'Please sign in again to activate Multi-Factor Authentication protection.'
);
if (isOAuthUser) {
// Already handled above with navigate
return;
} else {
// For email/password users, force sign out to require MFA on next login
setTimeout(async () => {
await supabase.auth.signOut();
window.location.href = '/auth';
}, 2000);
}
} catch (error: unknown) {
handleError(
new AppError(
getErrorMessage(error) || 'Invalid verification code. Please try again.',
'TOTP_VERIFY_FAILED'
),
{ action: 'Verify TOTP code', userId: user?.id, metadata: { factorId } }
);
} finally {
setLoading(false);
}
};
const handleRemovalSuccess = async () => {
await fetchTOTPFactors();
};
const copySecret = () => {
navigator.clipboard.writeText(secret);
handleInfo('Copied', 'Secret key copied to clipboard');
};
const cancelEnrollment = () => {
setEnrolling(false);
setQrCode('');
setSecret('');
setFactorId('');
setVerificationCode('');
};
const activeFactor = factors.find(f => f.status === 'verified');
if (enrolling) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Smartphone className="w-5 h-5" />
Set Up Authenticator App
</CardTitle>
<CardDescription>
Scan the QR code with your authenticator app, then enter the verification code below.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* QR Code */}
<div className="flex justify-center">
<div className="p-4 bg-white rounded-lg border">
<img src={qrCode} alt="TOTP QR Code" className="w-48 h-48" />
</div>
</div>
{/* Manual Entry */}
<div className="space-y-2">
<Label>Can't scan? Enter this key manually:</Label>
<div className="flex items-center gap-2">
<Input
value={secret}
readOnly
type={showSecret ? 'text' : 'password'}
className="font-mono text-sm"
/>
<Button
variant="outline"
size="sm"
onClick={() => setShowSecret(!showSecret)}
>
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="outline" size="sm" onClick={copySecret}>
<Copy className="w-4 h-4" />
</Button>
</div>
</div>
{/* Verification */}
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="verificationCode">Enter verification code from your app:</Label>
<Input
id="verificationCode"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
onPaste={(e) => e.preventDefault()}
placeholder="000000"
maxLength={6}
className="text-center text-lg tracking-widest font-mono"
/>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={cancelEnrollment}>
Cancel
</Button>
<Button onClick={verifyAndEnable} disabled={loading || !verificationCode.trim()}>
{loading ? 'Verifying...' : 'Enable Multi-Factor Authentication'}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardDescription>
Add an extra layer of security to your account with Multi-Factor Authentication.
</CardDescription>
</CardHeader>
<CardContent>
{activeFactor ? (
<div className="space-y-4">
<Alert>
<Shield className="w-4 h-4" />
<AlertDescription>
Multi-Factor Authentication is enabled for your account. You'll be prompted for a code from your authenticator app when signing in.
</AlertDescription>
</Alert>
{/* Phase 2: Warning for role-required users */}
{requiresMFA && (
<Alert variant="default" className="border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<AlertDescription className="text-amber-800 dark:text-amber-200">
Your role requires Multi-Factor Authentication. You cannot disable it.
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-green-50 dark:bg-green-950 rounded-full flex items-center justify-center">
<Smartphone className="w-4 h-4 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="font-medium">{activeFactor.friendly_name || 'Authenticator App'}</p>
<p className="text-sm text-muted-foreground">
Enabled {new Date(activeFactor.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300">
Active
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => setShowRemovalDialog(true)}
>
<Trash2 className="w-4 h-4 mr-2" />
Disable
</Button>
</div>
</div>
<MFARemovalDialog
open={showRemovalDialog}
onOpenChange={setShowRemovalDialog}
factorId={activeFactor.id}
onSuccess={handleRemovalSuccess}
/>
</div>
) : (
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
<Smartphone className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Authenticator App</p>
<p className="text-sm text-muted-foreground">
Use an authenticator app to generate verification codes
</p>
</div>
</div>
<Button onClick={startEnrollment} disabled={loading}>
{loading ? 'Setting up...' : 'Set Up'}
</Button>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from 'react';
import { Turnstile } from '@marsidev/react-turnstile';
import { Callout, CalloutDescription } from '@/components/ui/callout';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface TurnstileCaptchaProps {
onSuccess: (token: string) => void;
onError?: (error: string) => void;
onExpire?: () => void;
siteKey?: string;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact';
className?: string;
}
export function TurnstileCaptcha({
onSuccess,
onError,
onExpire,
siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY,
theme = 'auto',
size = 'normal',
className = ''
}: TurnstileCaptchaProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [key, setKey] = useState(0);
const turnstileRef = useRef(null);
const handleSuccess = (token: string) => {
setError(null);
setLoading(false);
onSuccess(token);
};
const handleError = (errorCode: string) => {
setLoading(false);
const errorMessage = getErrorMessage(errorCode);
setError(errorMessage);
onError?.(errorMessage);
};
const handleExpire = () => {
setError('CAPTCHA expired. Please try again.');
onExpire?.();
};
const handleLoad = () => {
setLoading(false);
setError(null);
};
const resetCaptcha = () => {
setKey(prev => prev + 1);
setError(null);
setLoading(true);
};
const getErrorMessage = (errorCode: string): string => {
switch (errorCode) {
case 'network-error':
return 'Network error. Please check your connection and try again.';
case 'timeout':
return 'CAPTCHA timed out. Please try again.';
case 'invalid-sitekey':
return 'Invalid site configuration. Please contact support.';
case 'token-already-spent':
return 'CAPTCHA token already used. Please refresh and try again.';
default:
return 'CAPTCHA verification failed. Please try again.';
}
};
// Monitor for initialization failures
useEffect(() => {
if (loading) {
const timeout = setTimeout(() => {
setLoading(false);
}, 5000); // 5 second timeout
return () => clearTimeout(timeout);
}
}, [loading]);
if (!siteKey) {
return (
<Callout variant="warning">
<AlertCircle className="h-4 w-4" />
<CalloutDescription>
CAPTCHA is not configured. Please set VITE_TURNSTILE_SITE_KEY environment variable.
</CalloutDescription>
</Callout>
);
}
return (
<div className={`space-y-3 ${className}`}>
<div className="flex flex-col items-center">
{loading && (
<div className="w-[300px] h-[65px] flex items-center justify-center border border-dashed border-muted-foreground/30 rounded-lg bg-muted/10 animate-pulse">
<span className="text-xs text-muted-foreground">Loading CAPTCHA...</span>
</div>
)}
<div
className="transition-opacity duration-100"
style={{
display: loading ? 'none' : 'block',
opacity: loading ? 0 : 1
}}
>
<Turnstile
key={key}
ref={turnstileRef}
siteKey={siteKey}
onSuccess={handleSuccess}
onError={handleError}
onExpire={handleExpire}
onLoad={handleLoad}
options={{
theme,
size,
execution: 'render',
appearance: 'always',
retry: 'auto'
}}
/>
</div>
</div>
{error && (
<Callout variant="destructive">
<AlertCircle className="h-4 w-4" />
<CalloutDescription className="flex items-center justify-between">
<span>{error}</span>
<Button
variant="outline"
size="sm"
onClick={resetCaptcha}
className="ml-2 h-6 px-2 text-xs"
>
<RefreshCw className="w-3 h-3 mr-1" />
Retry
</Button>
</CalloutDescription>
</Callout>
)}
</div>
);
}