feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature

This commit is contained in:
diallolatoile 2025-11-03 17:37:20 +00:00
parent bc47bca6b5
commit 65494d5af2
36 changed files with 5573 additions and 2379 deletions

View File

@ -21,7 +21,7 @@ KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin
KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025 KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025
KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025 KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025
KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support
KEYCLOAK_TEST_PASSWORD=@BOSupport2025 KEYCLOAK_TEST_PASSWORD=@BOSupport2025

View File

@ -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 {}

View File

@ -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' };
}
}

View File

@ -14,8 +14,7 @@ import { TerminusModule } from '@nestjs/terminus';
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config'; import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { ApiModule } from './api/api.module'; import { HubUsersModule } from './hub-users/hub-users.module';
import { UsersModule } from './users/users.module';
import { StartupService } from './auth/services/startup.service'; import { StartupService } from './auth/services/startup.service';
@Module({ @Module({
@ -69,8 +68,7 @@ import { StartupService } from './auth/services/startup.service';
// Feature Modules // Feature Modules
AuthModule, AuthModule,
ApiModule, HubUsersModule,
UsersModule,
], ],
providers: [ providers: [

View File

@ -4,8 +4,7 @@ import { JwtModule } from '@nestjs/jwt';
import { TokenService } from './services/token.service'; import { TokenService } from './services/token.service';
import { KeycloakApiService } from './services/keycloak-api.service'; import { KeycloakApiService } from './services/keycloak-api.service';
import { AuthController } from './controllers/auth.controller'; import { AuthController } from './controllers/auth.controller';
import { UsersService } from '../users/services/users.service'; import { HubUsersService } from '../hub-users/services/hub-users.service';
import { MerchantTeamService } from 'src/users/services/merchant-team.service';
import { JwtAuthGuard } from './guards/jwt.guard'; import { JwtAuthGuard } from './guards/jwt.guard';
@ -19,10 +18,9 @@ import { JwtAuthGuard } from './guards/jwt.guard';
JwtAuthGuard, JwtAuthGuard,
TokenService, TokenService,
KeycloakApiService, KeycloakApiService,
UsersService, HubUsersService
MerchantTeamService
], ],
controllers: [AuthController], controllers: [AuthController],
exports: [JwtAuthGuard, TokenService, KeycloakApiService, UsersService, MerchantTeamService, JwtModule], exports: [JwtAuthGuard, TokenService, KeycloakApiService, HubUsersService, JwtModule],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -9,14 +9,75 @@ import {
HttpException, HttpException,
HttpStatus, HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiBody,
ApiHeader,
ApiProperty
} from '@nestjs/swagger';
import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect'; import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect';
import { TokenService } from '../services/token.service'; import { TokenService } from '../services/token.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { Request } from 'express'; import type { Request } from 'express';
import { UsersService } from '../../users/services/users.service'; import { HubUsersService } from '../../hub-users/services/hub-users.service';
import * as user from '../../users/models/user'; 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') @Controller('auth')
export class AuthController { export class AuthController {
private readonly logger = new Logger(AuthController.name); private readonly logger = new Logger(AuthController.name);
@ -24,16 +85,40 @@ export class AuthController {
constructor( constructor(
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly usersService: UsersService private readonly usersService: HubUsersService
) {} ) {}
/** ------------------------------- /** -------------------------------
* LOGIN (Resource Owner Password Credentials) * LOGIN (Resource Owner Password Credentials)
* ------------------------------- */ * ------------------------------- */
// === AUTHENTIFICATION ===
@Public() @Public()
@Post('login') @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) { async login(@Body() loginDto: user.LoginDto) {
this.logger.log(`User login attempt: ${loginDto.username}`); this.logger.log(`User login attempt: ${loginDto.username}`);
@ -78,6 +163,36 @@ export class AuthController {
* LOGOUT * LOGOUT
* ------------------------------- */ * ------------------------------- */
@Post('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) { async logout(@Req() req: Request) {
const token = req.headers['authorization']?.split(' ')[1]; const token = req.headers['authorization']?.split(' ')[1];
if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST); if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST);
@ -120,6 +235,26 @@ export class AuthController {
* ------------------------------- */ * ------------------------------- */
@Public() @Public()
@Post('refresh') @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 }) { async refreshToken(@Body() body: { refresh_token: string }) {
const { refresh_token } = body; const { refresh_token } = body;
if (!refresh_token) throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST); if (!refresh_token) throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST);
@ -143,6 +278,20 @@ export class AuthController {
* ------------------------------- */ * ------------------------------- */
@Public() @Public()
@Get('status') @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) { async getAuthStatus(@Req() req: Request) {
const token = req.headers['authorization']?.replace('Bearer ', ''); const token = req.headers['authorization']?.replace('Bearer ', '');
let isValid = false; let isValid = false;
@ -155,4 +304,71 @@ export class AuthController {
} }
return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' }; return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' };
} }
/** -------------------------------
* USER PROFILE (protected)
* ------------------------------- */
@Get('profile')
@ApiOperation({
summary: 'Get current user profile',
description: 'Retrieve profile information of authenticated user'
})
@ApiBearerAuth()
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully'
})
@ApiResponse({
status: 401,
description: 'Unauthorized - invalid token',
type: ErrorResponseDto
})
async getProfile(@AuthenticatedUser() user: any) {
this.logger.log(`Profile requested for user: ${user.preferred_username}`);
return {
id: user.sub,
username: user.preferred_username,
email: user.email,
firstName: user.given_name,
lastName: user.family_name,
roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [],
emailVerified: user.email_verified,
};
}
/** -------------------------------
* VALIDATE TOKEN (protected)
* ------------------------------- */
@Get('validate')
@ApiOperation({
summary: 'Validate token',
description: 'Check if the current token is valid and get user information'
})
@ApiBearerAuth()
@ApiResponse({
status: 200,
description: 'Token is valid'
})
@ApiResponse({
status: 401,
description: 'Unauthorized - invalid token',
type: ErrorResponseDto
})
async validateToken(@AuthenticatedUser() user: any) {
this.logger.log(`Token validation requested for user: ${user.preferred_username}`);
return {
valid: true,
user: {
id: user.sub,
username: user.preferred_username,
email: user.email,
firstName: user.given_name,
lastName: user.family_name,
roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [],
},
expires_in: user.exp ? user.exp - Math.floor(Date.now() / 1000) : 0,
};
}
} }

View File

@ -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<boolean> {
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');
}
}
}

View File

@ -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<boolean> {
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');
}
}
}

View File

