mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:11:11 -05:00
Fix: Correct ban migration logic
This commit is contained in:
312
src/components/admin/BanUserDialog.tsx
Normal file
312
src/components/admin/BanUserDialog.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Ban, UserCheck } from 'lucide-react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
|
||||||
|
const BAN_REASONS = [
|
||||||
|
{ value: 'spam', label: 'Spam or advertising' },
|
||||||
|
{ value: 'harassment', label: 'Harassment or bullying' },
|
||||||
|
{ value: 'inappropriate_content', label: 'Inappropriate content' },
|
||||||
|
{ value: 'violation_tos', label: 'Terms of Service violation' },
|
||||||
|
{ value: 'abuse', label: 'Abuse of platform features' },
|
||||||
|
{ value: 'fake_info', label: 'Posting false information' },
|
||||||
|
{ value: 'copyright', label: 'Copyright infringement' },
|
||||||
|
{ value: 'multiple_accounts', label: 'Multiple account abuse' },
|
||||||
|
{ value: 'other', label: 'Other (specify below)' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BAN_DURATIONS = [
|
||||||
|
{ value: '1', label: '1 Day', days: 1 },
|
||||||
|
{ value: '7', label: '7 Days (1 Week)', days: 7 },
|
||||||
|
{ value: '30', label: '30 Days (1 Month)', days: 30 },
|
||||||
|
{ value: '90', label: '90 Days (3 Months)', days: 90 },
|
||||||
|
{ value: 'permanent', label: 'Permanent', days: null }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const banFormSchema = z.object({
|
||||||
|
reason_type: z.enum([
|
||||||
|
'spam',
|
||||||
|
'harassment',
|
||||||
|
'inappropriate_content',
|
||||||
|
'violation_tos',
|
||||||
|
'abuse',
|
||||||
|
'fake_info',
|
||||||
|
'copyright',
|
||||||
|
'multiple_accounts',
|
||||||
|
'other'
|
||||||
|
]),
|
||||||
|
custom_reason: z.string().max(500).optional(),
|
||||||
|
duration: z.enum(['1', '7', '30', '90', 'permanent'])
|
||||||
|
}).refine(
|
||||||
|
(data) => data.reason_type !== 'other' || (data.custom_reason && data.custom_reason.trim().length > 0),
|
||||||
|
{
|
||||||
|
message: "Please provide a custom reason",
|
||||||
|
path: ["custom_reason"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type BanFormValues = z.infer<typeof banFormSchema>;
|
||||||
|
|
||||||
|
interface BanUserDialogProps {
|
||||||
|
profile: {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
banned: boolean;
|
||||||
|
};
|
||||||
|
onBanComplete: () => void;
|
||||||
|
onBanUser: (userId: string, ban: boolean, reason?: string, expiresAt?: Date | null) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BanUserDialog({ profile, onBanComplete, onBanUser, disabled }: BanUserDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<BanFormValues>({
|
||||||
|
resolver: zodResolver(banFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
reason_type: 'violation_tos',
|
||||||
|
custom_reason: '',
|
||||||
|
duration: '7'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchReasonType = form.watch('reason_type');
|
||||||
|
const watchDuration = form.watch('duration');
|
||||||
|
|
||||||
|
const onSubmit = async (values: BanFormValues) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
// Construct the ban reason
|
||||||
|
let banReason: string;
|
||||||
|
if (values.reason_type === 'other' && values.custom_reason) {
|
||||||
|
banReason = values.custom_reason.trim();
|
||||||
|
} else {
|
||||||
|
const selectedReason = BAN_REASONS.find(r => r.value === values.reason_type);
|
||||||
|
banReason = selectedReason?.label || 'Policy violation';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate expiration date
|
||||||
|
let expiresAt: Date | null = null;
|
||||||
|
if (values.duration !== 'permanent') {
|
||||||
|
const durationConfig = BAN_DURATIONS.find(d => d.value === values.duration);
|
||||||
|
if (durationConfig?.days) {
|
||||||
|
expiresAt = new Date();
|
||||||
|
expiresAt.setDate(expiresAt.getDate() + durationConfig.days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await onBanUser(profile.user_id, true, banReason, expiresAt);
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
onBanComplete();
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is done by the parent component
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnban = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onBanUser(profile.user_id, false);
|
||||||
|
setOpen(false);
|
||||||
|
onBanComplete();
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is done by the parent component
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// For unbanning, use simpler dialog
|
||||||
|
if (profile.banned) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={disabled}>
|
||||||
|
<UserCheck className="w-4 h-4 mr-2" />
|
||||||
|
Unban
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Unban User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to unban @{profile.username}? They will be able to access the application again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUnban} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Unbanning...' : 'Unban User'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For banning, use detailed form
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="destructive" size="sm" disabled={disabled}>
|
||||||
|
<Ban className="w-4 h-4 mr-2" />
|
||||||
|
Ban
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ban User</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Ban @{profile.username} from accessing the application. You must provide a reason and duration.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="reason_type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ban Reason</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a reason" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{BAN_REASONS.map((reason) => (
|
||||||
|
<SelectItem key={reason.value} value={reason.value}>
|
||||||
|
{reason.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose the primary reason for this ban
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{watchReasonType === 'other' && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="custom_reason"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom Reason</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Provide a detailed reason for the ban..."
|
||||||
|
className="min-h-[100px] resize-none"
|
||||||
|
maxLength={500}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{field.value?.length || 0}/500 characters
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="duration"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ban Duration</FormLabel>
|
||||||
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select duration" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{BAN_DURATIONS.map((duration) => (
|
||||||
|
<SelectItem key={duration.value} value={duration.value}>
|
||||||
|
{duration.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
How long should this ban last?
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>User will see:</strong> Your account has been suspended. Reason:{' '}
|
||||||
|
{watchReasonType === 'other' && form.getValues('custom_reason')
|
||||||
|
? form.getValues('custom_reason')
|
||||||
|
: BAN_REASONS.find(r => r.value === watchReasonType)?.label || 'Policy violation'}
|
||||||
|
.{' '}
|
||||||
|
{watchDuration === 'permanent'
|
||||||
|
? 'This is a permanent ban.'
|
||||||
|
: `This ban will expire in ${BAN_DURATIONS.find(d => d.value === watchDuration)?.label.toLowerCase()}.`}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="destructive" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Banning...' : 'Ban User'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Admin components barrel exports
|
// Admin components barrel exports
|
||||||
export { AdminPageLayout } from './AdminPageLayout';
|
export { AdminPageLayout } from './AdminPageLayout';
|
||||||
|
export { BanUserDialog } from './BanUserDialog';
|
||||||
export { DesignerForm } from './DesignerForm';
|
export { DesignerForm } from './DesignerForm';
|
||||||
export { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
export { HeadquartersLocationInput } from './HeadquartersLocationInput';
|
||||||
export { LocationSearch } from './LocationSearch';
|
export { LocationSearch } from './LocationSearch';
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Search, Ban, Shield, UserCheck, UserX, AlertTriangle, Trash2 } from 'lucide-react';
|
import { Search, Shield, Trash2, Ban, AlertTriangle } from 'lucide-react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useUserRole, UserRole } from '@/hooks/useUserRole';
|
import { useUserRole, UserRole } from '@/hooks/useUserRole';
|
||||||
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
|
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
|
||||||
import { AdminUserDeletionDialog } from '@/components/admin/AdminUserDeletionDialog';
|
import { AdminUserDeletionDialog } from '@/components/admin/AdminUserDeletionDialog';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { BanUserDialog } from '@/components/admin/BanUserDialog';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
|
import { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
@@ -83,15 +83,32 @@ export function ProfileManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBanUser = async (targetUserId: string, ban: boolean) => {
|
const handleBanUser = async (
|
||||||
|
targetUserId: string,
|
||||||
|
ban: boolean,
|
||||||
|
banReason?: string,
|
||||||
|
banExpiresAt?: Date | null
|
||||||
|
) => {
|
||||||
if (!user || !permissions) return;
|
if (!user || !permissions) return;
|
||||||
|
|
||||||
setActionLoading(targetUserId);
|
setActionLoading(targetUserId);
|
||||||
try {
|
try {
|
||||||
|
// Prepare update data
|
||||||
|
const updateData: any = { banned: ban };
|
||||||
|
|
||||||
|
if (ban && banReason) {
|
||||||
|
updateData.ban_reason = banReason;
|
||||||
|
updateData.ban_expires_at = banExpiresAt;
|
||||||
|
} else if (!ban) {
|
||||||
|
// Clear ban data when unbanning
|
||||||
|
updateData.ban_reason = null;
|
||||||
|
updateData.ban_expires_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Update banned status
|
// Update banned status
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({ banned: ban })
|
.update(updateData)
|
||||||
.eq('user_id', targetUserId);
|
.eq('user_id', targetUserId);
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
if (updateError) throw updateError;
|
||||||
@@ -102,7 +119,11 @@ export function ProfileManager() {
|
|||||||
_admin_user_id: user.id,
|
_admin_user_id: user.id,
|
||||||
_target_user_id: targetUserId,
|
_target_user_id: targetUserId,
|
||||||
_action: ban ? 'ban_user' : 'unban_user',
|
_action: ban ? 'ban_user' : 'unban_user',
|
||||||
_details: { banned: ban }
|
_details: {
|
||||||
|
banned: ban,
|
||||||
|
ban_reason: banReason,
|
||||||
|
ban_expires_at: banExpiresAt?.toISOString()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (logError) logger.error('Failed to log admin action', { error: getErrorMessage(logError) });
|
if (logError) logger.error('Failed to log admin action', { error: getErrorMessage(logError) });
|
||||||
@@ -120,7 +141,7 @@ export function ProfileManager() {
|
|||||||
handleError(error, {
|
handleError(error, {
|
||||||
action: `${ban ? 'Ban' : 'Unban'} User`,
|
action: `${ban ? 'Ban' : 'Unban'} User`,
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
metadata: { targetUserId, ban }
|
metadata: { targetUserId, ban, banReason, banExpiresAt }
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(null);
|
setActionLoading(null);
|
||||||
@@ -363,47 +384,12 @@ export function ProfileManager() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Ban/Unban Button */}
|
{/* Ban/Unban Button */}
|
||||||
{canManageUser(profile) && permissions.can_ban_any_user && (
|
{canManageUser(profile) && permissions.can_ban_any_user && (
|
||||||
<AlertDialog>
|
<BanUserDialog
|
||||||
<AlertDialogTrigger asChild>
|
profile={profile}
|
||||||
<Button
|
onBanComplete={fetchProfiles}
|
||||||
variant={profile.banned ? "outline" : "destructive"}
|
onBanUser={handleBanUser}
|
||||||
size="sm"
|
disabled={actionLoading === profile.user_id}
|
||||||
disabled={actionLoading === profile.user_id}
|
/>
|
||||||
>
|
|
||||||
{profile.banned ? (
|
|
||||||
<>
|
|
||||||
<UserCheck className="w-4 h-4 mr-2" />
|
|
||||||
Unban
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<UserX className="w-4 h-4 mr-2" />
|
|
||||||
Ban
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
{profile.banned ? 'Unban' : 'Ban'} User
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Are you sure you want to {profile.banned ? 'unban' : 'ban'} {profile.username}?
|
|
||||||
{!profile.banned && ' This will prevent them from accessing the application.'}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => handleBanUser(profile.user_id, !profile.banned)}
|
|
||||||
className={profile.banned ? "" : "bg-destructive hover:bg-destructive/90"}
|
|
||||||
>
|
|
||||||
{profile.banned ? 'Unban' : 'Ban'} User
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete User Button - Superusers Only */}
|
{/* Delete User Button - Superusers Only */}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function useBanCheck() {
|
|||||||
try {
|
try {
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('banned, ban_reason')
|
.select('banned, ban_reason, ban_expires_at')
|
||||||
.eq('user_id', user.id)
|
.eq('user_id', user.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
@@ -33,10 +33,21 @@ export function useBanCheck() {
|
|||||||
const reason = profile.ban_reason
|
const reason = profile.ban_reason
|
||||||
? `Reason: ${profile.ban_reason}`
|
? `Reason: ${profile.ban_reason}`
|
||||||
: 'Contact support for assistance.';
|
: 'Contact support for assistance.';
|
||||||
|
|
||||||
|
// Add expiration info
|
||||||
|
let expirationText = '';
|
||||||
|
if (profile.ban_expires_at) {
|
||||||
|
const expiresAt = new Date(profile.ban_expires_at);
|
||||||
|
const now = new Date();
|
||||||
|
const daysLeft = Math.ceil((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
expirationText = ` This ban will expire in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}.`;
|
||||||
|
} else {
|
||||||
|
expirationText = ' This is a permanent ban.';
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account Suspended',
|
title: 'Account Suspended',
|
||||||
description: `Your account has been suspended. ${reason}`,
|
description: `Your account has been suspended. ${reason}${expirationText}`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: Infinity // Don't auto-dismiss
|
duration: Infinity // Don't auto-dismiss
|
||||||
});
|
});
|
||||||
@@ -65,7 +76,7 @@ export function useBanCheck() {
|
|||||||
filter: `user_id=eq.${user.id}`
|
filter: `user_id=eq.${user.id}`
|
||||||
},
|
},
|
||||||
(payload) => {
|
(payload) => {
|
||||||
const newProfile = payload.new as { banned: boolean; ban_reason: string | null };
|
const newProfile = payload.new as { banned: boolean; ban_reason: string | null; ban_expires_at: string | null };
|
||||||
|
|
||||||
// Handle BAN event
|
// Handle BAN event
|
||||||
if (newProfile.banned && !isBanned) {
|
if (newProfile.banned && !isBanned) {
|
||||||
@@ -75,10 +86,21 @@ export function useBanCheck() {
|
|||||||
const reason = newProfile.ban_reason
|
const reason = newProfile.ban_reason
|
||||||
? `Reason: ${newProfile.ban_reason}`
|
? `Reason: ${newProfile.ban_reason}`
|
||||||
: 'Contact support for assistance.';
|
: 'Contact support for assistance.';
|
||||||
|
|
||||||
|
// Add expiration info
|
||||||
|
let expirationText = '';
|
||||||
|
if (newProfile.ban_expires_at) {
|
||||||
|
const expiresAt = new Date(newProfile.ban_expires_at);
|
||||||
|
const now = new Date();
|
||||||
|
const daysLeft = Math.ceil((expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
expirationText = ` This ban will expire in ${daysLeft} day${daysLeft !== 1 ? 's' : ''}.`;
|
||||||
|
} else {
|
||||||
|
expirationText = ' This is a permanent ban.';
|
||||||
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Account Suspended',
|
title: 'Account Suspended',
|
||||||
description: `Your account has been suspended. ${reason}`,
|
description: `Your account has been suspended. ${reason}${expirationText}`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
duration: Infinity
|
duration: Infinity
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1952,6 +1952,7 @@ export type Database = {
|
|||||||
Row: {
|
Row: {
|
||||||
avatar_image_id: string | null
|
avatar_image_id: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
|
ban_expires_at: string | null
|
||||||
ban_reason: string | null
|
ban_reason: string | null
|
||||||
banned: boolean
|
banned: boolean
|
||||||
bio: string | null
|
bio: string | null
|
||||||
@@ -1984,6 +1985,7 @@ export type Database = {
|
|||||||
Insert: {
|
Insert: {
|
||||||
avatar_image_id?: string | null
|
avatar_image_id?: string | null
|
||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
|
ban_expires_at?: string | null
|
||||||
ban_reason?: string | null
|
ban_reason?: string | null
|
||||||
banned?: boolean
|
banned?: boolean
|
||||||
bio?: string | null
|
bio?: string | null
|
||||||
@@ -2016,6 +2018,7 @@ export type Database = {
|
|||||||
Update: {
|
Update: {
|
||||||
avatar_image_id?: string | null
|
avatar_image_id?: string | null
|
||||||
avatar_url?: string | null
|
avatar_url?: string | null
|
||||||
|
ban_expires_at?: string | null
|
||||||
ban_reason?: string | null
|
ban_reason?: string | null
|
||||||
banned?: boolean
|
banned?: boolean
|
||||||
bio?: string | null
|
bio?: string | null
|
||||||
|
|||||||
@@ -61,4 +61,7 @@ verify_jwt = true
|
|||||||
verify_jwt = true
|
verify_jwt = true
|
||||||
|
|
||||||
[functions.receive-inbound-email]
|
[functions.receive-inbound-email]
|
||||||
|
verify_jwt = false
|
||||||
|
|
||||||
|
[functions.process-expired-bans]
|
||||||
verify_jwt = false
|
verify_jwt = false
|
||||||
107
supabase/functions/process-expired-bans/index.ts
Normal file
107
supabase/functions/process-expired-bans/index.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||||
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create admin client
|
||||||
|
const supabaseAdmin = createClient(
|
||||||
|
Deno.env.get('SUPABASE_URL')!,
|
||||||
|
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Find expired bans
|
||||||
|
const { data: expiredBans, error: fetchError } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.select('user_id, username, ban_reason, ban_expires_at')
|
||||||
|
.eq('banned', true)
|
||||||
|
.not('ban_expires_at', 'is', null)
|
||||||
|
.lte('ban_expires_at', now);
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error('Error fetching expired bans:', fetchError);
|
||||||
|
throw fetchError;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${expiredBans?.length || 0} expired bans to process`);
|
||||||
|
|
||||||
|
// Unban users with expired bans
|
||||||
|
const unbannedUsers: string[] = [];
|
||||||
|
for (const profile of expiredBans || []) {
|
||||||
|
console.log(`Unbanning user: ${profile.username} (${profile.user_id})`);
|
||||||
|
|
||||||
|
const { error: unbanError } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.update({
|
||||||
|
banned: false,
|
||||||
|
ban_reason: null,
|
||||||
|
ban_expires_at: null
|
||||||
|
})
|
||||||
|
.eq('user_id', profile.user_id);
|
||||||
|
|
||||||
|
if (unbanError) {
|
||||||
|
console.error(`Failed to unban ${profile.username}:`, unbanError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the automatic unban
|
||||||
|
const { error: logError } = await supabaseAdmin
|
||||||
|
.rpc('log_admin_action', {
|
||||||
|
_admin_user_id: '00000000-0000-0000-0000-000000000000', // System user ID
|
||||||
|
_target_user_id: profile.user_id,
|
||||||
|
_action: 'auto_unban',
|
||||||
|
_details: {
|
||||||
|
reason: 'Ban expired',
|
||||||
|
original_ban_reason: profile.ban_reason,
|
||||||
|
expired_at: profile.ban_expires_at
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logError) {
|
||||||
|
console.error(`Failed to log auto-unban for ${profile.username}:`, logError);
|
||||||
|
}
|
||||||
|
|
||||||
|
unbannedUsers.push(profile.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully unbanned ${unbannedUsers.length} users`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
unbanned_count: unbannedUsers.length,
|
||||||
|
unbanned_users: unbannedUsers,
|
||||||
|
processed_at: now
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
status: 200
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in process-expired-bans:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
success: false
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
status: 500
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- Add ban expiration tracking
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS ban_expires_at timestamptz;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.profiles.ban_expires_at IS
|
||||||
|
'When the ban expires (null = permanent ban). Automatic unbanning is handled by a scheduled edge function.';
|
||||||
|
|
||||||
|
-- Update existing banned users to have a default ban reason
|
||||||
|
UPDATE public.profiles
|
||||||
|
SET ban_reason = 'No reason provided (legacy ban)'
|
||||||
|
WHERE banned = true AND (ban_reason IS NULL OR ban_reason = '');
|
||||||
|
|
||||||
|
-- Add constraint to require ban_reason when banned
|
||||||
|
ALTER TABLE public.profiles
|
||||||
|
ADD CONSTRAINT ban_reason_required
|
||||||
|
CHECK (
|
||||||
|
(banned = true AND ban_reason IS NOT NULL AND ban_reason != '')
|
||||||
|
OR
|
||||||
|
(banned = false)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON CONSTRAINT ban_reason_required ON public.profiles IS
|
||||||
|
'Ensures that a ban reason must be provided when banning a user';
|
||||||
Reference in New Issue
Block a user