mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 18:51:13 -05:00
feat: Implement identity management and safety checks
This commit is contained in:
159
src/components/auth/PasswordSetupDialog.tsx
Normal file
159
src/components/auth/PasswordSetupDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,72 +1,136 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { Shield, Key, Smartphone, Globe, Loader2 } from 'lucide-react';
|
||||||
import { Shield, Key, Smartphone, Globe } from 'lucide-react';
|
|
||||||
import { TOTPSetup } from '@/components/auth/TOTPSetup';
|
import { TOTPSetup } from '@/components/auth/TOTPSetup';
|
||||||
import { GoogleIcon } from '@/components/icons/GoogleIcon';
|
import { GoogleIcon } from '@/components/icons/GoogleIcon';
|
||||||
import { DiscordIcon } from '@/components/icons/DiscordIcon';
|
import { DiscordIcon } from '@/components/icons/DiscordIcon';
|
||||||
import { PasswordUpdateDialog } from './PasswordUpdateDialog';
|
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() {
|
export function SecurityTab() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
const handleSocialLogin = async (provider: 'google' | 'discord') => {
|
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||||
try {
|
const [loadingIdentities, setLoadingIdentities] = useState(true);
|
||||||
const {
|
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
|
||||||
error
|
const [passwordSetupProvider, setPasswordSetupProvider] = useState<OAuthProvider | null>(null);
|
||||||
} = await supabase.auth.signInWithOAuth({
|
|
||||||
provider: provider,
|
// Load user identities on mount
|
||||||
options: {
|
useEffect(() => {
|
||||||
redirectTo: `${window.location.origin}/settings`
|
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({
|
toast({
|
||||||
title: 'Redirecting...',
|
title: 'Redirecting...',
|
||||||
description: `Redirecting to ${provider} to link your account.`
|
description: `Connecting your ${provider} 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'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get connected accounts from user identities
|
const handleUnlinkSocial = async (provider: OAuthProvider) => {
|
||||||
const connectedAccounts = [{
|
// Check if disconnect is safe
|
||||||
provider: 'google',
|
const safetyCheck = await checkDisconnectSafety(provider);
|
||||||
connected: user?.identities?.some(identity => identity.provider === 'google') || false,
|
|
||||||
email: user?.identities?.find(identity => identity.provider === 'google')?.identity_data?.email || user?.email,
|
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" />
|
icon: <GoogleIcon className="w-5 h-5" />
|
||||||
}, {
|
},
|
||||||
provider: 'discord',
|
{
|
||||||
connected: user?.identities?.some(identity => identity.provider === 'discord') || false,
|
provider: 'discord' as OAuthProvider,
|
||||||
email: user?.identities?.find(identity => identity.provider === 'discord')?.identity_data?.email,
|
identity: identities.find(i => i.provider === 'discord'),
|
||||||
icon: <DiscordIcon className="w-5 h-5" />
|
icon: <DiscordIcon className="w-5 h-5" />
|
||||||
}];
|
}
|
||||||
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PasswordUpdateDialog
|
<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">
|
<div className="space-y-8">
|
||||||
{/* Change Password */}
|
{/* Change Password */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -119,27 +192,63 @@ export function SecurityTab() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||||
{account.icon}
|
{account.icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium capitalize">{account.provider}</p>
|
<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>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{account.connected ? <>
|
{isConnected ? (
|
||||||
|
<>
|
||||||
<Badge variant="secondary">Connected</Badge>
|
<Badge variant="secondary">Connected</Badge>
|
||||||
<Button variant="outline" size="sm" onClick={() => handleUnlinkSocial(account.provider as 'google' | 'discord')}>
|
<Button
|
||||||
Disconnect
|
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>
|
||||||
</> : <Button variant="outline" size="sm" onClick={() => handleSocialLogin(account.provider as 'google' | 'discord')}>
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSocialLogin(account.provider)}
|
||||||
|
>
|
||||||
Connect
|
Connect
|
||||||
</Button>}
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
220
src/lib/identityService.ts
Normal file
220
src/lib/identityService.ts
Normal 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
33
src/types/identity.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user