From 2cfefc17dcf612457ba8e8c15782ca227b21e3f3 Mon Sep 17 00:00:00 2001 From: CycroftX Date: Tue, 10 Feb 2026 12:37:48 +0530 Subject: [PATCH] feat: Bulk Actions floating bar, suspend/tag dialogs, dedicated server actions --- .../users/components/BulkActionBar.tsx | 146 +++++++++++ .../components/dialogs/BulkSuspendDialog.tsx | 164 ++++++++++++ .../components/dialogs/BulkTagDialog.tsx | 162 ++++++++++++ src/lib/actions/bulk-users.ts | 240 ++++++++++++++++++ src/pages/Users.tsx | 50 ++-- 5 files changed, 741 insertions(+), 21 deletions(-) create mode 100644 src/features/users/components/BulkActionBar.tsx create mode 100644 src/features/users/components/dialogs/BulkSuspendDialog.tsx create mode 100644 src/features/users/components/dialogs/BulkTagDialog.tsx create mode 100644 src/lib/actions/bulk-users.ts diff --git a/src/features/users/components/BulkActionBar.tsx b/src/features/users/components/BulkActionBar.tsx new file mode 100644 index 0000000..d3eeb3f --- /dev/null +++ b/src/features/users/components/BulkActionBar.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Tooltip, TooltipContent, TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + Shield, Tag, Mail, Download, Trash2, X, + CheckCircle2, Ban, Loader2, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { bulkExportUsers, bulkBanUsers, bulkDeleteUsers, bulkVerifyUsers } from '@/lib/actions/bulk-users'; +import type { User } from '@/lib/types/user'; + +interface BulkActionBarProps { + selectedUsers: User[]; + onClearSelection: () => void; + onOpenSuspendDialog: () => void; + onOpenTagDialog: () => void; + onOpenEmailComposer: () => void; + onComplete: () => void; +} + +export function BulkActionBar({ + selectedUsers, + onClearSelection, + onOpenSuspendDialog, + onOpenTagDialog, + onOpenEmailComposer, + onComplete, +}: BulkActionBarProps) { + const [loadingAction, setLoadingAction] = useState(null); + const count = selectedUsers.length; + + if (count === 0) return null; + + const userIds = selectedUsers.map(u => u.id); + + const runAction = async (key: string, action: () => Promise<{ success: boolean; message: string }>) => { + setLoadingAction(key); + try { + const res = await action(); + if (res.success) { + toast.success(res.message); + onClearSelection(); + onComplete(); + } else { + toast.error(res.message); + } + } catch { + toast.error('Action failed'); + } finally { + setLoadingAction(null); + } + }; + + const handleExport = () => { + runAction('export', async () => { + const res = await bulkExportUsers(userIds); + if (res.success && res.csvData) { + // Trigger download + const blob = new Blob([res.csvData], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `users_export_${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + return res; + }); + }; + + const handleBan = () => { + if (!confirm(`Permanently ban ${count} user(s)? This is a severe action.`)) return; + runAction('ban', () => bulkBanUsers(userIds, { reason: 'Bulk ban from action bar' })); + }; + + const handleDelete = () => { + if (!confirm(`Permanently delete ${count} user(s)? This CANNOT be undone.`)) return; + runAction('delete', () => bulkDeleteUsers(userIds)); + }; + + const handleVerify = () => { + runAction('verify', () => bulkVerifyUsers(userIds)); + }; + + const actions = [ + { key: 'suspend', icon: Shield, label: 'Suspend', color: 'text-orange-500 hover:bg-orange-500/10', onClick: onOpenSuspendDialog }, + { key: 'ban', icon: Ban, label: 'Ban', color: 'text-red-500 hover:bg-red-500/10', onClick: handleBan }, + { key: 'tag', icon: Tag, label: 'Tag', color: 'text-blue-500 hover:bg-blue-500/10', onClick: onOpenTagDialog }, + { key: 'email', icon: Mail, label: 'Email', color: 'text-violet-500 hover:bg-violet-500/10', onClick: onOpenEmailComposer }, + { key: 'verify', icon: CheckCircle2, label: 'Verify', color: 'text-emerald-500 hover:bg-emerald-500/10', onClick: handleVerify }, + { key: 'export', icon: Download, label: 'Export CSV', color: 'text-sky-500 hover:bg-sky-500/10', onClick: handleExport }, + { key: 'delete', icon: Trash2, label: 'Delete', color: 'text-red-600 hover:bg-red-600/10', onClick: handleDelete, destructive: true }, + ]; + + return ( +
+
+ {/* Selection Count */} +
+ + {count} + + + User{count > 1 ? 's' : ''} Selected + + +
+ + {/* Action Buttons */} +
+ {actions.map((action, i) => ( + + {action.destructive &&
} + + + + + + {action.label} + + + + ))} +
+
+
+ ); +} diff --git a/src/features/users/components/dialogs/BulkSuspendDialog.tsx b/src/features/users/components/dialogs/BulkSuspendDialog.tsx new file mode 100644 index 0000000..e9d41ef --- /dev/null +++ b/src/features/users/components/dialogs/BulkSuspendDialog.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react'; +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { bulkSuspendUsers } from '@/lib/actions/bulk-users'; +import { Loader2, Shield, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; +import type { User } from '@/lib/types/user'; + +const REASONS = [ + { value: 'spam', label: 'Spam / Abuse' }, + { value: 'fraud', label: 'Suspected Fraud' }, + { value: 'tos', label: 'Terms of Service Violation' }, + { value: 'chargeback', label: 'Chargeback Abuse' }, + { value: 'other', label: 'Other (specify below)' }, +]; + +const DURATIONS = [ + { value: '24h', label: '24 Hours' }, + { value: '7d', label: '7 Days' }, + { value: '30d', label: '30 Days' }, + { value: 'permanent', label: 'Permanent' }, +]; + +interface BulkSuspendDialogProps { + users: User[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onComplete: () => void; +} + +export function BulkSuspendDialog({ users, open, onOpenChange, onComplete }: BulkSuspendDialogProps) { + const [reason, setReason] = useState(''); + const [duration, setDuration] = useState('7d'); + const [customNote, setCustomNote] = useState(''); + const [notifyUsers, setNotifyUsers] = useState(true); + const [loading, setLoading] = useState(false); + + // Filter out already suspended/banned users + const actionable = users.filter(u => u.status !== 'Suspended' && u.status !== 'Banned'); + const skipped = users.length - actionable.length; + + const handleSubmit = async () => { + if (!reason) { toast.error('Please select a reason'); return; } + setLoading(true); + try { + const finalReason = reason === 'other' ? customNote || 'Unspecified' : REASONS.find(r => r.value === reason)?.label || reason; + const res = await bulkSuspendUsers( + actionable.map(u => u.id), + { reason: finalReason, duration, notifyUsers } + ); + if (res.success) { + toast.success('Bulk Suspension Applied', { + description: res.message + (skipped > 0 ? ` (${skipped} already suspended/banned, skipped)` : ''), + }); + onOpenChange(false); + onComplete(); + } else { + toast.error(res.message); + } + } catch { toast.error('An unexpected error occurred'); } + finally { setLoading(false); } + }; + + return ( + + + + + + Bulk Suspend Users + + + Suspend {actionable.length} user{actionable.length !== 1 ? 's' : ''} from the platform. + + + + {skipped > 0 && ( +
+ + {skipped} user{skipped > 1 ? 's are' : ' is'} already suspended/banned and will be skipped. +
+ )} + + {/* Affected Users Preview */} +
+
+ {users.slice(0, 20).map(u => ( + + {u.name} + {(u.status === 'Suspended' || u.status === 'Banned') && ( + (skip) + )} + + ))} + {users.length > 20 && +{users.length - 20} more} +
+
+ +
+ {/* Reason */} +
+ + +
+ + {reason === 'other' && ( +
+ +