mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
Enable RLS on rate limits table
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user