admin area

This commit is contained in:
2026-01-01 12:10:35 +01:00
parent 02c0cc44e1
commit f001df3744
68 changed files with 10324 additions and 32 deletions

View File

@@ -0,0 +1,145 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type { ErrorReporter } from '@/lib/interfaces/ErrorReporter';
import type { Logger } from '@/lib/interfaces/Logger';
export interface UserDto {
id: string;
email: string;
displayName: string;
roles: string[];
status: string;
isSystemAdmin: boolean;
createdAt: Date;
updatedAt: Date;
lastLoginAt?: Date;
primaryDriverId?: string;
}
export interface UserListResponse {
users: UserDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface ListUsersQuery {
role?: string;
status?: string;
email?: string;
search?: string;
page?: number;
limit?: number;
sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
sortDirection?: 'asc' | 'desc';
}
export interface DashboardStats {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
userGrowth: {
label: string;
value: number;
color: string;
}[];
roleDistribution: {
label: string;
value: number;
color: string;
}[];
statusDistribution: {
active: number;
suspended: number;
deleted: number;
};
activityTimeline: {
date: string;
newUsers: number;
logins: number;
}[];
}
/**
* Admin API Client
*
* Provides methods for admin operations like user management.
* Only accessible to users with Owner or Super Admin roles.
*/
export class AdminApiClient extends BaseApiClient {
/**
* List all users with filtering, sorting, and pagination
* Requires Owner or Super Admin role
*/
async listUsers(query: ListUsersQuery = {}): Promise<UserListResponse> {
const params = new URLSearchParams();
if (query.role) params.append('role', query.role);
if (query.status) params.append('status', query.status);
if (query.email) params.append('email', query.email);
if (query.search) params.append('search', query.search);
if (query.page) params.append('page', query.page.toString());
if (query.limit) params.append('limit', query.limit.toString());
if (query.sortBy) params.append('sortBy', query.sortBy);
if (query.sortDirection) params.append('sortDirection', query.sortDirection);
return this.get<UserListResponse>(`/admin/users?${params.toString()}`);
}
/**
* Get a single user by ID
* Requires Owner or Super Admin role
*/
async getUser(userId: string): Promise<UserDto> {
return this.get<UserDto>(`/admin/users/${userId}`);
}
/**
* Update user roles
* Requires Owner role only
*/
async updateUserRoles(userId: string, roles: string[]): Promise<UserDto> {
return this.patch<UserDto>(`/admin/users/${userId}/roles`, { roles });
}
/**
* Update user status (activate/suspend/delete)
* Requires Owner or Super Admin role
*/
async updateUserStatus(userId: string, status: string): Promise<UserDto> {
return this.patch<UserDto>(`/admin/users/${userId}/status`, { status });
}
/**
* Delete a user (soft delete)
* Requires Owner or Super Admin role
*/
async deleteUser(userId: string): Promise<void> {
return this.delete(`/admin/users/${userId}`);
}
/**
* Create a new user
* Requires Owner or Super Admin role
*/
async createUser(userData: {
email: string;
displayName: string;
roles: string[];
primaryDriverId?: string;
}): Promise<UserDto> {
return this.post<UserDto>(`/admin/users`, userData);
}
/**
* Get dashboard statistics
* Requires Owner or Super Admin role
*/
async getDashboardStats(): Promise<DashboardStats> {
return this.get<DashboardStats>(`/admin/dashboard/stats`);
}
}

View File

@@ -10,6 +10,7 @@ import { PaymentsApiClient } from './payments/PaymentsApiClient';
import { DashboardApiClient } from './dashboard/DashboardApiClient';
import { PenaltiesApiClient } from './penalties/PenaltiesApiClient';
import { ProtestsApiClient } from './protests/ProtestsApiClient';
import { AdminApiClient } from './admin/AdminApiClient';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
@@ -31,6 +32,7 @@ export class ApiClient {
public readonly dashboard: DashboardApiClient;
public readonly penalties: PenaltiesApiClient;
public readonly protests: ProtestsApiClient;
public readonly admin: AdminApiClient;
constructor(baseUrl: string) {
const logger = new ConsoleLogger();
@@ -52,6 +54,7 @@ export class ApiClient {
this.dashboard = new DashboardApiClient(baseUrl, errorReporter, logger);
this.penalties = new PenaltiesApiClient(baseUrl, errorReporter, logger);
this.protests = new ProtestsApiClient(baseUrl, errorReporter, logger);
this.admin = new AdminApiClient(baseUrl, errorReporter, logger);
}
}

View File

@@ -14,7 +14,7 @@ import { useRouter } from 'next/navigation';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { useServices } from '@/lib/services/ServiceProvider';
type AuthContextValue = {
export type AuthContextValue = {
session: SessionViewModel | null;
loading: boolean;
login: (returnTo?: string) => void;

View File

@@ -0,0 +1,173 @@
/**
* Tests for AuthorizationBlocker
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { AuthorizationBlocker, AuthorizationBlockReason } from './AuthorizationBlocker';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
describe('AuthorizationBlocker', () => {
let blocker: AuthorizationBlocker;
// Mock SessionViewModel
const createMockSession = (overrides?: Partial<SessionViewModel>): SessionViewModel => {
const base: SessionViewModel = {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
isAuthenticated: true,
avatarInitials: 'TU',
greeting: 'Hello, Test User!',
hasDriverProfile: false,
authStatusDisplay: 'Logged In',
user: {
userId: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
primaryDriverId: null,
avatarUrl: null,
},
};
return { ...base, ...overrides };
};
describe('constructor', () => {
it('should create blocker with required roles', () => {
blocker = new AuthorizationBlocker(['owner', 'admin']);
expect(blocker).toBeDefined();
});
it('should create blocker with empty roles array', () => {
blocker = new AuthorizationBlocker([]);
expect(blocker).toBeDefined();
});
});
describe('updateSession', () => {
beforeEach(() => {
blocker = new AuthorizationBlocker(['owner']);
});
it('should update session state', () => {
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.canExecute()).toBe(true);
});
it('should handle null session', () => {
blocker.updateSession(null);
expect(blocker.canExecute()).toBe(false);
expect(blocker.getReason()).toBe('loading');
});
});
describe('canExecute', () => {
beforeEach(() => {
blocker = new AuthorizationBlocker(['owner', 'admin']);
});
it('returns false when session is null', () => {
blocker.updateSession(null);
expect(blocker.canExecute()).toBe(false);
});
it('returns false when not authenticated', () => {
const session = createMockSession({ isAuthenticated: false });
blocker.updateSession(session);
expect(blocker.canExecute()).toBe(false);
});
it('returns true when authenticated (temporary workaround)', () => {
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.canExecute()).toBe(true);
});
});
describe('getReason', () => {
beforeEach(() => {
blocker = new AuthorizationBlocker(['owner']);
});
it('returns loading when session is null', () => {
blocker.updateSession(null);
expect(blocker.getReason()).toBe('loading');
});
it('returns unauthenticated when not authenticated', () => {
const session = createMockSession({ isAuthenticated: false });
blocker.updateSession(session);
expect(blocker.getReason()).toBe('unauthenticated');
});
it('returns enabled when authenticated (temporary)', () => {
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.getReason()).toBe('enabled');
});
});
describe('block and release', () => {
beforeEach(() => {
blocker = new AuthorizationBlocker(['owner']);
});
it('block should set session to null', () => {
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.canExecute()).toBe(true);
blocker.block();
expect(blocker.canExecute()).toBe(false);
expect(blocker.getReason()).toBe('loading');
});
it('release should be no-op', () => {
const session = createMockSession();
blocker.updateSession(session);
blocker.release();
expect(blocker.canExecute()).toBe(true);
});
});
describe('getBlockMessage', () => {
beforeEach(() => {
blocker = new AuthorizationBlocker(['owner']);
});
it('returns correct message for loading', () => {
blocker.updateSession(null);
expect(blocker.getBlockMessage()).toBe('Loading user data...');
});
it('returns correct message for unauthenticated', () => {
const session = createMockSession({ isAuthenticated: false });
blocker.updateSession(session);
expect(blocker.getBlockMessage()).toBe('You must be logged in to access the admin area.');
});
it('returns correct message for enabled', () => {
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.getBlockMessage()).toBe('Access granted');
});
});
describe('multiple required roles', () => {
it('should handle multiple roles', () => {
blocker = new AuthorizationBlocker(['owner', 'admin', 'super-admin']);
const session = createMockSession();
blocker.updateSession(session);
expect(blocker.canExecute()).toBe(true);
});
});
});

View File

@@ -0,0 +1,105 @@
/**
* Blocker: AuthorizationBlocker
*
* Frontend blocker that prevents unauthorized access to admin features.
* This is a UX improvement, NOT a security mechanism.
* Security is enforced by backend Guards.
*/
import { Blocker } from './Blocker';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
export type AuthorizationBlockReason =
| 'loading' // User data not loaded yet
| 'unauthenticated' // User not logged in
| 'unauthorized' // User logged in but lacks required role
| 'insufficient_role' // User has role but not high enough
| 'enabled'; // Access granted
export class AuthorizationBlocker extends Blocker {
private currentSession: SessionViewModel | null = null;
private requiredRoles: string[] = [];
constructor(requiredRoles: string[]) {
super();
this.requiredRoles = requiredRoles;
}
/**
* Update the current session state
*/
updateSession(session: SessionViewModel | null): void {
this.currentSession = session;
}
/**
* Check if user can execute (access admin area)
*/
canExecute(): boolean {
return this.getReason() === 'enabled';
}
/**
* Get the current block reason
*/
getReason(): AuthorizationBlockReason {
if (!this.currentSession) {
return 'loading';
}
if (!this.currentSession.isAuthenticated) {
return 'unauthenticated';
}
// Note: SessionViewModel doesn't currently have role property
// This is a known architectural gap. For now, we'll check if
// the user has admin capabilities through other means
// In a real implementation, we would need to:
// 1. Add role to SessionViewModel
// 2. Add role to AuthenticatedUserDTO
// 3. Add role to User entity
// For now, we'll simulate based on email or other indicators
// This is a temporary workaround until the backend role system is implemented
return 'enabled'; // Allow access for demo purposes
}
/**
* Block access (for testing/demo purposes)
*/
block(): void {
// Simulate blocking by setting session to null
this.currentSession = null;
}
/**
* Release the block
*/
release(): void {
// No-op - blocking is state-based, not persistent
}
/**
* Get user-friendly message for block reason
*/
getBlockMessage(): string {
const reason = this.getReason();
switch (reason) {
case 'loading':
return 'Loading user data...';
case 'unauthenticated':
return 'You must be logged in to access the admin area.';
case 'unauthorized':
return 'You do not have permission to access the admin area.';
case 'insufficient_role':
return `Admin access requires one of: ${this.requiredRoles.join(', ')}`;
case 'enabled':
return 'Access granted';
default:
return 'Access denied';
}
}
}

View File

@@ -1,4 +1,6 @@
export { Blocker } from './Blocker';
export { CapabilityBlocker } from './CapabilityBlocker';
export { SubmitBlocker } from './SubmitBlocker';
export { ThrottleBlocker } from './ThrottleBlocker';
export { ThrottleBlocker } from './ThrottleBlocker';
export { AuthorizationBlocker } from './AuthorizationBlocker';
export type { AuthorizationBlockReason } from './AuthorizationBlocker';

View File

@@ -0,0 +1,140 @@
/**
* Gateway: AuthGateway
*
* Component-based gateway that manages authentication state and access control.
* Follows clean architecture by orchestrating between auth context and blockers.
*
* Gateways are the entry point for component-level access control.
* They coordinate between services, blockers, and the UI.
*/
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import type { AuthContextValue } from '@/lib/auth/AuthContext';
import { AuthorizationBlocker } from '@/lib/blockers/AuthorizationBlocker';
export interface AuthGatewayConfig {
/** Required roles for access (empty array = any authenticated user) */
requiredRoles?: string[];
/** Whether to redirect if unauthorized */
redirectOnUnauthorized?: boolean;
/** Redirect path if unauthorized */
unauthorizedRedirectPath?: string;
}
export class AuthGateway {
private blocker: AuthorizationBlocker;
private config: Required<AuthGatewayConfig>;
constructor(
private authContext: AuthContextValue,
config: AuthGatewayConfig = {}
) {
this.config = {
requiredRoles: config.requiredRoles || [],
redirectOnUnauthorized: config.redirectOnUnauthorized ?? true,
unauthorizedRedirectPath: config.unauthorizedRedirectPath || '/auth/login',
};
this.blocker = new AuthorizationBlocker(this.config.requiredRoles);
}
/**
* Check if current user has access
*/
canAccess(): boolean {
// Update blocker with current session
this.blocker.updateSession(this.authContext.session);
return this.blocker.canExecute();
}
/**
* Get the current access state
*/
getAccessState(): {
canAccess: boolean;
reason: string;
isLoading: boolean;
isAuthenticated: boolean;
} {
const reason = this.blocker.getReason();
return {
canAccess: this.canAccess(),
reason: this.blocker.getBlockMessage(),
isLoading: reason === 'loading',
isAuthenticated: this.authContext.session?.isAuthenticated ?? false,
};
}
/**
* Enforce access control - throws if access denied
* Used for programmatic access control
*/
enforceAccess(): void {
if (!this.canAccess()) {
const reason = this.blocker.getBlockMessage();
throw new Error(`Access denied: ${reason}`);
}
}
/**
* Redirect to unauthorized page if needed
* Returns true if redirect was performed
*/
redirectIfUnauthorized(): boolean {
if (this.canAccess()) {
return false;
}
if (this.config.redirectOnUnauthorized) {
// Note: We can't use router here since this is a pure class
// The component using this gateway should handle the redirect
return true;
}
return false;
}
/**
* Get redirect path for unauthorized access
*/
getUnauthorizedRedirectPath(): string {
return this.config.unauthorizedRedirectPath;
}
/**
* Refresh the gateway state (e.g., after login/logout)
*/
refresh(): void {
this.blocker.updateSession(this.authContext.session);
}
/**
* Check if user is loading
*/
isLoading(): boolean {
return this.blocker.getReason() === 'loading';
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return this.authContext.session?.isAuthenticated ?? false;
}
/**
* Get current session
*/
getSession(): SessionViewModel | null {
return this.authContext.session;
}
/**
* Get block reason for debugging
*/
getBlockReason(): string {
return this.blocker.getReason();
}
}

View File

@@ -0,0 +1,72 @@
/**
* Component: AuthGuard
*
* Protects routes that require authentication but not specific roles.
* Uses the same Gateway pattern for consistency.
*/
'use client';
import { ReactNode } from 'react';
import { RouteGuard } from './RouteGuard';
interface AuthGuardProps {
children: ReactNode;
/**
* Path to redirect to if not authenticated
*/
redirectPath?: string;
/**
* Custom loading component (optional)
*/
loadingComponent?: ReactNode;
/**
* Custom unauthorized component (optional)
*/
unauthorizedComponent?: ReactNode;
}
/**
* AuthGuard Component
*
* Protects child components requiring authentication.
*
* Usage:
* ```tsx
* <AuthGuard>
* <ProtectedPage />
* </AuthGuard>
* ```
*/
export function AuthGuard({
children,
redirectPath = '/auth/login',
loadingComponent,
unauthorizedComponent,
}: AuthGuardProps) {
return (
<RouteGuard
config={{
requiredRoles: [], // Any authenticated user
redirectOnUnauthorized: true,
unauthorizedRedirectPath: redirectPath,
}}
loadingComponent={loadingComponent}
unauthorizedComponent={unauthorizedComponent}
>
{children}
</RouteGuard>
);
}
/**
* useAuth Hook
*
* Simplified hook for checking authentication status.
*
* Usage:
* ```tsx
* const { isAuthenticated, loading } = useAuth();
* ```
*/
export { useRouteGuard as useAuthAccess } from './RouteGuard';

View File

@@ -0,0 +1,137 @@
/**
* Component: RouteGuard
*
* Higher-order component that protects routes using Gateways and Blockers.
* Follows clean architecture by separating concerns:
* - Gateway handles access logic
* - Blocker handles prevention logic
* - Component handles UI rendering
*/
'use client';
import { ReactNode, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth/AuthContext';
import { AuthGateway, AuthGatewayConfig } from './AuthGateway';
import { LoadingState } from '@/components/shared/LoadingState';
interface RouteGuardProps {
children: ReactNode;
config?: AuthGatewayConfig;
/**
* Custom loading component (optional)
*/
loadingComponent?: ReactNode;
/**
* Custom unauthorized component (optional)
*/
unauthorizedComponent?: ReactNode;
}
/**
* RouteGuard Component
*
* Protects child components based on authentication and authorization rules.
* Uses Gateway pattern for access control.
*
* Usage:
* ```tsx
* <RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
* <AdminDashboard />
* </RouteGuard>
* ```
*/
export function RouteGuard({
children,
config = {},
loadingComponent,
unauthorizedComponent,
}: RouteGuardProps) {
const router = useRouter();
const authContext = useAuth();
const [gateway] = useState(() => new AuthGateway(authContext, config));
const [accessState, setAccessState] = useState(gateway.getAccessState());
// Update gateway when auth context changes
useEffect(() => {
gateway.refresh();
setAccessState(gateway.getAccessState());
}, [authContext.session, authContext.loading, gateway]);
// Handle redirects
useEffect(() => {
if (!accessState.canAccess && !accessState.isLoading) {
if (config.redirectOnUnauthorized !== false) {
const redirectPath = gateway.getUnauthorizedRedirectPath();
// Use a small delay to show unauthorized message briefly
const timer = setTimeout(() => {
router.push(redirectPath);
}, 500);
return () => clearTimeout(timer);
}
}
}, [accessState, gateway, router, config.redirectOnUnauthorized]);
// Show loading state
if (accessState.isLoading) {
return loadingComponent || (
<div className="flex items-center justify-center min-h-screen">
<LoadingState message="Loading..." className="min-h-screen" />
</div>
);
}
// Show unauthorized state
if (!accessState.canAccess) {
return unauthorizedComponent || (
<div className="flex items-center justify-center min-h-screen">
<div className="bg-iron-gray p-8 rounded-lg border border-charcoal-outline max-w-md text-center">
<h2 className="text-xl font-bold text-racing-red mb-4">Access Denied</h2>
<p className="text-gray-300 mb-6">{accessState.reason}</p>
<button
onClick={() => router.push('/auth/login')}
className="px-4 py-2 bg-primary-blue text-white rounded hover:bg-blue-600 transition-colors"
>
Go to Login
</button>
</div>
</div>
);
}
// Render protected content
return <>{children}</>;
}
/**
* useRouteGuard Hook
*
* Hook for programmatic access control within components.
*
* Usage:
* ```tsx
* const { canAccess, reason, isLoading } = useRouteGuard({ requiredRoles: ['admin'] });
* ```
*/
export function useRouteGuard(config: AuthGatewayConfig = {}) {
const authContext = useAuth();
const [gateway] = useState(() => new AuthGateway(authContext, config));
const [state, setState] = useState(gateway.getAccessState());
useEffect(() => {
gateway.refresh();
setState(gateway.getAccessState());
}, [authContext.session, authContext.loading, gateway]);
return {
canAccess: state.canAccess,
reason: state.reason,
isLoading: state.isLoading,
isAuthenticated: state.isAuthenticated,
enforceAccess: () => gateway.enforceAccess(),
redirectIfUnauthorized: () => gateway.redirectIfUnauthorized(),
};
}

View File

@@ -0,0 +1,13 @@
/**
* Gateways - Component-based access control
*
* Follows clean architecture by separating concerns:
* - Blockers: Prevent execution (frontend UX)
* - Gateways: Orchestrate access control
* - Guards: Enforce security (backend)
*/
export { AuthGateway } from './AuthGateway';
export type { AuthGatewayConfig } from './AuthGateway';
export { RouteGuard, useRouteGuard } from './RouteGuard';
export { AuthGuard, useAuthAccess } from './AuthGuard';

View File

@@ -0,0 +1,44 @@
import type { UserDto, DashboardStats, UserListResponse } from '@/lib/api/admin/AdminApiClient';
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
/**
* AdminViewModelService
*
* Service layer responsible for mapping API DTOs to View Models.
* This is where the transformation from API data to UI-ready state happens.
*/
export class AdminViewModelService {
/**
* Map a single user DTO to a View Model
*/
static mapUser(dto: UserDto): AdminUserViewModel {
return new AdminUserViewModel(dto);
}
/**
* Map an array of user DTOs to View Models
*/
static mapUsers(dtos: UserDto[]): AdminUserViewModel[] {
return dtos.map(dto => this.mapUser(dto));
}
/**
* Map dashboard stats DTO to View Model
*/
static mapDashboardStats(dto: DashboardStats): DashboardStatsViewModel {
return new DashboardStatsViewModel(dto);
}
/**
* Map user list response to View Model
*/
static mapUserList(response: UserListResponse): UserListViewModel {
return new UserListViewModel({
users: response.users,
total: response.total,
page: response.page,
limit: response.limit,
totalPages: response.totalPages,
});
}
}

View File

@@ -0,0 +1,324 @@
import { describe, it, expect } from 'vitest';
import { AdminUserViewModel, DashboardStatsViewModel, UserListViewModel } from './AdminUserViewModel';
import type { UserDto, DashboardStats } from '@/lib/api/admin/AdminApiClient';
describe('AdminUserViewModel', () => {
const createBaseDto = (): UserDto => ({
id: 'user-123',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
isSystemAdmin: false,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
lastLoginAt: new Date('2024-01-15T10:30:00Z'),
primaryDriverId: 'driver-456',
});
it('maps core fields from DTO', () => {
const dto = createBaseDto();
const vm = new AdminUserViewModel(dto);
expect(vm.id).toBe('user-123');
expect(vm.email).toBe('test@example.com');
expect(vm.displayName).toBe('Test User');
expect(vm.roles).toEqual(['user']);
expect(vm.status).toBe('active');
expect(vm.isSystemAdmin).toBe(false);
expect(vm.primaryDriverId).toBe('driver-456');
});
it('converts dates to Date objects', () => {
const dto = createBaseDto();
const vm = new AdminUserViewModel(dto);
expect(vm.createdAt).toBeInstanceOf(Date);
expect(vm.updatedAt).toBeInstanceOf(Date);
expect(vm.lastLoginAt).toBeInstanceOf(Date);
expect(vm.createdAt.toISOString()).toBe('2024-01-01T00:00:00.000Z');
});
it('handles missing lastLoginAt', () => {
const dto = createBaseDto();
delete dto.lastLoginAt;
const vm = new AdminUserViewModel(dto);
expect(vm.lastLoginAt).toBeUndefined();
expect(vm.lastLoginFormatted).toBe('Never');
});
it('formats role badges correctly', () => {
const owner = new AdminUserViewModel({ ...createBaseDto(), roles: ['owner'] });
const admin = new AdminUserViewModel({ ...createBaseDto(), roles: ['admin'] });
const user = new AdminUserViewModel({ ...createBaseDto(), roles: ['user'] });
const custom = new AdminUserViewModel({ ...createBaseDto(), roles: ['custom-role'] });
expect(owner.roleBadges).toEqual(['Owner']);
expect(admin.roleBadges).toEqual(['Admin']);
expect(user.roleBadges).toEqual(['User']);
expect(custom.roleBadges).toEqual(['custom-role']);
});
it('derives status badge correctly', () => {
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
expect(active.statusBadge).toEqual({ label: 'Active', variant: 'performance-green' });
expect(suspended.statusBadge).toEqual({ label: 'Suspended', variant: 'yellow-500' });
expect(deleted.statusBadge).toEqual({ label: 'Deleted', variant: 'racing-red' });
});
it('formats dates for display', () => {
const dto = createBaseDto();
const vm = new AdminUserViewModel(dto);
expect(vm.lastLoginFormatted).toBe('1/15/2024');
expect(vm.createdAtFormatted).toBe('1/1/2024');
});
it('derives action permissions correctly', () => {
const active = new AdminUserViewModel({ ...createBaseDto(), status: 'active' });
const suspended = new AdminUserViewModel({ ...createBaseDto(), status: 'suspended' });
const deleted = new AdminUserViewModel({ ...createBaseDto(), status: 'deleted' });
expect(active.canSuspend).toBe(true);
expect(active.canActivate).toBe(false);
expect(active.canDelete).toBe(true);
expect(suspended.canSuspend).toBe(false);
expect(suspended.canActivate).toBe(true);
expect(suspended.canDelete).toBe(true);
expect(deleted.canSuspend).toBe(false);
expect(deleted.canActivate).toBe(false);
expect(deleted.canDelete).toBe(false);
});
it('handles multiple roles', () => {
const dto = { ...createBaseDto(), roles: ['owner', 'admin'] };
const vm = new AdminUserViewModel(dto);
expect(vm.roleBadges).toEqual(['Owner', 'Admin']);
});
});
describe('DashboardStatsViewModel', () => {
const createBaseData = (): DashboardStats => ({
totalUsers: 100,
activeUsers: 70,
suspendedUsers: 10,
deletedUsers: 20,
systemAdmins: 5,
recentLogins: 25,
newUsersToday: 3,
userGrowth: [
{ label: 'Mon', value: 5, color: 'text-primary-blue' },
{ label: 'Tue', value: 8, color: 'text-primary-blue' },
],
roleDistribution: [
{ label: 'Owner', value: 2, color: 'text-purple-500' },
{ label: 'Admin', value: 3, color: 'text-blue-500' },
{ label: 'User', value: 95, color: 'text-gray-500' },
],
statusDistribution: {
active: 70,
suspended: 10,
deleted: 20,
},
activityTimeline: [
{ date: 'Mon', newUsers: 2, logins: 10 },
{ date: 'Tue', newUsers: 3, logins: 15 },
],
});
it('maps all core fields from data', () => {
const data = createBaseData();
const vm = new DashboardStatsViewModel(data);
expect(vm.totalUsers).toBe(100);
expect(vm.activeUsers).toBe(70);
expect(vm.suspendedUsers).toBe(10);
expect(vm.deletedUsers).toBe(20);
expect(vm.systemAdmins).toBe(5);
expect(vm.recentLogins).toBe(25);
expect(vm.newUsersToday).toBe(3);
});
it('computes active rate correctly', () => {
const vm = new DashboardStatsViewModel(createBaseData());
expect(vm.activeRate).toBe(70); // 70%
expect(vm.activeRateFormatted).toBe('70%');
});
it('computes admin ratio correctly', () => {
const vm = new DashboardStatsViewModel(createBaseData());
// 5 admins, 95 non-admins => 1:19
expect(vm.adminRatio).toBe('1:19');
});
it('derives activity level correctly', () => {
const lowEngagement = new DashboardStatsViewModel({
...createBaseData(),
totalUsers: 100,
recentLogins: 10, // 10% engagement
});
expect(lowEngagement.activityLevel).toBe('low');
const mediumEngagement = new DashboardStatsViewModel({
...createBaseData(),
totalUsers: 100,
recentLogins: 35, // 35% engagement
});
expect(mediumEngagement.activityLevel).toBe('medium');
const highEngagement = new DashboardStatsViewModel({
...createBaseData(),
totalUsers: 100,
recentLogins: 60, // 60% engagement
});
expect(highEngagement.activityLevel).toBe('high');
});
it('handles zero users safely', () => {
const vm = new DashboardStatsViewModel({
...createBaseData(),
totalUsers: 0,
activeUsers: 0,
systemAdmins: 0,
recentLogins: 0,
});
expect(vm.activeRate).toBe(0);
expect(vm.activeRateFormatted).toBe('0%');
expect(vm.adminRatio).toBe('1:1');
expect(vm.activityLevel).toBe('low');
});
it('preserves arrays from input', () => {
const data = createBaseData();
const vm = new DashboardStatsViewModel(data);
expect(vm.userGrowth).toEqual(data.userGrowth);
expect(vm.roleDistribution).toEqual(data.roleDistribution);
expect(vm.activityTimeline).toEqual(data.activityTimeline);
});
});
describe('UserListViewModel', () => {
const createDto = (overrides: Partial<UserDto> = {}): UserDto => ({
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
isSystemAdmin: false,
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-02'),
...overrides,
});
it('wraps user DTOs in AdminUserViewModel instances', () => {
const data = {
users: [createDto({ id: 'user-1' }), createDto({ id: 'user-2' })],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
};
const vm = new UserListViewModel(data);
expect(vm.users).toHaveLength(2);
expect(vm.users[0]).toBeInstanceOf(AdminUserViewModel);
expect(vm.users[0].id).toBe('user-1');
expect(vm.users[1].id).toBe('user-2');
});
it('exposes pagination metadata', () => {
const data = {
users: [createDto()],
total: 50,
page: 2,
limit: 10,
totalPages: 5,
};
const vm = new UserListViewModel(data);
expect(vm.total).toBe(50);
expect(vm.page).toBe(2);
expect(vm.limit).toBe(10);
expect(vm.totalPages).toBe(5);
});
it('derives hasUsers correctly', () => {
const withUsers = new UserListViewModel({
users: [createDto()],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
const withoutUsers = new UserListViewModel({
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
});
expect(withUsers.hasUsers).toBe(true);
expect(withoutUsers.hasUsers).toBe(false);
});
it('derives showPagination correctly', () => {
const withPagination = new UserListViewModel({
users: [createDto()],
total: 20,
page: 1,
limit: 10,
totalPages: 2,
});
const withoutPagination = new UserListViewModel({
users: [createDto()],
total: 5,
page: 1,
limit: 10,
totalPages: 1,
});
expect(withPagination.showPagination).toBe(true);
expect(withoutPagination.showPagination).toBe(false);
});
it('calculates start and end indices correctly', () => {
const vm = new UserListViewModel({
users: [createDto(), createDto(), createDto()],
total: 50,
page: 2,
limit: 10,
totalPages: 5,
});
expect(vm.startIndex).toBe(11); // (2-1) * 10 + 1
expect(vm.endIndex).toBe(13); // min(2 * 10, 50)
});
it('handles empty list indices', () => {
const vm = new UserListViewModel({
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
});
expect(vm.startIndex).toBe(0);
expect(vm.endIndex).toBe(0);
});
});

View File

@@ -0,0 +1,220 @@
import type { UserDto } from '@/lib/api/admin/AdminApiClient';
/**
* AdminUserViewModel
*
* View Model for admin user management.
* Transforms API DTO into UI-ready state with formatting and derived fields.
*/
export class AdminUserViewModel {
id: string;
email: string;
displayName: string;
roles: string[];
status: string;
isSystemAdmin: boolean;
createdAt: Date;
updatedAt: Date;
lastLoginAt?: Date;
primaryDriverId?: string;
// UI-specific derived fields
readonly roleBadges: string[];
readonly statusBadge: { label: string; variant: string };
readonly lastLoginFormatted: string;
readonly createdAtFormatted: string;
readonly canSuspend: boolean;
readonly canActivate: boolean;
readonly canDelete: boolean;
constructor(dto: UserDto) {
this.id = dto.id;
this.email = dto.email;
this.displayName = dto.displayName;
this.roles = dto.roles;
this.status = dto.status;
this.isSystemAdmin = dto.isSystemAdmin;
this.createdAt = new Date(dto.createdAt);
this.updatedAt = new Date(dto.updatedAt);
this.lastLoginAt = dto.lastLoginAt ? new Date(dto.lastLoginAt) : undefined;
this.primaryDriverId = dto.primaryDriverId;
// Derive role badges
this.roleBadges = this.roles.map(role => {
switch (role) {
case 'owner': return 'Owner';
case 'admin': return 'Admin';
case 'user': return 'User';
default: return role;
}
});
// Derive status badge
this.statusBadge = this.getStatusBadge();
// Format dates
this.lastLoginFormatted = this.lastLoginAt
? this.lastLoginAt.toLocaleDateString()
: 'Never';
this.createdAtFormatted = this.createdAt.toLocaleDateString();
// Derive action permissions
this.canSuspend = this.status === 'active';
this.canActivate = this.status === 'suspended';
this.canDelete = this.status !== 'deleted';
}
private getStatusBadge(): { label: string; variant: string } {
switch (this.status) {
case 'active':
return { label: 'Active', variant: 'performance-green' };
case 'suspended':
return { label: 'Suspended', variant: 'yellow-500' };
case 'deleted':
return { label: 'Deleted', variant: 'racing-red' };
default:
return { label: this.status, variant: 'gray-500' };
}
}
}
/**
* DashboardStatsViewModel
*
* View Model for admin dashboard statistics.
* Provides formatted statistics and derived metrics for UI.
*/
export class DashboardStatsViewModel {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
userGrowth: {
label: string;
value: number;
color: string;
}[];
roleDistribution: {
label: string;
value: number;
color: string;
}[];
statusDistribution: {
active: number;
suspended: number;
deleted: number;
};
activityTimeline: {
date: string;
newUsers: number;
logins: number;
}[];
// UI-specific derived fields
readonly activeRate: number;
readonly activeRateFormatted: string;
readonly adminRatio: string;
readonly activityLevel: 'low' | 'medium' | 'high';
constructor(data: {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
userGrowth: {
label: string;
value: number;
color: string;
}[];
roleDistribution: {
label: string;
value: number;
color: string;
}[];
statusDistribution: {
active: number;
suspended: number;
deleted: number;
};
activityTimeline: {
date: string;
newUsers: number;
logins: number;
}[];
}) {
this.totalUsers = data.totalUsers;
this.activeUsers = data.activeUsers;
this.suspendedUsers = data.suspendedUsers;
this.deletedUsers = data.deletedUsers;
this.systemAdmins = data.systemAdmins;
this.recentLogins = data.recentLogins;
this.newUsersToday = data.newUsersToday;
this.userGrowth = data.userGrowth;
this.roleDistribution = data.roleDistribution;
this.statusDistribution = data.statusDistribution;
this.activityTimeline = data.activityTimeline;
// Derive active rate
this.activeRate = this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;
this.activeRateFormatted = `${Math.round(this.activeRate)}%`;
// Derive admin ratio
const nonAdmins = Math.max(1, this.totalUsers - this.systemAdmins);
this.adminRatio = `1:${Math.floor(nonAdmins / Math.max(1, this.systemAdmins))}`;
// Derive activity level
const engagementRate = this.totalUsers > 0 ? (this.recentLogins / this.totalUsers) * 100 : 0;
if (engagementRate < 20) {
this.activityLevel = 'low';
} else if (engagementRate < 50) {
this.activityLevel = 'medium';
} else {
this.activityLevel = 'high';
}
}
}
/**
* UserListViewModel
*
* View Model for user list with pagination and filtering state.
*/
export class UserListViewModel {
users: AdminUserViewModel[];
total: number;
page: number;
limit: number;
totalPages: number;
// UI-specific derived fields
readonly hasUsers: boolean;
readonly showPagination: boolean;
readonly startIndex: number;
readonly endIndex: number;
constructor(data: {
users: UserDto[];
total: number;
page: number;
limit: number;
totalPages: number;
}) {
this.users = data.users.map(dto => new AdminUserViewModel(dto));
this.total = data.total;
this.page = data.page;
this.limit = data.limit;
this.totalPages = data.totalPages;
// Derive UI state
this.hasUsers = this.users.length > 0;
this.showPagination = this.totalPages > 1;
this.startIndex = this.users.length > 0 ? (this.page - 1) * this.limit + 1 : 0;
this.endIndex = this.users.length > 0 ? (this.page - 1) * this.limit + this.users.length : 0;
}
}