mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 20:11:12 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
110
src-old/components/auth/AuthButtons.tsx
Normal file
110
src-old/components/auth/AuthButtons.tsx
Normal 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>;
|
||||
}
|
||||
134
src-old/components/auth/AuthDiagnostics.tsx
Normal file
134
src-old/components/auth/AuthDiagnostics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
682
src-old/components/auth/AuthModal.tsx
Normal file
682
src-old/components/auth/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src-old/components/auth/AutoMFAVerificationModal.tsx
Normal file
110
src-old/components/auth/AutoMFAVerificationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src-old/components/auth/MFAChallenge.tsx
Normal file
97
src-old/components/auth/MFAChallenge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src-old/components/auth/MFAEnrollmentRequired.tsx
Normal file
26
src-old/components/auth/MFAEnrollmentRequired.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src-old/components/auth/MFAGuard.tsx
Normal file
74
src-old/components/auth/MFAGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
295
src-old/components/auth/MFARemovalDialog.tsx
Normal file
295
src-old/components/auth/MFARemovalDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src-old/components/auth/MFAStepUpModal.tsx
Normal file
34
src-old/components/auth/MFAStepUpModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src-old/components/auth/StorageWarning.tsx
Normal file
26
src-old/components/auth/StorageWarning.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
331
src-old/components/auth/TOTPSetup.tsx
Normal file
331
src-old/components/auth/TOTPSetup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src-old/components/auth/TurnstileCaptcha.tsx
Normal file
150
src-old/components/auth/TurnstileCaptcha.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user