mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 21:51:17 -05:00
feat: Implement all authentication compliance phases
This commit is contained in:
@@ -244,6 +244,7 @@ export function AuthModal({ open, onOpenChange, defaultTab = 'signin' }: AuthMod
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/auth/callback`,
|
||||
data: {
|
||||
username: formData.username,
|
||||
display_name: formData.displayName
|
||||
|
||||
101
src/components/auth/EmailOTPInput.tsx
Normal file
101
src/components/auth/EmailOTPInput.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Mail } from 'lucide-react';
|
||||
|
||||
interface EmailOTPInputProps {
|
||||
email: string;
|
||||
onVerify: (code: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
onResend: () => Promise<void>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailOTPInput({
|
||||
email,
|
||||
onVerify,
|
||||
onCancel,
|
||||
onResend,
|
||||
loading = false
|
||||
}: EmailOTPInputProps) {
|
||||
const [code, setCode] = useState('');
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (code.length === 6) {
|
||||
await onVerify(code);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
setResending(true);
|
||||
try {
|
||||
await onResend();
|
||||
setCode(''); // Reset code input
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Mail className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
We've sent a 6-digit verification code to <strong>{email}</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground text-center">
|
||||
Enter the 6-digit code
|
||||
</div>
|
||||
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={setCode}
|
||||
disabled={loading}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
|
||||
<div className="flex gap-2 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
disabled={loading || resending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerify}
|
||||
className="flex-1"
|
||||
disabled={code.length !== 6 || loading || resending}
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResend}
|
||||
disabled={loading || resending}
|
||||
className="text-xs"
|
||||
>
|
||||
{resending ? 'Sending...' : "Didn't receive a code? Resend"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
src/components/settings/IdentityManagement.tsx
Normal file
225
src/components/settings/IdentityManagement.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Link, Unlink, Shield, AlertCircle } from 'lucide-react';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
getUserIdentities,
|
||||
disconnectIdentity,
|
||||
linkOAuthIdentity,
|
||||
checkDisconnectSafety,
|
||||
addPasswordToAccount
|
||||
} from '@/lib/identityService';
|
||||
import type { UserIdentity, OAuthProvider } from '@/types/identity';
|
||||
|
||||
export function IdentityManagement() {
|
||||
const { toast } = useToast();
|
||||
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadIdentities();
|
||||
}, []);
|
||||
|
||||
const loadIdentities = async () => {
|
||||
setLoading(true);
|
||||
const data = await getUserIdentities();
|
||||
setIdentities(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||
// Safety check
|
||||
const safety = await checkDisconnectSafety(provider);
|
||||
if (!safety.canDisconnect) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Cannot Disconnect',
|
||||
description: safety.reason === 'last_identity'
|
||||
? 'This is your only sign-in method. Add a password or another provider first.'
|
||||
: 'Please add a password before disconnecting your last social login.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(provider);
|
||||
const result = await disconnectIdentity(provider);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Provider Disconnected',
|
||||
description: `${provider} has been removed from your account.`,
|
||||
});
|
||||
await loadIdentities();
|
||||
} else if (result.requiresAAL2) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'MFA Required',
|
||||
description: result.error || 'Please verify your identity with MFA.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to Disconnect',
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const handleLink = async (provider: OAuthProvider) => {
|
||||
setActionLoading(provider);
|
||||
const result = await linkOAuthIdentity(provider);
|
||||
|
||||
if (result.success) {
|
||||
// OAuth redirect will happen automatically
|
||||
toast({
|
||||
title: 'Redirecting...',
|
||||
description: `Opening ${provider} sign-in window...`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to Link',
|
||||
description: result.error,
|
||||
});
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPassword = async () => {
|
||||
setActionLoading('password');
|
||||
const result = await addPasswordToAccount();
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: 'Check Your Email',
|
||||
description: `We've sent a password setup link to ${result.email}`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Failed to Add Password',
|
||||
description: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
const hasProvider = (provider: string) =>
|
||||
identities.some(i => i.provider === provider);
|
||||
|
||||
const hasPassword = hasProvider('email');
|
||||
|
||||
const providers: { id: OAuthProvider; label: string; icon: string }[] = [
|
||||
{ id: 'google', label: 'Google', icon: 'G' },
|
||||
{ id: 'discord', label: 'Discord', icon: 'D' },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connected Accounts</CardTitle>
|
||||
<CardDescription>Loading...</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link className="w-5 h-5" />
|
||||
Connected Accounts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Link multiple sign-in methods to your account for easy access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{identities.length === 1 && !hasPassword && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Add a password as a backup sign-in method
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Password Authentication */}
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">Email & Password</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{hasPassword ? 'Connected' : 'Not set up'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasPassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddPassword}
|
||||
disabled={actionLoading === 'password'}
|
||||
>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
{actionLoading === 'password' ? 'Setting up...' : 'Add Password'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OAuth Providers */}
|
||||
{providers.map((provider) => {
|
||||
const isConnected = hasProvider(provider.id);
|
||||
|
||||
return (
|
||||
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center font-bold">
|
||||
{provider.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{provider.label}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isConnected ? 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant={isConnected ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => isConnected
|
||||
? handleDisconnect(provider.id)
|
||||
: handleLink(provider.id)
|
||||
}
|
||||
disabled={actionLoading === provider.id}
|
||||
>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Unlink className="w-4 h-4 mr-2" />
|
||||
{actionLoading === provider.id ? 'Disconnecting...' : 'Disconnect'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="w-4 h-4 mr-2" />
|
||||
{actionLoading === provider.id ? 'Connecting...' : 'Connect'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -261,77 +261,77 @@ export function SecurityTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connected Accounts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
<CardTitle>Connected Accounts</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage your social login connections for easier access to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loadingIdentities ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
connectedAccounts.map(account => {
|
||||
const isConnected = !!account.identity;
|
||||
const isDisconnecting = disconnectingProvider === account.provider;
|
||||
const email = account.identity?.identity_data?.email;
|
||||
{/* Identity Management Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
<CardTitle>Connected Accounts</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage your social login connections for easier access to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loadingIdentities ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
connectedAccounts.map(account => {
|
||||
const isConnected = !!account.identity;
|
||||
const isDisconnecting = disconnectingProvider === account.provider;
|
||||
const email = account.identity?.identity_data?.email;
|
||||
|
||||
return (
|
||||
<div key={account.provider} 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">
|
||||
{account.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium capitalize">{account.provider}</p>
|
||||
{isConnected && email && (
|
||||
<p className="text-sm text-muted-foreground">{email}</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div key={account.provider} 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">
|
||||
{account.icon}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Badge variant="secondary">Connected</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnlinkSocial(account.provider)}
|
||||
disabled={isDisconnecting}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSocialLogin(account.provider)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<div>
|
||||
<p className="font-medium capitalize">{account.provider}</p>
|
||||
{isConnected && email && (
|
||||
<p className="text-sm text-muted-foreground">{email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Badge variant="secondary">Connected</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnlinkSocial(account.provider)}
|
||||
disabled={isDisconnecting}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSocialLogin(account.provider)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Two-Factor Authentication - Full Width */}
|
||||
|
||||
Reference in New Issue
Block a user