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

@@ -11,6 +11,7 @@ import { LeagueModule } from './domain/league/LeagueModule';
import { LoggingModule } from './domain/logging/LoggingModule';
import { MediaModule } from './domain/media/MediaModule';
import { PaymentsModule } from './domain/payments/PaymentsModule';
import { AdminModule } from './domain/admin/AdminModule';
import { PolicyModule } from './domain/policy/PolicyModule';
import { ProtestsModule } from './domain/protests/ProtestsModule';
import { RaceModule } from './domain/race/RaceModule';
@@ -44,6 +45,7 @@ const ENABLE_BOOTSTRAP = getEnableBootstrap();
MediaModule,
PaymentsModule,
PolicyModule,
AdminModule,
],
})
export class AppModule implements NestModule {

View File

@@ -0,0 +1,67 @@
import { Controller, Get, Inject, Query, Req, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AdminService } from './AdminService';
import { ListUsersRequestDto } from './dtos/ListUsersRequestDto';
import { UserListResponseDto } from './dtos/UserResponseDto';
import { DashboardStatsResponseDto } from './dto/DashboardStatsResponseDto';
import { RequireAuthenticatedUser } from '../auth/RequireAuthenticatedUser';
import { RequireRoles } from '../auth/RequireRoles';
import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { Request } from 'express';
import type { ListUsersInput } from '@core/admin/application/use-cases/ListUsersUseCase';
@ApiTags('admin')
@Controller('admin')
@UseGuards(AuthorizationGuard)
export class AdminController {
constructor(
@Inject(AdminService) private readonly adminService: AdminService,
) {}
@Get('users')
@RequireAuthenticatedUser()
@RequireRoles('owner', 'admin')
@ApiOperation({ summary: 'List all users (Owner/Super Admin only)' })
@ApiResponse({ status: 200, description: 'List of users', type: UserListResponseDto })
@ApiResponse({ status: 403, description: 'Forbidden - not a system admin' })
async listUsers(
@Query() query: ListUsersRequestDto,
@Req() req: Request,
): Promise<UserListResponseDto> {
// Get actorId from request (will be set by auth middleware/guard)
const actorId = (req as Request & { user?: { userId?: string } }).user?.userId || 'current-user';
// Build input with only defined values
const input: ListUsersInput = {
actorId,
page: query.page || 1,
limit: query.limit || 10,
};
if (query.role !== undefined) input.role = query.role;
if (query.status !== undefined) input.status = query.status;
if (query.email !== undefined) input.email = query.email;
if (query.search !== undefined) input.search = query.search;
if (query.sortBy !== undefined) input.sortBy = query.sortBy;
if (query.sortDirection !== undefined) input.sortDirection = query.sortDirection;
return await this.adminService.listUsers(input);
}
@Get('dashboard/stats')
@RequireAuthenticatedUser()
@RequireRoles('owner', 'admin')
@ApiOperation({ summary: 'Get dashboard statistics (Owner/Super Admin only)' })
@ApiResponse({ status: 200, description: 'Dashboard statistics', type: DashboardStatsResponseDto })
@ApiResponse({ status: 403, description: 'Forbidden - not a system admin' })
async getDashboardStats(
@Req() req: Request,
): Promise<DashboardStatsResponseDto> {
// Get actorId from request (will be set by auth middleware/guard)
const actorId = (req as Request & { user?: { userId?: string } }).user?.userId || 'current-user';
const input = { actorId };
return await this.adminService.getDashboardStats(input);
}
}

View File

@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { InMemoryAdminPersistenceModule } from '../../persistence/inmemory/InMemoryAdminPersistenceModule';
import { AdminService } from './AdminService';
import { AdminController } from './AdminController';
import { ListUsersPresenter } from './presenters/ListUsersPresenter';
import { DashboardStatsPresenter } from './presenters/DashboardStatsPresenter';
import { AuthModule } from '../auth/AuthModule';
import { ListUsersUseCase } from '@core/admin/application/use-cases/ListUsersUseCase';
import { GetDashboardStatsUseCase } from './use-cases/GetDashboardStatsUseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
import type { DashboardStatsResult } from './use-cases/GetDashboardStatsUseCase';
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
export const ADMIN_USER_REPOSITORY_TOKEN = 'IAdminUserRepository';
export const LIST_USERS_OUTPUT_PORT_TOKEN = 'ListUsersOutputPort';
export const DASHBOARD_STATS_OUTPUT_PORT_TOKEN = 'DashboardStatsOutputPort';
@Module({
imports: [InMemoryAdminPersistenceModule, AuthModule],
controllers: [AdminController],
providers: [
AdminService,
ListUsersPresenter,
DashboardStatsPresenter,
{
provide: LIST_USERS_OUTPUT_PORT_TOKEN,
useExisting: ListUsersPresenter,
},
{
provide: DASHBOARD_STATS_OUTPUT_PORT_TOKEN,
useExisting: DashboardStatsPresenter,
},
{
provide: ListUsersUseCase,
useFactory: (
repository: IAdminUserRepository,
output: UseCaseOutputPort<ListUsersResult>,
) => new ListUsersUseCase(repository, output),
inject: [ADMIN_USER_REPOSITORY_TOKEN, LIST_USERS_OUTPUT_PORT_TOKEN],
},
{
provide: GetDashboardStatsUseCase,
useFactory: (
repository: IAdminUserRepository,
output: UseCaseOutputPort<DashboardStatsResult>,
) => new GetDashboardStatsUseCase(repository, output),
inject: [ADMIN_USER_REPOSITORY_TOKEN, DASHBOARD_STATS_OUTPUT_PORT_TOKEN],
},
],
exports: [AdminService],
})
export class AdminModule {}

View File

@@ -0,0 +1,334 @@
import { AdminService } from './AdminService';
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
import { Result } from '@core/shared/application/Result';
import { vi, describe, it, expect, beforeEach } from 'vitest';
// Mock use cases
const mockListUsersUseCase = {
execute: vi.fn(),
};
const mockGetDashboardStatsUseCase = {
execute: vi.fn(),
};
// Mock presenters
const mockListUsersPresenter = {
present: vi.fn(),
getViewModel: vi.fn(),
};
const mockDashboardStatsPresenter = {
present: vi.fn(),
responseModel: {},
reset: vi.fn(),
};
describe('AdminService', () => {
describe('TDD - Test First', () => {
let service: AdminService;
beforeEach(() => {
vi.clearAllMocks();
service = new AdminService(
mockListUsersUseCase as any,
mockListUsersPresenter as any,
mockGetDashboardStatsUseCase as any,
mockDashboardStatsPresenter as any
);
});
describe('listUsers', () => {
it('should return empty list when no users exist', async () => {
// Arrange
const expectedResult = {
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
const result = await service.listUsers({ actorId: 'actor-1' });
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith({ actorId: 'actor-1' });
expect(mockListUsersPresenter.getViewModel).toHaveBeenCalled();
expect(result).toEqual(expectedResult);
});
it('should return users when they exist', async () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User 1',
roles: ['user'],
status: 'active',
});
const user2 = AdminUser.create({
id: 'user-2',
email: 'user2@example.com',
displayName: 'User 2',
roles: ['admin'],
status: 'active',
});
const expectedResult = {
users: [user1, user2],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue({
users: [
{ id: 'user-1', email: 'user1@example.com', displayName: 'User 1', roles: ['user'], status: 'active', isSystemAdmin: false, createdAt: user1.createdAt, updatedAt: user1.updatedAt },
{ id: 'user-2', email: 'user2@example.com', displayName: 'User 2', roles: ['admin'], status: 'active', isSystemAdmin: true, createdAt: user2.createdAt, updatedAt: user2.updatedAt },
],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
});
// Act
const result = await service.listUsers({ actorId: 'actor-1' });
// Assert
expect(result.users).toHaveLength(2);
expect(result.total).toBe(2);
});
it('should apply filters correctly', async () => {
// Arrange
const adminUser = AdminUser.create({
id: 'admin-1',
email: 'admin@example.com',
displayName: 'Admin',
roles: ['admin'],
status: 'active',
});
const expectedResult = {
users: [adminUser],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue({
users: [{ id: 'admin-1', email: 'admin@example.com', displayName: 'Admin', roles: ['admin'], status: 'active', isSystemAdmin: true, createdAt: adminUser.createdAt, updatedAt: adminUser.updatedAt }],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
});
// Act
const result = await service.listUsers({
actorId: 'actor-1',
role: 'admin',
status: 'active',
});
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'actor-1',
role: 'admin',
status: 'active',
})
);
expect(result.users).toHaveLength(1);
});
it('should apply pagination correctly', async () => {
// Arrange
const expectedResult = {
users: [],
total: 50,
page: 3,
limit: 10,
totalPages: 5,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue({
users: [],
total: 50,
page: 3,
limit: 10,
totalPages: 5,
});
// Act
const result = await service.listUsers({
actorId: 'actor-1',
page: 3,
limit: 10,
});
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'actor-1',
page: 3,
limit: 10,
})
);
expect(result.page).toBe(3);
expect(result.limit).toBe(10);
expect(result.totalPages).toBe(5);
});
it('should apply sorting correctly', async () => {
// Arrange
const expectedResult = {
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({
actorId: 'actor-1',
sortBy: 'email',
sortDirection: 'desc',
});
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'actor-1',
sortBy: 'email',
sortDirection: 'desc',
})
);
});
it('should handle search filter', async () => {
// Arrange
const expectedResult = {
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({
actorId: 'actor-1',
search: 'test',
});
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'actor-1',
search: 'test',
})
);
});
it('should handle email filter', async () => {
// Arrange
const expectedResult = {
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({
actorId: 'actor-1',
email: 'test@example.com',
});
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'actor-1',
email: 'test@example.com',
})
);
});
it('should throw error when use case fails', async () => {
// Arrange
const error = { code: 'UNAUTHORIZED', details: { message: 'Not authorized' } };
mockListUsersUseCase.execute.mockResolvedValue(Result.err(error));
// Act & Assert
await expect(
service.listUsers({ actorId: 'actor-1' })
).rejects.toThrow('UNAUTHORIZED: Not authorized');
});
it('should handle all filters together', async () => {
// Arrange
const expectedResult = {
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
};
mockListUsersUseCase.execute.mockResolvedValue(Result.ok(expectedResult));
mockListUsersPresenter.getViewModel.mockReturnValue(expectedResult);
// Act
await service.listUsers({
actorId: 'actor-1',
role: 'admin',
status: 'active',
email: 'admin@example.com',
search: 'admin',
page: 2,
limit: 20,
sortBy: 'displayName',
sortDirection: 'asc',
});
// Assert
expect(mockListUsersUseCase.execute).toHaveBeenCalledWith(
expect.objectContaining({
actorId: 'actor-1',
role: 'admin',
status: 'active',
email: 'admin@example.com',
search: 'admin',
page: 2,
limit: 20,
sortBy: 'displayName',
sortDirection: 'asc',
})
);
});
});
});
});

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { ListUsersUseCase, ListUsersInput } from '@core/admin/application/use-cases/ListUsersUseCase';
import { ListUsersPresenter, ListUsersViewModel } from './presenters/ListUsersPresenter';
import { GetDashboardStatsUseCase, GetDashboardStatsInput } from './use-cases/GetDashboardStatsUseCase';
import { DashboardStatsPresenter, DashboardStatsResponse } from './presenters/DashboardStatsPresenter';
@Injectable()
export class AdminService {
constructor(
private readonly listUsersUseCase: ListUsersUseCase,
private readonly listUsersPresenter: ListUsersPresenter,
private readonly getDashboardStatsUseCase: GetDashboardStatsUseCase,
private readonly dashboardStatsPresenter: DashboardStatsPresenter,
) {}
async listUsers(input: ListUsersInput): Promise<ListUsersViewModel> {
const result = await this.listUsersUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(`${error.code}: ${error.details.message}`);
}
return this.listUsersPresenter.getViewModel();
}
async getDashboardStats(input: GetDashboardStatsInput): Promise<DashboardStatsResponse> {
this.dashboardStatsPresenter.reset();
const result = await this.getDashboardStatsUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(`${error.code}: ${error.details.message}`);
}
return this.dashboardStatsPresenter.responseModel;
}
}

