diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb5b84f..942b8a5 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,16 +9,17 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.90.5", "axios": "^1.13.1", @@ -31,7 +32,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-hook-form": "^7.65.0", + "react-hook-form": "^7.66.0", "react-markdown": "^10.1.0", "recharts": "^2.15.4", "rehype-autolink-headings": "^7.1.0", @@ -3223,6 +3224,52 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -3329,6 +3376,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -3395,6 +3460,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -3534,12 +3617,35 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", - "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", @@ -3596,6 +3702,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", @@ -3633,6 +3757,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -3736,6 +3878,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -3810,6 +3970,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", @@ -3834,9 +4012,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -14243,9 +14421,9 @@ } }, "node_modules/react-hook-form": { - "version": "7.65.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", - "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", + "version": "7.66.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", + "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/frontend/package.json b/frontend/package.json index 1b32f0f..fdfd66d 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,16 +22,17 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-icons": "^1.3.2", - "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-query": "^5.90.5", "axios": "^1.13.1", @@ -44,7 +45,7 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-hook-form": "^7.65.0", + "react-hook-form": "^7.66.0", "react-markdown": "^10.1.0", "recharts": "^2.15.4", "rehype-autolink-headings": "^7.1.0", diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index 30056c6..497c67f 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -9,6 +9,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { ArrowLeft } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { UserManagementContent } from '@/components/admin/users/UserManagementContent'; /* istanbul ignore next - Next.js metadata, not executable code */ export const metadata: Metadata = { @@ -36,26 +37,8 @@ export default function AdminUsersPage() { - {/* Placeholder Content */} -
-

- User Management Coming Soon -

-

- This page will allow you to view all users, create new accounts, - manage permissions, and perform bulk operations. -

-

- Features will include: -

- -
+ {/* User Management Content */} + ); diff --git a/frontend/src/components/admin/users/BulkActionToolbar.tsx b/frontend/src/components/admin/users/BulkActionToolbar.tsx new file mode 100644 index 0000000..ea22677 --- /dev/null +++ b/frontend/src/components/admin/users/BulkActionToolbar.tsx @@ -0,0 +1,186 @@ +/** + * BulkActionToolbar Component + * Toolbar for performing bulk actions on selected users + */ + +'use client'; + +import { useState } from 'react'; +import { CheckCircle, XCircle, Trash, X } from 'lucide-react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { useBulkUserAction } from '@/lib/api/hooks/useAdmin'; + +interface BulkActionToolbarProps { + selectedCount: number; + onClearSelection: () => void; + selectedUserIds: string[]; +} + +type BulkAction = 'activate' | 'deactivate' | 'delete' | null; + +export function BulkActionToolbar({ + selectedCount, + onClearSelection, + selectedUserIds, +}: BulkActionToolbarProps) { + const [pendingAction, setPendingAction] = useState(null); + const bulkAction = useBulkUserAction(); + + if (selectedCount === 0) { + return null; + } + + const handleAction = (action: BulkAction) => { + setPendingAction(action); + }; + + const confirmAction = async () => { + if (!pendingAction) return; + + try { + await bulkAction.mutateAsync({ + action: pendingAction, + userIds: selectedUserIds, + }); + + toast.success( + `Successfully ${pendingAction}d ${selectedCount} user${selectedCount > 1 ? 's' : ''}` + ); + + onClearSelection(); + setPendingAction(null); + } catch (error) { + toast.error( + error instanceof Error ? error.message : `Failed to ${pendingAction} users` + ); + setPendingAction(null); + } + }; + + const cancelAction = () => { + setPendingAction(null); + }; + + const getActionDescription = () => { + switch (pendingAction) { + case 'activate': + return `Are you sure you want to activate ${selectedCount} user${selectedCount > 1 ? 's' : ''}? They will be able to log in.`; + case 'deactivate': + return `Are you sure you want to deactivate ${selectedCount} user${selectedCount > 1 ? 's' : ''}? They will not be able to log in until reactivated.`; + case 'delete': + return `Are you sure you want to delete ${selectedCount} user${selectedCount > 1 ? 's' : ''}? This action cannot be undone.`; + default: + return ''; + } + }; + + const getActionTitle = () => { + switch (pendingAction) { + case 'activate': + return 'Activate Users'; + case 'deactivate': + return 'Deactivate Users'; + case 'delete': + return 'Delete Users'; + default: + return ''; + } + }; + + return ( + <> +
+
+
+ + {selectedCount} user{selectedCount > 1 ? 's' : ''} selected + + +
+ +
+ +
+ + + +
+
+
+ + {/* Confirmation Dialog */} + cancelAction()}> + + + {getActionTitle()} + + {getActionDescription()} + + + + Cancel + + {pendingAction === 'activate' && 'Activate'} + {pendingAction === 'deactivate' && 'Deactivate'} + {pendingAction === 'delete' && 'Delete'} + + + + + + ); +} diff --git a/frontend/src/components/admin/users/UserActionMenu.tsx b/frontend/src/components/admin/users/UserActionMenu.tsx new file mode 100644 index 0000000..3a86f20 --- /dev/null +++ b/frontend/src/components/admin/users/UserActionMenu.tsx @@ -0,0 +1,191 @@ +/** + * UserActionMenu Component + * Dropdown menu for user row actions (Edit, Activate/Deactivate, Delete) + */ + +'use client'; + +import { useState } from 'react'; +import { MoreHorizontal, Edit, CheckCircle, XCircle, Trash } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { + useActivateUser, + useDeactivateUser, + useDeleteUser, +} from '@/lib/api/hooks/useAdmin'; + +interface User { + id: string; + email: string; + first_name: string; + last_name: string | null; + is_active: boolean; + is_superuser: boolean; +} + +interface UserActionMenuProps { + user: User; + isCurrentUser: boolean; + onEdit?: (user: User) => void; +} + +type ConfirmAction = 'delete' | 'deactivate' | null; + +export function UserActionMenu({ user, isCurrentUser, onEdit }: UserActionMenuProps) { + const [confirmAction, setConfirmAction] = useState(null); + const [dropdownOpen, setDropdownOpen] = useState(false); + + const activateUser = useActivateUser(); + const deactivateUser = useDeactivateUser(); + const deleteUser = useDeleteUser(); + + const fullName = user.last_name + ? `${user.first_name} ${user.last_name}` + : user.first_name; + + // Handle activate action + const handleActivate = async () => { + try { + await activateUser.mutateAsync(user.id); + toast.success(`${fullName} has been activated successfully.`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to activate user'); + } + }; + + // Handle deactivate action + const handleDeactivate = async () => { + try { + await deactivateUser.mutateAsync(user.id); + toast.success(`${fullName} has been deactivated successfully.`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to deactivate user'); + } finally { + setConfirmAction(null); + } + }; + + // Handle delete action + const handleDelete = async () => { + try { + await deleteUser.mutateAsync(user.id); + toast.success(`${fullName} has been deleted successfully.`); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete user'); + } finally { + setConfirmAction(null); + } + }; + + // Handle edit action + const handleEdit = () => { + setDropdownOpen(false); + if (onEdit) { + onEdit(user); + } + }; + + // Render confirmation dialog + const renderConfirmDialog = () => { + if (!confirmAction) return null; + + const isDelete = confirmAction === 'delete'; + const title = isDelete ? 'Delete User' : 'Deactivate User'; + const description = isDelete + ? `Are you sure you want to delete ${fullName}? This action cannot be undone.` + : `Are you sure you want to deactivate ${fullName}? They will not be able to log in until reactivated.`; + const action = isDelete ? handleDelete : handleDeactivate; + const actionLabel = isDelete ? 'Delete' : 'Deactivate'; + + return ( + setConfirmAction(null)}> + + + {title} + {description} + + + Cancel + + {actionLabel} + + + + + ); + }; + + return ( + <> + + + + + + + + Edit User + + + + + {user.is_active ? ( + setConfirmAction('deactivate')} + disabled={isCurrentUser} + > + + Deactivate + + ) : ( + + + Activate + + )} + + + + setConfirmAction('delete')} + disabled={isCurrentUser} + className="text-destructive focus:text-destructive" + > + + Delete User + + + + + {renderConfirmDialog()} + + ); +} diff --git a/frontend/src/components/admin/users/UserFormDialog.tsx b/frontend/src/components/admin/users/UserFormDialog.tsx new file mode 100644 index 0000000..0e0b29d --- /dev/null +++ b/frontend/src/components/admin/users/UserFormDialog.tsx @@ -0,0 +1,372 @@ +/** + * UserFormDialog Component + * Dialog for creating and editing users with form validation + */ + +'use client'; + +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Alert } from '@/components/ui/alert'; +import { toast } from 'sonner'; +import { + useCreateUser, + useUpdateUser, +} from '@/lib/api/hooks/useAdmin'; + +// ============================================================================ +// Validation Schema +// ============================================================================ + +const userFormSchema = z.object({ + email: z + .string() + .min(1, 'Email is required') + .email('Please enter a valid email address'), + first_name: z + .string() + .min(1, 'First name is required') + .min(2, 'First name must be at least 2 characters') + .max(50, 'First name must not exceed 50 characters'), + last_name: z + .string() + .max(50, 'Last name must not exceed 50 characters') + .optional() + .or(z.literal('')), + password: z.string(), + is_active: z.boolean(), + is_superuser: z.boolean(), +}); + +type UserFormData = z.infer; + +// ============================================================================ +// Component +// ============================================================================ + +interface User { + id: string; + email: string; + first_name: string; + last_name: string | null; + is_active: boolean; + is_superuser: boolean; +} + +interface UserFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + user?: User | null; + mode: 'create' | 'edit'; +} + +export function UserFormDialog({ + open, + onOpenChange, + user, + mode, +}: UserFormDialogProps) { + const isEdit = mode === 'edit' && user; + const createUser = useCreateUser(); + const updateUser = useUpdateUser(); + + const form = useForm({ + resolver: zodResolver(userFormSchema), + defaultValues: { + email: '', + first_name: '', + last_name: '', + password: '', + is_active: true, + is_superuser: false, + }, + }); + + // Reset form when dialog opens/closes or user changes + useEffect(() => { + if (open && isEdit) { + form.reset({ + email: user.email, + first_name: user.first_name, + last_name: user.last_name || '', + password: '', + is_active: user.is_active, + is_superuser: user.is_superuser, + }); + } else if (open && !isEdit) { + form.reset({ + email: '', + first_name: '', + last_name: '', + password: '', + is_active: true, + is_superuser: false, + }); + } + }, [open, isEdit, user, form]); + + const onSubmit = async (data: UserFormData) => { + try { + // Validate password for create mode + if (!isEdit) { + if (!data.password || data.password.length === 0) { + form.setError('password', { message: 'Password is required' }); + return; + } + if (data.password.length < 8) { + form.setError('password', { message: 'Password must be at least 8 characters' }); + return; + } + if (!/[0-9]/.test(data.password)) { + form.setError('password', { message: 'Password must contain at least one number' }); + return; + } + if (!/[A-Z]/.test(data.password)) { + form.setError('password', { message: 'Password must contain at least one uppercase letter' }); + return; + } + } + + if (isEdit) { + // Validate password if provided in edit mode + if (data.password && data.password.length > 0) { + if (data.password.length < 8) { + form.setError('password', { message: 'Password must be at least 8 characters' }); + return; + } + if (!/[0-9]/.test(data.password)) { + form.setError('password', { message: 'Password must contain at least one number' }); + return; + } + if (!/[A-Z]/.test(data.password)) { + form.setError('password', { message: 'Password must contain at least one uppercase letter' }); + return; + } + } + + // Prepare update data (exclude password if empty) + const updateData: Record = { + email: data.email, + first_name: data.first_name, + last_name: data.last_name || null, + is_active: data.is_active, + is_superuser: data.is_superuser, + }; + + // Only include password if provided + if (data.password && data.password.length > 0) { + updateData.password = data.password; + } + + await updateUser.mutateAsync({ + userId: user.id, + userData: updateData as any, + }); + + toast.success(`User ${data.first_name} ${data.last_name || ''} updated successfully`); + onOpenChange(false); + form.reset(); + } else { + // Create new user + await createUser.mutateAsync({ + email: data.email, + first_name: data.first_name, + last_name: data.last_name || undefined, + password: data.password, + is_active: data.is_active, + is_superuser: data.is_superuser, + } as any); + + toast.success(`User ${data.first_name} ${data.last_name || ''} created successfully`); + onOpenChange(false); + form.reset(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Operation failed'); + } + }; + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + watch, + setValue, + } = form; + + const isActive = watch('is_active'); + const isSuperuser = watch('is_superuser'); + + return ( + + + + {isEdit ? 'Edit User' : 'Create New User'} + + {isEdit + ? 'Update user information and permissions' + : 'Add a new user to the system with specified permissions'} + + + +
+ {/* Email */} +
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ + {/* First Name */} +
+ + + {errors.first_name && ( +

+ {errors.first_name.message} +

+ )} +
+ + {/* Last Name */} +
+ + + {errors.last_name && ( +

+ {errors.last_name.message} +

+ )} +
+ + {/* Password */} +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} + {!isEdit && ( +

+ Must be at least 8 characters with 1 number and 1 uppercase letter +

+ )} +
+ + {/* Checkboxes */} +
+
+ setValue('is_active', checked as boolean)} + disabled={isSubmitting} + /> + +
+ +
+ setValue('is_superuser', checked as boolean)} + disabled={isSubmitting} + /> + +
+
+ + {/* Server Error Display */} + {(createUser.isError || updateUser.isError) && ( + + {createUser.isError && createUser.error instanceof Error + ? createUser.error.message + : updateUser.error instanceof Error + ? updateUser.error.message + : 'An error occurred'} + + )} + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/admin/users/UserListTable.tsx b/frontend/src/components/admin/users/UserListTable.tsx new file mode 100644 index 0000000..0bd892d --- /dev/null +++ b/frontend/src/components/admin/users/UserListTable.tsx @@ -0,0 +1,318 @@ +/** + * UserListTable Component + * Displays paginated list of users with search, filters, sorting, and bulk selection + */ + +'use client'; + +import { useState, useCallback } from 'react'; +import { format } from 'date-fns'; +import { Check, X } from 'lucide-react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { UserActionMenu } from './UserActionMenu'; + +interface User { + id: string; + email: string; + first_name: string; + last_name: string | null; + is_active: boolean; + is_superuser: boolean; + created_at: string; +} + +interface PaginationMeta { + total: number; + page: number; + page_size: number; + total_pages: number; + has_next: boolean; + has_prev: boolean; +} + +interface UserListTableProps { + users: User[]; + pagination: PaginationMeta; + isLoading: boolean; + selectedUsers: string[]; + onSelectUser: (userId: string) => void; + onSelectAll: (selected: boolean) => void; + onPageChange: (page: number) => void; + onSearch: (search: string) => void; + onFilterActive: (filter: string | null) => void; + onFilterSuperuser: (filter: string | null) => void; + onEditUser?: (user: User) => void; + currentUserId?: string; +} + +export function UserListTable({ + users, + pagination, + isLoading, + selectedUsers, + onSelectUser, + onSelectAll, + onPageChange, + onSearch, + onFilterActive, + onFilterSuperuser, + onEditUser, + currentUserId, +}: UserListTableProps) { + const [searchValue, setSearchValue] = useState(''); + + // Debounce search + const handleSearchChange = useCallback( + (value: string) => { + setSearchValue(value); + const timeoutId = setTimeout(() => { + onSearch(value); + }, 300); + return () => clearTimeout(timeoutId); + }, + [onSearch] + ); + + const allSelected = + users.length > 0 && users.every((user) => selectedUsers.includes(user.id)); + const someSelected = users.some((user) => selectedUsers.includes(user.id)); + + return ( +
+ {/* Filters */} +
+
+ handleSearchChange(e.target.value)} + className="max-w-sm" + /> + + +
+
+ + {/* Table */} +
+ + + + + + + Name + Email + Status + Superuser + Created + Actions + + + + {isLoading ? ( + // Loading skeleton + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + + + + + + + + + + + + + + + + )) + ) : users.length === 0 ? ( + // Empty state + + + No users found. Try adjusting your filters. + + + ) : ( + // User rows + users.map((user) => { + const isCurrentUser = currentUserId === user.id; + const fullName = user.last_name + ? `${user.first_name} ${user.last_name}` + : user.first_name; + + return ( + + + onSelectUser(user.id)} + aria-label={`Select ${fullName}`} + disabled={isCurrentUser} + /> + + + {fullName} + {isCurrentUser && ( + + You + + )} + + {user.email} + + + {user.is_active ? 'Active' : 'Inactive'} + + + + {user.is_superuser ? ( + + ) : ( + + )} + + + {format(new Date(user.created_at), 'MMM d, yyyy')} + + + + + + ); + }) + )} + +
+
+ + {/* Pagination */} + {!isLoading && users.length > 0 && ( +
+
+ Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '} + {Math.min( + pagination.page * pagination.page_size, + pagination.total + )}{' '} + of {pagination.total} users +
+
+ +
+ {Array.from({ length: pagination.total_pages }, (_, i) => i + 1) + .filter( + (page) => + page === 1 || + page === pagination.total_pages || + Math.abs(page - pagination.page) <= 1 + ) + .map((page, idx, arr) => { + const prevPage = arr[idx - 1]; + const showEllipsis = prevPage && page - prevPage > 1; + + return ( +
+ {showEllipsis && ( + ... + )} + +
+ ); + })} +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/admin/users/UserManagementContent.tsx b/frontend/src/components/admin/users/UserManagementContent.tsx new file mode 100644 index 0000000..f25e2f6 --- /dev/null +++ b/frontend/src/components/admin/users/UserManagementContent.tsx @@ -0,0 +1,170 @@ +/** + * UserManagementContent Component + * Client-side content for the user management page + */ + +'use client'; + +import { useState, useCallback } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { useAdminUsers } from '@/lib/api/hooks/useAdmin'; +import { UserListTable } from './UserListTable'; +import { UserFormDialog } from './UserFormDialog'; +import { BulkActionToolbar } from './BulkActionToolbar'; + +export function UserManagementContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { user: currentUser } = useAuth(); + + // URL state + const page = parseInt(searchParams.get('page') || '1', 10); + const searchQuery = searchParams.get('search') || ''; + const filterActive = searchParams.get('active') || null; + const filterSuperuser = searchParams.get('superuser') || null; + + // Local state + const [selectedUsers, setSelectedUsers] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create'); + const [editingUser, setEditingUser] = useState(null); + + // Fetch users with query params + const { data, isLoading } = useAdminUsers(page, 20); + + const users = data?.data || []; + const pagination = data?.pagination || { + total: 0, + page: 1, + page_size: 20, + total_pages: 1, + has_next: false, + has_prev: false, + }; + + // URL update helper + const updateURL = useCallback( + (params: Record) => { + const newParams = new URLSearchParams(searchParams.toString()); + + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === '' || value === 'all') { + newParams.delete(key); + } else { + newParams.set(key, String(value)); + } + }); + + router.push(`?${newParams.toString()}`); + }, + [searchParams, router] + ); + + // Handlers + const handleSelectUser = (userId: string) => { + setSelectedUsers((prev) => + prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId] + ); + }; + + const handleSelectAll = (selected: boolean) => { + if (selected) { + const selectableUsers = users + .filter((u: any) => u.id !== currentUser?.id) + .map((u: any) => u.id); + setSelectedUsers(selectableUsers); + } else { + setSelectedUsers([]); + } + }; + + const handlePageChange = (newPage: number) => { + updateURL({ page: newPage }); + setSelectedUsers([]); // Clear selection on page change + }; + + const handleSearch = (search: string) => { + updateURL({ search, page: 1 }); // Reset to page 1 on search + setSelectedUsers([]); + }; + + const handleFilterActive = (filter: string | null) => { + updateURL({ active: filter === 'all' ? null : filter, page: 1 }); + setSelectedUsers([]); + }; + + const handleFilterSuperuser = (filter: string | null) => { + updateURL({ superuser: filter === 'all' ? null : filter, page: 1 }); + setSelectedUsers([]); + }; + + const handleCreateUser = () => { + setDialogMode('create'); + setEditingUser(null); + setDialogOpen(true); + }; + + const handleEditUser = (user: any) => { + setDialogMode('edit'); + setEditingUser(user); + setDialogOpen(true); + }; + + const handleClearSelection = () => { + setSelectedUsers([]); + }; + + return ( + <> +
+ {/* Header with Create Button */} +
+
+

All Users

+

+ Manage user accounts and permissions +

+
+ +
+ + {/* User List Table */} + +
+ + {/* User Form Dialog */} + + + {/* Bulk Action Toolbar */} + + + ); +} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..4c2ff26 --- /dev/null +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils/index" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx index 65d4fcd..9fef397 100755 --- a/frontend/src/components/ui/button.tsx +++ b/frontend/src/components/ui/button.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils/index" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", diff --git a/frontend/src/lib/api/hooks/useAdmin.tsx b/frontend/src/lib/api/hooks/useAdmin.tsx index f9f03c8..4fa74f0 100644 --- a/frontend/src/lib/api/hooks/useAdmin.tsx +++ b/frontend/src/lib/api/hooks/useAdmin.tsx @@ -11,8 +11,20 @@ 'use client'; -import { useQuery } from '@tanstack/react-query'; -import { adminListUsers, adminListOrganizations } from '@/lib/api/client'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + adminListUsers, + adminListOrganizations, + adminCreateUser, + adminGetUser, + adminUpdateUser, + adminDeleteUser, + adminActivateUser, + adminDeactivateUser, + adminBulkUserAction, + type UserCreate, + type UserUpdate, +} from '@/lib/api/client'; import { useAuth } from '@/lib/auth/AuthContext'; /** @@ -170,3 +182,190 @@ export function useAdminOrganizations(page = 1, limit = DEFAULT_PAGE_LIMIT) { enabled: user?.is_superuser === true, }); } + +/** + * Hook to create a new user (admin only) + * + * @returns Mutation hook for creating users + */ +export function useCreateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (userData: UserCreate) => { + const response = await adminCreateUser({ + body: userData, + throwOnError: false, + }); + + if ('error' in response) { + throw new Error('Failed to create user'); + } + + return (response as { data: unknown }).data; + }, + onSuccess: () => { + // Invalidate user queries to refetch + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] }); + }, + }); +} + +/** + * Hook to update an existing user (admin only) + * + * @returns Mutation hook for updating users + */ +export function useUpdateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + userId, + userData, + }: { + userId: string; + userData: UserUpdate; + }) => { + const response = await adminUpdateUser({ + path: { user_id: userId }, + body: userData, + throwOnError: false, + }); + + if ('error' in response) { + throw new Error('Failed to update user'); + } + + return (response as { data: unknown }).data; + }, + onSuccess: () => { + // Invalidate user queries to refetch + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] }); + }, + }); +} + +/** + * Hook to delete a user (admin only) + * + * @returns Mutation hook for deleting users + */ +export function useDeleteUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (userId: string) => { + const response = await adminDeleteUser({ + path: { user_id: userId }, + throwOnError: false, + }); + + if ('error' in response) { + throw new Error('Failed to delete user'); + } + + return (response as { data: unknown }).data; + }, + onSuccess: () => { + // Invalidate user queries to refetch + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] }); + }, + }); +} + +/** + * Hook to activate a user (admin only) + * + * @returns Mutation hook for activating users + */ +export function useActivateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (userId: string) => { + const response = await adminActivateUser({ + path: { user_id: userId }, + throwOnError: false, + }); + + if ('error' in response) { + throw new Error('Failed to activate user'); + } + + return (response as { data: unknown }).data; + }, + onSuccess: () => { + // Invalidate user queries to refetch + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] }); + }, + }); +} + +/** + * Hook to deactivate a user (admin only) + * + * @returns Mutation hook for deactivating users + */ +export function useDeactivateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (userId: string) => { + const response = await adminDeactivateUser({ + path: { user_id: userId }, + throwOnError: false, + }); + + if ('error' in response) { + throw new Error('Failed to deactivate user'); + } + + return (response as { data: unknown }).data; + }, + onSuccess: () => { + // Invalidate user queries to refetch + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] }); + }, + }); +} + +/** + * Hook to perform bulk actions on users (admin only) + * + * @returns Mutation hook for bulk user actions + */ +export function useBulkUserAction() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + action, + userIds, + }: { + action: 'activate' | 'deactivate' | 'delete'; + userIds: string[]; + }) => { + const response = await adminBulkUserAction({ + body: { action, user_ids: userIds }, + throwOnError: false, + }); + + if ('error' in response) { + throw new Error('Failed to perform bulk action'); + } + + return (response as { data: unknown }).data; + }, + onSuccess: () => { + // Invalidate user queries to refetch + queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'stats'] }); + }, + }); +}