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 list with search and filtering
- - • Create/edit/delete user accounts
- - • Activate/deactivate users
- - • Role and permission management
- - • Bulk operations
-
-
+ {/* 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 (
+
+ );
+}
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'] });
+ },
+ });
+}