Reverted to commit 0091584677

This commit is contained in:
gpt-engineer-app[bot]
2025-11-01 15:22:30 +00:00
parent 26e5753807
commit 133141d474
125 changed files with 2316 additions and 9102 deletions

View File

@@ -28,7 +28,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
import { useAutoSave } from '@/hooks/useAutoSave';
import { useProfileUpdateMutation } from '@/hooks/profile/useProfileUpdateMutation';
import { formatDistanceToNow } from 'date-fns';
import { cn } from '@/lib/utils';
@@ -43,7 +42,7 @@ type ProfileFormData = z.infer<typeof profileSchema>;
export function AccountProfileTab() {
const { user, pendingEmail, clearPendingEmail } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const updateProfileMutation = useProfileUpdateMutation();
const [loading, setLoading] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEmailDialog, setShowEmailDialog] = useState(false);
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
@@ -108,28 +107,47 @@ export function AccountProfileTab() {
const handleFormSubmit = async (data: ProfileFormData) => {
if (!user) return;
// Update Novu subscriber if username changed (before mutation for optimistic update)
const usernameChanged = data.username !== profile?.username;
updateProfileMutation.mutate({
userId: user.id,
updates: {
username: data.username,
display_name: data.display_name || null,
bio: data.bio || null
}
}, {
onSuccess: async () => {
if (usernameChanged && notificationService.isEnabled()) {
await notificationService.updateSubscriber({
subscriberId: user.id,
email: user.email,
firstName: data.username,
});
setLoading(true);
try {
// Use the update_profile RPC function with server-side validation
const { data: result, error } = await supabase.rpc('update_profile', {
p_username: data.username,
p_display_name: data.display_name || null,
p_bio: data.bio || null
});
if (error) {
// Handle rate limiting error
if (error.code === 'P0001') {
const resetTime = error.message.match(/Try again at (.+)$/)?.[1];
throw new AppError(
error.message,
'RATE_LIMIT',
`Too many profile updates. ${resetTime ? 'Try again at ' + new Date(resetTime).toLocaleTimeString() : 'Please wait a few minutes.'}`
);
}
await refreshProfile();
throw error;
}
});
// Type the RPC result
const rpcResult = result as unknown as { success: boolean; changes_count: number };
// Update Novu subscriber if username changed
if (rpcResult?.changes_count > 0 && notificationService.isEnabled()) {
await notificationService.updateSubscriber({
subscriberId: user.id,
email: user.email,
firstName: data.username,
});
}
await refreshProfile();
handleSuccess('Profile updated', 'Your profile has been successfully updated.');
} catch (error: unknown) {
handleError(error, { action: 'Update profile', userId: user.id });
} finally {
setLoading(false);
}
};
const onSubmit = async (data: ProfileFormData) => {
@@ -382,17 +400,17 @@ export function AccountProfileTab() {
<Button
type="submit"
disabled={
updateProfileMutation.isPending ||
loading ||
isDeactivated ||
isSaving ||
usernameValidation.isChecking ||
usernameValidation.isAvailable === false
}
>
{updateProfileMutation.isPending || isSaving ? 'Saving...' : 'Save Changes'}
{loading || isSaving ? 'Saving...' : 'Save Changes'}
</Button>
{lastSaved && !updateProfileMutation.isPending && !isSaving && (
{lastSaved && !loading && !isSaving && (
<span className="text-sm text-muted-foreground">
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
</span>

View File

@@ -1,176 +0,0 @@
/**
* Auth0 MFA Settings Component
*
* Display MFA status and provide enrollment/unenrollment options via Auth0
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Shield, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useAuth0 } from '@auth0/auth0-react';
import { getMFAStatus, triggerMFAEnrollment } from '@/lib/auth0Management';
import { useToast } from '@/hooks/use-toast';
import type { Auth0MFAStatus } from '@/types/auth0';
export function Auth0MFASettings() {
const { user, isAuthenticated } = useAuth0();
const [mfaStatus, setMfaStatus] = useState<Auth0MFAStatus | null>(null);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
const fetchMFAStatus = async () => {
if (!isAuthenticated || !user?.sub) {
setLoading(false);
return;
}
try {
const status = await getMFAStatus(user.sub);
setMfaStatus(status);
} catch (error) {
console.error('Error fetching MFA status:', error);
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to load MFA status',
});
} finally {
setLoading(false);
}
};
fetchMFAStatus();
}, [isAuthenticated, user, toast]);
const handleEnroll = async () => {
try {
await triggerMFAEnrollment('/settings?tab=security');
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to start MFA enrollment',
});
}
};
if (loading) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
<CardTitle>Multi-Factor Authentication (MFA)</CardTitle>
</div>
<CardDescription>
Add an extra layer of security to your account with two-factor authentication
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* MFA Status */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
{mfaStatus?.enrolled ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-amber-500" />
)}
<div>
<p className="font-medium">
MFA Status
</p>
<p className="text-sm text-muted-foreground">
{mfaStatus?.enrolled
? 'Multi-factor authentication is active'
: 'MFA is not enabled on your account'}
</p>
</div>
</div>
<Badge variant={mfaStatus?.enrolled ? 'default' : 'secondary'}>
{mfaStatus?.enrolled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
{/* Enrolled Methods */}
{mfaStatus?.enrolled && mfaStatus.methods.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium">Active Methods:</p>
<div className="space-y-2">
{mfaStatus.methods.map((method) => (
<div
key={method.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="text-sm font-medium capitalize">{method.type}</p>
{method.name && (
<p className="text-xs text-muted-foreground">{method.name}</p>
)}
</div>
<Badge variant={method.confirmed ? 'default' : 'secondary'}>
{method.confirmed ? 'Active' : 'Pending'}
</Badge>
</div>
))}
</div>
</div>
)}
{/* Info Alert */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
{mfaStatus?.enrolled ? (
<>
MFA is managed through Auth0. To add or remove authentication methods,
click the button below to manage your MFA settings.
</>
) : (
<>
Enable MFA to protect your account with an additional security layer.
You'll be redirected to Auth0 to set up your preferred authentication method.
</>
)}
</AlertDescription>
</Alert>
{/* Action Buttons */}
<div className="flex gap-2">
{!mfaStatus?.enrolled ? (
<Button onClick={handleEnroll} className="w-full">
Enable MFA
</Button>
) : (
<Button onClick={handleEnroll} variant="outline" className="w-full">
Manage MFA Settings
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -2,7 +2,6 @@ import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useEmailChangeMutation } from '@/hooks/security/useEmailChangeMutation';
import {
Dialog,
DialogContent,
@@ -53,7 +52,6 @@ interface EmailChangeDialogProps {
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
const { theme } = useTheme();
const { changeEmail, isChanging } = useEmailChangeMutation();
const [step, setStep] = useState<Step>('verification');
const [loading, setLoading] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string>('');
@@ -158,18 +156,63 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
throw signInError;
}
// Step 3: Update email address using mutation hook
changeEmail.mutate(
{ newEmail: data.newEmail, currentEmail, userId },
{
onSuccess: () => {
setStep('success');
},
onError: (error) => {
throw error;
}
// Step 3: Update email address
// Supabase will send verification emails to both old and new addresses
const { error: updateError } = await supabase.auth.updateUser({
email: data.newEmail
});
if (updateError) throw updateError;
// Step 4: Novu subscriber will be updated automatically after both emails are confirmed
// This happens in the useAuth hook when the email change is fully verified
// Step 5: Log the email change attempt
supabase.from('admin_audit_log').insert({
admin_user_id: userId,
target_user_id: userId,
action: 'email_change_initiated',
details: {
old_email: currentEmail,
new_email: data.newEmail,
timestamp: new Date().toISOString(),
}
}).then(({ error }) => {
if (error) {
logger.error('Failed to log email change', {
userId,
action: 'email_change_audit_log',
error: error.message
});
}
});
// Step 6: Send security notifications (non-blocking)
if (notificationService.isEnabled()) {
notificationService.trigger({
workflowId: 'security-alert',
subscriberId: userId,
payload: {
alert_type: 'email_change_initiated',
old_email: currentEmail,
new_email: data.newEmail,
timestamp: new Date().toISOString(),
}
}).catch(error => {
logger.error('Failed to send security notification', {
userId,
action: 'email_change_notification',
error: error instanceof Error ? error.message : String(error)
});
});
}
handleSuccess(
'Email change initiated',
'Check both email addresses for confirmation links.'
);
setStep('success');
} catch (error: unknown) {
const errorMsg = getErrorMessage(error);
logger.error('Email change failed', {

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -8,7 +8,6 @@ import { Progress } from '@/components/ui/progress';
import { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useEmailChangeStatus } from '@/hooks/security/useEmailChangeStatus';
interface EmailChangeStatusProps {
currentEmail: string;
@@ -16,19 +15,55 @@ interface EmailChangeStatusProps {
onCancel: () => void;
}
type EmailChangeData = {
has_pending_change: boolean;
current_email?: string;
new_email?: string;
current_email_verified?: boolean;
new_email_verified?: boolean;
change_sent_at?: string;
};
export function EmailChangeStatus({
currentEmail,
pendingEmail,
onCancel
}: EmailChangeStatusProps) {
const [verificationStatus, setVerificationStatus] = useState({
oldEmailVerified: false,
newEmailVerified: false
});
const [loading, setLoading] = useState(true);
const [resending, setResending] = useState(false);
const { data: emailStatus, isLoading } = useEmailChangeStatus();
const verificationStatus = {
oldEmailVerified: emailStatus?.current_email_verified || false,
newEmailVerified: emailStatus?.new_email_verified || false
const checkVerificationStatus = async () => {
try {
const { data, error } = await supabase.rpc('get_email_change_status');
if (error) throw error;
const emailData = data as EmailChangeData;
if (emailData.has_pending_change) {
setVerificationStatus({
oldEmailVerified: emailData.current_email_verified || false,
newEmailVerified: emailData.new_email_verified || false
});
}
} catch (error: unknown) {
handleError(error, { action: 'Check verification status' });
} finally {
setLoading(false);
}
};
useEffect(() => {
checkVerificationStatus();
// Poll every 30 seconds
const interval = setInterval(checkVerificationStatus, 30000);
return () => clearInterval(interval);
}, []);
const handleResendVerification = async () => {
setResending(true);
try {
@@ -53,7 +88,7 @@ export function EmailChangeStatus({
(verificationStatus.oldEmailVerified ? 50 : 0) +
(verificationStatus.newEmailVerified ? 50 : 0);
if (isLoading) {
if (loading) {
return (
<Card className="border-blue-500/30">
<CardContent className="flex items-center justify-center py-8">

View File

@@ -1,225 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Link, Unlink, Shield, AlertCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
getUserIdentities,
disconnectIdentity,
linkOAuthIdentity,
checkDisconnectSafety,
addPasswordToAccount
} from '@/lib/identityService';
import type { UserIdentity, OAuthProvider } from '@/types/identity';
export function IdentityManagement() {
const { toast } = useToast();
const [identities, setIdentities] = useState<UserIdentity[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
loadIdentities();
}, []);
const loadIdentities = async () => {
setLoading(true);
const data = await getUserIdentities();
setIdentities(data);
setLoading(false);
};
const handleDisconnect = async (provider: OAuthProvider) => {
// Safety check
const safety = await checkDisconnectSafety(provider);
if (!safety.canDisconnect) {
toast({
variant: 'destructive',
title: 'Cannot Disconnect',
description: safety.reason === 'last_identity'
? 'This is your only sign-in method. Add a password or another provider first.'
: 'Please add a password before disconnecting your last social login.',
});
return;
}
setActionLoading(provider);
const result = await disconnectIdentity(provider);
if (result.success) {
toast({
title: 'Provider Disconnected',
description: `${provider} has been removed from your account.`,
});
await loadIdentities();
} else if (result.requiresAAL2) {
toast({
variant: 'destructive',
title: 'MFA Required',
description: result.error || 'Please verify your identity with MFA.',
});
} else {
toast({
variant: 'destructive',
title: 'Failed to Disconnect',
description: result.error,
});
}
setActionLoading(null);
};
const handleLink = async (provider: OAuthProvider) => {
setActionLoading(provider);
const result = await linkOAuthIdentity(provider);
if (result.success) {
// OAuth redirect will happen automatically
toast({
title: 'Redirecting...',
description: `Opening ${provider} sign-in window...`,
});
} else {
toast({
variant: 'destructive',
title: 'Failed to Link',
description: result.error,
});
setActionLoading(null);
}
};
const handleAddPassword = async () => {
setActionLoading('password');
const result = await addPasswordToAccount();
if (result.success) {
toast({
title: 'Check Your Email',
description: `We've sent a password setup link to ${result.email}`,
});
} else {
toast({
variant: 'destructive',
title: 'Failed to Add Password',
description: result.error,
});
}
setActionLoading(null);
};
const hasProvider = (provider: string) =>
identities.some(i => i.provider === provider);
const hasPassword = hasProvider('email');
const providers: { id: OAuthProvider; label: string; icon: string }[] = [
{ id: 'google', label: 'Google', icon: 'G' },
{ id: 'discord', label: 'Discord', icon: 'D' },
];
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>Connected Accounts</CardTitle>
<CardDescription>Loading...</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link className="w-5 h-5" />
Connected Accounts
</CardTitle>
<CardDescription>
Link multiple sign-in methods to your account for easy access
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{identities.length === 1 && !hasPassword && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Add a password as a backup sign-in method
</AlertDescription>
</Alert>
)}
{/* Password Authentication */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Shield className="w-5 h-5 text-primary" />
</div>
<div>
<div className="font-medium">Email & Password</div>
<div className="text-sm text-muted-foreground">
{hasPassword ? 'Connected' : 'Not set up'}
</div>
</div>
</div>
{!hasPassword && (
<Button
variant="outline"
size="sm"
onClick={handleAddPassword}
disabled={actionLoading === 'password'}
>
<Link className="w-4 h-4 mr-2" />
{actionLoading === 'password' ? 'Setting up...' : 'Add Password'}
</Button>
)}
</div>
{/* OAuth Providers */}
{providers.map((provider) => {
const isConnected = hasProvider(provider.id);
return (
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center font-bold">
{provider.icon}
</div>
<div>
<div className="font-medium">{provider.label}</div>
<div className="text-sm text-muted-foreground">
{isConnected ? 'Connected' : 'Not connected'}
</div>
</div>
</div>
<Button
variant={isConnected ? 'destructive' : 'outline'}
size="sm"
onClick={() => isConnected
? handleDisconnect(provider.id)
: handleLink(provider.id)
}
disabled={actionLoading === provider.id}
>
{isConnected ? (
<>
<Unlink className="w-4 h-4 mr-2" />
{actionLoading === provider.id ? 'Disconnecting...' : 'Disconnect'}
</>
) : (
<>
<Link className="w-4 h-4 mr-2" />
{actionLoading === provider.id ? 'Connecting...' : 'Connect'}
</>
)}
</Button>
</div>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -13,7 +13,6 @@ import { Skeleton } from '@/components/ui/skeleton';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
import { useProfileLocationMutation } from '@/hooks/profile/useProfileLocationMutation';
import { supabase } from '@/integrations/supabase/client';
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
@@ -31,8 +30,8 @@ export function LocationTab() {
const { user } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences();
const { updateLocation, isUpdating } = useProfileLocationMutation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [parks, setParks] = useState<ParkOption[]>([]);
const [accessibility, setAccessibility] = useState<AccessibilityOptions>(DEFAULT_ACCESSIBILITY_OPTIONS);
@@ -172,11 +171,42 @@ export function LocationTab() {
const onSubmit = async (data: LocationFormData) => {
if (!user) return;
setSaving(true);
try {
const validatedData = locationFormSchema.parse(data);
const validatedAccessibility = accessibilityOptionsSchema.parse(accessibility);
// Update accessibility preferences first
const previousProfile = {
personal_location: profile?.personal_location,
home_park_id: profile?.home_park_id,
timezone: profile?.timezone,
preferred_language: profile?.preferred_language,
preferred_pronouns: profile?.preferred_pronouns
};
const { error: profileError } = await supabase
.from('profiles')
.update({
preferred_pronouns: validatedData.preferred_pronouns || null,
timezone: validatedData.timezone,
preferred_language: validatedData.preferred_language,
personal_location: validatedData.personal_location || null,
home_park_id: validatedData.home_park_id || null,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (profileError) {
logger.error('Failed to update profile', {
userId: user.id,
action: 'update_profile_location',
error: profileError.message,
errorCode: profileError.code
});
throw profileError;
}
const { error: accessibilityError } = await supabase
.from('user_preferences')
.update({
@@ -197,20 +227,34 @@ export function LocationTab() {
await updateUnitPreferences(unitPreferences);
// Update profile via mutation hook with complete validated data
const locationData: LocationFormData = {
personal_location: validatedData.personal_location || null,
home_park_id: validatedData.home_park_id || null,
timezone: validatedData.timezone,
preferred_language: validatedData.preferred_language,
preferred_pronouns: validatedData.preferred_pronouns || null,
};
await supabase.from('profile_audit_log').insert([{
user_id: user.id,
changed_by: user.id,
action: 'location_info_updated',
changes: JSON.parse(JSON.stringify({
previous: {
profile: previousProfile,
accessibility: DEFAULT_ACCESSIBILITY_OPTIONS
},
updated: {
profile: validatedData,
accessibility: validatedAccessibility
},
timestamp: new Date().toISOString()
}))
}]);
updateLocation.mutate(locationData, {
onSuccess: () => {
refreshProfile();
}
await refreshProfile();
logger.info('Location and info settings updated', {
userId: user.id,
action: 'update_location_info'
});
handleSuccess(
'Settings saved',
'Your location, personal information, accessibility, and unit preferences have been updated.'
);
} catch (error: unknown) {
logger.error('Error saving location settings', {
userId: user.id,
@@ -233,6 +277,8 @@ export function LocationTab() {
userId: user.id
});
}
} finally {
setSaving(false);
}
};
@@ -512,8 +558,8 @@ export function LocationTab() {
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save Settings'}
<Button type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save Settings'}
</Button>
</div>
</form>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { usePasswordUpdateMutation } from '@/hooks/security/usePasswordUpdateMutation';
import {
Dialog,
DialogContent,
@@ -45,7 +45,6 @@ function isErrorWithCode(error: unknown): error is Error & ErrorWithCode {
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
const { theme } = useTheme();
const { updatePassword, isUpdating } = usePasswordUpdateMutation();
const [step, setStep] = useState<Step>('password');
const [loading, setLoading] = useState(false);
const [nonce, setNonce] = useState<string>('');
@@ -289,26 +288,62 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
try {
updatePassword.mutate(
{ password, hasMFA, userId },
{
onSuccess: () => {
setStep('success');
form.reset();
// Auto-close after 2 seconds
setTimeout(() => {
onOpenChange(false);
onSuccess();
setStep('password');
setTotpCode('');
}, 2000);
},
onError: (error) => {
throw error;
// Step 2: Update password
const { error: updateError } = await supabase.auth.updateUser({
password
});
if (updateError) throw updateError;
// Step 3: Log audit trail
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await supabase.from('admin_audit_log').insert({
admin_user_id: user.id,
target_user_id: user.id,
action: 'password_changed',
details: {
timestamp: new Date().toISOString(),
method: hasMFA ? 'password_with_mfa' : 'password_only',
user_agent: navigator.userAgent
}
});
// Step 4: Send security notification
try {
await invokeWithTracking(
'trigger-notification',
{
workflowId: 'security-alert',
subscriberId: user.id,
payload: {
alert_type: 'password_changed',
timestamp: new Date().toISOString(),
device: navigator.userAgent.split(' ')[0]
}
},
user.id
);
} catch (notifError) {
logger.error('Failed to send password change notification', {
userId: user!.id,
action: 'password_change_notification',
error: getErrorMessage(notifError)
});
// Don't fail the password update if notification fails
}
);
}
setStep('success');
form.reset();
// Auto-close after 2 seconds
setTimeout(() => {
onOpenChange(false);
onSuccess();
setStep('password');
setTotpCode('');
}, 2000);
} catch (error: unknown) {
throw error;
}

View File

@@ -11,7 +11,6 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { usePrivacyMutations } from '@/hooks/privacy/usePrivacyMutations';
import { supabase } from '@/integrations/supabase/client';
import { Eye, UserX, Shield, Search } from 'lucide-react';
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
@@ -22,7 +21,7 @@ import { z } from 'zod';
export function PrivacyTab() {
const { user } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const { updatePrivacy, isUpdating } = usePrivacyMutations();
const [loading, setLoading] = useState(false);
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
const form = useForm<PrivacyFormData>({
@@ -135,17 +134,106 @@ export function PrivacyTab() {
}
};
const onSubmit = (data: PrivacyFormData) => {
const onSubmit = async (data: PrivacyFormData) => {
if (!user) return;
updatePrivacy.mutate(data, {
onSuccess: () => {
refreshProfile();
// Extract privacy settings (exclude profile fields)
const { privacy_level, show_pronouns, ...privacySettings } = data;
setPreferences(privacySettings);
setLoading(true);
try {
// Validate the form data
const validated = privacyFormSchema.parse(data);
// Update profile privacy settings
const { error: profileError } = await supabase
.from('profiles')
.update({
privacy_level: validated.privacy_level,
show_pronouns: validated.show_pronouns,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (profileError) {
logger.error('Failed to update profile privacy', {
userId: user.id,
action: 'update_profile_privacy',
error: profileError.message,
errorCode: profileError.code
});
throw profileError;
}
});
// Extract privacy settings (exclude profile fields)
const { privacy_level, show_pronouns, ...privacySettings } = validated;
// Update user preferences
const { error: prefsError } = await supabase
.from('user_preferences')
.upsert([{
user_id: user.id,
privacy_settings: privacySettings,
updated_at: new Date().toISOString()
}]);
if (prefsError) {
logger.error('Failed to update privacy preferences', {
userId: user.id,
action: 'update_privacy_preferences',
error: prefsError.message,
errorCode: prefsError.code
});
throw prefsError;
}
// Log to audit trail
await supabase.from('profile_audit_log').insert([{
user_id: user.id,
changed_by: user.id,
action: 'privacy_settings_updated',
changes: JSON.parse(JSON.stringify({
previous: preferences,
updated: privacySettings,
timestamp: new Date().toISOString()
}))
}]);
await refreshProfile();
setPreferences(privacySettings);
logger.info('Privacy settings updated successfully', {
userId: user.id,
action: 'update_privacy_settings'
});
handleSuccess(
'Privacy settings updated',
'Your privacy preferences have been successfully saved.'
);
} catch (error: unknown) {
logger.error('Failed to update privacy settings', {
userId: user.id,
action: 'update_privacy_settings',
error: error instanceof Error ? error.message : String(error)
});
if (error instanceof z.ZodError) {
handleError(
new AppError(
'Invalid privacy settings',
'VALIDATION_ERROR',
error.issues.map(e => e.message).join(', ')
),
{ action: 'Validate privacy settings', userId: user.id }
);
} else {
handleError(error, {
action: 'Update privacy settings',
userId: user.id
});
}
} finally {
setLoading(false);
}
};
return (
@@ -362,8 +450,8 @@ export function PrivacyTab() {
{/* Save Button */}
<div className="flex justify-end">
<Button type="submit" disabled={isUpdating}>
{isUpdating ? 'Saving...' : 'Save Privacy Settings'}
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Privacy Settings'}
</Button>
</div>
</form>

View File

@@ -6,7 +6,6 @@ import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useAuth } from '@/hooks/useAuth';
import { useSecurityMutations } from '@/hooks/security/useSecurityMutations';
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Skeleton } from '@/components/ui/skeleton';
@@ -14,7 +13,6 @@ import { TOTPSetup } from '@/components/auth/TOTPSetup';
import { GoogleIcon } from '@/components/icons/GoogleIcon';
import { DiscordIcon } from '@/components/icons/DiscordIcon';
import { PasswordUpdateDialog } from './PasswordUpdateDialog';
import { useSessions } from '@/hooks/security/useSessions';
import {
getUserIdentities,
checkDisconnectSafety,
@@ -31,21 +29,20 @@ import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog';
export function SecurityTab() {
const { user } = useAuth();
const navigate = useNavigate();
const { revokeSession, isRevoking } = useSecurityMutations();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [identities, setIdentities] = useState<UserIdentity[]>([]);
const [loadingIdentities, setLoadingIdentities] = useState(true);
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
const [hasPassword, setHasPassword] = useState(false);
const [addingPassword, setAddingPassword] = useState(false);
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [loadingSessions, setLoadingSessions] = useState(true);
const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null);
// Fetch sessions using hook
const { data: sessions = [], isLoading: loadingSessions, refetch: refetchSessions } = useSessions(user?.id);
// Load user identities on mount
useEffect(() => {
loadIdentities();
fetchSessions();
}, []);
const loadIdentities = async () => {
@@ -146,6 +143,35 @@ export function SecurityTab() {
setAddingPassword(false);
};
const fetchSessions = async () => {
if (!user) return;
setLoadingSessions(true);
try {
const { data, error } = await supabase.rpc('get_my_sessions');
if (error) {
throw error;
}
setSessions((data as AuthSession[]) || []);
} catch (error: unknown) {
logger.error('Failed to fetch sessions', {
userId: user.id,
action: 'fetch_sessions',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Load active sessions',
userId: user.id
});
setSessions([]);
} finally {
setLoadingSessions(false);
}
};
const initiateSessionRevoke = async (sessionId: string) => {
// Get current session to check if revoking self
const { data: { session: currentSession } } = await supabase.auth.getSession();
@@ -156,23 +182,33 @@ export function SecurityTab() {
setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession });
};
const confirmRevokeSession = () => {
const confirmRevokeSession = async () => {
if (!sessionToRevoke) return;
revokeSession.mutate(
{ sessionId: sessionToRevoke.id, isCurrent: sessionToRevoke.isCurrent },
{
onSuccess: () => {
if (!sessionToRevoke.isCurrent) {
refetchSessions();
}
setSessionToRevoke(null);
},
onError: () => {
setSessionToRevoke(null);
}
const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionToRevoke.id });
if (error) {
logger.error('Failed to revoke session', {
userId: user?.id,
action: 'revoke_session',
sessionId: sessionToRevoke.id,
error: error.message
});
handleError(error, { action: 'Revoke session', userId: user?.id });
} else {
handleSuccess('Success', 'Session revoked successfully');
if (sessionToRevoke.isCurrent) {
// Redirect to login after revoking current session
setTimeout(() => {
window.location.href = '/auth';
}, 1000);
} else {
fetchSessions();
}
);
}
setSessionToRevoke(null);
};
const getDeviceIcon = (userAgent: string | null) => {
@@ -261,77 +297,77 @@ export function SecurityTab() {
</CardContent>
</Card>
{/* Identity Management Section */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5" />
<CardTitle>Connected Accounts</CardTitle>
</div>
<CardDescription>
Manage your social login connections for easier access to your account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{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;
{/* Connected Accounts */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Globe className="w-5 h-5" />
<CardTitle>Connected Accounts</CardTitle>
</div>
<CardDescription>
Manage your social login connections for easier access to your account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{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}
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>
<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>
<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={() => handleUnlinkSocial(account.provider)}
disabled={isDisconnecting}
onClick={() => handleSocialLogin(account.provider)}
>
{isDisconnecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Disconnecting...
</>
) : (
'Disconnect'
)}
Connect
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleSocialLogin(account.provider)}
>
Connect
</Button>
)}
)}
</div>
</div>
</div>
);
})
)}
</CardContent>
</Card>
);
})
)}
</CardContent>
</Card>
</div>
{/* Two-Factor Authentication - Full Width */}