Introduce comprehensive user management functionality for admin

- Added React Query hooks for user-related actions: `useCreateUser`, `useUpdateUser`, `useDeleteUser`, `useActivateUser`, `useDeactivateUser`, and `useBulkUserAction`.
- Implemented primary user management components: `UserFormDialog`, `UserManagementContent`, `UserListTable`, `BulkActionToolbar`, and `UserActionMenu`.
- Replaced placeholder content on the Users page with full user management capabilities.
- Included role-based validation, search, pagination, filtering, and bulk operations.
- Enhanced form validation with `zod` schema for robust user input handling.
- Added feedback mechanisms (toasts and alert dialogs) for user actions.
- Improved UI accessibility and usability across the admin user management feature.
This commit is contained in:
Felipe Cardoso
2025-11-06 12:08:10 +01:00
parent c10c1d1c39
commit 91bc4f190d
11 changed files with 1794 additions and 39 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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() {
</div>
</div>
{/* Placeholder Content */}
<div className="rounded-lg border bg-card p-12 text-center">
<h3 className="text-xl font-semibold mb-2">
User Management Coming Soon
</h3>
<p className="text-muted-foreground max-w-md mx-auto">
This page will allow you to view all users, create new accounts,
manage permissions, and perform bulk operations.
</p>
<p className="text-sm text-muted-foreground mt-4">
Features will include:
</p>
<ul className="text-sm text-muted-foreground mt-2 max-w-sm mx-auto text-left">
<li> User list with search and filtering</li>
<li> Create/edit/delete user accounts</li>
<li> Activate/deactivate users</li>
<li> Role and permission management</li>
<li> Bulk operations</li>
</ul>
</div>
{/* User Management Content */}
<UserManagementContent />
</div>
</div>
);

View File

