diff --git a/.env-sample b/.env-sample index d304c28..57de507 100644 --- a/.env-sample +++ b/.env-sample @@ -1,28 +1,60 @@ -# .env-sample +# .env NODE_ENV=development PORT=3000 -KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com -KEYCLOAK_REALM=dcb-dev +# === CONFIGURATION DES TESTS STARTUP === +RUN_STARTUP_TESTS=false +TEST_CLEANUP_DELAY_MS=100 +TEST_TIMEOUT_MS=30000 +TEST_USER_PASSWORD=SecureTempPass123! +TEST_EMAIL_DOMAIN=dcb-test.com +TEST_DEFAULT_PASSWORD=SecureTempPass123! -KEYCLOAK_JWKS_URI=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev/protocol/openid-connect/certs -KEYCLOAK_ISSUER=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev +# === CONFIGURATION DE SÉCURITÉ === +RUN_SECURITY_TESTS=false +SECURITY_TEST_TIMEOUT=300000 -KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwfT6BGerQyJ7EOFcgN1DLxRh/8g3cCN5qNZyeLQc6524Lsw3voMD2HJddvAunCcn6Eux2LTYXPzLvZc8829Sa5ksTzINyPqg9GFZa5+GAifMW6DfvQcxGyl5yvduCWxOSmST3PYN9UkCFP20e3gDLRox9rNe1/17xkDJwByJh/Xld/m07vHgyglDNRGkA/YW3A1JuAKgJjAstLOyeK+UGdMeJmD/5TF/yoBI/FsjW/OjZ78wP3dfkGo5zG2EOkK+39evU7HxB4jgL5SBhw32GLPVhtyCMnUW6IlsQhDSDWXqBdMCO0/hdrjyznyM7ZJqkUN7KAFKqcJsnja9mBNT4QIDAQAB +# === VALIDATION DES ENTREES === +MAX_USERNAME_LENGTH=50 +MIN_USERNAME_LENGTH=3 +ALLOWED_EMAIL_DOMAINS=dcb-test.com,pixpay.sn + +# === RATE LIMITING === +MAX_REQUESTS_PER_MINUTE=60 +RATE_LIMIT_BLOCK_DURATION=300000 + +# === SÉCURITÉ DES SESSIONS === +SESSION_TIMEOUT=900000 +JWT_EXPIRATION=3600000 + +# === SURVEILLANCE === +LOG_SECURITY_EVENTS=true +SECURITY_EVENT_RETENTION_DAYS=30 + +# === CONFIGURATION KEYCLOAK === + +KEYCLOAK_SERVER_URL=https://iam.dcb.pixpay.sn +KEYCLOAK_REALM=dcb-prod + +KEYCLOAK_JWKS_URI=https://iam.dcb.pixpay.sn/realms/dcb-prod/protocol/openid-connect/certs +KEYCLOAK_ISSUER=https://iam.dcb.pixpay.sn/realms/dcb-prod + +KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA01nspe5Sol9YAzm98wnQO1MvhRgJZSaOhozOHJEBm5VW5wLEEfcTlakzr/xXRjFYB9jySeaDWyhE6qGKuRK2Kx20qt3CuwT52ZSy97dKjJbgCxBCOymxKLJRdDfwtKOAayk5oCHqGp+cJTShnd9jVggYyTdqGqMWlpeiBKqvpgyldndwIfvDxPpPwsx/mwKV7S4sSTsONxSIB6zK+RumeYKOF0BskIxBw4tG3V5eicrECCKX/jP8rYFclBPXhxnLbbaHa21XAwQHfOioip3YfwPYF9GKTJEhM8ziJdTKikAtiwFm/Zvn1foLaF1MDLpV9yLrK0H1oa3y7j5p7tqHbQIDAQAB + +KEYCLOAK_CLIENT_ID=dcb-user-service-cc-app +KEYCLOAK_CLIENT_SECRET=IFNQWjBbcW6dXqQO76X5OZb1lL0esO30 -KEYCLOAK_CLIENT_ID=dcb-user-service-pwd -KEYCLOAK_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2 KEYCLOAK_VALIDATION_MODE=offline KEYCLOAK_TOKEN_BUFFER_SECONDS=30 -KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin +KEYCLOAK_TEST_USER_ADMIN=bo-admin KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025 -KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant -KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025 +KEYCLOAK_TEST_USER_MERCHANT=bo-partner +KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025 -KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support +KEYCLOAK_TEST_USER_SUPPORT=bo-support KEYCLOAK_TEST_PASSWORD=@BOSupport2025 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..9e71877 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,9 +14,8 @@ 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 { StartupService } from './auth/services/startup.service'; +import { HubUsersModule } from './hub-users/hub-users.module'; +import { StartupServiceInitialization } from './auth/services/startup.service'; @Module({ imports: [ @@ -69,12 +68,11 @@ import { StartupService } from './auth/services/startup.service'; // Feature Modules AuthModule, - ApiModule, - UsersModule, + HubUsersModule, ], providers: [ - StartupService, + StartupServiceInitialization, // Global Authentication Guard { provide: APP_GUARD, 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..aed00c8 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,62 @@ 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) { + return this.usersService.getCompleteUserProfile(user.sub, user); + + } + + /** ------------------------------- + * 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..9a4ed34 100644 --- a/src/auth/services/keycloak-api.service.ts +++ b/src/auth/services/keycloak-api.service.ts @@ -1,29 +1,47 @@ -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 } from 'rxjs'; import { TokenService } from './token.service'; +import { KeycloakUser, KeycloakRole, CreateUserData, UserRole } 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; -} +// === CONFIGURATION CENTRALISÉE AVEC HIÉRARCHIE DES RÔLES === +const ROLE_HIERARCHY = { + [UserRole.DCB_ADMIN]: [ + UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, + UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_SUPPORT]: [ + UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_PARTNER]: [ + UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_PARTNER_ADMIN]: [ + UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT + ], + [UserRole.DCB_PARTNER_MANAGER]: [], + [UserRole.DCB_PARTNER_SUPPORT]: [] +} as Record; -export interface KeycloakRole { - id: string; - name: string; - description?: string; -} - -export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support' | 'merchant-user'; +const CONFIG = { + TIMEOUTS: { + REQUEST: 10000, + HEALTH_CHECK: 5000 + }, + ROLES: { + HUB: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], + MERCHANT: [ + UserRole.DCB_PARTNER, + UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, + UserRole.DCB_PARTNER_SUPPORT + ] + }, + HIERARCHY: ROLE_HIERARCHY +}; @Injectable() export class KeycloakApiService { @@ -31,6 +49,7 @@ export class KeycloakApiService { private readonly keycloakBaseUrl: string; private readonly realm: string; private readonly clientId: string; + private clientCache?: { id: string }; constructor( private readonly httpService: HttpService, @@ -39,18 +58,481 @@ 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'; + this.clientId = this.configService.get('KEYCLOAK_CLIENT_ID') || 'dcb-admin-cli'; } - // ===== MÉTHODE POUR L'AUTHENTIFICATION UTILISATEUR ===== + // === MÉTHODES DE GESTION DE LA HIÉRARCHIE DES RÔLES === + + /** + * Vérifie si un rôle peut créer un autre rôle selon la hiérarchie + */ + canRoleCreateRole(creatorRole: UserRole, targetRole: UserRole): boolean { + return ROLE_HIERARCHY[creatorRole]?.includes(targetRole) || false; + } + + /** + * Vérifie si un ensemble de rôles peut créer un rôle cible + */ + canRolesCreateRole(creatorRoles: UserRole[], targetRole: UserRole): boolean { + return creatorRoles.some(creatorRole => this.canRoleCreateRole(creatorRole, targetRole)); + } + + /** + * Obtient le rôle le plus élevé dans la hiérarchie + */ + getHighestRole(roles: UserRole[]): UserRole { + const rolePriority: UserRole[] = [ + UserRole.DCB_ADMIN, + UserRole.DCB_SUPPORT, + UserRole.DCB_PARTNER, + UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, + UserRole.DCB_PARTNER_SUPPORT + ]; + + for (const role of rolePriority) { + if (roles.includes(role)) { + return role; + } + } + + throw new ForbiddenException('Cannot determine user role'); + } + + /** + * Obtient tous les rôles qu'un rôle peut gérer + */ + getManageableRoles(role: UserRole): UserRole[] { + return ROLE_HIERARCHY[role] || []; + } + + // === INTERFACE PUBLIQUE PRINCIPALE === + async authenticateUser(username: string, password: string) { return this.tokenService.acquireUserToken(username, password); } - // ===== CORE REQUEST METHOD ===== + async createUser(creatorId: string, userData: CreateUserData): Promise { + await this.validateUserCreation(creatorId, userData); + + const [creatorUsername, userPayload] = await Promise.all([ + this.getCreatorUsername(creatorId), + this.buildUserPayload(userData, creatorId) + ]); + + const finalPayload = { + ...userPayload, + attributes: { + ...userPayload.attributes, + createdByUsername: [creatorUsername] + } + }; + + try { + await this.request('POST', `/admin/realms/${this.realm}/users`, finalPayload); + + const users = await this.findUserByUsername(userData.username); + const userId = users[0]?.id; + + if (!userId) { + throw new Error('User not found after creation'); + } + + if (userData.clientRoles?.length) { + await this.setClientRoles(userId, userData.clientRoles); + } + + this.logger.log(`User created successfully: ${userData.username} (ID: ${userId})`); + return userId; + } catch (error: any) { + this.logger.error(`Failed to create user ${userData.username}: ${error.message}`); + throw error; + } + } + + async getUserById(userId: string, requesterId: string): Promise { + const user = await this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); + + if (userId !== requesterId) { + const userMerchantPartnerId = user.attributes?.merchantPartnerId?.[0]; + await this.validateUserAccess(requesterId, userMerchantPartnerId); + } + + return user; + } + + async updateUser(userId: string, updates: Partial, requesterId: string): Promise { + const currentUser = await this.getUserById(userId, requesterId); + const userMerchantPartnerId = currentUser.attributes?.merchantPartnerId?.[0]; + + await this.validateUserAccess(requesterId, userMerchantPartnerId); + return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, updates); + } + + async deleteUser(userId: string, requesterId: string): Promise { + await this.validateDeletion(requesterId, userId); + + const userMerchantPartnerId = await this.getUserMerchantPartnerId(userId); + await this.validateUserAccess(requesterId, userMerchantPartnerId); + + this.logSecurityEvent('USER_DELETION_ATTEMPT', 'HIGH', { + targetUserId: userId, + requesterId, + merchantPartnerId: userMerchantPartnerId + }); + + try { + await this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`); + this.logSecurityEvent('USER_DELETED_SUCCESS', 'HIGH', { targetUserId: userId, requesterId }); + } catch (error) { + this.logSecurityEvent('USER_DELETION_FAILED', 'HIGH', { + targetUserId: userId, + requesterId, + error: error.message + }); + throw error; + } + } + + // === GESTION DES MOTS DE PASSE === + + async resetUserPassword(userId: string, newPassword: string, temporary: boolean = true): Promise { + const passwordPayload = { + type: 'password', + value: newPassword, + temporary, + }; + + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, passwordPayload); + this.logger.log(`Password reset for user ${userId}, temporary: ${temporary}`); + } + + async updateUserPassword(userId: string, currentPassword: string, newPassword: string): Promise { + const passwordPayload = { + type: 'password', + value: newPassword, + temporary: false, + }; + + // Vérifier d'abord le mot de passe actuel + const user = await this.getUserById(userId, userId); + try { + await this.authenticateUser(user.username, currentPassword); + } catch (error) { + throw new ForbiddenException('Current password is incorrect'); + } + + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, passwordPayload); + this.logger.log(`Password updated for user ${userId}`); + } + + async sendResetPasswordEmail(email: string): Promise { + const users = await this.findUserByEmail(email); + if (users.length === 0) { + throw new NotFoundException('User with this email not found'); + } + + const userId = users[0].id; + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/execute-actions-email`, ['UPDATE_PASSWORD']); + this.logger.log(`Password reset email sent to ${email}`); + } + + // === GESTION DES RÔLES === + + async getUserClientRoles(userId: string | undefined): Promise { + try { + const client = await this.getClient(); + return await this.request( + 'GET', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}` + ); + } catch (error) { + this.logger.warn(`Failed to get client roles for user ${userId}: ${error.message}`); + return []; + } + } + + async setClientRoles(userId: string, roles: UserRole[]): Promise { + try { + const client = await this.getClient(); + const [currentRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(userId), + Promise.all(roles.map(role => this.getRole(role, client.id))) + ]); + + if (currentRoles.length > 0) { + await this.request( + 'DELETE', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + currentRoles + ); + } + + if (targetRoles.length > 0) { + await this.request( + 'POST', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + targetRoles + ); + } + + this.logger.log(`Roles updated for user ${userId}: ${roles.join(', ')}`); + } catch (error) { + this.logger.error(`Failed to set roles for user ${userId}: ${error.message}`); + throw error; + } + } + + async addClientRoles(userId: string, roles: UserRole[]): Promise { + try { + const client = await this.getClient(); + const targetRoles = await Promise.all(roles.map(role => this.getRole(role, client.id))); + + if (targetRoles.length > 0) { + await this.request( + 'POST', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + targetRoles + ); + } + + this.logger.log(`Roles added to user ${userId}: ${roles.join(', ')}`); + } catch (error) { + this.logger.error(`Failed to add roles to user ${userId}: ${error.message}`); + throw error; + } + } + + async removeClientRoles(userId: string, roles: UserRole[]): Promise { + try { + const client = await this.getClient(); + const targetRoles = await Promise.all(roles.map(role => this.getRole(role, client.id))); + + if (targetRoles.length > 0) { + await this.request( + 'DELETE', + `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${client.id}`, + targetRoles + ); + } + + this.logger.log(`Roles removed from user ${userId}: ${roles.join(', ')}`); + } catch (error) { + this.logger.error(`Failed to remove roles from user ${userId}: ${error.message}`); + throw error; + } + } + + async getAvailableClientRoles(): Promise { + try { + const client = await this.getClient(); + return await this.request( + 'GET', + `/admin/realms/${this.realm}/clients/${client.id}/roles` + ); + } catch (error) { + this.logger.error(`Failed to get available client roles: ${error.message}`); + throw error; + } + } + + // === RECHERCHE D'UTILISATEURS === + + async getAllUsers(): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users`); + } + + async findUserByUsername(username: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}` + ); + return users.filter(user => user.username === username); + } + + async findUserByEmail(email: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}` + ); + return users.filter(user => user.email === email); + } + + async findUsersByAttribute(attribute: string, value: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?q=${attribute}:${encodeURIComponent(value)}` + ); + return users.filter(user => user.attributes?.[attribute]?.includes(value)); + } + + async findUsersByMerchantPartnerId(merchantPartnerId: string): Promise { + return this.findUsersByAttribute('merchantPartnerId', merchantPartnerId); + } + + async searchUsers(query: string): Promise { + const users = await this.request( + 'GET', + `/admin/realms/${this.realm}/users?search=${encodeURIComponent(query)}` + ); + return users; + } + + async getUsersWithPagination(first: number = 0, max: number = 100): Promise { + return this.request( + 'GET', + `/admin/realms/${this.realm}/users?first=${first}&max=${max}` + ); + } + + async countUsers(): Promise { + const users = await this.request('GET', `/admin/realms/${this.realm}/users?briefRepresentation=true`); + return users.length; + } + + // === GESTION DES SESSIONS === + + async getUserSessions(userId: string): Promise { + return this.request('GET', `/admin/realms/${this.realm}/users/${userId}/sessions`); + } + + async logoutUser(userId: string): Promise { + await this.request('POST', `/admin/realms/${this.realm}/users/${userId}/logout`); + this.logger.log(`User ${userId} logged out from all sessions`); + } + + // === MÉTHODES UTILITAIRES === + + async getUserMerchantPartnerId(userId: string): Promise { + try { + const user = await this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); + return user.attributes?.merchantPartnerId?.[0] || null; + } catch (error) { + this.logger.warn(`Failed to get merchantPartnerId for user ${userId}: ${error.message}`); + return null; + } + } + + async setUserAttributes(userId: string, attributes: Record): Promise { + try { + const user = await this.getUserById(userId, userId); + const updatedUser = { + ...user, + attributes: { ...user.attributes, ...attributes } + }; + + await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, updatedUser); + this.logger.log(`Attributes updated for user ${userId}: ${Object.keys(attributes).join(', ')}`); + } catch (error: any) { + this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`); + throw error; + } + } + + async getUserAttribute(userId: string, attribute: string): Promise { + try { + const user = await this.getUserById(userId, userId); + return user.attributes?.[attribute] || null; + } catch (error) { + this.logger.warn(`Failed to get attribute ${attribute} for user ${userId}: ${error.message}`); + return null; + } + } + + async enableUser(userId: string): Promise { + await this.updateUser(userId, { enabled: true }, userId); + this.logger.log(`User ${userId} enabled`); + } + + async disableUser(userId: string): Promise { + await this.updateUser(userId, { enabled: false }, userId); + this.logger.log(`User ${userId} disabled`); + } + + async isUserEnabled(userId: string): Promise { + const user = await this.getUserById(userId, userId); + return user.enabled; + } + + // === VALIDATION DES PERMISSIONS === + + async validateUserAccess(requesterId: string, targetMerchantPartnerId?: string | null): Promise { + const requesterRoles = await this.getUserClientRoles(requesterId); + const requesterRoleNames = requesterRoles.map(role => role.name as UserRole); + + // Accès complet pour les administrateurs Hub + if (requesterRoleNames.some(role => CONFIG.ROLES.HUB.includes(role))) { + return; + } + + // Validation pour DCB_PARTNER + if (requesterRoleNames.includes(UserRole.DCB_PARTNER)) { + if (requesterId === targetMerchantPartnerId) return; + throw new ForbiddenException('DCB_PARTNER can only access their own merchant data'); + } + + // Validation pour les utilisateurs Merchant + if (targetMerchantPartnerId) { + const requesterMerchantId = await this.getUserMerchantPartnerId(requesterId); + if (requesterMerchantId === targetMerchantPartnerId) { + const hasMerchantRole = requesterRoleNames.some(role => + CONFIG.ROLES.MERCHANT.includes(role) + ); + if (hasMerchantRole) return; + } + } + + throw new ForbiddenException('Insufficient permissions to access this resource'); + } + + // === VÉRIFICATIONS DE SANTÉ === + + async checkKeycloakAvailability(): Promise { + const url = `${this.keycloakBaseUrl}/realms/${this.realm}`; + try { + await firstValueFrom(this.httpService.get(url).pipe(timeout(CONFIG.TIMEOUTS.HEALTH_CHECK))); + this.logger.log(`Keycloak available: ${url}`); + return true; + } catch (error: any) { + this.logger.error(`Keycloak unavailable: ${error.message}`); + return false; + } + } + + async checkServiceConnection(): Promise { + try { + const token = await this.tokenService.acquireServiceAccountToken(); + if (!token) return false; + + const testUrl = `${this.keycloakBaseUrl}/admin/realms/${this.realm}/users`; + const config = { headers: { Authorization: `Bearer ${token}` } }; + + await firstValueFrom(this.httpService.get(testUrl, config).pipe(timeout(CONFIG.TIMEOUTS.HEALTH_CHECK))); + this.logger.log('Service connection to Keycloak: OK'); + return true; + } catch (error: any) { + this.logger.error(`Service connection failed: ${error.message}`); + return false; + } + } + + async getKeycloakVersion(): Promise { + try { + const response = await firstValueFrom( + this.httpService.get(`${this.keycloakBaseUrl}/admin/serverinfo`).pipe(timeout(CONFIG.TIMEOUTS.REQUEST)) + ); + return response.data.systemInfo?.version || 'Unknown'; + } catch (error) { + this.logger.warn(`Failed to get Keycloak version: ${error.message}`); + return 'Unknown'; + } + } + + // === MÉTHODES PRIVÉES PRINCIPALES === + private async request( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', - path: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, data?: any ): Promise { const token = await this.tokenService.acquireServiceAccountToken(); @@ -61,455 +543,126 @@ export class KeycloakApiService { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - timeout: 10000, + timeout: CONFIG.TIMEOUTS.REQUEST, }; try { - let response: AxiosResponse; - - switch (method) { - case 'GET': - response = await firstValueFrom(this.httpService.get(url, config).pipe(rxjsTimeout(10000))); - break; - case 'POST': - response = await firstValueFrom(this.httpService.post(url, data, config).pipe(rxjsTimeout(10000))); - break; - case 'PUT': - response = await firstValueFrom(this.httpService.put(url, data, config).pipe(rxjsTimeout(10000))); - break; - case 'DELETE': - response = await firstValueFrom(this.httpService.delete(url, config).pipe(rxjsTimeout(10000))); - break; - default: - throw new BadRequestException(`Unsupported HTTP method: ${method}`); - } - + const response = await this.executeRequest(method, url, config, data); return response.data; } catch (error: any) { this.handleRequestError(error, path); } } - // ===== ERROR HANDLING ===== + private async executeRequest( + method: string, + url: string, + config: any, + data?: any + ): Promise> { + switch (method) { + case 'GET': + return firstValueFrom(this.httpService.get(url, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + case 'POST': + return firstValueFrom(this.httpService.post(url, data, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + case 'PUT': + return firstValueFrom(this.httpService.put(url, data, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + case 'DELETE': + return firstValueFrom(this.httpService.delete(url, config).pipe(timeout(CONFIG.TIMEOUTS.REQUEST))); + default: + throw new BadRequestException(`Unsupported HTTP method: ${method}`); + } + } + private handleRequestError(error: any, context: string): never { - if (error.response?.status === 404) { + const status = error.response?.status; + const message = error.response?.data?.errorMessage || 'Keycloak operation failed'; + + if (status === 404) { throw new NotFoundException(`Resource not found: ${context}`); } - if (error.response?.status === 409) { + if (status === 409) { throw new BadRequestException('User already exists'); } - - this.logger.error(`Keycloak API error in ${context}: ${error.message}`, { - status: error.response?.status, + if (status === 401) { + throw new ForbiddenException('Authentication failed'); + } + if (status === 403) { + throw new ForbiddenException('Insufficient permissions'); + } + + this.logger.error(`Keycloak API error in ${context}: ${message}`, { + status, data: error.response?.data, }); - throw new HttpException( - error.response?.data?.errorMessage || 'Keycloak operation failed', - error.response?.status || 500 - ); + throw new HttpException(message, status || 500); } - // ===== 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 { - - this.logger.debug(`CREATE USER - Input data:`, { - username: userData.username, - hasPassword: !!userData.password, - hasCredentials: !!userData.credentials, - credentialsLength: userData.credentials ? userData.credentials.length : 0 - }); + // === CONSTRUCTION DE PAYLOAD === - const userPayload: any = { + private async buildUserPayload(userData: CreateUserData, creatorId: string): Promise { + const basePayload: any = { username: userData.username, email: userData.email, firstName: userData.firstName, lastName: userData.lastName, enabled: userData.enabled ?? true, - emailVerified: userData.emailVerified ?? true, + emailVerified: userData.emailVerified ?? false, + attributes: this.buildUserAttributes(userData, creatorId), }; if (userData.password) { - // Format direct : password field - userPayload.credentials = [{ + basePayload.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!; - } catch (error: any) { - this.logger.error(`FAILED to create user in Keycloak: ${error.message}`); - if (error.response?.data) { - this.logger.error(`Keycloak error response: ${JSON.stringify(error.response.data)}`); - } - throw error; - } + return basePayload; } - async getUserById(userId: string): Promise { - return this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); - } - - 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 { - return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData); - } - - async deleteUser(userId: string): Promise { - return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`); - } - - // ===== 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; - } - } - - 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; - } - } - - async removeUserAttribute(userId: string, attributeName: 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}`); - } - } 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}`); - 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 ===== - async getUserClientRoles(userId: string): Promise { - try { - const clients = await this.getClient(); - return await this.request( - 'GET', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}` - ); - } catch (error) { - this.logger.error(`Failed to get client roles for user ${userId}: ${error.message}`); - return []; - } - } - - async 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 { - try { - const clients = await this.getClient(); - const clientId = clients[0].id; - - // Récupérer les rôles actuels - const currentRoles = await this.getUserClientRoles(userId); - - // Supprimer tous les rôles actuels si nécessaire - if (currentRoles.length > 0) { - await this.request( - 'DELETE', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clientId}`, - currentRoles - ); - } - - // Ajouter les nouveaux rôles - if (roles.length > 0) { - const targetRoles = await Promise.all( - roles.map(role => this.getRole(role, clientId)) - ); - - await this.request( - 'POST', - `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clientId}`, - targetRoles - ); - } - - this.logger.log(`Client roles set for user ${userId}: ${roles.join(', ')}`); - } catch (error) { - this.logger.error(`Failed to set client roles for user ${userId}: ${error.message}`); - throw error; - } - } - - 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 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, + private buildUserAttributes(userData: CreateUserData, creatorId: string): Record { + const attributes: Record = { + createdBy: [creatorId], + accountCreatedAt: [new Date().toISOString()] }; - - await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, credentials); - this.logger.log(`Password reset for user ${userId}`); - } - async getUsersByAttribute(attributeName: string, attributeValue: string): Promise { - try { - const allUsers = await this.getAllUsers(); - return allUsers.filter(user => - user.attributes && - user.attributes[attributeName] && - user.attributes[attributeName].includes(attributeValue) - ); - } catch (error) { - this.logger.error(`Failed to get users by attribute ${attributeName}: ${error.message}`); - return []; + if (userData.merchantPartnerId) { + attributes.merchantPartnerId = [userData.merchantPartnerId]; } - } - // ===== HEALTH CHECKS ===== - async checkKeycloakAvailability(): Promise { - const url = `${this.keycloakBaseUrl}/realms/${this.realm}`; - try { - await firstValueFrom( - this.httpService.get(url).pipe(rxjsTimeout(5000)), - ); - this.logger.log(`Keycloak disponible à l'adresse : ${url}`); - return true; - } catch (error: any) { - this.logger.error(`Keycloak indisponible : ${error.message}`); - return false; + if (userData.clientRoles) { + const isHubUser = userData.clientRoles.some(role => CONFIG.ROLES.HUB.includes(role)); + attributes.userType = [isHubUser ? 'HUB' : 'MERCHANT']; } - } - async checkServiceConnection(): Promise { - try { - const token = await this.tokenService.acquireServiceAccountToken(); - if (!token) { - throw new Error('Aucun token de service retourné'); - } - - const testUrl = `${this.keycloakBaseUrl}/admin/realms/${this.realm}/users`; - const config = { - headers: { Authorization: `Bearer ${token}` }, - timeout: 5000, - }; - - await firstValueFrom( - this.httpService.get(testUrl, config).pipe(rxjsTimeout(5000)), - ); - - this.logger.log('Connexion du service à Keycloak réussie'); - return true; - } catch (error: any) { - this.logger.error(`Échec de la connexion du service : ${error.message}`); - return false; + // Ajouter les attributs personnalisés + if (userData.attributes) { + Object.assign(attributes, userData.attributes); } + + return attributes; } - // ===== PRIVATE HELPERS ===== - private async getClient(): Promise { + // === GESTION DU CLIENT ET RÔLES === + + private async getClient(): Promise<{ id: string }> { + if (this.clientCache) return this.clientCache; + const clients = await this.request('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`); - if (!clients || clients.length === 0) { + const client = clients[0]; + + if (!client) { throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`); } - return clients; + + this.clientCache = client; + return client; } - private async getRole(role: ClientRole, clientId: string): Promise { + 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); @@ -519,48 +672,300 @@ export class KeycloakApiService { 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' }; + // === VALIDATION DE LA CRÉATION === + + private async validateUserCreation(creatorId: string, userData: CreateUserData): Promise { + const [creatorRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(creatorId), + Promise.resolve(userData.clientRoles || []) + ]); + + const creatorRoleNames = creatorRoles.map(role => role.name as UserRole); + const targetRoleNames = targetRoles; + + this.logSecurityEvent('USER_CREATION_VALIDATION', 'MEDIUM', { + creatorId, + targetRoles: targetRoleNames, + merchantPartnerId: userData.merchantPartnerId + }); + + if (targetRoleNames.length === 0) { + throw new BadRequestException('At least one client role must be specified'); + } + + // Validation de la hiérarchie des rôles + for (const targetRole of targetRoleNames) { + if (!this.canRolesCreateRole(creatorRoleNames, targetRole)) { + this.logSecurityEvent('ROLE_CREATION_VIOLATION', 'HIGH', { + creatorId, + targetRole, + creatorRoles: creatorRoleNames + }); + throw new ForbiddenException(`Cannot create user with role: ${targetRole}`); + } + } + + // Validation spécifique au type d'utilisateur + await this.validateUserTypeCreation(creatorId, creatorRoleNames, userData); + } + + private async validateUserTypeCreation( + creatorId: string, + creatorRoles: UserRole[], + userData: CreateUserData + ): Promise { + const targetRoles = userData.clientRoles || []; + const isMerchantUser = targetRoles.some(role => CONFIG.ROLES.MERCHANT.includes(role)); + + if (isMerchantUser) { + await this.validateMerchantUserCreation(creatorId, creatorRoles, userData); + } else { + await this.validateHubUserCreation(creatorRoles, userData); } } - 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}`; + private async validateMerchantUserCreation( + creatorId: string, + creatorRoles: UserRole[], + userData: CreateUserData + ): Promise { + // Exception pour DCB_PARTNER : il n'a pas besoin de merchantPartnerId car son ID est le merchantPartnerId + if (!userData.merchantPartnerId && !userData.clientRoles.includes(UserRole.DCB_PARTNER)) { + throw new BadRequestException('merchantPartnerId is required for merchant users'); } - 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 merchantCreationRules = { + [UserRole.DCB_PARTNER]: { + allowedRoles: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + validate: async (): Promise => creatorId === userData.merchantPartnerId + }, + [UserRole.DCB_PARTNER_ADMIN]: { + allowedRoles: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + validate: async (): Promise => { + const creatorMerchantId = await this.getUserMerchantPartnerId(creatorId); + return creatorMerchantId === userData.merchantPartnerId; + } + } }; - - const url = `${this.keycloakBaseUrl}/realms/${realm}/protocol/openid-connect/userinfo`; - const response = await firstValueFrom(this.httpService.get(url, config)); - return response.data; + + // Appliquer les règles + for (const [role, rule] of Object.entries(merchantCreationRules)) { + if (creatorRoles.includes(role as UserRole)) { + const isValid = await rule.validate(); + + if (!isValid) { + throw new ForbiddenException(`${role} can only create users for their own merchant`); + } + + const hasInvalidRole = userData.clientRoles?.some(targetRole => + !rule.allowedRoles.includes(targetRole as UserRole) + ); + + if (hasInvalidRole) { + throw new ForbiddenException(`${role} can only create roles: ${rule.allowedRoles.join(', ')}`); + } + return; + } + } + + // Les administrateurs Hub n'ont pas de restrictions supplémentaires + if (!creatorRoles.some(role => CONFIG.ROLES.HUB.includes(role))) { + throw new ForbiddenException('Insufficient permissions to create merchant users'); + } } - // ... autres méthodes de compatibilité pour le StartupService + private async validateHubUserCreation(creatorRoles: UserRole[], userData: CreateUserData): Promise { + if (userData.merchantPartnerId) { + throw new BadRequestException('merchantPartnerId should not be provided for hub users'); + } + + if (!creatorRoles.some(role => CONFIG.ROLES.HUB.includes(role))) { + throw new ForbiddenException('Only hub administrators can create hub users'); + } + } + + // === VALIDATION DE SUPPRESSION AVEC HIÉRARCHIE === + + private async validateDeletion(requesterId: string, targetUserId: string): Promise { + const requesterRoles = await this.getUserClientRoles(requesterId); + const requesterRoleNames = requesterRoles.map(role => role.name as UserRole); + + this.logSecurityEvent('DELETE_PERMISSION_CHECK', 'HIGH', { + requesterId, + targetUserId, + requesterRoles: requesterRoleNames + }); + + if (requesterRoleNames.length === 0) { + throw new ForbiddenException('No roles assigned to requester'); + } + + if (requesterRoleNames.includes(UserRole.DCB_SUPPORT)) { + throw new ForbiddenException('DCB_SUPPORT cannot delete users'); + } + + if (requesterId === targetUserId) { + throw new BadRequestException('Cannot delete your own account'); + } + + await this.validateRoleHierarchyForDeletion(requesterId, targetUserId); + } + + private async validateRoleHierarchyForDeletion(requesterId: string, targetUserId: string): Promise { + const [requesterRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(requesterId), + this.getUserClientRoles(targetUserId) + ]); + + const requesterRoleNames = requesterRoles.map(role => role.name as UserRole); + const targetRoleNames = targetRoles.map(role => role.name as UserRole); + + const requesterHighestRole = this.getHighestRole(requesterRoleNames); + const targetHighestRole = this.getHighestRole(targetRoleNames); + + const canDelete = this.canRoleCreateRole(requesterHighestRole, targetHighestRole); + + if (!canDelete) { + throw new ForbiddenException( + `Role ${requesterHighestRole} cannot delete users with role ${targetHighestRole}` + ); + } + } + + // === MÉTHODES UTILITAIRES === + + private async getCreatorUsername(creatorId: string): Promise { + try { + const creatorUser = await this.getUserById(creatorId, creatorId); + return creatorUser.username; + } catch (error) { + this.logger.warn(`Could not fetch creator username: ${error.message}`); + return 'unknown'; + } + } + + private logSecurityEvent( + event: string, + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL', + details: any + ): void { + const logEntry = { + timestamp: new Date().toISOString(), + service: 'KeycloakApiService', + event, + severity, + ...details + }; + + const logMethod = severity === 'CRITICAL' || severity === 'HIGH' ? 'error' : + severity === 'MEDIUM' ? 'warn' : 'log'; + + this.logger[logMethod](`SECURITY: ${event}`, logEntry); + } + + // === MÉTHODES PUBLIQUES POUR LA GESTION DES RÔLES === + + /** + * Obtient la hiérarchie des rôles complète + */ + getRoleHierarchy(): Record { + return { ...ROLE_HIERARCHY }; + } + + /** + * Vérifie si un utilisateur peut gérer un autre utilisateur basé sur leurs rôles + */ + async canUserManageUser(managerId: string, targetUserId: string): Promise { + try { + const [managerRoles, targetRoles] = await Promise.all([ + this.getUserClientRoles(managerId), + this.getUserClientRoles(targetUserId) + ]); + + const managerRoleNames = managerRoles.map(role => role.name as UserRole); + const targetRoleNames = targetRoles.map(role => role.name as UserRole); + + const managerHighestRole = this.getHighestRole(managerRoleNames); + const targetHighestRole = this.getHighestRole(targetRoleNames); + + return this.canRoleCreateRole(managerHighestRole, targetHighestRole); + } catch (error) { + this.logger.warn(`Error checking user management permissions: ${error.message}`); + return false; + } + } + + /** + * Vérifie si un utilisateur a un rôle spécifique + */ + async userHasRole(userId: string, role: UserRole): Promise { + const userRoles = await this.getUserClientRoles(userId); + return userRoles.some(userRole => userRole.name === role); + } + + /** + * Vérifie si un utilisateur a au moins un des rôles spécifiés + */ + async userHasAnyRole(userId: string, roles: UserRole[]): Promise { + const userRoles = await this.getUserClientRoles(userId); + const userRoleNames = userRoles.map(role => role.name as UserRole); + return roles.some(role => userRoleNames.includes(role)); + } + + /** + * Vérifie si un utilisateur a tous les rôles spécifiés + */ + async userHasAllRoles(userId: string, roles: UserRole[]): Promise { + const userRoles = await this.getUserClientRoles(userId); + const userRoleNames = userRoles.map(role => role.name as UserRole); + return roles.every(role => userRoleNames.includes(role)); + } + + // === MÉTHODES DE RAPPORT ET STATISTIQUES === + + async getUserStatistics(): Promise<{ + total: number; + enabled: number; + disabled: number; + byRole: Record; + byUserType: { HUB: number; MERCHANT: number }; + }> { + const users = await this.getAllUsers(); + + const statistics = { + total: users.length, + enabled: users.filter(user => user.enabled).length, + disabled: users.filter(user => !user.enabled).length, + byRole: {} as Record, + byUserType: { HUB: 0, MERCHANT: 0 } + }; + + // Compter par rôle + for (const user of users) { + const roles = await this.getUserClientRoles(user.id); + for (const role of roles) { + statistics.byRole[role.name] = (statistics.byRole[role.name] || 0) + 1; + } + + // Compter par type d'utilisateur + const userType = user.attributes?.userType?.[0]; + if (userType === 'HUB') { + statistics.byUserType.HUB++; + } else if (userType === 'MERCHANT') { + statistics.byUserType.MERCHANT++; + } + } + + return statistics; + } + + async getActiveSessionsCount(): Promise { + try { + const sessions = await this.request('GET', `/admin/realms/${this.realm}/clients/${(await this.getClient()).id}/user-sessions`); + return sessions.length; + } catch (error) { + this.logger.warn(`Failed to get active sessions count: ${error.message}`); + return 0; + } + } } \ No newline at end of file diff --git a/src/auth/services/keycloak-user.model.ts b/src/auth/services/keycloak-user.model.ts new file mode 100644 index 0000000..780a598 --- /dev/null +++ b/src/auth/services/keycloak-user.model.ts @@ -0,0 +1,153 @@ +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[]; + [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 | null; + clientRoles: UserRole[]; + attributes?: { + userStatus?: string[]; + lastLogin?: string[]; + merchantPartnerId?: string[]; + createdBy?: string[]; + createdByUsername?: string[]; + userType?: string[]; + [key: string]: string[] | undefined; + }; +} + +export enum UserType { + HUB = 'HUB', + MERCHANT_PARTNER = 'MERCHANT' +} + +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; + userType: string; + 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; + +} + +export interface HubUserStats { + totalAdmins: number; + totalSupports: number; + activeUsers: number; + inactiveUsers: number; +} + +export interface MerchantStats { + totalAdmins: number; + totalManagers: number; + totalSupports: number; + activeUsers: number; + inactiveUsers: number; +} + +export interface UserQueryDto { + page?: number; + limit?: number; + search?: string; + userType?: UserType; + merchantPartnerId?: string; + enabled?: boolean; +} + +export interface UserResponse { + user: KeycloakUser; + message: string; +} + +export interface PaginatedUsersResponse { + users: KeycloakUser[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +// Pour l'authentification +export interface LoginDto { + username: string; + password: string; +} + +export interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + refresh_expires_in?: number; + scope?: string; +} diff --git a/src/auth/services/startup.service.ts b/src/auth/services/startup.service.ts index a898b2d..4c49e0f 100644 --- a/src/auth/services/startup.service.ts +++ b/src/auth/services/startup.service.ts @@ -1,42 +1,76 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { KeycloakApiService } from './keycloak-api.service'; -@Injectable() -export class StartupService implements OnModuleInit { - private readonly logger = new Logger(StartupService.name); - private initialized = false; - private error: string | null = null; +interface TestResults { + connection: { [key: string]: string }; +} - constructor(private readonly keycloakApiService: KeycloakApiService) {} +@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('Vérification de la disponibilité de Keycloak...'); + this.logger.log('🚀 Démarrage des tests de connexion'); 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); + 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.initialized ? 'healthy' : 'unhealthy', - keycloakConnected: this.initialized, + status: this.isInitialized ? 'healthy' : 'unhealthy', + keycloakConnected: this.isInitialized, + testResults: this.testResults, timestamp: new Date(), - error: this.error, + error: this.initializationError, }; } isHealthy(): boolean { - return this.initialized; + return this.isInitialized; } -} + + getTestResults(): TestResults { + return this.testResults; + } +} \ No newline at end of file diff --git a/src/auth/services/token.service.ts b/src/auth/services/token.service.ts index 236d939..1b910eb 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,40 @@ 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; +} + + @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 +52,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 +69,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 +110,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 +127,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 +275,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 +307,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 +494,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..925c86e --- /dev/null +++ b/src/hub-users/controllers/hub-users.controller.ts @@ -0,0 +1,624 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Request, + HttpCode, + HttpStatus, + ParseUUIDPipe, + ForbiddenException, + Logger, + BadRequestException +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiProperty, + getSchemaPath +} from '@nestjs/swagger'; +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsBoolean, + MinLength, + IsString, + ValidateIf +} from 'class-validator'; + +import { HubUsersService } from '../services/hub-users.service'; +import { UserRole, UserType } from '../../auth/services/keycloak-user.model'; + +import { RESOURCES } from '../../constants/resources'; +import { SCOPES } from '../../constants/scopes'; +import { Resource, Scopes } from 'nest-keycloak-connect'; +import { CreateUserData, User } from '../models/hub-user.model'; + +// ===== DTO SPÉCIFIQUES AUX HUB USERS ===== + +export class CreateHubUserDto { + @ApiProperty({ description: 'Username for the user' }) + @IsNotEmpty({ message: 'Username is required' }) + @IsString() + @MinLength(3, { message: 'Username must be at least 3 characters' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + @IsNotEmpty({ message: 'Email is required' }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; + + @ApiProperty({ description: 'First name' }) + @IsNotEmpty({ message: 'First name is required' }) + @IsString() + firstName: string; + + @ApiProperty({ description: 'Last name' }) + @IsNotEmpty({ message: 'Last name is required' }) + @IsString() + lastName: string; + + @ApiProperty({ description: 'Password for the user' }) + @IsNotEmpty({ message: 'Password is required' }) + @IsString() + @MinLength(8, { message: 'Password must be at least 8 characters' }) + password: string; + + @ApiProperty({ + enum: UserRole, + description: 'Role for the user', + examples: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER] + }) + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'Enabled must be a boolean' }) + enabled?: boolean = true; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'EmailVerified must be a boolean' }) + emailVerified?: boolean = true; + + @ApiProperty({ + enum: UserType, + description: 'Type of user', + example: UserType.HUB + }) + @IsEnum(UserType, { message: 'Invalid user type' }) + @IsNotEmpty({ message: 'User type is required' }) + userType: UserType; + + // Pas de merchantPartnerId pour les hub users +} + +export class UpdateHubUserDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + firstName?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + lastName?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + +export class ResetHubUserPasswordDto { + @ApiProperty({ description: 'New password' }) + @IsNotEmpty() + @IsString() + @MinLength(8) + newPassword: string; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean() + temporary?: boolean = true; +} + +export class UpdateHubUserRoleDto { + @ApiProperty({ + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + description: 'New role for the user' + }) + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; +} + +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; + + @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({ enum: ['HUB'], description: 'User type' }) + userType: UserType; + + @ApiProperty({ description: 'Creation timestamp' }) + createdTimestamp: number; + + @ApiProperty({ required: false, description: 'Last login timestamp' }) + lastLogin?: number; +} + +export class MerchantUserResponse { + @ApiProperty({ description: 'User ID' }) + id: string; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + description: 'User role' + }) + role: UserRole; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ required: false, description: 'Merchant partner ID' }) + merchantPartnerId?: string; + + @ApiProperty({ description: 'User creator ID' }) + createdBy: string; + + @ApiProperty({ description: 'User creator username' }) + createdByUsername: string; + + @ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' }) + userType: 'HUB' | 'MERCHANT'; + + @ApiProperty({ description: 'Creation timestamp' }) + createdTimestamp: number; + + @ApiProperty({ required: false, description: 'Last login timestamp' }) + lastLogin?: number; +} + +export class HubUserProfileResponse { + @ApiProperty({ description: 'User ID' }) + id: string; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ + description: 'Client roles', + type: [String], + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER] + }) + clientRoles: string[]; + + @ApiProperty({ required: false, description: 'User creator ID' }) + createdBy?: string; + + @ApiProperty({ required: false, description: 'User creator username' }) + createdByUsername?: string; +} + +export class MessageResponse { + @ApiProperty({ description: 'Response message' }) + message: string; +} + +// Mapper functions +function mapToHubUserResponse(user: User): HubUserResponse { + return { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + enabled: user.enabled, + emailVerified: user.emailVerified, + createdBy: user.createdBy, + createdByUsername: user.createdByUsername, + userType: user.userType, + createdTimestamp: user.createdTimestamp, + lastLogin: user.lastLogin, + }; +} + +// Mapper functions +function mapToMerchantUserResponse(user: User): MerchantUserResponse { + return { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + enabled: user.enabled, + emailVerified: user.emailVerified, + merchantPartnerId: user.merchantPartnerId, + createdBy: user.createdBy, + createdByUsername: user.createdByUsername, + userType: user.userType, + createdTimestamp: user.createdTimestamp, + lastLogin: user.lastLogin, + }; +} + +function mapToHubUserProfileResponse(profile: any): HubUserProfileResponse { + return { + id: profile.id, + username: profile.username, + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + emailVerified: profile.emailVerified, + enabled: profile.enabled, + clientRoles: profile.clientRoles, + createdBy: profile.createdBy, + createdByUsername: profile.createdByUsername, + }; +} + +// ===== CONTROLLER POUR LES UTILISATEURS HUB ===== + +@ApiTags('Hub Users') +@ApiBearerAuth() +@Controller('hub-users') +@Resource(RESOURCES.HUB_USER) +export class HubUsersController { + constructor(private readonly usersService: HubUsersService) {} + private readonly logger = new Logger(HubUsersController.name); + + // ===== ROUTES SANS PARAMÈTRES ===== + + @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] + }) + @Scopes(SCOPES.READ) + async getAllHubUsers(@Request() req): Promise { + const userId = req.user.sub; + const users = await this.usersService.getAllHubUsers(userId); + return users.map(mapToHubUserResponse); + } + + @Get('partners/dcb-partners') + @ApiOperation({ + summary: 'Get all DCB_PARTNER users only', + description: 'Returns only DCB_PARTNER users (excludes DCB_ADMIN and DCB_SUPPORT)' + }) + @ApiResponse({ + status: 200, + description: 'DCB_PARTNER users retrieved successfully', + type: [HubUserResponse] + }) + @Scopes(SCOPES.READ) + async getAllDcbPartners(@Request() req): Promise { + const userId = req.user.sub; + const users = await this.usersService.getAllDcbPartners(userId); + return users.map(mapToHubUserResponse); + } + + @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 + }) + @Scopes(SCOPES.WRITE) + async createHubUser( + @Body() createUserDto: CreateHubUserDto, + @Request() req + ): Promise { + + // Debug complet + this.logger.debug('🔍 === CONTROLLER - CREATE HUB USER ==='); + this.logger.debug('Request headers:', req.headers); + this.logger.debug('Content-Type:', req.headers['content-type']); + this.logger.debug('Raw body exists:', !!req.body); + this.logger.debug('CreateHubUserDto received:', createUserDto); + this.logger.debug('DTO structure:', { + username: createUserDto.username, + email: createUserDto.email, + firstName: createUserDto.firstName, + lastName: createUserDto.lastName, + role: createUserDto.role, + userType: createUserDto.userType, + }); + this.logger.debug('===================================='); + + // Validation manuelle renforcée + const requiredFields = ['username', 'email', 'firstName', 'lastName', 'password', 'role', 'userType']; + const missingFields = requiredFields.filter(field => !createUserDto[field]); + + if (missingFields.length > 0) { + throw new BadRequestException(`Missing required fields: ${missingFields.join(', ')}`); + } + + if (createUserDto.userType !== UserType.HUB) { + throw new BadRequestException('User type must be HUB for hub users'); + } + + const userId = req.user.sub; + + const userData: CreateUserData = { + ...createUserDto, + }; + + this.logger.debug('UserData passed to service:', userData); + + try { + const user = await this.usersService.createHubUser(userId, userData); + return mapToHubUserResponse(user); + } catch (error) { + this.logger.error('Error creating hub user:', error); + throw error; + } + } + // ===== ROUTES AVEC PARAMÈTRES STATIQUES ===== + + @Get('all-users') + @ApiOperation({ + summary: 'Get global users overview', + description: 'Returns hub users and all merchant users (Admin only)' + }) + @ApiResponse({ + status: 200, + description: 'Global overview retrieved successfully', + schema: { + type: 'object', + properties: { + hubUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } }, + merchantUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } }, + statistics: { + type: 'object', + properties: { + totalHubUsers: { type: 'number' }, + totalMerchantUsers: { type: 'number' }, + totalUsers: { type: 'number' } + } + } + } + } + }) + @Scopes(SCOPES.READ) + async getGlobalUsersOverview(@Request() req): Promise { + const userId = req.user.sub; + + const isAdmin = await this.usersService.isUserHubAdminOrSupport(userId); + if (!isAdmin) { + throw new ForbiddenException('Only Hub administrators can access global overview'); + } + + const hubUsers = await this.usersService.getAllHubUsers(userId); + const merchantUsers = await this.usersService.getMyMerchantUsers(userId); + + return { + hubUsers: hubUsers.map(mapToHubUserResponse), + merchantUsers: merchantUsers.map(mapToMerchantUserResponse), + statistics: { + totalHubUsers: hubUsers.length, + totalMerchantUsers: merchantUsers.length, + totalUsers: hubUsers.length + merchantUsers.length + } + }; + } + + @Get('profile/:id') + @ApiOperation({ summary: 'Get complete user profile' }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully', + type: HubUserProfileResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getCompleteUserProfile( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const tokenUser = req.user; + const profile = await this.usersService.getCompleteUserProfile(id, tokenUser); + return mapToHubUserProfileResponse(profile); + } + + @Get('role/:role') + @ApiOperation({ summary: 'Get hub users by role' }) + @ApiResponse({ + status: 200, + description: 'Hub users retrieved successfully', + type: [HubUserResponse] + }) + @ApiParam({ + name: 'role', + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + description: 'User role' + }) + @Scopes(SCOPES.READ) + async getHubUsersByRole( + @Param('role') role: UserRole, + @Request() req + ): Promise { + const userId = req.user.sub; + const users = await this.usersService.getHubUsersByRole(role, userId); + return users.map(mapToHubUserResponse); + } + + // ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES ===== + + @Get(':id') + @ApiOperation({ summary: 'Get hub user by ID' }) + @ApiResponse({ + status: 200, + description: 'Hub user retrieved successfully', + type: HubUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getHubUserById( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.getHubUserById(id, userId); + return mapToHubUserResponse(user); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a hub user' }) + @ApiResponse({ + status: 200, + description: 'Hub user updated successfully', + type: HubUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.WRITE) + async updateHubUser( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateUserDto: UpdateHubUserDto, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.updateHubUser(id, updateUserDto, userId); + return mapToHubUserResponse(user); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a hub user' }) + @ApiResponse({ status: 200, description: 'Hub user deleted successfully' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.DELETE) + async deleteHubUser( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const userId = req.user.sub; + await this.usersService.deleteHubUser(id, userId); + return { message: 'Hub user deleted successfully' }; + } + + @Put(':id/role') + @ApiOperation({ summary: 'Update hub user role' }) + @ApiResponse({ + status: 200, + description: 'User role updated successfully', + type: HubUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.WRITE) + async updateHubUserRole( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateRoleDto: UpdateHubUserRoleDto, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.updateHubUserRole(id, updateRoleDto.role, userId); + return mapToHubUserResponse(user); + } + + @Post(':id/reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset hub user password' }) + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.WRITE) + async resetHubUserPassword( + @Param('id', ParseUUIDPipe) id: string, + @Body() resetPasswordDto: ResetHubUserPasswordDto, + @Request() req + ): Promise { + const userId = req.user.sub; + await this.usersService.resetUserPassword( + id, + resetPasswordDto.newPassword, + resetPasswordDto.temporary, + userId + ); + return { message: 'Password reset successfully' }; + } +} \ 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..e1a59c1 --- /dev/null +++ b/src/hub-users/controllers/merchant-users.controller.ts @@ -0,0 +1,479 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Request, + HttpCode, + HttpStatus, + ParseUUIDPipe, + ForbiddenException, + Logger, + BadRequestException, + InternalServerErrorException +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiProperty +} from '@nestjs/swagger'; + +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsBoolean, + MinLength, + IsString, + ValidateIf +} from 'class-validator'; + +import { HubUsersService } from '../services/hub-users.service'; +import { UserRole, UserType } from '../../auth/services/keycloak-user.model'; + +import { RESOURCES } from '../../constants/resources'; +import { SCOPES } from '../../constants/scopes'; +import { Resource, Scopes } from 'nest-keycloak-connect'; +import { CreateUserData, User } from '../models/hub-user.model'; + +// ===== DTO SPÉCIFIQUES AUX MERCHANT USERS ===== + +export class CreateMerchantUserDto { + @ApiProperty({ description: 'Username for the user' }) + @IsNotEmpty({ message: 'Username is required' }) + @IsString() + @MinLength(3, { message: 'Username must be at least 3 characters' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + @IsNotEmpty({ message: 'Email is required' }) + @IsEmail({}, { message: 'Invalid email format' }) + email: string; + + @ApiProperty({ description: 'First name' }) + @IsNotEmpty({ message: 'First name is required' }) + @IsString() + firstName: string; + + @ApiProperty({ description: 'Last name' }) + @IsNotEmpty({ message: 'Last name is required' }) + @IsString() + lastName: string; + + @ApiProperty({ description: 'Password for the user' }) + @IsNotEmpty({ message: 'Password is required' }) + @IsString() + @MinLength(8, { message: 'Password must be at least 8 characters' }) + password: string; + + @ApiProperty({ + enum: UserRole, + description: 'Role for the user', + examples: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT] + }) + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'Enabled must be a boolean' }) + enabled?: boolean = true; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean({ message: 'EmailVerified must be a boolean' }) + emailVerified?: boolean = true; + + @ApiProperty({ + enum: UserType, + description: 'Type of user', + example: UserType.MERCHANT_PARTNER + }) + @IsEnum(UserType, { message: 'Invalid user type' }) + @IsNotEmpty({ message: 'User type is required' }) + userType: UserType; + + @ApiProperty({ required: false }) + @IsOptional() + @ValidateIf((o) => o.userType === UserType.MERCHANT_PARTNER && o.role !== UserRole.DCB_PARTNER) + @IsString({ message: 'Merchant partner ID must be a string' }) + merchantPartnerId?: string | null; +} + +export class ResetMerchantUserPasswordDto { + @ApiProperty({ description: 'New password' }) + @IsNotEmpty() + @IsString() + @MinLength(8) + newPassword: string; + + @ApiProperty({ required: false, default: true }) + @IsOptional() + @IsBoolean() + temporary?: boolean = true; +} + +export class UpdateMerchantUserDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + firstName?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + lastName?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + enabled?: boolean; +} + +export class UpdateMerchantUserRoleDto { + @ApiProperty({ + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + description: 'New role for the user' + }) + @IsEnum(UserRole, { message: 'Invalid role' }) + @IsNotEmpty({ message: 'Role is required' }) + role: UserRole; +} + +export class MerchantUserResponse { + @ApiProperty({ description: 'User ID' }) + id: string; + + @ApiProperty({ description: 'Username' }) + username: string; + + @ApiProperty({ description: 'Email address' }) + email: string; + + @ApiProperty({ description: 'First name' }) + firstName: string; + + @ApiProperty({ description: 'Last name' }) + lastName: string; + + @ApiProperty({ + enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], + description: 'User role' + }) + role: UserRole; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ required: false, description: 'Merchant partner ID' }) + merchantPartnerId?: string; + + @ApiProperty({ description: 'User creator ID' }) + createdBy: string; + + @ApiProperty({ description: 'User creator username' }) + createdByUsername: string; + + @ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' }) + userType: 'HUB' | 'MERCHANT'; + + @ApiProperty({ description: 'Creation timestamp' }) + createdTimestamp: number; + + @ApiProperty({ required: false, description: 'Last login timestamp' }) + lastLogin?: number; +} + +export class UserProfileResponse { + @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({ description: 'Whether the email is verified' }) + emailVerified: boolean; + + @ApiProperty({ description: 'Whether the user is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'Client roles', type: [String] }) + clientRoles: string[]; + + @ApiProperty({ required: false, description: 'Merchant partner ID' }) + merchantPartnerId?: string; + + @ApiProperty({ required: false, description: 'User creator ID' }) + createdBy?: string; + + @ApiProperty({ required: false, description: 'User creator username' }) + createdByUsername?: string; +} + +export class MessageResponse { + @ApiProperty({ description: 'Response message' }) + message: string; +} + +// Mapper functions +function mapToMerchantUserResponse(user: User): MerchantUserResponse { + return { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + enabled: user.enabled, + emailVerified: user.emailVerified, + merchantPartnerId: user.merchantPartnerId, + createdBy: user.createdBy, + createdByUsername: user.createdByUsername, + userType: user.userType, + createdTimestamp: user.createdTimestamp, + lastLogin: user.lastLogin, + }; +} + +function mapToUserProfileResponse(profile: any): UserProfileResponse { + return { + id: profile.id, + username: profile.username, + email: profile.email, + firstName: profile.firstName, + lastName: profile.lastName, + emailVerified: profile.emailVerified, + enabled: profile.enabled, + clientRoles: profile.clientRoles, + merchantPartnerId: profile.merchantPartnerId, + createdBy: profile.createdBy, + createdByUsername: profile.createdByUsername, + }; +} + +// ===== CONTROLLER POUR LES UTILISATEURS MERCHANT ===== + +@ApiTags('Merchant Users') +@ApiBearerAuth() +@Controller('merchant-users') +@Resource(RESOURCES.MERCHANT_USER) +export class MerchantUsersController { + constructor(private readonly usersService: HubUsersService) {} + + // ===== ROUTES SANS PARAMÈTRES D'ABORD ===== + + @Get() + @ApiOperation({ + summary: 'Get merchant users for current user merchant', + description: 'Returns merchant users. Hub admins/support see all merchants users, others see only their own merchant users.' + }) + @ApiResponse({ + status: 200, + description: 'Merchant users retrieved successfully', + type: [MerchantUserResponse] + }) + @Scopes(SCOPES.READ) + async getMyMerchantUsers(@Request() req): Promise { + const userId = req.user.sub; + + try { + const users = await this.usersService.getMyMerchantUsers(userId); + return users.map(mapToMerchantUserResponse); + + } catch (error) { + if (error instanceof BadRequestException || error instanceof ForbiddenException) { + throw error; + } + + throw new InternalServerErrorException('Could not retrieve merchant users'); + } + } + + @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 + }) + @Scopes(SCOPES.WRITE) + async createMerchantUser( + @Body() createUserDto: CreateMerchantUserDto, + @Request() req + ): Promise { + const userId = req.user.sub; + + if (!createUserDto.merchantPartnerId && !createUserDto.role.includes(UserRole.DCB_PARTNER)) { + throw new BadRequestException('merchantPartnerId is required for merchant users except DCB_PARTNER'); + } + + const userData: CreateUserData = { + ...createUserDto, + }; + + const user = await this.usersService.createMerchantUser(userId, userData); + return mapToMerchantUserResponse(user); + } + + // ===== ROUTES AVEC PARAMÈTRES STATIQUES AVANT LES PARAMÈTRES DYNAMIQUES ===== + + @Get('profile/:id') + @ApiOperation({ summary: 'Get complete user profile' }) + @ApiResponse({ + status: 200, + description: 'User profile retrieved successfully', + type: UserProfileResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getCompleteUserProfile( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const tokenUser = req.user; + const profile = await this.usersService.getCompleteUserProfile(id, tokenUser); + return mapToUserProfileResponse(profile); + } + + @Get('merchant-partner/:userId') + @ApiOperation({ summary: 'Get merchant partner ID for a user' }) + @ApiResponse({ + status: 200, + description: 'Merchant partner ID retrieved successfully', + schema: { + type: 'object', + properties: { + merchantPartnerId: { type: 'string', nullable: true } + } + } + }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getUserMerchantPartnerId( + @Param('userId', ParseUUIDPipe) userId: string + ): Promise<{ merchantPartnerId: string | null }> { + const merchantPartnerId = await this.usersService.getUserMerchantPartnerId(userId); + return { merchantPartnerId }; + } + + // ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES EN DERNIER ===== + + @Get(':id') + @ApiOperation({ summary: 'Get merchant user by ID' }) + @ApiResponse({ + status: 200, + description: 'Merchant user retrieved successfully', + type: MerchantUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.READ) + async getMerchantUserById( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.getMerchantUserById(id, userId); + return mapToMerchantUserResponse(user); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a merchant user' }) + @ApiResponse({ + status: 200, + description: 'Merchant user updated successfully', + type: MerchantUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.WRITE) + async updateMerchantUser( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateUserDto: UpdateMerchantUserDto, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.updateMerchantUser(id, updateUserDto, userId); + return mapToMerchantUserResponse(user); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a merchant user' }) + @ApiResponse({ status: 200, description: 'Merchant user deleted successfully' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.DELETE) + async deleteMerchantUser( + @Param('id', ParseUUIDPipe) id: string, + @Request() req + ): Promise { + const userId = req.user.sub; + await this.usersService.deleteMerchantUser(id, userId); + return { message: 'Merchant user deleted successfully' }; + } + + @Put(':id/role') + @ApiOperation({ summary: 'Update merchant user role' }) + @ApiResponse({ + status: 200, + description: 'User role updated successfully', + type: MerchantUserResponse + }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.WRITE) + async updateMerchantUserRole( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateRoleDto: UpdateMerchantUserRoleDto, + @Request() req + ): Promise { + const userId = req.user.sub; + const user = await this.usersService.updateMerchantUserRole(id, updateRoleDto.role, userId); + return mapToMerchantUserResponse(user); + } + + @Post(':id/reset-password') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset merchant user password' }) + @ApiResponse({ status: 200, description: 'Password reset successfully' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @Scopes(SCOPES.WRITE) + async resetMerchantUserPassword( + @Param('id', ParseUUIDPipe) id: string, + @Body() resetPasswordDto: ResetMerchantUserPasswordDto, + @Request() req + ): Promise { + const userId = req.user.sub; + await this.usersService.resetUserPassword( + id, + resetPasswordDto.newPassword, + resetPasswordDto.temporary, + userId + ); + return { message: 'Password reset successfully' }; + } +} \ 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..7c93b60 --- /dev/null +++ b/src/hub-users/hub-users.module.ts @@ -0,0 +1,23 @@ +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 { MerchantUsersController } from './controllers/merchant-users.controller' +import { KeycloakApiService } from '../auth/services/keycloak-api.service'; + + +@Module({ + imports: [ + HttpModule, + JwtModule.register({}), + ], + providers: [HubUsersService, KeycloakApiService, TokenService], + controllers: [HubUsersController, MerchantUsersController ], + exports: [HubUsersService, KeycloakApiService, TokenService, JwtModule], +}) +export class HubUsersModule {} + + + diff --git a/src/hub-users/models/hub-user.model.ts b/src/hub-users/models/hub-user.model.ts new file mode 100644 index 0000000..09f9dcb --- /dev/null +++ b/src/hub-users/models/hub-user.model.ts @@ -0,0 +1,114 @@ +// user.models.ts +// Interfaces et Constantes Centralisées +export interface User { + id: string; + username: string; + email: string; + firstName: string; + lastName: string; + role: UserRole; + enabled: boolean; + emailVerified: boolean; + merchantPartnerId?: string; + createdBy: string; + createdByUsername: string; + createdTimestamp: number; + lastLogin?: number; + userType: UserType; +} + +export interface CreateUserData { + username: string; + email: string; + firstName: string; + lastName: string; + password?: string; + role: UserRole; + enabled?: boolean; + emailVerified?: boolean; + merchantPartnerId?: string | null; +} + +export enum UserType { + HUB = 'HUB', + MERCHANT_PARTNER = 'MERCHANT' +} + +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..1c9d92f --- /dev/null +++ b/src/hub-users/services/hub-users.service.ts @@ -0,0 +1,641 @@ +import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { KeycloakApiService } from '../../auth/services/keycloak-api.service'; +import { CreateUserData as KeycloakCreateUserData, UserRole, KeycloakUser, LoginDto, TokenResponse, KeycloakRole } from '../../auth/services/keycloak-user.model'; +import { CreateUserData, User, UserType } from '../models/hub-user.model'; + +// Configuration Centralisée +const SECURITY_CONFIG = { + ROLES: { + HUB: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], + MERCHANT: [ + UserRole.DCB_PARTNER_ADMIN, + UserRole.DCB_PARTNER_MANAGER, + UserRole.DCB_PARTNER_SUPPORT, + ] + }, + VALIDATION: { + MIN_PASSWORD_LENGTH: 8, + MAX_USERNAME_LENGTH: 50, + MIN_USERNAME_LENGTH: 3 + } +}; + +@Injectable() +export class HubUsersService { + private readonly logger = new Logger(HubUsersService.name); + + constructor(private readonly keycloakApi: KeycloakApiService) {} + + // === PUBLIC INTERFACE === + + async authenticateUser(loginDto: LoginDto): Promise { + return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password); + } + + async getCompleteUserProfile(userId: string, tokenUser: any) { + try { + const [userDetails, userRoles] = await Promise.all([ + this.keycloakApi.getUserById(userId, userId), + this.keycloakApi.getUserClientRoles(userId) + ]); + + return this.buildUserProfile(userId, tokenUser, userDetails, userRoles); + } catch (error) { + throw new InternalServerErrorException('Could not retrieve user profile'); + } + } + + // === HUB USERS MANAGEMENT === + + async getAllHubUsers(requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + return this.processUsersByType(await this.keycloakApi.getAllUsers(), UserType.HUB); + } + + /** + * Récupère uniquement les utilisateurs DCB_PARTNER + */ + async getAllDcbPartners(requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + + const allUsers = await this.keycloakApi.getAllUsers(); + const dcbPartners: User[] = []; + + for (const user of allUsers) { + if (!user.id) continue; + + try { + const userRoles = await this.keycloakApi.getUserClientRoles(user.id); + + // Vérifier si l'utilisateur est un DCB_PARTNER + const isDcbPartner = userRoles.some(role => + role.name === UserRole.DCB_PARTNER + ); + + if (isDcbPartner) { + const mappedUser = this.mapToUser(user, userRoles); + dcbPartners.push(mappedUser); + } + } catch (error) { + this.logger.warn(`Could not process user ${user.id} for DCB_PARTNER filter: ${error.message}`); + } + } + + this.logger.log(`Retrieved ${dcbPartners.length} DCB_PARTNER users`); + + return dcbPartners; + } + + async getHubUserById(userId: string, requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + return this.getValidatedUser(userId, requesterId, UserType.HUB); + } + + async createHubUser(creatorId: string, userData: CreateUserData): Promise { + this.validateUserCreationData(userData, UserType.HUB); + await this.validateHubUserAccess(creatorId); + await this.validateUserUniqueness(userData.username, userData.email); + + const keycloakUserData = this.buildKeycloakUserData(userData); + const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData); + + this.logger.log(`Hub user created: ${userData.username}`); + return this.getHubUserById(userId, creatorId); + } + + async updateHubUser( + userId: string, + updates: Partial>, + requesterId: string + ): Promise { + await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => { + await this.keycloakApi.updateUser(userId, updates, requesterId); + }); + return this.getHubUserById(userId, requesterId); + } + + async deleteHubUser(userId: string, requesterId: string): Promise { + await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => { + await this.keycloakApi.deleteUser(userId, requesterId); + }); + this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`); + } + + async getHubUsersByRole(role: UserRole, requesterId: string): Promise { + await this.validateHubUserAccess(requesterId); + const allHubUsers = await this.getAllHubUsers(requesterId); + return allHubUsers.filter(user => user.role === role); + } + + async updateHubUserRole(userId: string, newRole: UserRole, requesterId: string): Promise { + await this.validateRoleChangePermission(requesterId); + await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => { + await this.keycloakApi.setClientRoles(userId, [newRole]); + }); + return this.getHubUserById(userId, requesterId); + } + + // === MERCHANT USERS MANAGEMENT === + + /** + * Vérifie si un utilisateur est Hub Admin ou Support + */ + async isUserHubAdminOrSupport(userId: string): Promise { + try { + const userRoles = await this.keycloakApi.getUserClientRoles(userId); + const hubAdminSupportRoles = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT]; + + return userRoles.some(role => + hubAdminSupportRoles.includes(role.name as UserRole) + ); + } catch (error) { + this.logger.error(`Error checking Hub Admin/Support status for user ${userId}:`, error); + return false; + } + } + + /** + * Vérifie si un utilisateur est Hub Admin ou Support + */ + async isUserMerchantPartner(userId: string): Promise { + try { + const userRoles = await this.keycloakApi.getUserClientRoles(userId); + const hubMerchantPartnerRoles = [UserRole.DCB_PARTNER]; + + return userRoles.some(role => + hubMerchantPartnerRoles.includes(role.name as UserRole) + ); + } catch (error) { + this.logger.error(`Error checking Merchant Partner status for user ${userId}:`, error); + return false; + } + } + + /** + * Récupère les utilisateurs marchands selon les permissions de l'utilisateur + * - Hub Admin/Support: tous les utilisateurs marchands de tous les merchants + * - Autres: seulement les utilisateurs de leur propre merchant + */ + async getMyMerchantUsers(userId: string): Promise { + try { + // Vérifier si l'utilisateur est un admin ou support Hub + const isHubAdminOrSupport = await this.isUserHubAdminOrSupport(userId) + + if (isHubAdminOrSupport) { + // Hub Admin/Support peuvent voir TOUS les utilisateurs marchands + return await this.getAllMerchantUsersForHubAdmin(userId); + } + + // Pour les autres utilisateurs (DCB_PARTNER, DCB_PARTNER_ADMIN, etc.) + return await this.getUsersForMerchants(userId); + + } catch (error) { + this.logger.error(`Error in getMyMerchantUsers for user ${userId}:`, error); + throw error; + } + } + + /** + * Récupère TOUS les utilisateurs marchands pour les Hub Admin/Support + */ + private async getAllMerchantUsersForHubAdmin(adminUserId: string): Promise { + this.logger.log(`Hub Admin/Support ${adminUserId} accessing ALL merchant users`); + + // Valider que l'utilisateur a bien les droits Hub + await this.validateHubUserAccess(adminUserId); + + // Récupérer tous les utilisateurs du système + const allUsers = await this.keycloakApi.getAllUsers(); + const merchantUsers: User[] = []; + + // Filtrer pour ne garder que les utilisateurs marchands + for (const user of allUsers) { + if (!user.id) continue; + + try { + const userRoles = await this.keycloakApi.getUserClientRoles(user.id); + + // Vérifier si l'utilisateur a un rôle marchand + const hasMerchantRole = userRoles.some(role => + [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT] + .includes(role.name as UserRole) + ); + + if (hasMerchantRole) { + const mappedUser = this.mapToUser(user, userRoles); + merchantUsers.push(mappedUser); + } + } catch (error) { + this.logger.warn(`Could not process user ${user.id} for hub admin view: ${error.message}`); + continue; + } + } + + this.logger.log(`Hub Admin/Support retrieved ${merchantUsers.length} merchant users from all merchants`); + return merchantUsers; + } + + /** + * Récupère les utilisateurs marchands pour les utilisateurs réguliers (non Hub Admin/Support) + */ + private async getUsersForMerchants(userId: string): Promise { + // Récupérer le merchantPartnerId de l'utilisateur + let userMerchantId = await this.getUserMerchantPartnerId(userId); + + // Vérifier si l'utilisateur est un admin ou support Hub + const isUserMerchantPartner = await this.isUserMerchantPartner(userId); + + if(isUserMerchantPartner){ + userMerchantId = userId; + } + + if (!userMerchantId) { + throw new BadRequestException('Current user is not associated with a merchant partner'); + } + + this.logger.log(`User ${userId} accessing merchant users for partner ${userMerchantId}`); + + // Utiliser la méthode existante pour récupérer les utilisateurs du merchant spécifique + const users = await this.getMerchantUsersByPartner(userMerchantId, userId); + + this.logger.log(`User ${userId} retrieved ${users.length} merchant users for partner ${userMerchantId}`); + return users; + + } + + async getMerchantUsersByPartner(merchantPartnerId: string, requesterId: string): Promise { + await this.keycloakApi.validateUserAccess(requesterId, merchantPartnerId); + const allUsers = await this.keycloakApi.getAllUsers(); + + const merchantUsers = allUsers.filter(user => + user.attributes?.merchantPartnerId?.[0] === merchantPartnerId + ); + + return this.processUsersByType(merchantUsers, UserType.MERCHANT_PARTNER); + } + + async getMerchantUserById(userId: string, requesterId: string): Promise { + return this.getValidatedUser(userId, requesterId, UserType.MERCHANT_PARTNER); + } + + async createMerchantUser(creatorId: string, userData: CreateUserData): Promise { + this.validateUserCreationData(userData, UserType.MERCHANT_PARTNER); + await this.validateMerchantUserCreation(creatorId, userData); + await this.validateUserUniqueness(userData.username, userData.email); + + const keycloakUserData = this.buildKeycloakUserData(userData, userData.merchantPartnerId!); + const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData); + + this.logger.log(`Merchant user created: ${userData.username}`); + return this.getMerchantUserById(userId, creatorId); + } + + async updateMerchantUser( + userId: string, + updates: Partial>, + requesterId: string + ): Promise { + await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => { + await this.keycloakApi.updateUser(userId, updates, requesterId); + }); + return this.getMerchantUserById(userId, requesterId); + } + + async updateMerchantUserRole(userId: string, newRole: UserRole, requesterId: string): Promise { + await this.validateRoleChangePermission(requesterId); + await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => { + await this.keycloakApi.setClientRoles(userId, [newRole]); + }); + return this.getMerchantUserById(userId, requesterId); + } + + async deleteMerchantUser(userId: string, requesterId: string): Promise { + await this.validateSelfDeletion(userId, requesterId); + await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => { + await this.keycloakApi.deleteUser(userId, requesterId); + }); + this.logger.log(`Merchant user deleted: ${userId} by ${requesterId}`); + } + + // === COMMON OPERATIONS === + + async resetUserPassword( + userId: string, + newPassword: string, + temporary: boolean = true, + requesterId: string + ): Promise { + await this.ensureUserExists(userId, requesterId); + await this.keycloakApi.resetUserPassword(userId, newPassword, temporary); + this.logger.log(`Password reset for user: ${userId}`); + } + + async getUserMerchantPartnerId(userId: string): Promise { + return this.keycloakApi.getUserMerchantPartnerId(userId); + } + + // === PRIVATE CORE METHODS === + + private async getValidatedUser( + userId: string, + requesterId: string, + userType: UserType.HUB | UserType.MERCHANT_PARTNER + ): Promise { + const [user, userRoles] = await Promise.all([ + this.keycloakApi.getUserById(userId, requesterId), + this.keycloakApi.getUserClientRoles(userId) + ]); + + this.validateUserType(userRoles, userType, userId); + return this.mapToUser(user, userRoles); + } + + private async processUsersByType(users: KeycloakUser[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): Promise { + const result: User[] = []; + + for (const user of users) { + if (!user.id) continue; + + try { + const userRoles = await this.keycloakApi.getUserClientRoles(user.id); + if (this.isUserType(userRoles, userType)) { + result.push(this.mapToUser(user, userRoles)); + } + } catch (error) { + this.logger.warn(`Could not process user ${user.id}: ${error.message}`); + } + } + + return result; + } + + private async executeWithValidation( + userId: string, + requesterId: string, + userType: UserType.HUB | UserType.MERCHANT_PARTNER, + operation: () => Promise + ): Promise { + await this.getValidatedUser(userId, requesterId, userType); + await operation(); + } + + // === USER MAPPING AND VALIDATION === + + private mapToUser(user: KeycloakUser, roles: KeycloakRole[]): User { + if (!user.id || !user.email) { + throw new Error('User ID and email are required'); + } + + const role = this.determineUserRole(roles); + const userType = this.determineUserType(roles); + + return { + id: user.id, + username: user.username, + email: user.email, + firstName: user.firstName || '', + lastName: user.lastName || '', + role, + enabled: user.enabled, + emailVerified: user.emailVerified, + merchantPartnerId: user.attributes?.merchantPartnerId?.[0], + createdBy: user.attributes?.createdBy?.[0] || 'unknown', + createdByUsername: user.attributes?.createdByUsername?.[0] || 'unknown', + createdTimestamp: user.createdTimestamp || Date.now(), + lastLogin: this.parseTimestamp(user.attributes?.lastLogin), + userType, + }; + } + + private determineUserRole(roles: KeycloakRole[]): UserRole { + const allRoles = [...SECURITY_CONFIG.ROLES.HUB, ...SECURITY_CONFIG.ROLES.MERCHANT]; + const userRole = roles.find(role => allRoles.includes(role.name as UserRole)); + + if (!userRole) { + throw new Error('No valid role found for user'); + } + + return userRole.name as UserRole; + } + + private determineUserType(roles: KeycloakRole[]): UserType { + return roles.some(role => SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole)) + ? UserType.HUB + : UserType.MERCHANT_PARTNER; + } + + private isUserType(roles: KeycloakRole[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): boolean { + const targetRoles = userType === UserType.HUB + ? SECURITY_CONFIG.ROLES.HUB + : SECURITY_CONFIG.ROLES.MERCHANT; + + return roles.some(role => targetRoles.includes(role.name as UserRole)); + } + + private validateUserType(roles: KeycloakRole[], expectedType: UserType.HUB | UserType.MERCHANT_PARTNER, userId: string): void { + if (!this.isUserType(roles, expectedType)) { + throw new BadRequestException(`User ${userId} is not a ${expectedType.toLowerCase()} user`); + } + } + + // === VALIDATION METHODS === + + private async validateHubUserAccess(requesterId: string): Promise { + const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); + const hasHubAccess = requesterRoles.some(role => + SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole) + ); + + if (!hasHubAccess) { + throw new ForbiddenException('Only hub administrators can manage hub users'); + } + } + + + private validateUserCreationData(userData: CreateUserData, userType: UserType.HUB | UserType.MERCHANT_PARTNER): void { + // 🔍 DEBUG COMPLET + this.logger.debug('🔍 === VALIDATION USER CREATION DATA ==='); + this.logger.debug('UserType:', userType); + this.logger.debug('UserData complet:', JSON.stringify(userData, null, 2)); + this.logger.debug('userData.role:', userData.role); + this.logger.debug('Type de role:', typeof userData.role); + this.logger.debug('Est un tableau?:', Array.isArray(userData.role)); + this.logger.debug('Valeur brute role:', userData.role); + + // Afficher les rôles valides configurés + const validRoles = userType === UserType.HUB + ? SECURITY_CONFIG.ROLES.HUB + : SECURITY_CONFIG.ROLES.MERCHANT; + + this.logger.debug('Rôles valides pour', userType, ':', validRoles); + this.logger.debug('merchantPartnerId:', userData.merchantPartnerId); + this.logger.debug('===================================='); + + // Validation des rôles + if (!validRoles.includes(userData.role)) { + console.error(`❌ Rôle invalide: ${userData.role} pour le type ${userType}`); + console.error(`Rôles autorisés: ${validRoles.join(', ')}`); + throw new BadRequestException(`Invalid ${userType.toLowerCase()} role: ${userData.role}`); + } + + // Validation merchantPartnerId pour HUB + if (userType === UserType.HUB && userData.merchantPartnerId) { + console.error('❌ merchantPartnerId fourni pour un utilisateur HUB'); + throw new BadRequestException('merchantPartnerId should not be provided for hub users'); + } + + // Validation merchantPartnerId pour MERCHANT + // Vérifier d'abord si role est un tableau ou une valeur simple + const isDCBPartner = Array.isArray(userData.role) + ? userData.role.includes(UserRole.DCB_PARTNER) + : userData.role === UserRole.DCB_PARTNER; + + this.logger.debug('Est DCB_PARTNER?:', isDCBPartner); + + if (userType === UserType.MERCHANT_PARTNER && !userData.merchantPartnerId && !isDCBPartner) { + console.error('❌ merchantPartnerId manquant pour un utilisateur MERCHANT'); + throw new BadRequestException('merchantPartnerId is required for merchant users'); + } + + this.logger.debug('✅ Validation réussie'); + } + + private async validateUserUniqueness(username: string, email: string): Promise { + const [existingUsers, existingEmails] = await Promise.all([ + this.keycloakApi.findUserByUsername(username), + this.keycloakApi.findUserByEmail(email) + ]); + + if (existingUsers.length > 0) { + throw new BadRequestException(`User with username ${username} already exists`); + } + if (existingEmails.length > 0) { + throw new BadRequestException(`User with email ${email} already exists`); + } + } + + private async validateRoleChangePermission(requesterId: string): Promise { + const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId); + const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN || UserRole.DCB_PARTNER || UserRole.DCB_PARTNER_ADMIN); + + if (!isRequesterAdmin) { + throw new ForbiddenException('Only DCB_ADMIN can change user roles'); + } + } + + private async validateSelfDeletion(userId: string, requesterId: string): Promise { + if (userId === requesterId) { + throw new BadRequestException('Cannot delete your own account'); + } + } + + private async ensureUserExists(userId: string, requesterId: string): Promise { + try { + await this.getHubUserById(userId, requesterId); + } catch { + try { + await this.getMerchantUserById(userId, requesterId); + } catch { + throw new NotFoundException(`User ${userId} not found`); + } + } + } + + private buildUserProfile( + userId: string, + tokenUser: any, + userDetails: KeycloakUser, + userRoles: KeycloakRole[] + ) { + return { + id: userId, + username: tokenUser.preferred_username, + email: tokenUser.email, + firstName: tokenUser.given_name, + lastName: tokenUser.family_name, + emailVerified: tokenUser.email_verified, + enabled: userDetails.enabled, + clientRoles: userRoles.map(role => role.name), + merchantPartnerId: userDetails.attributes?.merchantPartnerId?.[0], + createdBy: userDetails.attributes?.createdBy?.[0], + createdByUsername: userDetails.attributes?.createdByUsername?.[0] + }; + } + + private buildKeycloakUserData( + userData: CreateUserData, + merchantPartnerId?: string + ): KeycloakCreateUserData { + return { + username: userData.username, + email: userData.email, + firstName: userData.firstName, + lastName: userData.lastName, + password: userData.password, + enabled: userData.enabled ?? true, + emailVerified: userData.emailVerified ?? false, + merchantPartnerId, + clientRoles: [userData.role] + }; + } + + private parseTimestamp(value: string[] | undefined): number | undefined { + const strValue = value?.[0]; + if (!strValue) return undefined; + + const timestamp = parseInt(strValue); + if (!isNaN(timestamp)) return timestamp; + + const date = new Date(strValue); + return isNaN(date.getTime()) ? undefined : date.getTime(); + } + + private async validateMerchantUserCreation(creatorId: string, userData: CreateUserData): Promise { + const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId); + const creationRules = this.getMerchantCreationRules(); + + for (const rule of creationRules) { + if (creatorRoles.some(role => role.name === rule.role)) { + await rule.validator(creatorId, userData); + return; + } + } + + // Vérifier les permissions des administrateurs Hub + 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 getMerchantCreationRules() { + return [ + { + role: UserRole.DCB_PARTNER, + validator: async (creatorId: string, userData: CreateUserData) => { + if (creatorId !== userData.merchantPartnerId) { + throw new ForbiddenException('DCB_PARTNER can only create users for their own merchant'); + } + const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]; + if (!allowedRoles.includes(userData.role)) { + throw new ForbiddenException('DCB_PARTNER can only create MANAGER and SUPPORT roles'); + } + } + }, + { + role: UserRole.DCB_PARTNER_ADMIN, + validator: async (creatorId: string, userData: CreateUserData) => { + const creatorMerchantId = await this.keycloakApi.getUserMerchantPartnerId(creatorId); + if (creatorMerchantId !== userData.merchantPartnerId) { + throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner'); + } + const allowedRoles = [UserRole.DCB_PARTNER_SUPPORT]; + if (!allowedRoles.includes(userData.role)) { + throw new ForbiddenException('DCB_PARTNER_ADMIN can only create SUPPORT roles'); + } + } + } + ]; + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 7e29da0..d9b60d1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,39 +1,86 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe, Logger } from '@nestjs/common'; +import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common'; import helmet from 'helmet'; import { KeycloakExceptionFilter } from './filters/keycloak-exception.filter'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { useContainer } from 'class-validator'; async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = new Logger('dcb-user-service'); + useContainer(app.select(AppModule), { fallbackOnErrors: true }); + + // Middlewares de sécurité app.use(helmet()); - app.enableCors(); + app.enableCors({ + origin: process.env.ALLOWED_ORIGINS?.split(',') || '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], + allowedHeaders: ['Content-Type', 'Authorization'], + }); + + // Gestion globale des erreurs et validation app.useGlobalFilters(new KeycloakExceptionFilter()); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + + // ValidationPipe CORRIGÉ + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + exceptionFactory: (errors) => { + const messages = errors.map((error) => { + // Détails complets de l'erreur + const constraints = error.constraints + ? Object.values(error.constraints) + : ['Unknown validation error']; + return { + field: error.property, + errors: constraints, + value: error.value, + children: error.children, + }; + }); + + console.log('🔴 VALIDATION ERRORS:', JSON.stringify(messages, null, 2)); + + return new BadRequestException({ + message: 'Validation failed', + errors: messages, + details: + 'Check the errors array for specific field validation issues', + }); + }, + }), + ); + + // Préfixe global de l'API 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') + .addTag('users', 'Gestion des Utilisateurs') + .addTag('partners', 'Gestion des Partenaires/Marchants') .addBearerAuth() - //.addServer('http://localhost:3000', 'Développement local') - // .addServer('https://api.example.com', 'Production') .build(); + const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api-docs', app, document); - app.enableCors({ origin: '*' }); app.getHttpAdapter().get('/api/swagger-json', (req, res) => { res.json(document); }); + // Démarrage du serveur const port = process.env.PORT || 3000; await app.listen(port); 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 {} - - -