View File

@@ -0,0 +1,13 @@
import { SetMetadata } from '@nestjs/common';
export const REQUIRE_SYSTEM_ADMIN_METADATA_KEY = 'gridpilot:requireSystemAdmin';
export type RequireSystemAdminMetadata = {
readonly required: true;
};
export function RequireSystemAdmin(): MethodDecorator & ClassDecorator {
return SetMetadata(REQUIRE_SYSTEM_ADMIN_METADATA_KEY, {
required: true,
} satisfies RequireSystemAdminMetadata);
}

View File

@@ -0,0 +1,80 @@
import { ApiProperty } from '@nestjs/swagger';
class UserGrowthDto {
@ApiProperty({ description: 'Label for the time period' })
label!: string;
@ApiProperty({ description: 'Number of new users' })
value!: number;
@ApiProperty({ description: 'Color class for the bar' })
color!: string;
}
class RoleDistributionDto {
@ApiProperty({ description: 'Role name' })
label!: string;
@ApiProperty({ description: 'Number of users with this role' })
value!: number;
@ApiProperty({ description: 'Color class for the bar' })
color!: string;
}
class StatusDistributionDto {
@ApiProperty({ description: 'Number of active users' })
active!: number;
@ApiProperty({ description: 'Number of suspended users' })
suspended!: number;
@ApiProperty({ description: 'Number of deleted users' })
deleted!: number;
}
class ActivityTimelineDto {
@ApiProperty({ description: 'Date label' })
date!: string;
@ApiProperty({ description: 'Number of new users' })
newUsers!: number;
@ApiProperty({ description: 'Number of logins' })
logins!: number;
}
export class DashboardStatsResponseDto {
@ApiProperty({ description: 'Total number of users' })
totalUsers!: number;
@ApiProperty({ description: 'Number of active users' })
activeUsers!: number;
@ApiProperty({ description: 'Number of suspended users' })
suspendedUsers!: number;
@ApiProperty({ description: 'Number of deleted users' })
deletedUsers!: number;
@ApiProperty({ description: 'Number of system admins' })
systemAdmins!: number;
@ApiProperty({ description: 'Number of recent logins (last 24h)' })
recentLogins!: number;
@ApiProperty({ description: 'Number of new users today' })
newUsersToday!: number;
@ApiProperty({ type: [UserGrowthDto], description: 'User growth over last 7 days' })
userGrowth!: UserGrowthDto[];
@ApiProperty({ type: [RoleDistributionDto], description: 'Distribution of user roles' })
roleDistribution!: RoleDistributionDto[];
@ApiProperty({ type: StatusDistributionDto, description: 'Distribution of user statuses' })
statusDistribution!: StatusDistributionDto;
@ApiProperty({ type: [ActivityTimelineDto], description: 'Activity timeline for last 7 days' })
activityTimeline!: ActivityTimelineDto[];
}

View File

