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

@@ -0,0 +1,159 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle, CheckCircle2, Key } from 'lucide-react';
import { addPasswordToAccount } from '@/lib/identityService';
import type { OAuthProvider } from '@/types/identity';
interface PasswordSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
provider: OAuthProvider;
}
export function PasswordSetupDialog({
open,
onOpenChange,
onSuccess,
provider
}: PasswordSetupDialogProps) {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const passwordsMatch = password === confirmPassword && password.length > 0;
const passwordValid = password.length >= 8;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!passwordValid) {
setError('Password must be at least 8 characters long');
return;
}
if (!passwordsMatch) {
setError('Passwords do not match');
return;
}
setLoading(true);
const result = await addPasswordToAccount(password);
setLoading(false);
if (result.success) {
setPassword('');
setConfirmPassword('');
onSuccess();
onOpenChange(false);
} else {
setError(result.error || 'Failed to set password');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-2">
<Key className="w-5 h-5" />
<DialogTitle>Set Up Password</DialogTitle>
</div>
<DialogDescription>
You need to set a password before disconnecting your {provider} account.
This ensures you can still access your account.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">New Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter new password"
disabled={loading}
required
/>
{password.length > 0 && (
<div className="flex items-center gap-2 text-sm">
{passwordValid ? (
<CheckCircle2 className="w-4 h-4 text-green-600" />
) : (
<AlertCircle className="w-4 h-4 text-destructive" />
)}
<span className={passwordValid ? 'text-green-600' : 'text-muted-foreground'}>
At least 8 characters
</span>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
disabled={loading}
required
/>
{confirmPassword.length > 0 && (
<div className="flex items-center gap-2 text-sm">
{passwordsMatch ? (
<CheckCircle2 className="w-4 h-4 text-green-600" />
) : (
<AlertCircle className="w-4 h-4 text-destructive" />
)}
<span className={passwordsMatch ? 'text-green-600' : 'text-muted-foreground'}>
Passwords match
</span>
</div>
)}
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={!passwordValid || !passwordsMatch || loading}
>
{loading ? 'Setting Password...' : 'Set Password'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

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>