forked from cardosofelipe/fast-next-template
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
frontend/src/app/forbidden/page.tsx
Normal file
53
frontend/src/app/forbidden/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -90,6 +90,7 @@ export function StatCard({
|
||||
'h-6 w-6',
|
||||
loading ? 'text-muted-foreground' : 'text-primary'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user