From 65494d5af288c06b82e2d547080e7629cf2e7aab Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 3 Nov 2025 17:37:20 +0000 Subject: [PATCH 1/7] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- .env-sample | 2 +- src/api/api.module.ts | 10 - src/api/controllers/api.controller.ts | 47 - src/app.module.ts | 6 +- src/auth/auth.module.ts | 8 +- src/auth/controllers/auth.controller.ts | 226 ++++- src/auth/guards/merchant-owner.guard.ts | 33 - src/auth/guards/merchant.guard.ts | 29 - src/auth/services/keycloak-api.service.ts | 840 +++++++++++------- src/auth/services/keycloak-user.model.ts | 125 +++ src/auth/services/startup.service-crud.ts | 76 ++ src/auth/services/startup.service-final.ts | 710 +++++++++++++++ src/auth/services/startup.service.ts | 718 ++++++++++++++- src/auth/services/token.service.ts | 237 ++++- src/config/keycloak.config.ts | 28 +- src/constants/resouces.ts | 4 - src/constants/resources.ts | 4 + .../controllers/hub-users.controller.ts | 673 ++++++++++++++ .../merchant-partners.controller.ts | 298 +++++++ .../controllers/merchant-users.controller.ts | 514 +++++++++++ src/hub-users/dto/hub-user.dto.ts | 105 +++ src/hub-users/dto/merchant-partners.dto.ts | 96 ++ src/hub-users/dto/merchant-users.dto.ts | 87 ++ src/hub-users/hub-users.module.ts | 24 + src/hub-users/models/hub-user.model.ts | 100 +++ src/hub-users/services/hub-users.service.ts | 552 ++++++++++++ .../services/merchant-partners.service.ts | 353 ++++++++ .../services/merchant-users.service.ts | 192 ++++ src/main.ts | 28 +- src/users/controllers/merchants.controller.ts | 396 --------- src/users/controllers/users.controller.ts | 254 ------ src/users/models/merchant.ts | 201 ----- src/users/models/user.ts | 198 ----- src/users/services/merchant-team.service.ts | 352 -------- src/users/services/users.service.ts | 402 --------- src/users/users.module.ts | 24 - 36 files changed, 5573 insertions(+), 2379 deletions(-) delete mode 100644 src/api/api.module.ts delete mode 100644 src/api/controllers/api.controller.ts delete mode 100644 src/auth/guards/merchant-owner.guard.ts delete mode 100644 src/auth/guards/merchant.guard.ts create mode 100644 src/auth/services/keycloak-user.model.ts create mode 100644 src/auth/services/startup.service-crud.ts create mode 100644 src/auth/services/startup.service-final.ts delete mode 100644 src/constants/resouces.ts create mode 100644 src/constants/resources.ts create mode 100644 src/hub-users/controllers/hub-users.controller.ts create mode 100644 src/hub-users/controllers/merchant-partners.controller.ts create mode 100644 src/hub-users/controllers/merchant-users.controller.ts create mode 100644 src/hub-users/dto/hub-user.dto.ts create mode 100644 src/hub-users/dto/merchant-partners.dto.ts create mode 100644 src/hub-users/dto/merchant-users.dto.ts create mode 100644 src/hub-users/hub-users.module.ts create mode 100644 src/hub-users/models/hub-user.model.ts create mode 100644 src/hub-users/services/hub-users.service.ts create mode 100644 src/hub-users/services/merchant-partners.service.ts create mode 100644 src/hub-users/services/merchant-users.service.ts delete mode 100644 src/users/controllers/merchants.controller.ts delete mode 100644 src/users/controllers/users.controller.ts delete mode 100644 src/users/models/merchant.ts delete mode 100644 src/users/models/user.ts delete mode 100644 src/users/services/merchant-team.service.ts delete mode 100644 src/users/services/users.service.ts delete mode 100644 src/users/users.module.ts diff --git a/.env-sample b/.env-sample index d304c28..437bb34 100644 --- a/.env-sample +++ b/.env-sample @@ -21,7 +21,7 @@ KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025 KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant -KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025 +KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025 KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support KEYCLOAK_TEST_PASSWORD=@BOSupport2025 diff --git a/src/api/api.module.ts b/src/api/api.module.ts deleted file mode 100644 index 73b78db..0000000 --- a/src/api/api.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuthModule } from '../auth/auth.module'; - -import { ApiController } from './controllers/api.controller'; - -@Module({ - imports: [AuthModule], - controllers: [ApiController], -}) -export class ApiModule {} diff --git a/src/api/controllers/api.controller.ts b/src/api/controllers/api.controller.ts deleted file mode 100644 index bb93a22..0000000 --- a/src/api/controllers/api.controller.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Controller, Get, Logger } from '@nestjs/common'; -import { AuthenticatedUser, Roles, Resource, Scopes } from 'nest-keycloak-connect'; -import { RESOURCES } from '../../constants/resouces'; -import { SCOPES } from '../../constants/scopes'; - -@Controller('api') -@Resource(RESOURCES.USER) -export class ApiController { - private readonly logger = new Logger(ApiController.name); - - @Get('secure') - @Scopes(SCOPES.READ) - getSecure(@AuthenticatedUser() user: any) { - this.logger.log(`User ${user?.preferred_username} accessed /secure`); - return { - message: 'Accès autorisé', - user, - }; - } - - @Get('token-details') - @Scopes(SCOPES.READ) - tokenDetails(@AuthenticatedUser() user: any) { - return { - username: user.preferred_username, - client_id: user.client_id, - realmRoles: user.realm_access?.roles || [], - resourceRoles: user.resource_access?.[user.client_id]?.roles || [], - }; - } - - - @Get('protected') - @Scopes(SCOPES.READ) - getProtected() { - this.logger.log('Accessed protected route'); - return { - message: 'Protected route accessed successfully', - time: new Date().toISOString(), - }; - } - - @Get('public') - getPublic() { - return { message: 'Accès public' }; - } -} diff --git a/src/app.module.ts b/src/app.module.ts index 9af1f09..0297f94 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,8 +14,7 @@ import { TerminusModule } from '@nestjs/terminus'; import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config'; import { AuthModule } from './auth/auth.module'; -import { ApiModule } from './api/api.module'; -import { UsersModule } from './users/users.module'; +import { HubUsersModule } from './hub-users/hub-users.module'; import { StartupService } from './auth/services/startup.service'; @Module({ @@ -69,8 +68,7 @@ import { StartupService } from './auth/services/startup.service'; // Feature Modules AuthModule, - ApiModule, - UsersModule, + HubUsersModule, ], providers: [ diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index a047725..b158f16 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -4,8 +4,7 @@ import { JwtModule } from '@nestjs/jwt'; import { TokenService } from './services/token.service'; import { KeycloakApiService } from './services/keycloak-api.service'; import { AuthController } from './controllers/auth.controller'; -import { UsersService } from '../users/services/users.service'; -import { MerchantTeamService } from 'src/users/services/merchant-team.service'; +import { HubUsersService } from '../hub-users/services/hub-users.service'; import { JwtAuthGuard } from './guards/jwt.guard'; @@ -19,10 +18,9 @@ import { JwtAuthGuard } from './guards/jwt.guard'; JwtAuthGuard, TokenService, KeycloakApiService, - UsersService, - MerchantTeamService + HubUsersService ], controllers: [AuthController], - exports: [JwtAuthGuard, TokenService, KeycloakApiService, UsersService, MerchantTeamService, JwtModule], + exports: [JwtAuthGuard, TokenService, KeycloakApiService, HubUsersService, JwtModule], }) export class AuthModule {} diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index b45695b..a2acbba 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -9,14 +9,75 @@ import { HttpException, HttpStatus, } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiBody, + ApiHeader, + ApiProperty +} from '@nestjs/swagger'; import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect'; import { TokenService } from '../services/token.service'; import { ConfigService } from '@nestjs/config'; import type { Request } from 'express'; -import { UsersService } from '../../users/services/users.service'; -import * as user from '../../users/models/user'; +import { HubUsersService } from '../../hub-users/services/hub-users.service'; +import * as user from '../../hub-users/models/hub-user.model'; +// DTOs pour Swagger +export class LoginDto { + @ApiProperty({ description: 'Username', example: 'admin@dcb.com' }) + username: string; + @ApiProperty({ description: 'Password', example: 'your_password' }) + password: string; +} + +export class RefreshTokenDto { + @ApiProperty({ description: 'Refresh token' }) + refresh_token: string; +} + +export class LoginResponseDto { + @ApiProperty({ description: 'Access token JWT' }) + access_token: string; + + @ApiProperty({ description: 'Refresh token' }) + refresh_token: string; + + @ApiProperty({ description: 'Token expiration in seconds' }) + expires_in: number; + + @ApiProperty({ description: 'Token type', example: 'Bearer' }) + token_type: string; +} + +export class LogoutResponseDto { + @ApiProperty({ description: 'Success message' }) + message: string; +} + +export class AuthStatusResponseDto { + @ApiProperty({ description: 'Whether user is authenticated' }) + authenticated: boolean; + + @ApiProperty({ description: 'Authentication status message' }) + status: string; +} + +export class ErrorResponseDto { + @ApiProperty({ description: 'Error message' }) + message: string; + + @ApiProperty({ description: 'HTTP status code' }) + statusCode: number; + + @ApiProperty({ description: 'Error type' }) + error: string; +} + +@ApiTags('Authentication') @Controller('auth') export class AuthController { private readonly logger = new Logger(AuthController.name); @@ -24,16 +85,40 @@ export class AuthController { constructor( private readonly tokenService: TokenService, private readonly configService: ConfigService, - private readonly usersService: UsersService + private readonly usersService: HubUsersService ) {} /** ------------------------------- * LOGIN (Resource Owner Password Credentials) * ------------------------------- */ - // === AUTHENTIFICATION === @Public() @Post('login') + @ApiOperation({ + summary: 'User login', + description: 'Authenticate user with username and password using Resource Owner Password Credentials flow' + }) + @ApiBody({ type: LoginDto }) + @ApiResponse({ + status: 200, + description: 'Login successful', + type: LoginResponseDto + }) + @ApiResponse({ + status: 400, + description: 'Bad request - missing credentials', + type: ErrorResponseDto + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid credentials', + type: ErrorResponseDto + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - account disabled or not fully set up', + type: ErrorResponseDto + }) async login(@Body() loginDto: user.LoginDto) { this.logger.log(`User login attempt: ${loginDto.username}`); @@ -78,6 +163,36 @@ export class AuthController { * LOGOUT * ------------------------------- */ @Post('logout') + @ApiOperation({ + summary: 'User logout', + description: 'Logout user by revoking refresh token and clearing session' + }) + @ApiBearerAuth() + @ApiHeader({ + name: 'Authorization', + description: 'Bearer token', + required: true + }) + @ApiResponse({ + status: 200, + description: 'Logout successful', + type: LogoutResponseDto + }) + @ApiResponse({ + status: 400, + description: 'Bad request - no token provided', + type: ErrorResponseDto + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid token', + type: ErrorResponseDto + }) + @ApiResponse({ + status: 500, + description: 'Internal server error', + type: ErrorResponseDto + }) async logout(@Req() req: Request) { const token = req.headers['authorization']?.split(' ')[1]; if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST); @@ -120,6 +235,26 @@ export class AuthController { * ------------------------------- */ @Public() @Post('refresh') + @ApiOperation({ + summary: 'Refresh access token', + description: 'Obtain new access token using refresh token' + }) + @ApiBody({ type: RefreshTokenDto }) + @ApiResponse({ + status: 200, + description: 'Token refreshed successfully', + type: LoginResponseDto + }) + @ApiResponse({ + status: 400, + description: 'Bad request - refresh token missing', + type: ErrorResponseDto + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid refresh token', + type: ErrorResponseDto + }) async refreshToken(@Body() body: { refresh_token: string }) { const { refresh_token } = body; if (!refresh_token) throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST); @@ -143,6 +278,20 @@ export class AuthController { * ------------------------------- */ @Public() @Get('status') + @ApiOperation({ + summary: 'Check authentication status', + description: 'Verify if the provided token is valid' + }) + @ApiHeader({ + name: 'Authorization', + description: 'Bearer token (optional)', + required: false + }) + @ApiResponse({ + status: 200, + description: 'Authentication status retrieved', + type: AuthStatusResponseDto + }) async getAuthStatus(@Req() req: Request) { const token = req.headers['authorization']?.replace('Bearer ', ''); let isValid = false; @@ -155,4 +304,71 @@ export class AuthController { } return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' }; } -} + + /** ------------------------------- + * USER PROFILE (protected) + * ------------------------------- */ + @Get('profile') + @ApiOperation({ + summary: 'Get current user profile', + description: 'Retrieve profile information of authenticated user' + }) + @ApiBearerAuth() + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully' + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid token', + type: ErrorResponseDto + }) + async getProfile(@AuthenticatedUser() user: any) { + this.logger.log(`Profile requested for user: ${user.preferred_username}`); + + 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, + }; + } + + /** ------------------------------- + * VALIDATE TOKEN (protected) + * ------------------------------- */ + @Get('validate') + @ApiOperation({ + summary: 'Validate token', + description: 'Check if the current token is valid and get user information' + }) + @ApiBearerAuth() + @ApiResponse({ + status: 200, + description: 'Token is valid' + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized - invalid token', + type: ErrorResponseDto + }) + async validateToken(@AuthenticatedUser() user: any) { + this.logger.log(`Token validation requested for user: ${user.preferred_username}`); + + return { + valid: true, + user: { + 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 || [], + }, + expires_in: user.exp ? user.exp - Math.floor(Date.now() / 1000) : 0, + }; + } +} \ No newline at end of file diff --git a/src/auth/guards/merchant-owner.guard.ts b/src/auth/guards/merchant-owner.guard.ts deleted file mode 100644 index 2b8c93c..0000000 --- a/src/auth/guards/merchant-owner.guard.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; -import { MerchantTeamService } from '../../users/services/merchant-team.service'; - -@Injectable() -export class MerchantOwnerGuard implements CanActivate { - constructor(private readonly merchantTeamService: MerchantTeamService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const user = request.user; - const targetUserId = request.params.userId; - - if (!user || !targetUserId) { - throw new ForbiddenException('Invalid request'); - } - - try { - // Vérifier que l'utilisateur cible appartient bien au marchand - const isInTeam = await this.merchantTeamService.isUserInMerchantTeam( - targetUserId, - user.sub - ); - - if (!isInTeam) { - throw new ForbiddenException('Access denied: User not in your team'); - } - - return true; - } catch (error) { - throw new ForbiddenException('Failed to verify team membership'); - } - } -} \ No newline at end of file diff --git a/src/auth/guards/merchant.guard.ts b/src/auth/guards/merchant.guard.ts deleted file mode 100644 index 458a8db..0000000 --- a/src/auth/guards/merchant.guard.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; -import { UsersService } from '../../users/services/users.service'; - -@Injectable() -export class MerchantGuard implements CanActivate { - constructor(private readonly usersService: UsersService) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const user = request.user; - - if (!user) { - throw new ForbiddenException('User not authenticated'); - } - - try { - const userRoles = await this.usersService.getUserClientRoles(user.sub); - - // Autoriser les admins et les marchands - if (userRoles.includes('admin') || userRoles.includes('merchant')) { - return true; - } - - throw new ForbiddenException('Merchant access required'); - } catch (error) { - throw new ForbiddenException('Failed to verify merchant permissions'); - } - } -} diff --git a/src/auth/services/keycloak-api.service.ts b/src/auth/services/keycloak-api.service.ts index 64a85e7..1468e27 100644 --- a/src/auth/services/keycloak-api.service.ts +++ b/src/auth/services/keycloak-api.service.ts @@ -1,30 +1,18 @@ -import { Injectable, Logger, HttpException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, HttpException, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { AxiosResponse } from 'axios'; -import { firstValueFrom, Observable, timeout as rxjsTimeout } from 'rxjs'; +import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs'; import { TokenService } from './token.service'; +import { KeycloakUser, KeycloakRole, CreateUserData, UserRole, UserType } from './keycloak-user.model'; -export interface KeycloakUser { - id?: string; - username: string; - email?: string; - firstName?: string; - lastName?: string; - enabled: boolean; - emailVerified: boolean; - attributes?: Record; - createdTimestamp?: number; +// Interface pour la hiérarchie des rôles +interface RoleHierarchy { + role: UserRole; + canCreate: UserRole[]; + requiresMerchantPartner?: boolean; } -export interface KeycloakRole { - id: string; - name: string; - description?: string; -} - -export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support' | 'merchant-user'; - @Injectable() export class KeycloakApiService { private readonly logger = new Logger(KeycloakApiService.name); @@ -32,6 +20,40 @@ export class KeycloakApiService { 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 + } + ]; + constructor( private readonly httpService: HttpService, private readonly configService: ConfigService, @@ -39,12 +61,7 @@ export class KeycloakApiService { ) { this.keycloakBaseUrl = this.configService.get('KEYCLOAK_SERVER_URL') || 'http://localhost:8080'; this.realm = this.configService.get('KEYCLOAK_REALM') || 'master'; - this.clientId = this.configService.get('KEYCLOAK_CLIENT_ID') || 'admin-cli'; - } - - // ===== MÉTHODE POUR L'AUTHENTIFICATION UTILISATEUR ===== - async authenticateUser(username: string, password: string) { - return this.tokenService.acquireUserToken(username, password); + this.clientId = this.configService.get('KEYCLOAK_CLIENT_ID') || 'dcb-admin-cli'; } // ===== CORE REQUEST METHOD ===== @@ -110,79 +127,375 @@ export class KeycloakApiService { ); } - // ===== USER CRUD OPERATIONS ===== - async createUser(userData: { - username: string; - email?: string; - firstName?: string; - lastName?: string; - password?: string; - enabled?: boolean; - emailVerified?: boolean; - attributes?: Record; - credentials?: any[]; - }): Promise { + // ===== 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()], + }; + + 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, - hasPassword: !!userData.password, - hasCredentials: !!userData.credentials, - credentialsLength: userData.credentials ? userData.credentials.length : 0 + 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 = { username: userData.username, email: userData.email, firstName: userData.firstName, lastName: userData.lastName, enabled: userData.enabled ?? true, - emailVerified: userData.emailVerified ?? true, + emailVerified: userData.emailVerified ?? false, + attributes: this.buildUserAttributes(userData, creatorId, creatorUsername), }; if (userData.password) { - // Format direct : password field userPayload.credentials = [{ type: 'password', value: userData.password, - temporary: false, + temporary: userData.passwordTemporary ?? false, }]; - } else if (userData.credentials && userData.credentials.length > 0) { - // Format credentials array (venant de UsersService) - userPayload.credentials = userData.credentials; - } else { - throw new BadRequestException('Password is required'); } this.logger.debug(`CREATE USER - Final Keycloak payload:`, JSON.stringify(userPayload, null, 2)); - // Format correct des attributs - if (userData.attributes) { - const formattedAttributes: Record = {}; - - for (const [key, value] of Object.entries(userData.attributes)) { - if (Array.isArray(value)) { - formattedAttributes[key] = value; - } else { - formattedAttributes[key] = [String(value)]; - } - } - - userPayload.attributes = formattedAttributes; - this.logger.debug(`CREATE USER - Formatted attributes:`, formattedAttributes); - } - try { this.logger.log(`Creating user in Keycloak: ${userData.username}`); + await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload); - // Récupérer l'ID const users = await this.findUserByUsername(userData.username); if (users.length === 0) { throw new Error('User not found after creation'); } - this.logger.log(`User created successfully with ID: ${users[0].id}`); - return users[0].id!; + 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) { @@ -192,121 +505,95 @@ export class KeycloakApiService { } } - async getUserById(userId: string): Promise { - return this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); + 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; } - async getAllUsers(): Promise { - return this.request('GET', `/admin/realms/${this.realm}/users`); - } - - async findUserByUsername(username: string): Promise { - return this.request( - 'GET', - `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}` - ); - } - - async findUserByEmail(email: string): Promise { - return this.request( - 'GET', - `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}` - ); - } - - async updateUser(userId: string, userData: Partial): Promise { + 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): Promise { + 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 ===== - async setUserAttributes(userId: string, attributes: Record): Promise { - try { - const user = await this.getUserById(userId); - const updatedUser = { - ...user, - attributes: { - ...user.attributes, - ...attributes - } - }; - - await this.updateUser(userId, updatedUser); - this.logger.log(`Attributes set for user ${userId}: ${Object.keys(attributes).join(', ')}`); - } catch (error) { - this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`); - throw error; + private buildUserAttributes( + userData: CreateUserData, + creatorId: string, + creatorUsername: string + ): Record { + const attributes: Record = {}; + + // Merchant Partner ID + if (userData.merchantPartnerId !== undefined) { + 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) + ); + 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']; + + return attributes; } - async updateUserAttributes(userId: string, attributes: Record): Promise { - try { - const user = await this.getUserById(userId); - const currentAttributes = user.attributes || {}; - - const updatedUser = { - ...user, - attributes: { - ...currentAttributes, - ...attributes - } - }; - - await this.updateUser(userId, updatedUser); - this.logger.log(`Attributes updated for user ${userId}`); - } catch (error) { - this.logger.error(`Failed to update attributes for user ${userId}: ${error.message}`); - throw error; - } + // ===== 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) + ); } - async removeUserAttribute(userId: string, attributeName: string): Promise { + async getUserMerchantPartnerId(userId: string): Promise { try { - const user = await this.getUserById(userId); - const currentAttributes = { ...user.attributes }; - - if (currentAttributes && currentAttributes[attributeName]) { - delete currentAttributes[attributeName]; - - const updatedUser = { - ...user, - attributes: currentAttributes - }; - - await this.updateUser(userId, updatedUser); - this.logger.log(`Attribute ${attributeName} removed from user ${userId}`); - } + 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 remove attribute ${attributeName} from user ${userId}: ${error.message}`); - throw error; - } - } - - async getUserAttribute(userId: string, attributeName: string): Promise { - try { - const user = await this.getUserById(userId); - const attributes = user.attributes || {}; - return attributes[attributeName]?.[0] || null; - } catch (error) { - this.logger.error(`Failed to get attribute ${attributeName} for user ${userId}: ${error.message}`); + this.logger.error(`Failed to get merchantPartnerId for user ${userId}: ${error.message}`); return null; } } - async getUserAttributes(userId: string): Promise> { - try { - const user = await this.getUserById(userId); - return user.attributes || {}; - } catch (error) { - this.logger.error(`Failed to get attributes for user ${userId}: ${error.message}`); - return {}; - } - } - - // ===== CLIENT ROLE OPERATIONS ===== + // ===== ROLE MANAGEMENT ===== async getUserClientRoles(userId: string): Promise { try { const clients = await this.getClient(); @@ -320,41 +607,7 @@ export class KeycloakApiService { } } - async assignClientRole(userId: string, role: ClientRole): Promise { - try { - const clients = await this.getClient(); - const targetRole = await this.getRole(role, clients[0].id); - - await this.request( - 'POST', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, - [targetRole] - ); - this.logger.log(`Role ${role} assigned to user ${userId}`); - } catch (error) { - this.logger.error(`Failed to assign role ${role} to user ${userId}: ${error.message}`); - throw error; - } - } - - async removeClientRole(userId: string, role: ClientRole): Promise { - try { - const clients = await this.getClient(); - const targetRole = await this.getRole(role, clients[0].id); - - await this.request( - 'DELETE', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, - [targetRole] - ); - this.logger.log(`Role ${role} removed from user ${userId}`); - } catch (error) { - this.logger.error(`Failed to remove role ${role} from user ${userId}: ${error.message}`); - throw error; - } - } - - async setClientRoles(userId: string, roles: ClientRole[]): Promise { + async setClientRoles(userId: string, roles: UserRole[]): Promise { try { const clients = await this.getClient(); const clientId = clients[0].id; @@ -362,7 +615,7 @@ export class KeycloakApiService { // Récupérer les rôles actuels const currentRoles = await this.getUserClientRoles(userId); - // Supprimer tous les rôles actuels si nécessaire + // Supprimer les rôles actuels si existants if (currentRoles.length > 0) { await this.request( 'DELETE', @@ -391,59 +644,28 @@ export class KeycloakApiService { } } - async addClientRoles(userId: string, roles: ClientRole[]): Promise { - try { - for (const role of roles) { - await this.assignClientRole(userId, role); - } - this.logger.log(`Client roles added to user ${userId}: ${roles.join(', ')}`); - } catch (error) { - this.logger.error(`Failed to add client roles to user ${userId}: ${error.message}`); - throw error; - } - } - - async removeClientRoles(userId: string, roles: ClientRole[]): Promise { - try { - for (const role of roles) { - await this.removeClientRole(userId, role); - } - this.logger.log(`Client roles removed from user ${userId}: ${roles.join(', ')}`); - } catch (error) { - this.logger.error(`Failed to remove client roles from user ${userId}: ${error.message}`); - throw error; - } - } - // ===== UTILITY METHODS ===== - async userExists(username: string): Promise { - try { - const users = await this.findUserByUsername(username); - return users.length > 0; - } catch { - return false; - } + async getAllUsers(): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users`); } - async enableUser(userId: string): Promise { - await this.updateUser(userId, { enabled: true }); - this.logger.log(`User ${userId} enabled`); - } - - async disableUser(userId: string): Promise { - await this.updateUser(userId, { enabled: false }); - this.logger.log(`User ${userId} disabled`); - } - - async resetPassword(userId: string, newPassword: string): Promise { - const credentials = { - type: 'password', - value: newPassword, - temporary: false, - }; + async findUserByUsername(username: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}` + ); - await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, credentials); - this.logger.log(`Password reset for user ${userId}`); + // 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 { @@ -454,12 +676,68 @@ export class KeycloakApiService { user.attributes[attributeName] && user.attributes[attributeName].includes(attributeValue) ); - } catch (error) { + } 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) { + throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`); + } + return clients; + } + + private async getRole(role: UserRole, clientId: string): Promise { + const roles = await this.request('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`); + const targetRole = roles.find(r => r.name === role); + + if (!targetRole) { + throw new BadRequestException(`Role '${role}' not found in client '${this.clientId}'`); + } + 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) + ); + } + + async getUserPermissions(userId: string): Promise<{ + canCreateMerchantPartners: boolean; + canManageUsers: boolean; + accessibleMerchantPartnerIds: string[]; + }> { + const roles = await this.getUserClientRoles(userId); + const merchantPartnerId = await this.getUserMerchantPartnerId(userId); + + const canCreateMerchantPartners = roles.some(role => + [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) + ); + + const canManageUsers = roles.some(role => + [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN].includes(role.name as UserRole) + ); + + const accessibleMerchantPartnerIds = canCreateMerchantPartners + ? [] // Accès à tous les merchants + : merchantPartnerId + ? [merchantPartnerId] + : []; + + return { + canCreateMerchantPartners, + canManageUsers, + accessibleMerchantPartnerIds + }; + } + // ===== HEALTH CHECKS ===== async checkKeycloakAvailability(): Promise { const url = `${this.keycloakBaseUrl}/realms/${this.realm}`; @@ -499,68 +777,4 @@ export class KeycloakApiService { return false; } } - - // ===== 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) { - throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`); - } - return clients; - } - - private async getRole(role: ClientRole, clientId: string): Promise { - const roles = await this.request('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`); - const targetRole = roles.find(r => r.name === role); - - if (!targetRole) { - throw new BadRequestException(`Role '${role}' not found in client '${this.clientId}'`); - } - return targetRole; - } - - // ===== MÉTHODES POUR LE STARTUP SERVICE (compatibilité) ===== - async checkHealth(username: string, password: string): Promise<{ status: string }> { - try { - const isAvailable = await this.checkKeycloakAvailability(); - const isConnected = await this.checkServiceConnection(); - - return { - status: isAvailable && isConnected ? 'healthy' : 'unhealthy' - }; - } catch (error) { - return { status: 'unhealthy' }; - } - } - - async getRealmClients(realm: string, username: string, password: string): Promise { - return this.request('GET', `/admin/realms/${realm}/clients`); - } - - async getRealmInfo(realm: string, username: string, password: string): Promise { - return this.request('GET', `/admin/realms/${realm}`); - } - - async getUsers(realm: string, username: string, password: string, options?: any): Promise { - let url = `/admin/realms/${realm}/users`; - if (options?.max) { - url += `?max=${options.max}`; - } - if (options?.username) { - url += `${url.includes('?') ? '&' : '?'}username=${encodeURIComponent(options.username)}`; - } - return this.request(url.includes('?') ? 'GET' : 'GET', url); - } - - async getUserProfile(realm: string, token: string): Promise { - const config = { - headers: { Authorization: `Bearer ${token}` }, - }; - - const url = `${this.keycloakBaseUrl}/realms/${realm}/protocol/openid-connect/userinfo`; - const response = await firstValueFrom(this.httpService.get(url, config)); - return response.data; - } - - // ... autres méthodes de compatibilité pour le StartupService } \ No newline at end of file diff --git a/src/auth/services/keycloak-user.model.ts b/src/auth/services/keycloak-user.model.ts new file mode 100644 index 0000000..7533064 --- /dev/null +++ b/src/auth/services/keycloak-user.model.ts @@ -0,0 +1,125 @@ +export interface KeycloakUser { + id?: string; + username: string; + email: string; // Rendre obligatoire + firstName: string; + lastName: string; + enabled: boolean; + emailVerified: boolean; + attributes?: { + merchantPartnerId?: string[]; + createdBy?: string[]; + createdByUsername?: string[]; + userType?: string[]; + userStatus?: string[]; + lastLogin?: string[]; + [key: string]: string[] | undefined; + }; + createdTimestamp?: number; +} + +export interface KeycloakRole { + id: string; + name: string; + description?: string; + composite?: boolean; + clientRole?: boolean; + containerId?: string; +} + +export interface CreateUserData { + username: string; + email: string; + firstName: string; + lastName: string; + password?: string; + passwordTemporary?: boolean; + enabled?: boolean; + emailVerified?: boolean; + merchantPartnerId?: string; + clientRoles: UserRole[]; + createdBy?: string; + createdByUsername?: string; + initialStatus?: string; +} + +export enum UserType { + HUB = 'hub', + MERCHANT_PARTNER = 'merchant_partner' +} + +export enum UserRole { + // Rôles Hub (sans merchantPartnerId) + DCB_ADMIN = 'dcb-admin', + DCB_SUPPORT = 'dcb-support', + DCB_PARTNER = 'dcb-partner', + + // Rôles Merchant Partner (avec merchantPartnerId obligatoire) + DCB_PARTNER_ADMIN = 'dcb-partner-admin', + DCB_PARTNER_MANAGER = 'dcb-partner-manager', + DCB_PARTNER_SUPPORT = 'dcb-partner-support' +} + +export interface HubUser { + id: string; + username: string; + email: string; + firstName: string; + lastName: string; + role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; + enabled: boolean; + emailVerified: boolean; + createdBy: string; + createdByUsername: string; + createdTimestamp: number; + lastLogin?: number; + userType: 'HUB'; + attributes?: { + userStatus?: string[]; + lastLogin?: string[]; + merchantPartnerId?: string[]; + createdBy?: string[]; + createdByUsername?: string[]; + userType?: string[]; + [key: string]: string[] | undefined; + }; +} + +export interface CreateHubUserData { + username: string; + email: string; + firstName: string; + lastName: string; + password?: string; + role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; + enabled?: boolean; + emailVerified?: boolean; + createdBy: string; +} + +export interface HubUserStats { + totalAdmins: number; + totalSupport: number; + activeUsers: number; + inactiveUsers: number; + pendingActivation: number; +} + +export interface MerchantStats { + totalMerchants: number; + activeMerchants: number; + suspendedMerchants: number; + pendingMerchants: number; + totalUsers: number; +} + +export interface HubUserActivity { + user: HubUser; + lastLogin?: Date; +} + +export interface HubHealthStatus { + status: 'healthy' | 'degraded' | 'unhealthy'; + issues: string[]; + stats: HubUserStats; +} \ No newline at end of file diff --git a/src/auth/services/startup.service-crud.ts b/src/auth/services/startup.service-crud.ts new file mode 100644 index 0000000..4c49e0f --- /dev/null +++ b/src/auth/services/startup.service-crud.ts @@ -0,0 +1,76 @@ +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 new file mode 100644 index 0000000..2ee8c7c --- /dev/null +++ b/src/auth/services/startup.service-final.ts @@ -0,0 +1,710 @@ +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 a898b2d..ee6b910 100644 --- a/src/auth/services/startup.service.ts +++ b/src/auth/services/startup.service.ts @@ -1,42 +1,706 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { KeycloakApiService } from './keycloak-api.service'; +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 StartupService implements OnModuleInit { private readonly logger = new Logger(StartupService.name); - private initialized = false; - private error: string | null = null; + + // 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 keycloakApiService: KeycloakApiService) {} + constructor( + private readonly hubUsersService: HubUsersService, + private readonly merchantUsersService: MerchantUsersService, + private readonly keycloakApi: KeycloakApiService, + private readonly tokenService: TokenService, + ) {} async onModuleInit() { - this.logger.log('Vérification de la disponibilité de Keycloak...'); - - try { - const available = await this.keycloakApiService.checkKeycloakAvailability(); - if (!available) throw new Error('Keycloak non accessible'); - - const serviceConnected = await this.keycloakApiService.checkServiceConnection(); - if (!serviceConnected) throw new Error('Échec de la connexion du service à Keycloak'); - - this.initialized = true; - this.logger.log('Keycloak disponible et connexion du service réussie'); - } catch (err: any) { - this.error = err.message; - this.logger.error('Échec de la vérification de Keycloak', err); + if (process.env.RUN_STARTUP_TESTS === 'true') { + this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...'); + await this.runAllTests(); } } - getStatus() { - return { - status: this.initialized ? 'healthy' : 'unhealthy', - keycloakConnected: this.initialized, - timestamp: new Date(), - error: this.error, + // ===== 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; } - isHealthy(): boolean { - return this.initialized; + // ===== 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/token.service.ts b/src/auth/services/token.service.ts index 236d939..ff5500a 100644 --- a/src/auth/services/token.service.ts +++ b/src/auth/services/token.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { firstValueFrom } from 'rxjs'; @@ -10,22 +10,43 @@ export interface KeycloakTokenResponse { refresh_token?: string; expires_in: number; token_type: string; + refresh_expire_in?: number; scope?: string; } +export interface DecodedToken { + sub: string; + email?: string; + preferred_username?: string; + given_name?: string; + family_name?: string; + realm_access?: { roles: string[] }; + resource_access?: { [key: string]: { roles: string[] } }; + merchantPartnerId?: string; + // Ajout des claims personnalisés + 'merchant-partner-id'?: string; + 'user-type'?: string; +} + + @Injectable() -export class TokenService { +export class TokenService implements OnModuleInit { private readonly logger = new Logger(TokenService.name); private readonly keycloakConfig: KeycloakConfig; // Cache pour le token de service account private serviceAccountToken: string | null = null; private serviceTokenExpiry: number = 0; + // === TOKEN STORAGE === private userToken: string | null = null; private userTokenExpiry: Date | null = null; private userRefreshToken: string | null = null; + + // Cache pour les clés publiques + private publicKeys: { [key: string]: string } = {}; + private keysLastFetched: number = 0; constructor( private readonly configService: ConfigService, @@ -34,6 +55,10 @@ export class TokenService { this.keycloakConfig = this.getKeycloakConfig(); } + async onModuleInit() { + await this.fetchPublicKeys(); + } + // === CONFIGURATION === private getKeycloakConfig(): KeycloakConfig { const config = this.configService.get('keycloak'); @@ -47,6 +72,32 @@ export class TokenService { return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`; } + private getCertsEndpoint(): string { + return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/certs`; + } + + // === GESTION DES CLÉS PUBLIQUES === + private async fetchPublicKeys(): Promise { + try { + const response = await firstValueFrom( + this.httpService.get<{ keys: any[] }>(this.getCertsEndpoint()) + ); + + response.data.keys.forEach((key: any) => { + this.publicKeys[key.kid] = key; + }); + + this.keysLastFetched = Date.now(); + this.logger.log('Public keys fetched successfully'); + } catch (error) { + this.logger.error('Failed to fetch public keys', error); + } + } + + private shouldRefreshKeys(): boolean { + return Date.now() - this.keysLastFetched > 3600000; // 1 heure + } + // === CACHE MANAGEMENT === private isServiceTokenValid(): boolean { if (!this.serviceAccountToken) return false; @@ -62,7 +113,6 @@ export class TokenService { // === TOKEN ACQUISITION === async acquireUserToken(username: string, password: string): Promise { - const params = new URLSearchParams({ grant_type: 'password', client_id: this.keycloakConfig.authClientId, @@ -80,7 +130,7 @@ export class TokenService { // Stocker le token et ses métadonnées this.storeUserToken(response.data); - + this.logger.log(`User token acquired for: ${username}`); return response.data; } catch (error: any) { @@ -228,30 +278,7 @@ export class TokenService { } } - async refreshToken(refreshToken: string): Promise { - const params = new URLSearchParams({ - grant_type: 'refresh_token', - client_id: this.keycloakConfig.authClientId, - client_secret: this.keycloakConfig.authClientSecret, - refresh_token: refreshToken, - }); - - try { - const response = await firstValueFrom( - this.httpService.post(this.getTokenEndpoint(), params, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - ); - - this.logger.log('Token refreshed successfully'); - return response.data; - } catch (error: any) { - this.logger.error('Token refresh failed', error.response?.data); - throw new Error(error.response?.data?.error_description || 'Token refresh failed'); - } - } - - // === TOKEN VALIDATION === + // === TOKEN VALIDATION AMÉLIORÉE === async validateToken(token: string): Promise { const mode = this.keycloakConfig.validationMode || 'online'; @@ -283,37 +310,170 @@ export class TokenService { } } - private validateOffline(token: string): boolean { - if (!this.keycloakConfig.publicKey) { - this.logger.error('Missing public key for offline validation'); - return false; + private async validateOffline(token: string): Promise { + if (this.shouldRefreshKeys()) { + await this.fetchPublicKeys(); } try { - const formattedKey = `-----BEGIN PUBLIC KEY-----\n${this.keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`; + const decoded = jwt.decode(token, { complete: true }) as any; + if (!decoded?.header?.kid) { + throw new Error('Token missing key ID'); + } + + const publicKey = this.publicKeys[decoded.header.kid]; + if (!publicKey) { + throw new Error(`Public key not found for kid: ${decoded.header.kid}`); + } + + const formattedKey = this.formatPublicKey(publicKey); jwt.verify(token, formattedKey, { algorithms: ['RS256'], - audience: this.keycloakConfig.authClientId, + //audience: this.keycloakConfig.authClientId, + issuer: `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}`, + ignoreExpiration: false, + ignoreNotBefore: false, }); return true; } catch (error: any) { this.logger.error('Offline token validation failed:', error.message); - return false; + + // Fallback: validation basique du token + try { + const decoded = this.decodeToken(token); + return !!decoded && !!decoded.sub; + } catch { + return false; + } } } - // === TOKEN UTILITIES === - decodeToken(token: string): any { + private formatPublicKey(key: any): string { + if (key.x5c && key.x5c[0]) { + return `-----BEGIN CERTIFICATE-----\n${key.x5c[0]}\n-----END CERTIFICATE-----`; + } + + // Fallback to public key format + const n = Buffer.from(key.n, 'base64'); + const e = Buffer.from(key.e, 'base64'); + + const jwk = { + kty: key.kty, + n: n.toString('base64'), + e: e.toString('base64'), + }; + + return `-----BEGIN PUBLIC KEY-----\n${jwk.n}\n-----END PUBLIC KEY-----`; + } + + // === DECODAGE AVEC TYPAGE FORT === + decodeToken(token: string): DecodedToken { try { - return jwt.decode(token); + const decoded = jwt.decode(token) as DecodedToken; + + // Normalisation des claims personnalisés + if (decoded) { + // Support pour les claims personnalisés Keycloak + decoded.merchantPartnerId = decoded['merchant-partner-id'] || decoded.merchantPartnerId; + } + + return decoded; } catch (error: any) { this.logger.error('Failed to decode token', error.message); throw new Error('Invalid token format'); } } + // === EXTRACTION D'INFORMATIONS SPÉCIFIQUES === + extractMerchantPartnerId(token: string): string | null { + const decoded = this.decodeToken(token); + return decoded.merchantPartnerId || null; + } + + extractUserRoles(token: string): string[] { + const decoded = this.decodeToken(token); + const roles: string[] = []; + + // Rôles realm + if (decoded.realm_access?.roles) { + roles.push(...decoded.realm_access.roles); + } + + // Rôles client + if (decoded.resource_access) { + Object.values(decoded.resource_access).forEach(client => { + if (client.roles) { + roles.push(...client.roles); + } + }); + } + + return roles; + } + + extractUserId(token: string): string | null { + const decoded = this.decodeToken(token); + return decoded.sub || null; + } + + // === UTILITAIRES DE PERMISSIONS === + hasRole(token: string, role: string): boolean { + const roles = this.extractUserRoles(token); + return roles.includes(role); + } + + hasAnyRole(token: string, roles: string[]): boolean { + const userRoles = this.extractUserRoles(token); + return roles.some(role => userRoles.includes(role)); + } + + hasAllRoles(token: string, roles: string[]): boolean { + const userRoles = this.extractUserRoles(token); + return roles.every(role => userRoles.includes(role)); + } + + isHubAdmin(token: string): boolean { + return this.hasRole(token, 'DCB_ADMIN'); + } + + isHubSupport(token: string): boolean { + return this.hasRole(token, 'DCB_SUPPORT'); + } + + isMerchantPartner(token: string): boolean { + return this.hasRole(token, 'DCB_PARTNER'); + } + + isMerchantPartnerAdmin(token: string): boolean { + return this.hasRole(token, 'DCB_PARTNER_ADMIN'); + } + + // === AUTRES MÉTHODES (inchangées) === + async refreshToken(refreshToken: string): Promise { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: this.keycloakConfig.authClientId, + client_secret: this.keycloakConfig.authClientSecret, + refresh_token: refreshToken, + }); + + try { + const response = await firstValueFrom( + this.httpService.post(this.getTokenEndpoint(), params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + ); + + this.logger.log('Token refreshed successfully'); + return response.data; + } catch (error: any) { + this.logger.error('Token refresh failed', error.response?.data); + throw new Error(error.response?.data?.error_description || 'Token refresh failed'); + } + } + async revokeToken(token: string): Promise { const params = new URLSearchParams({ client_id: this.keycloakConfig.authClientId, @@ -337,7 +497,6 @@ export class TokenService { } } - // === SERVICE MANAGEMENT === clearServiceToken(): void { this.serviceAccountToken = null; this.serviceTokenExpiry = 0; diff --git a/src/config/keycloak.config.ts b/src/config/keycloak.config.ts index 57d3c04..40d581d 100644 --- a/src/config/keycloak.config.ts +++ b/src/config/keycloak.config.ts @@ -5,10 +5,6 @@ export interface KeycloakConfig { serverUrl: string; realm: string; publicKey?: string; - // Client pour l'API Admin (Service Account - client_credentials) - //adminClientId: string; - //adminClientSecret: string; - // Client pour l'authentification utilisateur (Password Grant) authClientId: string; authClientSecret: string; validationMode: string; @@ -16,14 +12,10 @@ export interface KeycloakConfig { } export default registerAs('keycloak', (): KeycloakConfig => ({ - serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://keycloak-dcb.app.cameleonapp.com', - realm: process.env.KEYCLOAK_REALM || 'dcb-dev', + serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://iam.dcb.pixpay.sn', + realm: process.env.KEYCLOAK_REALM || 'dcb-prod', publicKey: process.env.KEYCLOAK_PUBLIC_KEY, - // Client pour Service Account (API Admin) - //adminClientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'dcb-user-service-cc', - //adminClientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET || '', - // Client pour Password Grant (Authentification utilisateur) - authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-pwd', + authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-cc-app', authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '', validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online', tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30, @@ -53,20 +45,6 @@ export const keycloakConfigValidationSchema = Joi.object({ 'any.required': 'KEYCLOAK_PUBLIC_KEY is required' }), - /*KEYCLOAK_ADMIN_CLIENT_ID: Joi.string() - .required() - .messages({ - 'any.required': 'KEYCLOAK_ADMIN_CLIENT_ID is required' - }), - - KEYCLOAK_ADMIN_CLIENT_SECRET: Joi.string() - .required() - .min(1) - .messages({ - 'any.required': 'KEYCLOAK_ADMIN_CLIENT_SECRET is required', - 'string.min': 'KEYCLOAK_ADMIN_CLIENT_SECRET cannot be empty' - }),*/ - KEYCLOAK_CLIENT_ID: Joi.string() .required() .messages({ diff --git a/src/constants/resouces.ts b/src/constants/resouces.ts deleted file mode 100644 index 3cb3966..0000000 --- a/src/constants/resouces.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const RESOURCES = { - USER: 'user', // user resource for /users/* endpoints - MERCHANT: 'merchants' // merchant resource for /merchants/* endpoints -}; diff --git a/src/constants/resources.ts b/src/constants/resources.ts new file mode 100644 index 0000000..8efa308 --- /dev/null +++ b/src/constants/resources.ts @@ -0,0 +1,4 @@ +export const RESOURCES = { + HUB_USER: 'user', // user resource for /users/* endpoints + MERCHANT_USER: 'partner' // merchant resource for /merchants/* endpoints +}; diff --git a/src/hub-users/controllers/hub-users.controller.ts b/src/hub-users/controllers/hub-users.controller.ts new file mode 100644 index 0000000..0f7cd51 --- /dev/null +++ b/src/hub-users/controllers/hub-users.controller.ts @@ -0,0 +1,673 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Request, + HttpCode, + HttpStatus, + ParseUUIDPipe, + BadRequestException +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, + ApiProperty +} from '@nestjs/swagger'; +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 { RESOURCES } from '../../constants/resources'; +import { SCOPES } from '../../constants/scopes'; +import { Resource, Scopes } from 'nest-keycloak-connect'; + +export class LoginDto { + @ApiProperty({ description: 'Username' }) + username: string; + + @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' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ description: 'Password for the user' }) + password: string; + + @ApiProperty({ + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + description: 'Role for the hub user' + }) + role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; + + @ApiProperty({ required: false, default: true }) + enabled?: boolean = true; + + @ApiProperty({ required: false, default: false }) + emailVerified?: boolean = false; +} + +export class UpdateHubUserDto { + @ApiProperty({ required: false }) + firstName?: string; + + @ApiProperty({ required: false }) + lastName?: string; + + @ApiProperty({ required: false }) + email?: string; + + @ApiProperty({ required: false }) + enabled?: boolean; +} + +export class UpdateUserRoleDto { + @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; +} + +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; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + description: 'User role' + }) + role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ description: 'User creator ID' }) + createdBy: string; + + @ApiProperty({ description: 'User creator username' }) + createdByUsername: string; + + @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 { + @ApiProperty({ description: 'User ID' }) + userId: string; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Last access date' }) + lastAccess: Date; +} + +export class PermissionResponse { + @ApiProperty({ description: 'Whether user can manage hub users' }) + canManageHubUsers: boolean; +} + +export class AvailableRolesResponse { + @ApiProperty({ + type: [Object], + description: 'Available roles' + }) + roles: Array<{ + value: UserRole; + label: string; + description: string; + }>; +} + +export class MessageResponse { + @ApiProperty({ description: 'Response message' }) + message: string; +} + +// Mapper functions +function mapToHubUserResponse(hubUser: HubUser): 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, + }; +} + +function mapToUserActivityResponse(activity: HubUserActivity): UserActivityResponse { + return { + user: mapToHubUserResponse(activity.user), + lastLogin: activity.lastLogin + }; +} + +@ApiTags('Hub Users') +@ApiBearerAuth() +@Controller('hub-users') +@Resource(RESOURCES.HUB_USER || RESOURCES.MERCHANT_USER) +export class HubUsersController { + constructor(private readonly hubUsersService: HubUsersService) {} + + // ===== GESTION DES UTILISATEURS HUB ===== + + @Get() + @ApiOperation({ + summary: 'Get all hub users', + description: 'Returns all hub users (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)' + }) + @ApiResponse({ + 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); + return users.map(mapToHubUserResponse); + } + + @Get('role/:role') + @ApiOperation({ summary: 'Get hub users by role' }) + @ApiResponse({ + status: 200, + description: 'Hub 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 { + const userId = req.user.sub; + const validRole = this.hubUsersService.validateHubRoleFromString(role); + const users = await this.hubUsersService.getHubUsersByRole(validRole, 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', + description: 'Create a hub user with specific role (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)' + }) + @ApiResponse({ + 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, + @Request() req + ): Promise { + const userId = req.user.sub; + + const userData: CreateHubUserData = { + ...createHubUserDto, + createdBy: userId, + }; + + const user = await this.hubUsersService.createHubUser(userId, userData); + return mapToHubUserResponse(user); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a hub user' }) + @ApiResponse({ + status: 200, + description: 'Hub user updated successfully', + type: HubUserResponse + }) + @ApiResponse({ status: 404, description: 'Hub user not found' }) + @ApiParam({ name: 'id', description: 'User ID' }) + + @Scopes(SCOPES.WRITE) + async updateHubUser( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateHubUserDto: UpdateHubUserDto, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.hubUsersService.updateHubUser(id, updateHubUserDto, userId); + return mapToHubUserResponse(user); + } + + @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' }) + + @Scopes(SCOPES.WRITE) + async updateHubUserRole( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateRoleDto: UpdateUserRoleDto, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.hubUsersService.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' }) + + @Scopes(SCOPES.WRITE) + async resetHubUserPassword( + @Param('id', ParseUUIDPipe) id: string, + @Body() resetPasswordDto: ResetPasswordDto, + @Request() req + ): Promise { + const userId = req.user.sub; + await this.hubUsersService.resetHubUserPassword( + id, + resetPasswordDto.newPassword, + resetPasswordDto.temporary, + userId + ); + 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-partners.controller.ts b/src/hub-users/controllers/merchant-partners.controller.ts new file mode 100644 index 0000000..86b64fc --- /dev/null +++ b/src/hub-users/controllers/merchant-partners.controller.ts @@ -0,0 +1,298 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + Request, + HttpStatus, + HttpCode, + Logger, + DefaultValuePipe, + ParseIntPipe, + BadRequestException, +} from '@nestjs/common'; + +import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect"; + +import { + MerchantPartnersService, + MerchantPartner, + CreateMerchantPartnerData, + MerchantStats, +} from '../services/merchant-partners.service'; +import { RESOURCES } from '../../constants/resources'; +import { SCOPES } from '../../constants/scopes'; + +// DTOs +import { + CreateMerchantPartnerDto, + UpdateMerchantPartnerDto, + SuspendMerchantPartnerDto, +} from '../dto/merchant-partners.dto'; + +export interface ApiResponse { + success: boolean; + message: string; + data?: T; + timestamp: string; +} + +export interface PaginatedResponse { + items: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Controller('partners') +@Resource(RESOURCES.MERCHANT_USER) +export class MerchantPartnersController { + private readonly logger = new Logger(MerchantPartnersController.name); + + constructor(private readonly merchantPartnersService: MerchantPartnersService) {} + + private createApiResponse( + success: boolean, + message: string, + data?: T, + ): ApiResponse { + return { + success, + message, + data, + timestamp: new Date().toISOString(), + }; + } + + // ===== CRÉATION DE MERCHANT PARTNERS ===== + @Post() + @Scopes(SCOPES.WRITE) + async createMerchantPartner( + @Body() createMerchantDto: CreateMerchantPartnerDto, + @Request() req: any, + ): Promise> { + this.logger.log(`Creating merchant partner: ${createMerchantDto.name}`); + + const creatorId = req.user.sub; + + const merchantData: CreateMerchantPartnerData = { + ...createMerchantDto, + dcbPartnerOwner: { + username: createMerchantDto.dcbPartnerOwnerUsername, + email: createMerchantDto.dcbPartnerOwnerEmail, + firstName: createMerchantDto.dcbPartnerOwnerFirstName, + lastName: createMerchantDto.dcbPartnerOwnerLastName, + password: createMerchantDto.dcbPartnerOwnerPassword, + }, + }; + + const merchant = await this.merchantPartnersService.createMerchantPartner(creatorId, merchantData); + + this.logger.log(`Merchant partner created successfully: ${merchant.name} (ID: ${merchant.id})`); + + return this.createApiResponse( + true, + 'Merchant partner created successfully', + merchant, + ); + } + + // ===== RÉCUPÉRATION DE MERCHANT PARTNERS ===== + @Get() + @Scopes(SCOPES.READ) + async getAllMerchantPartners( + @Request() req: any, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, + @Query('status') status?: string, + ): Promise>> { + const requesterId = req.user.sub; + + const safeLimit = Math.min(limit, 100); + const safePage = Math.max(page, 1); + + let merchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId); + + // Filtrage par statut + if (status && ['ACTIVE', 'SUSPENDED', 'PENDING'].includes(status)) { + merchants = merchants.filter(merchant => merchant.status === status); + } + + // Pagination + const startIndex = (safePage - 1) * safeLimit; + const endIndex = startIndex + safeLimit; + const paginatedMerchants = merchants.slice(startIndex, endIndex); + + const response: PaginatedResponse = { + items: paginatedMerchants, + total: merchants.length, + page: safePage, + limit: safeLimit, + totalPages: Math.ceil(merchants.length / safeLimit), + }; + + return this.createApiResponse( + true, + 'Merchant partners retrieved successfully', + response, + ); + } + + @Get('stats') + @Scopes(SCOPES.READ) + async getMerchantStats( + @Request() req: any, + ): Promise> { + const requesterId = req.user.sub; + const stats = await this.merchantPartnersService.getMerchantStats(requesterId); + + return this.createApiResponse( + true, + 'Merchant statistics retrieved successfully', + stats, + ); + } + + @Get(':id') + @Scopes(SCOPES.READ) + async getMerchantPartnerById( + @Param('id') merchantId: string, + @Request() req: any, + ): Promise> { + const requesterId = req.user.sub; + const merchant = await this.merchantPartnersService.getMerchantPartnerById(merchantId, requesterId); + + return this.createApiResponse( + true, + 'Merchant partner retrieved successfully', + merchant, + ); + } + + // ===== MISE À JOUR DE MERCHANT PARTNERS ===== + @Put(':id') + @Scopes(SCOPES.WRITE) + async updateMerchantPartner( + @Param('id') merchantId: string, + @Body() updateMerchantDto: UpdateMerchantPartnerDto, + @Request() req: any, + ): Promise> { + this.logger.log(`Updating merchant partner: ${merchantId}`); + + const requesterId = req.user.sub; + const merchant = await this.merchantPartnersService.updateMerchantPartner( + merchantId, + updateMerchantDto, + requesterId, + ); + + this.logger.log(`Merchant partner updated successfully: ${merchant.name}`); + + return this.createApiResponse( + true, + 'Merchant partner updated successfully', + merchant, + ); + } + + @Put(':id/suspend') + @Scopes(SCOPES.WRITE) + @HttpCode(HttpStatus.OK) + async suspendMerchantPartner( + @Param('id') merchantId: string, + @Body() suspendDto: SuspendMerchantPartnerDto, + @Request() req: any, + ): Promise> { + this.logger.log(`Suspending merchant partner: ${merchantId}`); + + const requesterId = req.user.sub; + await this.merchantPartnersService.suspendMerchantPartner( + merchantId, + suspendDto.reason, + requesterId, + ); + + this.logger.log(`Merchant partner suspended successfully: ${merchantId}`); + + return this.createApiResponse( + true, + 'Merchant partner suspended successfully', + ); + } + + @Put(':id/activate') + @Scopes(SCOPES.WRITE) + @HttpCode(HttpStatus.OK) + async activateMerchantPartner( + @Param('id') merchantId: string, + @Request() req: any, + ): Promise> { + this.logger.log(`Activating merchant partner: ${merchantId}`); + + const requesterId = req.user.sub; + const merchant = await this.merchantPartnersService.updateMerchantPartner( + merchantId, + { status: 'ACTIVE' }, + requesterId, + ); + + this.logger.log(`Merchant partner activated successfully: ${merchant.name}`); + + return this.createApiResponse( + true, + 'Merchant partner activated successfully', + merchant, + ); + } + + // ===== SUPPRESSION DE MERCHANT PARTNERS ===== + @Delete(':id') + @Scopes(SCOPES.DELETE) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteMerchantPartner( + @Param('id') merchantId: string, + @Request() req: any, + ): Promise { + this.logger.log(`Deleting merchant partner: ${merchantId}`); + + const requesterId = req.user.sub; + // Implémentez la logique de suppression si nécessaire + // await this.merchantPartnersService.deleteMerchantPartner(merchantId, requesterId); + + this.logger.log(`Merchant partner deletion scheduled: ${merchantId}`); + } + + // ===== RECHERCHE ET FILTRES ===== + @Get('search/by-name') + @Scopes(SCOPES.READ) + async searchMerchantsByName( + @Query('name') name: string, + @Request() req: any, + ): Promise> { + if (!name || name.length < 2) { + throw new BadRequestException('Name must be at least 2 characters long'); + } + + this.logger.log(`Searching merchants by name: ${name}`); + + const requesterId = req.user.sub; + const allMerchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId); + + const filteredMerchants = allMerchants.filter(merchant => + merchant.name.toLowerCase().includes(name.toLowerCase()), + ); + + this.logger.log(`Found ${filteredMerchants.length} merchants matching name: ${name}`); + + return this.createApiResponse( + true, + 'Merchants search completed successfully', + filteredMerchants, + ); + } +} \ 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 new file mode 100644 index 0000000..f2f34ba --- /dev/null +++ b/src/hub-users/controllers/merchant-users.controller.ts @@ -0,0 +1,514 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + Request, + HttpCode, + HttpStatus, + ParseUUIDPipe, + BadRequestException +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, + 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 { RESOURCES } from '../../constants/resources'; +import { SCOPES } from '../../constants/scopes'; +import { Resource, Scopes } from 'nest-keycloak-connect'; + +export class CreateMerchantUserDto { + @ApiProperty({ description: 'Username for the merchant user' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ description: 'Password for the user' }) + password: string; + + @ApiProperty({ + enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + description: 'Role for the merchant user' + }) + role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; + + @ApiProperty({ required: false, default: true }) + enabled?: boolean = true; + + @ApiProperty({ required: false, default: false }) + emailVerified?: boolean = false; + + @ApiProperty({ description: 'Merchant partner ID' }) + merchantPartnerId: string; +} + +export class UpdateMerchantUserDto { + @ApiProperty({ required: false }) + firstName?: string; + + @ApiProperty({ required: false }) + lastName?: string; + + @ApiProperty({ required: false }) + email?: string; + + @ApiProperty({ required: false }) + enabled?: boolean; +} + +export class ResetPasswordDto { + @ApiProperty({ description: 'New password' }) + newPassword: string; + + @ApiProperty({ required: false, default: true }) + temporary?: boolean = true; +} + +export class MerchantUserResponse { + @ApiProperty({ description: 'User ID' }) + id: string; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ + enum: [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; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ description: 'Merchant partner ID' }) + merchantPartnerId: string; + + @ApiProperty({ description: 'User creator ID' }) + createdBy: string; + + @ApiProperty({ description: 'User creator username' }) + createdByUsername: string; + + @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; + + @ApiProperty({ description: 'Total manager users' }) + totalManagers: number; + + @ApiProperty({ description: 'Total support users' }) + totalSupport: number; + + @ApiProperty({ description: 'Total users' }) + totalUsers: number; + + @ApiProperty({ description: 'Active users count' }) + activeUsers: number; + + @ApiProperty({ description: 'Inactive users count' }) + inactiveUsers: number; +} + +export class AvailableRolesResponse { + @ApiProperty({ + type: [Object], + description: 'Available roles with permissions' + }) + roles: Array<{ + value: UserRole; + label: string; + description: string; + allowedForCreation: boolean; + }>; +} + +// Mapper function pour convertir MerchantUser en MerchantUserResponse +function mapToMerchantUserResponse(merchantUser: MerchantUser): 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, + }; +} + +@ApiTags('Merchant Users') +@ApiBearerAuth() +@Controller('merchant-users') +@Resource(RESOURCES.MERCHANT_USER) +export class MerchantUsersController { + constructor(private readonly merchantUsersService: MerchantUsersService) {} + + // ===== RÉCUPÉRATION D'UTILISATEURS ===== + + @Get() + @ApiOperation({ + summary: 'Get merchant users for current user merchant', + description: 'Returns merchant users based on the current user merchant partner ID' + }) + @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'); + } + + 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', + description: 'Create a merchant user with specific role and merchant partner association' + }) + @ApiResponse({ + 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, + @Request() req + ): Promise { + const userId = req.user.sub; + + const userData: CreateMerchantUserData = { + ...createMerchantUserDto, + createdBy: userId, + }; + + const user = await this.merchantUsersService.createMerchantUser(userId, userData); + return mapToMerchantUserResponse(user); + } + + // ===== MISE À JOUR D'UTILISATEURS ===== + + @Put(':id') + @ApiOperation({ summary: 'Update a merchant user' }) + @ApiResponse({ + status: 200, + 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) + @Scopes(SCOPES.WRITE) + async updateMerchantUser( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateMerchantUserDto: 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'); + } + + // ===== 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) + @Scopes(SCOPES.DELETE) + async deleteMerchantUser( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise<{ message: string }> { + const userId = req.user.sub; + + // Vous devrez implémenter deleteMerchantUser dans le service + throw new BadRequestException('Delete merchant user not implemented yet'); + } + + // ===== GESTION DES MOTS DE PASSE ===== + + @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) + @Scopes(SCOPES.WRITE) + async resetMerchantUserPassword( + @Param('id', ParseUUIDPipe) id: string, + @Body() resetPasswordDto: ResetPasswordDto, + @Request() req + ): Promise<{ message: string }> { + 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) + ); + + 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 []; + } + } +} \ 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 new file mode 100644 index 0000000..f3e66bc --- /dev/null +++ b/src/hub-users/dto/hub-user.dto.ts @@ -0,0 +1,105 @@ +// 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-partners.dto.ts b/src/hub-users/dto/merchant-partners.dto.ts new file mode 100644 index 0000000..e0ad6ff --- /dev/null +++ b/src/hub-users/dto/merchant-partners.dto.ts @@ -0,0 +1,96 @@ +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + MinLength, + Matches, +} from 'class-validator'; + +export class CreateMerchantPartnerDto { + @IsNotEmpty() + @IsString() + @MinLength(2) + name: string; + + @IsNotEmpty() + @IsString() + @MinLength(2) + legalName: string; + + @IsNotEmpty() + @IsEmail() + email: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + address?: string; + + // Propriétaire DCB_PARTNER + @IsNotEmpty() + @IsString() + @MinLength(3) + dcbPartnerOwnerUsername: string; + + @IsNotEmpty() + @IsEmail() + dcbPartnerOwnerEmail: string; + + @IsNotEmpty() + @IsString() + @MinLength(2) + dcbPartnerOwnerFirstName: string; + + @IsNotEmpty() + @IsString() + @MinLength(2) + dcbPartnerOwnerLastName: string; + + @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', + }) + dcbPartnerOwnerPassword: string; +} + +export class UpdateMerchantPartnerDto { + @IsOptional() + @IsString() + @MinLength(2) + name?: string; + + @IsOptional() + @IsString() + @MinLength(2) + legalName?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsEnum(['ACTIVE', 'SUSPENDED', 'PENDING']) + status?: 'ACTIVE' | 'SUSPENDED' | 'PENDING'; +} + +export class SuspendMerchantPartnerDto { + @IsNotEmpty() + @IsString() + @MinLength(5) + reason: 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 new file mode 100644 index 0000000..385c969 --- /dev/null +++ b/src/hub-users/dto/merchant-users.dto.ts @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..1dee8bf --- /dev/null +++ b/src/hub-users/hub-users.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' +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'; + + +@Module({ + imports: [ + HttpModule, + JwtModule.register({}), + ], + providers: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService], + controllers: [HubUsersController, MerchantUsersController], + exports: [HubUsersService, MerchantUsersService, 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 new file mode 100644 index 0000000..85a8c9e --- /dev/null +++ b/src/hub-users/models/hub-user.model.ts @@ -0,0 +1,100 @@ +// user.models.ts +export interface User { + id: string; + username: string; + email: string; + firstName: string; + lastName: string; + enabled: boolean; + emailVerified: boolean; + userType: UserType; + merchantPartnerId?: string; + clientRoles: UserRole[]; + createdBy?: string; + createdByUsername?: string; + createdTimestamp: number; +} + +export enum UserType { + HUB = 'hub', + MERCHANT_PARTNER = 'merchant_partner' +} + +export enum UserRole { + // Rôles Hub (sans merchantPartnerId) + DCB_ADMIN = 'dcb-admin', + DCB_SUPPORT = 'dcb-support', + DCB_PARTNER = 'dcb-partner', + + // Rôles Merchant Partner (avec merchantPartnerId obligatoire) + DCB_PARTNER_ADMIN = 'dcb-partner-admin', + DCB_PARTNER_MANAGER = 'dcb-partner-manager', + DCB_PARTNER_SUPPORT = 'dcb-partner-support' +} + +// DTOs pour la création +export interface CreateUserDto { + username: string; + email: string; + firstName?: string; + lastName?: string; + password: string; + enabled?: boolean; + userType: UserType; + merchantPartnerId?: string; + clientRoles: UserRole[]; +} + +export interface CreateHubUserDto extends CreateUserDto { + hubRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; +} + +export interface CreateMerchantPartnerUserDto extends CreateUserDto { + merchantRole: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; +} + +export interface UpdateUserDto { + username?: string; + email?: string; + firstName?: string; + lastName?: string; + enabled?: boolean; + clientRoles?: UserRole[]; +} + +export interface UserQueryDto { + page?: number; + limit?: number; + search?: string; + userType?: UserType; + merchantPartnerId?: string; + enabled?: boolean; +} + +export interface UserResponse { + user: User; + message: string; +} + +export interface PaginatedUsersResponse { + users: User[]; + 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; +} \ No newline at end of file diff --git a/src/hub-users/services/hub-users.service.ts b/src/hub-users/services/hub-users.service.ts new file mode 100644 index 0000000..d996a01 --- /dev/null +++ b/src/hub-users/services/hub-users.service.ts @@ -0,0 +1,552 @@ +import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } 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'; + +// ===== 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 === + 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); + } + + 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` + ); + } + } + + 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}`); + } + + 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; + } + + 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) + ); + + if (!isHubAdmin) { + throw new ForbiddenException('Only hub administrators can manage hub users'); + } + } + + private parseKeycloakAttribute(value: string[] | undefined): string | undefined { + return value?.[0]; + } + + private parseKeycloakTimestamp(value: string[] | undefined): number | undefined { + const strValue = this.parseKeycloakAttribute(value); + if (!strValue) return undefined; + + const timestamp = parseInt(strValue); + if (!isNaN(timestamp)) return timestamp; + + const date = new Date(strValue); + 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)); + + 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); + + if (isMerchant) { + merchants.push(this.mapToHubUser(user, userRoles)); + } + } catch (error) { + this.logger.warn(`Could not process merchant ${user.id}: ${error.message}`); + } + } + + 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'); + 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'); + } + + 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}`); + } + } + } + } + + 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-partners.service.ts b/src/hub-users/services/merchant-partners.service.ts new file mode 100644 index 0000000..685ad97 --- /dev/null +++ b/src/hub-users/services/merchant-partners.service.ts @@ -0,0 +1,353 @@ +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 MerchantPartner { + id: string; + name: string; + legalName: string; + email: string; + phone?: string; + address?: string; + status: 'ACTIVE' | 'SUSPENDED' | 'PENDING'; + createdAt: Date; + updatedAt: Date; + dcbPartnerUserId: string; // ID du user DCB_PARTNER propriétaire +} + +export interface CreateMerchantPartnerData { + name: string; + legalName: string; + email: string; + phone?: string; + address?: string; + dcbPartnerOwner: { + username: string; + email: string; + firstName: string; + lastName: string; + password: string; + }; +} + +export interface MerchantStats { + totalMerchants: number; + activeMerchants: number; + suspendedMerchants: number; + pendingMerchants: number; + totalUsers: number; +} + +@Injectable() +export class MerchantPartnersService { + private readonly logger = new Logger(MerchantPartnersService.name); + + constructor(private readonly keycloakApi: KeycloakApiService) {} + + // ===== CRÉATION D'UN MERCHANT PARTNER ===== + async createMerchantPartner( + creatorId: string, + merchantData: CreateMerchantPartnerData + ): Promise { + this.logger.log(`Creating merchant partner: ${merchantData.name}`); + + // Validation des permissions du créateur + await this.validateMerchantCreationPermissions(creatorId); + + // Vérifier les doublons + await this.checkDuplicateMerchant(merchantData); + + // 1. Créer le user DCB_PARTNER (propriétaire) + const dcbPartnerUserData: CreateUserData = { + username: merchantData.dcbPartnerOwner.username, + email: merchantData.dcbPartnerOwner.email, + firstName: merchantData.dcbPartnerOwner.firstName, + lastName: merchantData.dcbPartnerOwner.lastName, + password: merchantData.dcbPartnerOwner.password, + enabled: true, + emailVerified: true, + clientRoles: [UserRole.DCB_PARTNER], + createdBy: creatorId, + }; + + const dcbPartnerUserId = await this.keycloakApi.createUser(creatorId, dcbPartnerUserData); + + // 2. Créer l'entité Merchant Partner (dans la base de données ou Keycloak attributes) + const merchantPartner: MerchantPartner = { + id: merchantData.dcbPartnerOwner.username, + name: merchantData.name, + legalName: merchantData.legalName, + email: merchantData.email, + phone: merchantData.phone, + address: merchantData.address, + status: 'ACTIVE', + createdAt: new Date(), + updatedAt: new Date(), + dcbPartnerUserId, + }; + + // 3. Stocker les infos du merchant dans les attributs du user DCB_PARTNER + await this.keycloakApi.setUserAttributes(dcbPartnerUserId, { + merchantPartnerName: [merchantData.name], + merchantLegalName: [merchantData.legalName], + merchantEmail: [merchantData.email], + merchantPhone: [merchantData.phone || ''], + merchantAddress: [merchantData.address || ''], + merchantStatus: ['ACTIVE'], + merchantCreatedAt: [new Date().toISOString()], + }); + + this.logger.log(`Merchant partner created successfully: ${merchantData.name}`); + return merchantPartner; + } + + // ===== GESTION DES MERCHANTS ===== + async getAllMerchantPartners(requesterId: string): Promise { + await this.validateHubAccess(requesterId); + + // Récupérer tous les users DCB_PARTNER + const allUsers = await this.keycloakApi.getAllUsers(); + const merchants: MerchantPartner[] = []; + + for (const user of allUsers) { + if (!user.id) continue; + + try { + const userRoles = await this.keycloakApi.getUserClientRoles(user.id); + const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER); + + if (isDcbPartner && user.attributes?.merchantPartnerName?.[0]) { + merchants.push(this.mapToMerchantPartner(user)); + } + } catch (error) { + this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`); + } + } + + return merchants; + } + + async getMerchantPartnerById(merchantId: string, requesterId: string): Promise { + await this.validateMerchantAccess(requesterId, merchantId); + + // Trouver le user DCB_PARTNER correspondant au merchant + const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId); + return this.mapToMerchantPartner(dcbPartnerUser); + } + + async updateMerchantPartner( + merchantId: string, + updates: Partial<{ + name: string; + legalName: string; + email: string; + phone: string; + address: string; + status: 'ACTIVE' | 'SUSPENDED' | 'PENDING'; + }>, + requesterId: string + ): Promise { + await this.validateMerchantManagementPermissions(requesterId, merchantId); + + const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId); + + // Mettre à jour les attributs + const attributes: any = {}; + if (updates.name) attributes.merchantPartnerName = [updates.name]; + if (updates.legalName) attributes.merchantLegalName = [updates.legalName]; + if (updates.email) attributes.merchantEmail = [updates.email]; + if (updates.phone) attributes.merchantPhone = [updates.phone]; + if (updates.address) attributes.merchantAddress = [updates.address]; + if (updates.status) attributes.merchantStatus = [updates.status]; + + attributes.merchantUpdatedAt = [new Date().toISOString()]; + + await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, attributes); + + return this.getMerchantPartnerById(merchantId, requesterId); + } + + 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}`); + } + + // ===== STATISTIQUES ===== + async getMerchantStats(requesterId: string): Promise { + await this.validateHubAccess(requesterId); + + const allMerchants = await this.getAllMerchantPartners(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.status === 'ACTIVE').length, + suspendedMerchants: allMerchants.filter(m => m.status === 'SUSPENDED').length, + pendingMerchants: allMerchants.filter(m => m.status === 'PENDING').length, + totalUsers, + }; + } + + // ===== MÉTHODES PRIVÉES ===== + private async validateMerchantCreationPermissions(creatorId: string): Promise { + const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId); + const canCreateMerchant = creatorRoles.some(role => + [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) + ); + + if (!canCreateMerchant) { + throw new ForbiddenException('Only hub administrators can create merchant partners'); + } + } + + 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'); + 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'); + } + + 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 checkDuplicateMerchant(merchantData: CreateMerchantPartnerData): Promise { + const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.dcbPartnerOwner.username); + if (existingUsers.length > 0) { + throw new BadRequestException(`Merchant partner with username ${merchantData.dcbPartnerOwner.username} already exists`); + } + + const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.dcbPartnerOwner.email); + if (existingEmails.length > 0) { + throw new BadRequestException(`Merchant partner with email ${merchantData.dcbPartnerOwner.email} already exists`); + } + } + + 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 mapToMerchantPartner(user: KeycloakUser): MerchantPartner { + return { + id: user.username, + name: user.attributes?.merchantPartnerName?.[0] || user.username, + legalName: user.attributes?.merchantLegalName?.[0] || '', + email: user.attributes?.merchantEmail?.[0] || user.email, + phone: user.attributes?.merchantPhone?.[0], + address: user.attributes?.merchantAddress?.[0], + status: (user.attributes?.merchantStatus?.[0] as 'ACTIVE' | 'SUSPENDED' | 'PENDING') || 'ACTIVE', + createdAt: user.attributes?.merchantCreatedAt?.[0] + ? new Date(user.attributes.merchantCreatedAt[0]) + : new Date(user.createdTimestamp || Date.now()), + updatedAt: user.attributes?.merchantUpdatedAt?.[0] + ? new Date(user.attributes.merchantUpdatedAt[0]) + : new Date(user.createdTimestamp || Date.now()), + dcbPartnerUserId: user.id!, + }; + } + + 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}`); + } + } + } + } +} \ 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 new file mode 100644 index 0000000..651be94 --- /dev/null +++ b/src/hub-users/services/merchant-users.service.ts @@ -0,0 +1,192 @@ +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 9959db4..5f2818f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,30 +9,38 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('dcb-user-service'); + // Middlewares de sécurité app.use(helmet()); - app.enableCors(); + app.enableCors({ origin: '*' }); + + // Gestion globale des erreurs et validation app.useGlobalFilters(new KeycloakExceptionFilter()); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useGlobalPipes(new ValidationPipe({ + whitelist: true, + transform: true + })); + + // Préfixe global de l'API app.setGlobalPrefix('api/v1'); - // Swagger Configuration + // Configuration Swagger const config = new DocumentBuilder() .setTitle('DCB User Service API') .setDescription('API de gestion des utilisateurs pour le système DCB') .setVersion('1.0') - .addTag('users', 'Gestion des utilisateurs') - .addBearerAuth() - //.addServer('http://localhost:3000', 'Développement local') - // .addServer('https://api.example.com', 'Production') + .addTag('users', 'Gestion des Utilisateurs') + .addTag('partners', 'Gestion des Partenaires/Marchants') + .addBearerAuth() .build(); + const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api-docs', app, document); - app.enableCors({ origin: '*' }) + // Démarrage du serveur const port = process.env.PORT || 3000; await app.listen(port); logger.log(`Application running on http://localhost:${port}`); - logger.log(`Swagger documentation available at http://localhost:${port}/api/docs`); + logger.log(`Swagger documentation available at http://localhost:${port}/api-docs`); } -bootstrap(); +bootstrap() \ No newline at end of file diff --git a/src/users/controllers/merchants.controller.ts b/src/users/controllers/merchants.controller.ts deleted file mode 100644 index 4b5381c..0000000 --- a/src/users/controllers/merchants.controller.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Put, - Query, - Logger, - HttpException, - HttpStatus, -} from "@nestjs/common"; -import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect"; -import { UsersService } from "../services/users.service"; -import { MerchantTeamService } from "../services/merchant-team.service"; -import * as user from "../models/user"; -import { RESOURCES } from '../../constants/resouces'; -import { SCOPES } from '../../constants/scopes'; - -@Controller('merchants') -@Resource(RESOURCES.MERCHANT) -export class MerchantsController { - private readonly logger = new Logger(MerchantsController.name); - - constructor( - private readonly usersService: UsersService, - private readonly merchantTeamService: MerchantTeamService, - ) {} - - // === CREATE USER === - @Post() - @Scopes(SCOPES.WRITE) - async createUser(@Body() createUserDto: user.CreateUserDto): Promise { - this.logger.log(`Creating new user: ${createUserDto.username}`); - try { - const createdUser = await this.usersService.createUser(createUserDto); - return createdUser; - } catch (error: any) { - this.logger.error(`Failed to create user: ${error.message}`); - throw new HttpException(error.message || "Failed to create user", HttpStatus.BAD_REQUEST); - } - } - - // === GET ALL USERS === - @Get() - @Scopes(SCOPES.READ) - async findAllUsers(@Query() query: user.UserQueryDto): Promise { - this.logger.log('Fetching users list'); - try { - const result = await this.usersService.findAllUsers(query); - return result; - } catch (error: any) { - this.logger.error(`Failed to fetch users: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch users", HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - // === GET USER BY ID === - @Get(':id') - @Scopes(SCOPES.READ) - async getUserById(@Param('id') id: string): Promise { - this.logger.log(`Fetching user by ID: ${id}`); - try { - const user = await this.usersService.getUserById(id); - return user; - } catch (error: any) { - this.logger.error(`Failed to fetch user ${id}: ${error.message}`); - throw new HttpException(error.message || "User not found", HttpStatus.NOT_FOUND); - } - } - - // === GET CURRENT USER PROFILE === - @Get('profile/me') - @Scopes(SCOPES.READ) - async getCurrentUserProfile(@AuthenticatedUser() user: any): Promise { - this.logger.log(`User ${user.sub} accessing own profile`); - try { - const userProfile = await this.usersService.getUserProfile(user); - return userProfile; - } catch (error: any) { - this.logger.error(`Failed to fetch user profile: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch user profile", HttpStatus.NOT_FOUND); - } - } - - // === UPDATE USER === - @Put(':id') - @Scopes(SCOPES.WRITE) - async updateUser( - @Param('id') id: string, - @Body() updateUserDto: user.UpdateUserDto - ): Promise { - this.logger.log(`Updating user: ${id}`); - try { - const updatedUser = await this.usersService.updateUser(id, updateUserDto); - return updatedUser; - } catch (error: any) { - this.logger.error(`Failed to update user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to update user", HttpStatus.BAD_REQUEST); - } - } - - // === UPDATE CURRENT USER PROFILE === - @Put('profile/me') - async updateCurrentUserProfile( - @AuthenticatedUser() user: any, - @Body() updateUserDto: user.UpdateUserDto - ): Promise { - this.logger.log(`User ${user.sub} updating own profile`); - try { - // Un utilisateur ne peut mettre à jour que son propre profil - const updatedUser = await this.usersService.updateUser(user.sub, updateUserDto); - return updatedUser; - } catch (error: any) { - this.logger.error(`Failed to update user profile: ${error.message}`); - throw new HttpException(error.message || "Failed to update user profile", HttpStatus.BAD_REQUEST); - } - } - - // === DELETE USER === - @Delete(':id') - @Scopes(SCOPES.DELETE) - async deleteUser(@Param('id') id: string): Promise<{ message: string }> { - this.logger.log(`Deleting user: ${id}`); - try { - await this.usersService.deleteUser(id); - return { message: `User ${id} deleted successfully` }; - } catch (error: any) { - this.logger.error(`Failed to delete user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to delete user", HttpStatus.BAD_REQUEST); - } - } - - // === RESET PASSWORD === - @Put(':id/password') - @Scopes(SCOPES.WRITE) - async resetPassword( - @Param('id') id: string, - @Body() resetPasswordDto: user.ResetPasswordDto - ): Promise<{ message: string }> { - this.logger.log(`Resetting password for user: ${id}`); - try { - await this.usersService.resetPassword(resetPasswordDto); - return { message: 'Password reset successfully' }; - } catch (error: any) { - this.logger.error(`Failed to reset password for user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST); - } - } - - // === ENABLE USER === - @Put(':id/enable') - @Scopes(SCOPES.WRITE) - async enableUser(@Param('id') id: string): Promise<{ message: string }> { - this.logger.log(`Enabling user: ${id}`); - try { - await this.usersService.enableUser(id); - return { message: 'User enabled successfully' }; - } catch (error: any) { - this.logger.error(`Failed to enable user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to enable user", HttpStatus.BAD_REQUEST); - } - } - - // === DISABLE USER === - @Put(':id/disable') - @Scopes(SCOPES.WRITE) - async disableUser(@Param('id') id: string): Promise<{ message: string }> { - this.logger.log(`Disabling user: ${id}`); - try { - await this.usersService.disableUser(id); - return { message: 'User disabled successfully' }; - } catch (error: any) { - this.logger.error(`Failed to disable user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to disable user", HttpStatus.BAD_REQUEST); - } - } - - // === CHECK USER EXISTS === - @Get('check/:username') - @Scopes(SCOPES.READ) - async userExists(@Param('username') username: string): Promise<{ exists: boolean }> { - this.logger.log(`Checking if user exists: ${username}`); - try { - const exists = await this.usersService.userExists(username); - return { exists }; - } catch (error: any) { - this.logger.error(`Failed to check if user exists ${username}: ${error.message}`); - throw new HttpException(error.message || "Failed to check user existence", HttpStatus.BAD_REQUEST); - } - } - - // === SEARCH USERS BY USERNAME === - @Get('search/username/:username') - @Scopes(SCOPES.READ) - async findUserByUsername(@Param('username') username: string): Promise { - this.logger.log(`Searching users by username: ${username}`); - try { - const users = await this.usersService.findUserByUsername(username); - return users; - } catch (error: any) { - this.logger.error(`Failed to search users by username ${username}: ${error.message}`); - throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST); - } - } - - // === SEARCH USERS BY EMAIL === - @Get('search/email/:email') - @Scopes(SCOPES.READ) - async findUserByEmail(@Param('email') email: string): Promise { - this.logger.log(`Searching users by email: ${email}`); - try { - const users = await this.usersService.findUserByEmail(email); - return users; - } catch (error: any) { - this.logger.error(`Failed to search users by email ${email}: ${error.message}`); - throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST); - } - } - - // === GET USER ROLES === - @Get(':id/roles') - @Scopes(SCOPES.READ) - async getUserClientRoles(@Param('id') id: string): Promise<{ roles: string[] }> { - this.logger.log(`Fetching roles for user: ${id}`); - try { - const roles = await this.usersService.getUserClientRoles(id); - return { roles }; - } catch (error: any) { - this.logger.error(`Failed to fetch roles for user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch user roles", HttpStatus.BAD_REQUEST); - } - } - - // === ASSIGN CLIENT ROLES TO USER === - @Put(':id/roles') - @Scopes(SCOPES.WRITE) - async assignClientRoles( - @Param('id') id: string, - @Body() assignRolesDto: { roles: string[] } - ): Promise<{ message: string }> { - this.logger.log(`Assigning roles to user: ${id}`, assignRolesDto.roles); - try { - await this.usersService.assignClientRoles(id, assignRolesDto.roles); - return { message: 'Roles assigned successfully' }; - } catch (error: any) { - this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to assign roles", HttpStatus.BAD_REQUEST); - } - } - - // ============================================= - // === ROUTES SPECIFIQUES POUR LES MARCHANDS === - // ============================================= - - // === CREER UN UTILISATEUR DANS L'EQUIPE MARCHAND === - @Post('merchant/team') - @Scopes(SCOPES.WRITE) - async createMerchantTeamUser( - @AuthenticatedUser() user: any, - @Body() createMerchantUserDto: any - ): Promise { - this.logger.log(`Merchant ${user.sub} creating team user: ${createMerchantUserDto.username}`); - try { - const createdUser = await this.merchantTeamService.createMerchantUser( - createMerchantUserDto, - user.sub - ); - return createdUser; - } catch (error: any) { - this.logger.error(`Failed to create merchant team user: ${error.message}`); - throw new HttpException(error.message || "Failed to create merchant team user", HttpStatus.BAD_REQUEST); - } - } - - // === OBTENIR L'EQUIPE DU MARCHAND === - @Get('merchant/team') - @Scopes(SCOPES.READ) - async getMerchantTeam(@AuthenticatedUser() user: any): Promise { - this.logger.log(`Merchant ${user.sub} fetching team`); - try { - const team = await this.merchantTeamService.getMerchantTeam(user.sub); - return team; - } catch (error: any) { - this.logger.error(`Failed to fetch merchant team: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch merchant team", HttpStatus.BAD_REQUEST); - } - } - - // === METTRE A JOUR UN MEMBRE DE L'EQUIPE === - @Put('merchant/team/:userId') - @Scopes(SCOPES.WRITE) - async updateMerchantTeamUser( - @AuthenticatedUser() user: any, - @Param('userId') userId: string, - @Body() updateData: any - ): Promise { - this.logger.log(`Merchant ${user.sub} updating team user: ${userId}`); - try { - const updatedUser = await this.merchantTeamService.updateMerchantUser( - userId, - updateData, - user.sub - ); - return updatedUser; - } catch (error: any) { - this.logger.error(`Failed to update merchant team user ${userId}: ${error.message}`); - throw new HttpException(error.message || "Failed to update merchant team user", HttpStatus.BAD_REQUEST); - } - } - - // === RETIRER UN MEMBRE DE L'EQUIPE === - @Delete('merchant/team/:userId') - @Scopes(SCOPES.DELETE) - async removeMerchantTeamUser( - @AuthenticatedUser() user: any, - @Param('userId') userId: string - ): Promise<{ message: string }> { - this.logger.log(`Merchant ${user.sub} removing team user: ${userId}`); - try { - await this.merchantTeamService.removeMerchantUser(userId, user.sub); - return { message: 'Team member removed successfully' }; - } catch (error: any) { - this.logger.error(`Failed to remove merchant team user ${userId}: ${error.message}`); - throw new HttpException(error.message || "Failed to remove merchant team user", HttpStatus.BAD_REQUEST); - } - } - - // === AJOUTER UN ROLE A UN MEMBRE === - @Post('merchant/team/:userId/roles/:role') - @Scopes(SCOPES.WRITE) - async addMerchantRole( - @AuthenticatedUser() user: any, - @Param('userId') userId: string, - @Param('role') role: string - ): Promise<{ message: string }> { - this.logger.log(`Merchant ${user.sub} adding role ${role} to user ${userId}`); - try { - const result = await this.merchantTeamService.addMerchantRole(userId, role, user.sub); - return result; - } catch (error: any) { - this.logger.error(`Failed to add merchant role to user ${userId}: ${error.message}`); - throw new HttpException(error.message || "Failed to add merchant role", HttpStatus.BAD_REQUEST); - } - } - - // === RETIRER UN ROLE D'UN MEMBRE === - @Delete('merchant/team/:userId/roles/:role') - @Scopes(SCOPES.WRITE) - async removeMerchantRole( - @AuthenticatedUser() user: any, - @Param('userId') userId: string, - @Param('role') role: string - ): Promise<{ message: string }> { - this.logger.log(`Merchant ${user.sub} removing role ${role} from user ${userId}`); - try { - const result = await this.merchantTeamService.removeMerchantRole(userId, role, user.sub); - return result; - } catch (error: any) { - this.logger.error(`Failed to remove merchant role from user ${userId}: ${error.message}`); - throw new HttpException(error.message || "Failed to remove merchant role", HttpStatus.BAD_REQUEST); - } - } - - // === OBTENIR LES ROLES MARCHANDS DISPONIBLES === - @Get('merchant/roles/available') - @Scopes(SCOPES.READ) - async getAvailableMerchantRoles(): Promise<{ roles: string[] }> { - this.logger.log('Fetching available merchant roles'); - try { - const roles = this.merchantTeamService.getAvailableMerchantRoles(); - return { roles }; - } catch (error: any) { - this.logger.error(`Failed to fetch available merchant roles: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch available merchant roles", HttpStatus.BAD_REQUEST); - } - } - - // === VERIFIER SI UN UTILISATEUR EST DANS L'EQUIPE === - @Get('merchant/team/:userId/check') - @Scopes(SCOPES.READ) - async checkUserInTeam( - @AuthenticatedUser() user: any, - @Param('userId') userId: string - ): Promise<{ isInTeam: boolean }> { - this.logger.log(`Checking if user ${userId} is in merchant ${user.sub} team`); - try { - const isInTeam = await this.merchantTeamService.isUserInMerchantTeam(userId, user.sub); - return { isInTeam }; - } catch (error: any) { - this.logger.error(`Failed to check team membership: ${error.message}`); - throw new HttpException(error.message || "Failed to check team membership", HttpStatus.BAD_REQUEST); - } - } -} \ No newline at end of file diff --git a/src/users/controllers/users.controller.ts b/src/users/controllers/users.controller.ts deleted file mode 100644 index a1e82c9..0000000 --- a/src/users/controllers/users.controller.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Put, - Query, - Logger, - HttpException, - HttpStatus, - UseGuards, - Request, -} from "@nestjs/common"; -import { Roles, AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect"; -import { UsersService } from "../services/users.service"; -import { MerchantTeamService } from "../services/merchant-team.service"; -import * as user from "../models/user"; -import { RESOURCES } from '../../constants/resouces'; -import { SCOPES } from '../../constants/scopes'; - -@Controller('users') -@Resource(RESOURCES.USER) -export class UsersController { - private readonly logger = new Logger(UsersController.name); - - constructor( - private readonly usersService: UsersService, - private readonly merchantTeamService: MerchantTeamService, - ) {} - - // === CREATE USER === - @Post() - @Scopes(SCOPES.WRITE) - async createUser(@Body() createUserDto: user.CreateUserDto): Promise { - this.logger.log(`Creating new user: ${createUserDto.username}`); - try { - const createdUser = await this.usersService.createUser(createUserDto); - return createdUser; - } catch (error: any) { - this.logger.error(`Failed to create user: ${error.message}`); - throw new HttpException(error.message || "Failed to create user", HttpStatus.BAD_REQUEST); - } - } - - // === GET ALL USERS === - @Get() - @Scopes(SCOPES.READ) - async findAllUsers(@Query() query: user.UserQueryDto): Promise { - this.logger.log('Fetching users list'); - try { - const result = await this.usersService.findAllUsers(query); - return result; - } catch (error: any) { - this.logger.error(`Failed to fetch users: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch users", HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - // === GET USER BY ID === - @Get(':id') - @Scopes(SCOPES.READ) - async getUserById(@Param('id') id: string): Promise { - this.logger.log(`Fetching user by ID: ${id}`); - try { - const user = await this.usersService.getUserById(id); - return user; - } catch (error: any) { - this.logger.error(`Failed to fetch user ${id}: ${error.message}`); - throw new HttpException(error.message || "User not found", HttpStatus.NOT_FOUND); - } - } - - // === GET CURRENT USER PROFILE === - @Get('profile/me') - @Scopes(SCOPES.READ) - async getCurrentUserProfile(@AuthenticatedUser() user: any): Promise { - this.logger.log(`User ${user.sub} accessing own profile`); - try { - const userProfile = await this.usersService.getUserProfile(user); - return userProfile; - } catch (error: any) { - this.logger.error(`Failed to fetch user profile: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch user profile", HttpStatus.NOT_FOUND); - } - } - - // === UPDATE USER === - @Put(':id') - @Scopes(SCOPES.WRITE) - async updateUser( - @Param('id') id: string, - @Body() updateUserDto: user.UpdateUserDto - ): Promise { - this.logger.log(`Updating user: ${id}`); - try { - const updatedUser = await this.usersService.updateUser(id, updateUserDto); - return updatedUser; - } catch (error: any) { - this.logger.error(`Failed to update user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to update user", HttpStatus.BAD_REQUEST); - } - } - - // === UPDATE CURRENT USER PROFILE === - @Put('profile/me') - async updateCurrentUserProfile( - @AuthenticatedUser() user: any, - @Body() updateUserDto: user.UpdateUserDto - ): Promise { - this.logger.log(`User ${user.sub} updating own profile`); - try { - // Un utilisateur ne peut mettre à jour que son propre profil - const updatedUser = await this.usersService.updateUser(user.sub, updateUserDto); - return updatedUser; - } catch (error: any) { - this.logger.error(`Failed to update user profile: ${error.message}`); - throw new HttpException(error.message || "Failed to update user profile", HttpStatus.BAD_REQUEST); - } - } - - // === DELETE USER === - @Delete(':id') - @Scopes(SCOPES.DELETE) - async deleteUser(@Param('id') id: string): Promise<{ message: string }> { - this.logger.log(`Deleting user: ${id}`); - try { - await this.usersService.deleteUser(id); - return { message: `User ${id} deleted successfully` }; - } catch (error: any) { - this.logger.error(`Failed to delete user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to delete user", HttpStatus.BAD_REQUEST); - } - } - - // === RESET PASSWORD === - @Put(':id/password') - @Scopes(SCOPES.WRITE) - async resetPassword( - @Param('id') id: string, - @Body() resetPasswordDto: user.ResetPasswordDto - ): Promise<{ message: string }> { - this.logger.log(`Resetting password for user: ${id}`); - try { - await this.usersService.resetPassword(resetPasswordDto); - return { message: 'Password reset successfully' }; - } catch (error: any) { - this.logger.error(`Failed to reset password for user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST); - } - } - - // === ENABLE USER === - @Put(':id/enable') - @Scopes(SCOPES.WRITE) - async enableUser(@Param('id') id: string): Promise<{ message: string }> { - this.logger.log(`Enabling user: ${id}`); - try { - await this.usersService.enableUser(id); - return { message: 'User enabled successfully' }; - } catch (error: any) { - this.logger.error(`Failed to enable user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to enable user", HttpStatus.BAD_REQUEST); - } - } - - // === DISABLE USER === - @Put(':id/disable') - @Scopes(SCOPES.WRITE) - async disableUser(@Param('id') id: string): Promise<{ message: string }> { - this.logger.log(`Disabling user: ${id}`); - try { - await this.usersService.disableUser(id); - return { message: 'User disabled successfully' }; - } catch (error: any) { - this.logger.error(`Failed to disable user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to disable user", HttpStatus.BAD_REQUEST); - } - } - - // === CHECK USER EXISTS === - @Get('check/:username') - @Scopes(SCOPES.READ) - async userExists(@Param('username') username: string): Promise<{ exists: boolean }> { - this.logger.log(`Checking if user exists: ${username}`); - try { - const exists = await this.usersService.userExists(username); - return { exists }; - } catch (error: any) { - this.logger.error(`Failed to check if user exists ${username}: ${error.message}`); - throw new HttpException(error.message || "Failed to check user existence", HttpStatus.BAD_REQUEST); - } - } - - // === SEARCH USERS BY USERNAME === - @Get('search/username/:username') - @Scopes(SCOPES.READ) - async findUserByUsername(@Param('username') username: string): Promise { - this.logger.log(`Searching users by username: ${username}`); - try { - const users = await this.usersService.findUserByUsername(username); - return users; - } catch (error: any) { - this.logger.error(`Failed to search users by username ${username}: ${error.message}`); - throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST); - } - } - - // === SEARCH USERS BY EMAIL === - @Get('search/email/:email') - @Scopes(SCOPES.READ) - async findUserByEmail(@Param('email') email: string): Promise { - this.logger.log(`Searching users by email: ${email}`); - try { - const users = await this.usersService.findUserByEmail(email); - return users; - } catch (error: any) { - this.logger.error(`Failed to search users by email ${email}: ${error.message}`); - throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST); - } - } - - // === GET USER ROLES === - @Get(':id/roles') - @Scopes(SCOPES.READ) - async getUserClientRoles(@Param('id') id: string): Promise<{ roles: string[] }> { - this.logger.log(`Fetching roles for user: ${id}`); - try { - const roles = await this.usersService.getUserClientRoles(id); - return { roles }; - } catch (error: any) { - this.logger.error(`Failed to fetch roles for user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to fetch user roles", HttpStatus.BAD_REQUEST); - } - } - - // === ASSIGN CLIENT ROLES TO USER === - @Put(':id/roles') - @Scopes(SCOPES.WRITE) - async assignClientRoles( - @Param('id') id: string, - @Body() assignRolesDto: { roles: string[] } - ): Promise<{ message: string }> { - this.logger.log(`Assigning roles to user: ${id}`, assignRolesDto.roles); - try { - await this.usersService.assignClientRoles(id, assignRolesDto.roles); - return { message: 'Roles assigned successfully' }; - } catch (error: any) { - this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`); - throw new HttpException(error.message || "Failed to assign roles", HttpStatus.BAD_REQUEST); - } - } -} \ No newline at end of file diff --git a/src/users/models/merchant.ts b/src/users/models/merchant.ts deleted file mode 100644 index 0808195..0000000 --- a/src/users/models/merchant.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator'; - -export class MerchanUser { - id?: string; - username: string; - email: string; - firstName: string; - lastName: string; - enabled?: boolean = true; - emailVerified?: boolean = false; - attributes?: Record; - merchantRoles?: string[]; // Rôles client uniquement (merchant-admin, merchant-manager, merchant-support) - createdTimestamp?: number; - merchantOwnerId: string; - - constructor(partial?: Partial) { - if (partial) { - Object.assign(this, partial); - } - } -} - -export class MerchantUserCredentials { - type: string; - value: string; - temporary: boolean = false; - - constructor(type: string, value: string, temporary: boolean = false) { - this.type = type; - this.value = value; - this.temporary = temporary; - } -} - -export class CreateMerchantUserDto { - @IsString() - @MinLength(3) - username: string; - - @IsEmail() - email: string; - - @IsString() - firstName: string; - - @IsString() - lastName: string; - - @IsString() - @MinLength(8) - password: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean = true; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean = false; - - @IsOptional() - attributes?: Record; - - @IsOptional() - @IsArray() - merchantRoles: string[]; -} - -export class UpdateMerchantUserDto { - @IsOptional() - @IsString() - username?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - @IsString() - firstName: string; - - @IsOptional() - @IsString() - lastName: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean; - - @IsOptional() - attributes?: Record; - - @IsOptional() - @IsArray() - merchantRoles?: string[]; -} - -export class MerchantUserQueryDto { - @IsOptional() - page?: number = 1; - - @IsOptional() - limit?: number = 10; - - @IsOptional() - @IsString() - search?: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean; -} - -export class ResetPasswordDto { - @IsString() - userId: string; - - @IsString() - @MinLength(8) - newPassword: string; - - @IsOptional() - @IsBoolean() - temporary?: boolean = false; -} - -export class MerchantUserResponse { - id: string; - username: string; - email: string; - firstName: string; - lastName: string; - enabled: boolean; - emailVerified: boolean; - attributes?: Record; - merchantRoles: string[]; // Rôles client uniquement - createdTimestamp: number; - merchantOwnerId: string; - - constructor(user: any) { - this.id = user.id; - this.username = user.username; - this.email = user.email; - this.firstName = user.firstName; - this.lastName = user.lastName; - this.enabled = user.enabled; - this.emailVerified = user.emailVerified; - this.attributes = user.attributes; - this.merchantRoles = user.merchantRoles || []; - this.createdTimestamp = user.createdTimestamp; - this.merchantOwnerId = user.merchantOwnerId; - } -} - -// Interface pour les réponses paginées -export class PaginatedMerchantUserResponse { - users: MerchantUserResponse[]; - total: number; - page: number; - limit: number; - totalPages: number; - - constructor(users: MerchantUserResponse[], total: number, page: number, limit: number) { - this.users = users; - this.total = total; - this.page = page; - this.limit = limit; - this.totalPages = Math.ceil(total / limit); - } -} - -export class AssignRolesDto { - @IsArray() - @IsString({ each: true }) - roles: string[]; -} - -// Types pour les rôles client -export type MerchantRole = 'merchant-admin' | 'merchant-manager' | 'merchant-support'; - -// Interface 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; - scope?: string; -} \ No newline at end of file diff --git a/src/users/models/user.ts b/src/users/models/user.ts deleted file mode 100644 index d52fec8..0000000 --- a/src/users/models/user.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator'; - -export class User { - id?: string; - username: string; - email: string; - firstName?: string; - lastName?: string; - enabled?: boolean = true; - emailVerified?: boolean = false; - attributes?: Record; - clientRoles?: string[]; // Rôles client uniquement (admin, merchant, support) - createdTimestamp?: number; - - constructor(partial?: Partial) { - if (partial) { - Object.assign(this, partial); - } - } -} - -export class UserCredentials { - type: string; - value: string; - temporary: boolean = false; - - constructor(type: string, value: string, temporary: boolean = false) { - this.type = type; - this.value = value; - this.temporary = temporary; - } -} - -export class CreateUserDto { - @IsString() - @MinLength(3) - username: string; - - @IsEmail() - email: string; - - @IsString() - firstName?: string; - - @IsString() - lastName?: string; - - @IsString() - @MinLength(8) - password: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean = true; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean = false; - - @IsOptional() - attributes?: Record; - - @IsOptional() - @IsArray() - clientRoles?: string[]; -} - -export class UpdateUserDto { - @IsOptional() - @IsString() - username?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - @IsString() - firstName?: string; - - @IsOptional() - @IsString() - lastName?: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean; - - @IsOptional() - attributes?: Record; - - @IsOptional() - @IsArray() - clientRoles?: string[]; -} - -export class UserQueryDto { - @IsOptional() - page?: number = 1; - - @IsOptional() - limit?: number = 10; - - @IsOptional() - @IsString() - search?: string; - - @IsOptional() - @IsBoolean() - enabled?: boolean; - - @IsOptional() - @IsBoolean() - emailVerified?: boolean; -} - -export class ResetPasswordDto { - @IsString() - userId: string; - - @IsString() - @MinLength(8) - newPassword: string; - - @IsOptional() - @IsBoolean() - temporary?: boolean = false; -} - -export class UserResponse { - id: string; - username: string; - email: string; - firstName: string; - lastName: string; - enabled: boolean; - emailVerified: boolean; - attributes?: Record; - clientRoles: string[]; // Rôles client uniquement - createdTimestamp: number; - - constructor(user: any) { - this.id = user.id; - this.username = user.username; - this.email = user.email; - this.firstName = user.firstName; - this.lastName = user.lastName; - this.enabled = user.enabled; - this.emailVerified = user.emailVerified; - this.attributes = user.attributes; - this.clientRoles = user.clientRoles || []; - this.createdTimestamp = user.createdTimestamp; - } -} - -// Interface pour les réponses paginées -export class PaginatedUserResponse { - users: UserResponse[]; - total: number; - page: number; - limit: number; - totalPages: number; - - constructor(users: UserResponse[], total: number, page: number, limit: number) { - this.users = users; - this.total = total; - this.page = page; - this.limit = limit; - this.totalPages = Math.ceil(total / limit); - } -} - -export class AssignRolesDto { - @IsArray() - @IsString({ each: true }) - roles: string[]; -} - -// Types pour les rôles client -export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support'; - -// Interface 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; - scope?: string; -} \ No newline at end of file diff --git a/src/users/services/merchant-team.service.ts b/src/users/services/merchant-team.service.ts deleted file mode 100644 index b1c865e..0000000 --- a/src/users/services/merchant-team.service.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { - Injectable, - Logger, - NotFoundException, - BadRequestException, - ForbiddenException, -} from '@nestjs/common'; -import { KeycloakApiService } from '../../auth/services/keycloak-api.service'; -import { UsersService } from './users.service'; - -import { - CreateMerchantUserDto, - UpdateMerchantUserDto, - MerchantUserResponse, - PaginatedMerchantUserResponse, - ResetPasswordDto, - MerchantUserQueryDto, - LoginDto, - TokenResponse, - MerchantRole -} from '../models/merchant'; - -@Injectable() -export class MerchantTeamService { - private readonly logger = new Logger(MerchantTeamService.name); - - // Rôles spécifiques aux équipes de marchands - private readonly availableMerchantRoles = [ - 'merchant-admin', - 'merchant-manager', - 'merchant-support', - 'merchant-user' - ]; - - constructor( - private readonly keycloakApi: KeycloakApiService, - private readonly usersService: UsersService, - ) {} - - // === VALIDATION DES ROLES MARCHAND === - private validateMerchantRole(role: string): MerchantRole { - const validRoles: MerchantRole[] = ['merchant-admin', 'merchant-manager', 'merchant-support']; - - if (!validRoles.includes(role as MerchantRole)) { - throw new BadRequestException(`Invalid client role: ${role}. Valid roles are: ${validRoles.join(', ')}`); - } - return role as MerchantRole; - } - - private validateMerchantRoles(roles: string[]): MerchantRole[] { - return roles.map(role => this.validateMerchantRole(role)); - } - - // === VERIFICATION DES DROITS D'ACCÈS === - private async validateMerchantAccess(targetUserId: string | null, requestingUserId: string): Promise<{ - success: boolean; - error?: string - }> { - try { - const requestingUserRoles = await this.usersService.getUserClientRoles(requestingUserId); - - // Les admins peuvent tout faire - if (requestingUserRoles.includes('admin')) { - return { success: true }; - } - - // Vérifier que le demandeur est un marchand - if (!requestingUserRoles.includes('merchant')) { - return { - success: false, - error: 'Access denied: Merchant role required' - }; - } - - // Pour les opérations sur un utilisateur spécifique - if (targetUserId) { - const targetOwnerId = await this.usersService.getMerchantOwner(targetUserId); - - // Si l'utilisateur cible n'a pas de merchantOwnerId, c'est un marchand principal - if (!targetOwnerId) { - return { - success: false, - error: 'Access denied: Cannot modify principal merchant accounts' - }; - } - - // Vérifier que le demandeur est bien le propriétaire - if (targetOwnerId !== requestingUserId) { - return { - success: false, - error: 'Access denied: Can only manage your own team members' - }; - } - } - - return { success: true }; - } catch (error: any) { - return { - success: false, - error: `Failed to validate access: ${error.message}` - }; - } - } - - // === CREATION D'UTILISATEUR MARCHAND === - async createMerchantUser( - merchantData: CreateMerchantUserDto, - createdByUserId: string - ): Promise { - try { - await this.validateMerchantAccess(null, createdByUserId); - - const validatedMerchantRoles = this.validateMerchantRoles(merchantData.merchantRoles); - - const userData = { - username: merchantData.username, - email: merchantData.email, - firstName: merchantData.firstName, - lastName: merchantData.lastName, - password: merchantData.password, - enabled: merchantData.enabled ?? true, - clientRoles: ['merchant', ...validatedMerchantRoles] as any, - attributes: { - merchantOwnerId: [createdByUserId] - } - }; - - const createdUser = await this.usersService.createUser(userData); - - return { - id: createdUser.id, - username: createdUser.username, - email: createdUser.email, - firstName: createdUser.firstName, - lastName: createdUser.lastName, - merchantRoles: validatedMerchantRoles, - emailVerified: true, - enabled: createdUser.enabled, - createdTimestamp: createdUser.createdTimestamp, - merchantOwnerId: createdByUserId - }; - } catch (error: any) { - this.logger.error(`Failed to create merchant user: ${error.message}`); - throw error; - } - } - - // === OBTENIR L'EQUIPE DU MARCHAND === - async getMerchantTeam(merchantUserId: string): Promise { - try { - // Vérifier les droits - await this.validateMerchantAccess(null, merchantUserId); - - // Obtenir tous les utilisateurs - const allUsers = await this.usersService.findAllUsers({ - limit: 1000, - page: 1 - }); - - const teamMembers = await Promise.all( - allUsers.users.map(async (user) => { - try { - const roles = await this.usersService.getUserClientRoles(user.id); - const merchantRoles = roles.filter(role => - role.startsWith('merchant-') && role !== 'merchant' - ); - - // Récupérer l'owner ID - const merchantOwnerId = user.attributes?.merchantOwnerId?.[0]; - - // Filtrer : ne retourner que les utilisateurs de CE marchand - if (merchantRoles.length > 0 && merchantOwnerId === merchantUserId) { - return { - id: user.id, - username: user.username, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - merchantRoles, - enabled: user.enabled, - createdTimestamp: user.createdTimestamp, - merchantOwnerId - }; - } - return null; - } catch (error) { - this.logger.warn(`Failed to process user ${user.id}: ${error.message}`); - return null; - } - }) - ); - - return teamMembers.filter((member): member is MerchantUserResponse => member !== null); - } catch (error: any) { - this.logger.error(`Failed to fetch merchant team: ${error.message}`); - throw new BadRequestException('Failed to fetch merchant team'); - } - } - - // === METTRE A JOUR UN MEMBRE === - async updateMerchantUser( - userId: string, - updateData: UpdateMerchantUserDto, - updatedByUserId: string - ): Promise { - try { - // Vérifier les droits - await this.validateMerchantAccess(userId, updatedByUserId); - - // Préparer les données de mise à jour - const updatePayload: any = {}; - if (updateData.firstName) updatePayload.firstName = updateData.firstName; - if (updateData.lastName) updatePayload.lastName = updateData.lastName; - if (updateData.email) updatePayload.email = updateData.email; - if (updateData.enabled !== undefined) updatePayload.enabled = updateData.enabled; - - // Mettre à jour les rôles si fournis - if (updateData.merchantRoles) { - const validatedRoles = this.validateMerchantRoles(updateData.merchantRoles); - const finalRoles = ['merchant', ...validatedRoles]; - await this.usersService.assignClientRoles(userId, finalRoles); - } - - // Mettre à jour les autres informations - if (Object.keys(updatePayload).length > 0) { - await this.usersService.updateUser(userId, updatePayload); - } - - // Récupérer l'utilisateur mis à jour - const updatedUser = await this.usersService.getUserById(userId); - const userRoles = await this.usersService.getUserClientRoles(userId); - const merchantRoles = userRoles.filter(role => - role.startsWith('merchant-') && role !== 'merchant' - ); - - const merchantOwnerId = await this.usersService.getMerchantOwner(userId); - - return { - id: updatedUser.id, - username: updatedUser.username, - email: updatedUser.email, - firstName: updatedUser.firstName, - lastName: updatedUser.lastName, - merchantRoles, - enabled: updatedUser.enabled, - emailVerified: true, - createdTimestamp: updatedUser.createdTimestamp, - merchantOwnerId: merchantOwnerId ?? '', - }; - } catch (error: any) { - this.logger.error(`Failed to update merchant user ${userId}: ${error.message}`); - throw error; - } - } - - // === SUPPRIMER UN MEMBRE (retirer les rôles marchands) === - async removeMerchantUser(userId: string, removedByUserId: string): Promise { - try { - await this.validateMerchantAccess(userId, removedByUserId); - - // Retirer les rôles marchands mais garder l'utilisateur - const userRoles = await this.usersService.getUserClientRoles(userId); - const nonMerchantRoles = userRoles.filter(role => - !role.startsWith('merchant-') && role !== 'merchant' - ); - - await this.usersService.assignClientRoles(userId, nonMerchantRoles); - - // Optionnel: supprimer l'attribut merchantOwnerId - await this.keycloakApi.setUserAttributes(userId, { - merchantOwnerId: [] - }); - } catch (error: any) { - this.logger.error(`Failed to remove merchant user ${userId}: ${error.message}`); - throw error; - } - } - - // === OBTENIR LES ROLES DISPONIBLES === - getAvailableMerchantRoles(): string[] { - return [...this.availableMerchantRoles]; - } - - // === AJOUTER UN ROLE === - async addMerchantRole( - userId: string, - role: string, - addedByUserId: string - ): Promise<{ message: string }> { - try { - await this.validateMerchantAccess(userId, addedByUserId); - - const validatedRole = this.validateMerchantRole(role); - const currentRoles = await this.usersService.getUserClientRoles(userId); - - if (!currentRoles.includes('merchant')) { - currentRoles.push('merchant'); - } - - if (!currentRoles.includes(validatedRole)) { - currentRoles.push(validatedRole); - await this.usersService.assignClientRoles(userId, currentRoles as any); // 🔥 CAST ICI - } - - return { message: `Role ${validatedRole} added successfully` }; - } catch (error: any) { - this.logger.error(`Failed to add merchant role to user ${userId}: ${error.message}`); - throw error; - } - } - - // === RETIRER UN ROLE === - async removeMerchantRole( - userId: string, - role: string, - removedByUserId: string - ): Promise<{ message: string }> { - try { - await this.validateMerchantAccess(userId, removedByUserId); - - const currentRoles = await this.usersService.getUserClientRoles(userId); - const updatedRoles = currentRoles.filter(r => r !== role); - - const hasOtherMerchantRoles = updatedRoles.some(r => - r.startsWith('merchant-') && r !== 'merchant' - ); - - if (!hasOtherMerchantRoles) { - const finalRoles = updatedRoles.filter(r => r !== 'merchant'); - await this.usersService.assignClientRoles(userId, finalRoles as any); // 🔥 CAST ICI - } else { - await this.usersService.assignClientRoles(userId, updatedRoles as any); // 🔥 CAST ICI - } - - return { message: `Role ${role} removed successfully` }; - } catch (error: any) { - this.logger.error(`Failed to remove merchant role from user ${userId}: ${error.message}`); - throw error; - } - } - - // === VERIFIER SI UN UTILISATEUR EST DANS L'EQUIPE === - async isUserInMerchantTeam(userId: string, merchantUserId: string): Promise { - try { - const merchantOwnerId = await this.usersService.getMerchantOwner(userId); - return merchantOwnerId === merchantUserId; - } catch (error) { - this.logger.warn(`Failed to check if user ${userId} is in merchant ${merchantUserId} team: ${error.message}`); - return false; - } - } -} \ No newline at end of file diff --git a/src/users/services/users.service.ts b/src/users/services/users.service.ts deleted file mode 100644 index 630cfa9..0000000 --- a/src/users/services/users.service.ts +++ /dev/null @@ -1,402 +0,0 @@ -import { - Injectable, - Logger, - NotFoundException, - BadRequestException, - ConflictException, -} from '@nestjs/common'; -import { KeycloakApiService } from '../../auth/services/keycloak-api.service'; -import { ConfigService } from '@nestjs/config'; -import { - CreateUserDto, - UpdateUserDto, - UserResponse, - PaginatedUserResponse, - ResetPasswordDto, - UserQueryDto, - LoginDto, - TokenResponse, - ClientRole -} from '../models/user'; - -@Injectable() -export class UsersService { - private readonly logger = new Logger(UsersService.name); - - constructor( - private readonly keycloakApi: KeycloakApiService, - private readonly configService: ConfigService, - ) {} - - // === VALIDATION DES ROLES === - private validateClientRole(role: string): ClientRole { - const validRoles: ClientRole[] = ['admin', 'merchant', 'support', 'merchant-admin', 'merchant-manager', 'merchant-support']; - if (!validRoles.includes(role as ClientRole)) { - throw new BadRequestException(`Invalid client role: ${role}. Valid roles are: ${validRoles.join(', ')}`); - } - return role as ClientRole; - } - - private validateClientRoles(roles: string[]): ClientRole[] { - return roles.map(role => this.validateClientRole(role)); - } - - // === AUTHENTIFICATION UTILISATEUR === - async authenticateUser(loginDto: LoginDto): Promise { - return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password); - } - - // === GET USER BY ID === - async getUserById(userId: string): Promise { - try { - this.logger.debug(`Fetching user by ID: ${userId}`); - - const user = await this.keycloakApi.getUserById(userId); - if (!user) throw new NotFoundException(`User with ID ${userId} not found`); - - const roles = await this.keycloakApi.getUserClientRoles(userId); - const clientRoles = roles.map(role => role.name); - - return new UserResponse({ - ...user, - clientRoles, - attributes: user.attributes || {} - }); - } catch (error: any) { - this.logger.error(`Failed to fetch user ${userId}: ${error.message}`); - if (error instanceof NotFoundException) throw error; - throw new NotFoundException(`User with ID ${userId} not found`); - } - } - - // === GET PROFILE FROM DECODED TOKEN === - async getUserProfile(decodedToken: any): Promise { - try { - const profileData = { - id: decodedToken.sub, - username: decodedToken.preferred_username || decodedToken.username, - email: decodedToken.email, - firstName: decodedToken.given_name || decodedToken.firstName, - lastName: decodedToken.family_name || decodedToken.lastName, - enabled: true, - emailVerified: decodedToken.email_verified || false, - attributes: decodedToken.attributes || {}, - clientRoles: decodedToken.resource_access ? - Object.values(decodedToken.resource_access).flatMap((client: any) => client.roles || []) : [], - realmRoles: decodedToken.realm_access?.roles || [], - createdTimestamp: decodedToken.iat ? decodedToken.iam * 1000 : Date.now() - }; - - return new UserResponse(profileData); - } catch (error: any) { - this.logger.error(`Failed to create user profile from token: ${error.message}`); - throw new NotFoundException('Failed to create user profile'); - } - } - - // === FIND ALL USERS === - async findAllUsers(query: UserQueryDto): Promise { - try { - let users = await this.keycloakApi.getAllUsers(); - - // Filtre de recherche - if (query.search) { - const q = query.search.toLowerCase(); - users = users.filter( - (u) => - u.username?.toLowerCase().includes(q) || - u.email?.toLowerCase().includes(q) || - u.firstName?.toLowerCase().includes(q) || - u.lastName?.toLowerCase().includes(q), - ); - } - - // Filtre par statut enabled - if (query.enabled !== undefined) { - users = users.filter((u) => u.enabled === query.enabled); - } - - if (query.emailVerified !== undefined) { - users = users.filter((u) => u.emailVerified === query.emailVerified); - } - - const page = query.page || 1; - const limit = query.limit || 10; - const startIndex = (page - 1) * limit; - const paginatedUsers = users.slice(startIndex, startIndex + limit); - - const usersWithRoles = await Promise.all( - paginatedUsers.map(async (u) => { - try { - const roles = await this.keycloakApi.getUserClientRoles(u.id!); - const clientRoles = roles.map(role => role.name); - return new UserResponse({ ...u, clientRoles }); - } catch (error) { - this.logger.warn(`Failed to fetch roles for user ${u.id}: ${error.message}`); - return new UserResponse({ ...u, clientRoles: [] }); - } - }), - ); - - return new PaginatedUserResponse(usersWithRoles, users.length, page, limit); - } catch (error: any) { - this.logger.error(`Failed to fetch users: ${error.message}`); - throw new BadRequestException('Failed to fetch users'); - } - } - - // === CREATE USER === - async createUser(userData: CreateUserDto): Promise { - try { - // Vérifier si l'utilisateur existe déjà - const existingByUsername = await this.keycloakApi.findUserByUsername(userData.username); - - if (userData.email) { - const existingByEmail = await this.keycloakApi.findUserByEmail(userData.email); - if (existingByEmail.length > 0) { - throw new ConflictException('User with this email already exists'); - } - } - - if (existingByUsername.length > 0) { - throw new ConflictException('User with this username already exists'); - } - - // Préparer les données utilisateur - const keycloakUserData: any = { - username: userData.username, - email: userData.email, - firstName: userData.firstName, - lastName: userData.lastName, - enabled: userData.enabled ?? true, - credentials: userData.password ? [ - { - type: 'password', - value: userData.password, - temporary: false - } - ] : [] - }; - - // Ajouter les attributs si présents - if (userData.attributes) { - keycloakUserData.attributes = userData.attributes; - } - - const userId = await this.keycloakApi.createUser(keycloakUserData); - - // Attribution des rôles client - if (userData.clientRoles?.length) { - const validatedRoles = this.validateClientRoles(userData.clientRoles); - await this.keycloakApi.setClientRoles(userId, validatedRoles); - } - - // Récupérer l'utilisateur créé - const createdUser = await this.keycloakApi.getUserById(userId); - const roles = await this.keycloakApi.getUserClientRoles(userId); - const clientRoles = roles.map(role => role.name); - - return new UserResponse({ - ...createdUser, - clientRoles, - attributes: createdUser.attributes || {} - }); - } catch (error: any) { - this.logger.error(`Failed to create user: ${error.message}`); - if (error instanceof ConflictException || error instanceof BadRequestException) throw error; - throw new BadRequestException('Failed to create user'); - } - } - - // === METHODE POUR OBTENIR LE MERCHANT OWNER === - async getMerchantOwner(userId: string): Promise { - try { - return await this.keycloakApi.getUserAttribute(userId, 'merchantOwnerId'); - } catch (error) { - this.logger.warn(`Failed to get merchant owner for user ${userId}: ${error.message}`); - return null; - } - } - - // === METHODE POUR DEFINIR LE MERCHANT OWNER === - async setMerchantOwner(userId: string, merchantOwnerId: string): Promise { - try { - await this.keycloakApi.setUserAttributes(userId, { - merchantOwnerId: [merchantOwnerId] - }); - } catch (error) { - this.logger.error(`Failed to set merchant owner for user ${userId}: ${error.message}`); - throw new BadRequestException('Failed to set merchant owner'); - } - } - - // === UPDATE USER === - async updateUser(id: string, userData: UpdateUserDto): Promise { - try { - // Vérifier que l'utilisateur existe - await this.keycloakApi.getUserById(id); - - await this.keycloakApi.updateUser(id, userData); - - // Mettre à jour les rôles si fournis - if (userData.clientRoles) { - const validatedRoles = this.validateClientRoles(userData.clientRoles); - await this.keycloakApi.setClientRoles(id, validatedRoles); - } - - const updatedUser = await this.keycloakApi.getUserById(id); - const roles = await this.keycloakApi.getUserClientRoles(id); - const clientRoles = roles.map(role => role.name); - - return new UserResponse({ ...updatedUser, clientRoles }); - } catch (error: any) { - this.logger.error(`Failed to update user ${id}: ${error.message}`); - if (error instanceof NotFoundException || error instanceof BadRequestException) throw error; - throw new BadRequestException('Failed to update user'); - } - } - - // === ASSIGN CLIENT ROLES TO USER === - async assignClientRoles(userId: string, roles: string[]): Promise<{ message: string }> { - try { - this.logger.log(`Assigning client roles to user: ${userId}`, roles); - - // Vérifier que l'utilisateur existe - await this.keycloakApi.getUserById(userId); - - // Valider et assigner les rôles - const validatedRoles = this.validateClientRoles(roles); - await this.keycloakApi.setClientRoles(userId, validatedRoles); - - this.logger.log(`Successfully assigned ${validatedRoles.length} roles to user ${userId}`); - return { message: 'Roles assigned successfully' }; - } catch (error: any) { - this.logger.error(`Failed to assign roles to user ${userId}: ${error.message}`); - if (error instanceof NotFoundException || error instanceof BadRequestException) throw error; - throw new BadRequestException('Failed to assign roles to user'); - } - } - - // === DELETE USER === - async deleteUser(id: string): Promise { - try { - await this.keycloakApi.getUserById(id); - await this.keycloakApi.deleteUser(id); - } catch (error: any) { - this.logger.error(`Failed to delete user ${id}: ${error.message}`); - if (error instanceof NotFoundException) throw error; - throw new BadRequestException('Failed to delete user'); - } - } - - // === RESET PASSWORD === - async resetPassword(resetPasswordDto: ResetPasswordDto): Promise { - try { - await this.keycloakApi.getUserById(resetPasswordDto.userId); - await this.keycloakApi.resetPassword(resetPasswordDto.userId, resetPasswordDto.newPassword); - } catch (error: any) { - this.logger.error(`Failed to reset password for user ${resetPasswordDto.userId}: ${error.message}`); - if (error instanceof NotFoundException) throw error; - throw new BadRequestException('Failed to reset password'); - } - } - - // === ENABLE/DISABLE USER === - async enableUser(userId: string): Promise { - try { - await this.keycloakApi.enableUser(userId); - } catch (error: any) { - this.logger.error(`Failed to enable user ${userId}: ${error.message}`); - throw new BadRequestException('Failed to enable user'); - } - } - - async disableUser(userId: string): Promise { - try { - await this.keycloakApi.disableUser(userId); - } catch (error: any) { - this.logger.error(`Failed to disable user ${userId}: ${error.message}`); - throw new BadRequestException('Failed to disable user'); - } - } - - // === UTILITY METHODS === - async userExists(username: string): Promise { - try { - const users = await this.keycloakApi.findUserByUsername(username); - return users.length > 0; - } catch { - return false; - } - } - - async getUserClientRoles(userId: string): Promise { - try { - const roles = await this.keycloakApi.getUserClientRoles(userId); - return roles.map(role => role.name); - } catch (error: any) { - this.logger.error(`Failed to get client roles for user ${userId}: ${error.message}`); - throw new BadRequestException('Failed to get user client roles'); - } - } - - async findUserByUsername(username: string): Promise { - try { - const users = await this.keycloakApi.findUserByUsername(username); - - const usersWithRoles = await Promise.all( - users.map(async (user) => { - try { - const roles = await this.keycloakApi.getUserClientRoles(user.id!); - const clientRoles = roles.map(role => role.name); - return new UserResponse({ ...user, clientRoles }); - } catch (error) { - this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`); - return new UserResponse({ ...user, clientRoles: [] }); - } - }) - ); - - return usersWithRoles; - } catch (error: any) { - this.logger.error(`Failed to find user by username ${username}: ${error.message}`); - throw new BadRequestException('Failed to find user by username'); - } - } - - async findUserByEmail(email: string): Promise { - try { - const users = await this.keycloakApi.findUserByEmail(email); - - const usersWithRoles = await Promise.all( - users.map(async (user) => { - try { - const roles = await this.keycloakApi.getUserClientRoles(user.id!); - const clientRoles = roles.map(role => role.name); - return new UserResponse({ ...user, clientRoles }); - } catch (error) { - this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`); - return new UserResponse({ ...user, clientRoles: [] }); - } - }) - ); - - return usersWithRoles; - } catch (error: any) { - this.logger.error(`Failed to find user by email ${email}: ${error.message}`); - throw new BadRequestException('Failed to find user by email'); - } - } - - // === PRIVATE METHODS === - private decodeJwtToken(token: string): any { - try { - const payload = token.split('.')[1]; - const decoded = JSON.parse(Buffer.from(payload, 'base64').toString()); - return decoded; - } catch (error) { - this.logger.error('Failed to decode JWT token', error); - throw new BadRequestException('Invalid token format'); - } - } -} \ No newline at end of file diff --git a/src/users/users.module.ts b/src/users/users.module.ts deleted file mode 100644 index 40e98c8..0000000 --- a/src/users/users.module.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Module } from '@nestjs/common' -import { JwtModule } from '@nestjs/jwt' -import { HttpModule } from '@nestjs/axios'; -import { TokenService } from '../auth/services/token.service' -import { UsersService } from './services/users.service' -import { MerchantTeamService } from 'src/users/services/merchant-team.service'; - -import { UsersController } from './controllers/users.controller' -import { KeycloakApiService } from '../auth/services/keycloak-api.service'; - - -@Module({ - imports: [ - HttpModule, - JwtModule.register({}), - ], - providers: [UsersService, MerchantTeamService, KeycloakApiService, TokenService], - controllers: [UsersController], - exports: [UsersService, MerchantTeamService, KeycloakApiService, TokenService, JwtModule], -}) -export class UsersModule {} - - - From ad751d96bd31c8670d246950d11be5b7bedb500c Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 3 Nov 2025 17:45:19 +0000 Subject: [PATCH 2/7] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- .env-sample | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.env-sample b/.env-sample index 437bb34..720bb10 100644 --- a/.env-sample +++ b/.env-sample @@ -1,28 +1,31 @@ -# .env-sample +# .env NODE_ENV=development PORT=3000 -KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com -KEYCLOAK_REALM=dcb-dev +RUN_STARTUP_TESTS=false -KEYCLOAK_JWKS_URI=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev/protocol/openid-connect/certs -KEYCLOAK_ISSUER=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev +KEYCLOAK_SERVER_URL=https://iam.dcb.pixpay.sn +KEYCLOAK_REALM=dcb-prod -KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwfT6BGerQyJ7EOFcgN1DLxRh/8g3cCN5qNZyeLQc6524Lsw3voMD2HJddvAunCcn6Eux2LTYXPzLvZc8829Sa5ksTzINyPqg9GFZa5+GAifMW6DfvQcxGyl5yvduCWxOSmST3PYN9UkCFP20e3gDLRox9rNe1/17xkDJwByJh/Xld/m07vHgyglDNRGkA/YW3A1JuAKgJjAstLOyeK+UGdMeJmD/5TF/yoBI/FsjW/OjZ78wP3dfkGo5zG2EOkK+39evU7HxB4jgL5SBhw32GLPVhtyCMnUW6IlsQhDSDWXqBdMCO0/hdrjyznyM7ZJqkUN7KAFKqcJsnja9mBNT4QIDAQAB +KEYCLOAK_JWKS_URI=https://iam.dcb.pixpay.sn/realms/dcb-prod/protocol/openid-connect/certs +KEYCLOAK_ISSUER=https://iam.dcb.pixpay.sn/realms/dcb-prod + +KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA01nspe5Sol9YAzm98wnQO1MvhRgJZSaOhozOHJEBm5VW5wLEEfcTlakzr/xXRjFYB9jySeaDWyhE6qGKuRK2Kx20qt3CuwT52ZSy97dKjJbgCxBCOymxKLJRdDfwtKOAayk5oCHqGp+cJTShnd9jVggYyTdqGqMWlpeiBKqvpgyldndwIfvDxPpPwsx/mwKV7S4sSTsONxSIB6zK+RumeYKOF0BskIxBw4tG3V5eicrECCKX/jP8rYFclBPXhxnLbbaHa21XAwQHfOioip3YfwPYF9GKTJEhM8ziJdTKikAtiwFm/Zvn1foLaF1MDLpV9yLrK0H1oa3y7j5p7tqHbQIDAQAB + +KEYCLOAK_CLIENT_ID=dcb-user-service-cc-app +KEYCLOAK_CLIENT_SECRET=IFNQWjBbcW6dXqQO76X5OZb1lL0esO30 -KEYCLOAK_CLIENT_ID=dcb-user-service-pwd -KEYCLOAK_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2 KEYCLOAK_VALIDATION_MODE=offline KEYCLOAK_TOKEN_BUFFER_SECONDS=30 -KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin +KEYCLOAK_TEST_USER_ADMIN=bo-admin KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025 -KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant +KEYCLOAK_TEST_USER_MERCHANT=bo-partner KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025 -KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support +KEYCLOAK_TEST_USER_SUPPORT=bo-support KEYCLOAK_TEST_PASSWORD=@BOSupport2025 From 389488bf283f4138b85bcfc6f539109f6acb09d9 Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Tue, 4 Nov 2025 21:06:44 +0000 Subject: [PATCH 3/7] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- src/auth/services/startup.service.ts | 2 +- .../merchant-partners.controller.ts | 298 --------------- src/hub-users/dto/merchant-partners.dto.ts | 96 ----- src/hub-users/services/hub-users.service.ts | 3 +- .../services/merchant-partners.service.ts | 353 ------------------ 5 files changed, 3 insertions(+), 749 deletions(-) delete mode 100644 src/hub-users/controllers/merchant-partners.controller.ts delete mode 100644 src/hub-users/dto/merchant-partners.dto.ts delete mode 100644 src/hub-users/services/merchant-partners.service.ts diff --git a/src/auth/services/startup.service.ts b/src/auth/services/startup.service.ts index ee6b910..28e2f7c 100644 --- a/src/auth/services/startup.service.ts +++ b/src/auth/services/startup.service.ts @@ -50,7 +50,7 @@ export class StartupService implements OnModuleInit { async onModuleInit() { if (process.env.RUN_STARTUP_TESTS === 'true') { - this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...'); + this.logger.log('Starting comprehensive tests (Hub + Merchants with isolation)...'); await this.runAllTests(); } } diff --git a/src/hub-users/controllers/merchant-partners.controller.ts b/src/hub-users/controllers/merchant-partners.controller.ts deleted file mode 100644 index 86b64fc..0000000 --- a/src/hub-users/controllers/merchant-partners.controller.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Query, - Request, - HttpStatus, - HttpCode, - Logger, - DefaultValuePipe, - ParseIntPipe, - BadRequestException, -} from '@nestjs/common'; - -import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect"; - -import { - MerchantPartnersService, - MerchantPartner, - CreateMerchantPartnerData, - MerchantStats, -} from '../services/merchant-partners.service'; -import { RESOURCES } from '../../constants/resources'; -import { SCOPES } from '../../constants/scopes'; - -// DTOs -import { - CreateMerchantPartnerDto, - UpdateMerchantPartnerDto, - SuspendMerchantPartnerDto, -} from '../dto/merchant-partners.dto'; - -export interface ApiResponse { - success: boolean; - message: string; - data?: T; - timestamp: string; -} - -export interface PaginatedResponse { - items: T[]; - total: number; - page: number; - limit: number; - totalPages: number; -} - -@Controller('partners') -@Resource(RESOURCES.MERCHANT_USER) -export class MerchantPartnersController { - private readonly logger = new Logger(MerchantPartnersController.name); - - constructor(private readonly merchantPartnersService: MerchantPartnersService) {} - - private createApiResponse( - success: boolean, - message: string, - data?: T, - ): ApiResponse { - return { - success, - message, - data, - timestamp: new Date().toISOString(), - }; - } - - // ===== CRÉATION DE MERCHANT PARTNERS ===== - @Post() - @Scopes(SCOPES.WRITE) - async createMerchantPartner( - @Body() createMerchantDto: CreateMerchantPartnerDto, - @Request() req: any, - ): Promise> { - this.logger.log(`Creating merchant partner: ${createMerchantDto.name}`); - - const creatorId = req.user.sub; - - const merchantData: CreateMerchantPartnerData = { - ...createMerchantDto, - dcbPartnerOwner: { - username: createMerchantDto.dcbPartnerOwnerUsername, - email: createMerchantDto.dcbPartnerOwnerEmail, - firstName: createMerchantDto.dcbPartnerOwnerFirstName, - lastName: createMerchantDto.dcbPartnerOwnerLastName, - password: createMerchantDto.dcbPartnerOwnerPassword, - }, - }; - - const merchant = await this.merchantPartnersService.createMerchantPartner(creatorId, merchantData); - - this.logger.log(`Merchant partner created successfully: ${merchant.name} (ID: ${merchant.id})`); - - return this.createApiResponse( - true, - 'Merchant partner created successfully', - merchant, - ); - } - - // ===== RÉCUPÉRATION DE MERCHANT PARTNERS ===== - @Get() - @Scopes(SCOPES.READ) - async getAllMerchantPartners( - @Request() req: any, - @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number, - @Query('status') status?: string, - ): Promise>> { - const requesterId = req.user.sub; - - const safeLimit = Math.min(limit, 100); - const safePage = Math.max(page, 1); - - let merchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId); - - // Filtrage par statut - if (status && ['ACTIVE', 'SUSPENDED', 'PENDING'].includes(status)) { - merchants = merchants.filter(merchant => merchant.status === status); - } - - // Pagination - const startIndex = (safePage - 1) * safeLimit; - const endIndex = startIndex + safeLimit; - const paginatedMerchants = merchants.slice(startIndex, endIndex); - - const response: PaginatedResponse = { - items: paginatedMerchants, - total: merchants.length, - page: safePage, - limit: safeLimit, - totalPages: Math.ceil(merchants.length / safeLimit), - }; - - return this.createApiResponse( - true, - 'Merchant partners retrieved successfully', - response, - ); - } - - @Get('stats') - @Scopes(SCOPES.READ) - async getMerchantStats( - @Request() req: any, - ): Promise> { - const requesterId = req.user.sub; - const stats = await this.merchantPartnersService.getMerchantStats(requesterId); - - return this.createApiResponse( - true, - 'Merchant statistics retrieved successfully', - stats, - ); - } - - @Get(':id') - @Scopes(SCOPES.READ) - async getMerchantPartnerById( - @Param('id') merchantId: string, - @Request() req: any, - ): Promise> { - const requesterId = req.user.sub; - const merchant = await this.merchantPartnersService.getMerchantPartnerById(merchantId, requesterId); - - return this.createApiResponse( - true, - 'Merchant partner retrieved successfully', - merchant, - ); - } - - // ===== MISE À JOUR DE MERCHANT PARTNERS ===== - @Put(':id') - @Scopes(SCOPES.WRITE) - async updateMerchantPartner( - @Param('id') merchantId: string, - @Body() updateMerchantDto: UpdateMerchantPartnerDto, - @Request() req: any, - ): Promise> { - this.logger.log(`Updating merchant partner: ${merchantId}`); - - const requesterId = req.user.sub; - const merchant = await this.merchantPartnersService.updateMerchantPartner( - merchantId, - updateMerchantDto, - requesterId, - ); - - this.logger.log(`Merchant partner updated successfully: ${merchant.name}`); - - return this.createApiResponse( - true, - 'Merchant partner updated successfully', - merchant, - ); - } - - @Put(':id/suspend') - @Scopes(SCOPES.WRITE) - @HttpCode(HttpStatus.OK) - async suspendMerchantPartner( - @Param('id') merchantId: string, - @Body() suspendDto: SuspendMerchantPartnerDto, - @Request() req: any, - ): Promise> { - this.logger.log(`Suspending merchant partner: ${merchantId}`); - - const requesterId = req.user.sub; - await this.merchantPartnersService.suspendMerchantPartner( - merchantId, - suspendDto.reason, - requesterId, - ); - - this.logger.log(`Merchant partner suspended successfully: ${merchantId}`); - - return this.createApiResponse( - true, - 'Merchant partner suspended successfully', - ); - } - - @Put(':id/activate') - @Scopes(SCOPES.WRITE) - @HttpCode(HttpStatus.OK) - async activateMerchantPartner( - @Param('id') merchantId: string, - @Request() req: any, - ): Promise> { - this.logger.log(`Activating merchant partner: ${merchantId}`); - - const requesterId = req.user.sub; - const merchant = await this.merchantPartnersService.updateMerchantPartner( - merchantId, - { status: 'ACTIVE' }, - requesterId, - ); - - this.logger.log(`Merchant partner activated successfully: ${merchant.name}`); - - return this.createApiResponse( - true, - 'Merchant partner activated successfully', - merchant, - ); - } - - // ===== SUPPRESSION DE MERCHANT PARTNERS ===== - @Delete(':id') - @Scopes(SCOPES.DELETE) - @HttpCode(HttpStatus.NO_CONTENT) - async deleteMerchantPartner( - @Param('id') merchantId: string, - @Request() req: any, - ): Promise { - this.logger.log(`Deleting merchant partner: ${merchantId}`); - - const requesterId = req.user.sub; - // Implémentez la logique de suppression si nécessaire - // await this.merchantPartnersService.deleteMerchantPartner(merchantId, requesterId); - - this.logger.log(`Merchant partner deletion scheduled: ${merchantId}`); - } - - // ===== RECHERCHE ET FILTRES ===== - @Get('search/by-name') - @Scopes(SCOPES.READ) - async searchMerchantsByName( - @Query('name') name: string, - @Request() req: any, - ): Promise> { - if (!name || name.length < 2) { - throw new BadRequestException('Name must be at least 2 characters long'); - } - - this.logger.log(`Searching merchants by name: ${name}`); - - const requesterId = req.user.sub; - const allMerchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId); - - const filteredMerchants = allMerchants.filter(merchant => - merchant.name.toLowerCase().includes(name.toLowerCase()), - ); - - this.logger.log(`Found ${filteredMerchants.length} merchants matching name: ${name}`); - - return this.createApiResponse( - true, - 'Merchants search completed successfully', - filteredMerchants, - ); - } -} \ No newline at end of file diff --git a/src/hub-users/dto/merchant-partners.dto.ts b/src/hub-users/dto/merchant-partners.dto.ts deleted file mode 100644 index e0ad6ff..0000000 --- a/src/hub-users/dto/merchant-partners.dto.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - IsEmail, - IsEnum, - IsNotEmpty, - IsOptional, - IsString, - MinLength, - Matches, -} from 'class-validator'; - -export class CreateMerchantPartnerDto { - @IsNotEmpty() - @IsString() - @MinLength(2) - name: string; - - @IsNotEmpty() - @IsString() - @MinLength(2) - legalName: string; - - @IsNotEmpty() - @IsEmail() - email: string; - - @IsOptional() - @IsString() - phone?: string; - - @IsOptional() - @IsString() - address?: string; - - // Propriétaire DCB_PARTNER - @IsNotEmpty() - @IsString() - @MinLength(3) - dcbPartnerOwnerUsername: string; - - @IsNotEmpty() - @IsEmail() - dcbPartnerOwnerEmail: string; - - @IsNotEmpty() - @IsString() - @MinLength(2) - dcbPartnerOwnerFirstName: string; - - @IsNotEmpty() - @IsString() - @MinLength(2) - dcbPartnerOwnerLastName: string; - - @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', - }) - dcbPartnerOwnerPassword: string; -} - -export class UpdateMerchantPartnerDto { - @IsOptional() - @IsString() - @MinLength(2) - name?: string; - - @IsOptional() - @IsString() - @MinLength(2) - legalName?: string; - - @IsOptional() - @IsEmail() - email?: string; - - @IsOptional() - @IsString() - phone?: string; - - @IsOptional() - @IsString() - address?: string; - - @IsOptional() - @IsEnum(['ACTIVE', 'SUSPENDED', 'PENDING']) - status?: 'ACTIVE' | 'SUSPENDED' | 'PENDING'; -} - -export class SuspendMerchantPartnerDto { - @IsNotEmpty() - @IsString() - @MinLength(5) - reason: string; -} \ No newline at end of file diff --git a/src/hub-users/services/hub-users.service.ts b/src/hub-users/services/hub-users.service.ts index d996a01..8ed1edb 100644 --- a/src/hub-users/services/hub-users.service.ts +++ b/src/hub-users/services/hub-users.service.ts @@ -151,7 +151,8 @@ export class HubUsersService { try { const userRoles = await this.keycloakApi.getUserClientRoles(user.id); - const isMerchant = userRoles.some(role => role.name === UserRole.DCB_PARTNER); + 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)); diff --git a/src/hub-users/services/merchant-partners.service.ts b/src/hub-users/services/merchant-partners.service.ts deleted file mode 100644 index 685ad97..0000000 --- a/src/hub-users/services/merchant-partners.service.ts +++ /dev/null @@ -1,353 +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 MerchantPartner { - id: string; - name: string; - legalName: string; - email: string; - phone?: string; - address?: string; - status: 'ACTIVE' | 'SUSPENDED' | 'PENDING'; - createdAt: Date; - updatedAt: Date; - dcbPartnerUserId: string; // ID du user DCB_PARTNER propriétaire -} - -export interface CreateMerchantPartnerData { - name: string; - legalName: string; - email: string; - phone?: string; - address?: string; - dcbPartnerOwner: { - username: string; - email: string; - firstName: string; - lastName: string; - password: string; - }; -} - -export interface MerchantStats { - totalMerchants: number; - activeMerchants: number; - suspendedMerchants: number; - pendingMerchants: number; - totalUsers: number; -} - -@Injectable() -export class MerchantPartnersService { - private readonly logger = new Logger(MerchantPartnersService.name); - - constructor(private readonly keycloakApi: KeycloakApiService) {} - - // ===== CRÉATION D'UN MERCHANT PARTNER ===== - async createMerchantPartner( - creatorId: string, - merchantData: CreateMerchantPartnerData - ): Promise { - this.logger.log(`Creating merchant partner: ${merchantData.name}`); - - // Validation des permissions du créateur - await this.validateMerchantCreationPermissions(creatorId); - - // Vérifier les doublons - await this.checkDuplicateMerchant(merchantData); - - // 1. Créer le user DCB_PARTNER (propriétaire) - const dcbPartnerUserData: CreateUserData = { - username: merchantData.dcbPartnerOwner.username, - email: merchantData.dcbPartnerOwner.email, - firstName: merchantData.dcbPartnerOwner.firstName, - lastName: merchantData.dcbPartnerOwner.lastName, - password: merchantData.dcbPartnerOwner.password, - enabled: true, - emailVerified: true, - clientRoles: [UserRole.DCB_PARTNER], - createdBy: creatorId, - }; - - const dcbPartnerUserId = await this.keycloakApi.createUser(creatorId, dcbPartnerUserData); - - // 2. Créer l'entité Merchant Partner (dans la base de données ou Keycloak attributes) - const merchantPartner: MerchantPartner = { - id: merchantData.dcbPartnerOwner.username, - name: merchantData.name, - legalName: merchantData.legalName, - email: merchantData.email, - phone: merchantData.phone, - address: merchantData.address, - status: 'ACTIVE', - createdAt: new Date(), - updatedAt: new Date(), - dcbPartnerUserId, - }; - - // 3. Stocker les infos du merchant dans les attributs du user DCB_PARTNER - await this.keycloakApi.setUserAttributes(dcbPartnerUserId, { - merchantPartnerName: [merchantData.name], - merchantLegalName: [merchantData.legalName], - merchantEmail: [merchantData.email], - merchantPhone: [merchantData.phone || ''], - merchantAddress: [merchantData.address || ''], - merchantStatus: ['ACTIVE'], - merchantCreatedAt: [new Date().toISOString()], - }); - - this.logger.log(`Merchant partner created successfully: ${merchantData.name}`); - return merchantPartner; - } - - // ===== GESTION DES MERCHANTS ===== - async getAllMerchantPartners(requesterId: string): Promise { - await this.validateHubAccess(requesterId); - - // Récupérer tous les users DCB_PARTNER - const allUsers = await this.keycloakApi.getAllUsers(); - const merchants: MerchantPartner[] = []; - - for (const user of allUsers) { - if (!user.id) continue; - - try { - const userRoles = await this.keycloakApi.getUserClientRoles(user.id); - const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER); - - if (isDcbPartner && user.attributes?.merchantPartnerName?.[0]) { - merchants.push(this.mapToMerchantPartner(user)); - } - } catch (error) { - this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`); - } - } - - return merchants; - } - - async getMerchantPartnerById(merchantId: string, requesterId: string): Promise { - await this.validateMerchantAccess(requesterId, merchantId); - - // Trouver le user DCB_PARTNER correspondant au merchant - const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId); - return this.mapToMerchantPartner(dcbPartnerUser); - } - - async updateMerchantPartner( - merchantId: string, - updates: Partial<{ - name: string; - legalName: string; - email: string; - phone: string; - address: string; - status: 'ACTIVE' | 'SUSPENDED' | 'PENDING'; - }>, - requesterId: string - ): Promise { - await this.validateMerchantManagementPermissions(requesterId, merchantId); - - const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId); - - // Mettre à jour les attributs - const attributes: any = {}; - if (updates.name) attributes.merchantPartnerName = [updates.name]; - if (updates.legalName) attributes.merchantLegalName = [updates.legalName]; - if (updates.email) attributes.merchantEmail = [updates.email]; - if (updates.phone) attributes.merchantPhone = [updates.phone]; - if (updates.address) attributes.merchantAddress = [updates.address]; - if (updates.status) attributes.merchantStatus = [updates.status]; - - attributes.merchantUpdatedAt = [new Date().toISOString()]; - - await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, attributes); - - return this.getMerchantPartnerById(merchantId, requesterId); - } - - 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}`); - } - - // ===== STATISTIQUES ===== - async getMerchantStats(requesterId: string): Promise { - await this.validateHubAccess(requesterId); - - const allMerchants = await this.getAllMerchantPartners(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.status === 'ACTIVE').length, - suspendedMerchants: allMerchants.filter(m => m.status === 'SUSPENDED').length, - pendingMerchants: allMerchants.filter(m => m.status === 'PENDING').length, - totalUsers, - }; - } - - // ===== MÉTHODES PRIVÉES ===== - private async validateMerchantCreationPermissions(creatorId: string): Promise { - const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId); - const canCreateMerchant = creatorRoles.some(role => - [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole) - ); - - if (!canCreateMerchant) { - throw new ForbiddenException('Only hub administrators can create merchant partners'); - } - } - - 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'); - 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'); - } - - 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 checkDuplicateMerchant(merchantData: CreateMerchantPartnerData): Promise { - const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.dcbPartnerOwner.username); - if (existingUsers.length > 0) { - throw new BadRequestException(`Merchant partner with username ${merchantData.dcbPartnerOwner.username} already exists`); - } - - const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.dcbPartnerOwner.email); - if (existingEmails.length > 0) { - throw new BadRequestException(`Merchant partner with email ${merchantData.dcbPartnerOwner.email} already exists`); - } - } - - 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 mapToMerchantPartner(user: KeycloakUser): MerchantPartner { - return { - id: user.username, - name: user.attributes?.merchantPartnerName?.[0] || user.username, - legalName: user.attributes?.merchantLegalName?.[0] || '', - email: user.attributes?.merchantEmail?.[0] || user.email, - phone: user.attributes?.merchantPhone?.[0], - address: user.attributes?.merchantAddress?.[0], - status: (user.attributes?.merchantStatus?.[0] as 'ACTIVE' | 'SUSPENDED' | 'PENDING') || 'ACTIVE', - createdAt: user.attributes?.merchantCreatedAt?.[0] - ? new Date(user.attributes.merchantCreatedAt[0]) - : new Date(user.createdTimestamp || Date.now()), - updatedAt: user.attributes?.merchantUpdatedAt?.[0] - ? new Date(user.attributes.merchantUpdatedAt[0]) - : new Date(user.createdTimestamp || Date.now()), - dcbPartnerUserId: user.id!, - }; - } - - 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}`); - } - } - } - } -} \ No newline at end of file From fefa5aef42dd3bc6c13ea152210a620a59e4700a Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 10 Nov 2025 01:27:29 +0000 Subject: [PATCH 4/7] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- src/app.module.ts | 4 +- src/auth/controllers/auth.controller.ts | 11 +- src/auth/services/keycloak-api.service.ts | 1509 ++++++++++------- src/auth/services/keycloak-user.model.ts | 80 +- src/auth/services/startup.service-crud.ts | 76 - src/auth/services/startup.service-final.ts | 710 -------- src/auth/services/startup.service.ts | 734 +------- src/auth/services/token.service.ts | 3 - .../controllers/hub-users.controller.ts | 778 ++++----- .../controllers/merchant-users.controller.ts | 599 +++---- src/hub-users/dto/hub-user.dto.ts | 105 -- src/hub-users/dto/merchant-users.dto.ts | 87 - src/hub-users/hub-users.module.ts | 7 +- src/hub-users/models/hub-user.model.ts | 26 +- src/hub-users/services/hub-users.service.ts | 1061 ++++++------ .../services/merchant-users.service.ts | 192 --- src/main.ts | 43 +- 17 files changed, 2195 insertions(+), 3830 deletions(-) delete mode 100644 src/auth/services/startup.service-crud.ts delete mode 100644 src/auth/services/startup.service-final.ts delete mode 100644 src/hub-users/dto/hub-user.dto.ts delete mode 100644 src/hub-users/dto/merchant-users.dto.ts delete mode 100644 src/hub-users/services/merchant-users.service.ts 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 From cbdfdfa29752bf30eac17911ee8c4f0202e51723 Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 10 Nov 2025 01:28:55 +0000 Subject: [PATCH 5/7] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- .env-sample | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.env-sample b/.env-sample index 720bb10..57de507 100644 --- a/.env-sample +++ b/.env-sample @@ -3,7 +3,36 @@ NODE_ENV=development PORT=3000 +# === CONFIGURATION DES TESTS STARTUP === RUN_STARTUP_TESTS=false +TEST_CLEANUP_DELAY_MS=100 +TEST_TIMEOUT_MS=30000 +TEST_USER_PASSWORD=SecureTempPass123! +TEST_EMAIL_DOMAIN=dcb-test.com +TEST_DEFAULT_PASSWORD=SecureTempPass123! + +# === CONFIGURATION DE SÉCURITÉ === +RUN_SECURITY_TESTS=false +SECURITY_TEST_TIMEOUT=300000 + +# === VALIDATION DES ENTREES === +MAX_USERNAME_LENGTH=50 +MIN_USERNAME_LENGTH=3 +ALLOWED_EMAIL_DOMAINS=dcb-test.com,pixpay.sn + +# === RATE LIMITING === +MAX_REQUESTS_PER_MINUTE=60 +RATE_LIMIT_BLOCK_DURATION=300000 + +# === SÉCURITÉ DES SESSIONS === +SESSION_TIMEOUT=900000 +JWT_EXPIRATION=3600000 + +# === SURVEILLANCE === +LOG_SECURITY_EVENTS=true +SECURITY_EVENT_RETENTION_DAYS=30 + +# === CONFIGURATION KEYCLOAK === KEYCLOAK_SERVER_URL=https://iam.dcb.pixpay.sn KEYCLOAK_REALM=dcb-prod From 403baaa0efeb2be81e63fa54b595583097423496 Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 10 Nov 2025 20:18:34 +0000 Subject: [PATCH 6/7] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- src/hub-users/services/hub-users.service.ts | 35 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/hub-users/services/hub-users.service.ts b/src/hub-users/services/hub-users.service.ts index 577a47d..1c9d92f 100644 --- a/src/hub-users/services/hub-users.service.ts +++ b/src/hub-users/services/hub-users.service.ts @@ -154,6 +154,23 @@ export class HubUsersService { } } + /** + * Vérifie si un utilisateur est Hub Admin ou Support + */ + async isUserMerchantPartner(userId: string): Promise { + try { + const userRoles = await this.keycloakApi.getUserClientRoles(userId); + const hubMerchantPartnerRoles = [UserRole.DCB_PARTNER]; + + return userRoles.some(role => + hubMerchantPartnerRoles.includes(role.name as UserRole) + ); + } catch (error) { + this.logger.error(`Error checking Merchant Partner status for user ${userId}:`, error); + return false; + } + } + /** * Récupère les utilisateurs marchands selon les permissions de l'utilisateur * - Hub Admin/Support: tous les utilisateurs marchands de tous les merchants @@ -170,7 +187,7 @@ export class HubUsersService { } // Pour les autres utilisateurs (DCB_PARTNER, DCB_PARTNER_ADMIN, etc.) - return await this.getMerchantUsersForRegularUser(userId); + return await this.getUsersForMerchants(userId); } catch (error) { this.logger.error(`Error in getMyMerchantUsers for user ${userId}:`, error); @@ -221,21 +238,29 @@ export class HubUsersService { /** * Récupère les utilisateurs marchands pour les utilisateurs réguliers (non Hub Admin/Support) */ - private async getMerchantUsersForRegularUser(userId: string): Promise { + private async getUsersForMerchants(userId: string): Promise { // Récupérer le merchantPartnerId de l'utilisateur - const userMerchantId = await this.getUserMerchantPartnerId(userId); - + let userMerchantId = await this.getUserMerchantPartnerId(userId); + + // Vérifier si l'utilisateur est un admin ou support Hub + const isUserMerchantPartner = await this.isUserMerchantPartner(userId); + + if(isUserMerchantPartner){ + userMerchantId = 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 { From 2b4f4ce1b8cecf3353562955ea02ebaf14d3ed1d Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 10 Nov 2025 20:41:25 +0000 Subject: [PATCH 7/7] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- .../controllers/hub-users.controller.ts | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/hub-users/controllers/hub-users.controller.ts b/src/hub-users/controllers/hub-users.controller.ts index 42ed783..925c86e 100644 --- a/src/hub-users/controllers/hub-users.controller.ts +++ b/src/hub-users/controllers/hub-users.controller.ts @@ -192,6 +192,53 @@ export class HubUserResponse { lastLogin?: number; } +export class MerchantUserResponse { + @ApiProperty({ description: 'User ID' }) + id: string; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ + 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; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ required: false, description: 'Merchant partner ID' }) + merchantPartnerId?: string; + + @ApiProperty({ description: 'User creator ID' }) + createdBy: string; + + @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; +} + export class HubUserProfileResponse { @ApiProperty({ description: 'User ID' }) id: string; @@ -252,6 +299,26 @@ function mapToHubUserResponse(user: User): HubUserResponse { }; } +// Mapper functions +function mapToMerchantUserResponse(user: User): MerchantUserResponse { + return { + 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 mapToHubUserProfileResponse(profile: any): HubUserProfileResponse { return { id: profile.id, @@ -413,7 +480,7 @@ export class HubUsersController { return { hubUsers: hubUsers.map(mapToHubUserResponse), - merchantUsers: merchantUsers.map(mapToHubUserResponse), + merchantUsers: merchantUsers.map(mapToMerchantUserResponse), statistics: { totalHubUsers: hubUsers.length, totalMerchantUsers: merchantUsers.length,