Fix: Correct ban migration logic

This commit is contained in:
gpt-engineer-app[bot]
2025-10-30 12:03:55 +00:00
parent e5de404e59
commit db101bc5f2
8 changed files with 509 additions and 52 deletions

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

View File

@@ -1,5 +1,6 @@
// Admin components barrel exports
export { AdminPageLayout } from './AdminPageLayout';
export { BanUserDialog } from './BanUserDialog';
export { DesignerForm } from './DesignerForm';
export { HeadquartersLocationInput } from './HeadquartersLocationInput';
export { LocationSearch } from './LocationSearch';

View File

@@ -1,16 +1,16 @@
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 { useAuth } from '@/hooks/useAuth';
import { useUserRole, UserRole } from '@/hooks/useUserRole';
import { useSuperuserGuard } from '@/hooks/useSuperuserGuard';
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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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 { handleError, handleSuccess, getErrorMessage } from '@/lib/errorHandler';
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;
setActionLoading(targetUserId);
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
const { error: updateError } = await supabase
.from('profiles')
.update({ banned: ban })
.update(updateData)
.eq('user_id', targetUserId);
if (updateError) throw updateError;
@@ -102,7 +119,11 @@ export function ProfileManager() {
_admin_user_id: user.id,
_target_user_id: targetUserId,
_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) });
@@ -120,7 +141,7 @@ export function ProfileManager() {
handleError(error, {
action: `${ban ? 'Ban' : 'Unban'} User`,
userId: user?.id,
metadata: { targetUserId, ban }
metadata: { targetUserId, ban, banReason, banExpiresAt }
});
} finally {
setActionLoading(null);
@@ -363,47 +384,12 @@ export function ProfileManager() {
<div className="flex items-center gap-2">
{/* Ban/Unban Button */}
{canManageUser(profile) && permissions.can_ban_any_user && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant={profile.banned ? "outline" : "destructive"}
size="sm"
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>
<BanUserDialog
profile={profile}
onBanComplete={fetchProfiles}
onBanUser={handleBanUser}
disabled={actionLoading === profile.user_id}
/>
)}
{/* Delete User Button - Superusers Only */}