feat: Implement identity management and safety checks

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 14:41:18 +00:00
parent e42853b797
commit 6430777dc0
4 changed files with 589 additions and 68 deletions

View File

@@ -1,72 +1,136 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { Shield, Key, Smartphone, Globe } from 'lucide-react';
import { Shield, Key, Smartphone, Globe, Loader2 } from 'lucide-react';
import { TOTPSetup } from '@/components/auth/TOTPSetup';
import { GoogleIcon } from '@/components/icons/GoogleIcon';
import { DiscordIcon } from '@/components/icons/DiscordIcon';
import { PasswordUpdateDialog } from './PasswordUpdateDialog';
import { PasswordSetupDialog } from '@/components/auth/PasswordSetupDialog';
import {
getUserIdentities,
checkDisconnectSafety,
disconnectIdentity,
connectIdentity
} from '@/lib/identityService';
import type { UserIdentity, OAuthProvider } from '@/types/identity';
export function SecurityTab() {
const { user } = useAuth();
const { toast } = useToast();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const handleSocialLogin = async (provider: 'google' | 'discord') => {
try {
const {
error
} = await supabase.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo: `${window.location.origin}/settings`
}
const [identities, setIdentities] = useState<UserIdentity[]>([]);
const [loadingIdentities, setLoadingIdentities] = useState(true);
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
const [passwordSetupProvider, setPasswordSetupProvider] = useState<OAuthProvider | null>(null);
// Load user identities on mount
useEffect(() => {
loadIdentities();
}, []);
const loadIdentities = async () => {
setLoadingIdentities(true);
const fetchedIdentities = await getUserIdentities();
setIdentities(fetchedIdentities);
setLoadingIdentities(false);
};
const handleSocialLogin = async (provider: OAuthProvider) => {
const result = await connectIdentity(provider, '/settings?tab=security');
if (!result.success) {
toast({
title: 'Connection Failed',
description: result.error,
variant: 'destructive'
});
if (error) throw error;
} else {
toast({
title: 'Redirecting...',
description: `Redirecting to ${provider} to link your account.`
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || `Failed to link ${provider} account`,
variant: 'destructive'
});
}
};
const handleUnlinkSocial = async (provider: 'google' | 'discord') => {
try {
// For now, show a message that this feature requires manual action
// In a production app, this would typically be handled through the admin API
toast({
title: `${provider.charAt(0).toUpperCase() + provider.slice(1)} Account`,
description: `To unlink your ${provider} account, please sign in without using ${provider} and then you can remove this connection. For assistance, contact support.`
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || `Failed to unlink ${provider} account`,
variant: 'destructive'
description: `Connecting your ${provider} account...`
});
}
};
// Get connected accounts from user identities
const connectedAccounts = [{
provider: 'google',
connected: user?.identities?.some(identity => identity.provider === 'google') || false,
email: user?.identities?.find(identity => identity.provider === 'google')?.identity_data?.email || user?.email,
icon: <GoogleIcon className="w-5 h-5" />
}, {
provider: 'discord',
connected: user?.identities?.some(identity => identity.provider === 'discord') || false,
email: user?.identities?.find(identity => identity.provider === 'discord')?.identity_data?.email,
icon: <DiscordIcon className="w-5 h-5" />
}];
const handleUnlinkSocial = async (provider: OAuthProvider) => {
// Check if disconnect is safe
const safetyCheck = await checkDisconnectSafety(provider);
if (!safetyCheck.canDisconnect) {
if (safetyCheck.reason === 'no_password_backup') {
// Show password setup dialog
setPasswordSetupProvider(provider);
toast({
title: "Password Required",
description: "Set a password before disconnecting your last social login to maintain account access.",
variant: "default"
});
return;
}
if (safetyCheck.reason === 'last_identity') {
toast({
title: "Cannot Disconnect",
description: "You cannot disconnect your only login method. Please add another authentication method first.",
variant: "destructive"
});
return;
}
}
// Proceed with disconnect
setDisconnectingProvider(provider);
const result = await disconnectIdentity(provider);
setDisconnectingProvider(null);
if (result.success) {
await loadIdentities(); // Refresh identities list
toast({
title: "Disconnected",
description: `Your ${provider} account has been successfully disconnected.`
});
} else {
toast({
title: "Disconnect Failed",
description: result.error,
variant: "destructive"
});
}
};
const handlePasswordSetupSuccess = async () => {
toast({
title: "Password Set",
description: "You can now disconnect your social login."
});
// Refresh identities to show email provider
await loadIdentities();
// Proceed with disconnect
if (passwordSetupProvider) {
await handleUnlinkSocial(passwordSetupProvider);
setPasswordSetupProvider(null);
}
};
// Get connected accounts with identity data
const connectedAccounts = [
{
provider: 'google' as OAuthProvider,
identity: identities.find(i => i.provider === 'google'),
icon: <GoogleIcon className="w-5 h-5" />
},
{
provider: 'discord' as OAuthProvider,
identity: identities.find(i => i.provider === 'discord'),
icon: <DiscordIcon className="w-5 h-5" />
}
];
return (
<>
<PasswordUpdateDialog
@@ -80,6 +144,15 @@ export function SecurityTab() {
}}
/>
{passwordSetupProvider && (
<PasswordSetupDialog
open={!!passwordSetupProvider}
onOpenChange={(open) => !open && setPasswordSetupProvider(null)}
onSuccess={handlePasswordSetupSuccess}
provider={passwordSetupProvider}
/>
)}
<div className="space-y-8">
{/* Change Password */}
<div className="space-y-4">
@@ -119,27 +192,63 @@ export function SecurityTab() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{connectedAccounts.map(account => <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}
{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>
</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>
</div>
<div>
<p className="font-medium capitalize">{account.provider}</p>
{account.connected && account.email && <p className="text-sm text-muted-foreground">{account.email}</p>}
</div>
</div>
<div className="flex items-center gap-2">
{account.connected ? <>
<Badge variant="secondary">Connected</Badge>
<Button variant="outline" size="sm" onClick={() => handleUnlinkSocial(account.provider as 'google' | 'discord')}>
Disconnect
</Button>
</> : <Button variant="outline" size="sm" onClick={() => handleSocialLogin(account.provider as 'google' | 'discord')}>
Connect
</Button>}
</div>
</div>)}
);
})
)}
</CardContent>
</Card>
</div>