From 7a8c441b347e3ff3e83d72ff67af2c32be8098c6 Mon Sep 17 00:00:00 2001 From: CycroftX Date: Mon, 9 Feb 2026 21:25:42 +0530 Subject: [PATCH] feat(users): implement server actions and audit logging --- package-lock.json | 35 + package.json | 1 + .../users/components/ActionButtons.tsx | 213 ++++++ src/features/users/components/BanModal.tsx | 218 ++++++ .../users/components/BulkActionsDropdown.tsx | 271 +++++++ .../users/components/CreateUserDialog.tsx | 477 ++++++++++++ .../users/components/DeleteConfirmDialog.tsx | 151 ++++ .../users/components/NotificationComposer.tsx | 365 +++++++++ .../users/components/SuspensionModal.tsx | 215 ++++++ src/features/users/components/UserFilters.tsx | 382 ++++++++++ .../users/components/UserInspectorSheet.tsx | 357 +++++++++ .../users/components/UserMetricsBar.tsx | 101 +++ src/features/users/components/UsersTable.tsx | 459 +++++++++++ src/features/users/data/mockUserCrmData.ts | 711 ++++++++++++++++++ src/lib/actions/user-management.ts | 146 ++++ src/lib/audit-logger.ts | 26 + src/lib/types/user.ts | 365 +++++++++ src/lib/validations/user.ts | 274 +++++++ src/pages/Users.tsx | 492 ++++++++++-- 19 files changed, 5188 insertions(+), 71 deletions(-) create mode 100644 src/features/users/components/ActionButtons.tsx create mode 100644 src/features/users/components/BanModal.tsx create mode 100644 src/features/users/components/BulkActionsDropdown.tsx create mode 100644 src/features/users/components/CreateUserDialog.tsx create mode 100644 src/features/users/components/DeleteConfirmDialog.tsx create mode 100644 src/features/users/components/NotificationComposer.tsx create mode 100644 src/features/users/components/SuspensionModal.tsx create mode 100644 src/features/users/components/UserFilters.tsx create mode 100644 src/features/users/components/UserInspectorSheet.tsx create mode 100644 src/features/users/components/UserMetricsBar.tsx create mode 100644 src/features/users/components/UsersTable.tsx create mode 100644 src/features/users/data/mockUserCrmData.ts create mode 100644 src/lib/actions/user-management.ts create mode 100644 src/lib/audit-logger.ts create mode 100644 src/lib/types/user.ts create mode 100644 src/lib/validations/user.ts diff --git a/package-lock.json b/package-lock.json index 050c917..08e7085 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.83.0", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -2820,6 +2821,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6844,6 +6878,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", "license": "MIT", + "peer": true, "dependencies": { "@remix-run/router": "1.23.0" }, diff --git a/package.json b/package.json index 7331472..89170c6 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-toggle-group": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.83.0", + "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/src/features/users/components/ActionButtons.tsx b/src/features/users/components/ActionButtons.tsx new file mode 100644 index 0000000..cacb875 --- /dev/null +++ b/src/features/users/components/ActionButtons.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useTransition } from 'react'; +import { + KeyRound, + Eye, + Ticket, + Ban, + Mail, + ShieldAlert, + LogOut, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { toast } from 'sonner'; +import { + resetPassword, + impersonateUser, + terminateSessions, + createSupportTicket, +} from '@/lib/actions/user-management'; + +interface ActionButtonsProps { + userId: string; + userName: string; + onSuspend?: () => void; // Provided by parent to open modal + onSendNotification?: () => void; // Provided by parent +} + +export function ActionButtons({ + userId, + userName, + onSuspend, + onSendNotification, +}: ActionButtonsProps) { + const [isPending, startTransition] = useTransition(); + + const handleResetPassword = () => { + startTransition(async () => { + const result = await resetPassword(userId); + if (result.success) { + toast.success('Password Reset Initiated', { + description: result.message, + }); + } else { + toast.error('Action Failed', { + description: result.message, + }); + } + }); + }; + + const handleImpersonate = () => { + startTransition(async () => { + const result = await impersonateUser(userId); + if (result.success) { + toast.success(`Impersonating ${userName}`, { + description: 'Redirecting to user dashboard...', + }); + // Simulate redirect delay + setTimeout(() => { + if (result.redirectUrl) window.location.href = result.redirectUrl; + }, 1000); + } else { + toast.error('Impersonation Failed', { + description: result.message, + }); + } + }); + }; + + const handleTerminateSessions = () => { + startTransition(async () => { + const result = await terminateSessions(userId); + if (result.success) { + toast.success('Sessions Terminated', { + description: 'User has been logged out from all devices.', + }); + } else { + toast.error('Action Failed', { + description: result.message, + }); + } + }); + }; + + const handleCreateTicket = () => { + // Quick action: create a generic ticket for now + // In reality, this might open a dialog + startTransition(async () => { + const result = await createSupportTicket(userId, "User requires manual review"); + if (result.success) { + toast.success('Support Ticket Created', { + description: result.message, + }); + } else { + toast.error('Failed to create ticket', { + description: result.message, + }); + } + }); + }; + + return ( +
+ + + + + + Reset Password + + + + + + + + + Impersonate User + + + + + + + + + Email / Notify User + + + + + + + + + Create Support Ticket + + + +
+ + + + + + + + + Terminate Sessions + + + + + Suspend / Ban User + + + +
+ ); +} diff --git a/src/features/users/components/BanModal.tsx b/src/features/users/components/BanModal.tsx new file mode 100644 index 0000000..e3f6979 --- /dev/null +++ b/src/features/users/components/BanModal.tsx @@ -0,0 +1,218 @@ +// BanModal - Ban workflow with ban types and evidence +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Ban, Loader2, AlertOctagon, Shield, AtSign, Smartphone } from 'lucide-react'; +import { banUserSchema, type BanUserInput } from '@/lib/validations/user'; +import type { User, BanType } from '@/lib/types/user'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; + +interface BanModalProps { + user: User | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onBanned?: () => void; +} + +const banTypeConfig: Record = { + 'ip_ban': { label: 'IP Ban', description: 'Block logins from current IP address', icon: Shield }, + 'device_ban': { label: 'Device Ban', description: 'Block logins from current device', icon: Smartphone }, + 'email_blacklist': { label: 'Email Blacklist', description: 'Block signups from this email domain', icon: AtSign }, +}; + +export function BanModal({ user, open, onOpenChange, onBanned }: BanModalProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(banUserSchema), + defaultValues: { + banTypes: ['email_blacklist'], + publicReason: '', + internalEvidence: '', + }, + }); + + const watchBanTypes = form.watch('banTypes'); + + const handleBanTypeToggle = (type: BanType) => { + const current = form.getValues('banTypes'); + const updated = current.includes(type) + ? current.filter((t) => t !== type) + : [...current, type]; + form.setValue('banTypes', updated as BanType[], { shouldValidate: true }); + }; + + const onSubmit = async (data: BanUserInput) => { + if (!user) return; + + setIsSubmitting(true); + try { + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1000)); + + toast.success(`User "${user.name}" has been permanently banned`, { + description: `Ban types: ${data.banTypes.join(', ')}`, + }); + + onOpenChange(false); + onBanned?.(); + form.reset(); + } catch (error) { + toast.error('Failed to ban user'); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + if (!isSubmitting) { + onOpenChange(false); + form.reset(); + } + }; + + if (!user) return null; + + return ( + + + + + + Ban User + + + Permanently ban {user.name} from the platform. This action is severe and should be used for fraud or serious violations. + + + +
+ +
+

This action cannot be easily undone.

+

The user will be permanently blocked from the platform.

+
+
+ +
+ + ( + + Ban Type(s) * + Select one or more ban types to apply. +
+ {(Object.keys(banTypeConfig) as BanType[]).map((type) => { + const config = banTypeConfig[type]; + const isSelected = watchBanTypes.includes(type); + return ( +
handleBanTypeToggle(type)} + className={cn( + 'flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-all', + isSelected + ? 'border-red-300 bg-red-50' + : 'border-border hover:border-red-200 hover:bg-red-50/50' + )} + > + +
+
+ + {config.label} +
+

{config.description}

+
+
+ ); + })} +
+ +
+ )} + /> + + ( + + Public Reason * + + + + This will be shown to the user when they try to login. + + + )} + /> + + ( + + Internal Evidence + +