Compare commits

...

5 Commits

Author SHA1 Message Date
Felipe Cardoso
f22f87250c Refactor and centralize user and pagination interfaces in useAdmin hook
- Unified `User` and `PaginationMeta` type definitions into `useAdmin` to improve maintainability and consistency.
- Updated affected components (`UserManagementContent`, `UserListTable`, `UserFormDialog`, `UserActionMenu`) to reference the centralized types.
- Enhanced test coverage for user-related hooks to include create, update, delete, activate, deactivate, and bulk actions.
2025-11-06 12:49:46 +01:00
Felipe Cardoso
91bc4f190d 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.
2025-11-06 12:08:10 +01:00
Felipe Cardoso
c10c1d1c39 Adjust STATS_FETCH_LIMIT in admin hooks to align with backend pagination limits 2025-11-06 11:11:26 +01:00
Felipe Cardoso
dde091138e Refine organization query to count only active members using CASE statement 2025-11-06 11:11:18 +01:00
Felipe Cardoso
9c72fe87f9 Add admin UX improvements, constants refactor, and comprehensive tests
- Introduced constants for admin hooks: `STATS_FETCH_LIMIT`, `DEFAULT_PAGE_LIMIT`, and `STATS_REFETCH_INTERVAL` to enhance readability and maintainability.
- Updated query guards to ensure data fetching is restricted to superusers.
- Enhanced accessibility across admin components by adding `aria-hidden` attributes and improving focus-visible styles.
- Simplified `useAdminStats`, `useAdminUsers`, and `useAdminOrganizations` with shared constants.
- Added 403 Forbidden page with proper structure, styling, and tests.
- Implemented new tests for admin hooks, DashboardStats, AdminLayout, and ForbiddenPage for better coverage.
2025-11-06 10:08:43 +01:00
25 changed files with 2921 additions and 86 deletions

View File

