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.
This commit is contained in:
Felipe Cardoso
2025-11-06 10:08:43 +01:00
parent abce06ad67
commit 9c72fe87f9
14 changed files with 852 additions and 40 deletions

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

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

@@ -13,6 +13,14 @@
import { useQuery } from '@tanstack/react-query';
import { adminListUsers, adminListOrganizations } from '@/lib/api/client';
import { useAuth } from '@/lib/auth/AuthContext';
/**
* Constants for admin hooks
*/
const STATS_FETCH_LIMIT = 10000; // High limit to fetch all records for stats calculation
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 +39,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 +49,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 +68,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,9 +103,11 @@ 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,
});
}
@@ -106,7 +118,9 @@ 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 () => {
@@ -122,6 +136,8 @@ export function useAdminUsers(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,
});
}
@@ -132,7 +148,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 +166,7 @@ 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,
});
}