diff --git a/src/components/privacy/BlockedUsers.tsx b/src/components/privacy/BlockedUsers.tsx index 2a2ddd16..25583208 100644 --- a/src/components/privacy/BlockedUsers.tsx +++ b/src/components/privacy/BlockedUsers.tsx @@ -1,153 +1,18 @@ -import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; import { UserX, Trash2 } from 'lucide-react'; -import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/hooks/useAuth'; -import { handleError, handleSuccess } from '@/lib/errorHandler'; -import { logger } from '@/lib/logger'; -import type { UserBlock } from '@/types/privacy'; +import { useBlockedUsers } from '@/hooks/privacy/useBlockedUsers'; +import { useBlockUserMutation } from '@/hooks/privacy/useBlockUserMutation'; export function BlockedUsers() { const { user } = useAuth(); - const [blockedUsers, setBlockedUsers] = useState([]); - const [loading, setLoading] = useState(true); + const { data: blockedUsers = [], isLoading: loading } = useBlockedUsers(user?.id); + const { unblockUser, isUnblocking } = useBlockUserMutation(); - useEffect(() => { - if (user) { - 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 } - }); - } + const handleUnblock = (blockId: string, blockedUserId: string, username: string) => { + unblockUser.mutate({ blockId, blockedUserId, username }); }; if (loading) { @@ -211,7 +76,7 @@ export function BlockedUsers() { - diff --git a/src/components/settings/AccountProfileTab.tsx b/src/components/settings/AccountProfileTab.tsx index cc16f5f6..77fc8738 100644 --- a/src/components/settings/AccountProfileTab.tsx +++ b/src/components/settings/AccountProfileTab.tsx @@ -28,6 +28,7 @@ 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'; @@ -42,7 +43,7 @@ type ProfileFormData = z.infer; export function AccountProfileTab() { const { user, pendingEmail, clearPendingEmail } = useAuth(); const { data: profile, refreshProfile } = useProfile(user?.id); - const [loading, setLoading] = useState(false); + const updateProfileMutation = useProfileUpdateMutation(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showEmailDialog, setShowEmailDialog] = useState(false); const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false); @@ -107,47 +108,28 @@ export function AccountProfileTab() { const handleFormSubmit = async (data: ProfileFormData) => { if (!user) return; - 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.'}` - ); + // 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, + }); } - 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) => { @@ -400,17 +382,17 @@ export function AccountProfileTab() { - {lastSaved && !loading && !isSaving && ( + {lastSaved && !updateProfileMutation.isPending && !isSaving && ( Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })} diff --git a/src/components/settings/EmailChangeDialog.tsx b/src/components/settings/EmailChangeDialog.tsx index 9283629f..9b550e1f 100644 --- a/src/components/settings/EmailChangeDialog.tsx +++ b/src/components/settings/EmailChangeDialog.tsx @@ -2,6 +2,7 @@ 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, @@ -52,6 +53,7 @@ interface EmailChangeDialogProps { export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) { const { theme } = useTheme(); + const { changeEmail, isChanging } = useEmailChangeMutation(); const [step, setStep] = useState('verification'); const [loading, setLoading] = useState(false); const [captchaToken, setCaptchaToken] = useState(''); @@ -156,63 +158,18 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: throw signInError; } - // 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(), + // Step 3: Update email address using mutation hook + changeEmail.mutate( + { newEmail: data.newEmail, currentEmail, userId }, + { + onSuccess: () => { + setStep('success'); + }, + onError: (error) => { + throw error; } - }).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', { diff --git a/src/components/settings/PasswordUpdateDialog.tsx b/src/components/settings/PasswordUpdateDialog.tsx index e0ff3d28..d4ca0317 100644 --- a/src/components/settings/PasswordUpdateDialog.tsx +++ b/src/components/settings/PasswordUpdateDialog.tsx @@ -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,6 +45,7 @@ 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('password'); const [loading, setLoading] = useState(false); const [nonce, setNonce] = useState(''); @@ -288,62 +289,26 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password const updatePasswordWithNonce = async (password: string, nonceValue: string) => { try { - // 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 + 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 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; } diff --git a/src/docs/API_PATTERNS.md b/src/docs/API_PATTERNS.md index 3e363f2d..90490885 100644 --- a/src/docs/API_PATTERNS.md +++ b/src/docs/API_PATTERNS.md @@ -207,12 +207,14 @@ const handleUpdate = () => { ## Component Migration Status ### ✅ Migrated Components -- `SecurityTab.tsx` - Using `useSecurityMutations()` -- `ReportsQueue.tsx` - Using `useReportActionMutation()` -- `PrivacyTab.tsx` - Using `usePrivacyMutations()` -- `LocationTab.tsx` - Using `useProfileLocationMutation()` -- `AccountProfileTab.tsx` - Using `useProfileUpdateMutation()` -- `BlockedUsers.tsx` - Using `useBlockUserMutation()` +- `SecurityTab.tsx` - Using `useSecurityMutations()` ✅ +- `ReportsQueue.tsx` - Using `useReportActionMutation()` ✅ +- `PrivacyTab.tsx` - Using `usePrivacyMutations()` ✅ +- `LocationTab.tsx` - Using `useProfileLocationMutation()` ✅ +- `AccountProfileTab.tsx` - Using `useProfileUpdateMutation()` ✅ +- `BlockedUsers.tsx` - Using `useBlockUserMutation()` and `useBlockedUsers()` ✅ +- `PasswordUpdateDialog.tsx` - Using `usePasswordUpdateMutation()` ✅ +- `EmailChangeDialog.tsx` - Using `useEmailChangeMutation()` ✅ ### 📊 Impact - **100%** of settings mutations now use mutation hooks @@ -220,7 +222,11 @@ const handleUpdate = () => { - **30%** faster perceived load times (optimistic updates) - **10%** fewer API calls (better cache invalidation) - **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 - [ ] Use `useMutation` instead of direct Supabase calls - [ ] Implement `onError` callback with toast notifications @@ -232,27 +238,42 @@ const handleUpdate = () => { - [ ] Test optimistic updates if applicable - [ ] Add audit log creation where appropriate - [ ] Ensure proper type safety with TypeScript +- [ ] Consider creating query hooks for data fetching instead of manual `useEffect` ## Available Mutation Hooks ### Profile & User Management - **`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) - - Features: Optimistic updates, automatic rollback + - Features: Optimistic updates, automatic rollback, rate limiting, Novu sync - **`useProfileLocationMutation`** - Location and personal info updates + - Modifies: `profiles` table and `user_preferences` table - Invalidates: profile, profile stats, audit logs - - Features: Optimistic updates, automatic rollback + - Features: Optimistic updates, automatic rollback, audit logging - **`usePrivacyMutations`** - Privacy settings updates + - Modifies: `profiles` table and `user_preferences` table - Invalidates: profile, audit logs, user search (privacy affects visibility) - - Features: Optimistic updates, automatic rollback + - Features: Optimistic updates, automatic rollback, audit logging ### Security - **`useSecurityMutations`** - Session management - `revokeSession` - Revoke user sessions with automatic redirect for current session + - Modifies: User sessions via `revoke_my_session` RPC - 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 - **`useReportMutation`** - Submit user reports - Invalidates: moderation queue, moderation stats @@ -263,27 +284,27 @@ const handleUpdate = () => { ### Privacy & Blocking - **`useBlockUserMutation`** - Block/unblock users + - Modifies: `user_blocks` table - Invalidates: blocked users list, audit logs - 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 - **`useAuditLogs`** - Query audit logs with pagination and filtering - Features: 2-minute stale time, disabled window focus refetch -### Profile & User Management -- `useProfileUpdateMutation` - Profile updates (username, display name, bio) -- `useProfileLocationMutation` - Location and personal info updates -- `usePrivacyMutations` - Privacy settings updates +## Query Hooks -### Security -- `useSecurityMutations` - Session management (revoke sessions) - -### Moderation -- `useReportMutation` - Submit user reports -- `useReportActionMutation` - Resolve/dismiss reports - -### Admin -- `useAuditLogs` - Query audit logs with pagination +### Privacy +- **`useBlockedUsers`** - Fetch blocked users for the authenticated user + - Queries: `user_blocks` and `profiles` tables + - Features: Automatic caching, refetch on window focus, 5-minute stale time + - Returns: Array of blocked users with profile information --- diff --git a/src/hooks/privacy/useBlockedUsers.ts b/src/hooks/privacy/useBlockedUsers.ts index fdf895c0..938f4acb 100644 --- a/src/hooks/privacy/useBlockedUsers.ts +++ b/src/hooks/privacy/useBlockedUsers.ts @@ -1,52 +1,72 @@ import { useQuery } from '@tanstack/react-query'; import { supabase } from '@/integrations/supabase/client'; -import { useAuth } from '@/hooks/useAuth'; +import { logger } from '@/lib/logger'; import type { UserBlock } from '@/types/privacy'; /** - * Hook for querying blocked users - * Provides: list of blocked users with profile information + * Hook to fetch blocked users for the authenticated user + * Provides: automatic caching, refetch on window focus, and loading states */ -export function useBlockedUsers() { - const { user } = useAuth(); - +export function useBlockedUsers(userId?: string) { return useQuery({ - queryKey: ['blocked-users', user?.id], + queryKey: ['blocked-users', userId], 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 .from('user_blocks') .select('id, blocked_id, reason, created_at') - .eq('blocker_id', user.id) + .eq('blocker_id', userId) .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) { return []; } - // Then get the profile information for blocked users + // Fetch 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) 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 const blockedUsersWithProfiles: UserBlock[] = blocks.map(block => ({ ...block, - blocker_id: user.id, + blocker_id: userId, 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; }, - enabled: !!user, - staleTime: 5 * 60 * 1000, // 5 minutes + enabled: !!userId, + staleTime: 1000 * 60 * 5, // 5 minutes }); } diff --git a/src/hooks/rides/useRideCreditsMutation.ts b/src/hooks/rides/useRideCreditsMutation.ts new file mode 100644 index 00000000..bcdab986 --- /dev/null +++ b/src/hooks/rides/useRideCreditsMutation.ts @@ -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, + }; +} diff --git a/src/hooks/security/useEmailChangeMutation.ts b/src/hooks/security/useEmailChangeMutation.ts new file mode 100644 index 00000000..5facf163 --- /dev/null +++ b/src/hooks/security/useEmailChangeMutation.ts @@ -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, + }; +} diff --git a/src/hooks/security/usePasswordUpdateMutation.ts b/src/hooks/security/usePasswordUpdateMutation.ts new file mode 100644 index 00000000..745422ac --- /dev/null +++ b/src/hooks/security/usePasswordUpdateMutation.ts @@ -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, + }; +}