@@ -4,7 +4,7 @@ import logging
from typing import Optional, List, Dict, Any
from uuid import UUID
from sqlalchemy import func, or_, and_, select
from sqlalchemy import func, or_, and_, select, case
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -149,15 +149,16 @@ class CRUDOrganization(CRUDBase[Organization, OrganizationCreate, OrganizationUp
"""
try:
# Build base query with LEFT JOIN and GROUP BY
# Use CASE statement to count only active members
query = (
select(
Organization,
func.count(
func.distinct(
and_(
UserOrganization.is_active == True,
UserOrganization.user_id
).self_group()
case(
(UserOrganization.is_active == True, UserOrganization.user_id),
else_=None
)
)
).label('member_count')
)

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

@@ -24,13 +24,19 @@ export default function AdminLayout({
}) {
return (
<AuthGuard requireAdmin>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
Skip to main content
</a>
<div className="flex min-h-screen flex-col">
<Header />
<div className="flex flex-1">
<AdminSidebar />
<div className="flex flex-1 flex-col">
<Breadcrumbs />
<main className="flex-1 overflow-y-auto">
<main id="main-content" className="flex-1 overflow-y-auto">
{children}
</main>
</div>

View File

@@ -22,8 +22,8 @@ export default function AdminOrganizationsPage() {
{/* Back Button + Header */}
<div className="flex items-center gap-4">
<Link href="/admin">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
<Button variant="outline" size="icon" aria-label="Back to Admin Dashboard">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
</Link>
<div>

View File

@@ -39,7 +39,7 @@ export default function AdminPage() {
<Link href="/admin/users" className="block">
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
<div className="flex items-center gap-3 mb-2">
<Users className="h-5 w-5 text-primary" />
<Users className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="font-semibold">User Management</h3>
</div>
<p className="text-sm text-muted-foreground">
@@ -51,7 +51,7 @@ export default function AdminPage() {
<Link href="/admin/organizations" className="block">
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
<div className="flex items-center gap-3 mb-2">
<Building2 className="h-5 w-5 text-primary" />
<Building2 className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="font-semibold">Organizations</h3>
</div>
<p className="text-sm text-muted-foreground">
@@ -63,7 +63,7 @@ export default function AdminPage() {
<Link href="/admin/settings" className="block">
<div className="rounded-lg border bg-card p-6 transition-colors hover:bg-accent cursor-pointer">
<div className="flex items-center gap-3 mb-2">
<Settings className="h-5 w-5 text-primary" />
<Settings className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="font-semibold">System Settings</h3>
</div>
<p className="text-sm text-muted-foreground">

View File

@@ -22,8 +22,8 @@ export default function AdminSettingsPage() {
{/* Back Button + Header */}
<div className="flex items-center gap-4">
<Link href="/admin">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
<Button variant="outline" size="icon" aria-label="Back to Admin Dashboard">
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
</Button>
</Link>
<div>

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,53 @@
/**
* 403 Forbidden Page
* Displayed when users try to access resources they don't have permission for
*/
/* istanbul ignore next - Next.js type import for metadata */
import type { Metadata } from 'next';
import Link from 'next/link';
import { ShieldAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
/* istanbul ignore next - Next.js metadata, not executable code */
export const metadata: Metadata = {
title: '403 - Forbidden',
description: 'You do not have permission to access this resource',
};
export default function ForbiddenPage() {
return (
<div className="container mx-auto px-6 py-16">
<div className="flex min-h-[60vh] flex-col items-center justify-center text-center">
<div className="mb-8 rounded-full bg-destructive/10 p-6">
<ShieldAlert
className="h-16 w-16 text-destructive"
aria-hidden="true"
/>
</div>
<h1 className="mb-4 text-4xl font-bold tracking-tight">
403 - Access Forbidden
</h1>
<p className="mb-2 text-lg text-muted-foreground max-w-md">
You don&apos;t have permission to access this resource.
</p>
<p className="mb-8 text-sm text-muted-foreground max-w-md">
This page requires administrator privileges. If you believe you should
have access, please contact your system administrator.
</p>
<div className="flex gap-4">
<Button asChild variant="default">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
<Button asChild variant="outline">
<Link href="/">Go to Home</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -69,14 +69,14 @@ export function AdminSidebar() {
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="rounded-md p-2 hover:bg-accent"
className="rounded-md p-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
data-testid="sidebar-toggle"
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronLeft className="h-4 w-4" />
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
@@ -96,6 +96,7 @@ export function AdminSidebar() {
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground',
@@ -104,7 +105,7 @@ export function AdminSidebar() {
title={collapsed ? item.name : undefined}
data-testid={`nav-${item.name.toLowerCase()}`}
>
<Icon className="h-5 w-5 flex-shrink-0" />
<Icon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
{!collapsed && <span>{item.name}</span>}
</Link>
);

View File

@@ -17,7 +17,7 @@ export function DashboardStats() {
if (isError) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertCircle className="h-4 w-4" aria-hidden="true" />
<AlertDescription>
Failed to load dashboard statistics: {error?.message || 'Unknown error'}
</AlertDescription>

View File

@@ -90,6 +90,7 @@ export function StatCard({
'h-6 w-6',
loading ? 'text-muted-foreground' : 'text-primary'
)}
aria-hidden="true"
/>
</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,183 @@
/**
* 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,
type User,
} from '@/lib/api/hooks/useAdmin';
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,364 @@
/**
* 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,
type User,
} 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 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,300 @@
/**
* 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';
import type { User, PaginationMeta } from '@/lib/api/hooks/useAdmin';
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, type User, type PaginationMeta } 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<User | null>(null);
// Fetch users with query params
const { data, isLoading } = useAdminUsers(page, 20);
const users: User[] = data?.data || [];
const pagination: PaginationMeta = 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: User) => {
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,28 @@
'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';
/**
* Constants for admin hooks
*/
const STATS_FETCH_LIMIT = 100; // Maximum allowed by backend pagination (use pagination.total for actual count)
const STATS_REFETCH_INTERVAL = 30000; // 30 seconds - refetch interval for near real-time stats
const DEFAULT_PAGE_LIMIT = 50; // Default number of records per page for paginated lists
/**
* Admin Stats interface
@@ -31,6 +51,8 @@ export interface AdminStats {
* @returns Admin statistics including user and organization counts
*/
export function useAdminStats() {
const { user } = useAuth();
return useQuery({
queryKey: ['admin', 'stats'],
queryFn: async (): Promise<AdminStats> => {
@@ -39,7 +61,7 @@ export function useAdminStats() {
const usersResponse = await adminListUsers({
query: {
page: 1,
limit: 10000, // High limit to get all users for stats
limit: STATS_FETCH_LIMIT,
},
throwOnError: false,
});
@@ -58,7 +80,7 @@ export function useAdminStats() {
const orgsResponse = await adminListOrganizations({
query: {
page: 1,
limit: 10000, // High limit to get all orgs for stats
limit: STATS_FETCH_LIMIT,
},
throwOnError: false,
});
@@ -93,12 +115,47 @@ export function useAdminStats() {
};
},
// Refetch every 30 seconds for near real-time stats
refetchInterval: 30000,
refetchInterval: STATS_REFETCH_INTERVAL,
// Keep previous data while refetching to avoid UI flicker
placeholderData: (previousData) => previousData,
// Only fetch if user is a superuser (frontend guard)
enabled: user?.is_superuser === true,
});
}
/**
* Pagination metadata structure
*/
export interface PaginationMeta {
total: number;
page: number;
page_size: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
/**
* User interface matching backend UserResponse
*/
export interface User {
id: string;
email: string;
first_name: string;
last_name: string | null;
is_active: boolean;
is_superuser: boolean;
created_at: string;
}
/**
* Paginated user list response
*/
export interface PaginatedUserResponse {
data: User[];
pagination: PaginationMeta;
}
/**
* Hook to fetch paginated list of all users (for admin)
*
@@ -106,10 +163,12 @@ export function useAdminStats() {
* @param limit - Number of records per page
* @returns Paginated list of users
*/
export function useAdminUsers(page = 1, limit = 50) {
export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) {
const { user } = useAuth();
return useQuery({
queryKey: ['admin', 'users', page, limit],
queryFn: async () => {
queryFn: async (): Promise<PaginatedUserResponse> => {
const response = await adminListUsers({
query: { page, limit },
throwOnError: false,
@@ -120,8 +179,10 @@ export function useAdminUsers(page = 1, limit = 50) {
}
// Type assertion: if no error, response has data
return (response as { data: unknown }).data;
return (response as { data: PaginatedUserResponse }).data;
},
// Only fetch if user is a superuser (frontend guard)
enabled: user?.is_superuser === true,
});
}
@@ -132,7 +193,9 @@ export function useAdminUsers(page = 1, limit = 50) {
* @param limit - Number of records per page
* @returns Paginated list of organizations
*/
export function useAdminOrganizations(page = 1, limit = 50) {
export function useAdminOrganizations(page = 1, limit = DEFAULT_PAGE_LIMIT) {
const { user } = useAuth();
return useQuery({
queryKey: ['admin', 'organizations', page, limit],
queryFn: async () => {
@@ -148,5 +211,194 @@ export function useAdminOrganizations(page = 1, limit = 50) {
// Type assertion: if no error, response has data
return (response as { data: unknown }).data;
},
// Only fetch if user is a superuser (frontend guard)
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'] });
},
});
}

View File

@@ -0,0 +1,169 @@
/**
* Tests for Admin Layout
* Verifies layout rendering, auth guard, and accessibility features
*/
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AdminLayout from '@/app/admin/layout';
import { useAuth } from '@/lib/auth/AuthContext';
// Mock dependencies
jest.mock('@/lib/auth/AuthContext');
jest.mock('@/components/layout/Header', () => ({
Header: () => <header data-testid="header">Header</header>,
}));
jest.mock('@/components/layout/Footer', () => ({
Footer: () => <footer data-testid="footer">Footer</footer>,
}));
jest.mock('@/components/admin/AdminSidebar', () => ({
AdminSidebar: () => <aside data-testid="sidebar">Sidebar</aside>,
}));
jest.mock('@/components/admin/Breadcrumbs', () => ({
Breadcrumbs: () => <div data-testid="breadcrumbs">Breadcrumbs</div>,
}));
// Mock next/navigation
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => '/admin',
}));
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
describe('AdminLayout', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
it('renders layout with all components for superuser', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
expect(screen.getByTestId('header')).toBeInTheDocument();
expect(screen.getByTestId('footer')).toBeInTheDocument();
expect(screen.getByTestId('sidebar')).toBeInTheDocument();
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
expect(screen.getByText('Test Content')).toBeInTheDocument();
});
it('renders skip link with correct attributes', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
const skipLink = screen.getByText('Skip to main content');
expect(skipLink).toBeInTheDocument();
expect(skipLink).toHaveAttribute('href', '#main-content');
expect(skipLink).toHaveClass('sr-only');
});
it('renders main element with id', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { container } = render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
const mainElement = container.querySelector('#main-content');
expect(mainElement).toBeInTheDocument();
expect(mainElement?.tagName).toBe('MAIN');
});
it('renders children inside main content area', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
render(
<AdminLayout>
<div data-testid="child-content">Child Content</div>
</AdminLayout>,
{ wrapper }
);
const mainElement = screen.getByTestId('child-content').closest('main');
expect(mainElement).toHaveAttribute('id', 'main-content');
});
it('applies correct layout structure classes', () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { container } = render(
<AdminLayout>
<div>Test Content</div>
</AdminLayout>,
{ wrapper }
);
// Check root container has min-height class
const rootDiv = container.querySelector('.min-h-screen');
expect(rootDiv).toBeInTheDocument();
expect(rootDiv).toHaveClass('flex', 'flex-col');
// Check main content area has flex and overflow classes
const mainElement = container.querySelector('#main-content');
expect(mainElement).toHaveClass('flex-1', 'overflow-y-auto');
});
});

View File

@@ -4,35 +4,44 @@
*/
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import AdminPage from '@/app/admin/page';
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
// Helper function to render with QueryClientProvider
function renderWithQueryClient(component: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Mock the useAdminStats hook
jest.mock('@/lib/api/hooks/useAdmin');
return render(
<QueryClientProvider client={queryClient}>
{component}
</QueryClientProvider>
);
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
// Helper function to render with default mocked stats
function renderWithMockedStats() {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 100,
activeUsers: 80,
totalOrganizations: 20,
totalSessions: 30,
},
isLoading: false,
isError: false,
error: null,
} as any);
return render(<AdminPage />);
}
describe('AdminPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders admin dashboard title', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('Admin Dashboard')).toBeInTheDocument();
});
it('renders description text', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(
screen.getByText('Manage users, organizations, and system settings')
@@ -40,13 +49,13 @@ describe('AdminPage', () => {
});
it('renders quick actions section', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
});
it('renders user management card', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('User Management')).toBeInTheDocument();
expect(
@@ -55,7 +64,7 @@ describe('AdminPage', () => {
});
it('renders organizations card', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
// Check for the quick actions card (not the stat card)
expect(
@@ -64,7 +73,7 @@ describe('AdminPage', () => {
});
it('renders system settings card', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
expect(screen.getByText('System Settings')).toBeInTheDocument();
expect(
@@ -73,7 +82,7 @@ describe('AdminPage', () => {
});
it('renders quick actions in grid layout', () => {
renderWithQueryClient(<AdminPage />);
renderWithMockedStats();
// Check for Quick Actions heading which is above the grid
expect(screen.getByText('Quick Actions')).toBeInTheDocument();
@@ -84,7 +93,7 @@ describe('AdminPage', () => {
});
it('renders with proper container structure', () => {
const { container } = renderWithQueryClient(<AdminPage />);
const { container } = renderWithMockedStats();
const containerDiv = container.querySelector('.container');
expect(containerDiv).toBeInTheDocument();

View File

@@ -0,0 +1,66 @@
/**
* Tests for 403 Forbidden Page
* Verifies rendering of access forbidden message and navigation
*/
import { render, screen } from '@testing-library/react';
import ForbiddenPage from '@/app/forbidden/page';
describe('ForbiddenPage', () => {
it('renders page heading', () => {
render(<ForbiddenPage />);
expect(
screen.getByRole('heading', { name: /403 - Access Forbidden/i })
).toBeInTheDocument();
});
it('renders permission denied message', () => {
render(<ForbiddenPage />);
expect(
screen.getByText(/You don't have permission to access this resource/)
).toBeInTheDocument();
});
it('renders admin privileges message', () => {
render(<ForbiddenPage />);
expect(
screen.getByText(/This page requires administrator privileges/)
).toBeInTheDocument();
});
it('renders link to dashboard', () => {
render(<ForbiddenPage />);
const dashboardLink = screen.getByRole('link', {
name: /Go to Dashboard/i,
});
expect(dashboardLink).toBeInTheDocument();
expect(dashboardLink).toHaveAttribute('href', '/dashboard');
});
it('renders link to home', () => {
render(<ForbiddenPage />);
const homeLink = screen.getByRole('link', { name: /Go to Home/i });
expect(homeLink).toBeInTheDocument();
expect(homeLink).toHaveAttribute('href', '/');
});
it('renders shield alert icon with aria-hidden', () => {
const { container } = render(<ForbiddenPage />);
const icon = container.querySelector('[aria-hidden="true"]');
expect(icon).toBeInTheDocument();
});
it('renders with proper container structure', () => {
const { container } = render(<ForbiddenPage />);
const containerDiv = container.querySelector('.container');
expect(containerDiv).toBeInTheDocument();
expect(containerDiv).toHaveClass('mx-auto', 'px-6', 'py-16');
});
});

View File

@@ -0,0 +1,157 @@
/**
* Tests for DashboardStats Component
* Verifies dashboard statistics display and error handling
*/
import { render, screen } from '@testing-library/react';
import { DashboardStats } from '@/components/admin/DashboardStats';
import { useAdminStats } from '@/lib/api/hooks/useAdmin';
// Mock the useAdminStats hook
jest.mock('@/lib/api/hooks/useAdmin');
const mockUseAdminStats = useAdminStats as jest.MockedFunction<typeof useAdminStats>;
describe('DashboardStats', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all stat cards with data', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 150,
activeUsers: 120,
totalOrganizations: 25,
totalSessions: 45,
},
isLoading: false,
isError: false,
error: null,
} as any);
render(<DashboardStats />);
// Check stat cards are rendered
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('All registered users')).toBeInTheDocument();
expect(screen.getByText('Active Users')).toBeInTheDocument();
expect(screen.getByText('120')).toBeInTheDocument();
expect(screen.getByText('Users with active status')).toBeInTheDocument();
expect(screen.getByText('Organizations')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
expect(screen.getByText('Total organizations')).toBeInTheDocument();
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
expect(screen.getByText('45')).toBeInTheDocument();
expect(screen.getByText('Current active sessions')).toBeInTheDocument();
});
it('renders loading state', () => {
mockUseAdminStats.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
error: null,
} as any);
render(<DashboardStats />);
// StatCard component should render loading state
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('Active Users')).toBeInTheDocument();
expect(screen.getByText('Organizations')).toBeInTheDocument();
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
});
it('renders error state', () => {
mockUseAdminStats.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error('Network error occurred'),
} as any);
render(<DashboardStats />);
expect(screen.getByText(/Failed to load dashboard statistics/)).toBeInTheDocument();
expect(screen.getByText(/Network error occurred/)).toBeInTheDocument();
});
it('renders error state with default message when error message is missing', () => {
mockUseAdminStats.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: {} as any,
} as any);
render(<DashboardStats />);
expect(screen.getByText(/Failed to load dashboard statistics/)).toBeInTheDocument();
expect(screen.getByText(/Unknown error/)).toBeInTheDocument();
});
it('renders with zero values', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 0,
activeUsers: 0,
totalOrganizations: 0,
totalSessions: 0,
},
isLoading: false,
isError: false,
error: null,
} as any);
render(<DashboardStats />);
// Check all zeros are displayed
const zeroValues = screen.getAllByText('0');
expect(zeroValues.length).toBe(4); // 4 stat cards with 0 value
});
it('renders with dashboard-stats test id', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 100,
activeUsers: 80,
totalOrganizations: 20,
totalSessions: 30,
},
isLoading: false,
isError: false,
error: null,
} as any);
const { container } = render(<DashboardStats />);
const dashboardStats = container.querySelector('[data-testid="dashboard-stats"]');
expect(dashboardStats).toBeInTheDocument();
expect(dashboardStats).toHaveClass('grid', 'gap-4', 'md:grid-cols-2', 'lg:grid-cols-4');
});
it('renders icons with aria-hidden', () => {
mockUseAdminStats.mockReturnValue({
data: {
totalUsers: 100,
activeUsers: 80,
totalOrganizations: 20,
totalSessions: 30,
},
isLoading: false,
isError: false,
error: null,
} as any);
const { container } = render(<DashboardStats />);
// Check that icons have aria-hidden attribute
const icons = container.querySelectorAll('[aria-hidden="true"]');
expect(icons.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,598 @@
/**
* Tests for useAdmin hooks
* Verifies admin statistics and list fetching functionality
*/
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
useAdminStats,
useAdminUsers,
useAdminOrganizations,
useCreateUser,
useUpdateUser,
useDeleteUser,
useActivateUser,
useDeactivateUser,
useBulkUserAction,
} from '@/lib/api/hooks/useAdmin';
import {
adminListUsers,
adminListOrganizations,
adminCreateUser,
adminUpdateUser,
adminDeleteUser,
adminActivateUser,
adminDeactivateUser,
adminBulkUserAction,
} from '@/lib/api/client';
import { useAuth } from '@/lib/auth/AuthContext';
// Mock dependencies
jest.mock('@/lib/api/client');
jest.mock('@/lib/auth/AuthContext');
const mockAdminListUsers = adminListUsers as jest.MockedFunction<typeof adminListUsers>;
const mockAdminListOrganizations = adminListOrganizations as jest.MockedFunction<typeof adminListOrganizations>;
const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>;
describe('useAdmin hooks', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
jest.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('useAdminStats', () => {
const mockUsersData = {
data: {
data: [
{ is_active: true },
{ is_active: true },
{ is_active: false },
],
pagination: { total: 3, page: 1, limit: 10000 },
},
};
const mockOrgsData = {
data: {
pagination: { total: 5 },
},
};
it('fetches and calculates stats when user is superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockUsersData as any);
mockAdminListOrganizations.mockResolvedValue(mockOrgsData as any);
const { result } = renderHook(() => useAdminStats(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({
totalUsers: 3,
activeUsers: 2,
totalOrganizations: 5,
totalSessions: 0,
});
expect(mockAdminListUsers).toHaveBeenCalledWith({
query: { page: 1, limit: 100 },
throwOnError: false,
});
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
query: { page: 1, limit: 100 },
throwOnError: false,
});
});
it('does not fetch when user is not superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: false } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminStats(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListUsers).not.toHaveBeenCalled();
expect(mockAdminListOrganizations).not.toHaveBeenCalled();
});
it('does not fetch when user is null', async () => {
mockUseAuth.mockReturnValue({
user: null,
isAuthenticated: false,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminStats(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListUsers).not.toHaveBeenCalled();
});
it('handles users API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue({ error: 'Users fetch failed' } as any);
const { result } = renderHook(() => useAdminStats(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
it('handles organizations API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockUsersData as any);
mockAdminListOrganizations.mockResolvedValue({ error: 'Orgs fetch failed' } as any);
const { result } = renderHook(() => useAdminStats(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
});
describe('useAdminUsers', () => {
const mockResponse = {
data: {
data: [{ id: '1' }, { id: '2' }],
pagination: { total: 2, page: 1, limit: 50 },
},
};
it('fetches users when user is superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useAdminUsers(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse.data);
expect(mockAdminListUsers).toHaveBeenCalledWith({
query: { page: 1, limit: 50 },
throwOnError: false,
});
});
it('uses custom page and limit parameters', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue(mockResponse as any);
renderHook(() => useAdminUsers(2, 100), { wrapper });
await waitFor(() => {
expect(mockAdminListUsers).toHaveBeenCalledWith({
query: { page: 2, limit: 100 },
throwOnError: false,
});
});
});
it('does not fetch when user is not superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: false } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminUsers(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListUsers).not.toHaveBeenCalled();
});
it('handles API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListUsers.mockResolvedValue({ error: 'Fetch failed' } as any);
const { result } = renderHook(() => useAdminUsers(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
});
describe('useAdminOrganizations', () => {
const mockResponse = {
data: {
data: [{ id: '1' }, { id: '2' }],
pagination: { total: 2, page: 1, limit: 50 },
},
};
it('fetches organizations when user is superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListOrganizations.mockResolvedValue(mockResponse as any);
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockResponse.data);
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
query: { page: 1, limit: 50 },
throwOnError: false,
});
});
it('uses custom page and limit parameters', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListOrganizations.mockResolvedValue(mockResponse as any);
renderHook(() => useAdminOrganizations(3, 25), { wrapper });
await waitFor(() => {
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
query: { page: 3, limit: 25 },
throwOnError: false,
});
});
});
it('does not fetch when user is not superuser', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: false } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toBeUndefined();
expect(mockAdminListOrganizations).not.toHaveBeenCalled();
});
it('handles API error', async () => {
mockUseAuth.mockReturnValue({
user: { is_superuser: true } as any,
isAuthenticated: true,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
});
mockAdminListOrganizations.mockResolvedValue({ error: 'Fetch failed' } as any);
const { result } = renderHook(() => useAdminOrganizations(), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error).toBeDefined();
});
});
describe('useCreateUser', () => {
it('creates a user successfully', async () => {
const mockCreateUser = adminCreateUser as jest.MockedFunction<typeof adminCreateUser>;
mockCreateUser.mockResolvedValue({
data: { id: '1', email: 'newuser@example.com', first_name: 'New', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' },
} as any);
const { result } = renderHook(() => useCreateUser(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
email: 'newuser@example.com',
first_name: 'New',
last_name: 'User',
password: 'Password123',
is_active: true,
is_superuser: false,
});
});
expect(mockCreateUser).toHaveBeenCalledWith({
body: {
email: 'newuser@example.com',
first_name: 'New',
last_name: 'User',
password: 'Password123',
is_active: true,
is_superuser: false,
},
throwOnError: false,
});
});
it('handles create error', async () => {
const mockCreateUser = adminCreateUser as jest.MockedFunction<typeof adminCreateUser>;
mockCreateUser.mockResolvedValue({ error: 'Create failed' } as any);
const { result } = renderHook(() => useCreateUser(), { wrapper });
await expect(
result.current.mutateAsync({
email: 'test@example.com',
first_name: 'Test',
password: 'Password123',
is_active: true,
is_superuser: false,
})
).rejects.toThrow('Failed to create user');
});
});
describe('useUpdateUser', () => {
it('updates a user successfully', async () => {
const mockUpdateUser = adminUpdateUser as jest.MockedFunction<typeof adminUpdateUser>;
mockUpdateUser.mockResolvedValue({
data: { id: '1', email: 'updated@example.com', first_name: 'Updated', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' },
} as any);
const { result } = renderHook(() => useUpdateUser(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
userId: '1',
userData: {
email: 'updated@example.com',
first_name: 'Updated',
},
});
});
expect(mockUpdateUser).toHaveBeenCalledWith({
path: { user_id: '1' },
body: {
email: 'updated@example.com',
first_name: 'Updated',
},
throwOnError: false,
});
});
it('handles update error', async () => {
const mockUpdateUser = adminUpdateUser as jest.MockedFunction<typeof adminUpdateUser>;
mockUpdateUser.mockResolvedValue({ error: 'Update failed' } as any);
const { result } = renderHook(() => useUpdateUser(), { wrapper });
await expect(
result.current.mutateAsync({
userId: '1',
userData: { email: 'test@example.com' },
})
).rejects.toThrow('Failed to update user');
});
});
describe('useDeleteUser', () => {
it('deletes a user successfully', async () => {
const mockDeleteUser = adminDeleteUser as jest.MockedFunction<typeof adminDeleteUser>;
mockDeleteUser.mockResolvedValue({ data: { success: true } } as any);
const { result } = renderHook(() => useDeleteUser(), { wrapper });
await act(async () => {
await result.current.mutateAsync('1');
});
expect(mockDeleteUser).toHaveBeenCalledWith({
path: { user_id: '1' },
throwOnError: false,
});
});
it('handles delete error', async () => {
const mockDeleteUser = adminDeleteUser as jest.MockedFunction<typeof adminDeleteUser>;
mockDeleteUser.mockResolvedValue({ error: 'Delete failed' } as any);
const { result } = renderHook(() => useDeleteUser(), { wrapper });
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to delete user');
});
});
describe('useActivateUser', () => {
it('activates a user successfully', async () => {
const mockActivateUser = adminActivateUser as jest.MockedFunction<typeof adminActivateUser>;
mockActivateUser.mockResolvedValue({ data: { success: true } } as any);
const { result } = renderHook(() => useActivateUser(), { wrapper });
await act(async () => {
await result.current.mutateAsync('1');
});
expect(mockActivateUser).toHaveBeenCalledWith({
path: { user_id: '1' },
throwOnError: false,
});
});
it('handles activate error', async () => {
const mockActivateUser = adminActivateUser as jest.MockedFunction<typeof adminActivateUser>;
mockActivateUser.mockResolvedValue({ error: 'Activate failed' } as any);
const { result } = renderHook(() => useActivateUser(), { wrapper });
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to activate user');
});
});
describe('useDeactivateUser', () => {
it('deactivates a user successfully', async () => {
const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction<typeof adminDeactivateUser>;
mockDeactivateUser.mockResolvedValue({ data: { success: true } } as any);
const { result } = renderHook(() => useDeactivateUser(), { wrapper });
await act(async () => {
await result.current.mutateAsync('1');
});
expect(mockDeactivateUser).toHaveBeenCalledWith({
path: { user_id: '1' },
throwOnError: false,
});
});
it('handles deactivate error', async () => {
const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction<typeof adminDeactivateUser>;
mockDeactivateUser.mockResolvedValue({ error: 'Deactivate failed' } as any);
const { result } = renderHook(() => useDeactivateUser(), { wrapper });
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to deactivate user');
});
});
describe('useBulkUserAction', () => {
it('performs bulk activate successfully', async () => {
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 2 } } as any);
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
action: 'activate',
userIds: ['1', '2'],
});
});
expect(mockBulkAction).toHaveBeenCalledWith({
body: { action: 'activate', user_ids: ['1', '2'] },
throwOnError: false,
});
});
it('performs bulk deactivate successfully', async () => {
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 3 } } as any);
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
action: 'deactivate',
userIds: ['1', '2', '3'],
});
});
expect(mockBulkAction).toHaveBeenCalledWith({
body: { action: 'deactivate', user_ids: ['1', '2', '3'] },
throwOnError: false,
});
});
it('performs bulk delete successfully', async () => {
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 1 } } as any);
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
await act(async () => {
await result.current.mutateAsync({
action: 'delete',
userIds: ['1'],
});
});
expect(mockBulkAction).toHaveBeenCalledWith({
body: { action: 'delete', user_ids: ['1'] },
throwOnError: false,
});
});
it('handles bulk action error', async () => {
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
mockBulkAction.mockResolvedValue({ error: 'Bulk action failed' } as any);
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
await expect(
result.current.mutateAsync({
action: 'activate',
userIds: ['1', '2'],
})
).rejects.toThrow('Failed to perform bulk action');
});
});
});