@@ -0,0 +1,119 @@
import { ListUsersRequestDto } from './ListUsersRequestDto';
describe('ListUsersRequestDto', () => {
describe('TDD - Test First', () => {
it('should create valid DTO with all fields', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
dto.role = 'admin';
dto.status = 'active';
dto.email = 'test@example.com';
dto.search = 'test';
dto.page = 2;
dto.limit = 20;
dto.sortBy = 'email';
dto.sortDirection = 'desc';
// Assert
expect(dto.role).toBe('admin');
expect(dto.status).toBe('active');
expect(dto.email).toBe('test@example.com');
expect(dto.search).toBe('test');
expect(dto.page).toBe(2);
expect(dto.limit).toBe(20);
expect(dto.sortBy).toBe('email');
expect(dto.sortDirection).toBe('desc');
});
it('should create DTO with optional fields undefined', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
// Assert
expect(dto.role).toBeUndefined();
expect(dto.status).toBeUndefined();
expect(dto.email).toBeUndefined();
expect(dto.search).toBeUndefined();
expect(dto.page).toBeUndefined();
expect(dto.limit).toBeUndefined();
expect(dto.sortBy).toBeUndefined();
expect(dto.sortDirection).toBeUndefined();
});
it('should handle partial input', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
dto.page = 1;
dto.limit = 10;
// Assert
expect(dto.page).toBe(1);
expect(dto.limit).toBe(10);
expect(dto.role).toBeUndefined();
expect(dto.status).toBeUndefined();
});
it('should accept string values for all fields', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
dto.role = 'owner';
dto.status = 'suspended';
dto.email = 'suspended@example.com';
dto.search = 'suspended';
dto.page = 3;
dto.limit = 50;
dto.sortBy = 'displayName';
dto.sortDirection = 'asc';
// Assert
expect(dto.role).toBe('owner');
expect(dto.status).toBe('suspended');
expect(dto.email).toBe('suspended@example.com');
expect(dto.search).toBe('suspended');
expect(dto.page).toBe(3);
expect(dto.limit).toBe(50);
expect(dto.sortBy).toBe('displayName');
expect(dto.sortDirection).toBe('asc');
});
it('should handle numeric string values for pagination', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
dto.page = '5' as any;
dto.limit = '25' as any;
// Assert - Should accept the values
expect(dto.page).toBe('5');
expect(dto.limit).toBe('25');
});
it('should handle empty string values', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
dto.role = '';
dto.search = '';
// Assert
expect(dto.role).toBe('');
expect(dto.search).toBe('');
});
it('should handle special characters in search', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
dto.search = 'test@example.com';
// Assert
expect(dto.search).toBe('test@example.com');
});
it('should handle unicode characters', () => {
// Arrange & Act
const dto = new ListUsersRequestDto();
dto.search = 'tëst ñame';
// Assert
expect(dto.search).toBe('tëst ñame');
});
});
});

View File

@@ -0,0 +1,73 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, IsNumber, Min, Max } from 'class-validator';
export class ListUsersRequestDto {
@ApiPropertyOptional({
description: 'Filter by user role',
enum: ['owner', 'admin', 'user'],
})
@IsOptional()
@IsString()
role?: string;
@ApiPropertyOptional({
description: 'Filter by user status',
enum: ['active', 'suspended', 'deleted'],
})
@IsOptional()
@IsString()
status?: string;
@ApiPropertyOptional({
description: 'Filter by email',
})
@IsOptional()
@IsString()
email?: string;
@ApiPropertyOptional({
description: 'Search by email or display name',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({
description: 'Page number',
default: 1,
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
page?: number;
@ApiPropertyOptional({
description: 'Items per page',
default: 10,
minimum: 1,
maximum: 100,
})
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
limit?: number;
@ApiPropertyOptional({
description: 'Sort field',
enum: ['email', 'displayName', 'createdAt', 'lastLoginAt', 'status'],
})
@IsOptional()
@IsString()
sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status';
@ApiPropertyOptional({
description: 'Sort direction',
enum: ['asc', 'desc'],
default: 'asc',
})
@IsOptional()
@IsString()
sortDirection?: 'asc' | 'desc';
}

View File

@@ -0,0 +1,232 @@
import { UserResponseDto } from './UserResponseDto';
describe('UserResponseDto', () => {
describe('TDD - Test First', () => {
it('should create valid DTO with all fields', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['owner', 'admin'];
dto.status = 'active';
dto.isSystemAdmin = true;
dto.createdAt = new Date('2024-01-01T00:00:00Z');
dto.updatedAt = new Date('2024-01-02T00:00:00Z');
dto.lastLoginAt = new Date('2024-01-03T00:00:00Z');
dto.primaryDriverId = 'driver-456';
// Assert
expect(dto.id).toBe('user-123');
expect(dto.email).toBe('test@example.com');
expect(dto.displayName).toBe('Test User');
expect(dto.roles).toEqual(['owner', 'admin']);
expect(dto.status).toBe('active');
expect(dto.isSystemAdmin).toBe(true);
expect(dto.createdAt).toEqual(new Date('2024-01-01T00:00:00Z'));
expect(dto.updatedAt).toEqual(new Date('2024-01-02T00:00:00Z'));
expect(dto.lastLoginAt).toEqual(new Date('2024-01-03T00:00:00Z'));
expect(dto.primaryDriverId).toBe('driver-456');
});
it('should create DTO with required fields only', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['user'];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.id).toBe('user-123');
expect(dto.email).toBe('test@example.com');
expect(dto.displayName).toBe('Test User');
expect(dto.roles).toEqual(['user']);
expect(dto.status).toBe('active');
expect(dto.isSystemAdmin).toBe(false);
expect(dto.createdAt).toBeInstanceOf(Date);
expect(dto.updatedAt).toBeInstanceOf(Date);
expect(dto.lastLoginAt).toBeUndefined();
expect(dto.primaryDriverId).toBeUndefined();
});
it('should handle empty roles array', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = [];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.roles).toEqual([]);
});
it('should handle single role', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['admin'];
dto.status = 'active';
dto.isSystemAdmin = true;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.roles).toEqual(['admin']);
expect(dto.isSystemAdmin).toBe(true);
});
it('should handle suspended status', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['user'];
dto.status = 'suspended';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.status).toBe('suspended');
});
it('should handle deleted status', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['user'];
dto.status = 'deleted';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.status).toBe('deleted');
});
it('should handle very long display names', () => {
// Arrange
const longName = 'A'.repeat(100);
// Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = longName;
dto.roles = ['user'];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.displayName).toBe(longName);
});
it('should handle special characters in email', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test+tag@example.co.uk';
dto.displayName = 'Test User';
dto.roles = ['user'];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.email).toBe('test+tag@example.co.uk');
});
it('should handle unicode in display name', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Tëst Ñame';
dto.roles = ['user'];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.displayName).toBe('Tëst Ñame');
});
it('should handle UUID format for ID', () => {
// Arrange
const uuid = '550e8400-e29b-41d4-a716-446655440000';
// Act
const dto = new UserResponseDto();
dto.id = uuid;
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['user'];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
// Assert
expect(dto.id).toBe(uuid);
});
it('should handle numeric primary driver ID', () => {
// Arrange & Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['user'];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = new Date();
dto.updatedAt = new Date();
dto.primaryDriverId = '123456';
// Assert
expect(dto.primaryDriverId).toBe('123456');
});
it('should handle very old and very new dates', () => {
// Arrange
const oldDate = new Date('1970-01-01T00:00:00Z');
const newDate = new Date('2099-12-31T23:59:59Z');
// Act
const dto = new UserResponseDto();
dto.id = 'user-123';
dto.email = 'test@example.com';
dto.displayName = 'Test User';
dto.roles = ['user'];
dto.status = 'active';
dto.isSystemAdmin = false;
dto.createdAt = oldDate;
dto.updatedAt = newDate;
dto.lastLoginAt = newDate;
// Assert
expect(dto.createdAt).toEqual(oldDate);
expect(dto.updatedAt).toEqual(newDate);
expect(dto.lastLoginAt).toEqual(newDate);
});
});
});