@@ -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<BulkAction>(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 (
<>
<div
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50"
data-testid="bulk-action-toolbar"
>
<div className="bg-background border rounded-lg shadow-lg p-4 flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{selectedCount} user{selectedCount > 1 ? 's' : ''} selected
</span>
<Button
variant="ghost"
size="sm"
onClick={onClearSelection}
aria-label="Clear selection"
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleAction('activate')}
disabled={bulkAction.isPending}
>
<CheckCircle className="mr-2 h-4 w-4" />
Activate
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleAction('deactivate')}
disabled={bulkAction.isPending}
>
<XCircle className="mr-2 h-4 w-4" />
Deactivate
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleAction('delete')}
disabled={bulkAction.isPending}
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash className="mr-2 h-4 w-4" />
Delete
</Button>
</div>
</div>
</div>
{/* Confirmation Dialog */}
<AlertDialog open={!!pendingAction} onOpenChange={() => cancelAction()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getActionTitle()}</AlertDialogTitle>
<AlertDialogDescription>
{getActionDescription()}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmAction}
className={
pendingAction === 'delete'
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: ''
}
>
{pendingAction === 'activate' && 'Activate'}
{pendingAction === 'deactivate' && 'Deactivate'}
{pendingAction === 'delete' && 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -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<ConfirmAction>(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 (
<AlertDialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={action}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{actionLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label={`Actions for ${fullName}`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Edit className="mr-2 h-4 w-4" />
Edit User
</DropdownMenuItem>
<DropdownMenuSeparator />
{user.is_active ? (
<DropdownMenuItem
onClick={() => setConfirmAction('deactivate')}
disabled={isCurrentUser}
>
<XCircle className="mr-2 h-4 w-4" />
Deactivate
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={handleActivate}>
<CheckCircle className="mr-2 h-4 w-4" />
Activate
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setConfirmAction('delete')}
disabled={isCurrentUser}
className="text-destructive focus:text-destructive"
>
<Trash className="mr-2 h-4 w-4" />
Delete User
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{renderConfirmDialog()}
</>
);
}

View File

@@ -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<typeof userFormSchema>;
// ============================================================================
// 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<UserFormData>({
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<string, unknown> = {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit User' : 'Create New User'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update user information and permissions'
: 'Add a new user to the system with specified permissions'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register('email')}
disabled={isSubmitting}
aria-invalid={errors.email ? 'true' : 'false'}
className={errors.email ? 'border-destructive' : ''}
/>
{errors.email && (
<p id="email-error" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
{/* First Name */}
<div className="space-y-2">
<Label htmlFor="first_name">First Name *</Label>
<Input
id="first_name"
{...register('first_name')}
disabled={isSubmitting}
aria-invalid={errors.first_name ? 'true' : 'false'}
className={errors.first_name ? 'border-destructive' : ''}
/>
{errors.first_name && (
<p id="first-name-error" className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
</div>
{/* Last Name */}
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
{...register('last_name')}
disabled={isSubmitting}
aria-invalid={errors.last_name ? 'true' : 'false'}
className={errors.last_name ? 'border-destructive' : ''}
/>
{errors.last_name && (
<p id="last-name-error" className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
</div>
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password">
Password {!isEdit && '*'} {isEdit && '(leave blank to keep current)'}
</Label>
<Input
id="password"
type="password"
{...register('password')}
disabled={isSubmitting}
aria-invalid={errors.password ? 'true' : 'false'}
className={errors.password ? 'border-destructive' : ''}
placeholder={isEdit ? 'Leave blank to keep current password' : ''}
/>
{errors.password && (
<p id="password-error" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
{!isEdit && (
<p className="text-xs text-muted-foreground">
Must be at least 8 characters with 1 number and 1 uppercase letter
</p>
)}
</div>
{/* Checkboxes */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="is_active"
checked={isActive}
onCheckedChange={(checked) => setValue('is_active', checked as boolean)}
disabled={isSubmitting}
/>
<Label
htmlFor="is_active"
className="text-sm font-normal cursor-pointer"
>
Active (user can log in)
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="is_superuser"
checked={isSuperuser}
onCheckedChange={(checked) => setValue('is_superuser', checked as boolean)}
disabled={isSubmitting}
/>
<Label
htmlFor="is_superuser"
className="text-sm font-normal cursor-pointer"
>
Superuser (admin privileges)
</Label>
</div>
</div>
{/* Server Error Display */}
{(createUser.isError || updateUser.isError) && (
<Alert variant="destructive">
{createUser.isError && createUser.error instanceof Error
? createUser.error.message
: updateUser.error instanceof Error
? updateUser.error.message
: 'An error occurred'}
</Alert>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? isEdit
? 'Updating...'
: 'Creating...'
: isEdit
? 'Update User'
: 'Create User'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 gap-2">
<Input
placeholder="Search by name or email..."
value={searchValue}
onChange={(e) => handleSearchChange(e.target.value)}
className="max-w-sm"
/>
<Select onValueChange={onFilterActive}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="true">Active</SelectItem>
<SelectItem value="false">Inactive</SelectItem>
</SelectContent>
</Select>
<Select onValueChange={onFilterSuperuser}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Users" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
<SelectItem value="true">Superusers</SelectItem>
<SelectItem value="false">Regular</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={allSelected}
onCheckedChange={onSelectAll}
aria-label="Select all users"
disabled={isLoading || users.length === 0}
/>
</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">Superuser</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[70px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
// Loading skeleton
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-4" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[150px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[200px]" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-[60px] mx-auto" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-4 mx-auto" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-8 w-8" />
</TableCell>
</TableRow>
))
) : users.length === 0 ? (
// Empty state
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
No users found. Try adjusting your filters.
</TableCell>
</TableRow>
) : (
// 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 (
<TableRow key={user.id}>
<TableCell>
<Checkbox
checked={selectedUsers.includes(user.id)}
onCheckedChange={() => onSelectUser(user.id)}
aria-label={`Select ${fullName}`}
disabled={isCurrentUser}
/>
</TableCell>
<TableCell className="font-medium">
{fullName}
{isCurrentUser && (
<Badge variant="outline" className="ml-2">
You
</Badge>
)}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell className="text-center">
<Badge
variant={user.is_active ? 'default' : 'secondary'}
>
{user.is_active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell className="text-center">
{user.is_superuser ? (
<Check
className="h-4 w-4 mx-auto text-green-600"
aria-label="Yes"
/>
) : (
<X
className="h-4 w-4 mx-auto text-muted-foreground"
aria-label="No"
/>
)}
</TableCell>
<TableCell>
{format(new Date(user.created_at), 'MMM d, yyyy')}
</TableCell>
<TableCell>
<UserActionMenu
user={user}
isCurrentUser={isCurrentUser}
onEdit={onEditUser}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{!isLoading && users.length > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
{Math.min(
pagination.page * pagination.page_size,
pagination.total
)}{' '}
of {pagination.total} users
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page - 1)}
disabled={!pagination.has_prev}
>
Previous
</Button>
<div className="flex items-center gap-1">
{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 (
<div key={page} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-muted-foreground">...</span>
)}
<Button
variant={
page === pagination.page ? 'default' : 'outline'
}
size="sm"
onClick={() => onPageChange(page)}
className="w-9"
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(pagination.page + 1)}
disabled={!pagination.has_next}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
const [editingUser, setEditingUser] = useState<any | null>(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<string, string | number | null>) => {
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 (
<>
<div className="space-y-6">
{/* Header with Create Button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">All Users</h2>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
<Button onClick={handleCreateUser}>
<Plus className="mr-2 h-4 w-4" />
Create User
</Button>
</div>
{/* User List Table */}
<UserListTable
users={users}
pagination={pagination}
isLoading={isLoading}
selectedUsers={selectedUsers}
onSelectUser={handleSelectUser}
onSelectAll={handleSelectAll}
onPageChange={handlePageChange}
onSearch={handleSearch}
onFilterActive={handleFilterActive}
onFilterSuperuser={handleFilterSuperuser}
onEditUser={handleEditUser}
currentUserId={currentUser?.id}
/>
</div>
{/* User Form Dialog */}
<UserFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
user={editingUser}
mode={dialogMode}
/>
{/* Bulk Action Toolbar */}
<BulkActionToolbar
selectedCount={selectedUsers.length}
onClearSelection={handleClearSelection}
selectedUserIds={selectedUsers}
/>
</>
);
}

View File

@@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -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",

View File

@@ -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'] });
},
});
}