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,
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',
connected: user?.identities?.some(identity => identity.provider === 'discord') || false,
email: user?.identities?.find(identity => identity.provider === 'discord')?.identity_data?.email,
},
{
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">
{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>
{account.connected && account.email && <p className="text-sm text-muted-foreground">{account.email}</p>}
{isConnected && email && (
<p className="text-sm text-muted-foreground">{email}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{account.connected ? <>
{isConnected ? (
<>
<Badge variant="secondary">Connected</Badge>
<Button variant="outline" size="sm" onClick={() => handleUnlinkSocial(account.provider as 'google' | 'discord')}>
Disconnect
<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 as 'google' | 'discord')}>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleSocialLogin(account.provider)}
>
Connect
</Button>}
</Button>
)}
</div>
</div>)}
</div>
);
})
)}
</CardContent>
</Card>
</div>

220
src/lib/identityService.ts Normal file
View File

@@ -0,0 +1,220 @@
/**
* Identity Management Service
* Handles OAuth provider connections, disconnections, and password fallback
*/
import { supabase } from '@/integrations/supabase/client';
import type { UserIdentity as SupabaseUserIdentity } from '@supabase/supabase-js';
import type {
UserIdentity,
OAuthProvider,
IdentitySafetyCheck,
IdentityOperationResult
} from '@/types/identity';
/**
* Get all identities for the current user
*/
export async function getUserIdentities(): Promise<UserIdentity[]> {
try {
const { data, error } = await supabase.auth.getUserIdentities();
if (error) throw error;
return (data?.identities || []) as UserIdentity[];
} catch (error: any) {
console.error('[IdentityService] Failed to get identities:', error);
return [];
}
}
/**
* Check if user has password authentication (email provider)
*/
export async function hasPasswordAuth(): Promise<boolean> {
const identities = await getUserIdentities();
return identities.some(identity => identity.provider === 'email');
}
/**
* Check if it's safe to disconnect a provider
* Returns safety information and reason if unsafe
*/
export async function checkDisconnectSafety(
provider: OAuthProvider
): Promise<IdentitySafetyCheck> {
const identities = await getUserIdentities();
const hasPassword = identities.some(i => i.provider === 'email');
const oauthIdentities = identities.filter(i =>
i.provider !== 'email' && i.provider !== 'phone'
);
const totalIdentities = identities.length;
// Can't disconnect if it's the only identity
if (totalIdentities === 1) {
return {
canDisconnect: false,
reason: 'last_identity',
hasPasswordAuth: hasPassword,
totalIdentities,
oauthIdentities: oauthIdentities.length
};
}
// Can't disconnect last OAuth provider if no password backup
if (oauthIdentities.length === 1 && !hasPassword) {
return {
canDisconnect: false,
reason: 'no_password_backup',
hasPasswordAuth: hasPassword,
totalIdentities,
oauthIdentities: oauthIdentities.length
};
}
return {
canDisconnect: true,
reason: 'safe',
hasPasswordAuth: hasPassword,
totalIdentities,
oauthIdentities: oauthIdentities.length
};
}
/**
* Disconnect an OAuth identity from the user's account
*/
export async function disconnectIdentity(
provider: OAuthProvider
): Promise<IdentityOperationResult> {
try {
// Safety check first
const safetyCheck = await checkDisconnectSafety(provider);
if (!safetyCheck.canDisconnect) {
return {
success: false,
error: safetyCheck.reason === 'last_identity'
? 'Cannot disconnect your only login method'
: 'Please set a password before disconnecting your last social login'
};
}
// Get all identities to find the one to unlink
const identities = await getUserIdentities();
const identity = identities.find(i => i.provider === provider);
if (!identity) {
return {
success: false,
error: `No ${provider} identity found`
};
}
// Unlink the identity - cast to Supabase's expected type
const { error } = await supabase.auth.unlinkIdentity(identity as SupabaseUserIdentity);
if (error) throw error;
// Log audit event
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await logIdentityChange(user.id, 'identity_disconnected', { provider });
}
return { success: true };
} catch (error: any) {
console.error('[IdentityService] Failed to disconnect identity:', error);
return {
success: false,
error: error.message || 'Failed to disconnect identity'
};
}
}
/**
* Connect an OAuth identity to the user's account
*/
export async function connectIdentity(
provider: OAuthProvider,
redirectTo?: string
): Promise<IdentityOperationResult> {
try {
const { error } = await supabase.auth.signInWithOAuth({
provider,
options: {
redirectTo: redirectTo || `${window.location.origin}/settings?tab=security`,
skipBrowserRedirect: false
}
});
if (error) throw error;
return { success: true };
} catch (error: any) {
console.error('[IdentityService] Failed to connect identity:', error);
return {
success: false,
error: error.message || `Failed to connect ${provider} account`
};
}
}
/**
* Add password authentication to an OAuth-only account
*/
export async function addPasswordToAccount(
password: string
): Promise<IdentityOperationResult> {
try {
// Validate password strength
if (password.length < 8) {
return {
success: false,
error: 'Password must be at least 8 characters long'
};
}
// Update user with password
const { error } = await supabase.auth.updateUser({ password });
if (error) throw error;
// Log audit event
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await logIdentityChange(user.id, 'password_added', {
method: 'oauth_fallback'
});
}
return { success: true };
} catch (error: any) {
console.error('[IdentityService] Failed to add password:', error);
return {
success: false,
error: error.message || 'Failed to set password'
};
}
}
/**
* Log identity changes to audit log
*/
async function logIdentityChange(
userId: string,
action: string,
details: Record<string, any>
): Promise<void> {
try {
await supabase.rpc('log_admin_action', {
_admin_user_id: userId,
_target_user_id: userId,
_action: action,
_details: details
});
} catch (error) {
console.error('[IdentityService] Failed to log audit event:', error);
// Don't fail the operation if audit logging fails
}
}

33
src/types/identity.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Type definitions for user identity and OAuth provider management
*/
export interface UserIdentity {
id: string;
user_id: string;
identity_data: {
email?: string;
full_name?: string;
avatar_url?: string;
[key: string]: any;
};
provider: 'google' | 'discord' | 'email' | 'github' | string;
created_at: string;
updated_at: string;
last_sign_in_at: string;
}
export type OAuthProvider = 'google' | 'discord';
export interface IdentitySafetyCheck {
canDisconnect: boolean;
reason?: 'last_identity' | 'no_password_backup' | 'safe';
hasPasswordAuth: boolean;
totalIdentities: number;
oauthIdentities: number;
}
export interface IdentityOperationResult {
success: boolean;
error?: string;
}