diff --git a/src/app.module.ts b/src/app.module.ts index 0297f94..9e71877 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,7 +15,7 @@ import { TerminusModule } from '@nestjs/terminus'; import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config'; import { AuthModule } from './auth/auth.module'; import { HubUsersModule } from './hub-users/hub-users.module'; -import { StartupService } from './auth/services/startup.service'; +import { StartupServiceInitialization } from './auth/services/startup.service'; @Module({ imports: [ @@ -72,7 +72,7 @@ import { StartupService } from './auth/services/startup.service'; ], providers: [ - StartupService, + StartupServiceInitialization, // Global Authentication Guard { provide: APP_GUARD, diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index a2acbba..aed00c8 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -324,17 +324,8 @@ export class AuthController { type: ErrorResponseDto }) async getProfile(@AuthenticatedUser() user: any) { - this.logger.log(`Profile requested for user: ${user.preferred_username}`); + return this.usersService.getCompleteUserProfile(user.sub, user); - return { - id: user.sub, - username: user.preferred_username, - email: user.email, - firstName: user.given_name, - lastName: user.family_name, - roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [], - emailVerified: user.email_verified, - }; } /** ------------------------------- diff --git a/src/auth/services/keycloak-api.service.ts b/src/auth/services/keycloak-api.service.ts index 1468e27..9a4ed34 100644 --- a/src/auth/services/keycloak-api.service.ts +++ b/src/auth/services/keycloak-api.service.ts @@ -2,16 +2,46 @@ import { Injectable, Logger, HttpException, NotFoundException, BadRequestExcepti import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { AxiosResponse } from 'axios'; -import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs'; +import { firstValueFrom, timeout } from 'rxjs'; import { TokenService } from './token.service'; -import { KeycloakUser, KeycloakRole, CreateUserData, UserRole, UserType } from './keycloak-user.model'; +import { KeycloakUser, KeycloakRole, CreateUserData, UserRole } from './keycloak-user.model'; -// Interface pour la hiérarchie des rôles -interface RoleHierarchy { - role: UserRole; - canCreate: UserRole[]; - requiresMerchantPartner?: boolean; -} +// === CONFIGURATION CENTRALISÉE AVEC HIÉRARCHIE DES RÔLES === +const ROLE_HIERARCHY = { + [UserRole.DCB_ADMIN]: [ + UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, + UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_SUPPORT]: [ + UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_PARTNER]: [ + UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_PARTNER_ADMIN]: [ + UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_PARTNER_MANAGER]: [], + [UserRole.DCB_PARTNER_SUPPORT]: [] +} as Record; + +const CONFIG = { + TIMEOUTS: { + REQUEST: 10000, + HEALTH_CHECK: 5000 + }, + ROLES: { + HUB: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], + MERCHANT: [ + UserRole.DCB_PARTNER, + UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, + UserRole.DCB_PARTNER_SUPPORT + ] + }, + HIERARCHY: ROLE_HIERARCHY +}; @Injectable() export class KeycloakApiService { @@ -19,40 +49,7 @@ export class KeycloakApiService { private readonly keycloakBaseUrl: string; private readonly realm: string; private readonly clientId: string; - - // Hiérarchie des rôles - CORRIGÉE selon votre analyse - private readonly roleHierarchy: RoleHierarchy[] = [ - { - role: UserRole.DCB_ADMIN, - canCreate: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], - requiresMerchantPartner: false - }, - { - role: UserRole.DCB_SUPPORT, - canCreate: [UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], - requiresMerchantPartner: false - }, - { - role: UserRole.DCB_PARTNER, - canCreate: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], - requiresMerchantPartner: false - }, - { - role: UserRole.DCB_PARTNER_ADMIN, - canCreate: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], - requiresMerchantPartner: true - }, - { - role: UserRole.DCB_PARTNER_MANAGER, - canCreate: [], - requiresMerchantPartner: true - }, - { - role: UserRole.DCB_PARTNER_SUPPORT, - canCreate: [], - requiresMerchantPartner: true - } - ]; + private clientCache?: { id: string }; constructor( private readonly httpService: HttpService, @@ -64,10 +61,478 @@ export class KeycloakApiService { this.clientId = this.configService.get('KEYCLOAK_CLIENT_ID') || 'dcb-admin-cli'; } - // ===== CORE REQUEST METHOD ===== + // === MÉTHODES DE GESTION DE LA HIÉRARCHIE DES RÔLES === + + /** + * Vérifie si un rôle peut créer un autre rôle selon la hiérarchie + */ + canRoleCreateRole(creatorRole: UserRole, targetRole: UserRole): boolean { + return ROLE_HIERARCHY[creatorRole]?.includes(targetRole) || false; + } + + /** + * Vérifie si un ensemble de rôles peut créer un rôle cible + */ + canRolesCreateRole(creatorRoles: UserRole[], targetRole: UserRole): boolean { + return creatorRoles.some(creatorRole => this.canRoleCreateRole(creatorRole, targetRole)); + } + + /** + * Obtient le rôle le plus élevé dans la hiérarchie + */ + getHighestRole(roles: UserRole[]): UserRole { + const rolePriority: UserRole[] = [ + UserRole.DCB_ADMIN, + UserRole.DCB_SUPPORT, + UserRole.DCB_PARTNER, + UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, + UserRole.DCB_PARTNER_SUPPORT + ]; + + for (const role of rolePriority) { + if (roles.includes(role)) { + return role; + } + } + + throw new ForbiddenException('Cannot determine user role'); + } + + /** + * Obtient tous les rôles qu'un rôle peut gérer + */ + getManageableRoles(role: UserRole): UserRole[] { + return ROLE_HIERARCHY[role] || []; + } + + // === INTERFACE PUBLIQUE PRINCIPALE === + + async authenticateUser(username: string, password: string) { + return this.tokenService.acquireUserToken(username, password); + } + + async createUser(creatorId: string, userData: CreateUserData): Promise { + await this.validateUserCreation(creatorId, userData); + + const [creatorUsername, userPayload] = await Promise.all([ + this.getCreatorUsername(creatorId), + this.buildUserPayload(userData, creatorId) + ]); + + const finalPayload = { + ...userPayload, + attributes: { + ...userPayload.attributes, + createdByUsername: [creatorUsername] + } + }; + + try { + await this.request('POST', `/admin/realms/${this.realm}/users`, finalPayload); + + const users = await this.findUserByUsername(userData.username); + const userId = users[0]?.id; + + if (!userId) { + throw new Error('User not found after creation'); + } + + if (userData.clientRoles?.length) { + await this.setClientRoles(userId, userData.clientRoles); + } + + this.logger.log(`User created successfully: ${userData.username} (ID: ${userId})`); + return userId; + } catch (error: any) { + this.logger.error(`Failed to create user ${userData.username}: ${error.message}`); + throw error; + } + } + + async getUserById(userId: string, requesterId: string): Promise { + const user = await this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); + + if (userId !== requesterId) { + const userMerchantPartnerId = user.attributes?.merchantPartnerId?.[0]; + await this.validateUserAccess(requesterId, userMerchantPartnerId); + } + + return user; + } + + async updateUser(userId: string, updates: Partial, requesterId: string): Promise { + const currentUser = await this.getUserById(userId, requesterId); + const userMerchantPartnerId = currentUser.attributes?.merchantPartnerId?.[0]; + + await this.validateUserAccess(requesterId, userMerchantPartnerId); + return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, updates); + } + + async deleteUser(userId: string, requesterId: string): Promise { + await this.validateDeletion(requesterId, userId); + + const userMerchantPartnerId = await this.getUserMerchantPartnerId(userId); + await this.validateUserAccess(requesterId, userMerchantPartnerId); + + this.logSecurityEvent('USER_DELETION_ATTEMPT', 'HIGH', { + targetUserId: userId, + requesterId, + merchantPartnerId: userMerchantPartnerId + }); + + try { + await this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`); + this.logSecurityEvent('USER_DELETED_SUCCESS', 'HIGH', { targetUserId: userId, requesterId }); + } catch (error) { + this.logSecurityEvent('USER_DELETION_FAILED', 'HIGH', { + targetUserId: userId, + requesterId, + error: error.message + }); + throw error; + } + } + + // === GESTION DES MOTS DE PASSE === + + async resetUserPassword(userId: string, newPassword: string, temporary: boolean = true): Promise { + const passwordPayload = { + type: 'password', + value: newPassword, + temporary, + }; + + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, passwordPayload); + this.logger.log(`Password reset for user ${userId}, temporary: ${temporary}`); + } + + async updateUserPassword(userId: string, currentPassword: string, newPassword: string): Promise { + const passwordPayload = { + type: 'password', + value: newPassword, + temporary: false, + }; + + // Vérifier d'abord le mot de passe actuel + const user = await this.getUserById(userId, userId); + try { + await this.authenticateUser(user.username, currentPassword); + } catch (error) { + throw new ForbiddenException('Current password is incorrect'); + } + + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, passwordPayload); + this.logger.log(`Password updated for user ${userId}`); + } + + async sendResetPasswordEmail(email: string): Promise { + const users = await this.findUserByEmail(email); + if (users.length === 0) { + throw new NotFoundException('User with this email not found'); + } + + const userId = users[0].id; + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/execute-actions-email`, ['UPDATE_PASSWORD']); + this.logger.log(`Password reset email sent to ${email}`); + } + + // === GESTION DES RÔLES === + + async getUserClientRoles(userId: string | undefined): Promise { + try { + const client = await this.getClient(); + return await this.request( + 'GET', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}` + ); + } catch (error) { + this.logger.warn(`Failed to get client roles for user ${userId}: ${error.message}`); + return []; + } + } + + async setClientRoles(userId: string, roles: UserRole[]): Promise { + try { + const client = await this.getClient(); + const [currentRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(userId), + Promise.all(roles.map(role => this.getRole(role, client.id))) + ]); + + if (currentRoles.length > 0) { + await this.request( + 'DELETE', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + currentRoles + ); + } + + if (targetRoles.length > 0) { + await this.request( + 'POST', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + targetRoles + ); + } + + this.logger.log(`Roles updated for user ${userId}: ${roles.join(', ')}`); + } catch (error) { + this.logger.error(`Failed to set roles for user ${userId}: ${error.message}`); + throw error; + } + } + + async addClientRoles(userId: string, roles: UserRole[]): Promise { + try { + const client = await this.getClient(); + const targetRoles = await Promise.all(roles.map(role => this.getRole(role, client.id))); + + if (targetRoles.length > 0) { + await this.request( + 'POST', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + targetRoles + ); + } + + this.logger.log(`Roles added to user ${userId}: ${roles.join(', ')}`); + } catch (error) { + this.logger.error(`Failed to add roles to user ${userId}: ${error.message}`); + throw error; + } + } + + async removeClientRoles(userId: string, roles: UserRole[]): Promise { + try { + const client = await this.getClient(); + const targetRoles = await Promise.all(roles.map(role => this.getRole(role, client.id))); + + if (targetRoles.length > 0) { + await this.request( + 'DELETE', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + targetRoles + ); + } + + this.logger.log(`Roles removed from user ${userId}: ${roles.join(', ')}`); + } catch (error) { + this.logger.error(`Failed to remove roles from user ${userId}: ${error.message}`); + throw error; + } + } + + async getAvailableClientRoles(): Promise { + try { + const client = await this.getClient(); + return await this.request( + 'GET', + `/admin/realms/${this.realm}/clients/${client.id}/roles` + ); + } catch (error) { + this.logger.error(`Failed to get available client roles: ${error.message}`); + throw error; + } + } + + // === RECHERCHE D'UTILISATEURS === + + async getAllUsers(): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users`); + } + + async findUserByUsername(username: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}` + ); + return users.filter(user => user.username === username); + } + + async findUserByEmail(email: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}` + ); + return users.filter(user => user.email === email); + } + + async findUsersByAttribute(attribute: string, value: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?q=${attribute}:${encodeURIComponent(value)}` + ); + return users.filter(user => user.attributes?.[attribute]?.includes(value)); + } + + async findUsersByMerchantPartnerId(merchantPartnerId: string): Promise { + return this.findUsersByAttribute('merchantPartnerId', merchantPartnerId); + } + + async searchUsers(query: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?search=${encodeURIComponent(query)}` + ); + return users; + } + + async getUsersWithPagination(first: number = 0, max: number = 100): Promise { + return this.request( + 'GET', + `/admin/realms/${this.realm}/users?first=${first}&max=${max}` + ); + } + + async countUsers(): Promise { + const users = await this.request('GET', `/admin/realms/${this.realm}/users?briefRepresentation=true`); + return users.length; + } + + // === GESTION DES SESSIONS === + + async getUserSessions(userId: string): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users/${userId}/sessions`); + } + + async logoutUser(userId: string): Promise { + await this.request('POST', `/admin/realms/${this.realm}/users/${userId}/logout`); + this.logger.log(`User ${userId} logged out from all sessions`); + } + + // === MÉTHODES UTILITAIRES === + + async getUserMerchantPartnerId(userId: string): Promise { + try { + const user = await this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); + return user.attributes?.merchantPartnerId?.[0] || null; + } catch (error) { + this.logger.warn(`Failed to get merchantPartnerId for user ${userId}: ${error.message}`); + return null; + } + } + + async setUserAttributes(userId: string, attributes: Record): Promise { + try { + const user = await this.getUserById(userId, userId); + const updatedUser = { + ...user, + attributes: { ...user.attributes, ...attributes } + }; + + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, updatedUser); + this.logger.log(`Attributes updated for user ${userId}: ${Object.keys(attributes).join(', ')}`); + } catch (error: any) { + this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`); + throw error; + } + } + + async getUserAttribute(userId: string, attribute: string): Promise { + try { + const user = await this.getUserById(userId, userId); + return user.attributes?.[attribute] || null; + } catch (error) { + this.logger.warn(`Failed to get attribute ${attribute} for user ${userId}: ${error.message}`); + return null; + } + } + + async enableUser(userId: string): Promise { + await this.updateUser(userId, { enabled: true }, userId); + this.logger.log(`User ${userId} enabled`); + } + + async disableUser(userId: string): Promise { + await this.updateUser(userId, { enabled: false }, userId); + this.logger.log(`User ${userId} disabled`); + } + + async isUserEnabled(userId: string): Promise { + const user = await this.getUserById(userId, userId); + return user.enabled; + } + + // === VALIDATION DES PERMISSIONS === + + async validateUserAccess(requesterId: string, targetMerchantPartnerId?: string | null): Promise { + const requesterRoles = await this.getUserClientRoles(requesterId); + const requesterRoleNames = requesterRoles.map(role => role.name as UserRole); + + // Accès complet pour les administrateurs Hub + if (requesterRoleNames.some(role => CONFIG.ROLES.HUB.includes(role))) { + return; + } + + // Validation pour DCB_PARTNER + if (requesterRoleNames.includes(UserRole.DCB_PARTNER)) { + if (requesterId === targetMerchantPartnerId) return; + throw new ForbiddenException('DCB_PARTNER can only access their own merchant data'); + } + + // Validation pour les utilisateurs Merchant + if (targetMerchantPartnerId) { + const requesterMerchantId = await this.getUserMerchantPartnerId(requesterId); + if (requesterMerchantId === targetMerchantPartnerId) { + const hasMerchantRole = requesterRoleNames.some(role => + CONFIG.ROLES.MERCHANT.includes(role) + ); + if (hasMerchantRole) return; + } + } + + throw new ForbiddenException('Insufficient permissions to access this resource'); + } + + // === VÉRIFICATIONS DE SANTÉ === + + async checkKeycloakAvailability(): Promise { + const url = `${this.keycloakBaseUrl}/realms/${this.realm}`; + try { + await firstValueFrom(this.httpService.get(url).pipe(timeout(CONFIG.TIMEOUTS.HEALTH_CHECK))); + this.logger.log(`Keycloak available: ${url}`); + return true; + } catch (error: any) { + this.logger.error(`Keycloak unavailable: ${error.message}`); + return false; + } + } + + async checkServiceConnection(): Promise { + try { + const token = await this.tokenService.acquireServiceAccountToken(); + if (!token) return false; + + const testUrl = `${this.keycloakBaseUrl}/admin/realms/${this.realm}/users`; + const config = { headers: { Authorization: `Bearer ${token}` } }; + + await firstValueFrom(this.httpService.get(testUrl, config).pipe(timeout(CONFIG.TIMEOUTS.HEALTH_CHECK))); + this.logger.log('Service connection to Keycloak: OK'); + return true; + } catch (error: any) { + this.logger.error(`Service connection failed: ${error.message}`); + return false; + } + } + + async getKeycloakVersion(): Promise { + try { + const response = await firstValueFrom( + this.httpService.get(`${this.keycloakBaseUrl}/admin/serverinfo`).pipe(timeout(CONFIG.TIMEOUTS.REQUEST)) + ); + return response.data.systemInfo?.version || 'Unknown'; + } catch (error) { + this.logger.warn(`Failed to get Keycloak version: ${error.message}`); + return 'Unknown'; + } + } + + // === MÉTHODES PRIVÉES PRINCIPALES === + private async request( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', - path: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, data?: any ): Promise { const token = await this.tokenService.acquireServiceAccountToken(); @@ -78,617 +543,123 @@ export class KeycloakApiService { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - timeout: 10000, + timeout: CONFIG.TIMEOUTS.REQUEST, }; try { - let response: AxiosResponse; - - switch (method) { - case 'GET': - response = await firstValueFrom(this.httpService.get(url, config).pipe(rxjsTimeout(10000))); - break; - case 'POST': - response = await firstValueFrom(this.httpService.post(url, data, config).pipe(rxjsTimeout(10000))); - break; - case 'PUT': - response = await firstValueFrom(this.httpService.put(url, data, config).pipe(rxjsTimeout(10000))); - break; - case 'DELETE': - response = await firstValueFrom(this.httpService.delete(url, config).pipe(rxjsTimeout(10000))); - break; - default: - throw new BadRequestException(`Unsupported HTTP method: ${method}`); - } - + const response = await this.executeRequest(method, url, config, data); return response.data; } catch (error: any) { this.handleRequestError(error, path); } } - // ===== ERROR HANDLING ===== + private async executeRequest( + method: string, + url: string, + config: any, + data?: any + ): Promise> { + switch (method) { + case 'GET': + return firstValueFrom(this.httpService.get(url, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + case 'POST': + return firstValueFrom(this.httpService.post(url, data, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + case 'PUT': + return firstValueFrom(this.httpService.put(url, data, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + case 'DELETE': + return firstValueFrom(this.httpService.delete(url, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + default: + throw new BadRequestException(`Unsupported HTTP method: ${method}`); + } + } + private handleRequestError(error: any, context: string): never { - if (error.response?.status === 404) { + const status = error.response?.status; + const message = error.response?.data?.errorMessage || 'Keycloak operation failed'; + + if (status === 404) { throw new NotFoundException(`Resource not found: ${context}`); } - if (error.response?.status === 409) { + if (status === 409) { throw new BadRequestException('User already exists'); } - - this.logger.error(`Keycloak API error in ${context}: ${error.message}`, { - status: error.response?.status, + if (status === 401) { + throw new ForbiddenException('Authentication failed'); + } + if (status === 403) { + throw new ForbiddenException('Insufficient permissions'); + } + + this.logger.error(`Keycloak API error in ${context}: ${message}`, { + status, data: error.response?.data, }); - throw new HttpException( - error.response?.data?.errorMessage || 'Keycloak operation failed', - error.response?.status || 500 - ); + throw new HttpException(message, status || 500); } - // ===== AUTHENTICATION METHODS ===== - async authenticateUser(username: string, password: string) { - return this.tokenService.acquireUserToken(username, password); - } - - // ===== USER LIFECYCLE MANAGEMENT ===== - async updateUserStatus( - userId: string, - status: string, - reason?: string, - performedBy?: string - ): Promise { - const attributes: Record = { - userStatus: [status], - lastStatusChange: [new Date().toISOString()], - }; + // === CONSTRUCTION DE PAYLOAD === - if (reason) { - attributes.statusChangeReason = [reason]; - } - - if (performedBy) { - attributes.lastStatusChangeBy = [performedBy]; - } - - await this.setUserAttributes(userId, attributes); - } - - async getUserStatus(userId: string): Promise { - return await this.getUserAttribute(userId, 'userStatus') || 'PENDING_ACTIVATION'; - } - - async setUserAttributes(userId: string, attributes: Record): Promise { - try { - const user = await this.getUserById(userId, userId); // Self-access pour les attributs - const updatedUser = { - ...user, - attributes: { - ...user.attributes, - ...attributes - } - }; - - await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, updatedUser); - this.logger.log(`Attributes set for user ${userId}: ${Object.keys(attributes).join(', ')}`); - } catch (error: any) { - this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`); - throw error; - } - } - - async getUserAttribute(userId: string, attributeName: string): Promise { - try { - const user = await this.getUserById(userId, userId); - const attributes = user.attributes || {}; - return attributes[attributeName]?.[0] || null; - } catch (error: any) { - this.logger.error(`Failed to get attribute ${attributeName} for user ${userId}: ${error.message}`); - return null; - } - } - - // ===== PASSWORD MANAGEMENT ===== - async resetUserPassword(userId: string, newPassword: string, temporary: boolean = true): Promise { - const requesterId = userId; // Self-service ou via admin - await this.validateUserAccess(requesterId, await this.getUserMerchantPartnerId(userId)); - - const passwordPayload = { - type: 'password', - value: newPassword, - temporary: temporary, - }; - - await this.request( - 'PUT', - `/admin/realms/${this.realm}/users/${userId}/reset-password`, - passwordPayload - ); - - // Mettre à jour les attributs de cycle de vie - await this.setUserAttributes(userId, { - lastPasswordChange: [new Date().toISOString()], - temporaryPassword: [temporary.toString()], - passwordChangeRequired: [temporary.toString()], - }); - - this.logger.log(`Password reset for user ${userId}, temporary: ${temporary}`); - } - - async sendPasswordResetEmail(userEmail: string): Promise { - const users = await this.findUserByEmail(userEmail); - if (users.length === 0) { - throw new NotFoundException('User not found'); - } - - const userId = users[0].id!; - const status = await this.getUserStatus(userId); - - if (status !== 'ACTIVE') { - throw new BadRequestException('User account is not active'); - } - - // Keycloak gère l'envoi d'email de reset - await this.request( - 'PUT', - `/admin/realms/${this.realm}/users/${userId}/execute-actions-email`, - ['UPDATE_PASSWORD'] - ); - - this.logger.log(`Password reset email sent to: ${userEmail}`); - } - - // ===== COMPLETE USER LIFECYCLE ===== - async suspendUser(userId: string, reason: string, performedBy: string): Promise { - const merchantPartnerId = await this.getUserMerchantPartnerId(userId); - await this.validateUserAccess(performedBy, merchantPartnerId); - - await this.updateUser(userId, { enabled: false }, performedBy); - await this.updateUserStatus(userId, 'SUSPENDED', reason, performedBy); - - this.logger.log(`User suspended: ${userId}, reason: ${reason}`); - } - - async reactivateUser(userId: string, performedBy: string): Promise { - const merchantPartnerId = await this.getUserMerchantPartnerId(userId); - await this.validateUserAccess(performedBy, merchantPartnerId); - - await this.updateUser(userId, { enabled: true }, performedBy); - await this.updateUserStatus(userId, 'ACTIVE', 'User reactivated', performedBy); - - this.logger.log(`User reactivated: ${userId}`); - } - - async deactivateUser(userId: string, reason: string, performedBy: string): Promise { - const merchantPartnerId = await this.getUserMerchantPartnerId(userId); - await this.validateUserAccess(performedBy, merchantPartnerId); - - await this.updateUser(userId, { enabled: false }, performedBy); - await this.updateUserStatus(userId, 'DEACTIVATED', reason, performedBy); - - this.logger.log(`User deactivated: ${userId}, reason: ${reason}`); - } - - // Méthode activateUser corrigée aussi - async activateUser(userId: string, activationData: { - firstName: string; - lastName: string; - termsAccepted: boolean; - }): Promise { - const currentStatus = await this.getUserStatus(userId); - if (currentStatus !== 'PENDING_ACTIVATION') { - throw new BadRequestException('User cannot be activated'); - } - - // Mettre à jour le profil - pas besoin de validation d'accès car self-service - await this.updateUser(userId, { - firstName: activationData.firstName, - lastName: activationData.lastName, - emailVerified: true, - }, userId); - - // Mettre à jour le statut - await this.updateUserStatus(userId, 'ACTIVE', 'User activated', userId); - await this.setUserAttributes(userId, { - termsAccepted: [activationData.termsAccepted.toString()], - profileCompleted: ['true'], - activatedAt: [new Date().toISOString()], - }); - - this.logger.log(`User activated: ${userId}`); - } - - // ===== VALIDATION DES PERMISSIONS ===== - async validateUserAccess(requesterId: string, targetMerchantPartnerId?: string | null): Promise { - const requesterRoles = await this.getUserClientRoles(requesterId); - - // Les admins Hub ont accès complet (peu importe le merchantPartnerId) - if (requesterRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) { - return; - } - - // Si pas de merchantPartnerId cible, seul le Hub peut accéder - if (!targetMerchantPartnerId) { - throw new ForbiddenException('Access to hub resources requires DCB_ADMIN or DCB_SUPPORT role'); - } - - // Vérifier si l'utilisateur est un DCB_PARTNER (propriétaire) - const isDcbPartner = requesterRoles.some(role => role.name === UserRole.DCB_PARTNER); - if (isDcbPartner) { - // Pour DCB_PARTNER, l'ID utilisateur DOIT être égal au merchantPartnerId - if (requesterId === targetMerchantPartnerId) { - return; - } - throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner data'); - } - - // Vérifier si l'utilisateur a un rôle merchant et accède au même merchant - const requesterMerchantPartnerId = await this.getUserMerchantPartnerId(requesterId); - if (requesterMerchantPartnerId && requesterMerchantPartnerId === targetMerchantPartnerId) { - // Vérifier que l'utilisateur a un rôle merchant valide - const merchantRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]; - if (requesterRoles.some(role => merchantRoles.includes(role.name as UserRole))) { - return; - } - } - - throw new ForbiddenException('Insufficient permissions to access this resource'); - } - -private async validateUserCreation(creatorId: string, userData: CreateUserData): Promise { - const creatorRoles = await this.getUserClientRoles(creatorId); - const targetRoles = userData.clientRoles || []; - - this.logger.debug(`Validating user creation: creator=${creatorId}, roles=${targetRoles.join(',')}`); - this.logger.debug(`Creator roles: ${creatorRoles.map(r => r.name).join(', ')}`); - - // Validation: au moins un rôle doit être spécifié - if (targetRoles.length === 0) { - throw new BadRequestException('At least one client role must be specified'); - } - - // Vérifier que le créateur peut créer ces rôles - for (const targetRole of targetRoles) { - let canCreate = false; - - for (const creatorRole of creatorRoles) { - if (this.canRoleCreateRole(creatorRole.name as UserRole, targetRole)) { - canCreate = true; - break; - } - } - - if (!canCreate) { - this.logger.error(`Creator cannot create role: ${targetRole}`); - this.logger.error(`Creator roles: ${creatorRoles.map(r => r.name).join(', ')}`); - throw new ForbiddenException(`Cannot create user with role: ${targetRole}`); - } - } - - // Validation du merchantPartnerId selon les règles - await this.validateMerchantPartnerForCreation(creatorId, creatorRoles, userData); - } - - private canRoleCreateRole(creatorRole: UserRole, targetRole: UserRole): boolean { - const hierarchy = this.roleHierarchy.find(h => h.role === creatorRole); - if (!hierarchy) { - this.logger.warn(`No hierarchy found for role: ${creatorRole}`); - return false; - } - - const canCreate = hierarchy.canCreate.includes(targetRole); - this.logger.debug(`Role ${creatorRole} can create ${targetRole}: ${canCreate}`); - return canCreate; - } - - private async validateMerchantPartnerForCreation( - creatorId: string, - creatorRoles: KeycloakRole[], - userData: CreateUserData - ): Promise { - const targetRoles = userData.clientRoles || []; - const requiresMerchantPartner = targetRoles.some(role => - this.roleHierarchy.find(h => h.role === role)?.requiresMerchantPartner - ); - - // Si le rôle cible nécessite un merchantPartnerId - if (requiresMerchantPartner) { - if (!userData.merchantPartnerId) { - throw new BadRequestException('merchantPartnerId is required for merchant partner roles'); - } - - // DCB_ADMIN/SUPPORT peuvent créer pour n'importe quel merchant - if (creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) { - return; - } - - // DCB_PARTNER ne peut créer que pour son propre merchant - if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER)) { - if (creatorId !== userData.merchantPartnerId) { - throw new ForbiddenException('DCB_PARTNER can only create users for their own merchant partner'); - } - return; - } - - // DCB_PARTNER_ADMIN ne peut créer que pour son merchant - if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER_ADMIN)) { - const creatorMerchantId = await this.getUserMerchantPartnerId(creatorId); - if (creatorMerchantId !== userData.merchantPartnerId) { - throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner'); - } - return; - } - - throw new ForbiddenException('Insufficient permissions to create merchant partner users'); - } else { - // Les rôles Hub ne doivent PAS avoir de merchantPartnerId - if (userData.merchantPartnerId) { - throw new BadRequestException('merchantPartnerId should not be provided for hub roles'); - } - - // Seul DCB_ADMIN/SUPPORT peut créer des rôles Hub - if (!creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) { - throw new ForbiddenException('Only hub admins can create hub roles'); - } - } - } - - // ===== USER CRUD OPERATIONS WITH ACCESS CONTROL ===== - async createUser(creatorId: string, userData: CreateUserData): Promise { - // Validation des permissions du créateur - await this.validateUserCreation(creatorId, userData); - - this.logger.debug(`CREATE USER - Input data:`, { - username: userData.username, - merchantPartnerId: userData.merchantPartnerId, - createdBy: creatorId, - clientRoles: userData.clientRoles - }); - - // Récupérer le username du créateur AVANT la création - let creatorUsername = ''; - try { - const creatorUser = await this.getUserById(creatorId, creatorId); - creatorUsername = creatorUser.username; - } catch (error) { - this.logger.warn(`Could not fetch creator username: ${error.message}`); - creatorUsername = 'unknown'; - } - - const userPayload: any = { + private async buildUserPayload(userData: CreateUserData, creatorId: string): Promise { + const basePayload: any = { username: userData.username, email: userData.email, firstName: userData.firstName, lastName: userData.lastName, enabled: userData.enabled ?? true, emailVerified: userData.emailVerified ?? false, - attributes: this.buildUserAttributes(userData, creatorId, creatorUsername), + attributes: this.buildUserAttributes(userData, creatorId), }; if (userData.password) { - userPayload.credentials = [{ + basePayload.credentials = [{ type: 'password', value: userData.password, temporary: userData.passwordTemporary ?? false, }]; } - this.logger.debug(`CREATE USER - Final Keycloak payload:`, JSON.stringify(userPayload, null, 2)); - - try { - this.logger.log(`Creating user in Keycloak: ${userData.username}`); - - await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload); - - const users = await this.findUserByUsername(userData.username); - if (users.length === 0) { - throw new Error('User not found after creation'); - } - - const userId = users[0].id!; - this.logger.log(`User created successfully with ID: ${userId}`); - - // Assigner les rôles client - if (userData.clientRoles && userData.clientRoles.length > 0) { - await this.setClientRoles(userId, userData.clientRoles); - this.logger.log(`Client roles assigned to user ${userId}: ${userData.clientRoles.join(', ')}`); - } - - return userId; - } catch (error: any) { - this.logger.error(`FAILED to create user in Keycloak: ${error.message}`); - if (error.response?.data) { - this.logger.error(`Keycloak error response: ${JSON.stringify(error.response.data)}`); - } - throw error; - } + return basePayload; } - async getUserById(userId: string, requesterId: string): Promise { - const user = await this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); - - // Valider l'accès du requester à cet utilisateur - const userMerchantPartnerId = user.attributes?.merchantPartnerId?.[0]; - await this.validateUserAccess(requesterId, userMerchantPartnerId); - - return user; - } + private buildUserAttributes(userData: CreateUserData, creatorId: string): Record { + const attributes: Record = { + createdBy: [creatorId], + accountCreatedAt: [new Date().toISOString()] + }; - async updateUser(userId: string, userData: Partial, requesterId: string): Promise { - // Valider l'accès du requester à cet utilisateur - const currentUser = await this.getUserById(userId, requesterId); - const userMerchantPartnerId = currentUser.attributes?.merchantPartnerId?.[0]; - await this.validateUserAccess(requesterId, userMerchantPartnerId); - - return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData); - } - - async deleteUser(userId: string, requesterId: string): Promise { - const userMerchantPartnerId = await this.getUserMerchantPartnerId(userId); - await this.validateUserAccess(requesterId, userMerchantPartnerId); - - return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`); - } - - async setUserMerchantPartnerId(userId: string, merchantPartnerId: string, requesterId: string): Promise { - await this.validateUserAccess(requesterId, merchantPartnerId); - - await this.setUserAttributes(userId, { - merchantPartnerId: [merchantPartnerId] - }); - } - - // ===== ATTRIBUTES MANAGEMENT ===== - private buildUserAttributes( - userData: CreateUserData, - creatorId: string, - creatorUsername: string - ): Record { - const attributes: Record = {}; - - // Merchant Partner ID - if (userData.merchantPartnerId !== undefined) { + if (userData.merchantPartnerId) { attributes.merchantPartnerId = [userData.merchantPartnerId]; } - // Tracking de création - attributes.createdBy = [creatorId]; - attributes.createdByUsername = [creatorUsername]; - - // Type d'utilisateur (Hub/Merchant) if (userData.clientRoles) { - const isHubUser = userData.clientRoles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role) - ); + const isHubUser = userData.clientRoles.some(role => CONFIG.ROLES.HUB.includes(role)); attributes.userType = [isHubUser ? 'HUB' : 'MERCHANT']; } - // Cycle de vie - attributes.userStatus = [userData.initialStatus || 'PENDING_ACTIVATION']; - attributes.accountCreatedAt = [new Date().toISOString()]; - attributes.termsAccepted = ['false']; - attributes.profileCompleted = ['false']; + // Ajouter les attributs personnalisés + if (userData.attributes) { + Object.assign(attributes, userData.attributes); + } return attributes; } - // ===== MERCHANT PARTNER SPECIFIC METHODS ===== - async getUsersByMerchantPartnerId(merchantPartnerId: string, requesterId: string): Promise { - await this.validateUserAccess(requesterId, merchantPartnerId); - - const allUsers = await this.getAllUsers(); - return allUsers.filter(user => - user.attributes?.merchantPartnerId?.includes(merchantPartnerId) - ); - } + // === GESTION DU CLIENT ET RÔLES === - async getUserMerchantPartnerId(userId: string): Promise { - try { - const user = await this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); - return user.attributes?.merchantPartnerId?.[0] || null; - } catch (error) { - this.logger.error(`Failed to get merchantPartnerId for user ${userId}: ${error.message}`); - return null; - } - } + private async getClient(): Promise<{ id: string }> { + if (this.clientCache) return this.clientCache; - // ===== ROLE MANAGEMENT ===== - async getUserClientRoles(userId: string): Promise { - try { - const clients = await this.getClient(); - return await this.request( - 'GET', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}` - ); - } catch (error) { - this.logger.error(`Failed to get client roles for user ${userId}: ${error.message}`); - return []; - } - } - - async setClientRoles(userId: string, roles: UserRole[]): Promise { - try { - const clients = await this.getClient(); - const clientId = clients[0].id; - - // Récupérer les rôles actuels - const currentRoles = await this.getUserClientRoles(userId); - - // Supprimer les rôles actuels si existants - if (currentRoles.length > 0) { - await this.request( - 'DELETE', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clientId}`, - currentRoles - ); - } - - // Ajouter les nouveaux rôles - if (roles.length > 0) { - const targetRoles = await Promise.all( - roles.map(role => this.getRole(role, clientId)) - ); - - await this.request( - 'POST', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clientId}`, - targetRoles - ); - } - - this.logger.log(`Client roles set for user ${userId}: ${roles.join(', ')}`); - } catch (error) { - this.logger.error(`Failed to set client roles for user ${userId}: ${error.message}`); - throw error; - } - } - - // ===== UTILITY METHODS ===== - async getAllUsers(): Promise { - return this.request('GET', `/admin/realms/${this.realm}/users`); - } - - async findUserByUsername(username: string): Promise { - const users = await this.request( - 'GET', - `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}` - ); - - // Keycloak fait une recherche partielle, on filtre pour une correspondance exacte - return users.filter(user => user.username === username); - } - - async findUserByEmail(email: string): Promise { - const users = await this.request( - 'GET', - `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}` - ); - - return users.filter(user => user.email === email); - } - - async getUsersByAttribute(attributeName: string, attributeValue: string): Promise { - try { - const allUsers = await this.getAllUsers(); - return allUsers.filter(user => - user.attributes && - user.attributes[attributeName] && - user.attributes[attributeName].includes(attributeValue) - ); - } catch (error: any) { - this.logger.error(`Failed to get users by attribute ${attributeName}: ${error.message}`); - return []; - } - } - - // ===== PRIVATE HELPERS ===== - private async getClient(): Promise { const clients = await this.request('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`); - if (!clients || clients.length === 0) { + const client = clients[0]; + + if (!client) { throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`); } - return clients; + + this.clientCache = client; + return client; } private async getRole(role: UserRole, clientId: string): Promise { @@ -701,80 +672,300 @@ private async validateUserCreation(creatorId: string, userData: CreateUserData): return targetRole; } - // ===== PERMISSION CHECKERS (pour usage externe) ===== - async canUserCreateRole(creatorId: string, targetRole: UserRole): Promise { - const creatorRoles = await this.getUserClientRoles(creatorId); - return creatorRoles.some(creatorRole => - this.canRoleCreateRole(creatorRole.name as UserRole, targetRole) - ); + // === VALIDATION DE LA CRÉATION === + + private async validateUserCreation(creatorId: string, userData: CreateUserData): Promise { + const [creatorRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(creatorId), + Promise.resolve(userData.clientRoles || []) + ]); + + const creatorRoleNames = creatorRoles.map(role => role.name as UserRole); + const targetRoleNames = targetRoles; + + this.logSecurityEvent('USER_CREATION_VALIDATION', 'MEDIUM', { + creatorId, + targetRoles: targetRoleNames, + merchantPartnerId: userData.merchantPartnerId + }); + + if (targetRoleNames.length === 0) { + throw new BadRequestException('At least one client role must be specified'); + } + + // Validation de la hiérarchie des rôles + for (const targetRole of targetRoleNames) { + if (!this.canRolesCreateRole(creatorRoleNames, targetRole)) { + this.logSecurityEvent('ROLE_CREATION_VIOLATION', 'HIGH', { + creatorId, + targetRole, + creatorRoles: creatorRoleNames + }); + throw new ForbiddenException(`Cannot create user with role: ${targetRole}`); + } + } + + // Validation spécifique au type d'utilisateur + await this.validateUserTypeCreation(creatorId, creatorRoleNames, userData); } - async getUserPermissions(userId: string): Promise<{ - canCreateMerchantPartners: boolean; - canManageUsers: boolean; - accessibleMerchantPartnerIds: string[]; - }> { - const roles = await this.getUserClientRoles(userId); - const merchantPartnerId = await this.getUserMerchantPartnerId(userId); + private async validateUserTypeCreation( + creatorId: string, + creatorRoles: UserRole[], + userData: CreateUserData + ): Promise { + const targetRoles = userData.clientRoles || []; + const isMerchantUser = targetRoles.some(role => CONFIG.ROLES.MERCHANT.includes(role)); - const canCreateMerchantPartners = roles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) - ); + if (isMerchantUser) { + await this.validateMerchantUserCreation(creatorId, creatorRoles, userData); + } else { + await this.validateHubUserCreation(creatorRoles, userData); + } + } - const canManageUsers = roles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN].includes(role.name as UserRole) - ); + private async validateMerchantUserCreation( + creatorId: string, + creatorRoles: UserRole[], + userData: CreateUserData + ): Promise { + // Exception pour DCB_PARTNER : il n'a pas besoin de merchantPartnerId car son ID est le merchantPartnerId + if (!userData.merchantPartnerId && !userData.clientRoles.includes(UserRole.DCB_PARTNER)) { + throw new BadRequestException('merchantPartnerId is required for merchant users'); + } - const accessibleMerchantPartnerIds = canCreateMerchantPartners - ? [] // Accès à tous les merchants - : merchantPartnerId - ? [merchantPartnerId] - : []; - - return { - canCreateMerchantPartners, - canManageUsers, - accessibleMerchantPartnerIds + const merchantCreationRules = { + [UserRole.DCB_PARTNER]: { + allowedRoles: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + validate: async (): Promise => creatorId === userData.merchantPartnerId + }, + [UserRole.DCB_PARTNER_ADMIN]: { + allowedRoles: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + validate: async (): Promise => { + const creatorMerchantId = await this.getUserMerchantPartnerId(creatorId); + return creatorMerchantId === userData.merchantPartnerId; + } + } }; + + // Appliquer les règles + for (const [role, rule] of Object.entries(merchantCreationRules)) { + if (creatorRoles.includes(role as UserRole)) { + const isValid = await rule.validate(); + + if (!isValid) { + throw new ForbiddenException(`${role} can only create users for their own merchant`); + } + + const hasInvalidRole = userData.clientRoles?.some(targetRole => + !rule.allowedRoles.includes(targetRole as UserRole) + ); + + if (hasInvalidRole) { + throw new ForbiddenException(`${role} can only create roles: ${rule.allowedRoles.join(', ')}`); + } + return; + } + } + + // Les administrateurs Hub n'ont pas de restrictions supplémentaires + if (!creatorRoles.some(role => CONFIG.ROLES.HUB.includes(role))) { + throw new ForbiddenException('Insufficient permissions to create merchant users'); + } } - // ===== HEALTH CHECKS ===== - async checkKeycloakAvailability(): Promise { - const url = `${this.keycloakBaseUrl}/realms/${this.realm}`; - try { - await firstValueFrom( - this.httpService.get(url).pipe(rxjsTimeout(5000)), + private async validateHubUserCreation(creatorRoles: UserRole[], userData: CreateUserData): Promise { + if (userData.merchantPartnerId) { + throw new BadRequestException('merchantPartnerId should not be provided for hub users'); + } + + if (!creatorRoles.some(role => CONFIG.ROLES.HUB.includes(role))) { + throw new ForbiddenException('Only hub administrators can create hub users'); + } + } + + // === VALIDATION DE SUPPRESSION AVEC HIÉRARCHIE === + + private async validateDeletion(requesterId: string, targetUserId: string): Promise { + const requesterRoles = await this.getUserClientRoles(requesterId); + const requesterRoleNames = requesterRoles.map(role => role.name as UserRole); + + this.logSecurityEvent('DELETE_PERMISSION_CHECK', 'HIGH', { + requesterId, + targetUserId, + requesterRoles: requesterRoleNames + }); + + if (requesterRoleNames.length === 0) { + throw new ForbiddenException('No roles assigned to requester'); + } + + if (requesterRoleNames.includes(UserRole.DCB_SUPPORT)) { + throw new ForbiddenException('DCB_SUPPORT cannot delete users'); + } + + if (requesterId === targetUserId) { + throw new BadRequestException('Cannot delete your own account'); + } + + await this.validateRoleHierarchyForDeletion(requesterId, targetUserId); + } + + private async validateRoleHierarchyForDeletion(requesterId: string, targetUserId: string): Promise { + const [requesterRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(requesterId), + this.getUserClientRoles(targetUserId) + ]); + + const requesterRoleNames = requesterRoles.map(role => role.name as UserRole); + const targetRoleNames = targetRoles.map(role => role.name as UserRole); + + const requesterHighestRole = this.getHighestRole(requesterRoleNames); + const targetHighestRole = this.getHighestRole(targetRoleNames); + + const canDelete = this.canRoleCreateRole(requesterHighestRole, targetHighestRole); + + if (!canDelete) { + throw new ForbiddenException( + `Role ${requesterHighestRole} cannot delete users with role ${targetHighestRole}` ); - this.logger.log(`Keycloak disponible à l'adresse : ${url}`); - return true; - } catch (error: any) { - this.logger.error(`Keycloak indisponible : ${error.message}`); + } + } + + // === MÉTHODES UTILITAIRES === + + private async getCreatorUsername(creatorId: string): Promise { + try { + const creatorUser = await this.getUserById(creatorId, creatorId); + return creatorUser.username; + } catch (error) { + this.logger.warn(`Could not fetch creator username: ${error.message}`); + return 'unknown'; + } + } + + private logSecurityEvent( + event: string, + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL', + details: any + ): void { + const logEntry = { + timestamp: new Date().toISOString(), + service: 'KeycloakApiService', + event, + severity, + ...details + }; + + const logMethod = severity === 'CRITICAL' || severity === 'HIGH' ? 'error' : + severity === 'MEDIUM' ? 'warn' : 'log'; + + this.logger[logMethod](`SECURITY: ${event}`, logEntry); + } + + // === MÉTHODES PUBLIQUES POUR LA GESTION DES RÔLES === + + /** + * Obtient la hiérarchie des rôles complète + */ + getRoleHierarchy(): Record { + return { ...ROLE_HIERARCHY }; + } + + /** + * Vérifie si un utilisateur peut gérer un autre utilisateur basé sur leurs rôles + */ + async canUserManageUser(managerId: string, targetUserId: string): Promise { + try { + const [managerRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(managerId), + this.getUserClientRoles(targetUserId) + ]); + + const managerRoleNames = managerRoles.map(role => role.name as UserRole); + const targetRoleNames = targetRoles.map(role => role.name as UserRole); + + const managerHighestRole = this.getHighestRole(managerRoleNames); + const targetHighestRole = this.getHighestRole(targetRoleNames); + + return this.canRoleCreateRole(managerHighestRole, targetHighestRole); + } catch (error) { + this.logger.warn(`Error checking user management permissions: ${error.message}`); return false; } } - async checkServiceConnection(): Promise { - try { - const token = await this.tokenService.acquireServiceAccountToken(); - if (!token) { - throw new Error('Aucun token de service retourné'); + /** + * Vérifie si un utilisateur a un rôle spécifique + */ + async userHasRole(userId: string, role: UserRole): Promise { + const userRoles = await this.getUserClientRoles(userId); + return userRoles.some(userRole => userRole.name === role); + } + + /** + * Vérifie si un utilisateur a au moins un des rôles spécifiés + */ + async userHasAnyRole(userId: string, roles: UserRole[]): Promise { + const userRoles = await this.getUserClientRoles(userId); + const userRoleNames = userRoles.map(role => role.name as UserRole); + return roles.some(role => userRoleNames.includes(role)); + } + + /** + * Vérifie si un utilisateur a tous les rôles spécifiés + */ + async userHasAllRoles(userId: string, roles: UserRole[]): Promise { + const userRoles = await this.getUserClientRoles(userId); + const userRoleNames = userRoles.map(role => role.name as UserRole); + return roles.every(role => userRoleNames.includes(role)); + } + + // === MÉTHODES DE RAPPORT ET STATISTIQUES === + + async getUserStatistics(): Promise<{ + total: number; + enabled: number; + disabled: number; + byRole: Record; + byUserType: { HUB: number; MERCHANT: number }; + }> { + const users = await this.getAllUsers(); + + const statistics = { + total: users.length, + enabled: users.filter(user => user.enabled).length, + disabled: users.filter(user => !user.enabled).length, + byRole: {} as Record, + byUserType: { HUB: 0, MERCHANT: 0 } + }; + + // Compter par rôle + for (const user of users) { + const roles = await this.getUserClientRoles(user.id); + for (const role of roles) { + statistics.byRole[role.name] = (statistics.byRole[role.name] || 0) + 1; } - const testUrl = `${this.keycloakBaseUrl}/admin/realms/${this.realm}/users`; - const config = { - headers: { Authorization: `Bearer ${token}` }, - timeout: 5000, - }; + // Compter par type d'utilisateur + const userType = user.attributes?.userType?.[0]; + if (userType === 'HUB') { + statistics.byUserType.HUB++; + } else if (userType === 'MERCHANT') { + statistics.byUserType.MERCHANT++; + } + } - await firstValueFrom( - this.httpService.get(testUrl, config).pipe(rxjsTimeout(5000)), - ); + return statistics; + } - this.logger.log('Connexion du service à Keycloak réussie'); - return true; - } catch (error: any) { - this.logger.error(`Échec de la connexion du service : ${error.message}`); - return false; + async getActiveSessionsCount(): Promise { + try { + const sessions = await this.request('GET', `/admin/realms/${this.realm}/clients/${(await this.getClient()).id}/user-sessions`); + return sessions.length; + } catch (error) { + this.logger.warn(`Failed to get active sessions count: ${error.message}`); + return 0; } } } \ No newline at end of file diff --git a/src/auth/services/keycloak-user.model.ts b/src/auth/services/keycloak-user.model.ts index 7533064..780a598 100644 --- a/src/auth/services/keycloak-user.model.ts +++ b/src/auth/services/keycloak-user.model.ts @@ -11,8 +11,6 @@ export interface KeycloakUser { createdBy?: string[]; createdByUsername?: string[]; userType?: string[]; - userStatus?: string[]; - lastLogin?: string[]; [key: string]: string[] | undefined; }; createdTimestamp?: number; @@ -36,16 +34,22 @@ export interface CreateUserData { passwordTemporary?: boolean; enabled?: boolean; emailVerified?: boolean; - merchantPartnerId?: string; + merchantPartnerId?: string | null; clientRoles: UserRole[]; - createdBy?: string; - createdByUsername?: string; - initialStatus?: string; + attributes?: { + userStatus?: string[]; + lastLogin?: string[]; + merchantPartnerId?: string[]; + createdBy?: string[]; + createdByUsername?: string[]; + userType?: string[]; + [key: string]: string[] | undefined; + }; } export enum UserType { - HUB = 'hub', - MERCHANT_PARTNER = 'merchant_partner' + HUB = 'HUB', + MERCHANT_PARTNER = 'MERCHANT' } export enum UserRole { @@ -72,8 +76,7 @@ export interface HubUser { createdBy: string; createdByUsername: string; createdTimestamp: number; - lastLogin?: number; - userType: 'HUB'; + userType: string; attributes?: { userStatus?: string[]; lastLogin?: string[]; @@ -94,32 +97,57 @@ export interface CreateHubUserData { role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; enabled?: boolean; emailVerified?: boolean; - createdBy: string; + } export interface HubUserStats { totalAdmins: number; - totalSupport: number; + totalSupports: number; activeUsers: number; inactiveUsers: number; - pendingActivation: number; } export interface MerchantStats { - totalMerchants: number; - activeMerchants: number; - suspendedMerchants: number; - pendingMerchants: number; - totalUsers: number; + totalAdmins: number; + totalManagers: number; + totalSupports: number; + activeUsers: number; + inactiveUsers: number; } -export interface HubUserActivity { - user: HubUser; - lastLogin?: Date; +export interface UserQueryDto { + page?: number; + limit?: number; + search?: string; + userType?: UserType; + merchantPartnerId?: string; + enabled?: boolean; } -export interface HubHealthStatus { - status: 'healthy' | 'degraded' | 'unhealthy'; - issues: string[]; - stats: HubUserStats; -} \ No newline at end of file +export interface UserResponse { + user: KeycloakUser; + message: string; +} + +export interface PaginatedUsersResponse { + users: KeycloakUser[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +// Pour l'authentification +export interface LoginDto { + username: string; + password: string; +} + +export interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + refresh_expires_in?: number; + scope?: string; +} diff --git a/src/auth/services/startup.service-crud.ts b/src/auth/services/startup.service-crud.ts deleted file mode 100644 index 4c49e0f..0000000 --- a/src/auth/services/startup.service-crud.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { KeycloakApiService } from './keycloak-api.service'; - -interface TestResults { - connection: { [key: string]: string }; -} - -@Injectable() -export class StartupServiceInitialization implements OnModuleInit { - private readonly logger = new Logger(StartupServiceInitialization.name); - private isInitialized = false; - private initializationError: string | null = null; - private testResults: TestResults = { - connection: {}, - }; - - constructor( - private readonly keycloakApiService: KeycloakApiService, - ) {} - - async onModuleInit() { - this.logger.log('🚀 Démarrage des tests de connexion'); - - try { - await this.validateKeycloakConnection(); - - this.isInitialized = true; - this.logger.log('✅ Tests de connexion terminés avec succès'); - } catch (error: any) { - this.initializationError = error.message; - this.logger.error(`❌ Échec des tests de connexion: ${error.message}`); - } - } - - // === VALIDATION CONNEXION KEYCLOAK === - private async validateKeycloakConnection() { - this.logger.log('🔌 Test de connexion Keycloak...'); - - try { - const isKeycloakAccessible = await this.keycloakApiService.checkKeycloakAvailability(); - if (!isKeycloakAccessible) { - throw new Error('Keycloak inaccessible'); - } - - const isServiceConnected = await this.keycloakApiService.checkServiceConnection(); - if (!isServiceConnected) { - throw new Error('Connexion service Keycloak échouée'); - } - - this.testResults.connection.keycloak = 'SUCCESS'; - this.logger.log('✅ Connexion Keycloak validée'); - } catch (error: any) { - this.testResults.connection.keycloak = 'FAILED'; - throw new Error(`Connexion Keycloak échouée: ${error.message}`); - } - } - - // === METHODES STATUT === - getStatus() { - return { - status: this.isInitialized ? 'healthy' : 'unhealthy', - keycloakConnected: this.isInitialized, - testResults: this.testResults, - timestamp: new Date(), - error: this.initializationError, - }; - } - - isHealthy(): boolean { - return this.isInitialized; - } - - getTestResults(): TestResults { - return this.testResults; - } -} \ No newline at end of file diff --git a/src/auth/services/startup.service-final.ts b/src/auth/services/startup.service-final.ts deleted file mode 100644 index 2ee8c7c..0000000 --- a/src/auth/services/startup.service-final.ts +++ /dev/null @@ -1,710 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { HubUsersService} from '../../hub-users/services/hub-users.service'; -import { MerchantUsersService, CreateMerchantUserData } from '../../hub-users/services/merchant-users.service'; -import { KeycloakApiService } from '../../auth/services/keycloak-api.service'; -import { TokenService } from '../../auth/services/token.service'; -import { UserRole, CreateHubUserData } from '../../auth/services/keycloak-user.model'; - -export interface TestResult { - testName: string; - success: boolean; - duration: number; - error?: string; - data?: any; -} - -export interface StartupTestSummary { - totalTests: number; - passedTests: number; - failedTests: number; - totalDuration: number; - results: TestResult[]; - healthStatus?: any; -} - -type HubUserRole = - | UserRole.DCB_ADMIN - | UserRole.DCB_SUPPORT - | UserRole.DCB_PARTNER; - -type MerchantUserRole = - | UserRole.DCB_PARTNER_ADMIN - | UserRole.DCB_PARTNER_MANAGER - | UserRole.DCB_PARTNER_SUPPORT; - -@Injectable() -export class StartupServiceFinal implements OnModuleInit { - private readonly logger = new Logger(StartupServiceFinal.name); - - // Stockage des données de test - private testUsers: { [key: string]: { id: string; username: string; role: UserRole } } = {}; - private testMerchants: { [key: string]: { id: string; username: string; role: UserRole } } = {}; - private testMerchantUsers: { [key: string]: { id: string; username: string; role: UserRole; merchantPartnerId: string } } = {}; - - constructor( - private readonly hubUsersService: HubUsersService, - private readonly merchantUsersService: MerchantUsersService, - private readonly keycloakApi: KeycloakApiService, - private readonly tokenService: TokenService, - ) {} - - async onModuleInit() { - if (process.env.RUN_STARTUP_TESTS === 'true') { - this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...'); - await this.runAllTests(); - } - else { - // 1. Tests de base - await this.testKeycloakConnection(); - } - } - - // ===== MÉTHODES DE TEST PRINCIPALES ===== - async runAllTests(): Promise { - const results: TestResult[] = []; - const startTime = Date.now(); - - try { - // 1. Tests de base - results.push(await this.testKeycloakConnection()); - results.push(await this.testServiceAccountPermissions()); - - // 2. Tests de création en parallèle avec isolation - const parallelTests = await this.runParallelIsolationTests(); - results.push(...parallelTests); - - // 3. Tests avancés - results.push(await this.testStatsAndReports()); - results.push(await this.testHealthCheck()); - results.push(await this.testSecurityValidations()); - - } catch (error) { - this.logger.error('Critical error during startup tests:', error); - } finally { - await this.cleanupTestUsers(); - await this.cleanupTestMerchants(); - } - - const totalDuration = Date.now() - startTime; - const passedTests = results.filter(r => r.success).length; - const failedTests = results.filter(r => !r.success).length; - - const summary: StartupTestSummary = { - totalTests: results.length, - passedTests, - failedTests, - totalDuration, - results, - }; - - this.logTestSummary(summary); - return summary; - } - - // ===== TESTS DE BASE ===== - private async testKeycloakConnection(): Promise { - const testName = 'Keycloak Connection Test'; - const startTime = Date.now(); - - try { - const token = await this.tokenService.acquireServiceAccountToken(); - const isValid = await this.tokenService.validateToken(token); - - if (!isValid) { - throw new Error('Service account token validation failed'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { testName, success: true, duration }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - private async testServiceAccountPermissions(): Promise { - const testName = 'Service Account Permissions Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - if (!serviceAccountId) { - throw new Error('Could not extract service account ID from token'); - } - - // Vérifier les rôles du service account - const roles = await this.keycloakApi.getUserClientRoles(serviceAccountId); - const roleNames = roles.map(r => r.name); - - this.logger.log(`Service account roles: ${roleNames.join(', ')}`); - - // Le service account doit avoir au moins DCB_ADMIN pour créer des utilisateurs - const hasRequiredRole = roleNames.some(role => - [UserRole.DCB_ADMIN].includes(role as UserRole) - ); - - if (!hasRequiredRole) { - throw new Error(`Service account missing required roles. Has: ${roleNames.join(', ')}, Needs: ${UserRole.DCB_ADMIN}`); - } - - // 1 - Service Account crée un ADMIN DCB-ADMIN - const adminData: CreateHubUserData = { - username: `test-dcb-admin-${Date.now()}`, - email: `test-dcb-admin-${Date.now()}@dcb-test.com`, - firstName: 'Test', - lastName: 'DCB Admin', - password: 'TempPassword123!', - role: UserRole.DCB_ADMIN, - enabled: true, - emailVerified: true, - createdBy: 'service-account', - }; - - const adminUser = await this.hubUsersService.createHubUser(serviceAccountId, adminData); - this.testUsers['dcb-admin'] = { - id: adminUser.id, - username: adminUser.username, - role: UserRole.DCB_ADMIN - }; - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { - serviceAccountId, - roles: roleNames, - createdAdmin: adminUser.username - } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - // ===== TESTS PARALLÈLES AVEC ISOLATION ===== - private async runParallelIsolationTests(): Promise { - const results: TestResult[] = []; - - try { - // Exécuter les tests pour deux merchants différents en parallèle - const [teamAResults, teamBResults] = await Promise.all([ - this.runMerchantTeamTests('TeamA'), - this.runMerchantTeamTests('TeamB') - ]); - - results.push(...teamAResults); - results.push(...teamBResults); - - // Test d'isolation entre les deux équipes - results.push(await this.testCrossTeamIsolation()); - - } catch (error) { - this.logger.error(`Parallel isolation tests failed: ${error.message}`); - results.push({ - testName: 'Parallel Isolation Tests', - success: false, - duration: 0, - error: error.message - }); - } - - return results; - } - - private async runMerchantTeamTests(teamName: string): Promise { - const results: TestResult[] = []; - const teamPrefix = teamName.toLowerCase(); - - try { - // 2 - ADMIN DCB-ADMIN crée DCB-SUPPORT et DCB-PARTNER pour cette équipe - const dcbAdmin = this.testUsers['dcb-admin']; - if (!dcbAdmin) { - throw new Error('DCB Admin not found for team tests'); - } - - // Créer DCB-SUPPORT - const supportData: CreateHubUserData = { - username: `test-${teamPrefix}-support-${Date.now()}`, - email: `test-${teamPrefix}-support-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Support', - password: 'TempPassword123!', - role: UserRole.DCB_SUPPORT, - enabled: true, - emailVerified: true, - createdBy: dcbAdmin.id, - }; - - const supportUser = await this.hubUsersService.createHubUser(dcbAdmin.id, supportData); - this.testUsers[`${teamPrefix}-support`] = { - id: supportUser.id, - username: supportUser.username, - role: UserRole.DCB_SUPPORT - }; - - // Créer DCB-PARTNER (Merchant Owner) - const partnerData: CreateHubUserData = { - username: `test-${teamPrefix}-partner-${Date.now()}`, - email: `test-${teamPrefix}-partner-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Partner', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER, - enabled: true, - emailVerified: true, - createdBy: dcbAdmin.id, - }; - - const partnerUser = await this.hubUsersService.createHubUser(dcbAdmin.id, partnerData); - this.testMerchants[`${teamPrefix}-partner`] = { - id: partnerUser.id, - username: partnerUser.username, - role: UserRole.DCB_PARTNER - }; - - results.push({ - testName: `${teamName} - Admin creates Support and Partner`, - success: true, - duration: 0, - data: { - supportUser: supportUser.username, - partnerUser: partnerUser.username - } - }); - - // 3 - ADMIN DCB-ADMIN crée DCB-PARTNER-ADMIN avec merchantPartnerId du DCB-PARTNER - const partnerAdminData: CreateMerchantUserData = { - username: `test-${teamPrefix}-partner-admin-${Date.now()}`, - email: `test-${teamPrefix}-partner-admin-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Partner Admin', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER_ADMIN, - enabled: true, - emailVerified: true, - merchantPartnerId: partnerUser.id, // Utilise l'ID du DCB-PARTNER - createdBy: dcbAdmin.id, - }; - - const partnerAdminUser = await this.merchantUsersService.createMerchantUser( - dcbAdmin.id, - partnerAdminData - ); - - this.testMerchantUsers[`${teamPrefix}-partner-admin`] = { - id: partnerAdminUser.id, - username: partnerAdminUser.username, - role: UserRole.DCB_PARTNER_ADMIN, - merchantPartnerId: partnerUser.id - }; - - results.push({ - testName: `${teamName} - Admin creates Partner Admin`, - success: true, - duration: 0, - data: { - partnerAdmin: partnerAdminUser.username, - merchantPartnerId: partnerUser.id - } - }); - - // 4 - DCB-PARTNER crée ses trois types d'utilisateurs - const partnerCreatedUsers = await this.testPartnerUserCreation(teamName, partnerUser.id); - results.push(...partnerCreatedUsers); - - // 5 - DCB-PARTNER-ADMIN crée un manager - const adminCreatedManager = await this.testPartnerAdminCreatesManager(teamName, partnerUser.id); - results.push(adminCreatedManager); - - } catch (error) { - results.push({ - testName: `${teamName} - Team Tests`, - success: false, - duration: 0, - error: error.message - }); - } - - return results; - } - - // Puis utilisez-le dans votre méthode - private async testPartnerUserCreation(teamName: string, partnerId: string): Promise { - const results: TestResult[] = []; - const teamPrefix = teamName.toLowerCase(); - - try { - const partner = this.testMerchants[`${teamPrefix}-partner`]; - if (!partner) { - throw new Error(`${teamName} Partner not found`); - } - - // Types d'utilisateurs à créer par le PARTNER - const userTypes: { role: MerchantUserRole; key: string }[] = [ - { role: UserRole.DCB_PARTNER_ADMIN, key: 'partner-admin-by-partner' }, - { role: UserRole.DCB_PARTNER_MANAGER, key: 'partner-manager-by-partner' }, - { role: UserRole.DCB_PARTNER_SUPPORT, key: 'partner-support-by-partner' } - ]; - - for (const userType of userTypes) { - const userData: CreateMerchantUserData = { - username: `test-${teamPrefix}-${userType.key}-${Date.now()}`, - email: `test-${teamPrefix}-${userType.key}-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: userType.role.split('_').pop() || 'User', - password: 'TempPassword123!', - role: userType.role, // Type compatible maintenant - enabled: true, - emailVerified: true, - merchantPartnerId: partnerId, - createdBy: partner.id, - }; - - const user = await this.merchantUsersService.createMerchantUser(partner.id, userData); - - this.testMerchantUsers[`${teamPrefix}-${userType.key}`] = { - id: user.id, - username: user.username, - role: userType.role, - merchantPartnerId: partnerId - }; - - results.push({ - testName: `${teamName} - Partner creates ${userType.role}`, - success: true, - duration: 0, - data: { - createdUser: user.username, - role: userType.role, - merchantPartnerId: partnerId - } - }); - } - - } catch (error) { - results.push({ - testName: `${teamName} - Partner User Creation`, - success: false, - duration: 0, - error: error.message - }); - } - - return results; - } - - private async testPartnerAdminCreatesManager(teamName: string, partnerId: string): Promise { - const testName = `${teamName} - Partner Admin creates Manager`; - const teamPrefix = teamName.toLowerCase(); - - try { - const partnerAdmin = this.testMerchantUsers[`${teamPrefix}-partner-admin`]; - if (!partnerAdmin) { - throw new Error(`${teamName} Partner Admin not found`); - } - - // 5 - DCB-PARTNER-ADMIN crée un manager avec l'ID de son DCB-PARTNER - const managerData: CreateMerchantUserData = { - username: `test-${teamPrefix}-manager-by-admin-${Date.now()}`, - email: `test-${teamPrefix}-manager-by-admin-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Manager by Admin', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER_MANAGER, - enabled: true, - emailVerified: true, - merchantPartnerId: partnerId, // Utilise l'ID du DCB-PARTNER (pas son propre ID) - createdBy: partnerAdmin.id, - }; - - const managerUser = await this.merchantUsersService.createMerchantUser( - partnerAdmin.id, - managerData - ); - - this.testMerchantUsers[`${teamPrefix}-manager-by-admin`] = { - id: managerUser.id, - username: managerUser.username, - role: UserRole.DCB_PARTNER_MANAGER, - merchantPartnerId: partnerId - }; - - return { - testName, - success: true, - duration: 0, - data: { - createdManager: managerUser.username, - createdBy: partnerAdmin.username, - merchantPartnerId: partnerId - } - }; - - } catch (error) { - return { - testName, - success: false, - duration: 0, - error: error.message - }; - } - } - - private async testCrossTeamIsolation(): Promise { - const testName = 'Cross-Team Isolation Test'; - const startTime = Date.now(); - - try { - const teamAPartnerAdmin = this.testMerchantUsers['teama-partner-admin']; - const teamBPartner = this.testMerchants['teamb-partner']; - - if (!teamAPartnerAdmin || !teamBPartner) { - throw new Error('Team users not found for isolation test'); - } - - // Tenter de créer un utilisateur dans l'autre équipe - devrait échouer - try { - const crossTeamUserData: CreateMerchantUserData = { - username: `test-cross-team-attempt-${Date.now()}`, - email: `test-cross-team-attempt-${Date.now()}@dcb-test.com`, - firstName: 'Cross', - lastName: 'Team Attempt', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER_MANAGER, - enabled: true, - emailVerified: true, - merchantPartnerId: teamBPartner.id, // ID d'une autre équipe - createdBy: teamAPartnerAdmin.id, - }; - - await this.merchantUsersService.createMerchantUser( - teamAPartnerAdmin.id, - crossTeamUserData - ); - - // Si on arrive ici, l'isolation a échoué - throw new Error('Isolation failed - User from TeamA could create user in TeamB'); - - } catch (error) { - // Comportement attendu - l'accès doit être refusé - if (error.message.includes('Forbidden') || - error.message.includes('Insufficient permissions') || - error.message.includes('not authorized') || - error.message.includes('own merchant')) { - // Succès - l'isolation fonctionne - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { isolationWorking: true } - }; - } else { - // Erreur inattendue - throw error; - } - } - - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - // ===== TESTS AVANCÉS (conservés depuis la version originale) ===== - private async testStatsAndReports(): Promise { - const testName = 'Stats and Reports Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - const stats = await this.hubUsersService.getHubUsersStats(serviceAccountId); - const activity = await this.hubUsersService.getHubUserActivity(serviceAccountId); - const sessions = await this.hubUsersService.getActiveHubSessions(serviceAccountId); - - // Validation basique des stats - if (typeof stats.totalAdmins !== 'number' || typeof stats.totalSupport !== 'number') { - throw new Error('Stats validation failed'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { - stats, - activityCount: activity.length, - sessionCount: sessions.length - } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - private async testHealthCheck(): Promise { - const testName = 'Health Check Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - const health = await this.hubUsersService.checkHubUsersHealth(serviceAccountId); - - if (!health.status || !health.stats || !Array.isArray(health.issues)) { - throw new Error('Health check validation failed'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { healthStatus: health.status } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - private async testSecurityValidations(): Promise { - const testName = 'Security Validations Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - // Test de la méthode canUserManageHubUsers - const canManage = await this.hubUsersService.canUserManageHubUsers(serviceAccountId); - - if (!canManage) { - throw new Error('Service account should be able to manage hub users'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { canManageHubUsers: canManage } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - // ===== NETTOYAGE ===== - private async cleanupTestUsers(): Promise { - this.logger.log('🧹 Cleaning up test users...'); - - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - // Nettoyer les utilisateurs hub - for (const [key, userInfo] of Object.entries(this.testUsers)) { - try { - await this.hubUsersService.deleteHubUser(userInfo.id, serviceAccountId); - this.logger.log(`✅ Deleted test user: ${key} (${userInfo.username})`); - } catch (error) { - this.logger.warn(`⚠️ Could not delete test user ${key}: ${error.message}`); - } - } - - this.testUsers = {}; - } - - private async cleanupTestMerchants(): Promise { - this.logger.log('🧹 Cleaning up test merchants...'); - - // Implémentez la logique de nettoyage des merchants de test - this.testMerchants = {}; - this.testMerchantUsers = {}; - } - - // ===== LOGGING ET RAPPORTS ===== - private logTestSummary(summary: StartupTestSummary): void { - this.logger.log('='.repeat(60)); - this.logger.log('🎯 PARALLEL ISOLATION STARTUP TEST SUMMARY'); - this.logger.log('='.repeat(60)); - this.logger.log(`📊 Total Tests: ${summary.totalTests}`); - this.logger.log(`✅ Passed: ${summary.passedTests}`); - this.logger.log(`❌ Failed: ${summary.failedTests}`); - this.logger.log(`⏱️ Total Duration: ${summary.totalDuration}ms`); - this.logger.log('-'.repeat(60)); - - summary.results.forEach(result => { - const status = result.success ? '✅' : '❌'; - this.logger.log(`${status} ${result.testName}: ${result.duration}ms`); - if (!result.success) { - this.logger.log(` ERROR: ${result.error}`); - } - }); - - this.logger.log('='.repeat(60)); - - if (summary.failedTests === 0) { - this.logger.log('🚀 ALL TESTS PASSED! System is ready with proper isolation.'); - } else { - this.logger.warn(`⚠️ ${summary.failedTests} test(s) failed. Please check the logs above.`); - } - } - - // ===== MÉTHODES PUBLIQUES POUR USAGE MANUEL ===== - async runQuickTest(): Promise { - this.logger.log('🔍 Running quick startup test...'); - return this.runAllTests(); - } - - async getTestStatus(): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; details: string }> { - try { - const summary = await this.runAllTests(); - const successRate = (summary.passedTests / summary.totalTests) * 100; - - if (successRate === 100) { - return { status: 'healthy', details: 'All tests passed successfully' }; - } else if (successRate >= 80) { - return { status: 'degraded', details: `${summary.failedTests} test(s) failed` }; - } else { - return { status: 'unhealthy', details: 'Multiple test failures detected' }; - } - } catch (error) { - return { status: 'unhealthy', details: `Test execution failed: ${error.message}` }; - } - } -} \ No newline at end of file diff --git a/src/auth/services/startup.service.ts b/src/auth/services/startup.service.ts index 28e2f7c..4c49e0f 100644 --- a/src/auth/services/startup.service.ts +++ b/src/auth/services/startup.service.ts @@ -1,706 +1,76 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { HubUsersService} from '../../hub-users/services/hub-users.service'; -import { MerchantUsersService, CreateMerchantUserData } from '../../hub-users/services/merchant-users.service'; -import { KeycloakApiService } from '../../auth/services/keycloak-api.service'; -import { TokenService } from '../../auth/services/token.service'; -import { UserRole, CreateHubUserData } from '../../auth/services/keycloak-user.model'; +import { KeycloakApiService } from './keycloak-api.service'; -export interface TestResult { - testName: string; - success: boolean; - duration: number; - error?: string; - data?: any; +interface TestResults { + connection: { [key: string]: string }; } -export interface StartupTestSummary { - totalTests: number; - passedTests: number; - failedTests: number; - totalDuration: number; - results: TestResult[]; - healthStatus?: any; -} - -type HubUserRole = - | UserRole.DCB_ADMIN - | UserRole.DCB_SUPPORT - | UserRole.DCB_PARTNER; - -type MerchantUserRole = - | UserRole.DCB_PARTNER_ADMIN - | UserRole.DCB_PARTNER_MANAGER - | UserRole.DCB_PARTNER_SUPPORT; - @Injectable() -export class StartupService implements OnModuleInit { - private readonly logger = new Logger(StartupService.name); - - // Stockage des données de test - private testUsers: { [key: string]: { id: string; username: string; role: UserRole } } = {}; - private testMerchants: { [key: string]: { id: string; username: string; role: UserRole } } = {}; - private testMerchantUsers: { [key: string]: { id: string; username: string; role: UserRole; merchantPartnerId: string } } = {}; +export class StartupServiceInitialization implements OnModuleInit { + private readonly logger = new Logger(StartupServiceInitialization.name); + private isInitialized = false; + private initializationError: string | null = null; + private testResults: TestResults = { + connection: {}, + }; constructor( - private readonly hubUsersService: HubUsersService, - private readonly merchantUsersService: MerchantUsersService, - private readonly keycloakApi: KeycloakApiService, - private readonly tokenService: TokenService, + private readonly keycloakApiService: KeycloakApiService, ) {} async onModuleInit() { - if (process.env.RUN_STARTUP_TESTS === 'true') { - this.logger.log('Starting comprehensive tests (Hub + Merchants with isolation)...'); - await this.runAllTests(); + this.logger.log('🚀 Démarrage des tests de connexion'); + + try { + await this.validateKeycloakConnection(); + + this.isInitialized = true; + this.logger.log('✅ Tests de connexion terminés avec succès'); + } catch (error: any) { + this.initializationError = error.message; + this.logger.error(`❌ Échec des tests de connexion: ${error.message}`); } } - // ===== MÉTHODES DE TEST PRINCIPALES ===== - async runAllTests(): Promise { - const results: TestResult[] = []; - const startTime = Date.now(); - + // === VALIDATION CONNEXION KEYCLOAK === + private async validateKeycloakConnection() { + this.logger.log('🔌 Test de connexion Keycloak...'); + try { - // 1. Tests de base - results.push(await this.testKeycloakConnection()); - results.push(await this.testServiceAccountPermissions()); - - // 2. Tests de création en parallèle avec isolation - const parallelTests = await this.runParallelIsolationTests(); - results.push(...parallelTests); - - // 3. Tests avancés - results.push(await this.testStatsAndReports()); - results.push(await this.testHealthCheck()); - results.push(await this.testSecurityValidations()); + const isKeycloakAccessible = await this.keycloakApiService.checkKeycloakAvailability(); + if (!isKeycloakAccessible) { + throw new Error('Keycloak inaccessible'); + } - } catch (error) { - this.logger.error('Critical error during startup tests:', error); - } finally { - await this.cleanupTestUsers(); - await this.cleanupTestMerchants(); + const isServiceConnected = await this.keycloakApiService.checkServiceConnection(); + if (!isServiceConnected) { + throw new Error('Connexion service Keycloak échouée'); + } + + this.testResults.connection.keycloak = 'SUCCESS'; + this.logger.log('✅ Connexion Keycloak validée'); + } catch (error: any) { + this.testResults.connection.keycloak = 'FAILED'; + throw new Error(`Connexion Keycloak échouée: ${error.message}`); } + } - const totalDuration = Date.now() - startTime; - const passedTests = results.filter(r => r.success).length; - const failedTests = results.filter(r => !r.success).length; - - const summary: StartupTestSummary = { - totalTests: results.length, - passedTests, - failedTests, - totalDuration, - results, + // === METHODES STATUT === + getStatus() { + return { + status: this.isInitialized ? 'healthy' : 'unhealthy', + keycloakConnected: this.isInitialized, + testResults: this.testResults, + timestamp: new Date(), + error: this.initializationError, }; - - this.logTestSummary(summary); - return summary; } - // ===== TESTS DE BASE ===== - private async testKeycloakConnection(): Promise { - const testName = 'Keycloak Connection Test'; - const startTime = Date.now(); - - try { - const token = await this.tokenService.acquireServiceAccountToken(); - const isValid = await this.tokenService.validateToken(token); - - if (!isValid) { - throw new Error('Service account token validation failed'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { testName, success: true, duration }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } + isHealthy(): boolean { + return this.isInitialized; } - private async testServiceAccountPermissions(): Promise { - const testName = 'Service Account Permissions Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - if (!serviceAccountId) { - throw new Error('Could not extract service account ID from token'); - } - - // Vérifier les rôles du service account - const roles = await this.keycloakApi.getUserClientRoles(serviceAccountId); - const roleNames = roles.map(r => r.name); - - this.logger.log(`Service account roles: ${roleNames.join(', ')}`); - - // Le service account doit avoir au moins DCB_ADMIN pour créer des utilisateurs - const hasRequiredRole = roleNames.some(role => - [UserRole.DCB_ADMIN].includes(role as UserRole) - ); - - if (!hasRequiredRole) { - throw new Error(`Service account missing required roles. Has: ${roleNames.join(', ')}, Needs: ${UserRole.DCB_ADMIN}`); - } - - // 1 - Service Account crée un ADMIN DCB-ADMIN - const adminData: CreateHubUserData = { - username: `test-dcb-admin-${Date.now()}`, - email: `test-dcb-admin-${Date.now()}@dcb-test.com`, - firstName: 'Test', - lastName: 'DCB Admin', - password: 'TempPassword123!', - role: UserRole.DCB_ADMIN, - enabled: true, - emailVerified: true, - createdBy: 'service-account', - }; - - const adminUser = await this.hubUsersService.createHubUser(serviceAccountId, adminData); - this.testUsers['dcb-admin'] = { - id: adminUser.id, - username: adminUser.username, - role: UserRole.DCB_ADMIN - }; - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { - serviceAccountId, - roles: roleNames, - createdAdmin: adminUser.username - } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - // ===== TESTS PARALLÈLES AVEC ISOLATION ===== - private async runParallelIsolationTests(): Promise { - const results: TestResult[] = []; - - try { - // Exécuter les tests pour deux merchants différents en parallèle - const [teamAResults, teamBResults] = await Promise.all([ - this.runMerchantTeamTests('TeamA'), - this.runMerchantTeamTests('TeamB') - ]); - - results.push(...teamAResults); - results.push(...teamBResults); - - // Test d'isolation entre les deux équipes - results.push(await this.testCrossTeamIsolation()); - - } catch (error) { - this.logger.error(`Parallel isolation tests failed: ${error.message}`); - results.push({ - testName: 'Parallel Isolation Tests', - success: false, - duration: 0, - error: error.message - }); - } - - return results; - } - - private async runMerchantTeamTests(teamName: string): Promise { - const results: TestResult[] = []; - const teamPrefix = teamName.toLowerCase(); - - try { - // 2 - ADMIN DCB-ADMIN crée DCB-SUPPORT et DCB-PARTNER pour cette équipe - const dcbAdmin = this.testUsers['dcb-admin']; - if (!dcbAdmin) { - throw new Error('DCB Admin not found for team tests'); - } - - // Créer DCB-SUPPORT - const supportData: CreateHubUserData = { - username: `test-${teamPrefix}-support-${Date.now()}`, - email: `test-${teamPrefix}-support-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Support', - password: 'TempPassword123!', - role: UserRole.DCB_SUPPORT, - enabled: true, - emailVerified: true, - createdBy: dcbAdmin.id, - }; - - const supportUser = await this.hubUsersService.createHubUser(dcbAdmin.id, supportData); - this.testUsers[`${teamPrefix}-support`] = { - id: supportUser.id, - username: supportUser.username, - role: UserRole.DCB_SUPPORT - }; - - // Créer DCB-PARTNER (Merchant Owner) - const partnerData: CreateHubUserData = { - username: `test-${teamPrefix}-partner-${Date.now()}`, - email: `test-${teamPrefix}-partner-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Partner', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER, - enabled: true, - emailVerified: true, - createdBy: dcbAdmin.id, - }; - - const partnerUser = await this.hubUsersService.createHubUser(dcbAdmin.id, partnerData); - this.testMerchants[`${teamPrefix}-partner`] = { - id: partnerUser.id, - username: partnerUser.username, - role: UserRole.DCB_PARTNER - }; - - results.push({ - testName: `${teamName} - Admin creates Support and Partner`, - success: true, - duration: 0, - data: { - supportUser: supportUser.username, - partnerUser: partnerUser.username - } - }); - - // 3 - ADMIN DCB-ADMIN crée DCB-PARTNER-ADMIN avec merchantPartnerId du DCB-PARTNER - const partnerAdminData: CreateMerchantUserData = { - username: `test-${teamPrefix}-partner-admin-${Date.now()}`, - email: `test-${teamPrefix}-partner-admin-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Partner Admin', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER_ADMIN, - enabled: true, - emailVerified: true, - merchantPartnerId: partnerUser.id, // Utilise l'ID du DCB-PARTNER - createdBy: dcbAdmin.id, - }; - - const partnerAdminUser = await this.merchantUsersService.createMerchantUser( - dcbAdmin.id, - partnerAdminData - ); - - this.testMerchantUsers[`${teamPrefix}-partner-admin`] = { - id: partnerAdminUser.id, - username: partnerAdminUser.username, - role: UserRole.DCB_PARTNER_ADMIN, - merchantPartnerId: partnerUser.id - }; - - results.push({ - testName: `${teamName} - Admin creates Partner Admin`, - success: true, - duration: 0, - data: { - partnerAdmin: partnerAdminUser.username, - merchantPartnerId: partnerUser.id - } - }); - - // 4 - DCB-PARTNER crée ses trois types d'utilisateurs - const partnerCreatedUsers = await this.testPartnerUserCreation(teamName, partnerUser.id); - results.push(...partnerCreatedUsers); - - // 5 - DCB-PARTNER-ADMIN crée un manager - const adminCreatedManager = await this.testPartnerAdminCreatesManager(teamName, partnerUser.id); - results.push(adminCreatedManager); - - } catch (error) { - results.push({ - testName: `${teamName} - Team Tests`, - success: false, - duration: 0, - error: error.message - }); - } - - return results; - } - - // Puis utilisez-le dans votre méthode - private async testPartnerUserCreation(teamName: string, partnerId: string): Promise { - const results: TestResult[] = []; - const teamPrefix = teamName.toLowerCase(); - - try { - const partner = this.testMerchants[`${teamPrefix}-partner`]; - if (!partner) { - throw new Error(`${teamName} Partner not found`); - } - - // Types d'utilisateurs à créer par le PARTNER - const userTypes: { role: MerchantUserRole; key: string }[] = [ - { role: UserRole.DCB_PARTNER_ADMIN, key: 'partner-admin-by-partner' }, - { role: UserRole.DCB_PARTNER_MANAGER, key: 'partner-manager-by-partner' }, - { role: UserRole.DCB_PARTNER_SUPPORT, key: 'partner-support-by-partner' } - ]; - - for (const userType of userTypes) { - const userData: CreateMerchantUserData = { - username: `test-${teamPrefix}-${userType.key}-${Date.now()}`, - email: `test-${teamPrefix}-${userType.key}-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: userType.role.split('_').pop() || 'User', - password: 'TempPassword123!', - role: userType.role, // Type compatible maintenant - enabled: true, - emailVerified: true, - merchantPartnerId: partnerId, - createdBy: partner.id, - }; - - const user = await this.merchantUsersService.createMerchantUser(partner.id, userData); - - this.testMerchantUsers[`${teamPrefix}-${userType.key}`] = { - id: user.id, - username: user.username, - role: userType.role, - merchantPartnerId: partnerId - }; - - results.push({ - testName: `${teamName} - Partner creates ${userType.role}`, - success: true, - duration: 0, - data: { - createdUser: user.username, - role: userType.role, - merchantPartnerId: partnerId - } - }); - } - - } catch (error) { - results.push({ - testName: `${teamName} - Partner User Creation`, - success: false, - duration: 0, - error: error.message - }); - } - - return results; - } - - private async testPartnerAdminCreatesManager(teamName: string, partnerId: string): Promise { - const testName = `${teamName} - Partner Admin creates Manager`; - const teamPrefix = teamName.toLowerCase(); - - try { - const partnerAdmin = this.testMerchantUsers[`${teamPrefix}-partner-admin`]; - if (!partnerAdmin) { - throw new Error(`${teamName} Partner Admin not found`); - } - - // 5 - DCB-PARTNER-ADMIN crée un manager avec l'ID de son DCB-PARTNER - const managerData: CreateMerchantUserData = { - username: `test-${teamPrefix}-manager-by-admin-${Date.now()}`, - email: `test-${teamPrefix}-manager-by-admin-${Date.now()}@dcb-test.com`, - firstName: `${teamName}`, - lastName: 'Manager by Admin', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER_MANAGER, - enabled: true, - emailVerified: true, - merchantPartnerId: partnerId, // Utilise l'ID du DCB-PARTNER (pas son propre ID) - createdBy: partnerAdmin.id, - }; - - const managerUser = await this.merchantUsersService.createMerchantUser( - partnerAdmin.id, - managerData - ); - - this.testMerchantUsers[`${teamPrefix}-manager-by-admin`] = { - id: managerUser.id, - username: managerUser.username, - role: UserRole.DCB_PARTNER_MANAGER, - merchantPartnerId: partnerId - }; - - return { - testName, - success: true, - duration: 0, - data: { - createdManager: managerUser.username, - createdBy: partnerAdmin.username, - merchantPartnerId: partnerId - } - }; - - } catch (error) { - return { - testName, - success: false, - duration: 0, - error: error.message - }; - } - } - - private async testCrossTeamIsolation(): Promise { - const testName = 'Cross-Team Isolation Test'; - const startTime = Date.now(); - - try { - const teamAPartnerAdmin = this.testMerchantUsers['teama-partner-admin']; - const teamBPartner = this.testMerchants['teamb-partner']; - - if (!teamAPartnerAdmin || !teamBPartner) { - throw new Error('Team users not found for isolation test'); - } - - // Tenter de créer un utilisateur dans l'autre équipe - devrait échouer - try { - const crossTeamUserData: CreateMerchantUserData = { - username: `test-cross-team-attempt-${Date.now()}`, - email: `test-cross-team-attempt-${Date.now()}@dcb-test.com`, - firstName: 'Cross', - lastName: 'Team Attempt', - password: 'TempPassword123!', - role: UserRole.DCB_PARTNER_MANAGER, - enabled: true, - emailVerified: true, - merchantPartnerId: teamBPartner.id, // ID d'une autre équipe - createdBy: teamAPartnerAdmin.id, - }; - - await this.merchantUsersService.createMerchantUser( - teamAPartnerAdmin.id, - crossTeamUserData - ); - - // Si on arrive ici, l'isolation a échoué - throw new Error('Isolation failed - User from TeamA could create user in TeamB'); - - } catch (error) { - // Comportement attendu - l'accès doit être refusé - if (error.message.includes('Forbidden') || - error.message.includes('Insufficient permissions') || - error.message.includes('not authorized') || - error.message.includes('own merchant')) { - // Succès - l'isolation fonctionne - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { isolationWorking: true } - }; - } else { - // Erreur inattendue - throw error; - } - } - - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - // ===== TESTS AVANCÉS (conservés depuis la version originale) ===== - private async testStatsAndReports(): Promise { - const testName = 'Stats and Reports Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - const stats = await this.hubUsersService.getHubUsersStats(serviceAccountId); - const activity = await this.hubUsersService.getHubUserActivity(serviceAccountId); - const sessions = await this.hubUsersService.getActiveHubSessions(serviceAccountId); - - // Validation basique des stats - if (typeof stats.totalAdmins !== 'number' || typeof stats.totalSupport !== 'number') { - throw new Error('Stats validation failed'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { - stats, - activityCount: activity.length, - sessionCount: sessions.length - } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - private async testHealthCheck(): Promise { - const testName = 'Health Check Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - const health = await this.hubUsersService.checkHubUsersHealth(serviceAccountId); - - if (!health.status || !health.stats || !Array.isArray(health.issues)) { - throw new Error('Health check validation failed'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { healthStatus: health.status } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - private async testSecurityValidations(): Promise { - const testName = 'Security Validations Test'; - const startTime = Date.now(); - - try { - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - // Test de la méthode canUserManageHubUsers - const canManage = await this.hubUsersService.canUserManageHubUsers(serviceAccountId); - - if (!canManage) { - throw new Error('Service account should be able to manage hub users'); - } - - const duration = Date.now() - startTime; - this.logger.log(`✅ ${testName} - Success (${duration}ms)`); - - return { - testName, - success: true, - duration, - data: { canManageHubUsers: canManage } - }; - } catch (error) { - const duration = Date.now() - startTime; - this.logger.error(`❌ ${testName} - Failed: ${error.message}`); - return { testName, success: false, duration, error: error.message }; - } - } - - // ===== NETTOYAGE ===== - private async cleanupTestUsers(): Promise { - this.logger.log('🧹 Cleaning up test users...'); - - const serviceToken = await this.tokenService.acquireServiceAccountToken(); - const decodedToken = this.tokenService.decodeToken(serviceToken); - const serviceAccountId = decodedToken.sub; - - // Nettoyer les utilisateurs hub - for (const [key, userInfo] of Object.entries(this.testUsers)) { - try { - await this.hubUsersService.deleteHubUser(userInfo.id, serviceAccountId); - this.logger.log(`✅ Deleted test user: ${key} (${userInfo.username})`); - } catch (error) { - this.logger.warn(`⚠️ Could not delete test user ${key}: ${error.message}`); - } - } - - this.testUsers = {}; - } - - private async cleanupTestMerchants(): Promise { - this.logger.log('🧹 Cleaning up test merchants...'); - - // Implémentez la logique de nettoyage des merchants de test - this.testMerchants = {}; - this.testMerchantUsers = {}; - } - - // ===== LOGGING ET RAPPORTS ===== - private logTestSummary(summary: StartupTestSummary): void { - this.logger.log('='.repeat(60)); - this.logger.log('🎯 PARALLEL ISOLATION STARTUP TEST SUMMARY'); - this.logger.log('='.repeat(60)); - this.logger.log(`📊 Total Tests: ${summary.totalTests}`); - this.logger.log(`✅ Passed: ${summary.passedTests}`); - this.logger.log(`❌ Failed: ${summary.failedTests}`); - this.logger.log(`⏱️ Total Duration: ${summary.totalDuration}ms`); - this.logger.log('-'.repeat(60)); - - summary.results.forEach(result => { - const status = result.success ? '✅' : '❌'; - this.logger.log(`${status} ${result.testName}: ${result.duration}ms`); - if (!result.success) { - this.logger.log(` ERROR: ${result.error}`); - } - }); - - this.logger.log('='.repeat(60)); - - if (summary.failedTests === 0) { - this.logger.log('🚀 ALL TESTS PASSED! System is ready with proper isolation.'); - } else { - this.logger.warn(`⚠️ ${summary.failedTests} test(s) failed. Please check the logs above.`); - } - } - - // ===== MÉTHODES PUBLIQUES POUR USAGE MANUEL ===== - async runQuickTest(): Promise { - this.logger.log('🔍 Running quick startup test...'); - return this.runAllTests(); - } - - async getTestStatus(): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; details: string }> { - try { - const summary = await this.runAllTests(); - const successRate = (summary.passedTests / summary.totalTests) * 100; - - if (successRate === 100) { - return { status: 'healthy', details: 'All tests passed successfully' }; - } else if (successRate >= 80) { - return { status: 'degraded', details: `${summary.failedTests} test(s) failed` }; - } else { - return { status: 'unhealthy', details: 'Multiple test failures detected' }; - } - } catch (error) { - return { status: 'unhealthy', details: `Test execution failed: ${error.message}` }; - } + getTestResults(): TestResults { + return this.testResults; } } \ No newline at end of file diff --git a/src/auth/services/token.service.ts b/src/auth/services/token.service.ts index ff5500a..1b910eb 100644 --- a/src/auth/services/token.service.ts +++ b/src/auth/services/token.service.ts @@ -23,9 +23,6 @@ export interface DecodedToken { realm_access?: { roles: string[] }; resource_access?: { [key: string]: { roles: string[] } }; merchantPartnerId?: string; - // Ajout des claims personnalisés - 'merchant-partner-id'?: string; - 'user-type'?: string; } diff --git a/src/hub-users/controllers/hub-users.controller.ts b/src/hub-users/controllers/hub-users.controller.ts index 0f7cd51..42ed783 100644 --- a/src/hub-users/controllers/hub-users.controller.ts +++ b/src/hub-users/controllers/hub-users.controller.ts @@ -5,13 +5,13 @@ import { Put, Delete, Body, - Param, - Query, - UseGuards, + Param, Request, HttpCode, HttpStatus, ParseUUIDPipe, + ForbiddenException, + Logger, BadRequestException } from '@nestjs/common'; import { @@ -19,111 +19,135 @@ import { ApiOperation, ApiResponse, ApiBearerAuth, - ApiParam, - ApiQuery, - ApiProperty + ApiParam, + ApiProperty, + getSchemaPath } from '@nestjs/swagger'; +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsBoolean, + MinLength, + IsString, + ValidateIf +} from 'class-validator'; + import { HubUsersService } from '../services/hub-users.service'; -import { UserRole, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model'; -import { JwtAuthGuard } from '../../auth/guards/jwt.guard'; +import { UserRole, UserType } from '../../auth/services/keycloak-user.model'; + import { RESOURCES } from '../../constants/resources'; import { SCOPES } from '../../constants/scopes'; import { Resource, Scopes } from 'nest-keycloak-connect'; +import { CreateUserData, User } from '../models/hub-user.model'; -export class LoginDto { - @ApiProperty({ description: 'Username' }) - username: string; +// ===== DTO SPÉCIFIQUES AUX HUB USERS ===== - @ApiProperty({ description: 'Password' }) - password: string; -} - -export class TokenResponseDto { - @ApiProperty({ description: 'Access token' }) - access_token: string; - - @ApiProperty({ description: 'Refresh token' }) - refresh_token?: string; - - @ApiProperty({ description: 'Token type' }) - token_type: string; - - @ApiProperty({ description: 'Expires in (seconds)' }) - expires_in: number; - - @ApiProperty({ description: 'Refresh expires in (seconds)' }) - refresh_expires_in?: number; - - @ApiProperty({ description: 'Scope' }) - scope?: string; -} - -// DTOs pour les utilisateurs Hub export class CreateHubUserDto { - @ApiProperty({ description: 'Username for the hub user' }) + @ApiProperty({ description: 'Username for the user' }) + @IsNotEmpty({ message: 'Username is required' }) + @IsString() + @MinLength(3, { message: 'Username must be at least 3 characters' }) username: string; @ApiProperty({ description: 'Email address' }) + @IsNotEmpty({ message: 'Email is required' }) + @IsEmail({}, { message: 'Invalid email format' }) email: string; @ApiProperty({ description: 'First name' }) + @IsNotEmpty({ message: 'First name is required' }) + @IsString() firstName: string; @ApiProperty({ description: 'Last name' }) + @IsNotEmpty({ message: 'Last name is required' }) + @IsString() lastName: string; @ApiProperty({ description: 'Password for the user' }) + @IsNotEmpty({ message: 'Password is required' }) + @IsString() + @MinLength(8, { message: 'Password must be at least 8 characters' }) password: string; @ApiProperty({ - enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], - description: 'Role for the hub user' + enum: UserRole, + description: 'Role for the user', + examples: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER] }) - role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'Enabled must be a boolean' }) enabled?: boolean = true; - @ApiProperty({ required: false, default: false }) - emailVerified?: boolean = false; + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'EmailVerified must be a boolean' }) + emailVerified?: boolean = true; + + @ApiProperty({ + enum: UserType, + description: 'Type of user', + example: UserType.HUB + }) + @IsEnum(UserType, { message: 'Invalid user type' }) + @IsNotEmpty({ message: 'User type is required' }) + userType: UserType; + + // Pas de merchantPartnerId pour les hub users } export class UpdateHubUserDto { @ApiProperty({ required: false }) + @IsOptional() + @IsString() firstName?: string; @ApiProperty({ required: false }) + @IsOptional() + @IsString() lastName?: string; @ApiProperty({ required: false }) + @IsOptional() + @IsEmail() email?: string; @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() enabled?: boolean; } -export class UpdateUserRoleDto { +export class ResetHubUserPasswordDto { + @ApiProperty({ description: 'New password' }) + @IsNotEmpty() + @IsString() + @MinLength(8) + newPassword: string; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean() + temporary?: boolean = true; +} + +export class UpdateHubUserRoleDto { @ApiProperty({ enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], description: 'New role for the user' }) - role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; } -export class ResetPasswordDto { - @ApiProperty({ description: 'New password' }) - newPassword: string; - - @ApiProperty({ required: false, default: true }) - temporary?: boolean = true; -} - -export class SuspendMerchantDto { - @ApiProperty({ description: 'Reason for suspension' }) - reason: string; -} - -// DTOs pour les réponses export class HubUserResponse { @ApiProperty({ description: 'User ID' }) id: string; @@ -144,7 +168,7 @@ export class HubUserResponse { enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], description: 'User role' }) - role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; + role: UserRole; @ApiProperty({ description: 'Whether the user is enabled' }) enabled: boolean; @@ -158,95 +182,50 @@ export class HubUserResponse { @ApiProperty({ description: 'User creator username' }) createdByUsername: string; + @ApiProperty({ enum: ['HUB'], description: 'User type' }) + userType: UserType; + @ApiProperty({ description: 'Creation timestamp' }) createdTimestamp: number; @ApiProperty({ required: false, description: 'Last login timestamp' }) lastLogin?: number; - - @ApiProperty({ enum: ['HUB'], description: 'User type' }) - userType: 'HUB'; } -export class HubUsersStatsResponse { - @ApiProperty({ description: 'Total admin users' }) - totalAdmins: number; - - @ApiProperty({ description: 'Total support users' }) - totalSupport: number; - - @ApiProperty({ description: 'Active users count' }) - activeUsers: number; - - @ApiProperty({ description: 'Inactive users count' }) - inactiveUsers: number; - - @ApiProperty({ description: 'Users pending activation' }) - pendingActivation: number; -} - -export class MerchantStatsResponse { - @ApiProperty({ description: 'Total merchants' }) - totalMerchants: number; - - @ApiProperty({ description: 'Active merchants count' }) - activeMerchants: number; - - @ApiProperty({ description: 'Suspended merchants count' }) - suspendedMerchants: number; - - @ApiProperty({ description: 'Pending merchants count' }) - pendingMerchants: number; - - @ApiProperty({ description: 'Total merchant users' }) - totalUsers: number; -} - -export class HealthStatusResponse { - @ApiProperty({ enum: ['healthy', 'degraded', 'unhealthy'] }) - status: string; - - @ApiProperty({ type: [String], description: 'Health issues detected' }) - issues: string[]; - - @ApiProperty({ description: 'System statistics' }) - stats: HubUsersStatsResponse; -} - -export class UserActivityResponse { - @ApiProperty({ description: 'User information' }) - user: HubUserResponse; - - @ApiProperty({ required: false, description: 'Last login date' }) - lastLogin?: Date; -} - -export class SessionResponse { +export class HubUserProfileResponse { @ApiProperty({ description: 'User ID' }) - userId: string; + id: string; @ApiProperty({ description: 'Username' }) username: string; - @ApiProperty({ description: 'Last access date' }) - lastAccess: Date; -} + @ApiProperty({ description: 'Email address' }) + email: string; -export class PermissionResponse { - @ApiProperty({ description: 'Whether user can manage hub users' }) - canManageHubUsers: boolean; -} + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; -export class AvailableRolesResponse { @ApiProperty({ - type: [Object], - description: 'Available roles' + description: 'Client roles', + type: [String], + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER] }) - roles: Array<{ - value: UserRole; - label: string; - description: string; - }>; + clientRoles: string[]; + + @ApiProperty({ required: false, description: 'User creator ID' }) + createdBy?: string; + + @ApiProperty({ required: false, description: 'User creator username' }) + createdByUsername?: string; } export class MessageResponse { @@ -255,39 +234,50 @@ export class MessageResponse { } // Mapper functions -function mapToHubUserResponse(hubUser: HubUser): HubUserResponse { +function mapToHubUserResponse(user: User): HubUserResponse { return { - id: hubUser.id, - username: hubUser.username, - email: hubUser.email, - firstName: hubUser.firstName, - lastName: hubUser.lastName, - role: hubUser.role, - enabled: hubUser.enabled, - emailVerified: hubUser.emailVerified, - createdBy: hubUser.createdBy, - createdByUsername: hubUser.createdByUsername, - createdTimestamp: hubUser.createdTimestamp, - lastLogin: hubUser.lastLogin, - userType: hubUser.userType, + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + enabled: user.enabled, + emailVerified: user.emailVerified, + createdBy: user.createdBy, + createdByUsername: user.createdByUsername, + userType: user.userType, + createdTimestamp: user.createdTimestamp, + lastLogin: user.lastLogin, }; } -function mapToUserActivityResponse(activity: HubUserActivity): UserActivityResponse { +function mapToHubUserProfileResponse(profile: any): HubUserProfileResponse { return { - user: mapToHubUserResponse(activity.user), - lastLogin: activity.lastLogin + id: profile.id, + username: profile.username, + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + emailVerified: profile.emailVerified, + enabled: profile.enabled, + clientRoles: profile.clientRoles, + createdBy: profile.createdBy, + createdByUsername: profile.createdByUsername, }; } +// ===== CONTROLLER POUR LES UTILISATEURS HUB ===== + @ApiTags('Hub Users') @ApiBearerAuth() @Controller('hub-users') -@Resource(RESOURCES.HUB_USER || RESOURCES.MERCHANT_USER) +@Resource(RESOURCES.HUB_USER) export class HubUsersController { - constructor(private readonly hubUsersService: HubUsersService) {} + constructor(private readonly usersService: HubUsersService) {} + private readonly logger = new Logger(HubUsersController.name); - // ===== GESTION DES UTILISATEURS HUB ===== + // ===== ROUTES SANS PARAMÈTRES ===== @Get() @ApiOperation({ @@ -298,57 +288,31 @@ export class HubUsersController { status: 200, description: 'Hub users retrieved successfully', type: [HubUserResponse] - }) - @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) - + }) @Scopes(SCOPES.READ) async getAllHubUsers(@Request() req): Promise { const userId = req.user.sub; - const users = await this.hubUsersService.getAllHubUsers(userId); + const users = await this.usersService.getAllHubUsers(userId); return users.map(mapToHubUserResponse); } - @Get('role/:role') - @ApiOperation({ summary: 'Get hub users by role' }) + @Get('partners/dcb-partners') + @ApiOperation({ + summary: 'Get all DCB_PARTNER users only', + description: 'Returns only DCB_PARTNER users (excludes DCB_ADMIN and DCB_SUPPORT)' + }) @ApiResponse({ status: 200, - description: 'Hub users retrieved successfully', + description: 'DCB_PARTNER users retrieved successfully', type: [HubUserResponse] - }) - @ApiResponse({ status: 400, description: 'Invalid role' }) - @ApiParam({ name: 'role', enum: UserRole, description: 'User role' }) - + }) @Scopes(SCOPES.READ) - async getHubUsersByRole( - @Param('role') role: UserRole, - @Request() req - ): Promise { + async getAllDcbPartners(@Request() req): Promise { const userId = req.user.sub; - const validRole = this.hubUsersService.validateHubRoleFromString(role); - const users = await this.hubUsersService.getHubUsersByRole(validRole, userId); + const users = await this.usersService.getAllDcbPartners(userId); return users.map(mapToHubUserResponse); } - @Get(':id') - @ApiOperation({ summary: 'Get hub user by ID' }) - @ApiResponse({ - status: 200, - description: 'Hub user retrieved successfully', - type: HubUserResponse - }) - @ApiResponse({ status: 404, description: 'Hub user not found' }) - @ApiParam({ name: 'id', description: 'User ID' }) - - @Scopes(SCOPES.READ) - async getHubUserById( - @Param('id', ParseUUIDPipe) id: string, - @Request() req - ): Promise { - const userId = req.user.sub; - const user = await this.hubUsersService.getHubUserById(id, userId); - return mapToHubUserResponse(user); - } - @Post() @ApiOperation({ summary: 'Create a new hub user', @@ -358,23 +322,163 @@ export class HubUsersController { status: 201, description: 'Hub user created successfully', type: HubUserResponse - }) - @ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' }) - @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) - + }) @Scopes(SCOPES.WRITE) async createHubUser( - @Body() createHubUserDto: CreateHubUserDto, + @Body() createUserDto: CreateHubUserDto, + @Request() req + ): Promise { + + // Debug complet + this.logger.debug('🔍 === CONTROLLER - CREATE HUB USER ==='); + this.logger.debug('Request headers:', req.headers); + this.logger.debug('Content-Type:', req.headers['content-type']); + this.logger.debug('Raw body exists:', !!req.body); + this.logger.debug('CreateHubUserDto received:', createUserDto); + this.logger.debug('DTO structure:', { + username: createUserDto.username, + email: createUserDto.email, + firstName: createUserDto.firstName, + lastName: createUserDto.lastName, + role: createUserDto.role, + userType: createUserDto.userType, + }); + this.logger.debug('===================================='); + + // Validation manuelle renforcée + const requiredFields = ['username', 'email', 'firstName', 'lastName', 'password', 'role', 'userType']; + const missingFields = requiredFields.filter(field => !createUserDto[field]); + + if (missingFields.length > 0) { + throw new BadRequestException(`Missing required fields: ${missingFields.join(', ')}`); + } + + if (createUserDto.userType !== UserType.HUB) { + throw new BadRequestException('User type must be HUB for hub users'); + } + + const userId = req.user.sub; + + const userData: CreateUserData = { + ...createUserDto, + }; + + this.logger.debug('UserData passed to service:', userData); + + try { + const user = await this.usersService.createHubUser(userId, userData); + return mapToHubUserResponse(user); + } catch (error) { + this.logger.error('Error creating hub user:', error); + throw error; + } + } + // ===== ROUTES AVEC PARAMÈTRES STATIQUES ===== + + @Get('all-users') + @ApiOperation({ + summary: 'Get global users overview', + description: 'Returns hub users and all merchant users (Admin only)' + }) + @ApiResponse({ + status: 200, + description: 'Global overview retrieved successfully', + schema: { + type: 'object', + properties: { + hubUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } }, + merchantUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } }, + statistics: { + type: 'object', + properties: { + totalHubUsers: { type: 'number' }, + totalMerchantUsers: { type: 'number' }, + totalUsers: { type: 'number' } + } + } + } + } + }) + @Scopes(SCOPES.READ) + async getGlobalUsersOverview(@Request() req): Promise { + const userId = req.user.sub; + + const isAdmin = await this.usersService.isUserHubAdminOrSupport(userId); + if (!isAdmin) { + throw new ForbiddenException('Only Hub administrators can access global overview'); + } + + const hubUsers = await this.usersService.getAllHubUsers(userId); + const merchantUsers = await this.usersService.getMyMerchantUsers(userId); + + return { + hubUsers: hubUsers.map(mapToHubUserResponse), + merchantUsers: merchantUsers.map(mapToHubUserResponse), + statistics: { + totalHubUsers: hubUsers.length, + totalMerchantUsers: merchantUsers.length, + totalUsers: hubUsers.length + merchantUsers.length + } + }; + } + + @Get('profile/:id') + @ApiOperation({ summary: 'Get complete user profile' }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully', + type: HubUserProfileResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getCompleteUserProfile( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const tokenUser = req.user; + const profile = await this.usersService.getCompleteUserProfile(id, tokenUser); + return mapToHubUserProfileResponse(profile); + } + + @Get('role/:role') + @ApiOperation({ summary: 'Get hub users by role' }) + @ApiResponse({ + status: 200, + description: 'Hub users retrieved successfully', + type: [HubUserResponse] + }) + @ApiParam({ + name: 'role', + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + description: 'User role' + }) + @Scopes(SCOPES.READ) + async getHubUsersByRole( + @Param('role') role: UserRole, + @Request() req + ): Promise { + const userId = req.user.sub; + const users = await this.usersService.getHubUsersByRole(role, userId); + return users.map(mapToHubUserResponse); + } + + // ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES ===== + + @Get(':id') + @ApiOperation({ summary: 'Get hub user by ID' }) + @ApiResponse({ + status: 200, + description: 'Hub user retrieved successfully', + type: HubUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getHubUserById( + @Param('id', ParseUUIDPipe) id: string, @Request() req ): Promise { const userId = req.user.sub; - - const userData: CreateHubUserData = { - ...createHubUserDto, - createdBy: userId, - }; - - const user = await this.hubUsersService.createHubUser(userId, userData); + const user = await this.usersService.getHubUserById(id, userId); return mapToHubUserResponse(user); } @@ -384,76 +488,65 @@ export class HubUsersController { status: 200, description: 'Hub user updated successfully', type: HubUserResponse - }) - @ApiResponse({ status: 404, description: 'Hub user not found' }) - @ApiParam({ name: 'id', description: 'User ID' }) - + }) + @ApiParam({ name: 'id', description: 'User ID' }) @Scopes(SCOPES.WRITE) async updateHubUser( @Param('id', ParseUUIDPipe) id: string, - @Body() updateHubUserDto: UpdateHubUserDto, + @Body() updateUserDto: UpdateHubUserDto, @Request() req ): Promise { const userId = req.user.sub; - const user = await this.hubUsersService.updateHubUser(id, updateHubUserDto, userId); + const user = await this.usersService.updateHubUser(id, updateUserDto, userId); return mapToHubUserResponse(user); } + @Delete(':id') + @ApiOperation({ summary: 'Delete a hub user' }) + @ApiResponse({ status: 200, description: 'Hub user deleted successfully' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.DELETE) + async deleteHubUser( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const userId = req.user.sub; + await this.usersService.deleteHubUser(id, userId); + return { message: 'Hub user deleted successfully' }; + } + @Put(':id/role') @ApiOperation({ summary: 'Update hub user role' }) @ApiResponse({ status: 200, description: 'User role updated successfully', type: HubUserResponse - }) - @ApiResponse({ status: 403, description: 'Forbidden - only DCB_ADMIN can change roles' }) - @ApiParam({ name: 'id', description: 'User ID' }) - + }) + @ApiParam({ name: 'id', description: 'User ID' }) @Scopes(SCOPES.WRITE) async updateHubUserRole( @Param('id', ParseUUIDPipe) id: string, - @Body() updateRoleDto: UpdateUserRoleDto, + @Body() updateRoleDto: UpdateHubUserRoleDto, @Request() req ): Promise { const userId = req.user.sub; - const user = await this.hubUsersService.updateHubUserRole(id, updateRoleDto.role, userId); + const user = await this.usersService.updateHubUserRole(id, updateRoleDto.role, userId); return mapToHubUserResponse(user); } - @Delete(':id') - @ApiOperation({ summary: 'Delete a hub user' }) - @ApiResponse({ status: 200, description: 'Hub user deleted successfully' }) - @ApiResponse({ status: 400, description: 'Cannot delete own account or last admin' }) - @ApiResponse({ status: 404, description: 'Hub user not found' }) - @ApiParam({ name: 'id', description: 'User ID' }) - - @Scopes(SCOPES.DELETE) - async deleteHubUser( - @Param('id', ParseUUIDPipe) id: string, - @Request() req - ): Promise { - const userId = req.user.sub; - await this.hubUsersService.deleteHubUser(id, userId); - return { message: 'Hub user deleted successfully' }; - } - - // ===== GESTION DES MOTS DE PASSE ===== - @Post(':id/reset-password') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Reset hub user password' }) - @ApiResponse({ status: 200, description: 'Password reset successfully' }) - @ApiResponse({ status: 404, description: 'Hub user not found' }) - @ApiParam({ name: 'id', description: 'User ID' }) - + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiParam({ name: 'id', description: 'User ID' }) @Scopes(SCOPES.WRITE) async resetHubUserPassword( @Param('id', ParseUUIDPipe) id: string, - @Body() resetPasswordDto: ResetPasswordDto, + @Body() resetPasswordDto: ResetHubUserPasswordDto, @Request() req ): Promise { const userId = req.user.sub; - await this.hubUsersService.resetHubUserPassword( + await this.usersService.resetUserPassword( id, resetPasswordDto.newPassword, resetPasswordDto.temporary, @@ -461,213 +554,4 @@ export class HubUsersController { ); return { message: 'Password reset successfully' }; } - - @Post(':id/send-reset-email') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Send password reset email to hub user' }) - @ApiResponse({ status: 200, description: 'Password reset email sent successfully' }) - @ApiResponse({ status: 404, description: 'Hub user not found' }) - @ApiParam({ name: 'id', description: 'User ID' }) - - @Scopes(SCOPES.WRITE) - async sendHubUserPasswordResetEmail( - @Param('id', ParseUUIDPipe) id: string, - @Request() req - ): Promise { - const userId = req.user.sub; - await this.hubUsersService.sendHubUserPasswordResetEmail(id, userId); - return { message: 'Password reset email sent successfully' }; - } - - // ===== GESTION DES MERCHANTS (DCB_PARTNER) ===== - - @Get('merchants/all') - @ApiOperation({ summary: 'Get all merchant partners' }) - @ApiResponse({ - status: 200, - description: 'Merchant partners retrieved successfully', - type: [HubUserResponse] - }) - - @Scopes(SCOPES.READ) - async getAllMerchants(@Request() req): Promise { - const userId = req.user.sub; - const merchants = await this.hubUsersService.getAllMerchants(userId); - return merchants.map(mapToHubUserResponse); - } - - @Get('merchants/:merchantId') - @ApiOperation({ summary: 'Get merchant partner by ID' }) - @ApiResponse({ - status: 200, - description: 'Merchant partner retrieved successfully', - type: HubUserResponse - }) - @ApiResponse({ status: 404, description: 'Merchant partner not found' }) - @ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' }) - - @Scopes(SCOPES.READ) - async getMerchantPartnerById( - @Param('merchantId', ParseUUIDPipe) merchantId: string, - @Request() req - ): Promise { - const userId = req.user.sub; - const merchant = await this.hubUsersService.getMerchantPartnerById(merchantId, userId); - return mapToHubUserResponse(merchant); - } - - @Put('merchants/:merchantId') - @ApiOperation({ summary: 'Update a merchant partner' }) - @ApiResponse({ - status: 200, - description: 'Merchant partner updated successfully', - type: HubUserResponse - }) - @ApiResponse({ status: 404, description: 'Merchant partner not found' }) - @ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' }) - - @Scopes(SCOPES.WRITE) - async updateMerchantPartner( - @Param('merchantId', ParseUUIDPipe) merchantId: string, - @Body() updateHubUserDto: UpdateHubUserDto, - @Request() req - ): Promise { - const userId = req.user.sub; - const merchant = await this.hubUsersService.updateMerchantPartner(merchantId, updateHubUserDto, userId); - return mapToHubUserResponse(merchant); - } - - @Post('merchants/:merchantId/suspend') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Suspend a merchant partner and all its users' }) - @ApiResponse({ status: 200, description: 'Merchant partner suspended successfully' }) - @ApiResponse({ status: 404, description: 'Merchant partner not found' }) - @ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' }) - - @Scopes(SCOPES.WRITE) - async suspendMerchantPartner( - @Param('merchantId', ParseUUIDPipe) merchantId: string, - @Body() suspendMerchantDto: SuspendMerchantDto, - @Request() req - ): Promise { - const userId = req.user.sub; - await this.hubUsersService.suspendMerchantPartner(merchantId, suspendMerchantDto.reason, userId); - return { message: 'Merchant partner suspended successfully' }; - } - - // ===== STATISTIQUES ET RAPPORTS ===== - - @Get('stats/overview') - @ApiOperation({ summary: 'Get hub users statistics overview' }) - @ApiResponse({ - status: 200, - description: 'Statistics retrieved successfully', - type: HubUsersStatsResponse - }) - - @Scopes(SCOPES.READ) - async getHubUsersStats(@Request() req): Promise { - const userId = req.user.sub; - return this.hubUsersService.getHubUsersStats(userId); - } - - @Get('stats/merchants') - @ApiOperation({ summary: 'Get merchants statistics' }) - @ApiResponse({ - status: 200, - description: 'Merchants statistics retrieved successfully', - type: MerchantStatsResponse - }) - - @Scopes(SCOPES.READ) - async getMerchantStats(@Request() req): Promise { - const userId = req.user.sub; - return this.hubUsersService.getMerchantStats(userId); - } - - @Get('activity/recent') - @ApiOperation({ summary: 'Get recent hub user activity' }) - @ApiResponse({ - status: 200, - description: 'Activity retrieved successfully', - type: [UserActivityResponse] - }) - - @Scopes(SCOPES.READ) - async getHubUserActivity(@Request() req): Promise { - const userId = req.user.sub; - const activities = await this.hubUsersService.getHubUserActivity(userId); - return activities.map(mapToUserActivityResponse); - } - - @Get('sessions/active') - @ApiOperation({ summary: 'Get active hub sessions' }) - @ApiResponse({ - status: 200, - description: 'Active sessions retrieved successfully', - type: [SessionResponse] - }) - - @Scopes(SCOPES.READ) - async getActiveHubSessions(@Request() req): Promise { - const userId = req.user.sub; - const sessions = await this.hubUsersService.getActiveHubSessions(userId); - return sessions.map(session => ({ - userId: session.userId, - username: session.username, - lastAccess: session.lastAccess - })); - } - - // ===== SANTÉ ET UTILITAIRES ===== - - @Get('health/status') - @ApiOperation({ summary: 'Get hub users health status' }) - @ApiResponse({ - status: 200, - description: 'Health status retrieved successfully', - type: HealthStatusResponse - }) - - @Scopes(SCOPES.READ) - async checkHubUsersHealth(@Request() req): Promise { - const userId = req.user.sub; - return this.hubUsersService.checkHubUsersHealth(userId); - } - - @Get('me/permissions') - @ApiOperation({ summary: 'Check if current user can manage hub users' }) - @ApiResponse({ status: 200, description: 'Permissions check completed' }) - async canUserManageHubUsers(@Request() req): Promise { - const userId = req.user.sub; - const canManage = await this.hubUsersService.canUserManageHubUsers(userId); - return { canManageHubUsers: canManage }; - } - - @Get('roles/available') - @ApiOperation({ summary: 'Get available hub roles' }) - @ApiResponse({ status: 200, description: 'Available roles retrieved successfully' }) - - @Scopes(SCOPES.READ) - async getAvailableHubRoles(): Promise { - const roles = [ - { - value: UserRole.DCB_ADMIN, - label: 'DCB Admin', - description: 'Full administrative access to the entire system' - }, - { - value: UserRole.DCB_SUPPORT, - label: 'DCB Support', - description: 'Support access with limited administrative capabilities' - }, - { - value: UserRole.DCB_PARTNER, - label: 'DCB Partner', - description: 'Merchant partner with access to their own merchant ecosystem' - } - ]; - - return { roles }; - } } \ No newline at end of file diff --git a/src/hub-users/controllers/merchant-users.controller.ts b/src/hub-users/controllers/merchant-users.controller.ts index f2f34ba..e1a59c1 100644 --- a/src/hub-users/controllers/merchant-users.controller.ts +++ b/src/hub-users/controllers/merchant-users.controller.ts @@ -5,83 +5,152 @@ import { Put, Delete, Body, - Param, - Query, - UseGuards, + Param, Request, HttpCode, HttpStatus, ParseUUIDPipe, - BadRequestException + ForbiddenException, + Logger, + BadRequestException, + InternalServerErrorException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, - ApiParam, - ApiQuery, + ApiParam, ApiProperty } from '@nestjs/swagger'; -import { MerchantUsersService, MerchantUser, CreateMerchantUserData } from '../services/merchant-users.service'; -import { JwtAuthGuard } from '../../auth/guards/jwt.guard'; -import { UserRole } from '../../auth/services/keycloak-user.model'; + +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsBoolean, + MinLength, + IsString, + ValidateIf +} from 'class-validator'; + +import { HubUsersService } from '../services/hub-users.service'; +import { UserRole, UserType } from '../../auth/services/keycloak-user.model'; + import { RESOURCES } from '../../constants/resources'; import { SCOPES } from '../../constants/scopes'; import { Resource, Scopes } from 'nest-keycloak-connect'; +import { CreateUserData, User } from '../models/hub-user.model'; + +// ===== DTO SPÉCIFIQUES AUX MERCHANT USERS ===== export class CreateMerchantUserDto { - @ApiProperty({ description: 'Username for the merchant user' }) + @ApiProperty({ description: 'Username for the user' }) + @IsNotEmpty({ message: 'Username is required' }) + @IsString() + @MinLength(3, { message: 'Username must be at least 3 characters' }) username: string; @ApiProperty({ description: 'Email address' }) + @IsNotEmpty({ message: 'Email is required' }) + @IsEmail({}, { message: 'Invalid email format' }) email: string; @ApiProperty({ description: 'First name' }) + @IsNotEmpty({ message: 'First name is required' }) + @IsString() firstName: string; @ApiProperty({ description: 'Last name' }) + @IsNotEmpty({ message: 'Last name is required' }) + @IsString() lastName: string; @ApiProperty({ description: 'Password for the user' }) + @IsNotEmpty({ message: 'Password is required' }) + @IsString() + @MinLength(8, { message: 'Password must be at least 8 characters' }) password: string; @ApiProperty({ - enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], - description: 'Role for the merchant user' + enum: UserRole, + description: 'Role for the user', + examples: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT] }) - role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'Enabled must be a boolean' }) enabled?: boolean = true; - @ApiProperty({ required: false, default: false }) - emailVerified?: boolean = false; + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'EmailVerified must be a boolean' }) + emailVerified?: boolean = true; - @ApiProperty({ description: 'Merchant partner ID' }) - merchantPartnerId: string; + @ApiProperty({ + enum: UserType, + description: 'Type of user', + example: UserType.MERCHANT_PARTNER + }) + @IsEnum(UserType, { message: 'Invalid user type' }) + @IsNotEmpty({ message: 'User type is required' }) + userType: UserType; + + @ApiProperty({ required: false }) + @IsOptional() + @ValidateIf((o) => o.userType === UserType.MERCHANT_PARTNER && o.role !== UserRole.DCB_PARTNER) + @IsString({ message: 'Merchant partner ID must be a string' }) + merchantPartnerId?: string | null; +} + +export class ResetMerchantUserPasswordDto { + @ApiProperty({ description: 'New password' }) + @IsNotEmpty() + @IsString() + @MinLength(8) + newPassword: string; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean() + temporary?: boolean = true; } export class UpdateMerchantUserDto { @ApiProperty({ required: false }) + @IsOptional() + @IsString() firstName?: string; @ApiProperty({ required: false }) + @IsOptional() + @IsString() lastName?: string; @ApiProperty({ required: false }) + @IsOptional() + @IsEmail() email?: string; @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() enabled?: boolean; } -export class ResetPasswordDto { - @ApiProperty({ description: 'New password' }) - newPassword: string; - - @ApiProperty({ required: false, default: true }) - temporary?: boolean = true; +export class UpdateMerchantUserRoleDto { + @ApiProperty({ + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + description: 'New role for the user' + }) + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; } export class MerchantUserResponse { @@ -101,10 +170,10 @@ export class MerchantUserResponse { lastName: string; @ApiProperty({ - enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], description: 'User role' }) - role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; + role: UserRole; @ApiProperty({ description: 'Whether the user is enabled' }) enabled: boolean; @@ -112,8 +181,8 @@ export class MerchantUserResponse { @ApiProperty({ description: 'Whether the email is verified' }) emailVerified: boolean; - @ApiProperty({ description: 'Merchant partner ID' }) - merchantPartnerId: string; + @ApiProperty({ required: false, description: 'Merchant partner ID' }) + merchantPartnerId?: string; @ApiProperty({ description: 'User creator ID' }) createdBy: string; @@ -121,150 +190,130 @@ export class MerchantUserResponse { @ApiProperty({ description: 'User creator username' }) createdByUsername: string; + @ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' }) + userType: 'HUB' | 'MERCHANT'; + @ApiProperty({ description: 'Creation timestamp' }) createdTimestamp: number; @ApiProperty({ required: false, description: 'Last login timestamp' }) lastLogin?: number; - - @ApiProperty({ enum: ['MERCHANT'], description: 'User type' }) - userType: 'MERCHANT'; } -export class MerchantUsersStatsResponse { - @ApiProperty({ description: 'Total admin users' }) - totalAdmins: number; +export class UserProfileResponse { + @ApiProperty({ description: 'User ID' }) + id: string; - @ApiProperty({ description: 'Total manager users' }) - totalManagers: number; + @ApiProperty({ description: 'Username' }) + username: string; - @ApiProperty({ description: 'Total support users' }) - totalSupport: number; + @ApiProperty({ description: 'Email address' }) + email: string; - @ApiProperty({ description: 'Total users' }) - totalUsers: number; + @ApiProperty({ description: 'First name' }) + firstName: string; - @ApiProperty({ description: 'Active users count' }) - activeUsers: number; + @ApiProperty({ description: 'Last name' }) + lastName: string; - @ApiProperty({ description: 'Inactive users count' }) - inactiveUsers: number; + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'Client roles', type: [String] }) + clientRoles: string[]; + + @ApiProperty({ required: false, description: 'Merchant partner ID' }) + merchantPartnerId?: string; + + @ApiProperty({ required: false, description: 'User creator ID' }) + createdBy?: string; + + @ApiProperty({ required: false, description: 'User creator username' }) + createdByUsername?: string; } -export class AvailableRolesResponse { - @ApiProperty({ - type: [Object], - description: 'Available roles with permissions' - }) - roles: Array<{ - value: UserRole; - label: string; - description: string; - allowedForCreation: boolean; - }>; +export class MessageResponse { + @ApiProperty({ description: 'Response message' }) + message: string; } -// Mapper function pour convertir MerchantUser en MerchantUserResponse -function mapToMerchantUserResponse(merchantUser: MerchantUser): MerchantUserResponse { +// Mapper functions +function mapToMerchantUserResponse(user: User): MerchantUserResponse { return { - id: merchantUser.id, - username: merchantUser.username, - email: merchantUser.email, - firstName: merchantUser.firstName, - lastName: merchantUser.lastName, - role: merchantUser.role, - enabled: merchantUser.enabled, - emailVerified: merchantUser.emailVerified, - merchantPartnerId: merchantUser.merchantPartnerId, - createdBy: merchantUser.createdBy, - createdByUsername: merchantUser.createdByUsername, - createdTimestamp: merchantUser.createdTimestamp, - lastLogin: merchantUser.lastLogin, - userType: merchantUser.userType, + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + enabled: user.enabled, + emailVerified: user.emailVerified, + merchantPartnerId: user.merchantPartnerId, + createdBy: user.createdBy, + createdByUsername: user.createdByUsername, + userType: user.userType, + createdTimestamp: user.createdTimestamp, + lastLogin: user.lastLogin, }; } +function mapToUserProfileResponse(profile: any): UserProfileResponse { + return { + id: profile.id, + username: profile.username, + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + emailVerified: profile.emailVerified, + enabled: profile.enabled, + clientRoles: profile.clientRoles, + merchantPartnerId: profile.merchantPartnerId, + createdBy: profile.createdBy, + createdByUsername: profile.createdByUsername, + }; +} + +// ===== CONTROLLER POUR LES UTILISATEURS MERCHANT ===== + @ApiTags('Merchant Users') @ApiBearerAuth() @Controller('merchant-users') @Resource(RESOURCES.MERCHANT_USER) export class MerchantUsersController { - constructor(private readonly merchantUsersService: MerchantUsersService) {} + constructor(private readonly usersService: HubUsersService) {} - // ===== RÉCUPÉRATION D'UTILISATEURS ===== + // ===== ROUTES SANS PARAMÈTRES D'ABORD ===== @Get() @ApiOperation({ summary: 'Get merchant users for current user merchant', - description: 'Returns merchant users based on the current user merchant partner ID' + description: 'Returns merchant users. Hub admins/support see all merchants users, others see only their own merchant users.' }) @ApiResponse({ status: 200, description: 'Merchant users retrieved successfully', type: [MerchantUserResponse] }) - @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) - @Resource(RESOURCES.MERCHANT_USER) @Scopes(SCOPES.READ) async getMyMerchantUsers(@Request() req): Promise { const userId = req.user.sub; - // Récupérer le merchantPartnerId de l'utilisateur courant - const userMerchantId = await this.getUserMerchantPartnerId(userId); - if (!userMerchantId) { - throw new BadRequestException('Current user is not associated with a merchant partner'); + try { + const users = await this.usersService.getMyMerchantUsers(userId); + return users.map(mapToMerchantUserResponse); + + } catch (error) { + if (error instanceof BadRequestException || error instanceof ForbiddenException) { + throw error; + } + + throw new InternalServerErrorException('Could not retrieve merchant users'); } - - const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId); - return users.map(mapToMerchantUserResponse); } - @Get('partner/:partnerId') - @ApiOperation({ - summary: 'Get merchant users by partner ID', - description: 'Returns all merchant users for a specific merchant partner' - }) - @ApiResponse({ - status: 200, - description: 'Merchant users retrieved successfully', - type: [MerchantUserResponse] - }) - @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) - @ApiResponse({ status: 404, description: 'Merchant partner not found' }) - @ApiParam({ name: 'partnerId', description: 'Merchant Partner ID' }) - @Resource(RESOURCES.MERCHANT_USER) - @Scopes(SCOPES.READ) - async getMerchantUsersByPartner( - @Param('partnerId', ParseUUIDPipe) partnerId: string, - @Request() req - ): Promise { - const userId = req.user.sub; - const users = await this.merchantUsersService.getMerchantUsersByPartner(partnerId, userId); - return users.map(mapToMerchantUserResponse); - } - - @Get(':id') - @ApiOperation({ summary: 'Get merchant user by ID' }) - @ApiResponse({ - status: 200, - description: 'Merchant user retrieved successfully', - type: MerchantUserResponse - }) - @ApiResponse({ status: 404, description: 'Merchant user not found' }) - @ApiParam({ name: 'id', description: 'Merchant User ID' }) - @Resource(RESOURCES.MERCHANT_USER) - @Scopes(SCOPES.READ) - async getMerchantUserById( - @Param('id', ParseUUIDPipe) id: string, - @Request() req - ): Promise { - const userId = req.user.sub; - const user = await this.merchantUsersService.getMerchantUserById(id, userId); - return mapToMerchantUserResponse(user); - } - - // ===== CRÉATION D'UTILISATEURS ===== - @Post() @ApiOperation({ summary: 'Create a new merchant user', @@ -274,27 +323,86 @@ export class MerchantUsersController { status: 201, description: 'Merchant user created successfully', type: MerchantUserResponse - }) - @ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' }) - @ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' }) - @Resource(RESOURCES.MERCHANT_USER) + }) @Scopes(SCOPES.WRITE) async createMerchantUser( - @Body() createMerchantUserDto: CreateMerchantUserDto, + @Body() createUserDto: CreateMerchantUserDto, @Request() req ): Promise { const userId = req.user.sub; - const userData: CreateMerchantUserData = { - ...createMerchantUserDto, - createdBy: userId, + if (!createUserDto.merchantPartnerId && !createUserDto.role.includes(UserRole.DCB_PARTNER)) { + throw new BadRequestException('merchantPartnerId is required for merchant users except DCB_PARTNER'); + } + + const userData: CreateUserData = { + ...createUserDto, }; - const user = await this.merchantUsersService.createMerchantUser(userId, userData); + const user = await this.usersService.createMerchantUser(userId, userData); return mapToMerchantUserResponse(user); } - // ===== MISE À JOUR D'UTILISATEURS ===== + // ===== ROUTES AVEC PARAMÈTRES STATIQUES AVANT LES PARAMÈTRES DYNAMIQUES ===== + + @Get('profile/:id') + @ApiOperation({ summary: 'Get complete user profile' }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully', + type: UserProfileResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getCompleteUserProfile( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const tokenUser = req.user; + const profile = await this.usersService.getCompleteUserProfile(id, tokenUser); + return mapToUserProfileResponse(profile); + } + + @Get('merchant-partner/:userId') + @ApiOperation({ summary: 'Get merchant partner ID for a user' }) + @ApiResponse({ + status: 200, + description: 'Merchant partner ID retrieved successfully', + schema: { + type: 'object', + properties: { + merchantPartnerId: { type: 'string', nullable: true } + } + } + }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getUserMerchantPartnerId( + @Param('userId', ParseUUIDPipe) userId: string + ): Promise<{ merchantPartnerId: string | null }> { + const merchantPartnerId = await this.usersService.getUserMerchantPartnerId(userId); + return { merchantPartnerId }; + } + + // ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES EN DERNIER ===== + + @Get(':id') + @ApiOperation({ summary: 'Get merchant user by ID' }) + @ApiResponse({ + status: 200, + description: 'Merchant user retrieved successfully', + type: MerchantUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getMerchantUserById( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.getMerchantUserById(id, userId); + return mapToMerchantUserResponse(user); + } @Put(':id') @ApiOperation({ summary: 'Update a merchant user' }) @@ -303,212 +411,69 @@ export class MerchantUsersController { description: 'Merchant user updated successfully', type: MerchantUserResponse }) - @ApiResponse({ status: 404, description: 'Merchant user not found' }) - @ApiParam({ name: 'id', description: 'Merchant User ID' }) - @Resource(RESOURCES.MERCHANT_USER) + @ApiParam({ name: 'id', description: 'User ID' }) @Scopes(SCOPES.WRITE) async updateMerchantUser( @Param('id', ParseUUIDPipe) id: string, - @Body() updateMerchantUserDto: UpdateMerchantUserDto, + @Body() updateUserDto: UpdateMerchantUserDto, @Request() req ): Promise { const userId = req.user.sub; - - // Pour l'instant, on suppose que la mise à jour se fait via Keycloak - // Vous devrez implémenter updateMerchantUser dans le service - throw new BadRequestException('Update merchant user not implemented yet'); + const user = await this.usersService.updateMerchantUser(id, updateUserDto, userId); + return mapToMerchantUserResponse(user); } - // ===== SUPPRESSION D'UTILISATEURS ===== - @Delete(':id') @ApiOperation({ summary: 'Delete a merchant user' }) @ApiResponse({ status: 200, description: 'Merchant user deleted successfully' }) - @ApiResponse({ status: 404, description: 'Merchant user not found' }) - @ApiParam({ name: 'id', description: 'Merchant User ID' }) - @Resource(RESOURCES.MERCHANT_USER) + @ApiParam({ name: 'id', description: 'User ID' }) @Scopes(SCOPES.DELETE) async deleteMerchantUser( @Param('id', ParseUUIDPipe) id: string, @Request() req - ): Promise<{ message: string }> { + ): Promise { const userId = req.user.sub; - - // Vous devrez implémenter deleteMerchantUser dans le service - throw new BadRequestException('Delete merchant user not implemented yet'); + await this.usersService.deleteMerchantUser(id, userId); + return { message: 'Merchant user deleted successfully' }; } - // ===== GESTION DES MOTS DE PASSE ===== + @Put(':id/role') + @ApiOperation({ summary: 'Update merchant user role' }) + @ApiResponse({ + status: 200, + description: 'User role updated successfully', + type: MerchantUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.WRITE) + async updateMerchantUserRole( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateRoleDto: UpdateMerchantUserRoleDto, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.updateMerchantUserRole(id, updateRoleDto.role, userId); + return mapToMerchantUserResponse(user); + } @Post(':id/reset-password') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Reset merchant user password' }) - @ApiResponse({ status: 200, description: 'Password reset successfully' }) - @ApiResponse({ status: 404, description: 'Merchant user not found' }) - @ApiParam({ name: 'id', description: 'Merchant User ID' }) - @Resource(RESOURCES.MERCHANT_USER) + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiParam({ name: 'id', description: 'User ID' }) @Scopes(SCOPES.WRITE) async resetMerchantUserPassword( @Param('id', ParseUUIDPipe) id: string, - @Body() resetPasswordDto: ResetPasswordDto, + @Body() resetPasswordDto: ResetMerchantUserPasswordDto, @Request() req - ): Promise<{ message: string }> { + ): Promise { const userId = req.user.sub; - - // Vous devrez implémenter resetMerchantUserPassword dans le service - throw new BadRequestException('Reset merchant user password not implemented yet'); - } - - // ===== STATISTIQUES ET RAPPORTS ===== - - @Get('stats/overview') - @ApiOperation({ summary: 'Get merchant users statistics overview' }) - @ApiResponse({ - status: 200, - description: 'Statistics retrieved successfully', - type: MerchantUsersStatsResponse - }) - @Resource(RESOURCES.MERCHANT_USER) - @Scopes(SCOPES.READ) - async getMerchantUsersStats(@Request() req): Promise { - const userId = req.user.sub; - - // Récupérer le merchantPartnerId de l'utilisateur courant - const userMerchantId = await this.getUserMerchantPartnerId(userId); - if (!userMerchantId) { - throw new BadRequestException('Current user is not associated with a merchant partner'); - } - - const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId); - - const stats: MerchantUsersStatsResponse = { - totalAdmins: users.filter(user => user.role === UserRole.DCB_PARTNER_ADMIN).length, - totalManagers: users.filter(user => user.role === UserRole.DCB_PARTNER_MANAGER).length, - totalSupport: users.filter(user => user.role === UserRole.DCB_PARTNER_SUPPORT).length, - totalUsers: users.length, - activeUsers: users.filter(user => user.enabled).length, - inactiveUsers: users.filter(user => !user.enabled).length, - }; - - return stats; - } - - @Get('search') - @ApiOperation({ summary: 'Search merchant users' }) - @ApiResponse({ - status: 200, - description: 'Search results retrieved successfully', - type: [MerchantUserResponse] - }) - @ApiQuery({ name: 'query', required: false, description: 'Search query (username, email, first name, last name)' }) - @ApiQuery({ name: 'role', required: false, enum: UserRole, description: 'Filter by role' }) - @ApiQuery({ name: 'enabled', required: false, type: Boolean, description: 'Filter by enabled status' }) - @Resource(RESOURCES.MERCHANT_USER) - @Scopes(SCOPES.READ) - async searchMerchantUsers( - @Request() req, - @Query('query') query?: string, - @Query('role') role?: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT, - @Query('enabled') enabled?: boolean - ): Promise { - const userId = req.user.sub; - - // Récupérer le merchantPartnerId de l'utilisateur courant - const userMerchantId = await this.getUserMerchantPartnerId(userId); - if (!userMerchantId) { - throw new BadRequestException('Current user is not associated with a merchant partner'); - } - - let users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId); - - // Appliquer les filtres - if (query) { - const lowerQuery = query.toLowerCase(); - users = users.filter(user => - user.username.toLowerCase().includes(lowerQuery) || - user.email.toLowerCase().includes(lowerQuery) || - user.firstName.toLowerCase().includes(lowerQuery) || - user.lastName.toLowerCase().includes(lowerQuery) - ); - } - - if (role) { - users = users.filter(user => user.role === role); - } - - if (enabled !== undefined) { - users = users.filter(user => user.enabled === enabled); - } - - return users.map(mapToMerchantUserResponse); - } - - // ===== UTILITAIRES ===== - - @Get('roles/available') - @ApiOperation({ summary: 'Get available merchant roles' }) - @ApiResponse({ - status: 200, - description: 'Available roles retrieved successfully', - type: AvailableRolesResponse - }) - @Resource(RESOURCES.MERCHANT_USER) - @Scopes(SCOPES.READ) - async getAvailableMerchantRoles(@Request() req): Promise { - const userId = req.user.sub; - const userRoles = await this.getUserRoles(userId); - - const isPartner = userRoles.includes(UserRole.DCB_PARTNER); - const isPartnerAdmin = userRoles.includes(UserRole.DCB_PARTNER_ADMIN); - const isHubAdmin = userRoles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role) + await this.usersService.resetUserPassword( + id, + resetPasswordDto.newPassword, + resetPasswordDto.temporary, + userId ); - - const roles = [ - { - value: UserRole.DCB_PARTNER_ADMIN, - label: 'Partner Admin', - description: 'Full administrative access within the merchant partner', - allowedForCreation: isPartner || isHubAdmin - }, - { - value: UserRole.DCB_PARTNER_MANAGER, - label: 'Partner Manager', - description: 'Manager access with limited administrative capabilities', - allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin - }, - { - value: UserRole.DCB_PARTNER_SUPPORT, - label: 'Partner Support', - description: 'Support role with read-only and basic operational access', - allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin - } - ]; - - return { roles }; - } - - // ===== MÉTHODES PRIVÉES D'ASSISTANCE ===== - - private async getUserMerchantPartnerId(userId: string): Promise { - // Implémentez cette méthode pour récupérer le merchantPartnerId de l'utilisateur - // Cela dépend de votre implémentation Keycloak - try { - // Exemple - à adapter selon votre implémentation - const user = await this.merchantUsersService['keycloakApi'].getUserById(userId, userId); - return user.attributes?.merchantPartnerId?.[0] || null; - } catch (error) { - return null; - } - } - - private async getUserRoles(userId: string): Promise { - // Implémentez cette méthode pour récupérer les rôles de l'utilisateur - try { - const roles = await this.merchantUsersService['keycloakApi'].getUserClientRoles(userId); - return roles.map(role => role.name as UserRole); - } catch (error) { - return []; - } + return { message: 'Password reset successfully' }; } } \ No newline at end of file diff --git a/src/hub-users/dto/hub-user.dto.ts b/src/hub-users/dto/hub-user.dto.ts deleted file mode 100644 index f3e66bc..0000000 --- a/src/hub-users/dto/hub-user.dto.ts +++ /dev/null @@ -1,105 +0,0 @@ -// dto/hub-users.dto.ts -import { - IsEmail, - IsEnum, - IsNotEmpty, - IsOptional, - IsBoolean, - IsString, - MinLength, - Matches, - IsUUID, -} from 'class-validator'; -import { UserRole } from '../../auth/services/keycloak-user.model'; - -// Utiliser directement UserRole au lieu de créer un enum local -export class CreateHubUserDto { - @IsNotEmpty() - @IsString() - @MinLength(3) - username: string; - - @IsNotEmpty() - @IsEmail() - email: string; - - @IsNotEmpty() - @IsString() - @MinLength(2) - firstName: string; - - @IsNotEmpty() - @IsString() - @MinLength(2) - lastName: string; - - @IsOptional() - @IsString() - @MinLength(8) - @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { - message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character', - }) - password?: string; - - @IsNotEmpty() - @IsEnum([UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], { - message: 'Role must be either DCB_ADMIN or DCB_SUPPORT', - }) - role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean; -} - -export class UpdateHubUserDto { - @IsOptional() - @IsString() - @MinLength(2) - firstName?: string; - - @IsOptional() - @IsString() - @MinLength(2) - lastName?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; -} - -export class UpdateHubUserRoleDto { - @IsNotEmpty() - @IsEnum([UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], { - message: 'Role must be either DCB_ADMIN or DCB_SUPPORT', - }) - role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT; -} - -export class ResetPasswordDto { - @IsNotEmpty() - @IsString() - @MinLength(8) - @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { - message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character', - }) - password: string; - - @IsOptional() - @IsBoolean() - temporary?: boolean; -} - -export class UserIdParamDto { - @IsNotEmpty() - @IsUUID() - id: string; -} \ No newline at end of file diff --git a/src/hub-users/dto/merchant-users.dto.ts b/src/hub-users/dto/merchant-users.dto.ts deleted file mode 100644 index 385c969..0000000 --- a/src/hub-users/dto/merchant-users.dto.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - IsEmail, - IsEnum, - IsNotEmpty, - IsOptional, - IsBoolean, - IsString, - MinLength, - Matches, - IsUUID, -} from 'class-validator'; -import { UserRole } from '../../auth/services/keycloak-user.model'; - -export class CreateMerchantUserDto { - @IsNotEmpty() - @IsString() - @MinLength(3) - username: string; - - @IsNotEmpty() - @IsEmail() - email: string; - - @IsNotEmpty() - @IsString() - @MinLength(2) - firstName: string; - - @IsNotEmpty() - @IsString() - @MinLength(2) - lastName: string; - - @IsOptional() - @IsString() - @MinLength(8) - @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { - message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character', - }) - password?: string; - - @IsNotEmpty() - @IsEnum([UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]) - role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean; -} - -export class UpdateMerchantUserDto { - @IsOptional() - @IsString() - @MinLength(2) - firstName?: string; - - @IsOptional() - @IsString() - @MinLength(2) - lastName?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; -} - -export class ResetMerchantPasswordDto { - @IsNotEmpty() - @IsString() - @MinLength(8) - @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, { - message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character', - }) - password: string; - - @IsOptional() - @IsBoolean() - temporary?: boolean; -} \ No newline at end of file diff --git a/src/hub-users/hub-users.module.ts b/src/hub-users/hub-users.module.ts index 1dee8bf..7c93b60 100644 --- a/src/hub-users/hub-users.module.ts +++ b/src/hub-users/hub-users.module.ts @@ -4,7 +4,6 @@ import { HttpModule } from '@nestjs/axios'; import { TokenService } from '../auth/services/token.service' import { HubUsersService } from './services/hub-users.service' import { HubUsersController } from './controllers/hub-users.controller' -import { MerchantUsersService } from './services/merchant-users.service' import { MerchantUsersController } from './controllers/merchant-users.controller' import { KeycloakApiService } from '../auth/services/keycloak-api.service'; @@ -14,9 +13,9 @@ import { KeycloakApiService } from '../auth/services/keycloak-api.service'; HttpModule, JwtModule.register({}), ], - providers: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService], - controllers: [HubUsersController, MerchantUsersController], - exports: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService, JwtModule], + providers: [HubUsersService, KeycloakApiService, TokenService], + controllers: [HubUsersController, MerchantUsersController ], + exports: [HubUsersService, KeycloakApiService, TokenService, JwtModule], }) export class HubUsersModule {} diff --git a/src/hub-users/models/hub-user.model.ts b/src/hub-users/models/hub-user.model.ts index 85a8c9e..09f9dcb 100644 --- a/src/hub-users/models/hub-user.model.ts +++ b/src/hub-users/models/hub-user.model.ts @@ -1,23 +1,37 @@ // user.models.ts +// Interfaces et Constantes Centralisées export interface User { id: string; username: string; email: string; firstName: string; lastName: string; + role: UserRole; enabled: boolean; emailVerified: boolean; - userType: UserType; merchantPartnerId?: string; - clientRoles: UserRole[]; - createdBy?: string; - createdByUsername?: string; + createdBy: string; + createdByUsername: string; createdTimestamp: number; + lastLogin?: number; + userType: UserType; +} + +export interface CreateUserData { + username: string; + email: string; + firstName: string; + lastName: string; + password?: string; + role: UserRole; + enabled?: boolean; + emailVerified?: boolean; + merchantPartnerId?: string | null; } export enum UserType { - HUB = 'hub', - MERCHANT_PARTNER = 'merchant_partner' + HUB = 'HUB', + MERCHANT_PARTNER = 'MERCHANT' } export enum UserRole { diff --git a/src/hub-users/services/hub-users.service.ts b/src/hub-users/services/hub-users.service.ts index 8ed1edb..577a47d 100644 --- a/src/hub-users/services/hub-users.service.ts +++ b/src/hub-users/services/hub-users.service.ts @@ -1,68 +1,561 @@ -import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException, InternalServerErrorException } from '@nestjs/common'; import { KeycloakApiService } from '../../auth/services/keycloak-api.service'; -import { CreateUserData, UserRole, KeycloakUser, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model'; // SUPPRIMER import type -import { LoginDto, TokenResponse, User } from '../models/hub-user.model'; +import { CreateUserData as KeycloakCreateUserData, UserRole, KeycloakUser, LoginDto, TokenResponse, KeycloakRole } from '../../auth/services/keycloak-user.model'; +import { CreateUserData, User, UserType } from '../models/hub-user.model'; + +// Configuration Centralisée +const SECURITY_CONFIG = { + ROLES: { + HUB: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + MERCHANT: [ + UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, + UserRole.DCB_PARTNER_SUPPORT, + ] + }, + VALIDATION: { + MIN_PASSWORD_LENGTH: 8, + MAX_USERNAME_LENGTH: 50, + MIN_USERNAME_LENGTH: 3 + } +}; -// ===== SERVICE PRINCIPAL ===== @Injectable() export class HubUsersService { private readonly logger = new Logger(HubUsersService.name); - // Définir les rôles Hub autorisés comme constante - private readonly HUB_ROLES = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER]; - constructor(private readonly keycloakApi: KeycloakApiService) {} - // === AUTHENTIFICATION UTILISATEUR === + // === PUBLIC INTERFACE === + async authenticateUser(loginDto: LoginDto): Promise { return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password); } - // ===== VALIDATION ET UTILITAIRES ===== - private isValidHubRole(role: UserRole): role is UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER { - return this.HUB_ROLES.includes(role); + async getCompleteUserProfile(userId: string, tokenUser: any) { + try { + const [userDetails, userRoles] = await Promise.all([ + this.keycloakApi.getUserById(userId, userId), + this.keycloakApi.getUserClientRoles(userId) + ]); + + return this.buildUserProfile(userId, tokenUser, userDetails, userRoles); + } catch (error) { + throw new InternalServerErrorException('Could not retrieve user profile'); + } } - private validateHubRole(role: UserRole): void { - if (!this.isValidHubRole(role)) { - throw new BadRequestException( - `Invalid hub role: ${role}. Hub roles must be DCB_ADMIN or DCB_SUPPORT` + // === HUB USERS MANAGEMENT === + + async getAllHubUsers(requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + return this.processUsersByType(await this.keycloakApi.getAllUsers(), UserType.HUB); + } + + /** + * Récupère uniquement les utilisateurs DCB_PARTNER + */ + async getAllDcbPartners(requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + + const allUsers = await this.keycloakApi.getAllUsers(); + const dcbPartners: User[] = []; + + for (const user of allUsers) { + if (!user.id) continue; + + try { + const userRoles = await this.keycloakApi.getUserClientRoles(user.id); + + // Vérifier si l'utilisateur est un DCB_PARTNER + const isDcbPartner = userRoles.some(role => + role.name === UserRole.DCB_PARTNER + ); + + if (isDcbPartner) { + const mappedUser = this.mapToUser(user, userRoles); + dcbPartners.push(mappedUser); + } + } catch (error) { + this.logger.warn(`Could not process user ${user.id} for DCB_PARTNER filter: ${error.message}`); + } + } + + this.logger.log(`Retrieved ${dcbPartners.length} DCB_PARTNER users`); + + return dcbPartners; + } + + async getHubUserById(userId: string, requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + return this.getValidatedUser(userId, requesterId, UserType.HUB); + } + + async createHubUser(creatorId: string, userData: CreateUserData): Promise { + this.validateUserCreationData(userData, UserType.HUB); + await this.validateHubUserAccess(creatorId); + await this.validateUserUniqueness(userData.username, userData.email); + + const keycloakUserData = this.buildKeycloakUserData(userData); + const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData); + + this.logger.log(`Hub user created: ${userData.username}`); + return this.getHubUserById(userId, creatorId); + } + + async updateHubUser( + userId: string, + updates: Partial>, + requesterId: string + ): Promise { + await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => { + await this.keycloakApi.updateUser(userId, updates, requesterId); + }); + return this.getHubUserById(userId, requesterId); + } + + async deleteHubUser(userId: string, requesterId: string): Promise { + await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => { + await this.keycloakApi.deleteUser(userId, requesterId); + }); + this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`); + } + + async getHubUsersByRole(role: UserRole, requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + const allHubUsers = await this.getAllHubUsers(requesterId); + return allHubUsers.filter(user => user.role === role); + } + + async updateHubUserRole(userId: string, newRole: UserRole, requesterId: string): Promise { + await this.validateRoleChangePermission(requesterId); + await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => { + await this.keycloakApi.setClientRoles(userId, [newRole]); + }); + return this.getHubUserById(userId, requesterId); + } + + // === MERCHANT USERS MANAGEMENT === + + /** + * Vérifie si un utilisateur est Hub Admin ou Support + */ + async isUserHubAdminOrSupport(userId: string): Promise { + try { + const userRoles = await this.keycloakApi.getUserClientRoles(userId); + const hubAdminSupportRoles = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT]; + + return userRoles.some(role => + hubAdminSupportRoles.includes(role.name as UserRole) ); + } catch (error) { + this.logger.error(`Error checking Hub Admin/Support status for user ${userId}:`, error); + return false; } } - validateHubRoleFromString(role: string): UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER { - // Vérifier si le rôle est valide - if (!Object.values(UserRole).includes(role as UserRole)) { - throw new BadRequestException(`Invalid role: ${role}`); + /** + * Récupère les utilisateurs marchands selon les permissions de l'utilisateur + * - Hub Admin/Support: tous les utilisateurs marchands de tous les merchants + * - Autres: seulement les utilisateurs de leur propre merchant + */ + async getMyMerchantUsers(userId: string): Promise { + try { + // Vérifier si l'utilisateur est un admin ou support Hub + const isHubAdminOrSupport = await this.isUserHubAdminOrSupport(userId) + + if (isHubAdminOrSupport) { + // Hub Admin/Support peuvent voir TOUS les utilisateurs marchands + return await this.getAllMerchantUsersForHubAdmin(userId); + } + + // Pour les autres utilisateurs (DCB_PARTNER, DCB_PARTNER_ADMIN, etc.) + return await this.getMerchantUsersForRegularUser(userId); + + } catch (error) { + this.logger.error(`Error in getMyMerchantUsers for user ${userId}:`, error); + throw error; + } + } + + /** + * Récupère TOUS les utilisateurs marchands pour les Hub Admin/Support + */ + private async getAllMerchantUsersForHubAdmin(adminUserId: string): Promise { + this.logger.log(`Hub Admin/Support ${adminUserId} accessing ALL merchant users`); + + // Valider que l'utilisateur a bien les droits Hub + await this.validateHubUserAccess(adminUserId); + + // Récupérer tous les utilisateurs du système + const allUsers = await this.keycloakApi.getAllUsers(); + const merchantUsers: User[] = []; + + // Filtrer pour ne garder que les utilisateurs marchands + for (const user of allUsers) { + if (!user.id) continue; + + try { + const userRoles = await this.keycloakApi.getUserClientRoles(user.id); + + // Vérifier si l'utilisateur a un rôle marchand + const hasMerchantRole = userRoles.some(role => + [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT] + .includes(role.name as UserRole) + ); + + if (hasMerchantRole) { + const mappedUser = this.mapToUser(user, userRoles); + merchantUsers.push(mappedUser); + } + } catch (error) { + this.logger.warn(`Could not process user ${user.id} for hub admin view: ${error.message}`); + continue; + } } - const userRole = role as UserRole; - if (!this.isValidHubRole(userRole)) { - throw new BadRequestException( - `Invalid hub role: ${role}. Must be DCB_ADMIN or DCB_SUPPORT` - ); - } - return userRole; + this.logger.log(`Hub Admin/Support retrieved ${merchantUsers.length} merchant users from all merchants`); + return merchantUsers; } + /** + * Récupère les utilisateurs marchands pour les utilisateurs réguliers (non Hub Admin/Support) + */ + private async getMerchantUsersForRegularUser(userId: string): Promise { + // Récupérer le merchantPartnerId de l'utilisateur + const userMerchantId = await this.getUserMerchantPartnerId(userId); + + if (!userMerchantId) { + throw new BadRequestException('Current user is not associated with a merchant partner'); + } + + this.logger.log(`User ${userId} accessing merchant users for partner ${userMerchantId}`); + + // Utiliser la méthode existante pour récupérer les utilisateurs du merchant spécifique + const users = await this.getMerchantUsersByPartner(userMerchantId, userId); + + this.logger.log(`User ${userId} retrieved ${users.length} merchant users for partner ${userMerchantId}`); + return users; + } + + async getMerchantUsersByPartner(merchantPartnerId: string, requesterId: string): Promise { + await this.keycloakApi.validateUserAccess(requesterId, merchantPartnerId); + const allUsers = await this.keycloakApi.getAllUsers(); + + const merchantUsers = allUsers.filter(user => + user.attributes?.merchantPartnerId?.[0] === merchantPartnerId + ); + + return this.processUsersByType(merchantUsers, UserType.MERCHANT_PARTNER); + } + + async getMerchantUserById(userId: string, requesterId: string): Promise { + return this.getValidatedUser(userId, requesterId, UserType.MERCHANT_PARTNER); + } + + async createMerchantUser(creatorId: string, userData: CreateUserData): Promise { + this.validateUserCreationData(userData, UserType.MERCHANT_PARTNER); + await this.validateMerchantUserCreation(creatorId, userData); + await this.validateUserUniqueness(userData.username, userData.email); + + const keycloakUserData = this.buildKeycloakUserData(userData, userData.merchantPartnerId!); + const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData); + + this.logger.log(`Merchant user created: ${userData.username}`); + return this.getMerchantUserById(userId, creatorId); + } + + async updateMerchantUser( + userId: string, + updates: Partial>, + requesterId: string + ): Promise { + await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => { + await this.keycloakApi.updateUser(userId, updates, requesterId); + }); + return this.getMerchantUserById(userId, requesterId); + } + + async updateMerchantUserRole(userId: string, newRole: UserRole, requesterId: string): Promise { + await this.validateRoleChangePermission(requesterId); + await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => { + await this.keycloakApi.setClientRoles(userId, [newRole]); + }); + return this.getMerchantUserById(userId, requesterId); + } + + async deleteMerchantUser(userId: string, requesterId: string): Promise { + await this.validateSelfDeletion(userId, requesterId); + await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => { + await this.keycloakApi.deleteUser(userId, requesterId); + }); + this.logger.log(`Merchant user deleted: ${userId} by ${requesterId}`); + } + + // === COMMON OPERATIONS === + + async resetUserPassword( + userId: string, + newPassword: string, + temporary: boolean = true, + requesterId: string + ): Promise { + await this.ensureUserExists(userId, requesterId); + await this.keycloakApi.resetUserPassword(userId, newPassword, temporary); + this.logger.log(`Password reset for user: ${userId}`); + } + + async getUserMerchantPartnerId(userId: string): Promise { + return this.keycloakApi.getUserMerchantPartnerId(userId); + } + + // === PRIVATE CORE METHODS === + + private async getValidatedUser( + userId: string, + requesterId: string, + userType: UserType.HUB | UserType.MERCHANT_PARTNER + ): Promise { + const [user, userRoles] = await Promise.all([ + this.keycloakApi.getUserById(userId, requesterId), + this.keycloakApi.getUserClientRoles(userId) + ]); + + this.validateUserType(userRoles, userType, userId); + return this.mapToUser(user, userRoles); + } + + private async processUsersByType(users: KeycloakUser[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): Promise { + const result: User[] = []; + + for (const user of users) { + if (!user.id) continue; + + try { + const userRoles = await this.keycloakApi.getUserClientRoles(user.id); + if (this.isUserType(userRoles, userType)) { + result.push(this.mapToUser(user, userRoles)); + } + } catch (error) { + this.logger.warn(`Could not process user ${user.id}: ${error.message}`); + } + } + + return result; + } + + private async executeWithValidation( + userId: string, + requesterId: string, + userType: UserType.HUB | UserType.MERCHANT_PARTNER, + operation: () => Promise + ): Promise { + await this.getValidatedUser(userId, requesterId, userType); + await operation(); + } + + // === USER MAPPING AND VALIDATION === + + private mapToUser(user: KeycloakUser, roles: KeycloakRole[]): User { + if (!user.id || !user.email) { + throw new Error('User ID and email are required'); + } + + const role = this.determineUserRole(roles); + const userType = this.determineUserType(roles); + + return { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName || '', + lastName: user.lastName || '', + role, + enabled: user.enabled, + emailVerified: user.emailVerified, + merchantPartnerId: user.attributes?.merchantPartnerId?.[0], + createdBy: user.attributes?.createdBy?.[0] || 'unknown', + createdByUsername: user.attributes?.createdByUsername?.[0] || 'unknown', + createdTimestamp: user.createdTimestamp || Date.now(), + lastLogin: this.parseTimestamp(user.attributes?.lastLogin), + userType, + }; + } + + private determineUserRole(roles: KeycloakRole[]): UserRole { + const allRoles = [...SECURITY_CONFIG.ROLES.HUB, ...SECURITY_CONFIG.ROLES.MERCHANT]; + const userRole = roles.find(role => allRoles.includes(role.name as UserRole)); + + if (!userRole) { + throw new Error('No valid role found for user'); + } + + return userRole.name as UserRole; + } + + private determineUserType(roles: KeycloakRole[]): UserType { + return roles.some(role => SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole)) + ? UserType.HUB + : UserType.MERCHANT_PARTNER; + } + + private isUserType(roles: KeycloakRole[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): boolean { + const targetRoles = userType === UserType.HUB + ? SECURITY_CONFIG.ROLES.HUB + : SECURITY_CONFIG.ROLES.MERCHANT; + + return roles.some(role => targetRoles.includes(role.name as UserRole)); + } + + private validateUserType(roles: KeycloakRole[], expectedType: UserType.HUB | UserType.MERCHANT_PARTNER, userId: string): void { + if (!this.isUserType(roles, expectedType)) { + throw new BadRequestException(`User ${userId} is not a ${expectedType.toLowerCase()} user`); + } + } + + // === VALIDATION METHODS === + private async validateHubUserAccess(requesterId: string): Promise { const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); - const isHubAdmin = requesterRoles.some(role => - this.HUB_ROLES.includes(role.name as UserRole) + const hasHubAccess = requesterRoles.some(role => + SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole) ); - if (!isHubAdmin) { + if (!hasHubAccess) { throw new ForbiddenException('Only hub administrators can manage hub users'); } } + - private parseKeycloakAttribute(value: string[] | undefined): string | undefined { - return value?.[0]; + private validateUserCreationData(userData: CreateUserData, userType: UserType.HUB | UserType.MERCHANT_PARTNER): void { + // 🔍 DEBUG COMPLET + this.logger.debug('🔍 === VALIDATION USER CREATION DATA ==='); + this.logger.debug('UserType:', userType); + this.logger.debug('UserData complet:', JSON.stringify(userData, null, 2)); + this.logger.debug('userData.role:', userData.role); + this.logger.debug('Type de role:', typeof userData.role); + this.logger.debug('Est un tableau?:', Array.isArray(userData.role)); + this.logger.debug('Valeur brute role:', userData.role); + + // Afficher les rôles valides configurés + const validRoles = userType === UserType.HUB + ? SECURITY_CONFIG.ROLES.HUB + : SECURITY_CONFIG.ROLES.MERCHANT; + + this.logger.debug('Rôles valides pour', userType, ':', validRoles); + this.logger.debug('merchantPartnerId:', userData.merchantPartnerId); + this.logger.debug('===================================='); + + // Validation des rôles + if (!validRoles.includes(userData.role)) { + console.error(`❌ Rôle invalide: ${userData.role} pour le type ${userType}`); + console.error(`Rôles autorisés: ${validRoles.join(', ')}`); + throw new BadRequestException(`Invalid ${userType.toLowerCase()} role: ${userData.role}`); + } + + // Validation merchantPartnerId pour HUB + if (userType === UserType.HUB && userData.merchantPartnerId) { + console.error('❌ merchantPartnerId fourni pour un utilisateur HUB'); + throw new BadRequestException('merchantPartnerId should not be provided for hub users'); + } + + // Validation merchantPartnerId pour MERCHANT + // Vérifier d'abord si role est un tableau ou une valeur simple + const isDCBPartner = Array.isArray(userData.role) + ? userData.role.includes(UserRole.DCB_PARTNER) + : userData.role === UserRole.DCB_PARTNER; + + this.logger.debug('Est DCB_PARTNER?:', isDCBPartner); + + if (userType === UserType.MERCHANT_PARTNER && !userData.merchantPartnerId && !isDCBPartner) { + console.error('❌ merchantPartnerId manquant pour un utilisateur MERCHANT'); + throw new BadRequestException('merchantPartnerId is required for merchant users'); + } + + this.logger.debug('✅ Validation réussie'); } - private parseKeycloakTimestamp(value: string[] | undefined): number | undefined { - const strValue = this.parseKeycloakAttribute(value); + private async validateUserUniqueness(username: string, email: string): Promise { + const [existingUsers, existingEmails] = await Promise.all([ + this.keycloakApi.findUserByUsername(username), + this.keycloakApi.findUserByEmail(email) + ]); + + if (existingUsers.length > 0) { + throw new BadRequestException(`User with username ${username} already exists`); + } + if (existingEmails.length > 0) { + throw new BadRequestException(`User with email ${email} already exists`); + } + } + + private async validateRoleChangePermission(requesterId: string): Promise { + const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); + const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN || UserRole.DCB_PARTNER || UserRole.DCB_PARTNER_ADMIN); + + if (!isRequesterAdmin) { + throw new ForbiddenException('Only DCB_ADMIN can change user roles'); + } + } + + private async validateSelfDeletion(userId: string, requesterId: string): Promise { + if (userId === requesterId) { + throw new BadRequestException('Cannot delete your own account'); + } + } + + private async ensureUserExists(userId: string, requesterId: string): Promise { + try { + await this.getHubUserById(userId, requesterId); + } catch { + try { + await this.getMerchantUserById(userId, requesterId); + } catch { + throw new NotFoundException(`User ${userId} not found`); + } + } + } + + private buildUserProfile( + userId: string, + tokenUser: any, + userDetails: KeycloakUser, + userRoles: KeycloakRole[] + ) { + return { + id: userId, + username: tokenUser.preferred_username, + email: tokenUser.email, + firstName: tokenUser.given_name, + lastName: tokenUser.family_name, + emailVerified: tokenUser.email_verified, + enabled: userDetails.enabled, + clientRoles: userRoles.map(role => role.name), + merchantPartnerId: userDetails.attributes?.merchantPartnerId?.[0], + createdBy: userDetails.attributes?.createdBy?.[0], + createdByUsername: userDetails.attributes?.createdByUsername?.[0] + }; + } + + private buildKeycloakUserData( + userData: CreateUserData, + merchantPartnerId?: string + ): KeycloakCreateUserData { + return { + username: userData.username, + email: userData.email, + firstName: userData.firstName, + lastName: userData.lastName, + password: userData.password, + enabled: userData.enabled ?? true, + emailVerified: userData.emailVerified ?? false, + merchantPartnerId, + clientRoles: [userData.role] + }; + } + + private parseTimestamp(value: string[] | undefined): number | undefined { + const strValue = value?.[0]; if (!strValue) return undefined; const timestamp = parseInt(strValue); @@ -72,482 +565,52 @@ export class HubUsersService { return isNaN(date.getTime()) ? undefined : date.getTime(); } - private mapToHubUser(user: KeycloakUser, roles: any[]): HubUser { - const hubRole = roles.find(role => this.isValidHubRole(role.name as UserRole)); + private async validateMerchantUserCreation(creatorId: string, userData: CreateUserData): Promise { + const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId); + const creationRules = this.getMerchantCreationRules(); - if (!user.id) throw new Error('User ID is required'); - if (!user.email) throw new Error('User email is required'); - - return { - id: user.id, - username: user.username, - email: user.email, - firstName: user.firstName || '', - lastName: user.lastName || '', - role: hubRole?.name as UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER, - enabled: user.enabled, - emailVerified: user.emailVerified, - createdBy: this.parseKeycloakAttribute(user.attributes?.createdBy) || 'unknown', - createdByUsername: this.parseKeycloakAttribute(user.attributes?.createdByUsername) || 'unknown', - createdTimestamp: user.createdTimestamp || Date.now(), - lastLogin: this.parseKeycloakTimestamp(user.attributes?.lastLogin), - userType: 'HUB', - attributes: user.attributes, - }; - } - - // ===== OPÉRATIONS CRUD ===== - async createHubUser(creatorId: string, userData: CreateHubUserData): Promise { - this.logger.log(`Creating hub user: ${userData.username} with role: ${userData.role}`); - - this.validateHubRole(userData.role); - await this.validateHubUserAccess(creatorId); - - // Vérifier les doublons - this.checkDuplicateHubUser(userData) - - const keycloakUserData: CreateUserData = { - username: userData.username, - email: userData.email, - firstName: userData.firstName, - lastName: userData.lastName, - password: userData.password, - enabled: userData.enabled ?? true, - emailVerified: userData.emailVerified ?? false, - merchantPartnerId: undefined, - clientRoles: [userData.role], - createdBy: creatorId, - initialStatus: 'PENDING_ACTIVATION', - }; - - const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData); - const createdUser = await this.getHubUserById(userId, creatorId); - - this.logger.log(`Hub user created successfully: ${userData.username} (ID: ${userId})`); - return createdUser; - } - - private async checkDuplicateHubUser(merchantData: CreateHubUserData): Promise { - const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.username); - if (existingUsers.length > 0) { - throw new BadRequestException(`Merchant partner with username ${merchantData.username} already exists`); - } - - const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.email); - if (existingEmails.length > 0) { - throw new BadRequestException(`Merchant partner with email ${merchantData.email} already exists`); - } - } - - // ===== RÉCUPÉRATION DE TOUS LES MERCHANTS (DCB_PARTNER) ===== - async getAllMerchants(requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - - const allUsers = await this.keycloakApi.getAllUsers(); - const merchants: HubUser[] = []; - - for (const user of allUsers) { - if (!user.id) continue; - - try { - const userRoles = await this.keycloakApi.getUserClientRoles(user.id); - const isMerchant = userRoles.some( - role => role.name === UserRole.DCB_PARTNER || UserRole.DCB_PARTNER_ADMIN || UserRole.DCB_PARTNER_MANAGER || UserRole.DCB_PARTNER_SUPPORT); - - if (isMerchant) { - merchants.push(this.mapToHubUser(user, userRoles)); - } - } catch (error) { - this.logger.warn(`Could not process merchant ${user.id}: ${error.message}`); + for (const rule of creationRules) { + if (creatorRoles.some(role => role.name === rule.role)) { + await rule.validator(creatorId, userData); + return; } } - return merchants; - } - - async suspendMerchantPartner(merchantId: string, reason: string, requesterId: string): Promise { - await this.validateHubAccess(requesterId); - - const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId); - - // Suspendre le merchant et tous ses utilisateurs - await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, { - merchantStatus: ['SUSPENDED'], - suspensionReason: [reason], - suspendedAt: [new Date().toISOString()], - }); - - // Suspendre tous les utilisateurs du merchant - await this.suspendAllMerchantUsers(merchantId, requesterId); - - this.logger.log(`Merchant partner suspended: ${merchantId}, reason: ${reason}`); - } - - async getMerchantPartnerById(merchantId: string, requesterId: string): Promise { - await this.validateMerchantAccess(requesterId, merchantId); - - const merchantPartner = await this.findDcbPartnerByMerchantId(merchantId); - - const merchantPartnerRoles = await this.keycloakApi.getUserClientRoles(merchantId); - - return this.mapToHubUser(merchantPartner, merchantPartnerRoles); - } - - // ===== STATISTIQUES ===== - async getMerchantStats(requesterId: string): Promise { - await this.validateHubAccess(requesterId); - - const allMerchants = await this.getAllMerchants(requesterId); - const allUsers = await this.keycloakApi.getAllUsers(); - - let totalUsers = 0; - for (const merchant of allMerchants) { - const merchantUsers = allUsers.filter(user => - user.attributes?.merchantPartnerId?.[0] === merchant.id - ); - totalUsers += merchantUsers.length; - } - - return { - totalMerchants: allMerchants.length, - activeMerchants: allMerchants.filter(m => m.enabled).length, - suspendedMerchants: allMerchants.filter(m => !m.enabled).length, - pendingMerchants: allMerchants.filter(m => m.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION' - ).length, - totalUsers, - }; - } - - private async validateHubAccess(requesterId: string): Promise { - const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); - const isHubAdmin = requesterRoles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) - ); - - if (!isHubAdmin) { - throw new ForbiddenException('Only hub administrators can access this resource'); - } - } - - private async validateMerchantAccess(requesterId: string, merchantId?: string): Promise { - const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); - - this.logger.debug(`Validating merchant access: requester=${requesterId}, merchant=${merchantId}`); - this.logger.debug(`Requester roles: ${requesterRoles.map(r => r.name).join(', ')}`); - - // Les admins Hub ont accès complet - const isHubAdmin = requesterRoles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) - ); - - if (isHubAdmin) { - this.logger.debug('Hub admin access granted'); + // Vérifier les permissions des administrateurs Hub + if (creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) { return; } - // CORRECTION: Service account est considéré comme admin hub - if (requesterId.includes('service-account')) { - this.logger.debug('Service account access granted'); - return; - } - - // Les DCB_PARTNER n'ont accès qu'à leur propre merchant - if (requesterRoles.some(role => role.name === UserRole.DCB_PARTNER)) { - if (merchantId && requesterId === merchantId) { - this.logger.debug('DCB_PARTNER access to own merchant granted'); - return; // DCB_PARTNER accède à son propre merchant - } - throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner'); - } - - // Les autres rôles merchants n'ont accès qu'à leur merchant - const requesterMerchantId = await this.keycloakApi.getUserMerchantPartnerId(requesterId); - if (requesterMerchantId && merchantId && requesterMerchantId === merchantId) { - this.logger.debug('Merchant user access to own merchant granted'); - return; - } - - throw new ForbiddenException('Insufficient permissions to access this merchant'); + throw new ForbiddenException('Insufficient permissions to create merchant users'); } - private async validateMerchantManagementPermissions(requesterId: string, merchantId: string): Promise { - const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); - - // Seuls les admins Hub peuvent modifier les merchants - const isHubAdmin = requesterRoles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) - ); - - if (!isHubAdmin) { - throw new ForbiddenException('Only hub administrators can manage merchant partners'); - } - } - - private async findDcbPartnerByMerchantId(merchantId: string): Promise { - const users = await this.keycloakApi.findUserByUsername(merchantId); - if (users.length === 0) { - throw new NotFoundException(`Merchant partner not found: ${merchantId}`); - } - - const user = users[0]; - const userRoles = await this.keycloakApi.getUserClientRoles(user.id!); - const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER); - - if (!isDcbPartner) { - throw new NotFoundException(`User ${merchantId} is not a DCB_PARTNER`); - } - - return user; - } - - private async suspendAllMerchantUsers(merchantId: string, requesterId: string): Promise { - const allUsers = await this.keycloakApi.getAllUsers(); - const merchantUsers = allUsers.filter(user => - user.attributes?.merchantPartnerId?.[0] === merchantId - ); - - for (const user of merchantUsers) { - if (user.id) { - try { - await this.keycloakApi.updateUser(user.id, { enabled: false }, requesterId); - } catch (error) { - this.logger.warn(`Could not suspend user ${user.id}: ${error.message}`); + private getMerchantCreationRules() { + return [ + { + role: UserRole.DCB_PARTNER, + validator: async (creatorId: string, userData: CreateUserData) => { + if (creatorId !== userData.merchantPartnerId) { + throw new ForbiddenException('DCB_PARTNER can only create users for their own merchant'); + } + const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]; + if (!allowedRoles.includes(userData.role)) { + throw new ForbiddenException('DCB_PARTNER can only create MANAGER and SUPPORT roles'); + } + } + }, + { + role: UserRole.DCB_PARTNER_ADMIN, + validator: async (creatorId: string, userData: CreateUserData) => { + const creatorMerchantId = await this.keycloakApi.getUserMerchantPartnerId(creatorId); + if (creatorMerchantId !== userData.merchantPartnerId) { + throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner'); + } + const allowedRoles = [UserRole.DCB_PARTNER_SUPPORT]; + if (!allowedRoles.includes(userData.role)) { + throw new ForbiddenException('DCB_PARTNER_ADMIN can only create SUPPORT roles'); + } } } - } - } - - async getHubUserById(userId: string, requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - - const [user, userRoles] = await Promise.all([ - this.keycloakApi.getUserById(userId, requesterId), - this.keycloakApi.getUserClientRoles(userId) - ]); - - const isHubUser = userRoles.some(role => this.isValidHubRole(role.name as UserRole)); - if (!isHubUser) { - throw new BadRequestException(`User ${userId} is not a hub user`); - } - - return this.mapToHubUser(user, userRoles); - } - - async getAllHubUsers(requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - - const allUsers = await this.keycloakApi.getAllUsers(); - const hubUsers: HubUser[] = []; - - for (const user of allUsers) { - if (!user.id) continue; - - try { - const userRoles = await this.keycloakApi.getUserClientRoles(user.id); - const hubRole = userRoles.find(role => this.isValidHubRole(role.name as UserRole)); - - if (hubRole) { - hubUsers.push(this.mapToHubUser(user, userRoles)); - } - } catch (error) { - this.logger.warn(`Could not process user ${user.id}: ${error.message}`); - } - } - - return hubUsers; - } - - async getHubUsersByRole(role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER, requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - - const allHubUsers = await this.getAllHubUsers(requesterId); - return allHubUsers.filter(user => user.role === role); - } - - async updateHubUser( - userId: string, - updates: Partial<{ - firstName: string; - lastName: string; - email: string; - enabled: boolean; - }>, - requesterId: string - ): Promise { - await this.validateHubUserAccess(requesterId); - await this.getHubUserById(userId, requesterId); - - await this.keycloakApi.updateUser(userId, updates, requesterId); - return this.getHubUserById(userId, requesterId); - } - - async updateHubUserRole( - userId: string, - newRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT |UserRole.DCB_PARTNER, - requesterId: string - ): Promise { - // Validation implicite via le typage - await this.validateHubUserAccess(requesterId); - - const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); - const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN); - - if (!isRequesterAdmin) { - throw new ForbiddenException('Only DCB_ADMIN can change user roles'); - } - - await this.getHubUserById(userId, requesterId); - await this.keycloakApi.setClientRoles(userId, [newRole]); - - return this.getHubUserById(userId, requesterId); - } - - async updateMerchantPartner( - merchantId: string, - updates: Partial<{ - firstName: string; - lastName: string; - email: string; - enabled: boolean; - }>, - requesterId: string - ): Promise { - await this.validateHubUserAccess(requesterId); - await this.getHubUserById(merchantId, requesterId); - - await this.validateMerchantManagementPermissions(requesterId, merchantId); - - return this.getMerchantPartnerById(merchantId, requesterId); - } - - async deleteHubUser(userId: string, requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - await this.getHubUserById(userId, requesterId); - - if (userId === requesterId) { - throw new BadRequestException('Cannot delete your own account'); - } - - if (await this.isLastAdmin(userId)) { - throw new BadRequestException('Cannot delete the last DCB_ADMIN user'); - } - - await this.keycloakApi.deleteUser(userId, requesterId); - this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`); - } - - // ===== GESTION DES MOTS DE PASSE ===== - async resetHubUserPassword( - userId: string, - newPassword: string, - temporary: boolean = true, - requesterId: string - ): Promise { - await this.validateHubUserAccess(requesterId); - await this.getHubUserById(userId, requesterId); - - await this.keycloakApi.resetUserPassword(userId, newPassword, temporary); - this.logger.log(`Password reset for hub user: ${userId}`); - } - - async sendHubUserPasswordResetEmail(userId: string, requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - const user = await this.getHubUserById(userId, requesterId); - - await this.keycloakApi.sendPasswordResetEmail(user.email); - this.logger.log(`Password reset email sent to hub user: ${user.email}`); - } - - // ===== STATISTIQUES ET RAPPORTS ===== - async getHubUsersStats(requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - const allHubUsers = await this.getAllHubUsers(requesterId); - - return { - totalAdmins: allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length, - totalSupport: allHubUsers.filter(user => user.role === UserRole.DCB_SUPPORT).length, - activeUsers: allHubUsers.filter(user => user.enabled).length, - inactiveUsers: allHubUsers.filter(user => !user.enabled).length, - pendingActivation: allHubUsers.filter(user => - user.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION' - ).length, - }; - } - - async getHubUserActivity(requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - const allHubUsers = await this.getAllHubUsers(requesterId); - - return allHubUsers.map(user => ({ - user, - lastLogin: user.lastLogin ? new Date(user.lastLogin) : undefined - })); - } - - async getActiveHubSessions(requesterId: string): Promise<{ userId: string; username: string; lastAccess: Date }[]> { - const activity = await this.getHubUserActivity(requesterId); - - return activity - .filter(item => item.lastLogin) - .map(item => ({ - userId: item.user.id, - username: item.user.username, - lastAccess: item.lastLogin! - })); - } - - // ===== SANTÉ ET VALIDATIONS ===== - async checkHubUsersHealth(requesterId: string): Promise { - await this.validateHubUserAccess(requesterId); - const stats = await this.getHubUsersStats(requesterId); - const issues: string[] = []; - - if (stats.totalAdmins === 0) { - issues.push('No DCB_ADMIN users found - system is vulnerable'); - } - if (stats.totalAdmins === 1) { - issues.push('Only one DCB_ADMIN user exists - consider creating backup administrators'); - } - if (stats.inactiveUsers > stats.activeUsers / 2) { - issues.push('More than half of hub users are inactive'); - } - if (stats.pendingActivation > 5) { - issues.push('Many users are pending activation - review onboarding process'); - } - - const status = issues.length === 0 ? 'healthy' : issues.length <= 2 ? 'degraded' : 'unhealthy'; - - return { status, issues, stats }; - } - - private async isLastAdmin(userId: string): Promise { - try { - const [user, userRoles] = await Promise.all([ - this.keycloakApi.getUserById(userId, userId), - this.keycloakApi.getUserClientRoles(userId) - ]); - - const isAdmin = userRoles.some(role => role.name === UserRole.DCB_ADMIN); - if (!isAdmin) return false; - - const allHubUsers = await this.getAllHubUsers(userId); - const adminCount = allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length; - - return adminCount <= 1; - } catch (error) { - this.logger.error(`Error checking if user is last admin: ${error.message}`); - return false; - } - } - - async canUserManageHubUsers(userId: string): Promise { - try { - const roles = await this.keycloakApi.getUserClientRoles(userId); - return roles.some(role => - this.HUB_ROLES.includes(role.name as UserRole) - ); - } catch (error) { - return false; - } + ]; } } \ No newline at end of file diff --git a/src/hub-users/services/merchant-users.service.ts b/src/hub-users/services/merchant-users.service.ts deleted file mode 100644 index 651be94..0000000 --- a/src/hub-users/services/merchant-users.service.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; -import { KeycloakApiService } from '../../auth/services/keycloak-api.service'; -import { CreateUserData, UserRole, KeycloakUser } from '../../auth/services/keycloak-user.model'; - -export interface MerchantUser { - id: string; - username: string; - email: string; - firstName: string; - lastName: string; - role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; - enabled: boolean; - emailVerified: boolean; - merchantPartnerId: string; - createdBy: string; - createdByUsername: string; - createdTimestamp: number; - lastLogin?: number; - userType: 'MERCHANT'; -} - -export interface CreateMerchantUserData { - username: string; - email: string; - firstName: string; - lastName: string; - password?: string; - role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; - enabled?: boolean; - emailVerified?: boolean; - merchantPartnerId: string; - createdBy: string; -} - -@Injectable() -export class MerchantUsersService { - private readonly logger = new Logger(MerchantUsersService.name); - - private readonly MERCHANT_ROLES = [ - UserRole.DCB_PARTNER_ADMIN, - UserRole.DCB_PARTNER_MANAGER, - UserRole.DCB_PARTNER_SUPPORT, - ]; - - constructor(private readonly keycloakApi: KeycloakApiService) {} - - // ===== CRÉATION D'UTILISATEURS MERCHANT ===== - async createMerchantUser(creatorId: string, userData: CreateMerchantUserData): Promise { - this.logger.log(`Creating merchant user: ${userData.username} for merchant: ${userData.merchantPartnerId}`); - - // Validation des permissions et du merchant - await this.validateMerchantUserCreation(creatorId, userData); - - // Vérifier les doublons - const existingUsers = await this.keycloakApi.findUserByUsername(userData.username); - if (existingUsers.length > 0) { - throw new BadRequestException(`User with username ${userData.username} already exists`); - } - - const keycloakUserData: CreateUserData = { - username: userData.username, - email: userData.email, - firstName: userData.firstName, - lastName: userData.lastName, - password: userData.password, - enabled: userData.enabled ?? true, - emailVerified: userData.emailVerified ?? false, - merchantPartnerId: userData.merchantPartnerId, - clientRoles: [userData.role], - createdBy: creatorId, - }; - - const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData); - const createdUser = await this.getMerchantUserById(userId, creatorId); - - this.logger.log(`Merchant user created successfully: ${userData.username}`); - return createdUser; - } - - // ===== RÉCUPÉRATION D'UTILISATEURS MERCHANT ===== - async getMerchantUsersByPartner(merchantPartnerId: string, requesterId: string): Promise { - await this.validateMerchantAccess(requesterId, merchantPartnerId); - - const allUsers = await this.keycloakApi.getAllUsers(); - const merchantUsers: MerchantUser[] = []; - - for (const user of allUsers) { - if (!user.id) continue; - - const userMerchantId = user.attributes?.merchantPartnerId?.[0]; - if (userMerchantId === merchantPartnerId) { - try { - const userRoles = await this.keycloakApi.getUserClientRoles(user.id); - const merchantRole = userRoles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole)); - - if (merchantRole) { - merchantUsers.push(this.mapToMerchantUser(user, userRoles)); - } - } catch (error) { - this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`); - } - } - } - - return merchantUsers; - } - - async getMerchantUserById(userId: string, requesterId: string): Promise { - const user = await this.keycloakApi.getUserById(userId, requesterId); - const userRoles = await this.keycloakApi.getUserClientRoles(userId); - - const merchantRole = userRoles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole)); - if (!merchantRole) { - throw new BadRequestException(`User ${userId} is not a merchant user`); - } - - const merchantPartnerId = user.attributes?.merchantPartnerId?.[0]; - if (!merchantPartnerId) { - throw new BadRequestException(`User ${userId} has no merchant partner association`); - } - - await this.validateMerchantAccess(requesterId, merchantPartnerId); - - return this.mapToMerchantUser(user, userRoles); - } - - // ===== VALIDATIONS ===== - private async validateMerchantUserCreation(creatorId: string, userData: CreateMerchantUserData): Promise { - const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId); - - // DCB_PARTNER peut créer des utilisateurs - if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER)) { - if (creatorId !== userData.merchantPartnerId) { - throw new ForbiddenException('DCB_PARTNER can only create users for their own '); - } - // DCB_PARTNER ne peut créer que certains rôles - const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]; - if (!allowedRoles.includes(userData.role)) { - throw new ForbiddenException('DCB_PARTNER can only create DCB_PARTNER_ADMIN, MANAGER, or SUPPORT roles'); - } - return; - } - - // DCB_PARTNER_ADMIN peut créer des utilisateurs pour son merchant - if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER_ADMIN)) { - const creatorMerchantId = await this.keycloakApi.getUserMerchantPartnerId(creatorId); - if (creatorMerchantId !== userData.merchantPartnerId) { - throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner'); - } - // DCB_PARTNER_ADMIN ne peut créer que certains rôles - const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]; - if (!allowedRoles.includes(userData.role)) { - throw new ForbiddenException('DCB_PARTNER_ADMIN can only create DCB_PARTNER_ADMIN, DCB_PARTNER_MANAGER or SUPPORT roles'); - } - return; - } - - // Les admins Hub peuvent créer pour n'importe quel merchant - if (creatorRoles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) - )) { - return; - } - - throw new ForbiddenException('Insufficient permissions to create merchant users'); - } - - private async validateMerchantAccess(requesterId: string, merchantPartnerId: string): Promise { - await this.keycloakApi.validateUserAccess(requesterId, merchantPartnerId); - } - - private mapToMerchantUser(user: KeycloakUser, roles: any[]): MerchantUser { - const merchantRole = roles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole)); - - return { - id: user.id!, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - role: merchantRole?.name as UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT, - enabled: user.enabled, - emailVerified: user.emailVerified, - merchantPartnerId: user.attributes?.merchantPartnerId?.[0]!, - createdBy: user.attributes?.createdBy?.[0] || 'unknown', - createdByUsername: user.attributes?.createdByUsername?.[0] || 'unknown', - createdTimestamp: user.createdTimestamp || Date.now(), - lastLogin: user.attributes?.lastLogin?.[0] ? parseInt(user.attributes.lastLogin[0]) : undefined, - userType: 'MERCHANT', - }; - } -} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 5f2818f..7b2244c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,23 +1,56 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe, Logger } from '@nestjs/common'; +import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common'; import helmet from 'helmet'; import { KeycloakExceptionFilter } from './filters/keycloak-exception.filter'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { useContainer } from 'class-validator'; async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('dcb-user-service'); + useContainer(app.select(AppModule), { fallbackOnErrors: true }); + // Middlewares de sécurité app.use(helmet()); - app.enableCors({ origin: '*' }); + app.enableCors({ + origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], + allowedHeaders: ['Content-Type', 'Authorization'] + }); // Gestion globale des erreurs et validation app.useGlobalFilters(new KeycloakExceptionFilter()); + + // ValidationPipe CORRIGÉ app.useGlobalPipes(new ValidationPipe({ - whitelist: true, - transform: true + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + exceptionFactory: (errors) => { + const messages = errors.map(error => { + // Détails complets de l'erreur + const constraints = error.constraints ? Object.values(error.constraints) : ['Unknown validation error']; + return { + field: error.property, + errors: constraints, + value: error.value, + children: error.children + }; + }); + + console.log('🔴 VALIDATION ERRORS:', JSON.stringify(messages, null, 2)); + + return new BadRequestException({ + message: 'Validation failed', + errors: messages, + details: 'Check the errors array for specific field validation issues' + }); + } })); // Préfixe global de l'API @@ -43,4 +76,4 @@ async function bootstrap() { logger.log(`Application running on http://localhost:${port}`); logger.log(`Swagger documentation available at http://localhost:${port}/api-docs`); } -bootstrap() \ No newline at end of file +bootstrap(); \ No newline at end of file