View File

@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UserResponseDto {
@ApiProperty({ description: 'User ID' })
id: string = '';
@ApiProperty({ description: 'User email' })
email: string = '';
@ApiProperty({ description: 'Display name' })
displayName: string = '';
@ApiProperty({ description: 'User roles', type: [String] })
roles: string[] = [];
@ApiProperty({ description: 'User status' })
status: string = '';
@ApiProperty({ description: 'Whether user is system admin' })
isSystemAdmin: boolean = false;
@ApiProperty({ description: 'Account creation date' })
createdAt: Date = new Date();
@ApiProperty({ description: 'Last update date' })
updatedAt: Date = new Date();
@ApiPropertyOptional({ description: 'Last login date' })
lastLoginAt?: Date;
@ApiPropertyOptional({ description: 'Primary driver ID' })
primaryDriverId?: string;
}
export class UserListResponseDto {
@ApiProperty({ description: 'List of users', type: [UserResponseDto] })
users: UserResponseDto[] = [];
@ApiProperty({ description: 'Total number of users' })
total: number = 0;
@ApiProperty({ description: 'Current page number' })
page: number = 1;
@ApiProperty({ description: 'Items per page' })
limit: number = 10;
@ApiProperty({ description: 'Total number of pages' })
totalPages: number = 0;
}

View File

@@ -0,0 +1,63 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { DashboardStatsResult } from '../use-cases/GetDashboardStatsUseCase';
export interface DashboardStatsResponse {
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;
}[];
}
export class DashboardStatsPresenter implements UseCaseOutputPort<DashboardStatsResult> {
private _responseModel: DashboardStatsResponse | null = null;
present(result: DashboardStatsResult): void {
this._responseModel = {
totalUsers: result.totalUsers,
activeUsers: result.activeUsers,
suspendedUsers: result.suspendedUsers,
deletedUsers: result.deletedUsers,
systemAdmins: result.systemAdmins,
recentLogins: result.recentLogins,
newUsersToday: result.newUsersToday,
userGrowth: result.userGrowth,
roleDistribution: result.roleDistribution,
statusDistribution: result.statusDistribution,
activityTimeline: result.activityTimeline,
};
}
get responseModel(): DashboardStatsResponse {
if (!this._responseModel) {
throw new Error('No response model available. Call present() first.');
}
return this._responseModel;
}
reset(): void {
this._responseModel = null;
}
}

View File

@@ -0,0 +1,398 @@
import { ListUsersPresenter } from './ListUsersPresenter';
import { ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
describe('ListUsersPresenter', () => {
describe('TDD - Test First', () => {
it('should convert result to response DTO with all fields', () => {
// Arrange - Create domain objects
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User One',
roles: ['user'],
status: 'active',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
lastLoginAt: new Date('2024-01-03T00:00:00Z'),
primaryDriverId: 'driver-1',
});
const user2 = AdminUser.create({
id: 'user-2',
email: 'user2@example.com',
displayName: 'User Two',
roles: ['owner'],
status: 'active',
createdAt: new Date('2024-01-04T00:00:00Z'),
updatedAt: new Date('2024-01-05T00:00:00Z'),
lastLoginAt: new Date('2024-01-06T00:00:00Z'),
primaryDriverId: 'driver-2',
});
const result: ListUsersResult = {
users: [user1, user2],
total: 2,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
expect(response.users).toHaveLength(2);
expect(response.total).toBe(2);
expect(response.page).toBe(1);
expect(response.limit).toBe(10);
expect(response.totalPages).toBe(1);
// Check first user
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.id).toBe('user-1');
expect(user0.email).toBe('user1@example.com');
expect(user0.displayName).toBe('User One');
expect(user0.roles).toEqual(['user']);
expect(user0.status).toBe('active');
expect(user0.isSystemAdmin).toBe(false);
expect(user0.createdAt).toEqual(new Date('2024-01-01T00:00:00Z'));
expect(user0.updatedAt).toEqual(new Date('2024-01-02T00:00:00Z'));
expect(user0.lastLoginAt).toEqual(new Date('2024-01-03T00:00:00Z'));
expect(user0.primaryDriverId).toBe('driver-1');
}
// Check second user
const user1Response = response.users[1];
expect(user1Response).toBeDefined();
if (user1Response) {
expect(user1Response.id).toBe('user-2');
expect(user1Response.email).toBe('user2@example.com');
expect(user1Response.displayName).toBe('User Two');
expect(user1Response.roles).toEqual(['owner']);
expect(user1Response.status).toBe('active');
expect(user1Response.isSystemAdmin).toBe(true);
expect(user1Response.createdAt).toEqual(new Date('2024-01-04T00:00:00Z'));
expect(user1Response.updatedAt).toEqual(new Date('2024-01-05T00:00:00Z'));
expect(user1Response.lastLoginAt).toEqual(new Date('2024-01-06T00:00:00Z'));
expect(user1Response.primaryDriverId).toBe('driver-2');
}
});
it('should handle empty user list', () => {
// Arrange
const result: ListUsersResult = {
users: [],
total: 0,
page: 1,
limit: 10,
totalPages: 0,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
expect(response.users).toEqual([]);
expect(response.total).toBe(0);
expect(response.page).toBe(1);
expect(response.limit).toBe(10);
expect(response.totalPages).toBe(0);
});
it('should handle users without optional fields', () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User One',
roles: ['user'],
status: 'active',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
expect(response.users).toHaveLength(1);
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.lastLoginAt).toBeUndefined();
expect(user0.primaryDriverId).toBeUndefined();
}
});
it('should handle users with multiple roles', () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User One',
roles: ['owner', 'admin'],
status: 'active',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.roles).toEqual(['owner', 'admin']);
expect(user0.isSystemAdmin).toBe(true);
}
});
it('should handle suspended users', () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User One',
roles: ['user'],
status: 'suspended',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.status).toBe('suspended');
}
});
it('should handle deleted users', () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User One',
roles: ['user'],
status: 'deleted',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.status).toBe('deleted');
}
});
it('should handle pagination metadata correctly', () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-1',
email: 'user1@example.com',
displayName: 'User One',
roles: ['user'],
status: 'active',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
});
const result: ListUsersResult = {
users: [user1],
total: 15,
page: 2,
limit: 5,
totalPages: 3,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
expect(response.total).toBe(15);
expect(response.page).toBe(2);
expect(response.limit).toBe(5);
expect(response.totalPages).toBe(3);
});
it('should handle special characters in user data', () => {
// Arrange
const user1 = AdminUser.create({
id: 'user-123',
email: 'test+tag@example.co.uk',
displayName: 'Tëst Ñame',
roles: ['admin-steward'],
status: 'under-review',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
primaryDriverId: 'driver-123',
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.email).toBe('test+tag@example.co.uk');
expect(user0.displayName).toBe('Tëst Ñame');
expect(user0.roles).toEqual(['admin-steward']);
expect(user0.status).toBe('under-review');
expect(user0.primaryDriverId).toBe('driver-123');
}
});
it('should handle very long display names', () => {
// Arrange
const longName = 'A'.repeat(100);
const user1 = AdminUser.create({
id: 'user-1',
email: 'test@example.com',
displayName: longName,
roles: ['user'],
status: 'active',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.displayName).toBe(longName);
}
});
it('should handle UUID format for IDs', () => {
// Arrange
const uuid = '550e8400-e29b-41d4-a716-446655440000';
const user1 = AdminUser.create({
id: uuid,
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-02T00:00:00Z'),
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.id).toBe(uuid);
}
});
it('should handle very old and very new dates', () => {
// Arrange
const oldDate = new Date('1970-01-01T00:00:00Z');
const newDate = new Date('2099-12-31T23:59:59Z');
const user1 = AdminUser.create({
id: 'user-1',
email: 'test@example.com',
displayName: 'Test User',
roles: ['user'],
status: 'active',
createdAt: oldDate,
updatedAt: newDate,
lastLoginAt: newDate,
});
const result: ListUsersResult = {
users: [user1],
total: 1,
page: 1,
limit: 10,
totalPages: 1,
};
// Act
const response = ListUsersPresenter.toResponse(result);
// Assert
const user0 = response.users[0];
expect(user0).toBeDefined();
if (user0) {
expect(user0.createdAt).toEqual(oldDate);
expect(user0.updatedAt).toEqual(newDate);
expect(user0.lastLoginAt).toEqual(newDate);
}
});
});
});

