feat: Implement complete API optimization plan

This commit is contained in:
gpt-engineer-app[bot]
2025-10-31 12:28:24 +00:00
parent 631ce9c89e
commit ca9aa757ae
9 changed files with 363 additions and 333 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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', {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { usePasswordUpdateMutation } from '@/hooks/security/usePasswordUpdateMutation';
import {
Dialog,
DialogContent,
@@ -45,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;
}