From 65494d5af288c06b82e2d547080e7629cf2e7aab Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 3 Nov 2025 17:37:20 +0000 Subject: [PATCH] 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 {} - - -