mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
feat: Implement complete API optimization plan
This commit is contained in:
@@ -1,153 +1,18 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||||
import { UserX, Trash2 } from 'lucide-react';
|
import { UserX, Trash2 } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
import { useBlockedUsers } from '@/hooks/privacy/useBlockedUsers';
|
||||||
import { logger } from '@/lib/logger';
|
import { useBlockUserMutation } from '@/hooks/privacy/useBlockUserMutation';
|
||||||
import type { UserBlock } from '@/types/privacy';
|
|
||||||
|
|
||||||
export function BlockedUsers() {
|
export function BlockedUsers() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [blockedUsers, setBlockedUsers] = useState<UserBlock[]>([]);
|
const { data: blockedUsers = [], isLoading: loading } = useBlockedUsers(user?.id);
|
||||||
const [loading, setLoading] = useState(true);
|
const { unblockUser, isUnblocking } = useBlockUserMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
const handleUnblock = (blockId: string, blockedUserId: string, username: string) => {
|
||||||
if (user) {
|
unblockUser.mutate({ blockId, blockedUserId, username });
|
||||||
fetchBlockedUsers();
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const fetchBlockedUsers = async () => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First get the blocked user IDs
|
|
||||||
const { data: blocks, error: blocksError } = await supabase
|
|
||||||
.from('user_blocks')
|
|
||||||
.select('id, blocked_id, reason, created_at')
|
|
||||||
.eq('blocker_id', user.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
if (blocksError) {
|
|
||||||
logger.error('Failed to fetch user blocks', {
|
|
||||||
userId: user.id,
|
|
||||||
action: 'fetch_blocked_users',
|
|
||||||
error: blocksError.message,
|
|
||||||
errorCode: blocksError.code
|
|
||||||
});
|
|
||||||
throw blocksError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!blocks || blocks.length === 0) {
|
|
||||||
setBlockedUsers([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then get the profile information for blocked users
|
|
||||||
const blockedIds = blocks.map(b => b.blocked_id);
|
|
||||||
const { data: profiles, error: profilesError } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('user_id, username, display_name, avatar_url')
|
|
||||||
.in('user_id', blockedIds);
|
|
||||||
|
|
||||||
if (profilesError) {
|
|
||||||
logger.error('Failed to fetch blocked user profiles', {
|
|
||||||
userId: user.id,
|
|
||||||
action: 'fetch_blocked_user_profiles',
|
|
||||||
error: profilesError.message,
|
|
||||||
errorCode: profilesError.code
|
|
||||||
});
|
|
||||||
throw profilesError;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine the data
|
|
||||||
const blockedUsersWithProfiles = blocks.map(block => ({
|
|
||||||
...block,
|
|
||||||
blocker_id: user.id,
|
|
||||||
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
|
|
||||||
}));
|
|
||||||
|
|
||||||
setBlockedUsers(blockedUsersWithProfiles);
|
|
||||||
|
|
||||||
logger.info('Blocked users fetched successfully', {
|
|
||||||
userId: user.id,
|
|
||||||
action: 'fetch_blocked_users',
|
|
||||||
count: blockedUsersWithProfiles.length
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error('Error fetching blocked users', {
|
|
||||||
userId: user.id,
|
|
||||||
action: 'fetch_blocked_users',
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
|
|
||||||
handleError(error, {
|
|
||||||
action: 'Load blocked users',
|
|
||||||
userId: user.id
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnblock = async (blockId: string, blockedUserId: string, username: string) => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('user_blocks')
|
|
||||||
.delete()
|
|
||||||
.eq('id', blockId);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error('Failed to unblock user', {
|
|
||||||
userId: user.id,
|
|
||||||
action: 'unblock_user',
|
|
||||||
targetUserId: blockedUserId,
|
|
||||||
error: error.message,
|
|
||||||
errorCode: error.code
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log to audit trail
|
|
||||||
await supabase.from('profile_audit_log').insert([{
|
|
||||||
user_id: user.id,
|
|
||||||
changed_by: user.id,
|
|
||||||
action: 'user_unblocked',
|
|
||||||
changes: JSON.parse(JSON.stringify({
|
|
||||||
blocked_user_id: blockedUserId,
|
|
||||||
username,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
}))
|
|
||||||
}]);
|
|
||||||
|
|
||||||
setBlockedUsers(prev => prev.filter(block => block.id !== blockId));
|
|
||||||
|
|
||||||
logger.info('User unblocked successfully', {
|
|
||||||
userId: user.id,
|
|
||||||
action: 'unblock_user',
|
|
||||||
targetUserId: blockedUserId
|
|
||||||
});
|
|
||||||
|
|
||||||
handleSuccess('User unblocked', `You have unblocked @${username}`);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.error('Error unblocking user', {
|
|
||||||
userId: user.id,
|
|
||||||
action: 'unblock_user',
|
|
||||||
targetUserId: blockedUserId,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
});
|
|
||||||
|
|
||||||
handleError(error, {
|
|
||||||
action: 'Unblock user',
|
|
||||||
userId: user.id,
|
|
||||||
metadata: { targetUsername: username }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -211,7 +76,7 @@ export function BlockedUsers() {
|
|||||||
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm" disabled={isUnblocking}>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
Unblock
|
Unblock
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
|||||||
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
|
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
|
||||||
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
|
||||||
import { useAutoSave } from '@/hooks/useAutoSave';
|
import { useAutoSave } from '@/hooks/useAutoSave';
|
||||||
|
import { useProfileUpdateMutation } from '@/hooks/profile/useProfileUpdateMutation';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ type ProfileFormData = z.infer<typeof profileSchema>;
|
|||||||
export function AccountProfileTab() {
|
export function AccountProfileTab() {
|
||||||
const { user, pendingEmail, clearPendingEmail } = useAuth();
|
const { user, pendingEmail, clearPendingEmail } = useAuth();
|
||||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||||
const [loading, setLoading] = useState(false);
|
const updateProfileMutation = useProfileUpdateMutation();
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [showEmailDialog, setShowEmailDialog] = useState(false);
|
const [showEmailDialog, setShowEmailDialog] = useState(false);
|
||||||
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
|
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
|
||||||
@@ -107,47 +108,28 @@ export function AccountProfileTab() {
|
|||||||
const handleFormSubmit = async (data: ProfileFormData) => {
|
const handleFormSubmit = async (data: ProfileFormData) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
setLoading(true);
|
// Update Novu subscriber if username changed (before mutation for optimistic update)
|
||||||
try {
|
const usernameChanged = data.username !== profile?.username;
|
||||||
// Use the update_profile RPC function with server-side validation
|
|
||||||
const { data: result, error } = await supabase.rpc('update_profile', {
|
updateProfileMutation.mutate({
|
||||||
p_username: data.username,
|
userId: user.id,
|
||||||
p_display_name: data.display_name || null,
|
updates: {
|
||||||
p_bio: data.bio || null
|
username: data.username,
|
||||||
});
|
display_name: data.display_name || null,
|
||||||
|
bio: data.bio || null
|
||||||
if (error) {
|
}
|
||||||
// Handle rate limiting error
|
}, {
|
||||||
if (error.code === 'P0001') {
|
onSuccess: async () => {
|
||||||
const resetTime = error.message.match(/Try again at (.+)$/)?.[1];
|
if (usernameChanged && notificationService.isEnabled()) {
|
||||||
throw new AppError(
|
await notificationService.updateSubscriber({
|
||||||
error.message,
|
subscriberId: user.id,
|
||||||
'RATE_LIMIT',
|
email: user.email,
|
||||||
`Too many profile updates. ${resetTime ? 'Try again at ' + new Date(resetTime).toLocaleTimeString() : 'Please wait a few minutes.'}`
|
firstName: data.username,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
await refreshProfile();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// 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) => {
|
const onSubmit = async (data: ProfileFormData) => {
|
||||||
@@ -400,17 +382,17 @@ export function AccountProfileTab() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
loading ||
|
updateProfileMutation.isPending ||
|
||||||
isDeactivated ||
|
isDeactivated ||
|
||||||
isSaving ||
|
isSaving ||
|
||||||
usernameValidation.isChecking ||
|
usernameValidation.isChecking ||
|
||||||
usernameValidation.isAvailable === false
|
usernameValidation.isAvailable === false
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loading || isSaving ? 'Saving...' : 'Save Changes'}
|
{updateProfileMutation.isPending || isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{lastSaved && !loading && !isSaving && (
|
{lastSaved && !updateProfileMutation.isPending && !isSaving && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
|
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { useEmailChangeMutation } from '@/hooks/security/useEmailChangeMutation';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -52,6 +53,7 @@ interface EmailChangeDialogProps {
|
|||||||
|
|
||||||
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
|
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const { changeEmail, isChanging } = useEmailChangeMutation();
|
||||||
const [step, setStep] = useState<Step>('verification');
|
const [step, setStep] = useState<Step>('verification');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [captchaToken, setCaptchaToken] = useState<string>('');
|
const [captchaToken, setCaptchaToken] = useState<string>('');
|
||||||
@@ -156,63 +158,18 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
|
|||||||
throw signInError;
|
throw signInError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Update email address
|
// Step 3: Update email address using mutation hook
|
||||||
// Supabase will send verification emails to both old and new addresses
|
changeEmail.mutate(
|
||||||
const { error: updateError } = await supabase.auth.updateUser({
|
{ newEmail: data.newEmail, currentEmail, userId },
|
||||||
email: data.newEmail
|
{
|
||||||
});
|
onSuccess: () => {
|
||||||
|
setStep('success');
|
||||||
if (updateError) throw updateError;
|
},
|
||||||
|
onError: (error) => {
|
||||||
// Step 4: Novu subscriber will be updated automatically after both emails are confirmed
|
throw error;
|
||||||
// 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) {
|
} catch (error: unknown) {
|
||||||
const errorMsg = getErrorMessage(error);
|
const errorMsg = getErrorMessage(error);
|
||||||
logger.error('Email change failed', {
|
logger.error('Email change failed', {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { usePasswordUpdateMutation } from '@/hooks/security/usePasswordUpdateMutation';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -45,6 +45,7 @@ function isErrorWithCode(error: unknown): error is Error & ErrorWithCode {
|
|||||||
|
|
||||||
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
|
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const { updatePassword, isUpdating } = usePasswordUpdateMutation();
|
||||||
const [step, setStep] = useState<Step>('password');
|
const [step, setStep] = useState<Step>('password');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [nonce, setNonce] = useState<string>('');
|
const [nonce, setNonce] = useState<string>('');
|
||||||
@@ -288,62 +289,26 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
|
|||||||
|
|
||||||
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
|
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
|
||||||
try {
|
try {
|
||||||
// Step 2: Update password
|
updatePassword.mutate(
|
||||||
const { error: updateError } = await supabase.auth.updateUser({
|
{ password, hasMFA, userId },
|
||||||
password
|
{
|
||||||
});
|
onSuccess: () => {
|
||||||
|
setStep('success');
|
||||||
if (updateError) throw updateError;
|
form.reset();
|
||||||
|
|
||||||
// Step 3: Log audit trail
|
// Auto-close after 2 seconds
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
setTimeout(() => {
|
||||||
if (user) {
|
onOpenChange(false);
|
||||||
await supabase.from('admin_audit_log').insert({
|
onSuccess();
|
||||||
admin_user_id: user.id,
|
setStep('password');
|
||||||
target_user_id: user.id,
|
setTotpCode('');
|
||||||
action: 'password_changed',
|
}, 2000);
|
||||||
details: {
|
},
|
||||||
timestamp: new Date().toISOString(),
|
onError: (error) => {
|
||||||
method: hasMFA ? 'password_with_mfa' : 'password_only',
|
throw error;
|
||||||
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) {
|
} catch (error: unknown) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,12 +207,14 @@ const handleUpdate = () => {
|
|||||||
## Component Migration Status
|
## Component Migration Status
|
||||||
|
|
||||||
### ✅ Migrated Components
|
### ✅ Migrated Components
|
||||||
- `SecurityTab.tsx` - Using `useSecurityMutations()`
|
- `SecurityTab.tsx` - Using `useSecurityMutations()` ✅
|
||||||
- `ReportsQueue.tsx` - Using `useReportActionMutation()`
|
- `ReportsQueue.tsx` - Using `useReportActionMutation()` ✅
|
||||||
- `PrivacyTab.tsx` - Using `usePrivacyMutations()`
|
- `PrivacyTab.tsx` - Using `usePrivacyMutations()` ✅
|
||||||
- `LocationTab.tsx` - Using `useProfileLocationMutation()`
|
- `LocationTab.tsx` - Using `useProfileLocationMutation()` ✅
|
||||||
- `AccountProfileTab.tsx` - Using `useProfileUpdateMutation()`
|
- `AccountProfileTab.tsx` - Using `useProfileUpdateMutation()` ✅
|
||||||
- `BlockedUsers.tsx` - Using `useBlockUserMutation()`
|
- `BlockedUsers.tsx` - Using `useBlockUserMutation()` and `useBlockedUsers()` ✅
|
||||||
|
- `PasswordUpdateDialog.tsx` - Using `usePasswordUpdateMutation()` ✅
|
||||||
|
- `EmailChangeDialog.tsx` - Using `useEmailChangeMutation()` ✅
|
||||||
|
|
||||||
### 📊 Impact
|
### 📊 Impact
|
||||||
- **100%** of settings mutations now use mutation hooks
|
- **100%** of settings mutations now use mutation hooks
|
||||||
@@ -220,7 +222,11 @@ const handleUpdate = () => {
|
|||||||
- **30%** faster perceived load times (optimistic updates)
|
- **30%** faster perceived load times (optimistic updates)
|
||||||
- **10%** fewer API calls (better cache invalidation)
|
- **10%** fewer API calls (better cache invalidation)
|
||||||
- **Zero** manual cache invalidation in components
|
- **Zero** manual cache invalidation in components
|
||||||
|
- **Zero** direct Supabase mutations in components
|
||||||
|
|
||||||
|
## Migration Checklist
|
||||||
|
|
||||||
|
When migrating a component:
|
||||||
- [ ] Create custom mutation hook in appropriate directory
|
- [ ] Create custom mutation hook in appropriate directory
|
||||||
- [ ] Use `useMutation` instead of direct Supabase calls
|
- [ ] Use `useMutation` instead of direct Supabase calls
|
||||||
- [ ] Implement `onError` callback with toast notifications
|
- [ ] Implement `onError` callback with toast notifications
|
||||||
@@ -232,27 +238,42 @@ const handleUpdate = () => {
|
|||||||
- [ ] Test optimistic updates if applicable
|
- [ ] Test optimistic updates if applicable
|
||||||
- [ ] Add audit log creation where appropriate
|
- [ ] Add audit log creation where appropriate
|
||||||
- [ ] Ensure proper type safety with TypeScript
|
- [ ] Ensure proper type safety with TypeScript
|
||||||
|
- [ ] Consider creating query hooks for data fetching instead of manual `useEffect`
|
||||||
|
|
||||||
## Available Mutation Hooks
|
## Available Mutation Hooks
|
||||||
|
|
||||||
### Profile & User Management
|
### Profile & User Management
|
||||||
- **`useProfileUpdateMutation`** - Profile updates (username, display name, bio, avatar)
|
- **`useProfileUpdateMutation`** - Profile updates (username, display name, bio, avatar)
|
||||||
|
- Modifies: `profiles` table via `update_profile` RPC
|
||||||
- Invalidates: profile, profile stats, profile activity, user search (if display name/username changed)
|
- Invalidates: profile, profile stats, profile activity, user search (if display name/username changed)
|
||||||
- Features: Optimistic updates, automatic rollback
|
- Features: Optimistic updates, automatic rollback, rate limiting, Novu sync
|
||||||
|
|
||||||
- **`useProfileLocationMutation`** - Location and personal info updates
|
- **`useProfileLocationMutation`** - Location and personal info updates
|
||||||
|
- Modifies: `profiles` table and `user_preferences` table
|
||||||
- Invalidates: profile, profile stats, audit logs
|
- Invalidates: profile, profile stats, audit logs
|
||||||
- Features: Optimistic updates, automatic rollback
|
- Features: Optimistic updates, automatic rollback, audit logging
|
||||||
|
|
||||||
- **`usePrivacyMutations`** - Privacy settings updates
|
- **`usePrivacyMutations`** - Privacy settings updates
|
||||||
|
- Modifies: `profiles` table and `user_preferences` table
|
||||||
- Invalidates: profile, audit logs, user search (privacy affects visibility)
|
- Invalidates: profile, audit logs, user search (privacy affects visibility)
|
||||||
- Features: Optimistic updates, automatic rollback
|
- Features: Optimistic updates, automatic rollback, audit logging
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- **`useSecurityMutations`** - Session management
|
- **`useSecurityMutations`** - Session management
|
||||||
- `revokeSession` - Revoke user sessions with automatic redirect for current session
|
- `revokeSession` - Revoke user sessions with automatic redirect for current session
|
||||||
|
- Modifies: User sessions via `revoke_my_session` RPC
|
||||||
- Invalidates: sessions list, audit logs
|
- Invalidates: sessions list, audit logs
|
||||||
|
|
||||||
|
- **`usePasswordUpdateMutation`** - Password updates
|
||||||
|
- Modifies: User password via Supabase Auth
|
||||||
|
- Invalidates: audit logs
|
||||||
|
- Features: MFA verification, audit logging, security notifications
|
||||||
|
|
||||||
|
- **`useEmailChangeMutation`** - Email address changes
|
||||||
|
- Modifies: User email via Supabase Auth
|
||||||
|
- Invalidates: audit logs
|
||||||
|
- Features: Dual verification emails, audit logging, security notifications
|
||||||
|
|
||||||
### Moderation
|
### Moderation
|
||||||
- **`useReportMutation`** - Submit user reports
|
- **`useReportMutation`** - Submit user reports
|
||||||
- Invalidates: moderation queue, moderation stats
|
- Invalidates: moderation queue, moderation stats
|
||||||
@@ -263,27 +284,27 @@ const handleUpdate = () => {
|
|||||||
|
|
||||||
### Privacy & Blocking
|
### Privacy & Blocking
|
||||||
- **`useBlockUserMutation`** - Block/unblock users
|
- **`useBlockUserMutation`** - Block/unblock users
|
||||||
|
- Modifies: `user_blocks` table
|
||||||
- Invalidates: blocked users list, audit logs
|
- Invalidates: blocked users list, audit logs
|
||||||
- Features: Automatic audit logging
|
- Features: Automatic audit logging
|
||||||
|
|
||||||
|
### Ride Credits
|
||||||
|
- **`useRideCreditsMutation`** - Reorder ride credits
|
||||||
|
- Modifies: User ride credits via `reorder_ride_credit` RPC
|
||||||
|
- Invalidates: ride credits cache
|
||||||
|
- Features: Optimistic drag-drop updates
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
- **`useAuditLogs`** - Query audit logs with pagination and filtering
|
- **`useAuditLogs`** - Query audit logs with pagination and filtering
|
||||||
- Features: 2-minute stale time, disabled window focus refetch
|
- Features: 2-minute stale time, disabled window focus refetch
|
||||||
|
|
||||||
### Profile & User Management
|
## Query Hooks
|
||||||
- `useProfileUpdateMutation` - Profile updates (username, display name, bio)
|
|
||||||
- `useProfileLocationMutation` - Location and personal info updates
|
|
||||||
- `usePrivacyMutations` - Privacy settings updates
|
|
||||||
|
|
||||||
### Security
|
### Privacy
|
||||||
- `useSecurityMutations` - Session management (revoke sessions)
|
- **`useBlockedUsers`** - Fetch blocked users for the authenticated user
|
||||||
|
- Queries: `user_blocks` and `profiles` tables
|
||||||
### Moderation
|
- Features: Automatic caching, refetch on window focus, 5-minute stale time
|
||||||
- `useReportMutation` - Submit user reports
|
- Returns: Array of blocked users with profile information
|
||||||
- `useReportActionMutation` - Resolve/dismiss reports
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
- `useAuditLogs` - Query audit logs with pagination
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +1,72 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { logger } from '@/lib/logger';
|
||||||
import type { UserBlock } from '@/types/privacy';
|
import type { UserBlock } from '@/types/privacy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for querying blocked users
|
* Hook to fetch blocked users for the authenticated user
|
||||||
* Provides: list of blocked users with profile information
|
* Provides: automatic caching, refetch on window focus, and loading states
|
||||||
*/
|
*/
|
||||||
export function useBlockedUsers() {
|
export function useBlockedUsers(userId?: string) {
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['blocked-users', user?.id],
|
queryKey: ['blocked-users', userId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!user) return [];
|
if (!userId) throw new Error('User ID required');
|
||||||
|
|
||||||
// First get the blocked user IDs
|
// Fetch blocked user IDs
|
||||||
const { data: blocks, error: blocksError } = await supabase
|
const { data: blocks, error: blocksError } = await supabase
|
||||||
.from('user_blocks')
|
.from('user_blocks')
|
||||||
.select('id, blocked_id, reason, created_at')
|
.select('id, blocked_id, reason, created_at')
|
||||||
.eq('blocker_id', user.id)
|
.eq('blocker_id', userId)
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (blocksError) throw blocksError;
|
if (blocksError) {
|
||||||
|
logger.error('Failed to fetch user blocks', {
|
||||||
|
userId,
|
||||||
|
action: 'fetch_blocked_users',
|
||||||
|
error: blocksError.message,
|
||||||
|
errorCode: blocksError.code
|
||||||
|
});
|
||||||
|
throw blocksError;
|
||||||
|
}
|
||||||
|
|
||||||
if (!blocks || blocks.length === 0) {
|
if (!blocks || blocks.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then get the profile information for blocked users
|
// Fetch profile information for blocked users
|
||||||
const blockedIds = blocks.map(b => b.blocked_id);
|
const blockedIds = blocks.map(b => b.blocked_id);
|
||||||
const { data: profiles, error: profilesError } = await supabase
|
const { data: profiles, error: profilesError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('user_id, username, display_name, avatar_url')
|
.select('user_id, username, display_name, avatar_url')
|
||||||
.in('user_id', blockedIds);
|
.in('user_id', blockedIds);
|
||||||
|
|
||||||
if (profilesError) throw profilesError;
|
if (profilesError) {
|
||||||
|
logger.error('Failed to fetch blocked user profiles', {
|
||||||
|
userId,
|
||||||
|
action: 'fetch_blocked_user_profiles',
|
||||||
|
error: profilesError.message,
|
||||||
|
errorCode: profilesError.code
|
||||||
|
});
|
||||||
|
throw profilesError;
|
||||||
|
}
|
||||||
|
|
||||||
// Combine the data
|
// Combine the data
|
||||||
const blockedUsersWithProfiles: UserBlock[] = blocks.map(block => ({
|
const blockedUsersWithProfiles: UserBlock[] = blocks.map(block => ({
|
||||||
...block,
|
...block,
|
||||||
blocker_id: user.id,
|
blocker_id: userId,
|
||||||
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
|
blocked_profile: profiles?.find(p => p.user_id === block.blocked_id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
logger.info('Blocked users fetched successfully', {
|
||||||
|
userId,
|
||||||
|
action: 'fetch_blocked_users',
|
||||||
|
count: blockedUsersWithProfiles.length
|
||||||
|
});
|
||||||
|
|
||||||
return blockedUsersWithProfiles;
|
return blockedUsersWithProfiles;
|
||||||
},
|
},
|
||||||
enabled: !!user,
|
enabled: !!userId,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/hooks/rides/useRideCreditsMutation.ts
Normal file
50
src/hooks/rides/useRideCreditsMutation.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||||
|
|
||||||
|
interface ReorderCreditParams {
|
||||||
|
creditId: string;
|
||||||
|
newPosition: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for ride credits mutations
|
||||||
|
* Provides: reorder ride credits with automatic cache invalidation
|
||||||
|
*/
|
||||||
|
export function useRideCreditsMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { invalidateRideDetail } = useQueryInvalidation();
|
||||||
|
|
||||||
|
const reorderCredit = useMutation({
|
||||||
|
mutationFn: async ({ creditId, newPosition }: ReorderCreditParams) => {
|
||||||
|
const { error } = await supabase.rpc('reorder_ride_credit', {
|
||||||
|
p_credit_id: creditId,
|
||||||
|
p_new_position: newPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
return { creditId, newPosition };
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error("Reorder Failed", {
|
||||||
|
description: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate ride credits queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['ride-credits'] });
|
||||||
|
|
||||||
|
toast.success("Order Updated", {
|
||||||
|
description: "Ride credit order has been saved.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reorderCredit,
|
||||||
|
isReordering: reorderCredit.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
83
src/hooks/security/useEmailChangeMutation.ts
Normal file
83
src/hooks/security/useEmailChangeMutation.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||||
|
import { notificationService } from '@/lib/notificationService';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
interface EmailChangeParams {
|
||||||
|
newEmail: string;
|
||||||
|
currentEmail: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for email change mutations
|
||||||
|
* Provides: email changes with automatic audit logging and cache invalidation
|
||||||
|
*/
|
||||||
|
export function useEmailChangeMutation() {
|
||||||
|
const { invalidateAuditLogs } = useQueryInvalidation();
|
||||||
|
|
||||||
|
const changeEmail = useMutation({
|
||||||
|
mutationFn: async ({ newEmail, currentEmail, userId }: EmailChangeParams) => {
|
||||||
|
// Update email address
|
||||||
|
const { error: updateError } = await supabase.auth.updateUser({
|
||||||
|
email: newEmail
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
// Log the email change attempt
|
||||||
|
await supabase.from('admin_audit_log').insert({
|
||||||
|
admin_user_id: userId,
|
||||||
|
target_user_id: userId,
|
||||||
|
action: 'email_change_initiated',
|
||||||
|
details: {
|
||||||
|
old_email: currentEmail,
|
||||||
|
new_email: newEmail,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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: 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)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { newEmail };
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error("Update Failed", {
|
||||||
|
description: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (_data, { userId }) => {
|
||||||
|
invalidateAuditLogs(userId);
|
||||||
|
|
||||||
|
toast.success("Email Change Initiated", {
|
||||||
|
description: "Check both email addresses for confirmation links.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
changeEmail,
|
||||||
|
isChanging: changeEmail.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
87
src/hooks/security/usePasswordUpdateMutation.ts
Normal file
87
src/hooks/security/usePasswordUpdateMutation.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getErrorMessage } from '@/lib/errorHandler';
|
||||||
|
import { useQueryInvalidation } from '@/lib/queryInvalidation';
|
||||||
|
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
interface PasswordUpdateParams {
|
||||||
|
password: string;
|
||||||
|
hasMFA: boolean;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for password update mutations
|
||||||
|
* Provides: password updates with automatic audit logging and cache invalidation
|
||||||
|
*/
|
||||||
|
export function usePasswordUpdateMutation() {
|
||||||
|
const { invalidateAuditLogs } = useQueryInvalidation();
|
||||||
|
|
||||||
|
const updatePassword = useMutation({
|
||||||
|
mutationFn: async ({ password, hasMFA, userId }: PasswordUpdateParams) => {
|
||||||
|
// Update password
|
||||||
|
const { error: updateError } = await supabase.auth.updateUser({
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateError) throw updateError;
|
||||||
|
|
||||||
|
// Log audit trail
|
||||||
|
await supabase.from('admin_audit_log').insert({
|
||||||
|
admin_user_id: userId,
|
||||||
|
target_user_id: userId,
|
||||||
|
action: 'password_changed',
|
||||||
|
details: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
method: hasMFA ? 'password_with_mfa' : 'password_only',
|
||||||
|
user_agent: navigator.userAgent
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send security notification (non-blocking)
|
||||||
|
try {
|
||||||
|
await invokeWithTracking(
|
||||||
|
'trigger-notification',
|
||||||
|
{
|
||||||
|
workflowId: 'security-alert',
|
||||||
|
subscriberId: userId,
|
||||||
|
payload: {
|
||||||
|
alert_type: 'password_changed',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
device: navigator.userAgent.split(' ')[0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
} catch (notifError) {
|
||||||
|
logger.error('Failed to send password change notification', {
|
||||||
|
userId,
|
||||||
|
action: 'password_change_notification',
|
||||||
|
error: getErrorMessage(notifError)
|
||||||
|
});
|
||||||
|
// Don't fail the password update if notification fails
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
toast.error("Update Failed", {
|
||||||
|
description: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: (_data, { userId }) => {
|
||||||
|
invalidateAuditLogs(userId);
|
||||||
|
|
||||||
|
toast.success("Password Updated", {
|
||||||
|
description: "Your password has been successfully changed.",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatePassword,
|
||||||
|
isUpdating: updatePassword.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user