Enable RLS on rate limits table

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 19:24:54 +00:00
parent fd17234b67
commit 0a325d7c37
11 changed files with 821 additions and 252 deletions

View File

@@ -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<typeof profileSchema>;
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<string>(profile?.avatar_url || '');
const [avatarImageId, setAvatarImageId] = useState<string>(profile?.avatar_image_id || '');
const [showDeletionDialog, setShowDeletionDialog] = useState(false);
const [deletionRequest, setDeletionRequest] = useState<AccountDeletionRequest | null>(null);
// Initialize avatar upload hook
const { avatarUrl, avatarImageId, isUploading: avatarLoading, uploadAvatar } = useAvatarUpload(
profile?.avatar_url || '',
profile?.avatar_image_id || '',
profile?.username || ''
);
const form = useForm<ProfileFormData>({
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' });
}}
/>
</div>
@@ -285,18 +277,41 @@ export function AccountProfileTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="username">Username *</Label>
<Label htmlFor="username">
Username *
{usernameValidation.isChecking && (
<Loader2 className="inline ml-2 w-3 h-3 animate-spin" />
)}
</Label>
<Input
id="username"
{...form.register('username')}
placeholder="Enter your username"
disabled={isDeactivated}
className={cn(
usernameValidation.error && !form.formState.errors.username && "border-destructive",
usernameValidation.isAvailable && "border-green-500"
)}
/>
{form.formState.errors.username && (
{usernameValidation.isChecking ? (
<p className="text-sm text-muted-foreground">
Checking availability...
</p>
) : usernameValidation.isAvailable === false && !form.formState.errors.username ? (
<p className="text-sm text-destructive flex items-center gap-1">
<X className="w-3 h-3" />
{usernameValidation.error}
</p>
) : usernameValidation.isAvailable === true && !form.formState.errors.username ? (
<p className="text-sm text-green-600 flex items-center gap-1">
<Check className="w-3 h-3" />
Username is available
</p>
) : form.formState.errors.username ? (
<p className="text-sm text-destructive">
{form.formState.errors.username.message}
</p>
)}
) : null}
</div>
<div className="space-y-2">
@@ -378,9 +393,26 @@ export function AccountProfileTab() {
)}
</div>
<Button type="submit" disabled={loading || isDeactivated}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
<div className="flex items-center justify-between">
<Button
type="submit"
disabled={
loading ||
isDeactivated ||
isSaving ||
usernameValidation.isChecking ||
usernameValidation.isAvailable === false
}
>
{loading || isSaving ? 'Saving...' : 'Save Changes'}
</Button>
{lastSaved && !loading && !isSaving && (
<span className="text-sm text-muted-foreground">
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
</span>
)}
</div>
{isDeactivated && (
<p className="text-sm text-muted-foreground">
Your account is deactivated. Profile editing is disabled.
@@ -395,26 +427,11 @@ export function AccountProfileTab() {
<h3 className="text-lg font-medium">Account Information</h3>
<div className="space-y-4">
{pendingEmail && (
<Alert className="border-blue-500/20 bg-blue-500/10">
<AlertCircle className="h-4 w-4 text-blue-500" />
<AlertTitle className="text-blue-500 flex items-center justify-between">
<span>Email Change in Progress</span>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCancelEmailDialog(true)}
className="h-auto py-1 px-2 text-blue-500 hover:text-blue-600 hover:bg-blue-500/20"
>
<X className="h-4 w-4 mr-1" />
Cancel Change
</Button>
</AlertTitle>
<AlertDescription className="text-sm text-muted-foreground">
You have a pending email change to <strong>{pendingEmail}</strong>.
Please check both your current email ({user?.email}) and new email ({pendingEmail}) for confirmation links.
Both must be confirmed to complete the change.
</AlertDescription>
</Alert>
<EmailChangeStatus
currentEmail={user?.email || ''}
pendingEmail={pendingEmail}
onCancel={() => setShowCancelEmailDialog(true)}
/>
)}
<div className="p-4 bg-muted/50 rounded-lg space-y-4">