feat: Implement all authentication compliance phases

This commit is contained in:
gpt-engineer-app[bot]
2025-10-31 14:01:45 +00:00
parent 4bc749a843
commit dade374c2a
8 changed files with 757 additions and 73 deletions

View File

@@ -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

View 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>
);
}

View 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>
);
}

View File

@@ -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 */}

View File

@@ -15,7 +15,7 @@ interface AuthContextType {
loading: boolean;
pendingEmail: string | null;
sessionError: string | null;
signOut: () => Promise<void>;
signOut: (scope?: 'global' | 'local' | 'others') => Promise<void>;
verifySession: () => Promise<boolean>;
clearPendingEmail: () => void;
checkAalStepUp: () => Promise<CheckAalResult>;
@@ -123,6 +123,24 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
await supabase.auth.signOut();
return;
}
// Enhanced session monitoring: Proactively refresh tokens before expiry
const expiresAt = session.expires_at;
if (expiresAt) {
const now = Math.floor(Date.now() / 1000);
const timeUntilExpiry = expiresAt - now;
// Refresh 5 minutes (300 seconds) before expiry
if (timeUntilExpiry < 300 && timeUntilExpiry > 0) {
authLog('[Auth] Token expiring soon, refreshing session...');
const { error } = await supabase.auth.refreshSession();
if (error) {
authError('[Auth] Session refresh failed:', error);
} else {
authLog('[Auth] Session refreshed successfully');
}
}
}
} else {
setAal(null);
}
@@ -218,12 +236,23 @@ function AuthProviderComponent({ children }: { children: React.ReactNode }) {
};
}, []);
const signOut = async () => {
authLog('[Auth] Signing out...');
const result = await signOutUser();
if (!result.success) {
authError('Error signing out:', result.error);
throw new Error(result.error);
const signOut = async (scope: 'global' | 'local' | 'others' = 'global') => {
authLog('[Auth] Signing out with scope:', scope);
try {
const { error } = await supabase.auth.signOut({ scope });
if (error) throw error;
// Clear all auth flags (only on global/local sign out)
if (scope !== 'others') {
clearAllAuthFlags();
}
authLog('[Auth] Sign out successful');
} catch (error) {
authError('[Auth] Error signing out:', error);
throw error;
}
};

View File

@@ -223,6 +223,76 @@ export async function connectIdentity(
}
}
/**
* Link an OAuth identity to the logged-in user's account (Manual Linking)
* Requires user to be authenticated
*/
export async function linkOAuthIdentity(
provider: OAuthProvider
): Promise<IdentityOperationResult> {
try {
const { data, error } = await supabase.auth.linkIdentity({
provider
});
if (error) throw error;
// Log audit event
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await logIdentityChange(user.id, 'identity_linked', {
provider,
method: 'manual',
timestamp: new Date().toISOString()
});
}
return { success: true };
} catch (error) {
const errorMsg = getErrorMessage(error);
logger.error('Failed to link identity', {
action: 'identity_link',
provider,
error: errorMsg
});
return {
success: false,
error: errorMsg
};
}
}
/**
* Log when automatic identity linking occurs
* Called internally when Supabase automatically links identities
*/
export async function logAutomaticIdentityLinking(
userId: string,
provider: OAuthProvider,
email: string
): Promise<void> {
try {
await logIdentityChange(userId, 'identity_auto_linked', {
provider,
email,
method: 'automatic',
timestamp: new Date().toISOString()
});
logger.info('Automatic identity linking logged', {
userId,
provider,
action: 'identity_auto_linked'
});
} catch (error) {
logger.error('Failed to log automatic identity linking', {
userId,
provider,
error: getErrorMessage(error)
});
}
}
/**
* Add password authentication to an OAuth-only account

View File

@@ -316,6 +316,7 @@ export default function Auth() {
email: formData.email,
password: formData.password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`,
captchaToken: tokenToUse,
data: {
username: formData.username,