xpeditis2.0/apps/backend/src/application/controllers/users.controller.ts

561 lines
17 KiB
TypeScript

import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
Query,
HttpCode,
HttpStatus,
Logger,
UsePipes,
ValidationPipe,
NotFoundException,
BadRequestException,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
UseGuards,
ForbiddenException,
ConflictException,
Inject,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
ApiQuery,
ApiParam,
ApiBearerAuth,
} from '@nestjs/swagger';
import {
CreateUserDto,
UpdateUserDto,
UpdatePasswordDto,
UserResponseDto,
UserListResponseDto,
} from '../dto/user.dto';
import { UserMapper } from '../mappers/user.mapper';
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
import { User, UserRole as DomainUserRole } from '@domain/entities/user.entity';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { RolesGuard } from '../guards/roles.guard';
import { FeatureFlagGuard } from '../guards/feature-flag.guard';
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
import { Roles } from '../decorators/roles.decorator';
import { RequiresFeature } from '../decorators/requires-feature.decorator';
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import * as crypto from 'crypto';
import { SubscriptionService } from '../services/subscription.service';
import { UserDeletionService } from '../services/user-deletion.service';
import { AdminContinuityService } from '../services/admin-continuity.service';
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
import {
OrganizationRepository,
ORGANIZATION_REPOSITORY,
} from '@domain/ports/out/organization.repository';
import { isAnonymisedEmail } from '@domain/services/data-retention';
/**
* Users Controller
*
* Manages user CRUD operations:
* - Create user / Invite user (admin/manager)
* - Get user details
* - Update user (admin/manager)
* - Delete/deactivate user (admin)
* - List users in organization
* - Update own password
*/
@ApiTags('Users')
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard, FeatureFlagGuard)
@RequiresFeature('user_management')
@ApiBearerAuth()
export class UsersController {
private readonly logger = new Logger(UsersController.name);
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
private readonly subscriptionService: SubscriptionService,
private readonly userDeletionService: UserDeletionService,
private readonly adminContinuity: AdminContinuityService,
@Inject(EMAIL_PORT) private readonly emailPort: EmailPort,
@Inject(ORGANIZATION_REPOSITORY)
private readonly organizationRepository: OrganizationRepository
) {}
/**
* Create/Invite a new user
*
* Admin can create users in any organization.
* Manager can only create users in their own organization.
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@Roles('admin', 'manager')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Create/Invite new user',
description:
'Create a new user account. Admin can create in any org, manager only in their own.',
})
@ApiResponse({
status: HttpStatus.CREATED,
description: 'User created successfully',
type: UserResponseDto,
})
@ApiResponse({
status: 401,
description: 'Unauthorized - missing or invalid token',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
@ApiBadRequestResponse({
description: 'Invalid request parameters',
})
async createUser(
@Body() dto: CreateUserDto,
@CurrentUser() user: UserPayload
): Promise<UserResponseDto & { invitationEmailSent: boolean }> {
this.logger.log(`[User: ${user.email}] Creating user: ${dto.email} (${dto.role})`);
// Authorization: Only ADMIN can assign ADMIN role
if (dto.role === 'ADMIN' && user.role !== 'ADMIN') {
throw new ForbiddenException('Only platform administrators can create users with ADMIN role');
}
// Authorization: Managers can only create users in their own organization
if (user.role === 'MANAGER' && dto.organizationId !== user.organizationId) {
throw new ForbiddenException('You can only create users in your own organization');
}
// Check if user already exists
const existingUser = await this.userRepository.findByEmail(dto.email);
if (existingUser) {
throw new ConflictException('User with this email already exists');
}
// Generate temporary password if not provided
const tempPassword = dto.password || this.generateTemporaryPassword();
// Hash password with Argon2id
const passwordHash = await argon2.hash(tempPassword, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
// Map DTO role to Domain role
const domainRole = dto.role as unknown as DomainUserRole;
// Create user entity
const newUser = User.create({
id: uuidv4(),
organizationId: dto.organizationId,
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
role: domainRole,
});
// Save to database
const savedUser = await this.userRepository.save(newUser);
this.logger.log(`User created successfully: ${savedUser.id}`);
// L'email d'accès n'etait jamais envoye (TODO), et le mot de passe
// temporaire etait ecrit en clair dans les journaux. Il part desormais par
// email, et n'apparait plus nulle part ailleurs.
const invitationEmailSent = await this.sendAccountCreatedEmail(savedUser, tempPassword, user);
return { ...UserMapper.toDto(savedUser), invitationEmailSent };
}
/**
* Envoie au nouveau compte ses identifiants et le lien de connexion.
* Un echec n'annule pas la creation : il est signale a l'administrateur.
*/
private async sendAccountCreatedEmail(
newUser: User,
tempPassword: string,
creator: UserPayload
): Promise<boolean> {
try {
const [organization, creatorAccount] = await Promise.all([
this.organizationRepository.findById(newUser.organizationId),
this.userRepository.findById(creator.id),
]);
const inviterName = creatorAccount
? `${creatorAccount.firstName} ${creatorAccount.lastName}`.trim()
: creator.email;
await this.emailPort.sendUserInvitation(
newUser.email,
organization?.name ?? 'Xpeditis',
inviterName || creator.email,
tempPassword
);
this.logger.log(`Access email sent to new user ${newUser.email}`);
return true;
} catch (error: unknown) {
this.logger.error('User created but the access email failed');
return false;
}
}
/**
* Get user by ID
*/
@Get(':id')
@Roles('admin', 'manager')
@ApiOperation({
summary: 'Get user by ID',
description: 'Retrieve user details. Only ADMIN and MANAGER can access this endpoint.',
})
@ApiParam({
name: 'id',
description: 'User ID (UUID)',
example: '550e8400-e29b-41d4-a716-446655440000',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'User details retrieved successfully',
type: UserResponseDto,
})
@ApiNotFoundResponse({
description: 'User not found',
})
async getUser(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() currentUser: UserPayload
): Promise<UserResponseDto> {
this.logger.log(`[User: ${currentUser.email}] Fetching user: ${id}`);
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
// Authorization: Can only view users in same organization (unless admin)
if (currentUser.role !== 'ADMIN' && user.organizationId !== currentUser.organizationId) {
throw new ForbiddenException('You can only view users in your organization');
}
return UserMapper.toDto(user);
}
/**
* Update user
*/
@Patch(':id')
@Roles('admin', 'manager')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Update user',
description: 'Update user details (name, role, status). Admin/manager only.',
})
@ApiParam({
name: 'id',
description: 'User ID (UUID)',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'User updated successfully',
type: UserResponseDto,
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin or manager role',
})
@ApiNotFoundResponse({
description: 'User not found',
})
async updateUser(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() currentUser: UserPayload
): Promise<UserResponseDto> {
this.logger.log(`[User: ${currentUser.email}] Updating user: ${id}`);
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
// Security: Prevent users from changing their own role
if (dto.role && id === currentUser.id) {
this.logger.warn(`[SECURITY] User ${currentUser.email} attempted to change their own role`);
throw new BadRequestException('You cannot change your own role');
}
if (user.role === DomainUserRole.ADMIN && currentUser.role !== DomainUserRole.ADMIN) {
throw new ForbiddenException('Only platform administrators can update ADMIN users');
}
// Authorization: Only ADMIN can assign ADMIN role
if (dto.role === 'ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only platform administrators can assign ADMIN role');
}
// Authorization: Managers can only update users in their own organization
if (currentUser.role === 'MANAGER' && user.organizationId !== currentUser.organizationId) {
throw new ForbiddenException('You can only update users in your own organization');
}
// At least one active admin must remain. Checked before any write — the
// license would otherwise be revoked for a change that is then refused.
await this.adminContinuity.assertKeepsAnActiveAdmin(user, {
role: dto.role ?? user.role,
isActive: dto.isActive ?? user.isActive,
});
// Update fields
if (dto.firstName) {
user.updateFirstName(dto.firstName);
}
if (dto.lastName) {
user.updateLastName(dto.lastName);
}
if (dto.role) {
const domainRole = dto.role as unknown as DomainUserRole;
user.updateRole(domainRole);
}
if (dto.isActive !== undefined) {
if (dto.isActive) {
user.activate();
// Reallocate license if reactivating user
try {
await this.subscriptionService.allocateLicense(id, user.organizationId);
this.logger.log(`License reallocated for reactivated user: ${id}`);
} catch (error) {
this.logger.error(`Failed to reallocate license for user ${id}:`, error);
throw new ForbiddenException(
'Cannot reactivate user: no licenses available. Please upgrade your subscription.'
);
}
} else {
user.deactivate();
// Revoke license when deactivating user
await this.subscriptionService.revokeLicense(id);
this.logger.log(`License revoked for deactivated user: ${id}`);
}
}
// Save updated user
const updatedUser = await this.userRepository.save(user);
this.logger.log(`User updated successfully: ${updatedUser.id}`);
return UserMapper.toDto(updatedUser);
}
/**
* Delete/deactivate user
*/
@Delete(':id')
@Roles('admin')
@ApiOperation({
summary: 'Delete user',
description:
'Erase a user (any role, including another ADMIN): personal data is deleted or anonymised, ' +
'the account is disabled and its license revoked. Bookings are kept. Admin only; ' +
'an admin cannot delete their own account here, nor the last active admin.',
})
@ApiParam({
name: 'id',
description: 'User ID (UUID)',
})
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: 'User deactivated successfully',
})
@ApiResponse({
status: 403,
description: 'Forbidden - requires admin role',
})
@ApiNotFoundResponse({
description: 'User not found',
})
async deleteUser(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() currentUser: UserPayload
): Promise<void> {
this.logger.log(`[Admin: ${currentUser.email}] Deleting user: ${id}`);
// GDPR erasure (bookings kept) + license revocation — see UserDeletionService
await this.userDeletionService.deleteByAdmin(id, currentUser);
this.logger.log(`User deleted successfully: ${id}`);
}
/**
* List users in organization
*/
@Get()
@Roles('admin', 'manager')
@ApiOperation({
summary: 'List users',
description:
'Retrieve a paginated list of users in your organization. Only ADMIN and MANAGER can access this endpoint.',
})
@ApiQuery({
name: 'page',
required: false,
description: 'Page number (1-based)',
example: 1,
})
@ApiQuery({
name: 'pageSize',
required: false,
description: 'Number of items per page',
example: 20,
})
@ApiQuery({
name: 'role',
required: false,
description: 'Filter by role',
enum: ['admin', 'manager', 'user', 'viewer'],
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Users list retrieved successfully',
type: UserListResponseDto,
})
async listUsers(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('pageSize', new DefaultValuePipe(20), ParseIntPipe) pageSize: number,
@Query('role') role: string | undefined,
@CurrentUser() currentUser: UserPayload
): Promise<UserListResponseDto> {
this.logger.log(
`[User: ${currentUser.email}] Listing users: page=${page}, pageSize=${pageSize}, role=${role}`
);
// Fetch users from current user's organization
this.logger.log(
`[User: ${currentUser.email}] Fetching users from organization: ${currentUser.organizationId}`
);
// Erased accounts stay in the table (their bookings reference them) but are
// no longer users: hide them from the list.
let users = (await this.userRepository.findByOrganization(currentUser.organizationId)).filter(
u => !isAnonymisedEmail(u.email)
);
// Security: Non-admin users cannot see ADMIN users
if (currentUser.role !== 'ADMIN') {
users = users.filter(u => u.role !== DomainUserRole.ADMIN);
this.logger.log(`[SECURITY] Non-admin user ${currentUser.email} - filtered out ADMIN users`);
} else {
this.logger.log(
`[ADMIN] User ${currentUser.email} can see all users including ADMINs in their organization`
);
}
// Filter by role if provided
const filteredUsers = role ? users.filter(u => u.role === role) : users;
// Paginate
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
// Convert to DTOs
const userDtos = UserMapper.toDtoArray(paginatedUsers);
const totalPages = Math.ceil(filteredUsers.length / pageSize);
return {
users: userDtos,
total: filteredUsers.length,
page,
pageSize,
totalPages,
};
}
/**
* Update own password
*/
@Patch('me/password')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
@ApiOperation({
summary: 'Update own password',
description: 'Update your own password. Requires current password.',
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Password updated successfully',
schema: {
properties: {
message: { type: 'string', example: 'Password updated successfully' },
},
},
})
@ApiBadRequestResponse({
description: 'Invalid current password',
})
async updatePassword(
@Body() dto: UpdatePasswordDto,
@CurrentUser() currentUser: UserPayload
): Promise<{ message: string }> {
this.logger.log(`[User: ${currentUser.email}] Updating password`);
const user = await this.userRepository.findById(currentUser.id);
if (!user) {
throw new NotFoundException('User not found');
}
// Verify current password
const isPasswordValid = await argon2.verify(user.passwordHash, dto.currentPassword);
if (!isPasswordValid) {
throw new ForbiddenException('Current password is incorrect');
}
// Hash new password
const newPasswordHash = await argon2.hash(dto.newPassword, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4,
});
// Update password
user.updatePassword(newPasswordHash);
await this.userRepository.save(user);
this.logger.log(`Password updated successfully for user: ${user.id}`);
return { message: 'Password updated successfully' };
}
/**
* Generate a secure temporary password
*/
private generateTemporaryPassword(): string {
const length = 16;
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
let password = '';
const randomBytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
password += charset[randomBytes[i] % charset.length];
}
return password;
}
}