diff --git a/src/components/auth/PasswordSetupDialog.tsx b/src/components/auth/PasswordSetupDialog.tsx new file mode 100644 index 00000000..a39d5157 --- /dev/null +++ b/src/components/auth/PasswordSetupDialog.tsx @@ -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(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 ( + + + +
+ + Set Up Password +
+ + You need to set a password before disconnecting your {provider} account. + This ensures you can still access your account. + +
+ +
+
+ + setPassword(e.target.value)} + placeholder="Enter new password" + disabled={loading} + required + /> + {password.length > 0 && ( +
+ {passwordValid ? ( + + ) : ( + + )} + + At least 8 characters + +
+ )} +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + disabled={loading} + required + /> + {confirmPassword.length > 0 && ( +
+ {passwordsMatch ? ( + + ) : ( + + )} + + Passwords match + +
+ )} +
+ + {error && ( + + + {error} + + )} + + + + + +
+
+
+ ); +} diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index 295a4d63..4c6386db 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -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([]); + const [loadingIdentities, setLoadingIdentities] = useState(true); + const [disconnectingProvider, setDisconnectingProvider] = useState(null); + const [passwordSetupProvider, setPasswordSetupProvider] = useState(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: - }, { - provider: 'discord', - connected: user?.identities?.some(identity => identity.provider === 'discord') || false, - email: user?.identities?.find(identity => identity.provider === 'discord')?.identity_data?.email, - icon: - }]; + 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: + }, + { + provider: 'discord' as OAuthProvider, + identity: identities.find(i => i.provider === 'discord'), + icon: + } + ]; return ( <> + {passwordSetupProvider && ( + !open && setPasswordSetupProvider(null)} + onSuccess={handlePasswordSetupSuccess} + provider={passwordSetupProvider} + /> + )} +
{/* Change Password */}
@@ -119,27 +192,63 @@ export function SecurityTab() { - {connectedAccounts.map(account =>
-
-
- {account.icon} + {loadingIdentities ? ( +
+ +
+ ) : ( + connectedAccounts.map(account => { + const isConnected = !!account.identity; + const isDisconnecting = disconnectingProvider === account.provider; + const email = account.identity?.identity_data?.email; + + return ( +
+
+
+ {account.icon} +
+
+

{account.provider}

+ {isConnected && email && ( +

{email}

+ )} +
+
+
+ {isConnected ? ( + <> + Connected + + + ) : ( + + )} +
-
-

{account.provider}

- {account.connected && account.email &&

{account.email}

} -
-
-
- {account.connected ? <> - Connected - - : } -
-
)} + ); + }) + )}
diff --git a/src/lib/identityService.ts b/src/lib/identityService.ts new file mode 100644 index 00000000..7af742d1 --- /dev/null +++ b/src/lib/identityService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 +): Promise { + 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 + } +} diff --git a/src/types/identity.ts b/src/types/identity.ts new file mode 100644 index 00000000..b1752336 --- /dev/null +++ b/src/types/identity.ts @@ -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; +}