From fd43877036a52dcf4de97f7e35403494cb9045a8 Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Wed, 29 Oct 2025 05:21:13 +0000 Subject: [PATCH] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- src/app.module.ts | 5 +- src/auth/auth.module.ts | 15 +- src/auth/guards/merchant-owner.guard.ts | 33 ++ src/auth/guards/merchant.guard.ts | 29 ++ src/auth/services/keycloak-api.service.ts | 412 ++++++++++++++++-- src/auth/services/keycloak.strategy.ts | 56 --- src/auth/services/startup.service.ts | 178 +------- src/auth/services/token.service copy.ts | 268 ------------ src/constants/resouces.ts | 3 +- src/health/health.controller.ts | 32 -- src/health/health.module.ts | 19 - src/users/controllers/merchants.controller.ts | 396 +++++++++++++++++ src/users/controllers/users.controller.ts | 151 ++++++- src/users/models/merchant.ts | 201 +++++++++ src/users/models/roles.enum.ts | 14 - src/users/models/user.ts | 14 +- src/users/services/merchant-team.service.ts | 352 +++++++++++++++ src/users/services/users.service.ts | 60 ++- src/users/users.module.ts | 6 +- 19 files changed, 1619 insertions(+), 625 deletions(-) create mode 100644 src/auth/guards/merchant-owner.guard.ts create mode 100644 src/auth/guards/merchant.guard.ts delete mode 100644 src/auth/services/keycloak.strategy.ts delete mode 100644 src/auth/services/token.service copy.ts delete mode 100644 src/health/health.controller.ts delete mode 100644 src/health/health.module.ts create mode 100644 src/users/controllers/merchants.controller.ts create mode 100644 src/users/models/merchant.ts delete mode 100644 src/users/models/roles.enum.ts create mode 100644 src/users/services/merchant-team.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 93da586..9af1f09 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,8 +15,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 { HealthModule } from './health/health.module'; import { UsersModule } from './users/users.module'; +import { StartupService } from './auth/services/startup.service'; @Module({ imports: [ @@ -70,10 +70,11 @@ import { UsersModule } from './users/users.module'; // Feature Modules AuthModule, ApiModule, - HealthModule, UsersModule, + ], providers: [ + StartupService, // Global Authentication Guard { provide: APP_GUARD, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 1d6fc3c..a047725 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,13 +1,11 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { JwtModule } from '@nestjs/jwt'; -import { StartupService } from './services/startup.service'; import { TokenService } from './services/token.service'; import { KeycloakApiService } from './services/keycloak-api.service'; import { AuthController } from './controllers/auth.controller'; -import { HealthController } from '../health/health.controller'; import { UsersService } from '../users/services/users.service'; -import { KeycloakJwtStrategy } from './services/keycloak.strategy'; +import { MerchantTeamService } from 'src/users/services/merchant-team.service'; import { JwtAuthGuard } from './guards/jwt.guard'; @@ -17,15 +15,14 @@ import { JwtAuthGuard } from './guards/jwt.guard'; HttpModule, JwtModule.register({}), ], - providers: [ - KeycloakJwtStrategy, + providers: [ JwtAuthGuard, - StartupService, TokenService, KeycloakApiService, - UsersService + UsersService, + MerchantTeamService ], - controllers: [AuthController, HealthController], - exports: [JwtAuthGuard, StartupService, TokenService, KeycloakApiService, UsersService, JwtModule], + controllers: [AuthController], + exports: [JwtAuthGuard, TokenService, KeycloakApiService, UsersService, MerchantTeamService, JwtModule], }) export class AuthModule {} diff --git a/src/auth/guards/merchant-owner.guard.ts b/src/auth/guards/merchant-owner.guard.ts new file mode 100644 index 0000000..2b8c93c --- /dev/null +++ b/src/auth/guards/merchant-owner.guard.ts @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..458a8db --- /dev/null +++ b/src/auth/guards/merchant.guard.ts @@ -0,0 +1,29 @@ +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 405de54..446bdde 100644 --- a/src/auth/services/keycloak-api.service.ts +++ b/src/auth/services/keycloak-api.service.ts @@ -3,7 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { HttpService } from '@nestjs/axios'; import { AxiosResponse } from 'axios'; import { firstValueFrom, Observable, timeout as rxjsTimeout } from 'rxjs'; -import { TokenService } from './token.service'; // Import du TokenService +import { TokenService } from './token.service'; export interface KeycloakUser { id?: string; @@ -13,7 +13,8 @@ export interface KeycloakUser { lastName?: string; enabled: boolean; emailVerified: boolean; - attributes?: Record; + attributes?: Record; + createdTimestamp?: number; } export interface KeycloakRole { @@ -22,7 +23,7 @@ export interface KeycloakRole { description?: string; } -export type ClientRole = 'admin' | 'merchant' | 'support'; +export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support' | 'merchant-user'; @Injectable() export class KeycloakApiService { @@ -34,7 +35,7 @@ export class KeycloakApiService { constructor( private readonly httpService: HttpService, private readonly configService: ConfigService, - private readonly tokenService: TokenService, // Injection du TokenService + private readonly tokenService: TokenService, ) { this.keycloakBaseUrl = this.configService.get('KEYCLOAK_SERVER_URL') || 'http://localhost:8080'; this.realm = this.configService.get('KEYCLOAK_REALM') || 'master'; @@ -46,7 +47,7 @@ export class KeycloakApiService { return this.tokenService.acquireUserToken(username, password); } - // ===== CORE REQUEST METHOD (pour opérations admin) ===== + // ===== CORE REQUEST METHOD ===== private async request( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, @@ -100,6 +101,7 @@ export class KeycloakApiService { this.logger.error(`Keycloak API error in ${context}: ${error.message}`, { status: error.response?.status, + data: error.response?.data, }); throw new HttpException( @@ -111,50 +113,105 @@ export class KeycloakApiService { // ===== USER CRUD OPERATIONS ===== async createUser(userData: { username: string; - email: string; - firstName: string; - lastName: string; - password: string; + email?: string; + firstName?: string; + lastName?: string; + password?: string; enabled?: boolean; + emailVerified?: boolean; + attributes?: Record; + credentials?: any[]; }): Promise { - const userPayload = { + + this.logger.debug(`🔍 CREATE USER - Input data:`, { + username: userData.username, + hasPassword: !!userData.password, + hasCredentials: !!userData.credentials, + credentialsLength: userData.credentials ? userData.credentials.length : 0 + }); + + const userPayload: any = { username: userData.username, email: userData.email, firstName: userData.firstName, lastName: userData.lastName, enabled: userData.enabled ?? true, - emailVerified: true, - credentials: [{ + emailVerified: userData.emailVerified ?? true, + }; + + if (userData.password) { + // Format direct : password field + userPayload.credentials = [{ type: 'password', value: userData.password, temporary: false, - }], - }; - - await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload); - - const users = await this.findUserByUsername(userData.username); - if (users.length === 0) { - throw new Error('Failed to create user'); + }]; + } 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 users[0].id!; } - + async getUserById(userId: string): Promise { - return this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); + return this.request('GET', `/admin/realms/${this.realm}/users/${userId}`); } async getAllUsers(): Promise { - return this.request('GET', `/admin/realms/${this.realm}/users`); + 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)}`); + 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)}`); + return this.request( + 'GET', + `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}` + ); } async updateUser(userId: string, userData: Partial): Promise { @@ -165,35 +222,196 @@ export class KeycloakApiService { 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 { - const clients = await this.getClient(); - return this.request('GET', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`); + 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 { - const clients = await this.getClient(); - const targetRole = await this.getRole(role, clients[0].id); - - return this.request('POST', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, [targetRole]); + 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 { - const clients = await this.getClient(); - const targetRole = await this.getRole(role, clients[0].id); - - return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, [targetRole]); + 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 { - const currentRoles = await this.getUserClientRoles(userId); - if (currentRoles.length > 0) { + try { const clients = await this.getClient(); - await this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`, currentRoles); - } + 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 + ); + } - for (const role of roles) { - await this.assignClientRole(userId, role); + // 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; } } @@ -209,10 +427,12 @@ export class KeycloakApiService { 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 { @@ -222,14 +442,69 @@ export class KeycloakApiService { temporary: false, }; - return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, credentials); + 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 []; + } + } + + // ===== 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; + } + } + + 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; + } } // ===== PRIVATE HELPERS ===== private async getClient(): Promise { const clients = await this.request('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`); if (!clients || clients.length === 0) { - throw new Error('Client not found'); + throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`); } return clients; } @@ -239,8 +514,53 @@ export class KeycloakApiService { const targetRole = roles.find(r => r.name === role); if (!targetRole) { - throw new BadRequestException(`Role '${role}' not found`); + throw new BadRequestException(`Role '${role}' not found in client '${this.clientId}'`); } return targetRole; } + + // ===== MÉTHODES POUR LE STARTUP SERVICE (compatibilité) ===== + async checkHealth(username: string, password: string): Promise<{ status: string }> { + try { + const isAvailable = await this.checkKeycloakAvailability(); + const isConnected = await this.checkServiceConnection(); + + return { + status: isAvailable && isConnected ? 'healthy' : 'unhealthy' + }; + } catch (error) { + return { status: 'unhealthy' }; + } + } + + async getRealmClients(realm: string, username: string, password: string): Promise { + return this.request('GET', `/admin/realms/${realm}/clients`); + } + + async getRealmInfo(realm: string, username: string, password: string): Promise { + return this.request('GET', `/admin/realms/${realm}`); + } + + async getUsers(realm: string, username: string, password: string, options?: any): Promise { + let url = `/admin/realms/${realm}/users`; + if (options?.max) { + url += `?max=${options.max}`; + } + if (options?.username) { + url += `${url.includes('?') ? '&' : '?'}username=${encodeURIComponent(options.username)}`; + } + return this.request(url.includes('?') ? 'GET' : 'GET', url); + } + + async getUserProfile(realm: string, token: string): Promise { + const config = { + headers: { Authorization: `Bearer ${token}` }, + }; + + const url = `${this.keycloakBaseUrl}/realms/${realm}/protocol/openid-connect/userinfo`; + const response = await firstValueFrom(this.httpService.get(url, config)); + return response.data; + } + + // ... autres méthodes de compatibilité pour le StartupService } \ No newline at end of file diff --git a/src/auth/services/keycloak.strategy.ts b/src/auth/services/keycloak.strategy.ts deleted file mode 100644 index 9c386de..0000000 --- a/src/auth/services/keycloak.strategy.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy } from 'passport-jwt'; -import * as jwksRsa from 'jwks-rsa'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class KeycloakJwtStrategy extends PassportStrategy(Strategy, 'jwt') { - private readonly logger = new Logger(KeycloakJwtStrategy.name); - - constructor(private configService: ConfigService) { - const jwksUri = configService.get('KEYCLOAK_JWKS_URI'); - const issuer = configService.get('KEYCLOAK_ISSUER'); - - if (!jwksUri || !issuer) { - throw new Error('Missing Keycloak configuration (KEYCLOAK_JWKS_URI / KEYCLOAK_ISSUER)'); - } - - super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKeyProvider: jwksRsa.passportJwtSecret({ - cache: true, - rateLimit: true, - jwksRequestsPerMinute: 5, - jwksUri, - }), - issuer, - algorithms: ['RS256'], - }); - } - - async validate(payload: any) { - // Récupération des rôles du realm - const realmRoles: string[] = payload.realm_access?.roles || []; - - // Récupération des rôles du client dans resource_access - const clientId = payload.client_id; - const resourceRoles: string[] = - payload.resource_access?.[clientId]?.roles || []; - - // Fusion et suppression des doublons - const roles = Array.from(new Set([...realmRoles, ...resourceRoles])); - - this.logger.verbose(`User ${payload.preferred_username} roles: ${JSON.stringify(roles)}`); - - return { - sub: payload.sub, - preferred_username: payload.preferred_username, - email: payload.email, - client_id: clientId, - roles, - realmRoles, - resourceRoles, - }; - } -} diff --git a/src/auth/services/startup.service.ts b/src/auth/services/startup.service.ts index 5174521..a898b2d 100644 --- a/src/auth/services/startup.service.ts +++ b/src/auth/services/startup.service.ts @@ -1,184 +1,42 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { TokenService } from './token.service'; -import { ConfigService } from '@nestjs/config'; import { KeycloakApiService } from './keycloak-api.service'; @Injectable() export class StartupService implements OnModuleInit { private readonly logger = new Logger(StartupService.name); - private isInitialized = false; - private initializationError: string | null = null; - private userToken: string | null = null; + private initialized = false; + private error: string | null = null; - constructor( - private readonly tokenService: TokenService, - private readonly keycloakApiService: KeycloakApiService, - private readonly configService: ConfigService, - ) {} + constructor(private readonly keycloakApiService: KeycloakApiService) {} async onModuleInit() { - this.logger.log('Starting Keycloak connection tests...'); - - const username = this.configService.get('KEYCLOAK_TEST_USER_ADMIN'); - const password = this.configService.get('KEYCLOAK_TEST_PASSWORD_ADMIN'); - - if (!username || !password) { - this.initializationError = 'Test username/password not configured'; - this.logger.error(this.initializationError); - return; - } + this.logger.log('Vérification de la disponibilité de Keycloak...'); try { - // 1. Test d'authentification utilisateur - const tokenResponse = await this.keycloakApiService.authenticateUser(username, password); - this.userToken = tokenResponse.access_token; - this.logger.log('✅ User authentication test passed'); + const available = await this.keycloakApiService.checkKeycloakAvailability(); + if (!available) throw new Error('Keycloak non accessible'); - // 2. Test des opérations CRUD admin - //await this.testAdminOperations(); + const serviceConnected = await this.keycloakApiService.checkServiceConnection(); + if (!serviceConnected) throw new Error('Échec de la connexion du service à Keycloak'); - // 3. Test des opérations avec le nouveau mot de passe - //await this.testUserOperationsWithNewPassword(); - - this.isInitialized = true; - this.logger.log('✅ All CRUD operations tested successfully'); - - } catch (error: any) { - this.initializationError = error.message || error; - this.logger.error('❌ Keycloak connection or method test failed', error); - } - } - - private async testAdminOperations(): Promise { - this.logger.log('Testing admin CRUD operations...'); - - let testUserId = ''; - const usernameTest = 'startup-test-user'; - - // Vérifier si l'utilisateur existe - let testUser = await this.keycloakApiService.findUserByUsername(usernameTest); - if (!testUser || testUser.length === 0) { - // Créer l'utilisateur si inexistant - const newUser = { - username: usernameTest, - email: 'startup-test@example.com', - firstName: 'Startup', - lastName: 'Test', - password: 'TempPassword123!', - enabled: true, - }; - testUserId = await this.keycloakApiService.createUser(newUser); - this.logger.log(`✅ Test user "${usernameTest}" created with ID: ${testUserId}`); - } else { - testUserId = testUser[0].id!; - this.logger.log(`✅ Using existing test user ID: ${testUserId}`); - } - - if (!testUserId) { - throw new Error('Unable to create or fetch test user'); - } - - // Récupérer l'utilisateur par ID - const userById = await this.keycloakApiService.getUserById(testUserId); - this.logger.log(`✅ User fetched by ID: ${userById.username}`); - - // Modifier l'utilisateur - await this.keycloakApiService.updateUser(testUserId, { firstName: 'UpdatedStartup' }); - this.logger.log(`✅ User updated: ${testUserId}`); - - // Opérations sur les rôles - const userRoles = await this.keycloakApiService.getUserClientRoles(testUserId); - this.logger.log(`✅ User client roles: ${userRoles.length}`); - - // Assigner un rôle client - await this.keycloakApiService.assignClientRole(testUserId, 'merchant'); - this.logger.log(`✅ Assigned merchant role to user: ${testUserId}`); - - // Vérifier les rôles après assignation - const rolesAfterAssign = await this.keycloakApiService.getUserClientRoles(testUserId); - this.logger.log(`✅ User roles after assignment: ${rolesAfterAssign.map(r => r.name).join(', ')}`); - - // Retirer le rôle - await this.keycloakApiService.removeClientRole(testUserId, 'merchant'); - this.logger.log(`✅ Removed merchant role from user: ${testUserId}`); - - // Tester l'assignation multiple de rôles - await this.keycloakApiService.setClientRoles(testUserId, ['admin', 'support']); - this.logger.log(`✅ Set multiple roles for user: admin, support`); - - // Vérifier les rôles après assignation multiple - const finalRoles = await this.keycloakApiService.getUserClientRoles(testUserId); - this.logger.log(`✅ Final user roles: ${finalRoles.map(r => r.name).join(', ')}`); - - // Réinitialisation du mot de passe - const newPassword = 'NewStartupPass123!'; - await this.keycloakApiService.resetPassword(testUserId, newPassword); - this.logger.log(`✅ Password reset for user: ${testUserId}`); - - // Tester enable/disable user - await this.keycloakApiService.disableUser(testUserId); - this.logger.log(`✅ User disabled: ${testUserId}`); - - // Vérifier que l'utilisateur est désactivé - const disabledUser = await this.keycloakApiService.getUserById(testUserId); - this.logger.log(`✅ User enabled status: ${disabledUser.enabled}`); - - // Réactiver l'utilisateur - await this.keycloakApiService.enableUser(testUserId); - this.logger.log(`✅ User enabled: ${testUserId}`); - - // Tester la recherche d'utilisateurs - const userByEmail = await this.keycloakApiService.findUserByEmail('startup-test@example.com'); - this.logger.log(`✅ User found by email: ${userByEmail ? userByEmail[0]?.username : 'none'}`); - - // Vérifier si l'utilisateur existe - const userExists = await this.keycloakApiService.userExists(usernameTest); - this.logger.log(`✅ User exists check: ${userExists}`); - - // Récupérer tous les utilisateurs - const allUsers = await this.keycloakApiService.getAllUsers(); - this.logger.log(`✅ All users count: ${allUsers.length}`); - - // Cleanup optionnel - Supprimer l'utilisateur de test - // await this.keycloakApiService.deleteUser(testUserId); - // this.logger.log(`✅ Test user deleted: ${testUserId}`); - } - - private async testUserOperationsWithNewPassword(): Promise { - this.logger.log('Testing user operations with new password...'); - - const usernameTest = 'startup-test-user'; - const newPassword = 'NewStartupPass123!'; - - try { - // Tester la reconnexion avec le nouveau mot de passe - const newTokenResponse = await this.keycloakApiService.authenticateUser(usernameTest, newPassword); - this.logger.log(`✅ Test user reconnected successfully with new password`); - this.logger.log(`✅ New token acquired for test user`); - } catch (error) { - this.logger.warn(`⚠️ Test user reconnection failed: ${error.message}`); + 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); } } getStatus() { return { - status: this.isInitialized ? 'healthy' : 'unhealthy', - keycloak: { - connected: this.isInitialized, - realm: this.configService.get('KEYCLOAK_REALM'), - serverUrl: this.configService.get('KEYCLOAK_SERVER_URL'), - }, + status: this.initialized ? 'healthy' : 'unhealthy', + keycloakConnected: this.initialized, timestamp: new Date(), - error: this.initializationError, - userToken: this.userToken ? 'available' : 'none', + error: this.error, }; } isHealthy(): boolean { - return this.isInitialized; + return this.initialized; } - - getUserToken(): string | null { - return this.userToken; - } -} \ No newline at end of file +} diff --git a/src/auth/services/token.service copy.ts b/src/auth/services/token.service copy.ts deleted file mode 100644 index ae539ca..0000000 --- a/src/auth/services/token.service copy.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { HttpService } from '@nestjs/axios'; -import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs'; - -export interface KeycloakTokenResponse { - access_token: string; - expires_in: number; - refresh_token?: string; - token_type: string; - scope?: string; -} - -@Injectable() -export class TokenService implements OnModuleInit { - private readonly logger = new Logger(TokenService.name); - - private currentToken: string | null = null; - private tokenExpiry: Date | null = null; - private acquiringTokenPromise: Promise | null = null; - - // Mapping access_token → refresh_token pour gérer les logouts utilisateurs - private userRefreshTokens = new Map(); - - constructor( - private readonly configService: ConfigService, - private readonly httpService: HttpService, - ) {} - - async onModuleInit() { - try { - await this.acquireClientToken(); - } catch { - this.logger.warn('Initial Keycloak client token acquisition failed (will retry on demand).'); - } - } - - private getTokenUrl(): string { - const serverUrl = this.configService.get('keycloak.serverUrl'); - const realm = this.configService.get('keycloak.realm'); - - if (!serverUrl || !realm) { - throw new Error('Keycloak serverUrl or realm not configured'); - } - - return `${serverUrl}/realms/${realm}/protocol/openid-connect/token`; - } - - getAccessTokenFromHeader(request: Request): string | null { - const authHeader = request.headers['authorization']; - if (!authHeader) return null; - const [type, token] = authHeader.split(' '); - return type === 'Bearer' ? token : null; - } - - private getLogoutUrl(): string { - const serverUrl = this.configService.get('keycloak.serverUrl'); - const realm = this.configService.get('keycloak.realm'); - return `${serverUrl}/realms/${realm}/protocol/openid-connect/logout`; - } - - private validateClientConfig(): { clientId: string; clientSecret: string } { - const clientId = this.configService.get('keycloak.clientId'); - const clientSecret = this.configService.get('keycloak.clientSecret'); - - if (!clientId) throw new Error('KEYCLOAK_CLIENT_ID is not configured'); - if (!clientSecret) throw new Error('KEYCLOAK_CLIENT_SECRET is not configured'); - - return { clientId, clientSecret }; - } - - private validateUserClientConfig(): { userClientId: string; userClientSecret: string } { - const userClientId = this.configService.get('keycloak.userClientId'); - const userClientSecret = this.configService.get('keycloak.userClientSecret'); - - if (!userClientId) throw new Error('KEYCLOAK_CLIENT_ID is not configured'); - if (!userClientSecret) throw new Error('KEYCLOAK_CLIENT_SECRET is not configured'); - - return { userClientId, userClientSecret }; - } - - /** ------------------------------- - * CLIENT CREDENTIALS TOKEN - * ------------------------------- */ - async acquireClientToken(): Promise { - if (this.acquiringTokenPromise) return this.acquiringTokenPromise; - - this.acquiringTokenPromise = (async () => { - try { - const tokenUrl = this.getTokenUrl(); - const { clientId, clientSecret } = this.validateClientConfig(); - - const params = new URLSearchParams(); - params.append('grant_type', 'client_credentials'); - params.append('client_id', clientId); - params.append('client_secret', clientSecret); - - const response = await firstValueFrom( - this.httpService - .post(tokenUrl, params.toString(), { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - .pipe(rxjsTimeout(10000)), - ); - - if (!response?.data?.access_token) throw new Error('No access_token in Keycloak response'); - - this.currentToken = response.data.access_token; - const expiresIn = response.data.expires_in ?? 60; - const buffer = this.configService.get('keycloak.tokenBufferSeconds') ?? 30; - - const expiry = new Date(); - expiry.setSeconds(expiry.getSeconds() + Math.max(0, expiresIn - buffer)); - this.tokenExpiry = expiry; - - this.logger.log(`Acquired Keycloak client token (expires in ${expiresIn}s)`); - return this.currentToken; - } finally { - this.acquiringTokenPromise = null; - } - })(); - - return this.acquiringTokenPromise; - } - - /** ------------------------------- - * USER PASSWORD TOKEN (ROPC) - * ------------------------------- */ - async acquireUserToken(username: string, password: string): Promise { - const tokenUrl = this.getTokenUrl(); - const { userClientId, userClientSecret } = this.validateUserClientConfig(); - - if (!username || !password) throw new Error('Username and password are required'); - - const params = new URLSearchParams(); - params.append('grant_type', 'password'); - params.append('client_id', userClientId); - params.append('client_secret', userClientSecret); - params.append('username', username); - params.append('password', password); - - try { - const response = await firstValueFrom( - this.httpService - .post(tokenUrl, params.toString(), { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - .pipe(rxjsTimeout(10000)), - ); - - if (!response?.data?.access_token) throw new Error('No access_token in Keycloak response'); - - // 🔹 Stocke le refresh_token associé - if (response.data.refresh_token) { - this.userRefreshTokens.set(response.data.access_token, response.data.refresh_token); - } - - this.logger.log(`User token acquired for "${username}"`); - return response.data; - } catch (err: any) { - this.logger.error(`Failed to acquire Keycloak user token: ${err?.message ?? err}`); - throw new Error('Invalid username or password'); - } - } - - /** ------------------------------- - * REFRESH TOKEN - * ------------------------------- */ - async refreshToken(refreshToken: string): Promise { - const tokenUrl = this.getTokenUrl(); - - const { userClientId, userClientSecret } = this.validateUserClientConfig(); - - if (!refreshToken) throw new Error('Refresh token is required'); - - const params = new URLSearchParams(); - params.append('grant_type', 'refresh_token'); - params.append('client_id', userClientId); - params.append('client_secret', userClientSecret); - params.append('refresh_token', refreshToken); - - try { - const response = await firstValueFrom( - this.httpService - .post(tokenUrl, params.toString(), { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - .pipe(rxjsTimeout(10000)) - ); - - if (!response?.data?.access_token) throw new Error('No access_token in Keycloak response'); - - // Met à jour le mapping - if (response.data.refresh_token) { - this.userRefreshTokens.set(response.data.access_token, response.data.refresh_token); - } - - this.logger.log('Token refreshed successfully'); - return response.data; - } catch (error: any) { - this.logger.error(`Failed to refresh token: ${error.message}`); - throw new Error('Failed to refresh token'); - } - } - - /** ------------------------------- - * REVOKE TOKEN (LOGOUT) - * ------------------------------- */ - async revokeToken(refreshToken: string): Promise { - const logoutUrl = this.getLogoutUrl(); - const clientId = this.configService.get('keycloak.userClientId'); - const clientSecret = this.configService.get('keycloak.userClientSecret'); - - if (!clientId || !clientSecret) throw new Error('ClientId or ClientSecret not configured'); - if (!refreshToken) throw new Error('Refresh token is required'); - - const params = new URLSearchParams(); - params.append('client_id', clientId); - params.append('client_secret', clientSecret); - params.append('refresh_token', refreshToken); - - try { - await firstValueFrom( - this.httpService - .post(logoutUrl, params.toString(), { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - }) - .pipe(rxjsTimeout(10000)), - ); - - this.logger.log(`Refresh token revoked successfully`); - } catch (error: any) { - this.logger.warn(`Failed to revoke refresh token: ${error.message}`); - } - } - - /** ------------------------------- - * HELPER METHODS - * ------------------------------- */ - async getToken(): Promise { - if (this.isTokenValid()) return this.currentToken!; - return this.acquireClientToken(); - } - - isTokenValid(): boolean { - return !!this.currentToken && !!this.tokenExpiry && new Date() < this.tokenExpiry; - } - - clearToken(): void { - this.currentToken = null; - this.tokenExpiry = null; - } - - async getStoredRefreshToken(accessToken: string): Promise { - return this.userRefreshTokens.get(accessToken) ?? null; - } - - getTokenInfo() { - if (!this.currentToken) return null; - return { - isValid: this.isTokenValid(), - expiresAt: this.tokenExpiry, - expiresIn: this.tokenExpiry - ? Math.floor((this.tokenExpiry.getTime() - new Date().getTime()) / 1000) - : 0, - }; - } -} diff --git a/src/constants/resouces.ts b/src/constants/resouces.ts index 28bc9c0..3cb3966 100644 --- a/src/constants/resouces.ts +++ b/src/constants/resouces.ts @@ -1,3 +1,4 @@ export const RESOURCES = { - USER: 'user',// user resource for /users/* endpoints + USER: 'user', // user resource for /users/* endpoints + MERCHANT: 'merchants' // merchant resource for /merchants/* endpoints }; diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts deleted file mode 100644 index 77464d5..0000000 --- a/src/health/health.controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { Public } from 'nest-keycloak-connect'; -import { StartupService } from '../auth/services/startup.service'; - -@Controller('health') -export class HealthController { - constructor(private readonly startupService: StartupService) {} - - @Public() - @Get() - getHealth() { - return this.startupService.getStatus(); - } - - @Public() - @Get('readiness') - getReadiness() { - return { - status: this.startupService.isHealthy() ? 'ready' : 'not-ready', - timestamp: new Date(), - }; - } - - @Public() - @Get('liveness') - getLiveness() { - return { - status: 'live', - timestamp: new Date(), - }; - } -} \ No newline at end of file diff --git a/src/health/health.module.ts b/src/health/health.module.ts deleted file mode 100644 index afed7f2..0000000 --- a/src/health/health.module.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TerminusModule } from '@nestjs/terminus'; -import { HealthController } from './health.controller'; -import { HttpModule } from '@nestjs/axios'; -import { StartupService } from '../auth/services/startup.service'; -import { TokenService } from '../auth/services/token.service'; -import { KeycloakApiService } from '../auth/services/keycloak-api.service'; - - -@Module({ - imports: [ - TerminusModule, - HttpModule, - ], - providers: [StartupService, TokenService, KeycloakApiService], - controllers: [HealthController], - exports: [StartupService, TokenService, KeycloakApiService], -}) -export class HealthModule {} diff --git a/src/users/controllers/merchants.controller.ts b/src/users/controllers/merchants.controller.ts new file mode 100644 index 0000000..4b5381c --- /dev/null +++ b/src/users/controllers/merchants.controller.ts @@ -0,0 +1,396 @@ +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 index cdd32a4..8b618c1 100644 --- a/src/users/controllers/users.controller.ts +++ b/src/users/controllers/users.controller.ts @@ -11,9 +11,11 @@ import { 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'; @@ -23,7 +25,10 @@ import { SCOPES } from '../../constants/scopes'; export class UsersController { private readonly logger = new Logger(UsersController.name); - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly merchantTeamService: MerchantTeamService, + ) {} // === CREATE USER === @Post() @@ -246,4 +251,148 @@ export class UsersController { 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/models/merchant.ts b/src/users/models/merchant.ts new file mode 100644 index 0000000..0808195 --- /dev/null +++ b/src/users/models/merchant.ts @@ -0,0 +1,201 @@ +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/roles.enum.ts b/src/users/models/roles.enum.ts deleted file mode 100644 index 7398b1e..0000000 --- a/src/users/models/roles.enum.ts +++ /dev/null @@ -1,14 +0,0 @@ -export enum ROLES { - // User Management Roles - CREATE_USER = 'create-user', - UPDATE_USER = 'update-user', - DELETE_USER = 'delete-user', - READ_USERS = 'read-users', -} - -export const ROLE_DESCRIPTIONS = { - [ROLES.CREATE_USER]: 'Can create new users in the system', - [ROLES.UPDATE_USER]: 'Can update existing user information', - [ROLES.DELETE_USER]: 'Can delete users from the system', - [ROLES.READ_USERS]: 'Can view the list of all users', -}; \ No newline at end of file diff --git a/src/users/models/user.ts b/src/users/models/user.ts index 292d8d6..d52fec8 100644 --- a/src/users/models/user.ts +++ b/src/users/models/user.ts @@ -4,8 +4,8 @@ export class User { id?: string; username: string; email: string; - firstName: string; - lastName: string; + firstName?: string; + lastName?: string; enabled?: boolean = true; emailVerified?: boolean = false; attributes?: Record; @@ -40,10 +40,10 @@ export class CreateUserDto { email: string; @IsString() - firstName: string; + firstName?: string; @IsString() - lastName: string; + lastName?: string; @IsString() @MinLength(8) @@ -135,8 +135,8 @@ export class UserResponse { id: string; username: string; email: string; - firstName?: string; - lastName?: string; + firstName: string; + lastName: string; enabled: boolean; emailVerified: boolean; attributes?: Record; @@ -181,7 +181,7 @@ export class AssignRolesDto { } // Types pour les rôles client -export type ClientRole = 'admin' | 'merchant' | 'support'; +export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support'; // Interface pour l'authentification export interface LoginDto { diff --git a/src/users/services/merchant-team.service.ts b/src/users/services/merchant-team.service.ts new file mode 100644 index 0000000..b1c865e --- /dev/null +++ b/src/users/services/merchant-team.service.ts @@ -0,0 +1,352 @@ +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 index eed9864..630cfa9 100644 --- a/src/users/services/users.service.ts +++ b/src/users/services/users.service.ts @@ -30,7 +30,7 @@ export class UsersService { // === VALIDATION DES ROLES === private validateClientRole(role: string): ClientRole { - const validRoles: ClientRole[] = ['admin', 'merchant', 'support']; + 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(', ')}`); } @@ -57,7 +57,11 @@ export class UsersService { const roles = await this.keycloakApi.getUserClientRoles(userId); const clientRoles = roles.map(role => role.name); - return new UserResponse({ ...user, clientRoles }); + 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; @@ -149,7 +153,6 @@ export class UsersService { if (userData.email) { const existingByEmail = await this.keycloakApi.findUserByEmail(userData.email); - if (existingByEmail.length > 0) { throw new ConflictException('User with this email already exists'); } @@ -159,26 +162,45 @@ export class UsersService { throw new ConflictException('User with this username already exists'); } - const userId = await this.keycloakApi.createUser({ + // Préparer les données utilisateur + const keycloakUserData: any = { username: userData.username, email: userData.email, firstName: userData.firstName, lastName: userData.lastName, - password: userData.password, enabled: userData.enabled ?? true, - }); + credentials: userData.password ? [ + { + type: 'password', + value: userData.password, + temporary: false + } + ] : [] + }; - // Attribution automatique de rôles client si fournis + // 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 }); + 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; @@ -186,6 +208,28 @@ export class UsersService { } } + // === 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 { diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 9800aaf..40e98c8 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -3,6 +3,8 @@ 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'; @@ -12,9 +14,9 @@ import { KeycloakApiService } from '../auth/services/keycloak-api.service'; HttpModule, JwtModule.register({}), ], - providers: [UsersService, KeycloakApiService, TokenService], + providers: [UsersService, MerchantTeamService, KeycloakApiService, TokenService], controllers: [UsersController], - exports: [UsersService, KeycloakApiService, TokenService, JwtModule], + exports: [UsersService, MerchantTeamService, KeycloakApiService, TokenService, JwtModule], }) export class UsersModule {}