View File

@@ -0,0 +1,85 @@
import { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { ListUsersResult } from '@core/admin/application/use-cases/ListUsersUseCase';
import { UserListResponseDto, UserResponseDto } from '../dtos/UserResponseDto';
import type { AdminUser } from '@core/admin/domain/entities/AdminUser';
export type ListUsersViewModel = UserListResponseDto;
/**
* Presenter to convert application layer result to API response DTO
* Implements UseCaseOutputPort to receive results from the use case
*/
export class ListUsersPresenter implements UseCaseOutputPort<ListUsersResult> {
private viewModel!: ListUsersViewModel;
present(result: ListUsersResult): void {
this.viewModel = {
users: result.users.map(user => this.toUserResponse(user)),
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
};
}
getViewModel(): ListUsersViewModel {
if (!this.viewModel) {
throw new Error('Presenter not presented');
}
return this.viewModel;
}
// Static method for backward compatibility with tests
static toResponse(result: ListUsersResult): UserListResponseDto {
const presenter = new ListUsersPresenter();
presenter.present(result);
return presenter.getViewModel();
}
private toUserResponse(user: AdminUser | Record<string, unknown>): UserResponseDto {
const response = new UserResponseDto();
// Handle both domain objects and plain objects
if (user.id && typeof user.id === 'object' && 'value' in (user.id as Record<string, unknown>)) {
// Domain object
const domainUser = user as AdminUser;
response.id = domainUser.id.value;
response.email = domainUser.email.value;
response.displayName = domainUser.displayName;
response.roles = domainUser.roles.map(r => r.value);
response.status = domainUser.status.value;
response.isSystemAdmin = domainUser.isSystemAdmin();
response.createdAt = domainUser.createdAt;
response.updatedAt = domainUser.updatedAt;
if (domainUser.lastLoginAt) {
response.lastLoginAt = domainUser.lastLoginAt;
}
if (domainUser.primaryDriverId) {
response.primaryDriverId = domainUser.primaryDriverId;
}
} else {
// Plain object (for tests)
const plainUser = user as Record<string, unknown>;
response.id = plainUser.id as string;
response.email = plainUser.email as string;
response.displayName = plainUser.displayName as string;
response.roles = plainUser.roles as string[];
response.status = plainUser.status as string;
response.isSystemAdmin = plainUser.isSystemAdmin as boolean;
response.createdAt = plainUser.createdAt as Date;
response.updatedAt = plainUser.updatedAt as Date;
if (plainUser.lastLoginAt) {
response.lastLoginAt = plainUser.lastLoginAt as Date;
}
if (plainUser.primaryDriverId) {
response.primaryDriverId = plainUser.primaryDriverId as string;
}
}
return response;
}
}

View File

@@ -0,0 +1,180 @@
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { IAdminUserRepository } from '@core/admin/domain/repositories/IAdminUserRepository';
import { AuthorizationService } from '@core/admin/domain/services/AuthorizationService';
import { UserId } from '@core/admin/domain/value-objects/UserId';
export interface DashboardStatsResult {
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;
}[];
}
export type GetDashboardStatsInput = {
actorId: string;
};
export type GetDashboardStatsErrorCode = 'AUTHORIZATION_ERROR' | 'REPOSITORY_ERROR';
export type GetDashboardStatsApplicationError = ApplicationErrorCode<GetDashboardStatsErrorCode, { message: string; details?: unknown }>;
export class GetDashboardStatsUseCase {
constructor(
private readonly adminUserRepo: IAdminUserRepository,
private readonly output: UseCaseOutputPort<DashboardStatsResult>,
) {}
async execute(input: GetDashboardStatsInput): Promise<Result<void, GetDashboardStatsApplicationError>> {
try {
// Get actor (current user)
const actor = await this.adminUserRepo.findById(UserId.fromString(input.actorId));
if (!actor) {
return Result.err({
code: 'AUTHORIZATION_ERROR',
details: { message: 'Actor not found' },
});
}
// Check authorization
if (!AuthorizationService.canListUsers(actor)) {
return Result.err({
code: 'AUTHORIZATION_ERROR',
details: { message: 'User is not authorized to view dashboard' },
});
}
// Get all users
const allUsersResult = await this.adminUserRepo.list();
const allUsers = allUsersResult.users;
// Calculate basic stats
const totalUsers = allUsers.length;
const activeUsers = allUsers.filter(u => u.status.value === 'active').length;
const suspendedUsers = allUsers.filter(u => u.status.value === 'suspended').length;
const deletedUsers = allUsers.filter(u => u.status.value === 'deleted').length;
const systemAdmins = allUsers.filter(u => u.isSystemAdmin()).length;
// Recent logins (last 24 hours)
const oneDayAgo = new Date();
oneDayAgo.setDate(oneDayAgo.getDate() - 1);
const recentLogins = allUsers.filter(u => u.lastLoginAt && u.lastLoginAt > oneDayAgo).length;
// New users today
const today = new Date();
today.setHours(0, 0, 0, 0);
const newUsersToday = allUsers.filter(u => u.createdAt > today).length;
// Role distribution
const roleCounts: Record<string, number> = {};
allUsers.forEach(user => {
user.roles.forEach(role => {
const roleValue = role.value;
roleCounts[roleValue] = (roleCounts[roleValue] || 0) + 1;
});
});
const roleDistribution = Object.entries(roleCounts).map(([role, count]) => ({
label: role.charAt(0).toUpperCase() + role.slice(1),
value: count,
color: role === 'owner' ? 'text-purple-500' : role === 'admin' ? 'text-blue-500' : 'text-gray-500',
}));
// User growth (last 7 days)
const userGrowth: DashboardStatsResult['userGrowth'] = [];
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const count = allUsers.filter(u => {
const userDate = new Date(u.createdAt);
return userDate.toDateString() === date.toDateString();
}).length;
userGrowth.push({
label: dateStr,
value: count,
color: 'text-primary-blue',
});
}
// Activity timeline (last 7 days)
const activityTimeline: DashboardStatsResult['activityTimeline'] = [];
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
const newUsers = allUsers.filter(u => {
const userDate = new Date(u.createdAt);
return userDate.toDateString() === date.toDateString();
}).length;
const logins = allUsers.filter(u => {
const loginDate = u.lastLoginAt;
return loginDate && loginDate.toDateString() === date.toDateString();
}).length;
activityTimeline.push({
date: dateStr,
newUsers,
logins,
});
}
const result: DashboardStatsResult = {
totalUsers,
activeUsers,
suspendedUsers,
deletedUsers,
systemAdmins,
recentLogins,
newUsersToday,
userGrowth,
roleDistribution,
statusDistribution: {
active: activeUsers,
suspended: suspendedUsers,
deleted: deletedUsers,
},
activityTimeline,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to get dashboard stats';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}

View File

@@ -12,6 +12,8 @@ export class AuthenticatedUserDTO {
primaryDriverId?: string;
@ApiProperty({ required: false, nullable: true })
avatarUrl?: string | null;
@ApiProperty({ required: false, enum: ['driver', 'sponsor', 'league-owner', 'league-steward', 'league-admin', 'system-owner', 'super-admin'] })
role?: 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin';
}
export class AuthSessionDTO {

View File

@@ -0,0 +1,11 @@
/**
* Admin Persistence Module
*
* Abstract module interface for admin persistence.
* Both InMemory and TypeORM implementations should export this.
*/
import { Module } from '@nestjs/common';
@Module({})
export class AdminPersistenceModule {}

View File

@@ -0,0 +1,7 @@
/**
* Admin Persistence Tokens
*
* Dependency injection tokens for admin persistence layer.
*/
export const ADMIN_USER_REPOSITORY_TOKEN = 'IAdminUserRepository';

View File

@@ -0,0 +1,15 @@
import { InMemoryAdminUserRepository } from '@core/admin/infrastructure/persistence/InMemoryAdminUserRepository';
import { Module } from '@nestjs/common';
import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens';
@Module({
providers: [
{
provide: ADMIN_USER_REPOSITORY_TOKEN,
useClass: InMemoryAdminUserRepository,
},
],
exports: [ADMIN_USER_REPOSITORY_TOKEN],
})
export class InMemoryAdminPersistenceModule {}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
import type { DataSource } from 'typeorm';
import { AdminUserOrmEntity } from '@core/admin/infrastructure/typeorm/entities/AdminUserOrmEntity';
import { TypeOrmAdminUserRepository } from '@core/admin/infrastructure/typeorm/repositories/TypeOrmAdminUserRepository';
import { AdminUserOrmMapper } from '@core/admin/infrastructure/typeorm/mappers/AdminUserOrmMapper';
import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens';
const typeOrmFeatureImports = [TypeOrmModule.forFeature([AdminUserOrmEntity])];
@Module({
imports: [...typeOrmFeatureImports],
providers: [
{ provide: AdminUserOrmMapper, useFactory: () => new AdminUserOrmMapper() },
{
provide: ADMIN_USER_REPOSITORY_TOKEN,
useFactory: (dataSource: DataSource, mapper: AdminUserOrmMapper) =>
new TypeOrmAdminUserRepository(dataSource, mapper),
inject: [getDataSourceToken(), AdminUserOrmMapper],
},
],
exports: [ADMIN_USER_REPOSITORY_TOKEN],
})
export class PostgresAdminPersistenceModule {}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
import type { DataSource } from 'typeorm';
import { AdminUserOrmEntity } from '@core/admin/infrastructure/typeorm/entities/AdminUserOrmEntity';
import { TypeOrmAdminUserRepository } from '@core/admin/infrastructure/typeorm/repositories/TypeOrmAdminUserRepository';
import { AdminUserOrmMapper } from '@core/admin/infrastructure/typeorm/mappers/AdminUserOrmMapper';
import { ADMIN_USER_REPOSITORY_TOKEN } from '../admin/AdminPersistenceTokens';
const typeOrmFeatureImports = [TypeOrmModule.forFeature([AdminUserOrmEntity])];
@Module({
imports: [...typeOrmFeatureImports],
providers: [
{ provide: AdminUserOrmMapper, useFactory: () => new AdminUserOrmMapper() },
{
provide: ADMIN_USER_REPOSITORY_TOKEN,
useFactory: (dataSource: DataSource, mapper: AdminUserOrmMapper) =>
new TypeOrmAdminUserRepository(dataSource, mapper),
inject: [getDataSourceToken(), AdminUserOrmMapper],
},
],
exports: [ADMIN_USER_REPOSITORY_TOKEN],
})
export class TypeOrmAdminPersistenceModule {}

View File

@@ -0,0 +1,13 @@
import { AdminLayout } from '@/components/admin/AdminLayout';
import { AdminDashboardPage } from '@/components/admin/AdminDashboardPage';
import { RouteGuard } from '@/lib/gateways/RouteGuard';
export default function AdminPage() {
return (
<RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
<AdminLayout>
<AdminDashboardPage />
</AdminLayout>
</RouteGuard>
);
}

View File

@@ -0,0 +1,13 @@
import { AdminLayout } from '@/components/admin/AdminLayout';
import { AdminUsersPage } from '@/components/admin/AdminUsersPage';
import { RouteGuard } from '@/lib/gateways/RouteGuard';
export default function AdminUsers() {
return (
<RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
<AdminLayout>
<AdminUsersPage />
</AdminLayout>
</RouteGuard>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState, useEffect } from 'react';
import { apiClient } from '@/lib/apiClient';
import Card from '@/components/ui/Card';
import { AdminViewModelService } from '@/lib/services/AdminViewModelService';
import { DashboardStatsViewModel } from '@/lib/view-models/AdminUserViewModel';
import {
Users,
Shield,
Activity,
Clock,
AlertTriangle,
RefreshCw
} from 'lucide-react';
export function AdminDashboardPage() {
const [stats, setStats] = useState<DashboardStatsViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadStats();
}, []);
const loadStats = async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.admin.getDashboardStats();
// Map DTO to View Model
const viewModel = AdminViewModelService.mapDashboardStats(response);
setStats(viewModel);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load stats';
if (message.includes('403') || message.includes('401')) {
setError('Access denied - You must be logged in as an Owner or Admin');
} else {
setError(message);
}
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-20 space-y-3">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading dashboard...</div>
</div>
);
}
if (error) {
return (
<div className="container mx-auto p-6">
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">Error</div>
<div className="text-sm opacity-90">{error}</div>
</div>
<button
onClick={loadStats}
className="px-3 py-1 text-xs bg-racing-red/20 hover:bg-racing-red/30 rounded"
>
Retry
</button>
</div>
</div>
);
}
if (!stats) {
return null;
}
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Admin Dashboard</h1>
<p className="text-gray-400 mt-1">System overview and statistics</p>
</div>
<button
onClick={loadStats}
disabled={loading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-3xl font-bold text-white">{stats.totalUsers}</div>
</div>
<Users className="w-8 h-8 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-3xl font-bold text-white">{stats.adminCount}</div>
</div>
<Shield className="w-8 h-8 text-purple-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active Users</div>
<div className="text-3xl font-bold text-white">{stats.activeUsers}</div>
</div>
<Activity className="w-8 h-8 text-green-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-orange-900/20 to-orange-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Recent Logins</div>
<div className="text-3xl font-bold text-white">{stats.recentLogins}</div>
</div>
<Clock className="w-8 h-8 text-orange-400" />
</div>
</Card>
</div>
{/* Activity Overview */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Activity */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Recent Activity</h3>
<div className="space-y-3">
{stats.recentActivity.length > 0 ? (
stats.recentActivity.map((activity, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-iron-gray/30 rounded-lg border border-charcoal-outline/50"
>
<div className="flex-1">
<div className="text-sm text-white">{activity.description}</div>
<div className="text-xs text-gray-500">{activity.timestamp}</div>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
activity.type === 'user_created' ? 'bg-blue-500/20 text-blue-300' :
activity.type === 'user_suspended' ? 'bg-yellow-500/20 text-yellow-300' :
activity.type === 'user_deleted' ? 'bg-red-500/20 text-red-300' :
'bg-gray-500/20 text-gray-300'
}`}>
{activity.type.replace('_', ' ')}
</span>
</div>
))
) : (
<div className="text-center py-8 text-gray-500">No recent activity</div>
)}
</div>
</Card>
{/* System Status */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">System Status</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">System Health</span>
<span className="px-2 py-1 text-xs rounded-full bg-performance-green/20 text-performance-green">
{stats.systemHealth}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Total Sessions</span>
<span className="text-white font-medium">{stats.totalSessions}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Active Sessions</span>
<span className="text-white font-medium">{stats.activeSessions}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-400">Avg Session Duration</span>
<span className="text-white font-medium">{stats.avgSessionDuration}</span>
</div>
</div>
</Card>
</div>
{/* Quick Actions */}
<Card>
<h3 className="text-lg font-semibold text-white mb-4">Quick Actions</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<button className="px-4 py-3 bg-primary-blue/20 border border-primary-blue/30 text-primary-blue rounded-lg hover:bg-primary-blue/30 transition-colors text-sm font-medium">
View All Users
</button>
<button className="px-4 py-3 bg-purple-500/20 border border-purple-500/30 text-purple-300 rounded-lg hover:bg-purple-500/30 transition-colors text-sm font-medium">
Manage Admins
</button>
<button className="px-4 py-3 bg-orange-500/20 border border-orange-500/30 text-orange-300 rounded-lg hover:bg-orange-500/30 transition-colors text-sm font-medium">
View Audit Log
</button>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { ReactNode, useState } from 'react';
import {
LayoutDashboard,
Users,
Settings,
LogOut,
Shield,
Activity
} from 'lucide-react';
import { useRouter, usePathname } from 'next/navigation';
interface AdminLayoutProps {
children: ReactNode;
}
type AdminTab = 'dashboard' | 'users';
export function AdminLayout({ children }: AdminLayoutProps) {
const router = useRouter();
const pathname = usePathname();
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
// Determine current tab from pathname
const getCurrentTab = (): AdminTab => {
if (pathname === '/admin') return 'dashboard';
if (pathname === '/admin/users') return 'users';
return 'dashboard';
};
const currentTab = getCurrentTab();
const navigation = [
{
id: 'dashboard',
label: 'Dashboard',
icon: LayoutDashboard,
href: '/admin',
description: 'Overview and statistics'
},
{
id: 'users',
label: 'User Management',
icon: Users,
href: '/admin/users',
description: 'Manage all users'
},
{
id: 'settings',
label: 'Settings',
icon: Settings,
href: '/admin/settings',
description: 'System configuration',
disabled: true
}
];
const handleNavigation = (href: string, disabled?: boolean) => {
if (!disabled) {
router.push(href);
}
};
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST' });
router.push('/');
} catch (error) {
console.error('Logout failed:', error);
}
};
return (
<div className="flex h-screen bg-deep-graphite overflow-hidden">
{/* Sidebar */}
<aside className={`${isSidebarOpen ? 'w-64' : 'w-20'} bg-iron-gray border-r border-charcoal-outline transition-all duration-300 flex flex-col`}>
{/* Logo/Header */}
<div className="p-4 border-b border-charcoal-outline">
<div className="flex items-center gap-2">
<Shield className="w-6 h-6 text-primary-blue" />
{isSidebarOpen && (
<span className="font-bold text-white text-lg">Admin Panel</span>
)}
</div>
{isSidebarOpen && (
<p className="text-xs text-gray-400 mt-1">System Administration</p>
)}
</div>
{/* Navigation */}
<nav className="flex-1 p-2 space-y-1 overflow-y-auto">
{navigation.map((item) => {
const Icon = item.icon;
const isActive = currentTab === item.id;
const isDisabled = item.disabled;
return (
<button
key={item.id}
onClick={() => handleNavigation(item.href, isDisabled)}
disabled={isDisabled}
className={`
w-full flex items-center gap-3 px-3 py-3 rounded-lg
transition-all duration-200 text-left
${isActive
? 'bg-primary-blue/20 text-primary-blue border border-primary-blue/30'
: 'text-gray-300 hover:bg-iron-gray/50 hover:text-white'
}
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<Icon className={`w-5 h-5 flex-shrink-0 ${isActive ? 'text-primary-blue' : 'text-gray-400'}`} />
{isSidebarOpen && (
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{item.label}</div>
<div className="text-xs text-gray-500">{item.description}</div>
</div>
)}
</button>
);
})}
</nav>
{/* Footer */}
<div className="p-2 border-t border-charcoal-outline space-y-1">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-gray-300 hover:bg-iron-gray/50 hover:text-white transition-colors"
>
<Activity className="w-5 h-5" />
{isSidebarOpen && <span className="text-sm">Toggle Sidebar</span>}
</button>
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-racing-red hover:bg-racing-red/10 transition-colors"
>
<LogOut className="w-5 h-5" />
{isSidebarOpen && <span className="text-sm">Logout</span>}
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col overflow-hidden">
{/* Top Bar */}
<header className="bg-iron-gray border-b border-charcoal-outline px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary-blue/20 rounded-lg">
<Shield className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">
{navigation.find(n => n.id === currentTab)?.label || 'Admin'}
</h2>
<p className="text-xs text-gray-400">
{navigation.find(n => n.id === currentTab)?.description}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 px-3 py-1.5 bg-deep-graphite rounded-lg border border-charcoal-outline">
<Shield className="w-4 h-4 text-purple-500" />
<span className="text-xs font-medium text-purple-400">Super Admin</span>
</div>
<div className="text-right hidden sm:block">
<div className="text-sm font-medium text-white">System Administrator</div>
<div className="text-xs text-gray-400">Full Access</div>
</div>
</div>
</div>
</header>
{/* Content Area */}
<div className="flex-1 overflow-y-auto bg-deep-graphite">
{children}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,341 @@
'use client';
import { useState, useEffect } from 'react';
import { apiClient } from '@/lib/apiClient';
import Card from '@/components/ui/Card';
import StatusBadge from '@/components/ui/StatusBadge';
import { AdminViewModelService } from '@/lib/services/AdminViewModelService';
import { AdminUserViewModel, UserListViewModel } from '@/lib/view-models/AdminUserViewModel';
import {
Search,
Filter,
RefreshCw,
Users,
Shield,
Trash2,
AlertTriangle
} from 'lucide-react';
export function AdminUsersPage() {
const [userList, setUserList] = useState<UserListViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [deletingUser, setDeletingUser] = useState<string | null>(null);
useEffect(() => {
const timeout = setTimeout(() => {
loadUsers();
}, 300);
return () => clearTimeout(timeout);
}, [search, roleFilter, statusFilter]);
const loadUsers = async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.admin.listUsers({
search: search || undefined,
role: roleFilter || undefined,
status: statusFilter || undefined,
page: 1,
limit: 50,
});
// Map DTO to View Model
const viewModel = AdminViewModelService.mapUserList(response);
setUserList(viewModel);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load users';
if (message.includes('403') || message.includes('401')) {
setError('Access denied - You must be logged in as an Owner or Admin');
} else {
setError(message);
}
} finally {
setLoading(false);
}
};
const handleUpdateStatus = async (userId: string, newStatus: string) => {
try {
await apiClient.admin.updateUserStatus(userId, newStatus);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update status');
}
};
const handleDeleteUser = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
return;
}
try {
setDeletingUser(userId);
await apiClient.admin.deleteUser(userId);
await loadUsers();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
setDeletingUser(null);
}
};
const clearFilters = () => {
setSearch('');
setRoleFilter('');
setStatusFilter('');
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">User Management</h1>
<p className="text-gray-400 mt-1">Manage and monitor all system users</p>
</div>
<button
onClick={loadUsers}
disabled={loading}
className="px-4 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white hover:bg-iron-gray/80 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
{/* Error Banner */}
{error && (
<div className="bg-racing-red/10 border border-racing-red text-racing-red px-4 py-3 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<div className="font-medium">Error</div>
<div className="text-sm opacity-90">{error}</div>
</div>
<button
onClick={() => setError(null)}
className="text-racing-red hover:opacity-70"
>
×
</button>
</div>
)}
{/* Filters Card */}
<Card>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">Filters</span>
</div>
{(search || roleFilter || statusFilter) && (
<button
onClick={clearFilters}
className="text-xs text-primary-blue hover:text-blue-400"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search by email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue transition-colors"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Roles</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue transition-colors"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="deleted">Deleted</option>
</select>
</div>
</div>
</Card>
{/* Users Table */}
<Card>
{loading ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-blue"></div>
<div className="text-gray-400">Loading users...</div>
</div>
) : !userList || !userList.hasUsers ? (
<div className="flex flex-col items-center justify-center py-12 space-y-3">
<Users className="w-12 h-12 text-gray-600" />
<div className="text-gray-400">No users found</div>
<button
onClick={clearFilters}
className="text-primary-blue hover:text-blue-400 text-sm"
>
Clear filters
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-charcoal-outline">
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">User</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Email</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Roles</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Status</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Last Login</th>
<th className="text-left py-3 px-4 text-xs font-medium text-gray-400 uppercase">Actions</th>
</tr>
</thead>
<tbody>
{userList.users.map((user: AdminUserViewModel, index: number) => (
<tr
key={user.id}
className={`border-b border-charcoal-outline/50 hover:bg-iron-gray/30 transition-colors ${index % 2 === 0 ? 'bg-transparent' : 'bg-iron-gray/10'}`}
>
<td className="py-3 px-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-blue/20 flex items-center justify-center">
<Shield className="w-4 h-4 text-primary-blue" />
</div>
<div>
<div className="font-medium text-white">{user.displayName}</div>
<div className="text-xs text-gray-500">ID: {user.id}</div>
{user.primaryDriverId && (
<div className="text-xs text-gray-500">Driver: {user.primaryDriverId}</div>
)}
</div>
</div>
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-300">{user.email}</div>
</td>
<td className="py-3 px-4">
<div className="flex flex-wrap gap-1">
{user.roleBadges.map((badge: string, idx: number) => (
<span
key={idx}
className={`px-2 py-1 text-xs rounded-full font-medium ${
user.roles[idx] === 'owner'
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
: user.roles[idx] === 'admin'
? 'bg-blue-500/20 text-blue-300 border border-blue-500/30'
: 'bg-gray-500/20 text-gray-300 border border-gray-500/30'
}`}
>
{badge}
</span>
))}
</div>
</td>
<td className="py-3 px-4">
<StatusBadge status={user.statusBadge.label.toLowerCase()} />
</td>
<td className="py-3 px-4">
<div className="text-sm text-gray-400">
{user.lastLoginFormatted}
</div>
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
{user.canSuspend && (
<button
onClick={() => handleUpdateStatus(user.id, 'suspended')}
className="px-3 py-1 text-xs rounded bg-yellow-500/20 text-yellow-300 hover:bg-yellow-500/30 transition-colors"
>
Suspend
</button>
)}
{user.canActivate && (
<button
onClick={() => handleUpdateStatus(user.id, 'active')}
className="px-3 py-1 text-xs rounded bg-performance-green/20 text-performance-green hover:bg-performance-green/30 transition-colors"
>
Activate
</button>
)}
{user.canDelete && (
<button
onClick={() => handleDeleteUser(user.id)}
disabled={deletingUser === user.id}
className="px-3 py-1 text-xs rounded bg-racing-red/20 text-racing-red hover:bg-racing-red/30 transition-colors flex items-center gap-1"
>
<Trash2 className="w-3 h-3" />
{deletingUser === user.id ? 'Deleting...' : 'Delete'}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Stats Summary */}
{userList && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="bg-gradient-to-br from-blue-900/20 to-blue-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Total Users</div>
<div className="text-2xl font-bold text-white">{userList.total}</div>
</div>
<Users className="w-6 h-6 text-blue-400" />
</div>
</Card>
<Card className="bg-gradient-to-br from-green-900/20 to-green-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Active</div>
<div className="text-2xl font-bold text-white">
{userList.users.filter(u => u.status === 'active').length}
</div>
</div>
<div className="w-6 h-6 text-green-400"></div>
</div>
</Card>
<Card className="bg-gradient-to-br from-purple-900/20 to-purple-700/10">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-400 mb-1">Admins</div>
<div className="text-2xl font-bold text-white">
{userList.users.filter(u => u.isSystemAdmin).length}
</div>
</div>
<Shield className="w-6 h-6 text-purple-400" />
</div>
</Card>
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useAuth } from '@/lib/auth/AuthContext';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy } from 'lucide-react';
import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy, Shield } from 'lucide-react';
import Link from 'next/link';
import React, { useEffect, useMemo, useState } from 'react';
@@ -65,6 +65,31 @@ function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } {
return demoMode;
}
// Helper to check if user has admin access (Owner or Super Admin)
function useHasAdminAccess(): boolean {
const { session } = useAuth();
const { isDemo, demoRole } = useDemoUserMode();
// Demo users with system-owner or super-admin roles
if (isDemo && (demoRole === 'system-owner' || demoRole === 'super-admin')) {
return true;
}
// Real users - would need role information from session
// For now, we'll check if the user has any admin-related capabilities
// This can be enhanced when the API includes role information
if (!session?.user) return false;
// Check for admin-related email patterns as a temporary measure
const email = session.user.email?.toLowerCase() || '';
const displayName = session.user.displayName?.toLowerCase() || '';
return email.includes('system-owner') ||
email.includes('super-admin') ||
displayName.includes('system owner') ||
displayName.includes('super admin');
}
// Sponsor Pill Component - matches the style of DriverSummaryPill
function SponsorSummaryPill({
onClick,
@@ -320,6 +345,17 @@ export default function UserPill() {
{/* Menu Items */}
<div className="py-1 text-sm text-gray-200">
{/* Admin link for system-owner and super-admin demo users */}
{(demoRole === 'system-owner' || demoRole === 'super-admin') && (
<Link
href="/admin"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-iron-gray/50 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Shield className="h-4 w-4 text-indigo-400" />
<span>Admin Area</span>
</Link>
)}
<div className="px-4 py-2 text-xs text-gray-500 italic">
Demo users have limited profile access
</div>
@@ -481,6 +517,8 @@ export default function UserPill() {
return null;
}
const hasAdminAccess = useHasAdminAccess();
return (
<div className="relative inline-flex items-center" data-user-pill>
<DriverSummaryPill
@@ -493,7 +531,7 @@ export default function UserPill() {
<AnimatePresence>
{isMenuOpen && (
<motion.div
<motion.div
className="absolute right-0 top-full mt-2 w-48 rounded-lg bg-deep-graphite border border-charcoal-outline shadow-lg z-50"
initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
@@ -501,6 +539,17 @@ export default function UserPill() {
transition={{ duration: 0.15 }}
>
<div className="py-1 text-sm text-gray-200">
{/* Admin link for Owner/Super Admin users */}
{hasAdminAccess && (
<Link
href="/admin"
className="flex items-center gap-2 px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"
onClick={() => setIsMenuOpen(false)}
>
<Shield className="h-4 w-4 text-indigo-400" />
<span>Admin Area</span>
</Link>
)}
<Link
href="/profile"
className="block px-3 py-2 hover:bg-charcoal-outline/80 transition-colors"

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;
}
}