mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:11:13 -05:00
Enable RLS on rate limits table
This commit is contained in:
85
src/components/admin/ProfileAuditLog.tsx
Normal file
85
src/components/admin/ProfileAuditLog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
193
src/components/settings/EmailChangeStatus.tsx
Normal file
193
src/components/settings/EmailChangeStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
58
src/hooks/useAutoSave.ts
Normal 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)
|
||||||
|
};
|
||||||
|
};
|
||||||
84
src/hooks/useAvatarUpload.ts
Normal file
84
src/hooks/useAvatarUpload.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
89
src/lib/deletionDialogMachine.ts
Normal file
89
src/lib/deletionDialogMachine.ts
Normal 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
63
src/lib/errorHandler.ts
Normal 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
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
Reference in New Issue
Block a user