mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 15:11: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 { 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
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