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

@@ -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<any[]>([]);
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 (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Profile Audit Log</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Changes</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell>
{log.profiles?.display_name || log.profiles?.username || 'Unknown'}
</TableCell>
<TableCell>
<Badge variant="secondary">{log.action}</Badge>
</TableCell>
<TableCell>
<pre className="text-xs">{JSON.stringify(log.changes, null, 2)}</pre>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{format(new Date(log.created_at), 'PPpp')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -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 { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -6,8 +6,15 @@ import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast';
import { Loader2, AlertTriangle, Info } from 'lucide-react'; import { Loader2, AlertTriangle, Info } from 'lucide-react';
import {
deletionDialogReducer,
initialState,
canProceedToConfirm,
canRequestDeletion,
canConfirmDeletion
} from '@/lib/deletionDialogMachine';
import { handleError, handleSuccess } from '@/lib/errorHandler';
interface AccountDeletionDialogProps { interface AccountDeletionDialogProps {
open: boolean; open: boolean;
@@ -17,15 +24,13 @@ interface AccountDeletionDialogProps {
} }
export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletionRequested }: AccountDeletionDialogProps) { export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletionRequested }: AccountDeletionDialogProps) {
const [step, setStep] = useState<'warning' | 'confirm' | 'code'>('warning'); const [state, dispatch] = useReducer(deletionDialogReducer, initialState);
const [loading, setLoading] = useState(false);
const [confirmationCode, setConfirmationCode] = useState('');
const [codeReceived, setCodeReceived] = useState(false);
const [scheduledDate, setScheduledDate] = useState<string>('');
const { toast } = useToast();
const handleRequestDeletion = async () => { const handleRequestDeletion = async () => {
setLoading(true); if (!canRequestDeletion(state)) return;
dispatch({ type: 'SET_LOADING', payload: true });
try { try {
const { data, error } = await supabase.functions.invoke('request-account-deletion', { const { data, error } = await supabase.functions.invoke('request-account-deletion', {
body: {}, body: {},
@@ -33,63 +38,52 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
if (error) throw error; if (error) throw error;
setScheduledDate(data.scheduled_deletion_at); dispatch({
setStep('code'); type: 'REQUEST_DELETION',
payload: { scheduledDate: data.scheduled_deletion_at }
});
onDeletionRequested(); onDeletionRequested();
toast({ handleSuccess('Deletion Requested', 'Check your email for the confirmation code.');
title: 'Deletion Requested', } catch (error) {
description: 'Check your email for the confirmation code.', handleError(error, { action: 'Request account deletion' });
}); dispatch({ type: 'SET_ERROR', payload: 'Failed to request deletion' });
} catch (error: any) {
toast({
variant: 'destructive',
title: 'Error',
description: error.message || 'Failed to request account deletion',
});
} finally {
setLoading(false);
} }
}; };
const handleConfirmDeletion = async () => { const handleConfirmDeletion = async () => {
if (!confirmationCode || confirmationCode.length !== 6) { if (!canConfirmDeletion(state)) {
toast({ handleError(
variant: 'destructive', new Error('Please enter a 6-digit confirmation code and confirm you received it'),
title: 'Invalid Code', { action: 'Confirm deletion' }
description: 'Please enter a 6-digit confirmation code', );
});
return; return;
} }
setLoading(true); dispatch({ type: 'SET_LOADING', payload: true });
try { try {
const { error } = await supabase.functions.invoke('confirm-account-deletion', { const { error } = await supabase.functions.invoke('confirm-account-deletion', {
body: { confirmation_code: confirmationCode }, body: { confirmation_code: state.confirmationCode },
}); });
if (error) throw error; if (error) throw error;
toast({ handleSuccess(
title: 'Deletion Confirmed', 'Deletion Confirmed',
description: 'Your account has been deactivated and scheduled for permanent deletion.', 'Your account has been deactivated and scheduled for permanent deletion.'
}); );
// Refresh the page to show the deletion banner // Refresh the page to show the deletion banner
window.location.reload(); window.location.reload();
} catch (error: any) { } catch (error) {
toast({ handleError(error, { action: 'Confirm account deletion' });
variant: 'destructive',
title: 'Error',
description: error.message || 'Failed to confirm deletion',
});
} finally {
setLoading(false);
} }
}; };
const handleResendCode = async () => { const handleResendCode = async () => {
setLoading(true); dispatch({ type: 'SET_LOADING', payload: true });
try { try {
const { error } = await supabase.functions.invoke('resend-deletion-code', { const { error } = await supabase.functions.invoke('resend-deletion-code', {
body: {}, body: {},
@@ -97,25 +91,16 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
if (error) throw error; if (error) throw error;
toast({ handleSuccess('Code Resent', 'A new confirmation code has been sent to your email.');
title: 'Code Resent', } catch (error) {
description: 'A new confirmation code has been sent to your email.', handleError(error, { action: 'Resend deletion code' });
});
} catch (error: any) {
toast({
variant: 'destructive',
title: 'Error',
description: error.message || 'Failed to resend code',
});
} finally { } finally {
setLoading(false); dispatch({ type: 'SET_LOADING', payload: false });
} }
}; };
const handleClose = () => { const handleClose = () => {
setStep('warning'); dispatch({ type: 'RESET' });
setConfirmationCode('');
setCodeReceived(false);
onOpenChange(false); onOpenChange(false);
}; };
@@ -128,7 +113,7 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
Delete Account Delete Account
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-left space-y-4"> <AlertDialogDescription className="text-left space-y-4">
{step === 'warning' && ( {state.step === 'warning' && (
<> <>
<Alert> <Alert>
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
@@ -165,7 +150,7 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
</> </>
)} )}
{step === 'confirm' && ( {state.step === 'confirm' && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription> <AlertDescription>
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. 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
</Alert> </Alert>
)} )}
{step === 'code' && ( {state.step === 'code' && (
<div className="space-y-4"> <div className="space-y-4">
<Alert> <Alert>
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
<AlertDescription> <AlertDescription>
A 6-digit confirmation code has been sent to <strong>{userEmail}</strong>. Enter it below within 24 hours to confirm deletion. Your account will be deactivated and scheduled for deletion on{' '} A 6-digit confirmation code has been sent to <strong>{userEmail}</strong>. Enter it below within 24 hours to confirm deletion. Your account will be deactivated and scheduled for deletion on{' '}
<strong>{scheduledDate ? new Date(scheduledDate).toLocaleDateString() : '14 days from confirmation'}</strong>. <strong>{state.scheduledDate ? new Date(state.scheduledDate).toLocaleDateString() : '14 days from confirmation'}</strong>.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -191,8 +176,8 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
type="text" type="text"
placeholder="000000" placeholder="000000"
maxLength={6} maxLength={6}
value={confirmationCode} value={state.confirmationCode}
onChange={(e) => setConfirmationCode(e.target.value.replace(/\D/g, ''))} onChange={(e) => dispatch({ type: 'UPDATE_CODE', payload: { code: e.target.value } })}
className="text-center text-2xl tracking-widest" className="text-center text-2xl tracking-widest"
/> />
</div> </div>
@@ -200,8 +185,8 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="codeReceived" id="codeReceived"
checked={codeReceived} checked={state.codeReceived}
onCheckedChange={(checked) => setCodeReceived(checked === true)} onCheckedChange={() => dispatch({ type: 'TOGGLE_CODE_RECEIVED' })}
/> />
<Label htmlFor="codeReceived" className="text-sm font-normal cursor-pointer"> <Label htmlFor="codeReceived" className="text-sm font-normal cursor-pointer">
I have received the code in my email I have received the code in my email
@@ -211,10 +196,10 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
<Button <Button
variant="outline" variant="outline"
onClick={handleResendCode} onClick={handleResendCode}
disabled={loading} disabled={state.isLoading}
className="w-full" className="w-full"
> >
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Resend Code'} {state.isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Resend Code'}
</Button> </Button>
</div> </div>
</div> </div>
@@ -222,34 +207,38 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
{step === 'warning' && ( {state.step === 'warning' && (
<> <>
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button variant="destructive" onClick={() => setStep('confirm')}> <Button
variant="destructive"
onClick={() => dispatch({ type: 'CONTINUE_TO_CONFIRM' })}
disabled={!canProceedToConfirm(state)}
>
Continue Continue
</Button> </Button>
</> </>
)} )}
{step === 'confirm' && ( {state.step === 'confirm' && (
<> <>
<Button variant="outline" onClick={() => setStep('warning')}> <Button variant="outline" onClick={() => dispatch({ type: 'GO_BACK_TO_WARNING' })}>
Go Back Go Back
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={handleRequestDeletion} onClick={handleRequestDeletion}
disabled={loading} disabled={!canRequestDeletion(state)}
> >
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null} {state.isLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
Request Deletion Request Deletion
</Button> </Button>
</> </>
)} )}
{step === 'code' && ( {state.step === 'code' && (
<> <>
<Button variant="outline" onClick={handleClose}> <Button variant="outline" onClick={handleClose}>
Close Close
@@ -257,9 +246,9 @@ export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletio
<Button <Button
variant="destructive" variant="destructive"
onClick={handleConfirmDeletion} onClick={handleConfirmDeletion}
disabled={loading || !codeReceived || confirmationCode.length !== 6} disabled={!canConfirmDeletion(state)}
> >
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null} {state.isLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
Verify Code & Deactivate Account Verify Code & Deactivate Account
</Button> </Button>
</> </>

View File

@@ -10,22 +10,27 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'; 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 { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile'; import { useProfile } from '@/hooks/useProfile';
import { supabase } from '@/integrations/supabase/client'; 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 { PhotoUpload } from '@/components/upload/PhotoUpload';
import { notificationService } from '@/lib/notificationService'; import { notificationService } from '@/lib/notificationService';
import { EmailChangeDialog } from './EmailChangeDialog'; import { EmailChangeDialog } from './EmailChangeDialog';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast as sonnerToast } from 'sonner';
import { AccountDeletionDialog } from './AccountDeletionDialog'; import { AccountDeletionDialog } from './AccountDeletionDialog';
import { DeletionStatusBanner } from './DeletionStatusBanner'; import { DeletionStatusBanner } from './DeletionStatusBanner';
import { EmailChangeStatus } from './EmailChangeStatus';
import { usernameSchema, displayNameSchema, bioSchema, personalLocationSchema, preferredPronounsSchema } from '@/lib/validation'; import { usernameSchema, displayNameSchema, bioSchema, personalLocationSchema, preferredPronounsSchema } from '@/lib/validation';
import { z } from 'zod'; import { z } from 'zod';
import { AccountDeletionRequest } from '@/types/database'; 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({ const profileSchema = z.object({
username: usernameSchema, username: usernameSchema,
@@ -42,18 +47,21 @@ type ProfileFormData = z.infer<typeof profileSchema>;
export function AccountProfileTab() { export function AccountProfileTab() {
const { user, pendingEmail, clearPendingEmail } = useAuth(); const { user, pendingEmail, clearPendingEmail } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id); const { data: profile, refreshProfile } = useProfile(user?.id);
const { toast } = useToast();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEmailDialog, setShowEmailDialog] = useState(false); const [showEmailDialog, setShowEmailDialog] = useState(false);
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false); const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
const [cancellingEmail, setCancellingEmail] = 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 [showDeletionDialog, setShowDeletionDialog] = useState(false);
const [deletionRequest, setDeletionRequest] = useState<AccountDeletionRequest | null>(null); 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>({ const form = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
defaultValues: { 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 // Check for existing deletion request on mount
useEffect(() => { useEffect(() => {
const checkDeletionRequest = async () => { const checkDeletionRequest = async () => {
@@ -87,7 +113,7 @@ export function AccountProfileTab() {
checkDeletionRequest(); checkDeletionRequest();
}, [user?.id]); }, [user?.id]);
const onSubmit = async (data: ProfileFormData) => { const handleFormSubmit = async (data: ProfileFormData) => {
if (!user) return; if (!user) return;
setLoading(true); setLoading(true);
@@ -98,18 +124,27 @@ export function AccountProfileTab() {
p_display_name: data.display_name || null, p_display_name: data.display_name || null,
p_bio: data.bio || null, p_bio: data.bio || null,
p_preferred_pronouns: data.preferred_pronouns || 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 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 // 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 // Update Novu subscriber if username changed
if (rpcResult?.username_changed && notificationService.isEnabled()) { if (rpcResult?.changes_count > 0 && notificationService.isEnabled()) {
await notificationService.updateSubscriber({ await notificationService.updateSubscriber({
subscriberId: user.id, subscriberId: user.id,
email: user.email, email: user.email,
@@ -118,56 +153,21 @@ export function AccountProfileTab() {
} }
await refreshProfile(); await refreshProfile();
toast({ handleSuccess('Profile updated', 'Your profile has been successfully updated.');
title: 'Profile updated', } catch (error) {
description: 'Your profile has been successfully updated.' handleError(error, { action: 'Update profile', userId: user.id });
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to update profile',
variant: 'destructive'
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const onSubmit = async (data: ProfileFormData) => {
await handleFormSubmit(data);
};
const handleAvatarUpload = async (urls: string[], imageId?: string) => { const handleAvatarUpload = async (urls: string[], imageId?: string) => {
if (!user || !urls[0]) return; await uploadAvatar(urls, imageId);
await refreshProfile();
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'
});
}
}; };
const handleCancelEmailChange = async () => { const handleCancelEmailChange = async () => {
@@ -212,16 +212,12 @@ export function AccountProfileTab() {
}); });
} }
sonnerToast.success('Email change cancelled', { handleSuccess('Email change cancelled', 'Your email change request has been cancelled.');
description: 'Your email change request has been cancelled.'
});
setShowCancelEmailDialog(false); setShowCancelEmailDialog(false);
await refreshProfile(); await refreshProfile();
} catch (error: any) { } catch (error) {
sonnerToast.error('Failed to cancel email change', { handleError(error, { action: 'Cancel email change' });
description: error.message || 'An error occurred while cancelling the email change.'
});
} finally { } finally {
setCancellingEmail(false); setCancellingEmail(false);
} }
@@ -268,11 +264,7 @@ export function AccountProfileTab() {
onUploadComplete={handleAvatarUpload} onUploadComplete={handleAvatarUpload}
currentImageId={avatarImageId} currentImageId={avatarImageId}
onError={(error) => { onError={(error) => {
toast({ handleError(new Error(error), { action: 'Upload avatar' });
title: "Upload Error",
description: error,
variant: "destructive"
});
}} }}
/> />
</div> </div>
@@ -285,18 +277,41 @@ export function AccountProfileTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <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 <Input
id="username" id="username"
{...form.register('username')} {...form.register('username')}
placeholder="Enter your username" placeholder="Enter your username"
disabled={isDeactivated} 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"> <p className="text-sm text-destructive">
{form.formState.errors.username.message} {form.formState.errors.username.message}
</p> </p>
)} ) : null}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -378,9 +393,26 @@ export function AccountProfileTab() {
)} )}
</div> </div>
<Button type="submit" disabled={loading || isDeactivated}> <div className="flex items-center justify-between">
{loading ? 'Saving...' : 'Save Changes'} <Button
</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 && ( {isDeactivated && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your account is deactivated. Profile editing is disabled. Your account is deactivated. Profile editing is disabled.
@@ -395,26 +427,11 @@ export function AccountProfileTab() {
<h3 className="text-lg font-medium">Account Information</h3> <h3 className="text-lg font-medium">Account Information</h3>
<div className="space-y-4"> <div className="space-y-4">
{pendingEmail && ( {pendingEmail && (
<Alert className="border-blue-500/20 bg-blue-500/10"> <EmailChangeStatus
<AlertCircle className="h-4 w-4 text-blue-500" /> currentEmail={user?.email || ''}
<AlertTitle className="text-blue-500 flex items-center justify-between"> pendingEmail={pendingEmail}
<span>Email Change in Progress</span> onCancel={() => setShowCancelEmailDialog(true)}
<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>
)} )}
<div className="p-4 bg-muted/50 rounded-lg space-y-4"> <div className="p-4 bg-muted/50 rounded-lg space-y-4">

View File

@@ -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 (
<Card className="border-blue-500/30">
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</CardContent>
</Card>
);
}
return (
<Card className="border-blue-500/30">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="w-5 h-5 text-blue-500" />
Email Change in Progress
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<Info className="w-4 h-4" />
<AlertDescription>
To complete your email change, both emails must be verified.
</AlertDescription>
</Alert>
{/* Progress indicator */}
<div className="space-y-3">
<div className="flex items-center gap-3">
{verificationStatus.oldEmailVerified ? (
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
) : (
<Circle className="w-5 h-5 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium">Current Email</p>
<p className="text-sm text-muted-foreground truncate">{currentEmail}</p>
</div>
{verificationStatus.oldEmailVerified && (
<Badge variant="secondary" className="bg-green-500/10 text-green-500">
Verified
</Badge>
)}
</div>
<Separator />
<div className="flex items-center gap-3">
{verificationStatus.newEmailVerified ? (
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
) : (
<Circle className="w-5 h-5 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium">New Email</p>
<p className="text-sm text-muted-foreground truncate">{pendingEmail}</p>
</div>
{verificationStatus.newEmailVerified && (
<Badge variant="secondary" className="bg-green-500/10 text-green-500">
Verified
</Badge>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="outline"
onClick={handleResendVerification}
disabled={resending}
className="flex-1"
>
{resending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Resending...
</>
) : (
'Resend Verification Emails'
)}
</Button>
<Button
variant="ghost"
onClick={onCancel}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
Cancel Change
</Button>
</div>
{/* Progress bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">{verificationProgress}%</span>
</div>
<Progress value={verificationProgress} className="h-2" />
</div>
</CardContent>
</Card>
);
}

View File

@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast'; import { handleError, handleSuccess } from '@/lib/errorHandler';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react'; import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
@@ -20,7 +20,6 @@ import {
addPasswordToAccount addPasswordToAccount
} from '@/lib/identityService'; } from '@/lib/identityService';
import type { UserIdentity, OAuthProvider } from '@/types/identity'; import type { UserIdentity, OAuthProvider } from '@/types/identity';
import { toast as sonnerToast } from '@/components/ui/sonner';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
interface AuthSession { interface AuthSession {
@@ -36,7 +35,6 @@ interface AuthSession {
export function SecurityTab() { export function SecurityTab() {
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false); const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
const [identities, setIdentities] = useState<UserIdentity[]>([]); const [identities, setIdentities] = useState<UserIdentity[]>([]);
@@ -64,11 +62,7 @@ export function SecurityTab() {
setHasPassword(hasEmailProvider); setHasPassword(hasEmailProvider);
} catch (error) { } catch (error) {
console.error('Failed to load identities:', error); console.error('Failed to load identities:', error);
toast({ handleError(error, { action: 'Load connected accounts' });
title: 'Error',
description: 'Failed to load connected accounts',
variant: 'destructive',
});
} finally { } finally {
setLoadingIdentities(false); setLoadingIdentities(false);
} }
@@ -79,32 +73,15 @@ export function SecurityTab() {
const result = await connectIdentity(provider, '/settings?tab=security'); const result = await connectIdentity(provider, '/settings?tab=security');
if (!result.success) { if (!result.success) {
// Handle rate limiting handleError(
if (result.error?.includes('rate limit')) { new Error(result.error || 'Connection failed'),
toast({ { action: `Connect ${provider} account` }
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'
});
}
} else { } else {
toast({ handleSuccess('Redirecting...', `Connecting your ${provider} account...`);
title: 'Redirecting...',
description: `Connecting your ${provider} account...`
});
} }
} catch (error: any) { } catch (error) {
toast({ handleError(error, { action: `Connect ${provider} account` });
title: 'Connection Error',
description: error.message || 'Failed to connect account',
variant: 'destructive'
});
} }
}; };
@@ -116,20 +93,18 @@ export function SecurityTab() {
if (safetyCheck.reason === 'no_password_backup') { if (safetyCheck.reason === 'no_password_backup') {
// Trigger password reset flow first // Trigger password reset flow first
await handleAddPassword(); await handleAddPassword();
toast({ handleSuccess(
title: "Password Required First", "Password Required First",
description: "Check your email for a password reset link. Once you've set a password, you can disconnect your social login.", "Check your email for a password reset link. Once you've set a password, you can disconnect your social login."
duration: 10000 );
});
return; return;
} }
if (safetyCheck.reason === 'last_identity') { if (safetyCheck.reason === 'last_identity') {
toast({ handleError(
title: "Cannot Disconnect", new Error("You cannot disconnect your only login method"),
description: "You cannot disconnect your only login method. Please add another authentication method first.", { action: "Disconnect social login" }
variant: "destructive" );
});
return; return;
} }
} }
@@ -141,16 +116,12 @@ export function SecurityTab() {
if (result.success) { if (result.success) {
await loadIdentities(); // Refresh identities list await loadIdentities(); // Refresh identities list
toast({ handleSuccess("Disconnected", `Your ${provider} account has been successfully disconnected.`);
title: "Disconnected",
description: `Your ${provider} account has been successfully disconnected.`
});
} else { } else {
toast({ handleError(
title: "Disconnect Failed", new Error(result.error || 'Disconnect failed'),
description: result.error, { action: `Disconnect ${provider} account` }
variant: "destructive" );
});
} }
}; };
@@ -160,16 +131,15 @@ export function SecurityTab() {
const result = await addPasswordToAccount(); const result = await addPasswordToAccount();
if (result.success) { if (result.success) {
sonnerToast.success("Password Setup Email Sent!", { handleSuccess(
description: `Check ${result.email} for a password reset link. Click it to set your password.`, "Password Setup Email Sent!",
duration: 15000, `Check ${result.email} for a password reset link. Click it to set your password.`
}); );
} else { } else {
toast({ handleError(
title: "Failed to Send Email", new Error(result.error || 'Failed to send email'),
description: result.error, { action: 'Send password setup email' }
variant: "destructive" );
});
} }
setAddingPassword(false); setAddingPassword(false);
@@ -182,11 +152,7 @@ export function SecurityTab() {
if (error) { if (error) {
console.error('Error fetching sessions:', error); console.error('Error fetching sessions:', error);
toast({ handleError(error, { action: 'Load sessions' });
title: 'Error',
description: 'Failed to load sessions',
variant: 'destructive'
});
} else { } else {
setSessions(data || []); setSessions(data || []);
} }
@@ -197,16 +163,9 @@ export function SecurityTab() {
const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionId }); const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionId });
if (error) { if (error) {
toast({ handleError(error, { action: 'Revoke session' });
title: 'Error',
description: 'Failed to revoke session',
variant: 'destructive'
});
} else { } else {
toast({ handleSuccess('Success', 'Session revoked successfully');
title: 'Success',
description: 'Session revoked successfully'
});
fetchSessions(); fetchSessions();
} }
}; };
@@ -254,10 +213,7 @@ export function SecurityTab() {
open={passwordDialogOpen} open={passwordDialogOpen}
onOpenChange={setPasswordDialogOpen} onOpenChange={setPasswordDialogOpen}
onSuccess={() => { onSuccess={() => {
toast({ handleSuccess('Password updated', 'Your password has been successfully changed.');
title: 'Password updated',
description: 'Your password has been successfully changed.'
});
}} }}
/> />

58
src/hooks/useAutoSave.ts Normal file
View File

@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'react';
import { useDebounce } from './useDebounce';
export type AutoSaveOptions<T> = {
data: T;
onSave: (data: T) => Promise<void>;
debounceMs?: number;
enabled?: boolean;
isValid?: boolean;
};
export const useAutoSave = <T,>({
data,
onSave,
debounceMs = 3000,
enabled = true,
isValid = true
}: AutoSaveOptions<T>) => {
const [isSaving, setIsSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(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)
};
};

View File

@@ -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<AvatarUploadState>({
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
};
};

View File

@@ -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
);
};

63
src/lib/errorHandler.ts Normal file
View File

@@ -0,0 +1,63 @@
import { toast } from 'sonner';
import { logger } from './logger';
export type ErrorContext = {
action: string;
userId?: string;
metadata?: Record<string, any>;
};
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
});
};

View File

@@ -7,14 +7,24 @@
const isDev = import.meta.env.DEV; const isDev = import.meta.env.DEV;
type LogContext = {
[key: string]: any;
};
export const logger = { export const logger = {
log: (...args: any[]) => { log: (...args: any[]) => {
if (isDev) console.log(...args); if (isDev) console.log(...args);
}, },
error: (...args: any[]) => { error: (message: string, context?: LogContext) => {
console.error(...args); // Always log errors console.error(message, context); // Always log errors
}, },
warn: (...args: any[]) => { warn: (...args: any[]) => {
if (isDev) console.warn(...args); if (isDev) console.warn(...args);
}, },
info: (...args: any[]) => {
if (isDev) console.info(...args);
},
debug: (...args: any[]) => {
if (isDev) console.debug(...args);
}
}; };

View File

@@ -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');