mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-29 08:07:06 -05:00
Compare commits
2 Commits
631ce9c89e
...
c70c5a4150
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c70c5a4150 | ||
|
|
ca9aa757ae |
@@ -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<UserBlock[]>([]);
|
||||
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() {
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Button variant="outline" size="sm" disabled={isUnblocking}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
Unblock
|
||||
</Button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RideCreditFilters } from './RideCreditFilters';
|
||||
import { UserRideCredit } from '@/types/database';
|
||||
import { useRideCreditFilters } from '@/hooks/useRideCreditFilters';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { useRideCreditsMutation } from '@/hooks/rides/useRideCreditsMutation';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -39,6 +40,7 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { reorderCredit, isReordering } = useRideCreditsMutation();
|
||||
|
||||
// Use the filter hook
|
||||
const {
|
||||
@@ -246,24 +248,16 @@ export function RideCreditsManager({ userId }: RideCreditsManagerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReorder = async (creditId: string, newPosition: number) => {
|
||||
try {
|
||||
const { error } = await supabase.rpc('reorder_ride_credit', {
|
||||
p_credit_id: creditId,
|
||||
p_new_position: newPosition
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// No refetch - optimistic update is already applied
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Reorder Ride Credit',
|
||||
userId,
|
||||
metadata: { creditId, newPosition }
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const handleReorder = (creditId: string, newPosition: number) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
reorderCredit.mutate(
|
||||
{ creditId, newPosition },
|
||||
{
|
||||
onSuccess: () => resolve(),
|
||||
onError: (error) => reject(error)
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
|
||||
@@ -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<typeof profileSchema>;
|
||||
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() {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
loading ||
|
||||
updateProfileMutation.isPending ||
|
||||
isDeactivated ||
|
||||
isSaving ||
|
||||
usernameValidation.isChecking ||
|
||||
usernameValidation.isAvailable === false
|
||||
}
|
||||
>
|
||||
{loading || isSaving ? 'Saving...' : 'Save Changes'}
|
||||
{updateProfileMutation.isPending || isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
|
||||
{lastSaved && !loading && !isSaving && (
|
||||
{lastSaved && !updateProfileMutation.isPending && !isSaving && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
|
||||
</span>
|
||||
|
||||
@@ -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<Step>('verification');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string>('');
|
||||
@@ -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', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { 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,6 +8,7 @@ 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;
|
||||
@@ -15,55 +16,19 @@ 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 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);
|
||||
}
|
||||
const verificationStatus = {
|
||||
oldEmailVerified: emailStatus?.current_email_verified || false,
|
||||
newEmailVerified: emailStatus?.new_email_verified || false
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkVerificationStatus();
|
||||
// Poll every 30 seconds
|
||||
const interval = setInterval(checkVerificationStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setResending(true);
|
||||
try {
|
||||
@@ -88,7 +53,7 @@ export function EmailChangeStatus({
|
||||
(verificationStatus.oldEmailVerified ? 50 : 0) +
|
||||
(verificationStatus.newEmailVerified ? 50 : 0);
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-blue-500/30">
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
|
||||
@@ -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<Step>('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nonce, setNonce] = useState<string>('');
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -37,14 +38,14 @@ export function SecurityTab() {
|
||||
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 () => {
|
||||
@@ -145,35 +146,6 @@ 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();
|
||||
@@ -192,7 +164,7 @@ export function SecurityTab() {
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (!sessionToRevoke.isCurrent) {
|
||||
fetchSessions();
|
||||
refetchSessions();
|
||||
}
|
||||
setSessionToRevoke(null);
|
||||
},
|
||||
|
||||
@@ -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,62 @@ 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
|
||||
|
||||
### 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
|
||||
|
||||
### Security
|
||||
- `useSecurityMutations` - Session management (revoke sessions)
|
||||
- **`useEmailChangeStatus`** - Query email change verification status
|
||||
- Queries: `get_email_change_status` RPC function
|
||||
- Features: Automatic polling every 30 seconds, 15-second stale time
|
||||
- Returns: Email change status with verification flags
|
||||
|
||||
### Moderation
|
||||
- `useReportMutation` - Submit user reports
|
||||
- `useReportActionMutation` - Resolve/dismiss reports
|
||||
- **`useSessions`** - Fetch active user sessions
|
||||
- Queries: `get_my_sessions` RPC function
|
||||
- Features: Automatic caching, refetch on window focus, 5-minute stale time
|
||||
- Returns: Array of active sessions with device info
|
||||
|
||||
### Admin
|
||||
- `useAuditLogs` - Query audit logs with pagination
|
||||
---
|
||||
|
||||
## Type Safety Guidelines
|
||||
|
||||
Always use proper TypeScript types in hooks:
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Define proper interfaces
|
||||
interface Profile {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<Profile>(['profile', userId], (old) =>
|
||||
old ? { ...old, ...updates } : old
|
||||
);
|
||||
|
||||
// ❌ WRONG - Using any type
|
||||
queryClient.setQueryData(['profile', userId], (old: any) => ({
|
||||
...old,
|
||||
...updates
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,8 +57,22 @@ export function useHomepageRecentChanges(
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
interface DatabaseRecentChange {
|
||||
entity_id: string;
|
||||
entity_name: string;
|
||||
entity_type: string;
|
||||
entity_slug: string;
|
||||
park_slug?: string;
|
||||
image_url?: string;
|
||||
change_type: string;
|
||||
changed_at: string;
|
||||
changed_by_username?: string;
|
||||
changed_by_avatar?: string;
|
||||
change_reason?: string;
|
||||
}
|
||||
|
||||
// Transform the database response to match our interface
|
||||
const result: RecentChange[] = (data || []).map((item: any) => ({
|
||||
const result: RecentChange[] = (data as unknown as DatabaseRecentChange[] || []).map((item) => ({
|
||||
id: item.entity_id,
|
||||
name: item.entity_name,
|
||||
type: item.entity_type as 'park' | 'ride' | 'company',
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,15 +67,22 @@ export function usePrivacyMutations() {
|
||||
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
|
||||
|
||||
// Snapshot current value
|
||||
const previousProfile = queryClient.getQueryData(['profile', user?.id]);
|
||||
interface Profile {
|
||||
privacy_level?: string;
|
||||
show_pronouns?: boolean;
|
||||
}
|
||||
|
||||
const previousProfile = queryClient.getQueryData<Profile>(['profile', user?.id]);
|
||||
|
||||
// Optimistically update cache
|
||||
if (previousProfile) {
|
||||
queryClient.setQueryData(['profile', user?.id], (old: any) => ({
|
||||
...old,
|
||||
privacy_level: newData.privacy_level,
|
||||
show_pronouns: newData.show_pronouns,
|
||||
}));
|
||||
queryClient.setQueryData<Profile>(['profile', user?.id], (old) =>
|
||||
old ? {
|
||||
...old,
|
||||
privacy_level: newData.privacy_level,
|
||||
show_pronouns: newData.show_pronouns,
|
||||
} : old
|
||||
);
|
||||
}
|
||||
|
||||
return { previousProfile };
|
||||
|
||||
@@ -166,13 +166,28 @@ export function useProfileActivity(
|
||||
photoItemsMap.get(item.photo_submission_id)!.push(item);
|
||||
});
|
||||
|
||||
interface DatabaseEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const entityMap = new Map<string, EntityData>([
|
||||
...parks.map((p: any): [string, EntityData] => [p.id, p]),
|
||||
...rides.map((r: any): [string, EntityData] => [r.id, r])
|
||||
...parks.map((p: DatabaseEntity): [string, EntityData] => [p.id, p]),
|
||||
...rides.map((r: DatabaseEntity): [string, EntityData] => [r.id, r])
|
||||
]);
|
||||
|
||||
interface PhotoSubmissionWithAllFields {
|
||||
id: string;
|
||||
photo_count?: number;
|
||||
photo_preview?: string;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
// Enrich submissions
|
||||
photoSubmissions.forEach((sub: any) => {
|
||||
photoSubmissions.forEach((sub: PhotoSubmissionWithAllFields) => {
|
||||
const photoSub = photoSubMap.get(sub.id);
|
||||
if (photoSub) {
|
||||
const items = photoItemsMap.get(photoSub.id) || [];
|
||||
|
||||
@@ -64,18 +64,26 @@ export function useProfileLocationMutation() {
|
||||
await queryClient.cancelQueries({ queryKey: ['profile', user?.id] });
|
||||
|
||||
// Snapshot current value
|
||||
const previousProfile = queryClient.getQueryData(['profile', user?.id]);
|
||||
interface Profile {
|
||||
personal_location?: string;
|
||||
home_park_id?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
const previousProfile = queryClient.getQueryData<Profile>(['profile', user?.id]);
|
||||
|
||||
// Optimistically update cache
|
||||
if (previousProfile) {
|
||||
queryClient.setQueryData(['profile', user?.id], (old: any) => ({
|
||||
...old,
|
||||
personal_location: newData.personal_location,
|
||||
home_park_id: newData.home_park_id,
|
||||
timezone: newData.timezone,
|
||||
preferred_language: newData.preferred_language,
|
||||
preferred_pronouns: newData.preferred_pronouns,
|
||||
}));
|
||||
queryClient.setQueryData<Profile>(['profile', user?.id], (old) =>
|
||||
old ? {
|
||||
...old,
|
||||
personal_location: newData.personal_location,
|
||||
home_park_id: newData.home_park_id,
|
||||
timezone: newData.timezone,
|
||||
preferred_language: newData.preferred_language,
|
||||
preferred_pronouns: newData.preferred_pronouns,
|
||||
} : old
|
||||
);
|
||||
}
|
||||
|
||||
return { previousProfile };
|
||||
|
||||
@@ -37,14 +37,20 @@ export function useProfileUpdateMutation() {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['profile', userId] });
|
||||
|
||||
interface Profile {
|
||||
display_name?: string;
|
||||
bio?: string;
|
||||
location_id?: string;
|
||||
website?: string;
|
||||
}
|
||||
|
||||
// Snapshot previous value
|
||||
const previousProfile = queryClient.getQueryData(['profile', userId]);
|
||||
const previousProfile = queryClient.getQueryData<Profile>(['profile', userId]);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData(['profile', userId], (old: any) => ({
|
||||
...old,
|
||||
...updates,
|
||||
}));
|
||||
queryClient.setQueryData<Profile>(['profile', userId], (old) =>
|
||||
old ? { ...old, ...updates } : old
|
||||
);
|
||||
|
||||
return { previousProfile, userId };
|
||||
},
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
38
src/hooks/security/useEmailChangeStatus.ts
Normal file
38
src/hooks/security/useEmailChangeStatus.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export interface EmailChangeStatus {
|
||||
has_pending_change: boolean;
|
||||
current_email?: string;
|
||||
new_email?: string;
|
||||
current_email_verified?: boolean;
|
||||
new_email_verified?: boolean;
|
||||
change_sent_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query email change verification status
|
||||
* Provides: automatic polling every 30 seconds, cache management, loading states
|
||||
*/
|
||||
export function useEmailChangeStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['email-change-status'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_email_change_status');
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to fetch email change status', {
|
||||
action: 'fetch_email_change_status',
|
||||
error: error.message,
|
||||
errorCode: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as EmailChangeStatus;
|
||||
},
|
||||
refetchInterval: 30000, // Poll every 30 seconds
|
||||
staleTime: 15000, // 15 seconds
|
||||
});
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
34
src/hooks/security/useSessions.ts
Normal file
34
src/hooks/security/useSessions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { AuthSession } from '@/types/auth';
|
||||
|
||||
/**
|
||||
* Hook to fetch active user sessions
|
||||
* Provides: automatic caching, refetch on window focus, loading states
|
||||
*/
|
||||
export function useSessions(userId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['sessions', userId],
|
||||
queryFn: async () => {
|
||||
if (!userId) throw new Error('User ID required');
|
||||
|
||||
const { data, error } = await supabase.rpc('get_my_sessions');
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to fetch sessions', {
|
||||
userId,
|
||||
action: 'fetch_sessions',
|
||||
error: error.message,
|
||||
errorCode: error.code
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (data as AuthSession[]) || [];
|
||||
},
|
||||
enabled: !!userId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
@@ -93,7 +93,14 @@ export function useEntityVersions(entityType: EntityType, entityId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
const versionsWithProfiles = (data || []).map((v: any) => ({
|
||||
interface DatabaseVersion {
|
||||
profiles?: {
|
||||
username?: string;
|
||||
display_name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const versionsWithProfiles = (data as DatabaseVersion[] || []).map((v) => ({
|
||||
...v,
|
||||
profiles: v.profiles || {
|
||||
username: 'Unknown',
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useRideCreditFilters(credits: UserRideCredit[]) {
|
||||
const [filters, setFilters] = useState<RideCreditFilters>({});
|
||||
const debouncedSearchQuery = useDebounce(filters.searchQuery || '', 300);
|
||||
|
||||
const updateFilter = useCallback((key: keyof RideCreditFilters, value: any) => {
|
||||
const updateFilter = useCallback((key: keyof RideCreditFilters, value: RideCreditFilters[typeof key]) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user