From 0a325d7c37c5193c2dcb6f613c04bf61c29f085b Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 19:24:54 +0000 Subject: [PATCH] Enable RLS on rate limits table --- src/components/admin/ProfileAuditLog.tsx | 85 +++++++ .../settings/AccountDeletionDialog.tsx | 143 ++++++------ src/components/settings/AccountProfileTab.tsx | 207 ++++++++++-------- src/components/settings/EmailChangeStatus.tsx | 193 ++++++++++++++++ src/components/settings/SecurityTab.tsx | 112 +++------- src/hooks/useAutoSave.ts | 58 +++++ src/hooks/useAvatarUpload.ts | 84 +++++++ src/lib/deletionDialogMachine.ts | 89 ++++++++ src/lib/errorHandler.ts | 63 ++++++ src/lib/logger.ts | 14 +- ...5_138c9c48-dea6-4914-9d4a-bbc8ecc951ce.sql | 25 +++ 11 files changed, 821 insertions(+), 252 deletions(-) create mode 100644 src/components/admin/ProfileAuditLog.tsx create mode 100644 src/components/settings/EmailChangeStatus.tsx create mode 100644 src/hooks/useAutoSave.ts create mode 100644 src/hooks/useAvatarUpload.ts create mode 100644 src/lib/deletionDialogMachine.ts create mode 100644 src/lib/errorHandler.ts create mode 100644 supabase/migrations/20251014191945_138c9c48-dea6-4914-9d4a-bbc8ecc951ce.sql diff --git a/src/components/admin/ProfileAuditLog.tsx b/src/components/admin/ProfileAuditLog.tsx new file mode 100644 index 00000000..2cd85b41 --- /dev/null +++ b/src/components/admin/ProfileAuditLog.tsx @@ -0,0 +1,85 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Loader2 } from 'lucide-react'; +import { format } from 'date-fns'; +import { handleError } from '@/lib/errorHandler'; + +export function ProfileAuditLog() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchAuditLogs(); + }, []); + + const fetchAuditLogs = async () => { + try { + const { data, error } = await supabase + .from('profile_audit_log') + .select(` + *, + profiles!user_id(username, display_name) + `) + .order('created_at', { ascending: false }) + .limit(50); + + if (error) throw error; + setLogs(data || []); + } catch (error) { + handleError(error, { action: 'Load audit logs' }); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( + + + Profile Audit Log + + + + + + User + Action + Changes + Date + + + + {logs.map((log) => ( + + + {log.profiles?.display_name || log.profiles?.username || 'Unknown'} + + + {log.action} + + +
{JSON.stringify(log.changes, null, 2)}
+
+ + {format(new Date(log.created_at), 'PPpp')} + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/settings/AccountDeletionDialog.tsx b/src/components/settings/AccountDeletionDialog.tsx index 45cc344e..b78a419c 100644 --- a/src/components/settings/AccountDeletionDialog.tsx +++ b/src/components/settings/AccountDeletionDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useReducer } from 'react'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -6,8 +6,15 @@ import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { supabase } from '@/integrations/supabase/client'; -import { useToast } from '@/hooks/use-toast'; import { Loader2, AlertTriangle, Info } from 'lucide-react'; +import { + deletionDialogReducer, + initialState, + canProceedToConfirm, + canRequestDeletion, + canConfirmDeletion +} from '@/lib/deletionDialogMachine'; +import { handleError, handleSuccess } from '@/lib/errorHandler'; interface AccountDeletionDialogProps { open: boolean; @@ -17,15 +24,13 @@ interface AccountDeletionDialogProps { } export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletionRequested }: AccountDeletionDialogProps) { - const [step, setStep] = useState<'warning' | 'confirm' | 'code'>('warning'); - const [loading, setLoading] = useState(false); - const [confirmationCode, setConfirmationCode] = useState(''); - const [codeReceived, setCodeReceived] = useState(false); - const [scheduledDate, setScheduledDate] = useState(''); - const { toast } = useToast(); + const [state, dispatch] = useReducer(deletionDialogReducer, initialState); const handleRequestDeletion = async () => { - setLoading(true); + if (!canRequestDeletion(state)) return; + + dispatch({ type: 'SET_LOADING', payload: true }); + try { const { data, error } = await supabase.functions.invoke('request-account-deletion', { body: {}, @@ -33,63 +38,52 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio if (error) throw error; - setScheduledDate(data.scheduled_deletion_at); - setStep('code'); + dispatch({ + type: 'REQUEST_DELETION', + payload: { scheduledDate: data.scheduled_deletion_at } + }); onDeletionRequested(); - toast({ - title: 'Deletion Requested', - description: 'Check your email for the confirmation code.', - }); - } catch (error: any) { - toast({ - variant: 'destructive', - title: 'Error', - description: error.message || 'Failed to request account deletion', - }); - } finally { - setLoading(false); + handleSuccess('Deletion Requested', 'Check your email for the confirmation code.'); + } catch (error) { + handleError(error, { action: 'Request account deletion' }); + dispatch({ type: 'SET_ERROR', payload: 'Failed to request deletion' }); } }; const handleConfirmDeletion = async () => { - if (!confirmationCode || confirmationCode.length !== 6) { - toast({ - variant: 'destructive', - title: 'Invalid Code', - description: 'Please enter a 6-digit confirmation code', - }); + if (!canConfirmDeletion(state)) { + handleError( + new Error('Please enter a 6-digit confirmation code and confirm you received it'), + { action: 'Confirm deletion' } + ); return; } - setLoading(true); + dispatch({ type: 'SET_LOADING', payload: true }); + try { const { error } = await supabase.functions.invoke('confirm-account-deletion', { - body: { confirmation_code: confirmationCode }, + body: { confirmation_code: state.confirmationCode }, }); if (error) throw error; - toast({ - title: 'Deletion Confirmed', - description: 'Your account has been deactivated and scheduled for permanent deletion.', - }); + handleSuccess( + 'Deletion Confirmed', + 'Your account has been deactivated and scheduled for permanent deletion.' + ); // Refresh the page to show the deletion banner window.location.reload(); - } catch (error: any) { - toast({ - variant: 'destructive', - title: 'Error', - description: error.message || 'Failed to confirm deletion', - }); - } finally { - setLoading(false); + } catch (error) { + handleError(error, { action: 'Confirm account deletion' }); } }; const handleResendCode = async () => { - setLoading(true); + dispatch({ type: 'SET_LOADING', payload: true }); + try { const { error } = await supabase.functions.invoke('resend-deletion-code', { body: {}, @@ -97,25 +91,16 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio if (error) throw error; - toast({ - title: 'Code Resent', - description: 'A new confirmation code has been sent to your email.', - }); - } catch (error: any) { - toast({ - variant: 'destructive', - title: 'Error', - description: error.message || 'Failed to resend code', - }); + handleSuccess('Code Resent', 'A new confirmation code has been sent to your email.'); + } catch (error) { + handleError(error, { action: 'Resend deletion code' }); } finally { - setLoading(false); + dispatch({ type: 'SET_LOADING', payload: false }); } }; const handleClose = () => { - setStep('warning'); - setConfirmationCode(''); - setCodeReceived(false); + dispatch({ type: 'RESET' }); onOpenChange(false); }; @@ -128,7 +113,7 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio Delete Account - {step === 'warning' && ( + {state.step === 'warning' && ( <> @@ -165,7 +150,7 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio )} - {step === 'confirm' && ( + {state.step === 'confirm' && ( Are you absolutely sure? You'll receive a confirmation code via email. After confirming with the code, your account will be deactivated and scheduled for deletion in 2 weeks. @@ -173,13 +158,13 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio )} - {step === 'code' && ( + {state.step === 'code' && (
A 6-digit confirmation code has been sent to {userEmail}. Enter it below within 24 hours to confirm deletion. Your account will be deactivated and scheduled for deletion on{' '} - {scheduledDate ? new Date(scheduledDate).toLocaleDateString() : '14 days from confirmation'}. + {state.scheduledDate ? new Date(state.scheduledDate).toLocaleDateString() : '14 days from confirmation'}. @@ -191,8 +176,8 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio type="text" placeholder="000000" maxLength={6} - value={confirmationCode} - onChange={(e) => setConfirmationCode(e.target.value.replace(/\D/g, ''))} + value={state.confirmationCode} + onChange={(e) => dispatch({ type: 'UPDATE_CODE', payload: { code: e.target.value } })} className="text-center text-2xl tracking-widest" />
@@ -200,8 +185,8 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
setCodeReceived(checked === true)} + checked={state.codeReceived} + onCheckedChange={() => dispatch({ type: 'TOGGLE_CODE_RECEIVED' })} />
@@ -222,34 +207,38 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
- {step === 'warning' && ( + {state.step === 'warning' && ( <> - )} - {step === 'confirm' && ( + {state.step === 'confirm' && ( <> - )} - {step === 'code' && ( + {state.step === 'code' && ( <> diff --git a/src/components/settings/AccountProfileTab.tsx b/src/components/settings/AccountProfileTab.tsx index f11ffd35..78069875 100644 --- a/src/components/settings/AccountProfileTab.tsx +++ b/src/components/settings/AccountProfileTab.tsx @@ -10,22 +10,27 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; -import { useToast } from '@/hooks/use-toast'; import { useAuth } from '@/hooks/useAuth'; import { useProfile } from '@/hooks/useProfile'; import { supabase } from '@/integrations/supabase/client'; -import { User, Upload, Trash2, Mail, AlertCircle, X } from 'lucide-react'; +import { User, Upload, Trash2, Mail, AlertCircle, X, Check, Loader2 } from 'lucide-react'; import { PhotoUpload } from '@/components/upload/PhotoUpload'; import { notificationService } from '@/lib/notificationService'; import { EmailChangeDialog } from './EmailChangeDialog'; import { Badge } from '@/components/ui/badge'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { toast as sonnerToast } from 'sonner'; import { AccountDeletionDialog } from './AccountDeletionDialog'; import { DeletionStatusBanner } from './DeletionStatusBanner'; +import { EmailChangeStatus } from './EmailChangeStatus'; import { usernameSchema, displayNameSchema, bioSchema, personalLocationSchema, preferredPronounsSchema } from '@/lib/validation'; import { z } from 'zod'; import { AccountDeletionRequest } from '@/types/database'; +import { handleError, handleSuccess, AppError } from '@/lib/errorHandler'; +import { useAvatarUpload } from '@/hooks/useAvatarUpload'; +import { useUsernameValidation } from '@/hooks/useUsernameValidation'; +import { useAutoSave } from '@/hooks/useAutoSave'; +import { formatDistanceToNow } from 'date-fns'; +import { cn } from '@/lib/utils'; const profileSchema = z.object({ username: usernameSchema, @@ -42,18 +47,21 @@ type ProfileFormData = z.infer; export function AccountProfileTab() { const { user, pendingEmail, clearPendingEmail } = useAuth(); const { data: profile, refreshProfile } = useProfile(user?.id); - const { toast } = useToast(); const [loading, setLoading] = useState(false); - const [avatarLoading, setAvatarLoading] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showEmailDialog, setShowEmailDialog] = useState(false); const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false); const [cancellingEmail, setCancellingEmail] = useState(false); - const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || ''); - const [avatarImageId, setAvatarImageId] = useState(profile?.avatar_image_id || ''); const [showDeletionDialog, setShowDeletionDialog] = useState(false); const [deletionRequest, setDeletionRequest] = useState(null); + // Initialize avatar upload hook + const { avatarUrl, avatarImageId, isUploading: avatarLoading, uploadAvatar } = useAvatarUpload( + profile?.avatar_url || '', + profile?.avatar_image_id || '', + profile?.username || '' + ); + const form = useForm({ resolver: zodResolver(profileSchema), defaultValues: { @@ -67,6 +75,24 @@ export function AccountProfileTab() { } }); + // Username validation + const usernameValue = form.watch('username'); + const usernameValidation = useUsernameValidation(usernameValue, profile?.username); + + // Auto-save (disabled for now - can be enabled per user preference) + const formData = form.watch(); + const { isSaving, lastSaved } = useAutoSave({ + data: formData, + onSave: async (data) => { + const isValid = await form.trigger(); + if (!isValid || usernameValidation.isAvailable === false) return; + await handleFormSubmit(data); + }, + debounceMs: 3000, + enabled: false, // TODO: Get from user preferences + isValid: usernameValidation.isAvailable !== false + }); + // Check for existing deletion request on mount useEffect(() => { const checkDeletionRequest = async () => { @@ -87,7 +113,7 @@ export function AccountProfileTab() { checkDeletionRequest(); }, [user?.id]); - const onSubmit = async (data: ProfileFormData) => { + const handleFormSubmit = async (data: ProfileFormData) => { if (!user) return; setLoading(true); @@ -98,18 +124,27 @@ export function AccountProfileTab() { p_display_name: data.display_name || null, p_bio: data.bio || null, p_preferred_pronouns: data.preferred_pronouns || null, - p_show_pronouns: data.show_pronouns, - p_preferred_language: data.preferred_language, p_personal_location: data.personal_location || null }); - if (error) throw error; + 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.'}` + ); + } + throw error; + } // Type the RPC result - const rpcResult = result as unknown as { success: boolean; username_changed: boolean; changes_count: number }; + const rpcResult = result as unknown as { success: boolean; changes_count: number }; // Update Novu subscriber if username changed - if (rpcResult?.username_changed && notificationService.isEnabled()) { + if (rpcResult?.changes_count > 0 && notificationService.isEnabled()) { await notificationService.updateSubscriber({ subscriberId: user.id, email: user.email, @@ -118,56 +153,21 @@ export function AccountProfileTab() { } await refreshProfile(); - toast({ - title: 'Profile updated', - description: 'Your profile has been successfully updated.' - }); - } catch (error: any) { - toast({ - title: 'Error', - description: error.message || 'Failed to update profile', - variant: 'destructive' - }); + handleSuccess('Profile updated', 'Your profile has been successfully updated.'); + } catch (error) { + handleError(error, { action: 'Update profile', userId: user.id }); } finally { setLoading(false); } }; + const onSubmit = async (data: ProfileFormData) => { + await handleFormSubmit(data); + }; + const handleAvatarUpload = async (urls: string[], imageId?: string) => { - if (!user || !urls[0]) return; - - const newAvatarUrl = urls[0]; - const newImageId = imageId || ''; - - // Update local state immediately for optimistic UI - setAvatarUrl(newAvatarUrl); - setAvatarImageId(newImageId); - - try { - // Use update_profile RPC for avatar updates - const { error } = await supabase.rpc('update_profile', { - p_username: profile?.username || '', - p_avatar_url: newAvatarUrl, - p_avatar_image_id: newImageId - }); - - if (error) throw error; - - await refreshProfile(); - toast({ - title: 'Avatar updated', - description: 'Your avatar has been successfully updated.' - }); - } catch (error: any) { - // Revert local state on error - setAvatarUrl(profile?.avatar_url || ''); - setAvatarImageId(profile?.avatar_image_id || ''); - toast({ - title: 'Error', - description: error.message || 'Failed to update avatar', - variant: 'destructive' - }); - } + await uploadAvatar(urls, imageId); + await refreshProfile(); }; const handleCancelEmailChange = async () => { @@ -212,16 +212,12 @@ export function AccountProfileTab() { }); } - sonnerToast.success('Email change cancelled', { - description: 'Your email change request has been cancelled.' - }); + handleSuccess('Email change cancelled', 'Your email change request has been cancelled.'); setShowCancelEmailDialog(false); await refreshProfile(); - } catch (error: any) { - sonnerToast.error('Failed to cancel email change', { - description: error.message || 'An error occurred while cancelling the email change.' - }); + } catch (error) { + handleError(error, { action: 'Cancel email change' }); } finally { setCancellingEmail(false); } @@ -268,11 +264,7 @@ export function AccountProfileTab() { onUploadComplete={handleAvatarUpload} currentImageId={avatarImageId} onError={(error) => { - toast({ - title: "Upload Error", - description: error, - variant: "destructive" - }); + handleError(new Error(error), { action: 'Upload avatar' }); }} /> @@ -285,18 +277,41 @@ export function AccountProfileTab() {
- + - {form.formState.errors.username && ( + {usernameValidation.isChecking ? ( +

+ Checking availability... +

+ ) : usernameValidation.isAvailable === false && !form.formState.errors.username ? ( +

+ + {usernameValidation.error} +

+ ) : usernameValidation.isAvailable === true && !form.formState.errors.username ? ( +

+ + Username is available +

+ ) : form.formState.errors.username ? (

{form.formState.errors.username.message}

- )} + ) : null}
@@ -378,9 +393,26 @@ export function AccountProfileTab() { )}
- +
+ + + {lastSaved && !loading && !isSaving && ( + + Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })} + + )} +
{isDeactivated && (

Your account is deactivated. Profile editing is disabled. @@ -395,26 +427,11 @@ export function AccountProfileTab() {

Account Information

{pendingEmail && ( - - - - Email Change in Progress - - - - You have a pending email change to {pendingEmail}. - Please check both your current email ({user?.email}) and new email ({pendingEmail}) for confirmation links. - Both must be confirmed to complete the change. - - + setShowCancelEmailDialog(true)} + /> )}
diff --git a/src/components/settings/EmailChangeStatus.tsx b/src/components/settings/EmailChangeStatus.tsx new file mode 100644 index 00000000..a311d792 --- /dev/null +++ b/src/components/settings/EmailChangeStatus.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +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'; + +interface EmailChangeStatusProps { + currentEmail: string; + pendingEmail: string; + 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 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) { + handleError(error, { action: 'Check verification status' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + checkVerificationStatus(); + // Poll every 30 seconds + const interval = setInterval(checkVerificationStatus, 30000); + return () => clearInterval(interval); + }, []); + + const handleResendVerification = async () => { + setResending(true); + try { + const { error } = await supabase.auth.updateUser({ + email: pendingEmail + }); + + if (error) throw error; + + handleSuccess( + 'Verification emails resent', + 'Check your inbox for the verification links.' + ); + } catch (error) { + handleError(error, { action: 'Resend verification emails' }); + } finally { + setResending(false); + } + }; + + const verificationProgress = + (verificationStatus.oldEmailVerified ? 50 : 0) + + (verificationStatus.newEmailVerified ? 50 : 0); + + if (loading) { + return ( + + + + + + ); + } + + return ( + + + + + Email Change in Progress + + + + + + + To complete your email change, both emails must be verified. + + + + {/* Progress indicator */} +
+
+ {verificationStatus.oldEmailVerified ? ( + + ) : ( + + )} +
+

Current Email

+

{currentEmail}

+
+ {verificationStatus.oldEmailVerified && ( + + Verified + + )} +
+ + + +
+ {verificationStatus.newEmailVerified ? ( + + ) : ( + + )} +
+

New Email

+

{pendingEmail}

+
+ {verificationStatus.newEmailVerified && ( + + Verified + + )} +
+
+ + {/* Action buttons */} +
+ + +
+ + {/* Progress bar */} +
+
+ Progress + {verificationProgress}% +
+ +
+
+
+ ); +} diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index b686f157..bf48a024 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Badge } from '@/components/ui/badge'; -import { useToast } from '@/hooks/use-toast'; +import { handleError, handleSuccess } from '@/lib/errorHandler'; import { useAuth } from '@/hooks/useAuth'; import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react'; import { format } from 'date-fns'; @@ -20,7 +20,6 @@ import { addPasswordToAccount } from '@/lib/identityService'; import type { UserIdentity, OAuthProvider } from '@/types/identity'; -import { toast as sonnerToast } from '@/components/ui/sonner'; import { supabase } from '@/integrations/supabase/client'; interface AuthSession { @@ -36,7 +35,6 @@ interface AuthSession { export function SecurityTab() { const { user } = useAuth(); - const { toast } = useToast(); const navigate = useNavigate(); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [identities, setIdentities] = useState([]); @@ -64,11 +62,7 @@ export function SecurityTab() { setHasPassword(hasEmailProvider); } catch (error) { console.error('Failed to load identities:', error); - toast({ - title: 'Error', - description: 'Failed to load connected accounts', - variant: 'destructive', - }); + handleError(error, { action: 'Load connected accounts' }); } finally { setLoadingIdentities(false); } @@ -79,32 +73,15 @@ export function SecurityTab() { const result = await connectIdentity(provider, '/settings?tab=security'); if (!result.success) { - // Handle rate limiting - if (result.error?.includes('rate limit')) { - toast({ - title: 'Too Many Attempts', - description: 'Please wait a few minutes before trying again.', - variant: 'destructive' - }); - } else { - toast({ - title: 'Connection Failed', - description: result.error, - variant: 'destructive' - }); - } + handleError( + new Error(result.error || 'Connection failed'), + { action: `Connect ${provider} account` } + ); } else { - toast({ - title: 'Redirecting...', - description: `Connecting your ${provider} account...` - }); + handleSuccess('Redirecting...', `Connecting your ${provider} account...`); } - } catch (error: any) { - toast({ - title: 'Connection Error', - description: error.message || 'Failed to connect account', - variant: 'destructive' - }); + } catch (error) { + handleError(error, { action: `Connect ${provider} account` }); } }; @@ -116,20 +93,18 @@ export function SecurityTab() { if (safetyCheck.reason === 'no_password_backup') { // Trigger password reset flow first await handleAddPassword(); - toast({ - title: "Password Required First", - description: "Check your email for a password reset link. Once you've set a password, you can disconnect your social login.", - duration: 10000 - }); + handleSuccess( + "Password Required First", + "Check your email for a password reset link. Once you've set a password, you can disconnect your social login." + ); return; } if (safetyCheck.reason === 'last_identity') { - toast({ - title: "Cannot Disconnect", - description: "You cannot disconnect your only login method. Please add another authentication method first.", - variant: "destructive" - }); + handleError( + new Error("You cannot disconnect your only login method"), + { action: "Disconnect social login" } + ); return; } } @@ -141,16 +116,12 @@ export function SecurityTab() { if (result.success) { await loadIdentities(); // Refresh identities list - toast({ - title: "Disconnected", - description: `Your ${provider} account has been successfully disconnected.` - }); + handleSuccess("Disconnected", `Your ${provider} account has been successfully disconnected.`); } else { - toast({ - title: "Disconnect Failed", - description: result.error, - variant: "destructive" - }); + handleError( + new Error(result.error || 'Disconnect failed'), + { action: `Disconnect ${provider} account` } + ); } }; @@ -160,16 +131,15 @@ export function SecurityTab() { const result = await addPasswordToAccount(); if (result.success) { - sonnerToast.success("Password Setup Email Sent!", { - description: `Check ${result.email} for a password reset link. Click it to set your password.`, - duration: 15000, - }); + handleSuccess( + "Password Setup Email Sent!", + `Check ${result.email} for a password reset link. Click it to set your password.` + ); } else { - toast({ - title: "Failed to Send Email", - description: result.error, - variant: "destructive" - }); + handleError( + new Error(result.error || 'Failed to send email'), + { action: 'Send password setup email' } + ); } setAddingPassword(false); @@ -182,11 +152,7 @@ export function SecurityTab() { if (error) { console.error('Error fetching sessions:', error); - toast({ - title: 'Error', - description: 'Failed to load sessions', - variant: 'destructive' - }); + handleError(error, { action: 'Load sessions' }); } else { setSessions(data || []); } @@ -197,16 +163,9 @@ export function SecurityTab() { const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionId }); if (error) { - toast({ - title: 'Error', - description: 'Failed to revoke session', - variant: 'destructive' - }); + handleError(error, { action: 'Revoke session' }); } else { - toast({ - title: 'Success', - description: 'Session revoked successfully' - }); + handleSuccess('Success', 'Session revoked successfully'); fetchSessions(); } }; @@ -254,10 +213,7 @@ export function SecurityTab() { open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen} onSuccess={() => { - toast({ - title: 'Password updated', - description: 'Your password has been successfully changed.' - }); + handleSuccess('Password updated', 'Your password has been successfully changed.'); }} /> diff --git a/src/hooks/useAutoSave.ts b/src/hooks/useAutoSave.ts new file mode 100644 index 00000000..332d5003 --- /dev/null +++ b/src/hooks/useAutoSave.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'react'; +import { useDebounce } from './useDebounce'; + +export type AutoSaveOptions = { + data: T; + onSave: (data: T) => Promise; + debounceMs?: number; + enabled?: boolean; + isValid?: boolean; +}; + +export const useAutoSave = ({ + data, + onSave, + debounceMs = 3000, + enabled = true, + isValid = true +}: AutoSaveOptions) => { + const [isSaving, setIsSaving] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [error, setError] = useState(null); + const debouncedData = useDebounce(data, debounceMs); + const initialRender = useRef(true); + + useEffect(() => { + // Skip initial render + if (initialRender.current) { + initialRender.current = false; + return; + } + + // Skip if disabled or invalid + if (!enabled || !isValid) return; + + const save = async () => { + setIsSaving(true); + setError(null); + + try { + await onSave(debouncedData); + setLastSaved(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to auto-save'); + } finally { + setIsSaving(false); + } + }; + + save(); + }, [debouncedData, enabled, isValid, onSave]); + + return { + isSaving, + lastSaved, + error, + resetError: () => setError(null) + }; +}; diff --git a/src/hooks/useAvatarUpload.ts b/src/hooks/useAvatarUpload.ts new file mode 100644 index 00000000..189fd771 --- /dev/null +++ b/src/hooks/useAvatarUpload.ts @@ -0,0 +1,84 @@ +import { useState, useCallback } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { handleError, handleSuccess } from '@/lib/errorHandler'; + +export type AvatarUploadState = { + url: string; + imageId: string; + isUploading: boolean; +}; + +export const useAvatarUpload = ( + initialUrl: string = '', + initialImageId: string = '', + username: string +) => { + const [state, setState] = useState({ + url: initialUrl, + imageId: initialImageId, + isUploading: false + }); + + const uploadAvatar = useCallback(async ( + urls: string[], + imageId?: string + ) => { + if (!urls[0]) return { success: false }; + + const newUrl = urls[0]; + const newImageId = imageId || ''; + + // Optimistic update + setState(prev => ({ + ...prev, + url: newUrl, + imageId: newImageId, + isUploading: true + })); + + try { + const { error } = await supabase.rpc('update_profile', { + p_username: username, + p_avatar_url: newUrl, + p_avatar_image_id: newImageId + }); + + if (error) throw error; + + setState(prev => ({ ...prev, isUploading: false })); + handleSuccess('Avatar updated', 'Your avatar has been successfully updated.'); + + return { success: true }; + } catch (error) { + // Rollback on error + setState({ + url: initialUrl, + imageId: initialImageId, + isUploading: false + }); + + handleError(error, { + action: 'Avatar upload failed', + metadata: { username } + }); + + return { success: false, error }; + } + }, [username, initialUrl, initialImageId]); + + const resetAvatar = useCallback(() => { + setState({ + url: initialUrl, + imageId: initialImageId, + isUploading: false + }); + }, [initialUrl, initialImageId]); + + return { + avatarUrl: state.url, + avatarImageId: state.imageId, + isUploading: state.isUploading, + uploadAvatar, + resetAvatar + }; +}; diff --git a/src/lib/deletionDialogMachine.ts b/src/lib/deletionDialogMachine.ts new file mode 100644 index 00000000..c4a9d2b8 --- /dev/null +++ b/src/lib/deletionDialogMachine.ts @@ -0,0 +1,89 @@ +export type DeletionStep = 'warning' | 'confirm' | 'code'; + +export type DeletionDialogState = { + step: DeletionStep; + confirmationCode: string; + codeReceived: boolean; + scheduledDate: string; + isLoading: boolean; + error: string | null; +}; + +export type DeletionDialogAction = + | { type: 'CONTINUE_TO_CONFIRM' } + | { type: 'GO_BACK_TO_WARNING' } + | { type: 'REQUEST_DELETION'; payload: { scheduledDate: string } } + | { type: 'UPDATE_CODE'; payload: { code: string } } + | { type: 'TOGGLE_CODE_RECEIVED' } + | { type: 'SET_LOADING'; payload: boolean } + | { type: 'SET_ERROR'; payload: string | null } + | { type: 'RESET' }; + +export const initialState: DeletionDialogState = { + step: 'warning', + confirmationCode: '', + codeReceived: false, + scheduledDate: '', + isLoading: false, + error: null +}; + +export function deletionDialogReducer( + state: DeletionDialogState, + action: DeletionDialogAction +): DeletionDialogState { + switch (action.type) { + case 'CONTINUE_TO_CONFIRM': + return { ...state, step: 'confirm' }; + + case 'GO_BACK_TO_WARNING': + return { ...state, step: 'warning', error: null }; + + case 'REQUEST_DELETION': + return { + ...state, + step: 'code', + scheduledDate: action.payload.scheduledDate, + isLoading: false, + error: null + }; + + case 'UPDATE_CODE': + // Only allow digits, max 6 + const sanitized = action.payload.code.replace(/\D/g, '').slice(0, 6); + return { ...state, confirmationCode: sanitized }; + + case 'TOGGLE_CODE_RECEIVED': + return { ...state, codeReceived: !state.codeReceived }; + + case 'SET_LOADING': + return { ...state, isLoading: action.payload }; + + case 'SET_ERROR': + return { ...state, error: action.payload, isLoading: false }; + + case 'RESET': + return initialState; + + default: + return state; + } +} + +// Validation helpers +export const canProceedToConfirm = (state: DeletionDialogState): boolean => { + return state.step === 'warning' && !state.isLoading; +}; + +export const canRequestDeletion = (state: DeletionDialogState): boolean => { + return state.step === 'confirm' && !state.isLoading; +}; + +export const canConfirmDeletion = (state: DeletionDialogState): boolean => { + return ( + state.step === 'code' && + state.confirmationCode.length === 6 && + state.codeReceived && + !state.isLoading + ); +}; diff --git a/src/lib/errorHandler.ts b/src/lib/errorHandler.ts new file mode 100644 index 00000000..ebf83ef3 --- /dev/null +++ b/src/lib/errorHandler.ts @@ -0,0 +1,63 @@ +import { toast } from 'sonner'; +import { logger } from './logger'; + +export type ErrorContext = { + action: string; + userId?: string; + metadata?: Record; +}; + +export class AppError extends Error { + constructor( + message: string, + public code: string, + public userMessage?: string + ) { + super(message); + this.name = 'AppError'; + } +} + +export const handleError = ( + error: unknown, + context: ErrorContext +): void => { + const errorMessage = error instanceof AppError + ? error.userMessage || error.message + : error instanceof Error + ? error.message + : 'An unexpected error occurred'; + + // Log to console/monitoring + logger.error('Error occurred', { + ...context, + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + + // Show user-friendly toast + toast.error(context.action, { + description: errorMessage, + duration: 5000 + }); +}; + +export const handleSuccess = ( + title: string, + description?: string +): void => { + toast.success(title, { + description, + duration: 3000 + }); +}; + +export const handleInfo = ( + title: string, + description?: string +): void => { + toast.info(title, { + description, + duration: 4000 + }); +}; diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 558b6714..c2559158 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -7,14 +7,24 @@ const isDev = import.meta.env.DEV; +type LogContext = { + [key: string]: any; +}; + export const logger = { log: (...args: any[]) => { if (isDev) console.log(...args); }, - error: (...args: any[]) => { - console.error(...args); // Always log errors + error: (message: string, context?: LogContext) => { + console.error(message, context); // Always log errors }, warn: (...args: any[]) => { if (isDev) console.warn(...args); }, + info: (...args: any[]) => { + if (isDev) console.info(...args); + }, + debug: (...args: any[]) => { + if (isDev) console.debug(...args); + } }; diff --git a/supabase/migrations/20251014191945_138c9c48-dea6-4914-9d4a-bbc8ecc951ce.sql b/supabase/migrations/20251014191945_138c9c48-dea6-4914-9d4a-bbc8ecc951ce.sql new file mode 100644 index 00000000..8c59171d --- /dev/null +++ b/supabase/migrations/20251014191945_138c9c48-dea6-4914-9d4a-bbc8ecc951ce.sql @@ -0,0 +1,25 @@ +-- Enable RLS on rate_limits table +ALTER TABLE public.rate_limits ENABLE ROW LEVEL SECURITY; + +-- Users can only view their own rate limits +CREATE POLICY "Users can view their own rate limits" + ON public.rate_limits FOR SELECT + TO authenticated + USING (user_id = auth.uid()); + +-- System can manage rate limits (handled by check_rate_limit function) +CREATE POLICY "System can insert rate limits" + ON public.rate_limits FOR INSERT + TO authenticated + WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "System can update rate limits" + ON public.rate_limits FOR UPDATE + TO authenticated + USING (user_id = auth.uid()); + +-- Allow cleanup of old records +CREATE POLICY "System can delete old rate limits" + ON public.rate_limits FOR DELETE + TO authenticated + USING (window_start < now() - interval '24 hours'); \ No newline at end of file