@ -1,30 +1,18 @@
import { Injectable, Logger, HttpException, NotFoundException, BadRequestException } from '@nestjs/common'; import { Injectable, Logger, HttpException, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { firstValueFrom, Observable, timeout as rxjsTimeout } from 'rxjs'; import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs';
import { TokenService } from './token.service'; import { TokenService } from './token.service';
import { KeycloakUser, KeycloakRole, CreateUserData, UserRole, UserType } from './keycloak-user.model';
export interface KeycloakUser { // Interface pour la hiérarchie des rôles
id?: string; interface RoleHierarchy {
username: string; role: UserRole;
email?: string; canCreate: UserRole[];
firstName?: string; requiresMerchantPartner?: boolean;
lastName?: string;
enabled: boolean;
emailVerified: boolean;
attributes?: Record<string, string[]>;
createdTimestamp?: number;
} }
export interface KeycloakRole {
id: string;
name: string;
description?: string;
}
export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support' | 'merchant-user';
@Injectable() @Injectable()
export class KeycloakApiService { export class KeycloakApiService {
private readonly logger = new Logger(KeycloakApiService.name); private readonly logger = new Logger(KeycloakApiService.name);
@ -32,6 +20,40 @@ export class KeycloakApiService {
private readonly realm: string; private readonly realm: string;
private readonly clientId: string; private readonly clientId: string;
// Hiérarchie des rôles - CORRIGÉE selon votre analyse
private readonly roleHierarchy: RoleHierarchy[] = [
{
role: UserRole.DCB_ADMIN,
canCreate: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
requiresMerchantPartner: false
},
{
role: UserRole.DCB_SUPPORT,
canCreate: [UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
requiresMerchantPartner: false
},
{
role: UserRole.DCB_PARTNER,
canCreate: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
requiresMerchantPartner: false
},
{
role: UserRole.DCB_PARTNER_ADMIN,
canCreate: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
requiresMerchantPartner: true
},
{
role: UserRole.DCB_PARTNER_MANAGER,
canCreate: [],
requiresMerchantPartner: true
},
{
role: UserRole.DCB_PARTNER_SUPPORT,
canCreate: [],
requiresMerchantPartner: true
}
];
constructor( constructor(
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
@ -39,12 +61,7 @@ export class KeycloakApiService {
) { ) {
this.keycloakBaseUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL') || 'http://localhost:8080'; this.keycloakBaseUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL') || 'http://localhost:8080';
this.realm = this.configService.get<string>('KEYCLOAK_REALM') || 'master'; this.realm = this.configService.get<string>('KEYCLOAK_REALM') || 'master';
this.clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID') || 'admin-cli'; this.clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID') || 'dcb-admin-cli';
}
// ===== MÉTHODE POUR L'AUTHENTIFICATION UTILISATEUR =====
async authenticateUser(username: string, password: string) {
return this.tokenService.acquireUserToken(username, password);
} }
// ===== CORE REQUEST METHOD ===== // ===== CORE REQUEST METHOD =====
@ -110,79 +127,375 @@ export class KeycloakApiService {
); );
} }
// ===== USER CRUD OPERATIONS ===== // ===== AUTHENTICATION METHODS =====
async createUser(userData: { async authenticateUser(username: string, password: string) {
username: string; return this.tokenService.acquireUserToken(username, password);
email?: string; }
firstName?: string;
lastName?: string; // ===== USER LIFECYCLE MANAGEMENT =====
password?: string; async updateUserStatus(
enabled?: boolean; userId: string,
emailVerified?: boolean; status: string,
attributes?: Record<string, string[]>; reason?: string,
credentials?: any[]; performedBy?: string
}): Promise<string> { ): Promise<void> {
const attributes: Record<string, string[]> = {
userStatus: [status],
lastStatusChange: [new Date().toISOString()],
};
if (reason) {
attributes.statusChangeReason = [reason];
}
if (performedBy) {
attributes.lastStatusChangeBy = [performedBy];
}
await this.setUserAttributes(userId, attributes);
}
async getUserStatus(userId: string): Promise<string> {
return await this.getUserAttribute(userId, 'userStatus') || 'PENDING_ACTIVATION';
}
async setUserAttributes(userId: string, attributes: Record<string, string[]>): Promise<void> {
try {
const user = await this.getUserById(userId, userId); // Self-access pour les attributs
const updatedUser = {
...user,
attributes: {
...user.attributes,
...attributes
}
};
await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, updatedUser);
this.logger.log(`Attributes set for user ${userId}: ${Object.keys(attributes).join(', ')}`);
} catch (error: any) {
this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`);
throw error;
}
}
async getUserAttribute(userId: string, attributeName: string): Promise<string | null> {
try {
const user = await this.getUserById(userId, userId);
const attributes = user.attributes || {};
return attributes[attributeName]?.[0] || null;
} catch (error: any) {
this.logger.error(`Failed to get attribute ${attributeName} for user ${userId}: ${error.message}`);
return null;
}
}
// ===== PASSWORD MANAGEMENT =====
async resetUserPassword(userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
const requesterId = userId; // Self-service ou via admin
await this.validateUserAccess(requesterId, await this.getUserMerchantPartnerId(userId));
const passwordPayload = {
type: 'password',
value: newPassword,
temporary: temporary,
};
await this.request(
'PUT',
`/admin/realms/${this.realm}/users/${userId}/reset-password`,
passwordPayload
);
// Mettre à jour les attributs de cycle de vie
await this.setUserAttributes(userId, {
lastPasswordChange: [new Date().toISOString()],
temporaryPassword: [temporary.toString()],
passwordChangeRequired: [temporary.toString()],
});
this.logger.log(`Password reset for user ${userId}, temporary: ${temporary}`);
}
async sendPasswordResetEmail(userEmail: string): Promise<void> {
const users = await this.findUserByEmail(userEmail);
if (users.length === 0) {
throw new NotFoundException('User not found');
}
const userId = users[0].id!;
const status = await this.getUserStatus(userId);
if (status !== 'ACTIVE') {
throw new BadRequestException('User account is not active');
}
// Keycloak gère l'envoi d'email de reset
await this.request(
'PUT',
`/admin/realms/${this.realm}/users/${userId}/execute-actions-email`,
['UPDATE_PASSWORD']
);
this.logger.log(`Password reset email sent to: ${userEmail}`);
}
// ===== COMPLETE USER LIFECYCLE =====
async suspendUser(userId: string, reason: string, performedBy: string): Promise<void> {
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
await this.validateUserAccess(performedBy, merchantPartnerId);
await this.updateUser(userId, { enabled: false }, performedBy);
await this.updateUserStatus(userId, 'SUSPENDED', reason, performedBy);
this.logger.log(`User suspended: ${userId}, reason: ${reason}`);
}
async reactivateUser(userId: string, performedBy: string): Promise<void> {
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
await this.validateUserAccess(performedBy, merchantPartnerId);
await this.updateUser(userId, { enabled: true }, performedBy);
await this.updateUserStatus(userId, 'ACTIVE', 'User reactivated', performedBy);
this.logger.log(`User reactivated: ${userId}`);
}
async deactivateUser(userId: string, reason: string, performedBy: string): Promise<void> {
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
await this.validateUserAccess(performedBy, merchantPartnerId);
await this.updateUser(userId, { enabled: false }, performedBy);
await this.updateUserStatus(userId, 'DEACTIVATED', reason, performedBy);
this.logger.log(`User deactivated: ${userId}, reason: ${reason}`);
}
// Méthode activateUser corrigée aussi
async activateUser(userId: string, activationData: {
firstName: string;
lastName: string;
termsAccepted: boolean;
}): Promise<void> {
const currentStatus = await this.getUserStatus(userId);
if (currentStatus !== 'PENDING_ACTIVATION') {
throw new BadRequestException('User cannot be activated');
}
// Mettre à jour le profil - pas besoin de validation d'accès car self-service
await this.updateUser(userId, {
firstName: activationData.firstName,
lastName: activationData.lastName,
emailVerified: true,
}, userId);
// Mettre à jour le statut
await this.updateUserStatus(userId, 'ACTIVE', 'User activated', userId);
await this.setUserAttributes(userId, {
termsAccepted: [activationData.termsAccepted.toString()],
profileCompleted: ['true'],
activatedAt: [new Date().toISOString()],
});
this.logger.log(`User activated: ${userId}`);
}
// ===== VALIDATION DES PERMISSIONS =====
async validateUserAccess(requesterId: string, targetMerchantPartnerId?: string | null): Promise<void> {
const requesterRoles = await this.getUserClientRoles(requesterId);
// Les admins Hub ont accès complet (peu importe le merchantPartnerId)
if (requesterRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
return;
}
// Si pas de merchantPartnerId cible, seul le Hub peut accéder
if (!targetMerchantPartnerId) {
throw new ForbiddenException('Access to hub resources requires DCB_ADMIN or DCB_SUPPORT role');
}
// Vérifier si l'utilisateur est un DCB_PARTNER (propriétaire)
const isDcbPartner = requesterRoles.some(role => role.name === UserRole.DCB_PARTNER);
if (isDcbPartner) {
// Pour DCB_PARTNER, l'ID utilisateur DOIT être égal au merchantPartnerId
if (requesterId === targetMerchantPartnerId) {
return;
}
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner data');
}
// Vérifier si l'utilisateur a un rôle merchant et accède au même merchant
const requesterMerchantPartnerId = await this.getUserMerchantPartnerId(requesterId);
if (requesterMerchantPartnerId && requesterMerchantPartnerId === targetMerchantPartnerId) {
// Vérifier que l'utilisateur a un rôle merchant valide
const merchantRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
if (requesterRoles.some(role => merchantRoles.includes(role.name as UserRole))) {
return;
}
}
throw new ForbiddenException('Insufficient permissions to access this resource');
}
private async validateUserCreation(creatorId: string, userData: CreateUserData): Promise<void> {
const creatorRoles = await this.getUserClientRoles(creatorId);
const targetRoles = userData.clientRoles || [];
this.logger.debug(`Validating user creation: creator=${creatorId}, roles=${targetRoles.join(',')}`);
this.logger.debug(`Creator roles: ${creatorRoles.map(r => r.name).join(', ')}`);
// Validation: au moins un rôle doit être spécifié
if (targetRoles.length === 0) {
throw new BadRequestException('At least one client role must be specified');
}
// Vérifier que le créateur peut créer ces rôles
for (const targetRole of targetRoles) {
let canCreate = false;
for (const creatorRole of creatorRoles) {
if (this.canRoleCreateRole(creatorRole.name as UserRole, targetRole)) {
canCreate = true;
break;
}
}
if (!canCreate) {
this.logger.error(`Creator cannot create role: ${targetRole}`);
this.logger.error(`Creator roles: ${creatorRoles.map(r => r.name).join(', ')}`);
throw new ForbiddenException(`Cannot create user with role: ${targetRole}`);
}
}
// Validation du merchantPartnerId selon les règles
await this.validateMerchantPartnerForCreation(creatorId, creatorRoles, userData);
}
private canRoleCreateRole(creatorRole: UserRole, targetRole: UserRole): boolean {
const hierarchy = this.roleHierarchy.find(h => h.role === creatorRole);
if (!hierarchy) {
this.logger.warn(`No hierarchy found for role: ${creatorRole}`);
return false;
}
const canCreate = hierarchy.canCreate.includes(targetRole);
this.logger.debug(`Role ${creatorRole} can create ${targetRole}: ${canCreate}`);
return canCreate;
}
private async validateMerchantPartnerForCreation(
creatorId: string,
creatorRoles: KeycloakRole[],
userData: CreateUserData
): Promise<void> {
const targetRoles = userData.clientRoles || [];
const requiresMerchantPartner = targetRoles.some(role =>
this.roleHierarchy.find(h => h.role === role)?.requiresMerchantPartner
);
// Si le rôle cible nécessite un merchantPartnerId
if (requiresMerchantPartner) {
if (!userData.merchantPartnerId) {
throw new BadRequestException('merchantPartnerId is required for merchant partner roles');
}
// DCB_ADMIN/SUPPORT peuvent créer pour n'importe quel merchant
if (creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
return;
}
// DCB_PARTNER ne peut créer que pour son propre merchant
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
if (creatorId !== userData.merchantPartnerId) {
throw new ForbiddenException('DCB_PARTNER can only create users for their own merchant partner');
}
return;
}
// DCB_PARTNER_ADMIN ne peut créer que pour son merchant
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER_ADMIN)) {
const creatorMerchantId = await this.getUserMerchantPartnerId(creatorId);
if (creatorMerchantId !== userData.merchantPartnerId) {
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner');
}
return;
}
throw new ForbiddenException('Insufficient permissions to create merchant partner users');
} else {
// Les rôles Hub ne doivent PAS avoir de merchantPartnerId
if (userData.merchantPartnerId) {
throw new BadRequestException('merchantPartnerId should not be provided for hub roles');
}
// Seul DCB_ADMIN/SUPPORT peut créer des rôles Hub
if (!creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
throw new ForbiddenException('Only hub admins can create hub roles');
}
}
}
// ===== USER CRUD OPERATIONS WITH ACCESS CONTROL =====
async createUser(creatorId: string, userData: CreateUserData): Promise<string> {
// Validation des permissions du créateur
await this.validateUserCreation(creatorId, userData);
this.logger.debug(`CREATE USER - Input data:`, { this.logger.debug(`CREATE USER - Input data:`, {
username: userData.username, username: userData.username,
hasPassword: !!userData.password, merchantPartnerId: userData.merchantPartnerId,
hasCredentials: !!userData.credentials, createdBy: creatorId,
credentialsLength: userData.credentials ? userData.credentials.length : 0 clientRoles: userData.clientRoles
}); });
// Récupérer le username du créateur AVANT la création
let creatorUsername = '';
try {
const creatorUser = await this.getUserById(creatorId, creatorId);
creatorUsername = creatorUser.username;
} catch (error) {
this.logger.warn(`Could not fetch creator username: ${error.message}`);
creatorUsername = 'unknown';
}
const userPayload: any = { const userPayload: any = {
username: userData.username, username: userData.username,
email: userData.email, email: userData.email,
firstName: userData.firstName, firstName: userData.firstName,
lastName: userData.lastName, lastName: userData.lastName,
enabled: userData.enabled ?? true, enabled: userData.enabled ?? true,
emailVerified: userData.emailVerified ?? true, emailVerified: userData.emailVerified ?? false,
attributes: this.buildUserAttributes(userData, creatorId, creatorUsername),
}; };
if (userData.password) { if (userData.password) {
// Format direct : password field
userPayload.credentials = [{ userPayload.credentials = [{
type: 'password', type: 'password',
value: userData.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)); this.logger.debug(`CREATE USER - Final Keycloak payload:`, JSON.stringify(userPayload, null, 2));
// Format correct des attributs
if (userData.attributes) {
const formattedAttributes: Record<string, string[]> = {};
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 { try {
this.logger.log(`Creating user in Keycloak: ${userData.username}`); this.logger.log(`Creating user in Keycloak: ${userData.username}`);
await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload); await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload);
// Récupérer l'ID
const users = await this.findUserByUsername(userData.username); const users = await this.findUserByUsername(userData.username);
if (users.length === 0) { if (users.length === 0) {
throw new Error('User not found after creation'); throw new Error('User not found after creation');
} }
this.logger.log(`User created successfully with ID: ${users[0].id}`); const userId = users[0].id!;
return users[0].id!; this.logger.log(`User created successfully with ID: ${userId}`);
// Assigner les rôles client
if (userData.clientRoles && userData.clientRoles.length > 0) {
await this.setClientRoles(userId, userData.clientRoles);
this.logger.log(`Client roles assigned to user ${userId}: ${userData.clientRoles.join(', ')}`);
}
return userId;
} catch (error: any) { } catch (error: any) {
this.logger.error(`FAILED to create user in Keycloak: ${error.message}`); this.logger.error(`FAILED to create user in Keycloak: ${error.message}`);
if (error.response?.data) { if (error.response?.data) {
@ -192,121 +505,95 @@ export class KeycloakApiService {
} }
} }
async getUserById(userId: string): Promise<KeycloakUser> { async getUserById(userId: string, requesterId: string): Promise<KeycloakUser> {
return this.request<KeycloakUser>('GET', `/admin/realms/${this.realm}/users/${userId}`); const user = await this.request<KeycloakUser>('GET', `/admin/realms/${this.realm}/users/${userId}`);
// Valider l'accès du requester à cet utilisateur
const userMerchantPartnerId = user.attributes?.merchantPartnerId?.[0];
await this.validateUserAccess(requesterId, userMerchantPartnerId);
return user;
} }
async getAllUsers(): Promise<KeycloakUser[]> { async updateUser(userId: string, userData: Partial<KeycloakUser>, requesterId: string): Promise<void> {
return this.request<KeycloakUser[]>('GET', `/admin/realms/${this.realm}/users`); // Valider l'accès du requester à cet utilisateur
} const currentUser = await this.getUserById(userId, requesterId);
const userMerchantPartnerId = currentUser.attributes?.merchantPartnerId?.[0];
await this.validateUserAccess(requesterId, userMerchantPartnerId);
async findUserByUsername(username: string): Promise<KeycloakUser[]> {
return this.request<KeycloakUser[]>(
'GET',
`/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}`
);
}
async findUserByEmail(email: string): Promise<KeycloakUser[]> {
return this.request<KeycloakUser[]>(
'GET',
`/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}`
);
}
async updateUser(userId: string, userData: Partial<KeycloakUser>): Promise<void> {
return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData); return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData);
} }
async deleteUser(userId: string): Promise<void> { async deleteUser(userId: string, requesterId: string): Promise<void> {
const userMerchantPartnerId = await this.getUserMerchantPartnerId(userId);
await this.validateUserAccess(requesterId, userMerchantPartnerId);
return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`); return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`);
} }
async setUserMerchantPartnerId(userId: string, merchantPartnerId: string, requesterId: string): Promise<void> {
await this.validateUserAccess(requesterId, merchantPartnerId);
await this.setUserAttributes(userId, {
merchantPartnerId: [merchantPartnerId]
});
}
// ===== ATTRIBUTES MANAGEMENT ===== // ===== ATTRIBUTES MANAGEMENT =====
async setUserAttributes(userId: string, attributes: Record<string, string[]>): Promise<void> { private buildUserAttributes(
userData: CreateUserData,
creatorId: string,
creatorUsername: string
): Record<string, string[]> {
const attributes: Record<string, string[]> = {};
// Merchant Partner ID
if (userData.merchantPartnerId !== undefined) {
attributes.merchantPartnerId = [userData.merchantPartnerId];
}
// Tracking de création
attributes.createdBy = [creatorId];
attributes.createdByUsername = [creatorUsername];
// Type d'utilisateur (Hub/Merchant)
if (userData.clientRoles) {
const isHubUser = userData.clientRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role)
);
attributes.userType = [isHubUser ? 'HUB' : 'MERCHANT'];
}
// Cycle de vie
attributes.userStatus = [userData.initialStatus || 'PENDING_ACTIVATION'];
attributes.accountCreatedAt = [new Date().toISOString()];
attributes.termsAccepted = ['false'];
attributes.profileCompleted = ['false'];
return attributes;
}
// ===== MERCHANT PARTNER SPECIFIC METHODS =====
async getUsersByMerchantPartnerId(merchantPartnerId: string, requesterId: string): Promise<KeycloakUser[]> {
await this.validateUserAccess(requesterId, merchantPartnerId);
const allUsers = await this.getAllUsers();
return allUsers.filter(user =>
user.attributes?.merchantPartnerId?.includes(merchantPartnerId)
);
}
async getUserMerchantPartnerId(userId: string): Promise<string | null> {
try { try {
const user = await this.getUserById(userId); const user = await this.request<KeycloakUser>('GET', `/admin/realms/${this.realm}/users/${userId}`);
const updatedUser = { return user.attributes?.merchantPartnerId?.[0] || null;
...user,
attributes: {
...user.attributes,
...attributes
}
};
await this.updateUser(userId, updatedUser);
this.logger.log(`Attributes set for user ${userId}: ${Object.keys(attributes).join(', ')}`);
} catch (error) { } catch (error) {
this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`); this.logger.error(`Failed to get merchantPartnerId for user ${userId}: ${error.message}`);
throw error;
}
}
async updateUserAttributes(userId: string, attributes: Record<string, string[]>): Promise<void> {
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<void> {
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<string | null> {
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; return null;
} }
} }
async getUserAttributes(userId: string): Promise<Record<string, string[]>> { // ===== ROLE MANAGEMENT =====
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<KeycloakRole[]> { async getUserClientRoles(userId: string): Promise<KeycloakRole[]> {
try { try {
const clients = await this.getClient(); const clients = await this.getClient();
@ -320,41 +607,7 @@ export class KeycloakApiService {
} }
} }
async assignClientRole(userId: string, role: ClientRole): Promise<void> { async setClientRoles(userId: string, roles: UserRole[]): Promise<void> {
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<void> {
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<void> {
try { try {
const clients = await this.getClient(); const clients = await this.getClient();
const clientId = clients[0].id; const clientId = clients[0].id;
@ -362,7 +615,7 @@ export class KeycloakApiService {
// Récupérer les rôles actuels // Récupérer les rôles actuels
const currentRoles = await this.getUserClientRoles(userId); const currentRoles = await this.getUserClientRoles(userId);
// Supprimer tous les rôles actuels si nécessaire // Supprimer les rôles actuels si existants
if (currentRoles.length > 0) { if (currentRoles.length > 0) {
await this.request( await this.request(
'DELETE', 'DELETE',
@ -391,59 +644,28 @@ export class KeycloakApiService {
} }
} }
async addClientRoles(userId: string, roles: ClientRole[]): Promise<void> {
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<void> {
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 ===== // ===== UTILITY METHODS =====
async userExists(username: string): Promise<boolean> { async getAllUsers(): Promise<KeycloakUser[]> {
try { return this.request<KeycloakUser[]>('GET', `/admin/realms/${this.realm}/users`);
const users = await this.findUserByUsername(username);
return users.length > 0;
} catch {
return false;
}
} }
async enableUser(userId: string): Promise<void> { async findUserByUsername(username: string): Promise<KeycloakUser[]> {
await this.updateUser(userId, { enabled: true }); const users = await this.request<KeycloakUser[]>(
this.logger.log(`User ${userId} enabled`); 'GET',
`/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}`
);
// Keycloak fait une recherche partielle, on filtre pour une correspondance exacte
return users.filter(user => user.username === username);
} }
async disableUser(userId: string): Promise<void> { async findUserByEmail(email: string): Promise<KeycloakUser[]> {
await this.updateUser(userId, { enabled: false }); const users = await this.request<KeycloakUser[]>(
this.logger.log(`User ${userId} disabled`); 'GET',
} `/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}`
);
async resetPassword(userId: string, newPassword: string): Promise<void> { return users.filter(user => user.email === email);
const credentials = {
type: 'password',
value: newPassword,
temporary: false,
};
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<KeycloakUser[]> { async getUsersByAttribute(attributeName: string, attributeValue: string): Promise<KeycloakUser[]> {
@ -454,12 +676,68 @@ export class KeycloakApiService {
user.attributes[attributeName] && user.attributes[attributeName] &&
user.attributes[attributeName].includes(attributeValue) user.attributes[attributeName].includes(attributeValue)
); );
} catch (error) { } catch (error: any) {
this.logger.error(`Failed to get users by attribute ${attributeName}: ${error.message}`); this.logger.error(`Failed to get users by attribute ${attributeName}: ${error.message}`);
return []; return [];
} }
} }
// ===== PRIVATE HELPERS =====
private async getClient(): Promise<any[]> {
const clients = await this.request<any[]>('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`);
if (!clients || clients.length === 0) {
throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`);
}
return clients;
}
private async getRole(role: UserRole, clientId: string): Promise<KeycloakRole> {
const roles = await this.request<KeycloakRole[]>('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`);
const targetRole = roles.find(r => r.name === role);
if (!targetRole) {
throw new BadRequestException(`Role '${role}' not found in client '${this.clientId}'`);
}
return targetRole;
}
// ===== PERMISSION CHECKERS (pour usage externe) =====
async canUserCreateRole(creatorId: string, targetRole: UserRole): Promise<boolean> {
const creatorRoles = await this.getUserClientRoles(creatorId);
return creatorRoles.some(creatorRole =>
this.canRoleCreateRole(creatorRole.name as UserRole, targetRole)
);
}
async getUserPermissions(userId: string): Promise<{
canCreateMerchantPartners: boolean;
canManageUsers: boolean;
accessibleMerchantPartnerIds: string[];
}> {
const roles = await this.getUserClientRoles(userId);
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
const canCreateMerchantPartners = roles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
const canManageUsers = roles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN].includes(role.name as UserRole)
);
const accessibleMerchantPartnerIds = canCreateMerchantPartners
? [] // Accès à tous les merchants
: merchantPartnerId
? [merchantPartnerId]
: [];
return {
canCreateMerchantPartners,
canManageUsers,
accessibleMerchantPartnerIds
};
}
// ===== HEALTH CHECKS ===== // ===== HEALTH CHECKS =====
async checkKeycloakAvailability(): Promise<boolean> { async checkKeycloakAvailability(): Promise<boolean> {
const url = `${this.keycloakBaseUrl}/realms/${this.realm}`; const url = `${this.keycloakBaseUrl}/realms/${this.realm}`;
@ -499,68 +777,4 @@ export class KeycloakApiService {
return false; return false;
} }
} }
// ===== PRIVATE HELPERS =====
private async getClient(): Promise<any[]> {
const clients = await this.request<any[]>('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`);
if (!clients || clients.length === 0) {
throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`);
}
return clients;
}
private async getRole(role: ClientRole, clientId: string): Promise<KeycloakRole> {
const roles = await this.request<KeycloakRole[]>('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`);
const targetRole = roles.find(r => r.name === role);
if (!targetRole) {
throw new BadRequestException(`Role '${role}' not found in client '${this.clientId}'`);
}
return targetRole;
}
// ===== MÉTHODES POUR LE STARTUP SERVICE (compatibilité) =====
async checkHealth(username: string, password: string): Promise<{ status: string }> {
try {
const isAvailable = await this.checkKeycloakAvailability();
const isConnected = await this.checkServiceConnection();
return {
status: isAvailable && isConnected ? 'healthy' : 'unhealthy'
};
} catch (error) {
return { status: 'unhealthy' };
}
}
async getRealmClients(realm: string, username: string, password: string): Promise<any[]> {
return this.request<any[]>('GET', `/admin/realms/${realm}/clients`);
}
async getRealmInfo(realm: string, username: string, password: string): Promise<any> {
return this.request<any>('GET', `/admin/realms/${realm}`);
}
async getUsers(realm: string, username: string, password: string, options?: any): Promise<KeycloakUser[]> {
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<KeycloakUser[]>(url.includes('?') ? 'GET' : 'GET', url);
}
async getUserProfile(realm: string, token: string): Promise<any> {
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
} }

View File

@ -0,0 +1,125 @@
export interface KeycloakUser {
id?: string;
username: string;
email: string; // Rendre obligatoire
firstName: string;
lastName: string;
enabled: boolean;
emailVerified: boolean;
attributes?: {
merchantPartnerId?: string[];
createdBy?: string[];
createdByUsername?: string[];
userType?: string[];
userStatus?: string[];
lastLogin?: string[];
[key: string]: string[] | undefined;
};
createdTimestamp?: number;
}
export interface KeycloakRole {
id: string;
name: string;
description?: string;
composite?: boolean;
clientRole?: boolean;
containerId?: string;
}
export interface CreateUserData {
username: string;
email: string;
firstName: string;
lastName: string;
password?: string;
passwordTemporary?: boolean;
enabled?: boolean;
emailVerified?: boolean;
merchantPartnerId?: string;
clientRoles: UserRole[];
createdBy?: string;
createdByUsername?: string;
initialStatus?: string;
}
export enum UserType {
HUB = 'hub',
MERCHANT_PARTNER = 'merchant_partner'
}
export enum UserRole {
// Rôles Hub (sans merchantPartnerId)
DCB_ADMIN = 'dcb-admin',
DCB_SUPPORT = 'dcb-support',
DCB_PARTNER = 'dcb-partner',
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
}
export interface HubUser {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
enabled: boolean;
emailVerified: boolean;
createdBy: string;
createdByUsername: string;
createdTimestamp: number;
lastLogin?: number;
userType: 'HUB';
attributes?: {
userStatus?: string[];
lastLogin?: string[];
merchantPartnerId?: string[];
createdBy?: string[];
createdByUsername?: string[];
userType?: string[];
[key: string]: string[] | undefined;
};
}
export interface CreateHubUserData {
username: string;
email: string;
firstName: string;
lastName: string;
password?: string;
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
enabled?: boolean;
emailVerified?: boolean;
createdBy: string;
}
export interface HubUserStats {
totalAdmins: number;
totalSupport: number;
activeUsers: number;
inactiveUsers: number;
pendingActivation: number;
}
export interface MerchantStats {
totalMerchants: number;
activeMerchants: number;
suspendedMerchants: number;
pendingMerchants: number;
totalUsers: number;
}
export interface HubUserActivity {
user: HubUser;
lastLogin?: Date;
}
export interface HubHealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
issues: string[];
stats: HubUserStats;
}

View File

@ -0,0 +1,76 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { KeycloakApiService } from './keycloak-api.service';
interface TestResults {
connection: { [key: string]: string };
}
@Injectable()
export class StartupServiceInitialization implements OnModuleInit {
private readonly logger = new Logger(StartupServiceInitialization.name);
private isInitialized = false;
private initializationError: string | null = null;
private testResults: TestResults = {
connection: {},
};
constructor(
private readonly keycloakApiService: KeycloakApiService,
) {}
async onModuleInit() {
this.logger.log('🚀 Démarrage des tests de connexion');
try {
await this.validateKeycloakConnection();
this.isInitialized = true;
this.logger.log('✅ Tests de connexion terminés avec succès');
} catch (error: any) {
this.initializationError = error.message;
this.logger.error(`❌ Échec des tests de connexion: ${error.message}`);
}
}
// === VALIDATION CONNEXION KEYCLOAK ===
private async validateKeycloakConnection() {
this.logger.log('🔌 Test de connexion Keycloak...');
try {
const isKeycloakAccessible = await this.keycloakApiService.checkKeycloakAvailability();
if (!isKeycloakAccessible) {
throw new Error('Keycloak inaccessible');
}
const isServiceConnected = await this.keycloakApiService.checkServiceConnection();
if (!isServiceConnected) {
throw new Error('Connexion service Keycloak échouée');
}
this.testResults.connection.keycloak = 'SUCCESS';
this.logger.log('✅ Connexion Keycloak validée');
} catch (error: any) {
this.testResults.connection.keycloak = 'FAILED';
throw new Error(`Connexion Keycloak échouée: ${error.message}`);
}
}
// === METHODES STATUT ===
getStatus() {
return {
status: this.isInitialized ? 'healthy' : 'unhealthy',
keycloakConnected: this.isInitialized,
testResults: this.testResults,
timestamp: new Date(),
error: this.initializationError,
};
}
isHealthy(): boolean {
return this.isInitialized;
}
getTestResults(): TestResults {
return this.testResults;
}
}

View File

@ -0,0 +1,710 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { HubUsersService} from '../../hub-users/services/hub-users.service';
import { MerchantUsersService, CreateMerchantUserData } from '../../hub-users/services/merchant-users.service';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { TokenService } from '../../auth/services/token.service';
import { UserRole, CreateHubUserData } from '../../auth/services/keycloak-user.model';
export interface TestResult {
testName: string;
success: boolean;
duration: number;
error?: string;
data?: any;
}
export interface StartupTestSummary {
totalTests: number;
passedTests: number;
failedTests: number;
totalDuration: number;
results: TestResult[];
healthStatus?: any;
}
type HubUserRole =
| UserRole.DCB_ADMIN
| UserRole.DCB_SUPPORT
| UserRole.DCB_PARTNER;
type MerchantUserRole =
| UserRole.DCB_PARTNER_ADMIN
| UserRole.DCB_PARTNER_MANAGER
| UserRole.DCB_PARTNER_SUPPORT;
@Injectable()
export class StartupServiceFinal implements OnModuleInit {
private readonly logger = new Logger(StartupServiceFinal.name);
// Stockage des données de test
private testUsers: { [key: string]: { id: string; username: string; role: UserRole } } = {};
private testMerchants: { [key: string]: { id: string; username: string; role: UserRole } } = {};
private testMerchantUsers: { [key: string]: { id: string; username: string; role: UserRole; merchantPartnerId: string } } = {};
constructor(
private readonly hubUsersService: HubUsersService,
private readonly merchantUsersService: MerchantUsersService,
private readonly keycloakApi: KeycloakApiService,
private readonly tokenService: TokenService,
) {}
async onModuleInit() {
if (process.env.RUN_STARTUP_TESTS === 'true') {
this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...');
await this.runAllTests();
}
else {
// 1. Tests de base
await this.testKeycloakConnection();
}
}
// ===== MÉTHODES DE TEST PRINCIPALES =====
async runAllTests(): Promise<StartupTestSummary> {
const results: TestResult[] = [];
const startTime = Date.now();
try {
// 1. Tests de base
results.push(await this.testKeycloakConnection());
results.push(await this.testServiceAccountPermissions());
// 2. Tests de création en parallèle avec isolation
const parallelTests = await this.runParallelIsolationTests();
results.push(...parallelTests);
// 3. Tests avancés
results.push(await this.testStatsAndReports());
results.push(await this.testHealthCheck());
results.push(await this.testSecurityValidations());
} catch (error) {
this.logger.error('Critical error during startup tests:', error);
} finally {
await this.cleanupTestUsers();
await this.cleanupTestMerchants();
}
const totalDuration = Date.now() - startTime;
const passedTests = results.filter(r => r.success).length;
const failedTests = results.filter(r => !r.success).length;
const summary: StartupTestSummary = {
totalTests: results.length,
passedTests,
failedTests,
totalDuration,
results,
};
this.logTestSummary(summary);
return summary;
}
// ===== TESTS DE BASE =====
private async testKeycloakConnection(): Promise<TestResult> {
const testName = 'Keycloak Connection Test';
const startTime = Date.now();
try {
const token = await this.tokenService.acquireServiceAccountToken();
const isValid = await this.tokenService.validateToken(token);
if (!isValid) {
throw new Error('Service account token validation failed');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return { testName, success: true, duration };
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
private async testServiceAccountPermissions(): Promise<TestResult> {
const testName = 'Service Account Permissions Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
if (!serviceAccountId) {
throw new Error('Could not extract service account ID from token');
}
// Vérifier les rôles du service account
const roles = await this.keycloakApi.getUserClientRoles(serviceAccountId);
const roleNames = roles.map(r => r.name);
this.logger.log(`Service account roles: ${roleNames.join(', ')}`);
// Le service account doit avoir au moins DCB_ADMIN pour créer des utilisateurs
const hasRequiredRole = roleNames.some(role =>
[UserRole.DCB_ADMIN].includes(role as UserRole)
);
if (!hasRequiredRole) {
throw new Error(`Service account missing required roles. Has: ${roleNames.join(', ')}, Needs: ${UserRole.DCB_ADMIN}`);
}
// 1 - Service Account crée un ADMIN DCB-ADMIN
const adminData: CreateHubUserData = {
username: `test-dcb-admin-${Date.now()}`,
email: `test-dcb-admin-${Date.now()}@dcb-test.com`,
firstName: 'Test',
lastName: 'DCB Admin',
password: 'TempPassword123!',
role: UserRole.DCB_ADMIN,
enabled: true,
emailVerified: true,
createdBy: 'service-account',
};
const adminUser = await this.hubUsersService.createHubUser(serviceAccountId, adminData);
this.testUsers['dcb-admin'] = {
id: adminUser.id,
username: adminUser.username,
role: UserRole.DCB_ADMIN
};
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: {
serviceAccountId,
roles: roleNames,
createdAdmin: adminUser.username
}
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
// ===== TESTS PARALLÈLES AVEC ISOLATION =====
private async runParallelIsolationTests(): Promise<TestResult[]> {
const results: TestResult[] = [];
try {
// Exécuter les tests pour deux merchants différents en parallèle
const [teamAResults, teamBResults] = await Promise.all([
this.runMerchantTeamTests('TeamA'),
this.runMerchantTeamTests('TeamB')
]);
results.push(...teamAResults);
results.push(...teamBResults);
// Test d'isolation entre les deux équipes
results.push(await this.testCrossTeamIsolation());
} catch (error) {
this.logger.error(`Parallel isolation tests failed: ${error.message}`);
results.push({
testName: 'Parallel Isolation Tests',
success: false,
duration: 0,
error: error.message
});
}
return results;
}
private async runMerchantTeamTests(teamName: string): Promise<TestResult[]> {
const results: TestResult[] = [];
const teamPrefix = teamName.toLowerCase();
try {
// 2 - ADMIN DCB-ADMIN crée DCB-SUPPORT et DCB-PARTNER pour cette équipe
const dcbAdmin = this.testUsers['dcb-admin'];
if (!dcbAdmin) {
throw new Error('DCB Admin not found for team tests');
}
// Créer DCB-SUPPORT
const supportData: CreateHubUserData = {
username: `test-${teamPrefix}-support-${Date.now()}`,
email: `test-${teamPrefix}-support-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Support',
password: 'TempPassword123!',
role: UserRole.DCB_SUPPORT,
enabled: true,
emailVerified: true,
createdBy: dcbAdmin.id,
};
const supportUser = await this.hubUsersService.createHubUser(dcbAdmin.id, supportData);
this.testUsers[`${teamPrefix}-support`] = {
id: supportUser.id,
username: supportUser.username,
role: UserRole.DCB_SUPPORT
};
// Créer DCB-PARTNER (Merchant Owner)
const partnerData: CreateHubUserData = {
username: `test-${teamPrefix}-partner-${Date.now()}`,
email: `test-${teamPrefix}-partner-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Partner',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER,
enabled: true,
emailVerified: true,
createdBy: dcbAdmin.id,
};
const partnerUser = await this.hubUsersService.createHubUser(dcbAdmin.id, partnerData);
this.testMerchants[`${teamPrefix}-partner`] = {
id: partnerUser.id,
username: partnerUser.username,
role: UserRole.DCB_PARTNER
};
results.push({
testName: `${teamName} - Admin creates Support and Partner`,
success: true,
duration: 0,
data: {
supportUser: supportUser.username,
partnerUser: partnerUser.username
}
});
// 3 - ADMIN DCB-ADMIN crée DCB-PARTNER-ADMIN avec merchantPartnerId du DCB-PARTNER
const partnerAdminData: CreateMerchantUserData = {
username: `test-${teamPrefix}-partner-admin-${Date.now()}`,
email: `test-${teamPrefix}-partner-admin-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Partner Admin',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER_ADMIN,
enabled: true,
emailVerified: true,
merchantPartnerId: partnerUser.id, // Utilise l'ID du DCB-PARTNER
createdBy: dcbAdmin.id,
};
const partnerAdminUser = await this.merchantUsersService.createMerchantUser(
dcbAdmin.id,
partnerAdminData
);
this.testMerchantUsers[`${teamPrefix}-partner-admin`] = {
id: partnerAdminUser.id,
username: partnerAdminUser.username,
role: UserRole.DCB_PARTNER_ADMIN,
merchantPartnerId: partnerUser.id
};
results.push({
testName: `${teamName} - Admin creates Partner Admin`,
success: true,
duration: 0,
data: {
partnerAdmin: partnerAdminUser.username,
merchantPartnerId: partnerUser.id
}
});
// 4 - DCB-PARTNER crée ses trois types d'utilisateurs
const partnerCreatedUsers = await this.testPartnerUserCreation(teamName, partnerUser.id);
results.push(...partnerCreatedUsers);
// 5 - DCB-PARTNER-ADMIN crée un manager
const adminCreatedManager = await this.testPartnerAdminCreatesManager(teamName, partnerUser.id);
results.push(adminCreatedManager);
} catch (error) {
results.push({
testName: `${teamName} - Team Tests`,
success: false,
duration: 0,
error: error.message
});
}
return results;
}
// Puis utilisez-le dans votre méthode
private async testPartnerUserCreation(teamName: string, partnerId: string): Promise<TestResult[]> {
const results: TestResult[] = [];
const teamPrefix = teamName.toLowerCase();
try {
const partner = this.testMerchants[`${teamPrefix}-partner`];
if (!partner) {
throw new Error(`${teamName} Partner not found`);
}
// Types d'utilisateurs à créer par le PARTNER
const userTypes: { role: MerchantUserRole; key: string }[] = [
{ role: UserRole.DCB_PARTNER_ADMIN, key: 'partner-admin-by-partner' },
{ role: UserRole.DCB_PARTNER_MANAGER, key: 'partner-manager-by-partner' },
{ role: UserRole.DCB_PARTNER_SUPPORT, key: 'partner-support-by-partner' }
];
for (const userType of userTypes) {
const userData: CreateMerchantUserData = {
username: `test-${teamPrefix}-${userType.key}-${Date.now()}`,
email: `test-${teamPrefix}-${userType.key}-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: userType.role.split('_').pop() || 'User',
password: 'TempPassword123!',
role: userType.role, // Type compatible maintenant
enabled: true,
emailVerified: true,
merchantPartnerId: partnerId,
createdBy: partner.id,
};
const user = await this.merchantUsersService.createMerchantUser(partner.id, userData);
this.testMerchantUsers[`${teamPrefix}-${userType.key}`] = {
id: user.id,
username: user.username,
role: userType.role,
merchantPartnerId: partnerId
};
results.push({
testName: `${teamName} - Partner creates ${userType.role}`,
success: true,
duration: 0,
data: {
createdUser: user.username,
role: userType.role,
merchantPartnerId: partnerId
}
});
}
} catch (error) {
results.push({
testName: `${teamName} - Partner User Creation`,
success: false,
duration: 0,
error: error.message
});
}
return results;
}
private async testPartnerAdminCreatesManager(teamName: string, partnerId: string): Promise<TestResult> {
const testName = `${teamName} - Partner Admin creates Manager`;
const teamPrefix = teamName.toLowerCase();
try {
const partnerAdmin = this.testMerchantUsers[`${teamPrefix}-partner-admin`];
if (!partnerAdmin) {
throw new Error(`${teamName} Partner Admin not found`);
}
// 5 - DCB-PARTNER-ADMIN crée un manager avec l'ID de son DCB-PARTNER
const managerData: CreateMerchantUserData = {
username: `test-${teamPrefix}-manager-by-admin-${Date.now()}`,
email: `test-${teamPrefix}-manager-by-admin-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Manager by Admin',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER_MANAGER,
enabled: true,
emailVerified: true,
merchantPartnerId: partnerId, // Utilise l'ID du DCB-PARTNER (pas son propre ID)
createdBy: partnerAdmin.id,
};
const managerUser = await this.merchantUsersService.createMerchantUser(
partnerAdmin.id,
managerData
);
this.testMerchantUsers[`${teamPrefix}-manager-by-admin`] = {
id: managerUser.id,
username: managerUser.username,
role: UserRole.DCB_PARTNER_MANAGER,
merchantPartnerId: partnerId
};
return {
testName,
success: true,
duration: 0,
data: {
createdManager: managerUser.username,
createdBy: partnerAdmin.username,
merchantPartnerId: partnerId
}
};
} catch (error) {
return {
testName,
success: false,
duration: 0,
error: error.message
};
}
}
private async testCrossTeamIsolation(): Promise<TestResult> {
const testName = 'Cross-Team Isolation Test';
const startTime = Date.now();
try {
const teamAPartnerAdmin = this.testMerchantUsers['teama-partner-admin'];
const teamBPartner = this.testMerchants['teamb-partner'];
if (!teamAPartnerAdmin || !teamBPartner) {
throw new Error('Team users not found for isolation test');
}
// Tenter de créer un utilisateur dans l'autre équipe - devrait échouer
try {
const crossTeamUserData: CreateMerchantUserData = {
username: `test-cross-team-attempt-${Date.now()}`,
email: `test-cross-team-attempt-${Date.now()}@dcb-test.com`,
firstName: 'Cross',
lastName: 'Team Attempt',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER_MANAGER,
enabled: true,
emailVerified: true,
merchantPartnerId: teamBPartner.id, // ID d'une autre équipe
createdBy: teamAPartnerAdmin.id,
};
await this.merchantUsersService.createMerchantUser(
teamAPartnerAdmin.id,
crossTeamUserData
);
// Si on arrive ici, l'isolation a échoué
throw new Error('Isolation failed - User from TeamA could create user in TeamB');
} catch (error) {
// Comportement attendu - l'accès doit être refusé
if (error.message.includes('Forbidden') ||
error.message.includes('Insufficient permissions') ||
error.message.includes('not authorized') ||
error.message.includes('own merchant')) {
// Succès - l'isolation fonctionne
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: { isolationWorking: true }
};
} else {
// Erreur inattendue
throw error;
}
}
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
// ===== TESTS AVANCÉS (conservés depuis la version originale) =====
private async testStatsAndReports(): Promise<TestResult> {
const testName = 'Stats and Reports Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
const stats = await this.hubUsersService.getHubUsersStats(serviceAccountId);
const activity = await this.hubUsersService.getHubUserActivity(serviceAccountId);
const sessions = await this.hubUsersService.getActiveHubSessions(serviceAccountId);
// Validation basique des stats
if (typeof stats.totalAdmins !== 'number' || typeof stats.totalSupport !== 'number') {
throw new Error('Stats validation failed');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: {
stats,
activityCount: activity.length,
sessionCount: sessions.length
}
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
private async testHealthCheck(): Promise<TestResult> {
const testName = 'Health Check Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
const health = await this.hubUsersService.checkHubUsersHealth(serviceAccountId);
if (!health.status || !health.stats || !Array.isArray(health.issues)) {
throw new Error('Health check validation failed');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: { healthStatus: health.status }
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
private async testSecurityValidations(): Promise<TestResult> {
const testName = 'Security Validations Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
// Test de la méthode canUserManageHubUsers
const canManage = await this.hubUsersService.canUserManageHubUsers(serviceAccountId);
if (!canManage) {
throw new Error('Service account should be able to manage hub users');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: { canManageHubUsers: canManage }
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
// ===== NETTOYAGE =====
private async cleanupTestUsers(): Promise<void> {
this.logger.log('🧹 Cleaning up test users...');
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
// Nettoyer les utilisateurs hub
for (const [key, userInfo] of Object.entries(this.testUsers)) {
try {
await this.hubUsersService.deleteHubUser(userInfo.id, serviceAccountId);
this.logger.log(`✅ Deleted test user: ${key} (${userInfo.username})`);
} catch (error) {
this.logger.warn(`⚠️ Could not delete test user ${key}: ${error.message}`);
}
}
this.testUsers = {};
}
private async cleanupTestMerchants(): Promise<void> {
this.logger.log('🧹 Cleaning up test merchants...');
// Implémentez la logique de nettoyage des merchants de test
this.testMerchants = {};
this.testMerchantUsers = {};
}
// ===== LOGGING ET RAPPORTS =====
private logTestSummary(summary: StartupTestSummary): void {
this.logger.log('='.repeat(60));
this.logger.log('🎯 PARALLEL ISOLATION STARTUP TEST SUMMARY');
this.logger.log('='.repeat(60));
this.logger.log(`📊 Total Tests: ${summary.totalTests}`);
this.logger.log(`✅ Passed: ${summary.passedTests}`);
this.logger.log(`❌ Failed: ${summary.failedTests}`);
this.logger.log(`⏱️ Total Duration: ${summary.totalDuration}ms`);
this.logger.log('-'.repeat(60));
summary.results.forEach(result => {
const status = result.success ? '✅' : '❌';
this.logger.log(`${status} ${result.testName}: ${result.duration}ms`);
if (!result.success) {
this.logger.log(` ERROR: ${result.error}`);
}
});
this.logger.log('='.repeat(60));
if (summary.failedTests === 0) {
this.logger.log('🚀 ALL TESTS PASSED! System is ready with proper isolation.');
} else {
this.logger.warn(`⚠️ ${summary.failedTests} test(s) failed. Please check the logs above.`);
}
}
// ===== MÉTHODES PUBLIQUES POUR USAGE MANUEL =====
async runQuickTest(): Promise<StartupTestSummary> {
this.logger.log('🔍 Running quick startup test...');
return this.runAllTests();
}
async getTestStatus(): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; details: string }> {
try {
const summary = await this.runAllTests();
const successRate = (summary.passedTests / summary.totalTests) * 100;
if (successRate === 100) {
return { status: 'healthy', details: 'All tests passed successfully' };
} else if (successRate >= 80) {
return { status: 'degraded', details: `${summary.failedTests} test(s) failed` };
} else {
return { status: 'unhealthy', details: 'Multiple test failures detected' };
}
} catch (error) {
return { status: 'unhealthy', details: `Test execution failed: ${error.message}` };
}
}
}

View File

@ -1,42 +1,706 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { KeycloakApiService } from './keycloak-api.service'; import { HubUsersService} from '../../hub-users/services/hub-users.service';
import { MerchantUsersService, CreateMerchantUserData } from '../../hub-users/services/merchant-users.service';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { TokenService } from '../../auth/services/token.service';
import { UserRole, CreateHubUserData } from '../../auth/services/keycloak-user.model';
export interface TestResult {
testName: string;
success: boolean;
duration: number;
error?: string;
data?: any;
}
export interface StartupTestSummary {
totalTests: number;
passedTests: number;
failedTests: number;
totalDuration: number;
results: TestResult[];
healthStatus?: any;
}
type HubUserRole =
| UserRole.DCB_ADMIN
| UserRole.DCB_SUPPORT
| UserRole.DCB_PARTNER;
type MerchantUserRole =
| UserRole.DCB_PARTNER_ADMIN
| UserRole.DCB_PARTNER_MANAGER
| UserRole.DCB_PARTNER_SUPPORT;
@Injectable() @Injectable()
export class StartupService implements OnModuleInit { export class StartupService implements OnModuleInit {
private readonly logger = new Logger(StartupService.name); private readonly logger = new Logger(StartupService.name);
private initialized = false;
private error: string | null = null;
constructor(private readonly keycloakApiService: KeycloakApiService) {} // Stockage des données de test
private testUsers: { [key: string]: { id: string; username: string; role: UserRole } } = {};
private testMerchants: { [key: string]: { id: string; username: string; role: UserRole } } = {};
private testMerchantUsers: { [key: string]: { id: string; username: string; role: UserRole; merchantPartnerId: string } } = {};
constructor(
private readonly hubUsersService: HubUsersService,
private readonly merchantUsersService: MerchantUsersService,
private readonly keycloakApi: KeycloakApiService,
private readonly tokenService: TokenService,
) {}
async onModuleInit() { async onModuleInit() {
this.logger.log('Vérification de la disponibilité de Keycloak...'); if (process.env.RUN_STARTUP_TESTS === 'true') {
this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...');
await this.runAllTests();
}
}
// ===== MÉTHODES DE TEST PRINCIPALES =====
async runAllTests(): Promise<StartupTestSummary> {
const results: TestResult[] = [];
const startTime = Date.now();
try { try {
const available = await this.keycloakApiService.checkKeycloakAvailability(); // 1. Tests de base
if (!available) throw new Error('Keycloak non accessible'); results.push(await this.testKeycloakConnection());
results.push(await this.testServiceAccountPermissions());
const serviceConnected = await this.keycloakApiService.checkServiceConnection(); // 2. Tests de création en parallèle avec isolation
if (!serviceConnected) throw new Error('Échec de la connexion du service à Keycloak'); const parallelTests = await this.runParallelIsolationTests();
results.push(...parallelTests);
this.initialized = true; // 3. Tests avancés
this.logger.log('Keycloak disponible et connexion du service réussie'); results.push(await this.testStatsAndReports());
} catch (err: any) { results.push(await this.testHealthCheck());
this.error = err.message; results.push(await this.testSecurityValidations());
this.logger.error('Échec de la vérification de Keycloak', err);
} catch (error) {
this.logger.error('Critical error during startup tests:', error);
} finally {
await this.cleanupTestUsers();
await this.cleanupTestMerchants();
}
const totalDuration = Date.now() - startTime;
const passedTests = results.filter(r => r.success).length;
const failedTests = results.filter(r => !r.success).length;
const summary: StartupTestSummary = {
totalTests: results.length,
passedTests,
failedTests,
totalDuration,
results,
};
this.logTestSummary(summary);
return summary;
}
// ===== TESTS DE BASE =====
private async testKeycloakConnection(): Promise<TestResult> {
const testName = 'Keycloak Connection Test';
const startTime = Date.now();
try {
const token = await this.tokenService.acquireServiceAccountToken();
const isValid = await this.tokenService.validateToken(token);
if (!isValid) {
throw new Error('Service account token validation failed');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return { testName, success: true, duration };
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
} }
} }
getStatus() { private async testServiceAccountPermissions(): Promise<TestResult> {
const testName = 'Service Account Permissions Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
if (!serviceAccountId) {
throw new Error('Could not extract service account ID from token');
}
// Vérifier les rôles du service account
const roles = await this.keycloakApi.getUserClientRoles(serviceAccountId);
const roleNames = roles.map(r => r.name);
this.logger.log(`Service account roles: ${roleNames.join(', ')}`);
// Le service account doit avoir au moins DCB_ADMIN pour créer des utilisateurs
const hasRequiredRole = roleNames.some(role =>
[UserRole.DCB_ADMIN].includes(role as UserRole)
);
if (!hasRequiredRole) {
throw new Error(`Service account missing required roles. Has: ${roleNames.join(', ')}, Needs: ${UserRole.DCB_ADMIN}`);
}
// 1 - Service Account crée un ADMIN DCB-ADMIN
const adminData: CreateHubUserData = {
username: `test-dcb-admin-${Date.now()}`,
email: `test-dcb-admin-${Date.now()}@dcb-test.com`,
firstName: 'Test',
lastName: 'DCB Admin',
password: 'TempPassword123!',
role: UserRole.DCB_ADMIN,
enabled: true,
emailVerified: true,
createdBy: 'service-account',
};
const adminUser = await this.hubUsersService.createHubUser(serviceAccountId, adminData);
this.testUsers['dcb-admin'] = {
id: adminUser.id,
username: adminUser.username,
role: UserRole.DCB_ADMIN
};
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return { return {
status: this.initialized ? 'healthy' : 'unhealthy', testName,
keycloakConnected: this.initialized, success: true,
timestamp: new Date(), duration,
error: this.error, data: {
serviceAccountId,
roles: roleNames,
createdAdmin: adminUser.username
}
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
// ===== TESTS PARALLÈLES AVEC ISOLATION =====
private async runParallelIsolationTests(): Promise<TestResult[]> {
const results: TestResult[] = [];
try {
// Exécuter les tests pour deux merchants différents en parallèle
const [teamAResults, teamBResults] = await Promise.all([
this.runMerchantTeamTests('TeamA'),
this.runMerchantTeamTests('TeamB')
]);
results.push(...teamAResults);
results.push(...teamBResults);
// Test d'isolation entre les deux équipes
results.push(await this.testCrossTeamIsolation());
} catch (error) {
this.logger.error(`Parallel isolation tests failed: ${error.message}`);
results.push({
testName: 'Parallel Isolation Tests',
success: false,
duration: 0,
error: error.message
});
}
return results;
}
private async runMerchantTeamTests(teamName: string): Promise<TestResult[]> {
const results: TestResult[] = [];
const teamPrefix = teamName.toLowerCase();
try {
// 2 - ADMIN DCB-ADMIN crée DCB-SUPPORT et DCB-PARTNER pour cette équipe
const dcbAdmin = this.testUsers['dcb-admin'];
if (!dcbAdmin) {
throw new Error('DCB Admin not found for team tests');
}
// Créer DCB-SUPPORT
const supportData: CreateHubUserData = {
username: `test-${teamPrefix}-support-${Date.now()}`,
email: `test-${teamPrefix}-support-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Support',
password: 'TempPassword123!',
role: UserRole.DCB_SUPPORT,
enabled: true,
emailVerified: true,
createdBy: dcbAdmin.id,
};
const supportUser = await this.hubUsersService.createHubUser(dcbAdmin.id, supportData);
this.testUsers[`${teamPrefix}-support`] = {
id: supportUser.id,
username: supportUser.username,
role: UserRole.DCB_SUPPORT
};
// Créer DCB-PARTNER (Merchant Owner)
const partnerData: CreateHubUserData = {
username: `test-${teamPrefix}-partner-${Date.now()}`,
email: `test-${teamPrefix}-partner-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Partner',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER,
enabled: true,
emailVerified: true,
createdBy: dcbAdmin.id,
};
const partnerUser = await this.hubUsersService.createHubUser(dcbAdmin.id, partnerData);
this.testMerchants[`${teamPrefix}-partner`] = {
id: partnerUser.id,
username: partnerUser.username,
role: UserRole.DCB_PARTNER
};
results.push({
testName: `${teamName} - Admin creates Support and Partner`,
success: true,
duration: 0,
data: {
supportUser: supportUser.username,
partnerUser: partnerUser.username
}
});
// 3 - ADMIN DCB-ADMIN crée DCB-PARTNER-ADMIN avec merchantPartnerId du DCB-PARTNER
const partnerAdminData: CreateMerchantUserData = {
username: `test-${teamPrefix}-partner-admin-${Date.now()}`,
email: `test-${teamPrefix}-partner-admin-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Partner Admin',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER_ADMIN,
enabled: true,
emailVerified: true,
merchantPartnerId: partnerUser.id, // Utilise l'ID du DCB-PARTNER
createdBy: dcbAdmin.id,
};
const partnerAdminUser = await this.merchantUsersService.createMerchantUser(
dcbAdmin.id,
partnerAdminData
);
this.testMerchantUsers[`${teamPrefix}-partner-admin`] = {
id: partnerAdminUser.id,
username: partnerAdminUser.username,
role: UserRole.DCB_PARTNER_ADMIN,
merchantPartnerId: partnerUser.id
};
results.push({
testName: `${teamName} - Admin creates Partner Admin`,
success: true,
duration: 0,
data: {
partnerAdmin: partnerAdminUser.username,
merchantPartnerId: partnerUser.id
}
});
// 4 - DCB-PARTNER crée ses trois types d'utilisateurs
const partnerCreatedUsers = await this.testPartnerUserCreation(teamName, partnerUser.id);
results.push(...partnerCreatedUsers);
// 5 - DCB-PARTNER-ADMIN crée un manager
const adminCreatedManager = await this.testPartnerAdminCreatesManager(teamName, partnerUser.id);
results.push(adminCreatedManager);
} catch (error) {
results.push({
testName: `${teamName} - Team Tests`,
success: false,
duration: 0,
error: error.message
});
}
return results;
}
// Puis utilisez-le dans votre méthode
private async testPartnerUserCreation(teamName: string, partnerId: string): Promise<TestResult[]> {
const results: TestResult[] = [];
const teamPrefix = teamName.toLowerCase();
try {
const partner = this.testMerchants[`${teamPrefix}-partner`];
if (!partner) {
throw new Error(`${teamName} Partner not found`);
}
// Types d'utilisateurs à créer par le PARTNER
const userTypes: { role: MerchantUserRole; key: string }[] = [
{ role: UserRole.DCB_PARTNER_ADMIN, key: 'partner-admin-by-partner' },
{ role: UserRole.DCB_PARTNER_MANAGER, key: 'partner-manager-by-partner' },
{ role: UserRole.DCB_PARTNER_SUPPORT, key: 'partner-support-by-partner' }
];
for (const userType of userTypes) {
const userData: CreateMerchantUserData = {
username: `test-${teamPrefix}-${userType.key}-${Date.now()}`,
email: `test-${teamPrefix}-${userType.key}-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: userType.role.split('_').pop() || 'User',
password: 'TempPassword123!',
role: userType.role, // Type compatible maintenant
enabled: true,
emailVerified: true,
merchantPartnerId: partnerId,
createdBy: partner.id,
};
const user = await this.merchantUsersService.createMerchantUser(partner.id, userData);
this.testMerchantUsers[`${teamPrefix}-${userType.key}`] = {
id: user.id,
username: user.username,
role: userType.role,
merchantPartnerId: partnerId
};
results.push({
testName: `${teamName} - Partner creates ${userType.role}`,
success: true,
duration: 0,
data: {
createdUser: user.username,
role: userType.role,
merchantPartnerId: partnerId
}
});
}
} catch (error) {
results.push({
testName: `${teamName} - Partner User Creation`,
success: false,
duration: 0,
error: error.message
});
}
return results;
}
private async testPartnerAdminCreatesManager(teamName: string, partnerId: string): Promise<TestResult> {
const testName = `${teamName} - Partner Admin creates Manager`;
const teamPrefix = teamName.toLowerCase();
try {
const partnerAdmin = this.testMerchantUsers[`${teamPrefix}-partner-admin`];
if (!partnerAdmin) {
throw new Error(`${teamName} Partner Admin not found`);
}
// 5 - DCB-PARTNER-ADMIN crée un manager avec l'ID de son DCB-PARTNER
const managerData: CreateMerchantUserData = {
username: `test-${teamPrefix}-manager-by-admin-${Date.now()}`,
email: `test-${teamPrefix}-manager-by-admin-${Date.now()}@dcb-test.com`,
firstName: `${teamName}`,
lastName: 'Manager by Admin',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER_MANAGER,
enabled: true,
emailVerified: true,
merchantPartnerId: partnerId, // Utilise l'ID du DCB-PARTNER (pas son propre ID)
createdBy: partnerAdmin.id,
};
const managerUser = await this.merchantUsersService.createMerchantUser(
partnerAdmin.id,
managerData
);
this.testMerchantUsers[`${teamPrefix}-manager-by-admin`] = {
id: managerUser.id,
username: managerUser.username,
role: UserRole.DCB_PARTNER_MANAGER,
merchantPartnerId: partnerId
};
return {
testName,
success: true,
duration: 0,
data: {
createdManager: managerUser.username,
createdBy: partnerAdmin.username,
merchantPartnerId: partnerId
}
};
} catch (error) {
return {
testName,
success: false,
duration: 0,
error: error.message
}; };
} }
}
isHealthy(): boolean { private async testCrossTeamIsolation(): Promise<TestResult> {
return this.initialized; const testName = 'Cross-Team Isolation Test';
const startTime = Date.now();
try {
const teamAPartnerAdmin = this.testMerchantUsers['teama-partner-admin'];
const teamBPartner = this.testMerchants['teamb-partner'];
if (!teamAPartnerAdmin || !teamBPartner) {
throw new Error('Team users not found for isolation test');
}
// Tenter de créer un utilisateur dans l'autre équipe - devrait échouer
try {
const crossTeamUserData: CreateMerchantUserData = {
username: `test-cross-team-attempt-${Date.now()}`,
email: `test-cross-team-attempt-${Date.now()}@dcb-test.com`,
firstName: 'Cross',
lastName: 'Team Attempt',
password: 'TempPassword123!',
role: UserRole.DCB_PARTNER_MANAGER,
enabled: true,
emailVerified: true,
merchantPartnerId: teamBPartner.id, // ID d'une autre équipe
createdBy: teamAPartnerAdmin.id,
};
await this.merchantUsersService.createMerchantUser(
teamAPartnerAdmin.id,
crossTeamUserData
);
// Si on arrive ici, l'isolation a échoué
throw new Error('Isolation failed - User from TeamA could create user in TeamB');
} catch (error) {
// Comportement attendu - l'accès doit être refusé
if (error.message.includes('Forbidden') ||
error.message.includes('Insufficient permissions') ||
error.message.includes('not authorized') ||
error.message.includes('own merchant')) {
// Succès - l'isolation fonctionne
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: { isolationWorking: true }
};
} else {
// Erreur inattendue
throw error;
}
}
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
// ===== TESTS AVANCÉS (conservés depuis la version originale) =====
private async testStatsAndReports(): Promise<TestResult> {
const testName = 'Stats and Reports Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
const stats = await this.hubUsersService.getHubUsersStats(serviceAccountId);
const activity = await this.hubUsersService.getHubUserActivity(serviceAccountId);
const sessions = await this.hubUsersService.getActiveHubSessions(serviceAccountId);
// Validation basique des stats
if (typeof stats.totalAdmins !== 'number' || typeof stats.totalSupport !== 'number') {
throw new Error('Stats validation failed');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: {
stats,
activityCount: activity.length,
sessionCount: sessions.length
}
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
private async testHealthCheck(): Promise<TestResult> {
const testName = 'Health Check Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
const health = await this.hubUsersService.checkHubUsersHealth(serviceAccountId);
if (!health.status || !health.stats || !Array.isArray(health.issues)) {
throw new Error('Health check validation failed');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: { healthStatus: health.status }
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
private async testSecurityValidations(): Promise<TestResult> {
const testName = 'Security Validations Test';
const startTime = Date.now();
try {
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
// Test de la méthode canUserManageHubUsers
const canManage = await this.hubUsersService.canUserManageHubUsers(serviceAccountId);
if (!canManage) {
throw new Error('Service account should be able to manage hub users');
}
const duration = Date.now() - startTime;
this.logger.log(`${testName} - Success (${duration}ms)`);
return {
testName,
success: true,
duration,
data: { canManageHubUsers: canManage }
};
} catch (error) {
const duration = Date.now() - startTime;
this.logger.error(`${testName} - Failed: ${error.message}`);
return { testName, success: false, duration, error: error.message };
}
}
// ===== NETTOYAGE =====
private async cleanupTestUsers(): Promise<void> {
this.logger.log('🧹 Cleaning up test users...');
const serviceToken = await this.tokenService.acquireServiceAccountToken();
const decodedToken = this.tokenService.decodeToken(serviceToken);
const serviceAccountId = decodedToken.sub;
// Nettoyer les utilisateurs hub
for (const [key, userInfo] of Object.entries(this.testUsers)) {
try {
await this.hubUsersService.deleteHubUser(userInfo.id, serviceAccountId);
this.logger.log(`✅ Deleted test user: ${key} (${userInfo.username})`);
} catch (error) {
this.logger.warn(`⚠️ Could not delete test user ${key}: ${error.message}`);
}
}
this.testUsers = {};
}
private async cleanupTestMerchants(): Promise<void> {
this.logger.log('🧹 Cleaning up test merchants...');
// Implémentez la logique de nettoyage des merchants de test
this.testMerchants = {};
this.testMerchantUsers = {};
}
// ===== LOGGING ET RAPPORTS =====
private logTestSummary(summary: StartupTestSummary): void {
this.logger.log('='.repeat(60));
this.logger.log('🎯 PARALLEL ISOLATION STARTUP TEST SUMMARY');
this.logger.log('='.repeat(60));
this.logger.log(`📊 Total Tests: ${summary.totalTests}`);
this.logger.log(`✅ Passed: ${summary.passedTests}`);
this.logger.log(`❌ Failed: ${summary.failedTests}`);
this.logger.log(`⏱️ Total Duration: ${summary.totalDuration}ms`);
this.logger.log('-'.repeat(60));
summary.results.forEach(result => {
const status = result.success ? '✅' : '❌';
this.logger.log(`${status} ${result.testName}: ${result.duration}ms`);
if (!result.success) {
this.logger.log(` ERROR: ${result.error}`);
}
});
this.logger.log('='.repeat(60));
if (summary.failedTests === 0) {
this.logger.log('🚀 ALL TESTS PASSED! System is ready with proper isolation.');
} else {
this.logger.warn(`⚠️ ${summary.failedTests} test(s) failed. Please check the logs above.`);
}
}
// ===== MÉTHODES PUBLIQUES POUR USAGE MANUEL =====
async runQuickTest(): Promise<StartupTestSummary> {
this.logger.log('🔍 Running quick startup test...');
return this.runAllTests();
}
async getTestStatus(): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; details: string }> {
try {
const summary = await this.runAllTests();
const successRate = (summary.passedTests / summary.totalTests) * 100;
if (successRate === 100) {
return { status: 'healthy', details: 'All tests passed successfully' };
} else if (successRate >= 80) {
return { status: 'degraded', details: `${summary.failedTests} test(s) failed` };
} else {
return { status: 'unhealthy', details: 'Multiple test failures detected' };
}
} catch (error) {
return { status: 'unhealthy', details: `Test execution failed: ${error.message}` };
}
} }
} }

View File

@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
@ -10,11 +10,27 @@ export interface KeycloakTokenResponse {
refresh_token?: string; refresh_token?: string;
expires_in: number; expires_in: number;
token_type: string; token_type: string;
refresh_expire_in?: number;
scope?: string; scope?: string;
} }
export interface DecodedToken {
sub: string;
email?: string;
preferred_username?: string;
given_name?: string;
family_name?: string;
realm_access?: { roles: string[] };
resource_access?: { [key: string]: { roles: string[] } };
merchantPartnerId?: string;
// Ajout des claims personnalisés
'merchant-partner-id'?: string;
'user-type'?: string;
}
@Injectable() @Injectable()
export class TokenService { export class TokenService implements OnModuleInit {
private readonly logger = new Logger(TokenService.name); private readonly logger = new Logger(TokenService.name);
private readonly keycloakConfig: KeycloakConfig; private readonly keycloakConfig: KeycloakConfig;
@ -22,11 +38,16 @@ export class TokenService {
private serviceAccountToken: string | null = null; private serviceAccountToken: string | null = null;
private serviceTokenExpiry: number = 0; private serviceTokenExpiry: number = 0;
// === TOKEN STORAGE === // === TOKEN STORAGE ===
private userToken: string | null = null; private userToken: string | null = null;
private userTokenExpiry: Date | null = null; private userTokenExpiry: Date | null = null;
private userRefreshToken: string | null = null; private userRefreshToken: string | null = null;
// Cache pour les clés publiques
private publicKeys: { [key: string]: string } = {};
private keysLastFetched: number = 0;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly httpService: HttpService, private readonly httpService: HttpService,
@ -34,6 +55,10 @@ export class TokenService {
this.keycloakConfig = this.getKeycloakConfig(); this.keycloakConfig = this.getKeycloakConfig();
} }
async onModuleInit() {
await this.fetchPublicKeys();
}
// === CONFIGURATION === // === CONFIGURATION ===
private getKeycloakConfig(): KeycloakConfig { private getKeycloakConfig(): KeycloakConfig {
const config = this.configService.get<KeycloakConfig>('keycloak'); const config = this.configService.get<KeycloakConfig>('keycloak');
@ -47,6 +72,32 @@ export class TokenService {
return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`; 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<void> {
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 === // === CACHE MANAGEMENT ===
private isServiceTokenValid(): boolean { private isServiceTokenValid(): boolean {
if (!this.serviceAccountToken) return false; if (!this.serviceAccountToken) return false;
@ -62,7 +113,6 @@ export class TokenService {
// === TOKEN ACQUISITION === // === TOKEN ACQUISITION ===
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> { async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'password', grant_type: 'password',
client_id: this.keycloakConfig.authClientId, client_id: this.keycloakConfig.authClientId,
@ -228,30 +278,7 @@ export class TokenService {
} }
} }
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> { // === TOKEN VALIDATION AMÉLIORÉE ===
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<KeycloakTokenResponse>(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 ===
async validateToken(token: string): Promise<boolean> { async validateToken(token: string): Promise<boolean> {
const mode = this.keycloakConfig.validationMode || 'online'; const mode = this.keycloakConfig.validationMode || 'online';
@ -283,37 +310,170 @@ export class TokenService {
} }
} }
private validateOffline(token: string): boolean { private async validateOffline(token: string): Promise<boolean> {
if (!this.keycloakConfig.publicKey) { if (this.shouldRefreshKeys()) {
this.logger.error('Missing public key for offline validation'); await this.fetchPublicKeys();
return false;
} }
try { 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, { jwt.verify(token, formattedKey, {
algorithms: ['RS256'], algorithms: ['RS256'],
audience: this.keycloakConfig.authClientId, //audience: this.keycloakConfig.authClientId,
issuer: `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}`,
ignoreExpiration: false,
ignoreNotBefore: false,
}); });
return true; return true;
} catch (error: any) { } catch (error: any) {
this.logger.error('Offline token validation failed:', error.message); this.logger.error('Offline token validation failed:', error.message);
// Fallback: validation basique du token
try {
const decoded = this.decodeToken(token);
return !!decoded && !!decoded.sub;
} catch {
return false; return false;
} }
} }
}
// === TOKEN UTILITIES === private formatPublicKey(key: any): string {
decodeToken(token: string): any { 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 { 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) { } catch (error: any) {
this.logger.error('Failed to decode token', error.message); this.logger.error('Failed to decode token', error.message);
throw new Error('Invalid token format'); 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<KeycloakTokenResponse> {
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<KeycloakTokenResponse>(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<void> { async revokeToken(token: string): Promise<void> {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: this.keycloakConfig.authClientId, client_id: this.keycloakConfig.authClientId,
@ -337,7 +497,6 @@ export class TokenService {
} }
} }
// === SERVICE MANAGEMENT ===
clearServiceToken(): void { clearServiceToken(): void {
this.serviceAccountToken = null; this.serviceAccountToken = null;
this.serviceTokenExpiry = 0; this.serviceTokenExpiry = 0;

View File

@ -5,10 +5,6 @@ export interface KeycloakConfig {
serverUrl: string; serverUrl: string;
realm: string; realm: string;
publicKey?: 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; authClientId: string;
authClientSecret: string; authClientSecret: string;
validationMode: string; validationMode: string;
@ -16,14 +12,10 @@ export interface KeycloakConfig {
} }
export default registerAs('keycloak', (): KeycloakConfig => ({ export default registerAs('keycloak', (): KeycloakConfig => ({
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://keycloak-dcb.app.cameleonapp.com', serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://iam.dcb.pixpay.sn',
realm: process.env.KEYCLOAK_REALM || 'dcb-dev', realm: process.env.KEYCLOAK_REALM || 'dcb-prod',
publicKey: process.env.KEYCLOAK_PUBLIC_KEY, publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
// Client pour Service Account (API Admin) authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-cc-app',
//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',
authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '', authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online', validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30, 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' '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() KEYCLOAK_CLIENT_ID: Joi.string()
.required() .required()
.messages({ .messages({

View File

@ -1,4 +0,0 @@
export const RESOURCES = {
USER: 'user', // user resource for /users/* endpoints
MERCHANT: 'merchants' // merchant resource for /merchants/* endpoints
};

View File

@ -0,0 +1,4 @@
export const RESOURCES = {
HUB_USER: 'user', // user resource for /users/* endpoints
MERCHANT_USER: 'partner' // merchant resource for /merchants/* endpoints
};

View File

@ -0,0 +1,673 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
ParseUUIDPipe,
BadRequestException
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
ApiProperty
} from '@nestjs/swagger';
import { HubUsersService } from '../services/hub-users.service';
import { UserRole, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model';
import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
import { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes';
import { Resource, Scopes } from 'nest-keycloak-connect';
export class LoginDto {
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Password' })
password: string;
}
export class TokenResponseDto {
@ApiProperty({ description: 'Access token' })
access_token: string;
@ApiProperty({ description: 'Refresh token' })
refresh_token?: string;
@ApiProperty({ description: 'Token type' })
token_type: string;
@ApiProperty({ description: 'Expires in (seconds)' })
expires_in: number;
@ApiProperty({ description: 'Refresh expires in (seconds)' })
refresh_expires_in?: number;
@ApiProperty({ description: 'Scope' })
scope?: string;
}
// DTOs pour les utilisateurs Hub
export class CreateHubUserDto {
@ApiProperty({ description: 'Username for the hub user' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({ description: 'Password for the user' })
password: string;
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'Role for the hub user'
})
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
@ApiProperty({ required: false, default: true })
enabled?: boolean = true;
@ApiProperty({ required: false, default: false })
emailVerified?: boolean = false;
}
export class UpdateHubUserDto {
@ApiProperty({ required: false })
firstName?: string;
@ApiProperty({ required: false })
lastName?: string;
@ApiProperty({ required: false })
email?: string;
@ApiProperty({ required: false })
enabled?: boolean;
}
export class UpdateUserRoleDto {
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'New role for the user'
})
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
}
export class ResetPasswordDto {
@ApiProperty({ description: 'New password' })
newPassword: string;
@ApiProperty({ required: false, default: true })
temporary?: boolean = true;
}
export class SuspendMerchantDto {
@ApiProperty({ description: 'Reason for suspension' })
reason: string;
}
// DTOs pour les réponses
export class HubUserResponse {
@ApiProperty({ description: 'User ID' })
id: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'User role'
})
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ description: 'User creator ID' })
createdBy: string;
@ApiProperty({ description: 'User creator username' })
createdByUsername: string;
@ApiProperty({ description: 'Creation timestamp' })
createdTimestamp: number;
@ApiProperty({ required: false, description: 'Last login timestamp' })
lastLogin?: number;
@ApiProperty({ enum: ['HUB'], description: 'User type' })
userType: 'HUB';
}
export class HubUsersStatsResponse {
@ApiProperty({ description: 'Total admin users' })
totalAdmins: number;
@ApiProperty({ description: 'Total support users' })
totalSupport: number;
@ApiProperty({ description: 'Active users count' })
activeUsers: number;
@ApiProperty({ description: 'Inactive users count' })
inactiveUsers: number;
@ApiProperty({ description: 'Users pending activation' })
pendingActivation: number;
}
export class MerchantStatsResponse {
@ApiProperty({ description: 'Total merchants' })
totalMerchants: number;
@ApiProperty({ description: 'Active merchants count' })
activeMerchants: number;
@ApiProperty({ description: 'Suspended merchants count' })
suspendedMerchants: number;
@ApiProperty({ description: 'Pending merchants count' })
pendingMerchants: number;
@ApiProperty({ description: 'Total merchant users' })
totalUsers: number;
}
export class HealthStatusResponse {
@ApiProperty({ enum: ['healthy', 'degraded', 'unhealthy'] })
status: string;
@ApiProperty({ type: [String], description: 'Health issues detected' })
issues: string[];
@ApiProperty({ description: 'System statistics' })
stats: HubUsersStatsResponse;
}
export class UserActivityResponse {
@ApiProperty({ description: 'User information' })
user: HubUserResponse;
@ApiProperty({ required: false, description: 'Last login date' })
lastLogin?: Date;
}
export class SessionResponse {
@ApiProperty({ description: 'User ID' })
userId: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Last access date' })
lastAccess: Date;
}
export class PermissionResponse {
@ApiProperty({ description: 'Whether user can manage hub users' })
canManageHubUsers: boolean;
}
export class AvailableRolesResponse {
@ApiProperty({
type: [Object],
description: 'Available roles'
})
roles: Array<{
value: UserRole;
label: string;
description: string;
}>;
}
export class MessageResponse {
@ApiProperty({ description: 'Response message' })
message: string;
}
// Mapper functions
function mapToHubUserResponse(hubUser: HubUser): HubUserResponse {
return {
id: hubUser.id,
username: hubUser.username,
email: hubUser.email,
firstName: hubUser.firstName,
lastName: hubUser.lastName,
role: hubUser.role,
enabled: hubUser.enabled,
emailVerified: hubUser.emailVerified,
createdBy: hubUser.createdBy,
createdByUsername: hubUser.createdByUsername,
createdTimestamp: hubUser.createdTimestamp,
lastLogin: hubUser.lastLogin,
userType: hubUser.userType,
};
}
function mapToUserActivityResponse(activity: HubUserActivity): UserActivityResponse {
return {
user: mapToHubUserResponse(activity.user),
lastLogin: activity.lastLogin
};
}
@ApiTags('Hub Users')
@ApiBearerAuth()
@Controller('hub-users')
@Resource(RESOURCES.HUB_USER || RESOURCES.MERCHANT_USER)
export class HubUsersController {
constructor(private readonly hubUsersService: HubUsersService) {}
// ===== GESTION DES UTILISATEURS HUB =====
@Get()
@ApiOperation({
summary: 'Get all hub users',
description: 'Returns all hub users (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
})
@ApiResponse({
status: 200,
description: 'Hub users retrieved successfully',
type: [HubUserResponse]
})
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Scopes(SCOPES.READ)
async getAllHubUsers(@Request() req): Promise<HubUserResponse[]> {
const userId = req.user.sub;
const users = await this.hubUsersService.getAllHubUsers(userId);
return users.map(mapToHubUserResponse);
}
@Get('role/:role')
@ApiOperation({ summary: 'Get hub users by role' })
@ApiResponse({
status: 200,
description: 'Hub users retrieved successfully',
type: [HubUserResponse]
})
@ApiResponse({ status: 400, description: 'Invalid role' })
@ApiParam({ name: 'role', enum: UserRole, description: 'User role' })
@Scopes(SCOPES.READ)
async getHubUsersByRole(
@Param('role') role: UserRole,
@Request() req
): Promise<HubUserResponse[]> {
const userId = req.user.sub;
const validRole = this.hubUsersService.validateHubRoleFromString(role);
const users = await this.hubUsersService.getHubUsersByRole(validRole, userId);
return users.map(mapToHubUserResponse);
}
@Get(':id')
@ApiOperation({ summary: 'Get hub user by ID' })
@ApiResponse({
status: 200,
description: 'Hub user retrieved successfully',
type: HubUserResponse
})
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.READ)
async getHubUserById(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const user = await this.hubUsersService.getHubUserById(id, userId);
return mapToHubUserResponse(user);
}
@Post()
@ApiOperation({
summary: 'Create a new hub user',
description: 'Create a hub user with specific role (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
})
@ApiResponse({
status: 201,
description: 'Hub user created successfully',
type: HubUserResponse
})
@ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Scopes(SCOPES.WRITE)
async createHubUser(
@Body() createHubUserDto: CreateHubUserDto,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const userData: CreateHubUserData = {
...createHubUserDto,
createdBy: userId,
};
const user = await this.hubUsersService.createHubUser(userId, userData);
return mapToHubUserResponse(user);
}
@Put(':id')
@ApiOperation({ summary: 'Update a hub user' })
@ApiResponse({
status: 200,
description: 'Hub user updated successfully',
type: HubUserResponse
})
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async updateHubUser(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateHubUserDto: UpdateHubUserDto,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const user = await this.hubUsersService.updateHubUser(id, updateHubUserDto, userId);
return mapToHubUserResponse(user);
}
@Put(':id/role')
@ApiOperation({ summary: 'Update hub user role' })
@ApiResponse({
status: 200,
description: 'User role updated successfully',
type: HubUserResponse
})
@ApiResponse({ status: 403, description: 'Forbidden - only DCB_ADMIN can change roles' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async updateHubUserRole(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateRoleDto: UpdateUserRoleDto,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const user = await this.hubUsersService.updateHubUserRole(id, updateRoleDto.role, userId);
return mapToHubUserResponse(user);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a hub user' })
@ApiResponse({ status: 200, description: 'Hub user deleted successfully' })
@ApiResponse({ status: 400, description: 'Cannot delete own account or last admin' })
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.DELETE)
async deleteHubUser(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.hubUsersService.deleteHubUser(id, userId);
return { message: 'Hub user deleted successfully' };
}
// ===== GESTION DES MOTS DE PASSE =====
@Post(':id/reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset hub user password' })
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async resetHubUserPassword(
@Param('id', ParseUUIDPipe) id: string,
@Body() resetPasswordDto: ResetPasswordDto,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.hubUsersService.resetHubUserPassword(
id,
resetPasswordDto.newPassword,
resetPasswordDto.temporary,
userId
);
return { message: 'Password reset successfully' };
}
@Post(':id/send-reset-email')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send password reset email to hub user' })
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async sendHubUserPasswordResetEmail(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.hubUsersService.sendHubUserPasswordResetEmail(id, userId);
return { message: 'Password reset email sent successfully' };
}
// ===== GESTION DES MERCHANTS (DCB_PARTNER) =====
@Get('merchants/all')
@ApiOperation({ summary: 'Get all merchant partners' })
@ApiResponse({
status: 200,
description: 'Merchant partners retrieved successfully',
type: [HubUserResponse]
})
@Scopes(SCOPES.READ)
async getAllMerchants(@Request() req): Promise<HubUserResponse[]> {
const userId = req.user.sub;
const merchants = await this.hubUsersService.getAllMerchants(userId);
return merchants.map(mapToHubUserResponse);
}
@Get('merchants/:merchantId')
@ApiOperation({ summary: 'Get merchant partner by ID' })
@ApiResponse({
status: 200,
description: 'Merchant partner retrieved successfully',
type: HubUserResponse
})
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
@Scopes(SCOPES.READ)
async getMerchantPartnerById(
@Param('merchantId', ParseUUIDPipe) merchantId: string,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const merchant = await this.hubUsersService.getMerchantPartnerById(merchantId, userId);
return mapToHubUserResponse(merchant);
}
@Put('merchants/:merchantId')
@ApiOperation({ summary: 'Update a merchant partner' })
@ApiResponse({
status: 200,
description: 'Merchant partner updated successfully',
type: HubUserResponse
})
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
@Scopes(SCOPES.WRITE)
async updateMerchantPartner(
@Param('merchantId', ParseUUIDPipe) merchantId: string,
@Body() updateHubUserDto: UpdateHubUserDto,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const merchant = await this.hubUsersService.updateMerchantPartner(merchantId, updateHubUserDto, userId);
return mapToHubUserResponse(merchant);
}
@Post('merchants/:merchantId/suspend')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Suspend a merchant partner and all its users' })
@ApiResponse({ status: 200, description: 'Merchant partner suspended successfully' })
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
@Scopes(SCOPES.WRITE)
async suspendMerchantPartner(
@Param('merchantId', ParseUUIDPipe) merchantId: string,
@Body() suspendMerchantDto: SuspendMerchantDto,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.hubUsersService.suspendMerchantPartner(merchantId, suspendMerchantDto.reason, userId);
return { message: 'Merchant partner suspended successfully' };
}
// ===== STATISTIQUES ET RAPPORTS =====
@Get('stats/overview')
@ApiOperation({ summary: 'Get hub users statistics overview' })
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: HubUsersStatsResponse
})
@Scopes(SCOPES.READ)
async getHubUsersStats(@Request() req): Promise<HubUsersStatsResponse> {
const userId = req.user.sub;
return this.hubUsersService.getHubUsersStats(userId);
}
@Get('stats/merchants')
@ApiOperation({ summary: 'Get merchants statistics' })
@ApiResponse({
status: 200,
description: 'Merchants statistics retrieved successfully',
type: MerchantStatsResponse
})
@Scopes(SCOPES.READ)
async getMerchantStats(@Request() req): Promise<MerchantStatsResponse> {
const userId = req.user.sub;
return this.hubUsersService.getMerchantStats(userId);
}
@Get('activity/recent')
@ApiOperation({ summary: 'Get recent hub user activity' })
@ApiResponse({
status: 200,
description: 'Activity retrieved successfully',
type: [UserActivityResponse]
})
@Scopes(SCOPES.READ)
async getHubUserActivity(@Request() req): Promise<UserActivityResponse[]> {
const userId = req.user.sub;
const activities = await this.hubUsersService.getHubUserActivity(userId);
return activities.map(mapToUserActivityResponse);
}
@Get('sessions/active')
@ApiOperation({ summary: 'Get active hub sessions' })
@ApiResponse({
status: 200,
description: 'Active sessions retrieved successfully',
type: [SessionResponse]
})
@Scopes(SCOPES.READ)
async getActiveHubSessions(@Request() req): Promise<SessionResponse[]> {
const userId = req.user.sub;
const sessions = await this.hubUsersService.getActiveHubSessions(userId);
return sessions.map(session => ({
userId: session.userId,
username: session.username,
lastAccess: session.lastAccess
}));
}
// ===== SANTÉ ET UTILITAIRES =====
@Get('health/status')
@ApiOperation({ summary: 'Get hub users health status' })
@ApiResponse({
status: 200,
description: 'Health status retrieved successfully',
type: HealthStatusResponse
})
@Scopes(SCOPES.READ)
async checkHubUsersHealth(@Request() req): Promise<HealthStatusResponse> {
const userId = req.user.sub;
return this.hubUsersService.checkHubUsersHealth(userId);
}
@Get('me/permissions')
@ApiOperation({ summary: 'Check if current user can manage hub users' })
@ApiResponse({ status: 200, description: 'Permissions check completed' })
async canUserManageHubUsers(@Request() req): Promise<PermissionResponse> {
const userId = req.user.sub;
const canManage = await this.hubUsersService.canUserManageHubUsers(userId);
return { canManageHubUsers: canManage };
}
@Get('roles/available')
@ApiOperation({ summary: 'Get available hub roles' })
@ApiResponse({ status: 200, description: 'Available roles retrieved successfully' })
@Scopes(SCOPES.READ)
async getAvailableHubRoles(): Promise<AvailableRolesResponse> {
const roles = [
{
value: UserRole.DCB_ADMIN,
label: 'DCB Admin',
description: 'Full administrative access to the entire system'
},
{
value: UserRole.DCB_SUPPORT,
label: 'DCB Support',
description: 'Support access with limited administrative capabilities'
},
{
value: UserRole.DCB_PARTNER,
label: 'DCB Partner',
description: 'Merchant partner with access to their own merchant ecosystem'
}
];
return { roles };
}
}

View File

@ -0,0 +1,298 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
Request,
HttpStatus,
HttpCode,
Logger,
DefaultValuePipe,
ParseIntPipe,
BadRequestException,
} from '@nestjs/common';
import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
import {
MerchantPartnersService,
MerchantPartner,
CreateMerchantPartnerData,
MerchantStats,
} from '../services/merchant-partners.service';
import { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes';
// DTOs
import {
CreateMerchantPartnerDto,
UpdateMerchantPartnerDto,
SuspendMerchantPartnerDto,
} from '../dto/merchant-partners.dto';
export interface ApiResponse<T = any> {
success: boolean;
message: string;
data?: T;
timestamp: string;
}
export interface PaginatedResponse<T = any> {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
@Controller('partners')
@Resource(RESOURCES.MERCHANT_USER)
export class MerchantPartnersController {
private readonly logger = new Logger(MerchantPartnersController.name);
constructor(private readonly merchantPartnersService: MerchantPartnersService) {}
private createApiResponse<T>(
success: boolean,
message: string,
data?: T,
): ApiResponse<T> {
return {
success,
message,
data,
timestamp: new Date().toISOString(),
};
}
// ===== CRÉATION DE MERCHANT PARTNERS =====
@Post()
@Scopes(SCOPES.WRITE)
async createMerchantPartner(
@Body() createMerchantDto: CreateMerchantPartnerDto,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
this.logger.log(`Creating merchant partner: ${createMerchantDto.name}`);
const creatorId = req.user.sub;
const merchantData: CreateMerchantPartnerData = {
...createMerchantDto,
dcbPartnerOwner: {
username: createMerchantDto.dcbPartnerOwnerUsername,
email: createMerchantDto.dcbPartnerOwnerEmail,
firstName: createMerchantDto.dcbPartnerOwnerFirstName,
lastName: createMerchantDto.dcbPartnerOwnerLastName,
password: createMerchantDto.dcbPartnerOwnerPassword,
},
};
const merchant = await this.merchantPartnersService.createMerchantPartner(creatorId, merchantData);
this.logger.log(`Merchant partner created successfully: ${merchant.name} (ID: ${merchant.id})`);
return this.createApiResponse(
true,
'Merchant partner created successfully',
merchant,
);
}
// ===== RÉCUPÉRATION DE MERCHANT PARTNERS =====
@Get()
@Scopes(SCOPES.READ)
async getAllMerchantPartners(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
@Query('status') status?: string,
): Promise<ApiResponse<PaginatedResponse<MerchantPartner>>> {
const requesterId = req.user.sub;
const safeLimit = Math.min(limit, 100);
const safePage = Math.max(page, 1);
let merchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId);
// Filtrage par statut
if (status && ['ACTIVE', 'SUSPENDED', 'PENDING'].includes(status)) {
merchants = merchants.filter(merchant => merchant.status === status);
}
// Pagination
const startIndex = (safePage - 1) * safeLimit;
const endIndex = startIndex + safeLimit;
const paginatedMerchants = merchants.slice(startIndex, endIndex);
const response: PaginatedResponse<MerchantPartner> = {
items: paginatedMerchants,
total: merchants.length,
page: safePage,
limit: safeLimit,
totalPages: Math.ceil(merchants.length / safeLimit),
};
return this.createApiResponse(
true,
'Merchant partners retrieved successfully',
response,
);
}
@Get('stats')
@Scopes(SCOPES.READ)
async getMerchantStats(
@Request() req: any,
): Promise<ApiResponse<MerchantStats>> {
const requesterId = req.user.sub;
const stats = await this.merchantPartnersService.getMerchantStats(requesterId);
return this.createApiResponse(
true,
'Merchant statistics retrieved successfully',
stats,
);
}
@Get(':id')
@Scopes(SCOPES.READ)
async getMerchantPartnerById(
@Param('id') merchantId: string,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
const requesterId = req.user.sub;
const merchant = await this.merchantPartnersService.getMerchantPartnerById(merchantId, requesterId);
return this.createApiResponse(
true,
'Merchant partner retrieved successfully',
merchant,
);
}
// ===== MISE À JOUR DE MERCHANT PARTNERS =====
@Put(':id')
@Scopes(SCOPES.WRITE)
async updateMerchantPartner(
@Param('id') merchantId: string,
@Body() updateMerchantDto: UpdateMerchantPartnerDto,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
this.logger.log(`Updating merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
const merchant = await this.merchantPartnersService.updateMerchantPartner(
merchantId,
updateMerchantDto,
requesterId,
);
this.logger.log(`Merchant partner updated successfully: ${merchant.name}`);
return this.createApiResponse(
true,
'Merchant partner updated successfully',
merchant,
);
}
@Put(':id/suspend')
@Scopes(SCOPES.WRITE)
@HttpCode(HttpStatus.OK)
async suspendMerchantPartner(
@Param('id') merchantId: string,
@Body() suspendDto: SuspendMerchantPartnerDto,
@Request() req: any,
): Promise<ApiResponse<void>> {
this.logger.log(`Suspending merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
await this.merchantPartnersService.suspendMerchantPartner(
merchantId,
suspendDto.reason,
requesterId,
);
this.logger.log(`Merchant partner suspended successfully: ${merchantId}`);
return this.createApiResponse(
true,
'Merchant partner suspended successfully',
);
}
@Put(':id/activate')
@Scopes(SCOPES.WRITE)
@HttpCode(HttpStatus.OK)
async activateMerchantPartner(
@Param('id') merchantId: string,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner>> {
this.logger.log(`Activating merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
const merchant = await this.merchantPartnersService.updateMerchantPartner(
merchantId,
{ status: 'ACTIVE' },
requesterId,
);
this.logger.log(`Merchant partner activated successfully: ${merchant.name}`);
return this.createApiResponse(
true,
'Merchant partner activated successfully',
merchant,
);
}
// ===== SUPPRESSION DE MERCHANT PARTNERS =====
@Delete(':id')
@Scopes(SCOPES.DELETE)
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMerchantPartner(
@Param('id') merchantId: string,
@Request() req: any,
): Promise<void> {
this.logger.log(`Deleting merchant partner: ${merchantId}`);
const requesterId = req.user.sub;
// Implémentez la logique de suppression si nécessaire
// await this.merchantPartnersService.deleteMerchantPartner(merchantId, requesterId);
this.logger.log(`Merchant partner deletion scheduled: ${merchantId}`);
}
// ===== RECHERCHE ET FILTRES =====
@Get('search/by-name')
@Scopes(SCOPES.READ)
async searchMerchantsByName(
@Query('name') name: string,
@Request() req: any,
): Promise<ApiResponse<MerchantPartner[]>> {
if (!name || name.length < 2) {
throw new BadRequestException('Name must be at least 2 characters long');
}
this.logger.log(`Searching merchants by name: ${name}`);
const requesterId = req.user.sub;
const allMerchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId);
const filteredMerchants = allMerchants.filter(merchant =>
merchant.name.toLowerCase().includes(name.toLowerCase()),
);
this.logger.log(`Found ${filteredMerchants.length} merchants matching name: ${name}`);
return this.createApiResponse(
true,
'Merchants search completed successfully',
filteredMerchants,
);
}
}

View File

@ -0,0 +1,514 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
HttpCode,
HttpStatus,
ParseUUIDPipe,
BadRequestException
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
ApiProperty
} from '@nestjs/swagger';
import { MerchantUsersService, MerchantUser, CreateMerchantUserData } from '../services/merchant-users.service';
import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
import { UserRole } from '../../auth/services/keycloak-user.model';
import { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes';
import { Resource, Scopes } from 'nest-keycloak-connect';
export class CreateMerchantUserDto {
@ApiProperty({ description: 'Username for the merchant user' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({ description: 'Password for the user' })
password: string;
@ApiProperty({
enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
description: 'Role for the merchant user'
})
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
@ApiProperty({ required: false, default: true })
enabled?: boolean = true;
@ApiProperty({ required: false, default: false })
emailVerified?: boolean = false;
@ApiProperty({ description: 'Merchant partner ID' })
merchantPartnerId: string;
}
export class UpdateMerchantUserDto {
@ApiProperty({ required: false })
firstName?: string;
@ApiProperty({ required: false })
lastName?: string;
@ApiProperty({ required: false })
email?: string;
@ApiProperty({ required: false })
enabled?: boolean;
}
export class ResetPasswordDto {
@ApiProperty({ description: 'New password' })
newPassword: string;
@ApiProperty({ required: false, default: true })
temporary?: boolean = true;
}
export class MerchantUserResponse {
@ApiProperty({ description: 'User ID' })
id: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({
enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
description: 'User role'
})
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ description: 'Merchant partner ID' })
merchantPartnerId: string;
@ApiProperty({ description: 'User creator ID' })
createdBy: string;
@ApiProperty({ description: 'User creator username' })
createdByUsername: string;
@ApiProperty({ description: 'Creation timestamp' })
createdTimestamp: number;
@ApiProperty({ required: false, description: 'Last login timestamp' })
lastLogin?: number;
@ApiProperty({ enum: ['MERCHANT'], description: 'User type' })
userType: 'MERCHANT';
}
export class MerchantUsersStatsResponse {
@ApiProperty({ description: 'Total admin users' })
totalAdmins: number;
@ApiProperty({ description: 'Total manager users' })
totalManagers: number;
@ApiProperty({ description: 'Total support users' })
totalSupport: number;
@ApiProperty({ description: 'Total users' })
totalUsers: number;
@ApiProperty({ description: 'Active users count' })
activeUsers: number;
@ApiProperty({ description: 'Inactive users count' })
inactiveUsers: number;
}
export class AvailableRolesResponse {
@ApiProperty({
type: [Object],
description: 'Available roles with permissions'
})
roles: Array<{
value: UserRole;
label: string;
description: string;
allowedForCreation: boolean;
}>;
}
// Mapper function pour convertir MerchantUser en MerchantUserResponse
function mapToMerchantUserResponse(merchantUser: MerchantUser): MerchantUserResponse {
return {
id: merchantUser.id,
username: merchantUser.username,
email: merchantUser.email,
firstName: merchantUser.firstName,
lastName: merchantUser.lastName,
role: merchantUser.role,
enabled: merchantUser.enabled,
emailVerified: merchantUser.emailVerified,
merchantPartnerId: merchantUser.merchantPartnerId,
createdBy: merchantUser.createdBy,
createdByUsername: merchantUser.createdByUsername,
createdTimestamp: merchantUser.createdTimestamp,
lastLogin: merchantUser.lastLogin,
userType: merchantUser.userType,
};
}
@ApiTags('Merchant Users')
@ApiBearerAuth()
@Controller('merchant-users')
@Resource(RESOURCES.MERCHANT_USER)
export class MerchantUsersController {
constructor(private readonly merchantUsersService: MerchantUsersService) {}
// ===== RÉCUPÉRATION D'UTILISATEURS =====
@Get()
@ApiOperation({
summary: 'Get merchant users for current user merchant',
description: 'Returns merchant users based on the current user merchant partner ID'
})
@ApiResponse({
status: 200,
description: 'Merchant users retrieved successfully',
type: [MerchantUserResponse]
})
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getMyMerchantUsers(@Request() req): Promise<MerchantUserResponse[]> {
const userId = req.user.sub;
// Récupérer le merchantPartnerId de l'utilisateur courant
const userMerchantId = await this.getUserMerchantPartnerId(userId);
if (!userMerchantId) {
throw new BadRequestException('Current user is not associated with a merchant partner');
}
const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
return users.map(mapToMerchantUserResponse);
}
@Get('partner/:partnerId')
@ApiOperation({
summary: 'Get merchant users by partner ID',
description: 'Returns all merchant users for a specific merchant partner'
})
@ApiResponse({
status: 200,
description: 'Merchant users retrieved successfully',
type: [MerchantUserResponse]
})
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'partnerId', description: 'Merchant Partner ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getMerchantUsersByPartner(
@Param('partnerId', ParseUUIDPipe) partnerId: string,
@Request() req
): Promise<MerchantUserResponse[]> {
const userId = req.user.sub;
const users = await this.merchantUsersService.getMerchantUsersByPartner(partnerId, userId);
return users.map(mapToMerchantUserResponse);
}
@Get(':id')
@ApiOperation({ summary: 'Get merchant user by ID' })
@ApiResponse({
status: 200,
description: 'Merchant user retrieved successfully',
type: MerchantUserResponse
})
@ApiResponse({ status: 404, description: 'Merchant user not found' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getMerchantUserById(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
const user = await this.merchantUsersService.getMerchantUserById(id, userId);
return mapToMerchantUserResponse(user);
}
// ===== CRÉATION D'UTILISATEURS =====
@Post()
@ApiOperation({
summary: 'Create a new merchant user',
description: 'Create a merchant user with specific role and merchant partner association'
})
@ApiResponse({
status: 201,
description: 'Merchant user created successfully',
type: MerchantUserResponse
})
@ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.WRITE)
async createMerchantUser(
@Body() createMerchantUserDto: CreateMerchantUserDto,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
const userData: CreateMerchantUserData = {
...createMerchantUserDto,
createdBy: userId,
};
const user = await this.merchantUsersService.createMerchantUser(userId, userData);
return mapToMerchantUserResponse(user);
}
// ===== MISE À JOUR D'UTILISATEURS =====
@Put(':id')
@ApiOperation({ summary: 'Update a merchant user' })
@ApiResponse({
status: 200,
description: 'Merchant user updated successfully',
type: MerchantUserResponse
})
@ApiResponse({ status: 404, description: 'Merchant user not found' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.WRITE)
async updateMerchantUser(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateMerchantUserDto: UpdateMerchantUserDto,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
// Pour l'instant, on suppose que la mise à jour se fait via Keycloak
// Vous devrez implémenter updateMerchantUser dans le service
throw new BadRequestException('Update merchant user not implemented yet');
}
// ===== SUPPRESSION D'UTILISATEURS =====
@Delete(':id')
@ApiOperation({ summary: 'Delete a merchant user' })
@ApiResponse({ status: 200, description: 'Merchant user deleted successfully' })
@ApiResponse({ status: 404, description: 'Merchant user not found' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.DELETE)
async deleteMerchantUser(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<{ message: string }> {
const userId = req.user.sub;
// Vous devrez implémenter deleteMerchantUser dans le service
throw new BadRequestException('Delete merchant user not implemented yet');
}
// ===== GESTION DES MOTS DE PASSE =====
@Post(':id/reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset merchant user password' })
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 404, description: 'Merchant user not found' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.WRITE)
async resetMerchantUserPassword(
@Param('id', ParseUUIDPipe) id: string,
@Body() resetPasswordDto: ResetPasswordDto,
@Request() req
): Promise<{ message: string }> {
const userId = req.user.sub;
// Vous devrez implémenter resetMerchantUserPassword dans le service
throw new BadRequestException('Reset merchant user password not implemented yet');
}
// ===== STATISTIQUES ET RAPPORTS =====
@Get('stats/overview')
@ApiOperation({ summary: 'Get merchant users statistics overview' })
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: MerchantUsersStatsResponse
})
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getMerchantUsersStats(@Request() req): Promise<MerchantUsersStatsResponse> {
const userId = req.user.sub;
// Récupérer le merchantPartnerId de l'utilisateur courant
const userMerchantId = await this.getUserMerchantPartnerId(userId);
if (!userMerchantId) {
throw new BadRequestException('Current user is not associated with a merchant partner');
}
const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
const stats: MerchantUsersStatsResponse = {
totalAdmins: users.filter(user => user.role === UserRole.DCB_PARTNER_ADMIN).length,
totalManagers: users.filter(user => user.role === UserRole.DCB_PARTNER_MANAGER).length,
totalSupport: users.filter(user => user.role === UserRole.DCB_PARTNER_SUPPORT).length,
totalUsers: users.length,
activeUsers: users.filter(user => user.enabled).length,
inactiveUsers: users.filter(user => !user.enabled).length,
};
return stats;
}
@Get('search')
@ApiOperation({ summary: 'Search merchant users' })
@ApiResponse({
status: 200,
description: 'Search results retrieved successfully',
type: [MerchantUserResponse]
})
@ApiQuery({ name: 'query', required: false, description: 'Search query (username, email, first name, last name)' })
@ApiQuery({ name: 'role', required: false, enum: UserRole, description: 'Filter by role' })
@ApiQuery({ name: 'enabled', required: false, type: Boolean, description: 'Filter by enabled status' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async searchMerchantUsers(
@Request() req,
@Query('query') query?: string,
@Query('role') role?: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT,
@Query('enabled') enabled?: boolean
): Promise<MerchantUserResponse[]> {
const userId = req.user.sub;
// Récupérer le merchantPartnerId de l'utilisateur courant
const userMerchantId = await this.getUserMerchantPartnerId(userId);
if (!userMerchantId) {
throw new BadRequestException('Current user is not associated with a merchant partner');
}
let users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
// Appliquer les filtres
if (query) {
const lowerQuery = query.toLowerCase();
users = users.filter(user =>
user.username.toLowerCase().includes(lowerQuery) ||
user.email.toLowerCase().includes(lowerQuery) ||
user.firstName.toLowerCase().includes(lowerQuery) ||
user.lastName.toLowerCase().includes(lowerQuery)
);
}
if (role) {
users = users.filter(user => user.role === role);
}
if (enabled !== undefined) {
users = users.filter(user => user.enabled === enabled);
}
return users.map(mapToMerchantUserResponse);
}
// ===== UTILITAIRES =====
@Get('roles/available')
@ApiOperation({ summary: 'Get available merchant roles' })
@ApiResponse({
status: 200,
description: 'Available roles retrieved successfully',
type: AvailableRolesResponse
})
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getAvailableMerchantRoles(@Request() req): Promise<AvailableRolesResponse> {
const userId = req.user.sub;
const userRoles = await this.getUserRoles(userId);
const isPartner = userRoles.includes(UserRole.DCB_PARTNER);
const isPartnerAdmin = userRoles.includes(UserRole.DCB_PARTNER_ADMIN);
const isHubAdmin = userRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role)
);
const roles = [
{
value: UserRole.DCB_PARTNER_ADMIN,
label: 'Partner Admin',
description: 'Full administrative access within the merchant partner',
allowedForCreation: isPartner || isHubAdmin
},
{
value: UserRole.DCB_PARTNER_MANAGER,
label: 'Partner Manager',
description: 'Manager access with limited administrative capabilities',
allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin
},
{
value: UserRole.DCB_PARTNER_SUPPORT,
label: 'Partner Support',
description: 'Support role with read-only and basic operational access',
allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin
}
];
return { roles };
}
// ===== MÉTHODES PRIVÉES D'ASSISTANCE =====
private async getUserMerchantPartnerId(userId: string): Promise<string | null> {
// Implémentez cette méthode pour récupérer le merchantPartnerId de l'utilisateur
// Cela dépend de votre implémentation Keycloak
try {
// Exemple - à adapter selon votre implémentation
const user = await this.merchantUsersService['keycloakApi'].getUserById(userId, userId);
return user.attributes?.merchantPartnerId?.[0] || null;
} catch (error) {
return null;
}
}
private async getUserRoles(userId: string): Promise<UserRole[]> {
// Implémentez cette méthode pour récupérer les rôles de l'utilisateur
try {
const roles = await this.merchantUsersService['keycloakApi'].getUserClientRoles(userId);
return roles.map(role => role.name as UserRole);
} catch (error) {
return [];
}
}
}

View File

@ -0,0 +1,105 @@
// dto/hub-users.dto.ts
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsBoolean,
IsString,
MinLength,
Matches,
IsUUID,
} from 'class-validator';
import { UserRole } from '../../auth/services/keycloak-user.model';
// Utiliser directement UserRole au lieu de créer un enum local
export class CreateHubUserDto {
@IsNotEmpty()
@IsString()
@MinLength(3)
username: string;
@IsNotEmpty()
@IsEmail()
email: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
firstName: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
lastName: string;
@IsOptional()
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
password?: string;
@IsNotEmpty()
@IsEnum([UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], {
message: 'Role must be either DCB_ADMIN or DCB_SUPPORT',
})
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
emailVerified?: boolean;
}
export class UpdateHubUserDto {
@IsOptional()
@IsString()
@MinLength(2)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(2)
lastName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class UpdateHubUserRoleDto {
@IsNotEmpty()
@IsEnum([UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], {
message: 'Role must be either DCB_ADMIN or DCB_SUPPORT',
})
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT;
}
export class ResetPasswordDto {
@IsNotEmpty()
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
password: string;
@IsOptional()
@IsBoolean()
temporary?: boolean;
}
export class UserIdParamDto {
@IsNotEmpty()
@IsUUID()
id: string;
}

View File

@ -0,0 +1,96 @@
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
Matches,
} from 'class-validator';
export class CreateMerchantPartnerDto {
@IsNotEmpty()
@IsString()
@MinLength(2)
name: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
legalName: string;
@IsNotEmpty()
@IsEmail()
email: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
address?: string;
// Propriétaire DCB_PARTNER
@IsNotEmpty()
@IsString()
@MinLength(3)
dcbPartnerOwnerUsername: string;
@IsNotEmpty()
@IsEmail()
dcbPartnerOwnerEmail: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
dcbPartnerOwnerFirstName: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
dcbPartnerOwnerLastName: string;
@IsNotEmpty()
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
dcbPartnerOwnerPassword: string;
}
export class UpdateMerchantPartnerDto {
@IsOptional()
@IsString()
@MinLength(2)
name?: string;
@IsOptional()
@IsString()
@MinLength(2)
legalName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsEnum(['ACTIVE', 'SUSPENDED', 'PENDING'])
status?: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
}
export class SuspendMerchantPartnerDto {
@IsNotEmpty()
@IsString()
@MinLength(5)
reason: string;
}

View File

@ -0,0 +1,87 @@
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsBoolean,
IsString,
MinLength,
Matches,
IsUUID,
} from 'class-validator';
import { UserRole } from '../../auth/services/keycloak-user.model';
export class CreateMerchantUserDto {
@IsNotEmpty()
@IsString()
@MinLength(3)
username: string;
@IsNotEmpty()
@IsEmail()
email: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
firstName: string;
@IsNotEmpty()
@IsString()
@MinLength(2)
lastName: string;
@IsOptional()
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
password?: string;
@IsNotEmpty()
@IsEnum([UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT])
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
emailVerified?: boolean;
}
export class UpdateMerchantUserDto {
@IsOptional()
@IsString()
@MinLength(2)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(2)
lastName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class ResetMerchantPasswordDto {
@IsNotEmpty()
@IsString()
@MinLength(8)
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
})
password: string;
@IsOptional()
@IsBoolean()
temporary?: boolean;
}

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common'
import { JwtModule } from '@nestjs/jwt'
import { HttpModule } from '@nestjs/axios';
import { TokenService } from '../auth/services/token.service'
import { HubUsersService } from './services/hub-users.service'
import { HubUsersController } from './controllers/hub-users.controller'
import { MerchantUsersService } from './services/merchant-users.service'
import { MerchantUsersController } from './controllers/merchant-users.controller'
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
@Module({
imports: [
HttpModule,
JwtModule.register({}),
],
providers: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService],
controllers: [HubUsersController, MerchantUsersController],
exports: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService, JwtModule],
})
export class HubUsersModule {}

View File

@ -0,0 +1,100 @@
// user.models.ts
export interface User {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
enabled: boolean;
emailVerified: boolean;
userType: UserType;
merchantPartnerId?: string;
clientRoles: UserRole[];
createdBy?: string;
createdByUsername?: string;
createdTimestamp: number;
}
export enum UserType {
HUB = 'hub',
MERCHANT_PARTNER = 'merchant_partner'
}
export enum UserRole {
// Rôles Hub (sans merchantPartnerId)
DCB_ADMIN = 'dcb-admin',
DCB_SUPPORT = 'dcb-support',
DCB_PARTNER = 'dcb-partner',
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
}
// DTOs pour la création
export interface CreateUserDto {
username: string;
email: string;
firstName?: string;
lastName?: string;
password: string;
enabled?: boolean;
userType: UserType;
merchantPartnerId?: string;
clientRoles: UserRole[];
}
export interface CreateHubUserDto extends CreateUserDto {
hubRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
}
export interface CreateMerchantPartnerUserDto extends CreateUserDto {
merchantRole: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
}
export interface UpdateUserDto {
username?: string;
email?: string;
firstName?: string;
lastName?: string;
enabled?: boolean;
clientRoles?: UserRole[];
}
export interface UserQueryDto {
page?: number;
limit?: number;
search?: string;
userType?: UserType;
merchantPartnerId?: string;
enabled?: boolean;
}
export interface UserResponse {
user: User;
message: string;
}
export interface PaginatedUsersResponse {
users: User[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// Pour l'authentification
export interface LoginDto {
username: string;
password: string;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
refresh_expires_in?: number;
scope?: string;
}

View File

@ -0,0 +1,552 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { CreateUserData, UserRole, KeycloakUser, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model'; // SUPPRIMER import type
import { LoginDto, TokenResponse, User } from '../models/hub-user.model';
// ===== SERVICE PRINCIPAL =====
@Injectable()
export class HubUsersService {
private readonly logger = new Logger(HubUsersService.name);
// Définir les rôles Hub autorisés comme constante
private readonly HUB_ROLES = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER];
constructor(private readonly keycloakApi: KeycloakApiService) {}
// === AUTHENTIFICATION UTILISATEUR ===
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
}
// ===== VALIDATION ET UTILITAIRES =====
private isValidHubRole(role: UserRole): role is UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER {
return this.HUB_ROLES.includes(role);
}
private validateHubRole(role: UserRole): void {
if (!this.isValidHubRole(role)) {
throw new BadRequestException(
`Invalid hub role: ${role}. Hub roles must be DCB_ADMIN or DCB_SUPPORT`
);
}
}
validateHubRoleFromString(role: string): UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER {
// Vérifier si le rôle est valide
if (!Object.values(UserRole).includes(role as UserRole)) {
throw new BadRequestException(`Invalid role: ${role}`);
}
const userRole = role as UserRole;
if (!this.isValidHubRole(userRole)) {
throw new BadRequestException(
`Invalid hub role: ${role}. Must be DCB_ADMIN or DCB_SUPPORT`
);
}
return userRole;
}
private async validateHubUserAccess(requesterId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
const isHubAdmin = requesterRoles.some(role =>
this.HUB_ROLES.includes(role.name as UserRole)
);
if (!isHubAdmin) {
throw new ForbiddenException('Only hub administrators can manage hub users');
}
}
private parseKeycloakAttribute(value: string[] | undefined): string | undefined {
return value?.[0];
}
private parseKeycloakTimestamp(value: string[] | undefined): number | undefined {
const strValue = this.parseKeycloakAttribute(value);
if (!strValue) return undefined;
const timestamp = parseInt(strValue);
if (!isNaN(timestamp)) return timestamp;
const date = new Date(strValue);
return isNaN(date.getTime()) ? undefined : date.getTime();
}
private mapToHubUser(user: KeycloakUser, roles: any[]): HubUser {
const hubRole = roles.find(role => this.isValidHubRole(role.name as UserRole));
if (!user.id) throw new Error('User ID is required');
if (!user.email) throw new Error('User email is required');
return {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName || '',
lastName: user.lastName || '',
role: hubRole?.name as UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER,
enabled: user.enabled,
emailVerified: user.emailVerified,
createdBy: this.parseKeycloakAttribute(user.attributes?.createdBy) || 'unknown',
createdByUsername: this.parseKeycloakAttribute(user.attributes?.createdByUsername) || 'unknown',
createdTimestamp: user.createdTimestamp || Date.now(),
lastLogin: this.parseKeycloakTimestamp(user.attributes?.lastLogin),
userType: 'HUB',
attributes: user.attributes,
};
}
// ===== OPÉRATIONS CRUD =====
async createHubUser(creatorId: string, userData: CreateHubUserData): Promise<HubUser> {
this.logger.log(`Creating hub user: ${userData.username} with role: ${userData.role}`);
this.validateHubRole(userData.role);
await this.validateHubUserAccess(creatorId);
// Vérifier les doublons
this.checkDuplicateHubUser(userData)
const keycloakUserData: CreateUserData = {
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
password: userData.password,
enabled: userData.enabled ?? true,
emailVerified: userData.emailVerified ?? false,
merchantPartnerId: undefined,
clientRoles: [userData.role],
createdBy: creatorId,
initialStatus: 'PENDING_ACTIVATION',
};
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
const createdUser = await this.getHubUserById(userId, creatorId);
this.logger.log(`Hub user created successfully: ${userData.username} (ID: ${userId})`);
return createdUser;
}
private async checkDuplicateHubUser(merchantData: CreateHubUserData): Promise<void> {
const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.username);
if (existingUsers.length > 0) {
throw new BadRequestException(`Merchant partner with username ${merchantData.username} already exists`);
}
const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.email);
if (existingEmails.length > 0) {
throw new BadRequestException(`Merchant partner with email ${merchantData.email} already exists`);
}
}
// ===== RÉCUPÉRATION DE TOUS LES MERCHANTS (DCB_PARTNER) =====
async getAllMerchants(requesterId: string): Promise<HubUser[]> {
await this.validateHubUserAccess(requesterId);
const allUsers = await this.keycloakApi.getAllUsers();
const merchants: HubUser[] = [];
for (const user of allUsers) {
if (!user.id) continue;
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
const isMerchant = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
if (isMerchant) {
merchants.push(this.mapToHubUser(user, userRoles));
}
} catch (error) {
this.logger.warn(`Could not process merchant ${user.id}: ${error.message}`);
}
}
return merchants;
}
async suspendMerchantPartner(merchantId: string, reason: string, requesterId: string): Promise<void> {
await this.validateHubAccess(requesterId);
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
// Suspendre le merchant et tous ses utilisateurs
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, {
merchantStatus: ['SUSPENDED'],
suspensionReason: [reason],
suspendedAt: [new Date().toISOString()],
});
// Suspendre tous les utilisateurs du merchant
await this.suspendAllMerchantUsers(merchantId, requesterId);
this.logger.log(`Merchant partner suspended: ${merchantId}, reason: ${reason}`);
}
async getMerchantPartnerById(merchantId: string, requesterId: string): Promise<HubUser> {
await this.validateMerchantAccess(requesterId, merchantId);
const merchantPartner = await this.findDcbPartnerByMerchantId(merchantId);
const merchantPartnerRoles = await this.keycloakApi.getUserClientRoles(merchantId);
return this.mapToHubUser(merchantPartner, merchantPartnerRoles);
}
// ===== STATISTIQUES =====
async getMerchantStats(requesterId: string): Promise<MerchantStats> {
await this.validateHubAccess(requesterId);
const allMerchants = await this.getAllMerchants(requesterId);
const allUsers = await this.keycloakApi.getAllUsers();
let totalUsers = 0;
for (const merchant of allMerchants) {
const merchantUsers = allUsers.filter(user =>
user.attributes?.merchantPartnerId?.[0] === merchant.id
);
totalUsers += merchantUsers.length;
}
return {
totalMerchants: allMerchants.length,
activeMerchants: allMerchants.filter(m => m.enabled).length,
suspendedMerchants: allMerchants.filter(m => !m.enabled).length,
pendingMerchants: allMerchants.filter(m => m.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION'
).length,
totalUsers,
};
}
private async validateHubAccess(requesterId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!isHubAdmin) {
throw new ForbiddenException('Only hub administrators can access this resource');
}
}
private async validateMerchantAccess(requesterId: string, merchantId?: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
this.logger.debug(`Validating merchant access: requester=${requesterId}, merchant=${merchantId}`);
this.logger.debug(`Requester roles: ${requesterRoles.map(r => r.name).join(', ')}`);
// Les admins Hub ont accès complet
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (isHubAdmin) {
this.logger.debug('Hub admin access granted');
return;
}
// CORRECTION: Service account est considéré comme admin hub
if (requesterId.includes('service-account')) {
this.logger.debug('Service account access granted');
return;
}
// Les DCB_PARTNER n'ont accès qu'à leur propre merchant
if (requesterRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
if (merchantId && requesterId === merchantId) {
this.logger.debug('DCB_PARTNER access to own merchant granted');
return; // DCB_PARTNER accède à son propre merchant
}
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner');
}
// Les autres rôles merchants n'ont accès qu'à leur merchant
const requesterMerchantId = await this.keycloakApi.getUserMerchantPartnerId(requesterId);
if (requesterMerchantId && merchantId && requesterMerchantId === merchantId) {
this.logger.debug('Merchant user access to own merchant granted');
return;
}
throw new ForbiddenException('Insufficient permissions to access this merchant');
}
private async validateMerchantManagementPermissions(requesterId: string, merchantId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
// Seuls les admins Hub peuvent modifier les merchants
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!isHubAdmin) {
throw new ForbiddenException('Only hub administrators can manage merchant partners');
}
}
private async findDcbPartnerByMerchantId(merchantId: string): Promise<KeycloakUser> {
const users = await this.keycloakApi.findUserByUsername(merchantId);
if (users.length === 0) {
throw new NotFoundException(`Merchant partner not found: ${merchantId}`);
}
const user = users[0];
const userRoles = await this.keycloakApi.getUserClientRoles(user.id!);
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
if (!isDcbPartner) {
throw new NotFoundException(`User ${merchantId} is not a DCB_PARTNER`);
}
return user;
}
private async suspendAllMerchantUsers(merchantId: string, requesterId: string): Promise<void> {
const allUsers = await this.keycloakApi.getAllUsers();
const merchantUsers = allUsers.filter(user =>
user.attributes?.merchantPartnerId?.[0] === merchantId
);
for (const user of merchantUsers) {
if (user.id) {
try {
await this.keycloakApi.updateUser(user.id, { enabled: false }, requesterId);
} catch (error) {
this.logger.warn(`Could not suspend user ${user.id}: ${error.message}`);
}
}
}
}
async getHubUserById(userId: string, requesterId: string): Promise<HubUser> {
await this.validateHubUserAccess(requesterId);
const [user, userRoles] = await Promise.all([
this.keycloakApi.getUserById(userId, requesterId),
this.keycloakApi.getUserClientRoles(userId)
]);
const isHubUser = userRoles.some(role => this.isValidHubRole(role.name as UserRole));
if (!isHubUser) {
throw new BadRequestException(`User ${userId} is not a hub user`);
}
return this.mapToHubUser(user, userRoles);
}
async getAllHubUsers(requesterId: string): Promise<HubUser[]> {
await this.validateHubUserAccess(requesterId);
const allUsers = await this.keycloakApi.getAllUsers();
const hubUsers: HubUser[] = [];
for (const user of allUsers) {
if (!user.id) continue;
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
const hubRole = userRoles.find(role => this.isValidHubRole(role.name as UserRole));
if (hubRole) {
hubUsers.push(this.mapToHubUser(user, userRoles));
}
} catch (error) {
this.logger.warn(`Could not process user ${user.id}: ${error.message}`);
}
}
return hubUsers;
}
async getHubUsersByRole(role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER, requesterId: string): Promise<HubUser[]> {
await this.validateHubUserAccess(requesterId);
const allHubUsers = await this.getAllHubUsers(requesterId);
return allHubUsers.filter(user => user.role === role);
}
async updateHubUser(
userId: string,
updates: Partial<{
firstName: string;
lastName: string;
email: string;
enabled: boolean;
}>,
requesterId: string
): Promise<HubUser> {
await this.validateHubUserAccess(requesterId);
await this.getHubUserById(userId, requesterId);
await this.keycloakApi.updateUser(userId, updates, requesterId);
return this.getHubUserById(userId, requesterId);
}
async updateHubUserRole(
userId: string,
newRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT |UserRole.DCB_PARTNER,
requesterId: string
): Promise<HubUser> {
// Validation implicite via le typage
await this.validateHubUserAccess(requesterId);
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN);
if (!isRequesterAdmin) {
throw new ForbiddenException('Only DCB_ADMIN can change user roles');
}
await this.getHubUserById(userId, requesterId);
await this.keycloakApi.setClientRoles(userId, [newRole]);
return this.getHubUserById(userId, requesterId);
}
async updateMerchantPartner(
merchantId: string,
updates: Partial<{
firstName: string;
lastName: string;
email: string;
enabled: boolean;
}>,
requesterId: string
): Promise<HubUser> {
await this.validateHubUserAccess(requesterId);
await this.getHubUserById(merchantId, requesterId);
await this.validateMerchantManagementPermissions(requesterId, merchantId);
return this.getMerchantPartnerById(merchantId, requesterId);
}
async deleteHubUser(userId: string, requesterId: string): Promise<void> {
await this.validateHubUserAccess(requesterId);
await this.getHubUserById(userId, requesterId);
if (userId === requesterId) {
throw new BadRequestException('Cannot delete your own account');
}
if (await this.isLastAdmin(userId)) {
throw new BadRequestException('Cannot delete the last DCB_ADMIN user');
}
await this.keycloakApi.deleteUser(userId, requesterId);
this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`);
}
// ===== GESTION DES MOTS DE PASSE =====
async resetHubUserPassword(
userId: string,
newPassword: string,
temporary: boolean = true,
requesterId: string
): Promise<void> {
await this.validateHubUserAccess(requesterId);
await this.getHubUserById(userId, requesterId);
await this.keycloakApi.resetUserPassword(userId, newPassword, temporary);
this.logger.log(`Password reset for hub user: ${userId}`);
}
async sendHubUserPasswordResetEmail(userId: string, requesterId: string): Promise<void> {
await this.validateHubUserAccess(requesterId);
const user = await this.getHubUserById(userId, requesterId);
await this.keycloakApi.sendPasswordResetEmail(user.email);
this.logger.log(`Password reset email sent to hub user: ${user.email}`);
}
// ===== STATISTIQUES ET RAPPORTS =====
async getHubUsersStats(requesterId: string): Promise<HubUserStats> {
await this.validateHubUserAccess(requesterId);
const allHubUsers = await this.getAllHubUsers(requesterId);
return {
totalAdmins: allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length,
totalSupport: allHubUsers.filter(user => user.role === UserRole.DCB_SUPPORT).length,
activeUsers: allHubUsers.filter(user => user.enabled).length,
inactiveUsers: allHubUsers.filter(user => !user.enabled).length,
pendingActivation: allHubUsers.filter(user =>
user.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION'
).length,
};
}
async getHubUserActivity(requesterId: string): Promise<HubUserActivity[]> {
await this.validateHubUserAccess(requesterId);
const allHubUsers = await this.getAllHubUsers(requesterId);
return allHubUsers.map(user => ({
user,
lastLogin: user.lastLogin ? new Date(user.lastLogin) : undefined
}));
}
async getActiveHubSessions(requesterId: string): Promise<{ userId: string; username: string; lastAccess: Date }[]> {
const activity = await this.getHubUserActivity(requesterId);
return activity
.filter(item => item.lastLogin)
.map(item => ({
userId: item.user.id,
username: item.user.username,
lastAccess: item.lastLogin!
}));
}
// ===== SANTÉ ET VALIDATIONS =====
async checkHubUsersHealth(requesterId: string): Promise<HubHealthStatus> {
await this.validateHubUserAccess(requesterId);
const stats = await this.getHubUsersStats(requesterId);
const issues: string[] = [];
if (stats.totalAdmins === 0) {
issues.push('No DCB_ADMIN users found - system is vulnerable');
}
if (stats.totalAdmins === 1) {
issues.push('Only one DCB_ADMIN user exists - consider creating backup administrators');
}
if (stats.inactiveUsers > stats.activeUsers / 2) {
issues.push('More than half of hub users are inactive');
}
if (stats.pendingActivation > 5) {
issues.push('Many users are pending activation - review onboarding process');
}
const status = issues.length === 0 ? 'healthy' : issues.length <= 2 ? 'degraded' : 'unhealthy';
return { status, issues, stats };
}
private async isLastAdmin(userId: string): Promise<boolean> {
try {
const [user, userRoles] = await Promise.all([
this.keycloakApi.getUserById(userId, userId),
this.keycloakApi.getUserClientRoles(userId)
]);
const isAdmin = userRoles.some(role => role.name === UserRole.DCB_ADMIN);
if (!isAdmin) return false;
const allHubUsers = await this.getAllHubUsers(userId);
const adminCount = allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length;
return adminCount <= 1;
} catch (error) {
this.logger.error(`Error checking if user is last admin: ${error.message}`);
return false;
}
}
async canUserManageHubUsers(userId: string): Promise<boolean> {
try {
const roles = await this.keycloakApi.getUserClientRoles(userId);
return roles.some(role =>
this.HUB_ROLES.includes(role.name as UserRole)
);
} catch (error) {
return false;
}
}
}

View File

@ -0,0 +1,353 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { CreateUserData, UserRole, KeycloakUser } from '../../auth/services/keycloak-user.model';
export interface MerchantPartner {
id: string;
name: string;
legalName: string;
email: string;
phone?: string;
address?: string;
status: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
createdAt: Date;
updatedAt: Date;
dcbPartnerUserId: string; // ID du user DCB_PARTNER propriétaire
}
export interface CreateMerchantPartnerData {
name: string;
legalName: string;
email: string;
phone?: string;
address?: string;
dcbPartnerOwner: {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
};
}
export interface MerchantStats {
totalMerchants: number;
activeMerchants: number;
suspendedMerchants: number;
pendingMerchants: number;
totalUsers: number;
}
@Injectable()
export class MerchantPartnersService {
private readonly logger = new Logger(MerchantPartnersService.name);
constructor(private readonly keycloakApi: KeycloakApiService) {}
// ===== CRÉATION D'UN MERCHANT PARTNER =====
async createMerchantPartner(
creatorId: string,
merchantData: CreateMerchantPartnerData
): Promise<MerchantPartner> {
this.logger.log(`Creating merchant partner: ${merchantData.name}`);
// Validation des permissions du créateur
await this.validateMerchantCreationPermissions(creatorId);
// Vérifier les doublons
await this.checkDuplicateMerchant(merchantData);
// 1. Créer le user DCB_PARTNER (propriétaire)
const dcbPartnerUserData: CreateUserData = {
username: merchantData.dcbPartnerOwner.username,
email: merchantData.dcbPartnerOwner.email,
firstName: merchantData.dcbPartnerOwner.firstName,
lastName: merchantData.dcbPartnerOwner.lastName,
password: merchantData.dcbPartnerOwner.password,
enabled: true,
emailVerified: true,
clientRoles: [UserRole.DCB_PARTNER],
createdBy: creatorId,
};
const dcbPartnerUserId = await this.keycloakApi.createUser(creatorId, dcbPartnerUserData);
// 2. Créer l'entité Merchant Partner (dans la base de données ou Keycloak attributes)
const merchantPartner: MerchantPartner = {
id: merchantData.dcbPartnerOwner.username,
name: merchantData.name,
legalName: merchantData.legalName,
email: merchantData.email,
phone: merchantData.phone,
address: merchantData.address,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
dcbPartnerUserId,
};
// 3. Stocker les infos du merchant dans les attributs du user DCB_PARTNER
await this.keycloakApi.setUserAttributes(dcbPartnerUserId, {
merchantPartnerName: [merchantData.name],
merchantLegalName: [merchantData.legalName],
merchantEmail: [merchantData.email],
merchantPhone: [merchantData.phone || ''],
merchantAddress: [merchantData.address || ''],
merchantStatus: ['ACTIVE'],
merchantCreatedAt: [new Date().toISOString()],
});
this.logger.log(`Merchant partner created successfully: ${merchantData.name}`);
return merchantPartner;
}
// ===== GESTION DES MERCHANTS =====
async getAllMerchantPartners(requesterId: string): Promise<MerchantPartner[]> {
await this.validateHubAccess(requesterId);
// Récupérer tous les users DCB_PARTNER
const allUsers = await this.keycloakApi.getAllUsers();
const merchants: MerchantPartner[] = [];
for (const user of allUsers) {
if (!user.id) continue;
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
if (isDcbPartner && user.attributes?.merchantPartnerName?.[0]) {
merchants.push(this.mapToMerchantPartner(user));
}
} catch (error) {
this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`);
}
}
return merchants;
}
async getMerchantPartnerById(merchantId: string, requesterId: string): Promise<MerchantPartner> {
await this.validateMerchantAccess(requesterId, merchantId);
// Trouver le user DCB_PARTNER correspondant au merchant
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
return this.mapToMerchantPartner(dcbPartnerUser);
}
async updateMerchantPartner(
merchantId: string,
updates: Partial<{
name: string;
legalName: string;
email: string;
phone: string;
address: string;
status: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
}>,
requesterId: string
): Promise<MerchantPartner> {
await this.validateMerchantManagementPermissions(requesterId, merchantId);
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
// Mettre à jour les attributs
const attributes: any = {};
if (updates.name) attributes.merchantPartnerName = [updates.name];
if (updates.legalName) attributes.merchantLegalName = [updates.legalName];
if (updates.email) attributes.merchantEmail = [updates.email];
if (updates.phone) attributes.merchantPhone = [updates.phone];
if (updates.address) attributes.merchantAddress = [updates.address];
if (updates.status) attributes.merchantStatus = [updates.status];
attributes.merchantUpdatedAt = [new Date().toISOString()];
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, attributes);
return this.getMerchantPartnerById(merchantId, requesterId);
}
async suspendMerchantPartner(merchantId: string, reason: string, requesterId: string): Promise<void> {
await this.validateHubAccess(requesterId);
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
// Suspendre le merchant et tous ses utilisateurs
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, {
merchantStatus: ['SUSPENDED'],
suspensionReason: [reason],
suspendedAt: [new Date().toISOString()],
});
// Suspendre tous les utilisateurs du merchant
await this.suspendAllMerchantUsers(merchantId, requesterId);
this.logger.log(`Merchant partner suspended: ${merchantId}, reason: ${reason}`);
}
// ===== STATISTIQUES =====
async getMerchantStats(requesterId: string): Promise<MerchantStats> {
await this.validateHubAccess(requesterId);
const allMerchants = await this.getAllMerchantPartners(requesterId);
const allUsers = await this.keycloakApi.getAllUsers();
let totalUsers = 0;
for (const merchant of allMerchants) {
const merchantUsers = allUsers.filter(user =>
user.attributes?.merchantPartnerId?.[0] === merchant.id
);
totalUsers += merchantUsers.length;
}
return {
totalMerchants: allMerchants.length,
activeMerchants: allMerchants.filter(m => m.status === 'ACTIVE').length,
suspendedMerchants: allMerchants.filter(m => m.status === 'SUSPENDED').length,
pendingMerchants: allMerchants.filter(m => m.status === 'PENDING').length,
totalUsers,
};
}
// ===== MÉTHODES PRIVÉES =====
private async validateMerchantCreationPermissions(creatorId: string): Promise<void> {
const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId);
const canCreateMerchant = creatorRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!canCreateMerchant) {
throw new ForbiddenException('Only hub administrators can create merchant partners');
}
}
private async validateHubAccess(requesterId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!isHubAdmin) {
throw new ForbiddenException('Only hub administrators can access this resource');
}
}
private async validateMerchantAccess(requesterId: string, merchantId?: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
this.logger.debug(`Validating merchant access: requester=${requesterId}, merchant=${merchantId}`);
this.logger.debug(`Requester roles: ${requesterRoles.map(r => r.name).join(', ')}`);
// Les admins Hub ont accès complet
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (isHubAdmin) {
this.logger.debug('Hub admin access granted');
return;
}
// CORRECTION: Service account est considéré comme admin hub
if (requesterId.includes('service-account')) {
this.logger.debug('Service account access granted');
return;
}
// Les DCB_PARTNER n'ont accès qu'à leur propre merchant
if (requesterRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
if (merchantId && requesterId === merchantId) {
this.logger.debug('DCB_PARTNER access to own merchant granted');
return; // DCB_PARTNER accède à son propre merchant
}
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner');
}
// Les autres rôles merchants n'ont accès qu'à leur merchant
const requesterMerchantId = await this.keycloakApi.getUserMerchantPartnerId(requesterId);
if (requesterMerchantId && merchantId && requesterMerchantId === merchantId) {
this.logger.debug('Merchant user access to own merchant granted');
return;
}
throw new ForbiddenException('Insufficient permissions to access this merchant');
}
private async validateMerchantManagementPermissions(requesterId: string, merchantId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
// Seuls les admins Hub peuvent modifier les merchants
const isHubAdmin = requesterRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
);
if (!isHubAdmin) {
throw new ForbiddenException('Only hub administrators can manage merchant partners');
}
}
private async checkDuplicateMerchant(merchantData: CreateMerchantPartnerData): Promise<void> {
const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.dcbPartnerOwner.username);
if (existingUsers.length > 0) {
throw new BadRequestException(`Merchant partner with username ${merchantData.dcbPartnerOwner.username} already exists`);
}
const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.dcbPartnerOwner.email);
if (existingEmails.length > 0) {
throw new BadRequestException(`Merchant partner with email ${merchantData.dcbPartnerOwner.email} already exists`);
}
}
private async findDcbPartnerByMerchantId(merchantId: string): Promise<KeycloakUser> {
const users = await this.keycloakApi.findUserByUsername(merchantId);
if (users.length === 0) {
throw new NotFoundException(`Merchant partner not found: ${merchantId}`);
}
const user = users[0];
const userRoles = await this.keycloakApi.getUserClientRoles(user.id!);
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
if (!isDcbPartner) {
throw new NotFoundException(`User ${merchantId} is not a DCB_PARTNER`);
}
return user;
}
private mapToMerchantPartner(user: KeycloakUser): MerchantPartner {
return {
id: user.username,
name: user.attributes?.merchantPartnerName?.[0] || user.username,
legalName: user.attributes?.merchantLegalName?.[0] || '',
email: user.attributes?.merchantEmail?.[0] || user.email,
phone: user.attributes?.merchantPhone?.[0],
address: user.attributes?.merchantAddress?.[0],
status: (user.attributes?.merchantStatus?.[0] as 'ACTIVE' | 'SUSPENDED' | 'PENDING') || 'ACTIVE',
createdAt: user.attributes?.merchantCreatedAt?.[0]
? new Date(user.attributes.merchantCreatedAt[0])
: new Date(user.createdTimestamp || Date.now()),
updatedAt: user.attributes?.merchantUpdatedAt?.[0]
? new Date(user.attributes.merchantUpdatedAt[0])
: new Date(user.createdTimestamp || Date.now()),
dcbPartnerUserId: user.id!,
};
}
private async suspendAllMerchantUsers(merchantId: string, requesterId: string): Promise<void> {
const allUsers = await this.keycloakApi.getAllUsers();
const merchantUsers = allUsers.filter(user =>
user.attributes?.merchantPartnerId?.[0] === merchantId
);
for (const user of merchantUsers) {
if (user.id) {
try {
await this.keycloakApi.updateUser(user.id, { enabled: false }, requesterId);
} catch (error) {
this.logger.warn(`Could not suspend user ${user.id}: ${error.message}`);
}
}
}
}
}

View File

@ -0,0 +1,192 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { CreateUserData, UserRole, KeycloakUser } from '../../auth/services/keycloak-user.model';
export interface MerchantUser {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled: boolean;
emailVerified: boolean;
merchantPartnerId: string;
createdBy: string;
createdByUsername: string;
createdTimestamp: number;
lastLogin?: number;
userType: 'MERCHANT';
}
export interface CreateMerchantUserData {
username: string;
email: string;
firstName: string;
lastName: string;
password?: string;
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled?: boolean;
emailVerified?: boolean;
merchantPartnerId: string;
createdBy: string;
}
@Injectable()
export class MerchantUsersService {
private readonly logger = new Logger(MerchantUsersService.name);
private readonly MERCHANT_ROLES = [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT,
];
constructor(private readonly keycloakApi: KeycloakApiService) {}
// ===== CRÉATION D'UTILISATEURS MERCHANT =====
async createMerchantUser(creatorId: string, userData: CreateMerchantUserData): Promise<MerchantUser> {
this.logger.log(`Creating merchant user: ${userData.username} for merchant: ${userData.merchantPartnerId}`);
// Validation des permissions et du merchant
await this.validateMerchantUserCreation(creatorId, userData);
// Vérifier les doublons
const existingUsers = await this.keycloakApi.findUserByUsername(userData.username);
if (existingUsers.length > 0) {
throw new BadRequestException(`User with username ${userData.username} already exists`);
}
const keycloakUserData: CreateUserData = {
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
password: userData.password,
enabled: userData.enabled ?? true,
emailVerified: userData.emailVerified ?? false,
merchantPartnerId: userData.merchantPartnerId,
clientRoles: [userData.role],
createdBy: creatorId,
};
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
const createdUser = await this.getMerchantUserById(userId, creatorId);
this.logger.log(`Merchant user created successfully: ${userData.username}`);
return createdUser;
}
// ===== RÉCUPÉRATION D'UTILISATEURS MERCHANT =====
async getMerchantUsersByPartner(merchantPartnerId: string, requesterId: string): Promise<MerchantUser[]> {
await this.validateMerchantAccess(requesterId, merchantPartnerId);
const allUsers = await this.keycloakApi.getAllUsers();
const merchantUsers: MerchantUser[] = [];
for (const user of allUsers) {
if (!user.id) continue;
const userMerchantId = user.attributes?.merchantPartnerId?.[0];
if (userMerchantId === merchantPartnerId) {
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
const merchantRole = userRoles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole));
if (merchantRole) {
merchantUsers.push(this.mapToMerchantUser(user, userRoles));
}
} catch (error) {
this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`);
}
}
}
return merchantUsers;
}
async getMerchantUserById(userId: string, requesterId: string): Promise<MerchantUser> {
const user = await this.keycloakApi.getUserById(userId, requesterId);
const userRoles = await this.keycloakApi.getUserClientRoles(userId);
const merchantRole = userRoles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole));
if (!merchantRole) {
throw new BadRequestException(`User ${userId} is not a merchant user`);
}
const merchantPartnerId = user.attributes?.merchantPartnerId?.[0];
if (!merchantPartnerId) {
throw new BadRequestException(`User ${userId} has no merchant partner association`);
}
await this.validateMerchantAccess(requesterId, merchantPartnerId);
return this.mapToMerchantUser(user, userRoles);
}
// ===== VALIDATIONS =====
private async validateMerchantUserCreation(creatorId: string, userData: CreateMerchantUserData): Promise<void> {
const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId);
// DCB_PARTNER peut créer des utilisateurs
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
if (creatorId !== userData.merchantPartnerId) {
throw new ForbiddenException('DCB_PARTNER can only create users for their own ');
}
// DCB_PARTNER ne peut créer que certains rôles
const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
if (!allowedRoles.includes(userData.role)) {
throw new ForbiddenException('DCB_PARTNER can only create DCB_PARTNER_ADMIN, MANAGER, or SUPPORT roles');
}
return;
}
// DCB_PARTNER_ADMIN peut créer des utilisateurs pour son merchant
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER_ADMIN)) {
const creatorMerchantId = await this.keycloakApi.getUserMerchantPartnerId(creatorId);
if (creatorMerchantId !== userData.merchantPartnerId) {
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner');
}
// DCB_PARTNER_ADMIN ne peut créer que certains rôles
const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
if (!allowedRoles.includes(userData.role)) {
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create DCB_PARTNER_ADMIN, DCB_PARTNER_MANAGER or SUPPORT roles');
}
return;
}
// Les admins Hub peuvent créer pour n'importe quel merchant
if (creatorRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
)) {
return;
}
throw new ForbiddenException('Insufficient permissions to create merchant users');
}
private async validateMerchantAccess(requesterId: string, merchantPartnerId: string): Promise<void> {
await this.keycloakApi.validateUserAccess(requesterId, merchantPartnerId);
}
private mapToMerchantUser(user: KeycloakUser, roles: any[]): MerchantUser {
const merchantRole = roles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole));
return {
id: user.id!,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: merchantRole?.name as UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT,
enabled: user.enabled,
emailVerified: user.emailVerified,
merchantPartnerId: user.attributes?.merchantPartnerId?.[0]!,
createdBy: user.attributes?.createdBy?.[0] || 'unknown',
createdByUsername: user.attributes?.createdByUsername?.[0] || 'unknown',
createdTimestamp: user.createdTimestamp || Date.now(),
lastLogin: user.attributes?.lastLogin?.[0] ? parseInt(user.attributes.lastLogin[0]) : undefined,
userType: 'MERCHANT',
};
}
}

View File

@ -9,30 +9,38 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const logger = new Logger('dcb-user-service'); const logger = new Logger('dcb-user-service');
// Middlewares de sécurité
app.use(helmet()); app.use(helmet());
app.enableCors(); app.enableCors({ origin: '*' });
// Gestion globale des erreurs et validation
app.useGlobalFilters(new KeycloakExceptionFilter()); app.useGlobalFilters(new KeycloakExceptionFilter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true
}));
// Préfixe global de l'API
app.setGlobalPrefix('api/v1'); app.setGlobalPrefix('api/v1');
// Swagger Configuration // Configuration Swagger
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('DCB User Service API') .setTitle('DCB User Service API')
.setDescription('API de gestion des utilisateurs pour le système DCB') .setDescription('API de gestion des utilisateurs pour le système DCB')
.setVersion('1.0') .setVersion('1.0')
.addTag('users', 'Gestion des utilisateurs') .addTag('users', 'Gestion des Utilisateurs')
.addTag('partners', 'Gestion des Partenaires/Marchants')
.addBearerAuth() .addBearerAuth()
//.addServer('http://localhost:3000', 'Développement local')
// .addServer('https://api.example.com', 'Production')
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document); SwaggerModule.setup('api-docs', app, document);
app.enableCors({ origin: '*' })
// Démarrage du serveur
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
await app.listen(port); await app.listen(port);
logger.log(`Application running on http://localhost:${port}`); logger.log(`Application running on http://localhost:${port}`);
logger.log(`Swagger documentation available at http://localhost:${port}/api/docs`); logger.log(`Swagger documentation available at http://localhost:${port}/api-docs`);
} }
bootstrap(); bootstrap()

View File

@ -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<user.UserResponse> {
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<user.PaginatedUserResponse> {
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<user.UserResponse> {
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<user.UserResponse> {
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<user.UserResponse> {
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<user.UserResponse> {
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<user.UserResponse[]> {
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<user.UserResponse[]> {
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<any> {
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<any[]> {
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<any> {
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);
}
}
}

View File

@ -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<user.UserResponse> {
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<user.PaginatedUserResponse> {
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<user.UserResponse> {
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<user.UserResponse> {
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<user.UserResponse> {
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<user.UserResponse> {
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<user.UserResponse[]> {
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<user.UserResponse[]> {
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);
}
}
}

View File

@ -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<string, any>;
merchantRoles?: string[]; // Rôles client uniquement (merchant-admin, merchant-manager, merchant-support)
createdTimestamp?: number;
merchantOwnerId: string;
constructor(partial?: Partial<MerchanUser>) {
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<string, any>;
@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<string, any>;
@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<string, any>;
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;
}

View File

@ -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<string, any>;
clientRoles?: string[]; // Rôles client uniquement (admin, merchant, support)
createdTimestamp?: number;
constructor(partial?: Partial<User>) {
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<string, any>;
@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<string, any>;
@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<string, any>;
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;
}

View File

@ -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<MerchantUserResponse> {
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<MerchantUserResponse[]> {
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<MerchantUserResponse> {
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<void> {
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<boolean> {
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;
}
}
}

View File

@ -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<TokenResponse> {
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
}
// === GET USER BY ID ===
async getUserById(userId: string): Promise<UserResponse> {
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<UserResponse> {
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<PaginatedUserResponse> {
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<UserResponse> {
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<string | null> {
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<void> {
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<UserResponse> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
try {
const users = await this.keycloakApi.findUserByUsername(username);
return users.length > 0;
} catch {
return false;
}
}
async getUserClientRoles(userId: string): Promise<string[]> {
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<UserResponse[]> {
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<UserResponse[]> {
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');
}
}
}

View File

@ -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 {}