Compare commits

..

10 Commits

Author SHA1 Message Date
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
557c899d0f fix swagger json 2025-12-01 22:52:52 +00:00
Mamadou Khoussa [028918 DSI/DAC/DIF/DS]
ca698e1c8a fix swagger json 2025-12-01 22:51:21 +00:00
diallolatoile
2b4f4ce1b8 feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-11-10 20:41:25 +00:00
diallolatoile
403baaa0ef feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-11-10 20:18:34 +00:00
diallolatoile
cbdfdfa297 feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-11-10 01:28:55 +00:00
diallolatoile
fefa5aef42 feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-11-10 01:27:29 +00:00
diallolatoile
389488bf28 feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-11-04 21:06:44 +00:00
diallolatoile
ad751d96bd feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-11-03 17:45:19 +00:00
diallolatoile
65494d5af2 feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-11-03 17:37:20 +00:00
diallolatoile
bc47bca6b5 feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2025-10-29 05:54:32 +00:00
28 changed files with 3487 additions and 2680 deletions

View File

@ -1,28 +1,60 @@
# .env-sample # .env
NODE_ENV=development NODE_ENV=development
PORT=3000 PORT=3000
KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com # === CONFIGURATION DES TESTS STARTUP ===
KEYCLOAK_REALM=dcb-dev RUN_STARTUP_TESTS=false
TEST_CLEANUP_DELAY_MS=100
TEST_TIMEOUT_MS=30000
TEST_USER_PASSWORD=SecureTempPass123!
TEST_EMAIL_DOMAIN=dcb-test.com
TEST_DEFAULT_PASSWORD=SecureTempPass123!
KEYCLOAK_JWKS_URI=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev/protocol/openid-connect/certs # === CONFIGURATION DE SÉCURITÉ ===
KEYCLOAK_ISSUER=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev RUN_SECURITY_TESTS=false
SECURITY_TEST_TIMEOUT=300000
KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwfT6BGerQyJ7EOFcgN1DLxRh/8g3cCN5qNZyeLQc6524Lsw3voMD2HJddvAunCcn6Eux2LTYXPzLvZc8829Sa5ksTzINyPqg9GFZa5+GAifMW6DfvQcxGyl5yvduCWxOSmST3PYN9UkCFP20e3gDLRox9rNe1/17xkDJwByJh/Xld/m07vHgyglDNRGkA/YW3A1JuAKgJjAstLOyeK+UGdMeJmD/5TF/yoBI/FsjW/OjZ78wP3dfkGo5zG2EOkK+39evU7HxB4jgL5SBhw32GLPVhtyCMnUW6IlsQhDSDWXqBdMCO0/hdrjyznyM7ZJqkUN7KAFKqcJsnja9mBNT4QIDAQAB # === VALIDATION DES ENTREES ===
MAX_USERNAME_LENGTH=50
MIN_USERNAME_LENGTH=3
ALLOWED_EMAIL_DOMAINS=dcb-test.com,pixpay.sn
# === RATE LIMITING ===
MAX_REQUESTS_PER_MINUTE=60
RATE_LIMIT_BLOCK_DURATION=300000
# === SÉCURITÉ DES SESSIONS ===
SESSION_TIMEOUT=900000
JWT_EXPIRATION=3600000
# === SURVEILLANCE ===
LOG_SECURITY_EVENTS=true
SECURITY_EVENT_RETENTION_DAYS=30
# === CONFIGURATION KEYCLOAK ===
KEYCLOAK_SERVER_URL=https://iam.dcb.pixpay.sn
KEYCLOAK_REALM=dcb-prod
KEYCLOAK_JWKS_URI=https://iam.dcb.pixpay.sn/realms/dcb-prod/protocol/openid-connect/certs
KEYCLOAK_ISSUER=https://iam.dcb.pixpay.sn/realms/dcb-prod
KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA01nspe5Sol9YAzm98wnQO1MvhRgJZSaOhozOHJEBm5VW5wLEEfcTlakzr/xXRjFYB9jySeaDWyhE6qGKuRK2Kx20qt3CuwT52ZSy97dKjJbgCxBCOymxKLJRdDfwtKOAayk5oCHqGp+cJTShnd9jVggYyTdqGqMWlpeiBKqvpgyldndwIfvDxPpPwsx/mwKV7S4sSTsONxSIB6zK+RumeYKOF0BskIxBw4tG3V5eicrECCKX/jP8rYFclBPXhxnLbbaHa21XAwQHfOioip3YfwPYF9GKTJEhM8ziJdTKikAtiwFm/Zvn1foLaF1MDLpV9yLrK0H1oa3y7j5p7tqHbQIDAQAB
KEYCLOAK_CLIENT_ID=dcb-user-service-cc-app
KEYCLOAK_CLIENT_SECRET=IFNQWjBbcW6dXqQO76X5OZb1lL0esO30
KEYCLOAK_CLIENT_ID=dcb-user-service-pwd
KEYCLOAK_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2
KEYCLOAK_VALIDATION_MODE=offline KEYCLOAK_VALIDATION_MODE=offline
KEYCLOAK_TOKEN_BUFFER_SECONDS=30 KEYCLOAK_TOKEN_BUFFER_SECONDS=30
KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin KEYCLOAK_TEST_USER_ADMIN=bo-admin
KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025 KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025
KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant KEYCLOAK_TEST_USER_MERCHANT=bo-partner
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025 KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025
KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support KEYCLOAK_TEST_USER_SUPPORT=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,9 +14,8 @@ 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 { StartupServiceInitialization } from './auth/services/startup.service';
import { StartupService } from './auth/services/startup.service';
@Module({ @Module({
imports: [ imports: [
@ -69,12 +68,11 @@ import { StartupService } from './auth/services/startup.service';
// Feature Modules // Feature Modules
AuthModule, AuthModule,
ApiModule, HubUsersModule,
UsersModule,
], ],
providers: [ providers: [
StartupService, StartupServiceInitialization,
// Global Authentication Guard // Global Authentication Guard
{ {
provide: APP_GUARD, provide: APP_GUARD,

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,62 @@ 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) {
return this.usersService.getCompleteUserProfile(user.sub, user);
}
/** -------------------------------
* VALIDATE TOKEN (protected)
* ------------------------------- */
@Get('validate')
@ApiOperation({
summary: 'Validate token',
description: 'Check if the current token is valid and get user information'
})
@ApiBearerAuth()
@ApiResponse({
status: 200,
description: 'Token is valid'
})
@ApiResponse({
status: 401,
description: 'Unauthorized - invalid token',
type: ErrorResponseDto
})
async validateToken(@AuthenticatedUser() user: any) {
this.logger.log(`Token validation requested for user: ${user.preferred_username}`);
return {
valid: true,
user: {
id: user.sub,
username: user.preferred_username,
email: user.email,
firstName: user.given_name,
lastName: user.family_name,
roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [],
},
expires_in: user.exp ? user.exp - Math.floor(Date.now() / 1000) : 0,
};
}
}

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,153 @@
export interface KeycloakUser {
id?: string;
username: string;
email: string; // Rendre obligatoire
firstName: string;
lastName: string;
enabled: boolean;
emailVerified: boolean;
attributes?: {
merchantPartnerId?: string[];
createdBy?: string[];
createdByUsername?: string[];
userType?: string[];
[key: string]: string[] | undefined;
};
createdTimestamp?: number;
}
export interface KeycloakRole {
id: string;
name: string;
description?: string;
composite?: boolean;
clientRole?: boolean;
containerId?: string;
}
export interface CreateUserData {
username: string;
email: string;
firstName: string;
lastName: string;
password?: string;
passwordTemporary?: boolean;
enabled?: boolean;
emailVerified?: boolean;
merchantPartnerId?: string | null;
clientRoles: UserRole[];
attributes?: {
userStatus?: string[];
lastLogin?: string[];
merchantPartnerId?: string[];
createdBy?: string[];
createdByUsername?: string[];
userType?: string[];
[key: string]: string[] | undefined;
};
}
export enum UserType {
HUB = 'HUB',
MERCHANT_PARTNER = 'MERCHANT'
}
export enum UserRole {
// Rôles Hub (sans merchantPartnerId)
DCB_ADMIN = 'dcb-admin',
DCB_SUPPORT = 'dcb-support',
DCB_PARTNER = 'dcb-partner',
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
}
export interface HubUser {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
enabled: boolean;
emailVerified: boolean;
createdBy: string;
createdByUsername: string;
createdTimestamp: number;
userType: string;
attributes?: {
userStatus?: string[];
lastLogin?: string[];
merchantPartnerId?: string[];
createdBy?: string[];
createdByUsername?: string[];
userType?: string[];
[key: string]: string[] | undefined;
};
}
export interface CreateHubUserData {
username: string;
email: string;
firstName: string;
lastName: string;
password?: string;
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
enabled?: boolean;
emailVerified?: boolean;
}
export interface HubUserStats {
totalAdmins: number;
totalSupports: number;
activeUsers: number;
inactiveUsers: number;
}
export interface MerchantStats {
totalAdmins: number;
totalManagers: number;
totalSupports: number;
activeUsers: number;
inactiveUsers: number;
}
export interface UserQueryDto {
page?: number;
limit?: number;
search?: string;
userType?: UserType;
merchantPartnerId?: string;
enabled?: boolean;
}
export interface UserResponse {
user: KeycloakUser;
message: string;
}
export interface PaginatedUsersResponse {
users: KeycloakUser[];
total: number;
page: number;
limit: number;
totalPages: number;
}
// Pour l'authentification
export interface LoginDto {
username: string;
password: string;
}
export interface TokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
refresh_expires_in?: number;
scope?: string;
}

View File

@ -1,42 +1,76 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { KeycloakApiService } from './keycloak-api.service'; import { KeycloakApiService } from './keycloak-api.service';
@Injectable() interface TestResults {
export class StartupService implements OnModuleInit { connection: { [key: string]: string };
private readonly logger = new Logger(StartupService.name); }
private initialized = false;
private error: string | null = null;
constructor(private readonly keycloakApiService: KeycloakApiService) {} @Injectable()
export class StartupServiceInitialization implements OnModuleInit {
private readonly logger = new Logger(StartupServiceInitialization.name);
private isInitialized = false;
private initializationError: string | null = null;
private testResults: TestResults = {
connection: {},
};
constructor(
private readonly keycloakApiService: KeycloakApiService,
) {}
async onModuleInit() { async onModuleInit() {
this.logger.log('Vérification de la disponibilité de Keycloak...'); this.logger.log('🚀 Démarrage des tests de connexion');
try { try {
const available = await this.keycloakApiService.checkKeycloakAvailability(); await this.validateKeycloakConnection();
if (!available) throw new Error('Keycloak non accessible');
this.isInitialized = true;
const serviceConnected = await this.keycloakApiService.checkServiceConnection(); this.logger.log('✅ Tests de connexion terminés avec succès');
if (!serviceConnected) throw new Error('Échec de la connexion du service à Keycloak'); } catch (error: any) {
this.initializationError = error.message;
this.initialized = true; this.logger.error(`❌ Échec des tests de connexion: ${error.message}`);
this.logger.log('Keycloak disponible et connexion du service réussie');
} catch (err: any) {
this.error = err.message;
this.logger.error('Échec de la vérification de Keycloak', err);
} }
} }
// === 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() { getStatus() {
return { return {
status: this.initialized ? 'healthy' : 'unhealthy', status: this.isInitialized ? 'healthy' : 'unhealthy',
keycloakConnected: this.initialized, keycloakConnected: this.isInitialized,
testResults: this.testResults,
timestamp: new Date(), timestamp: new Date(),
error: this.error, error: this.initializationError,
}; };
} }
isHealthy(): boolean { isHealthy(): boolean {
return this.initialized; return this.isInitialized;
} }
}
getTestResults(): TestResults {
return this.testResults;
}
}

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,22 +10,40 @@ 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;
}
@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;
// Cache pour le token de service account // Cache pour le token de service account
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,
@ -34,6 +52,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 +69,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 +110,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,
@ -80,7 +127,7 @@ export class TokenService {
// Stocker le token et ses métadonnées // Stocker le token et ses métadonnées
this.storeUserToken(response.data); this.storeUserToken(response.data);
this.logger.log(`User token acquired for: ${username}`); this.logger.log(`User token acquired for: ${username}`);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
@ -228,30 +275,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 +307,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);
return false;
// Fallback: validation basique du token
try {
const decoded = this.decodeToken(token);
return !!decoded && !!decoded.sub;
} catch {
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 +494,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,624 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Request,
HttpCode,
HttpStatus,
ParseUUIDPipe,
ForbiddenException,
Logger,
BadRequestException
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiProperty,
getSchemaPath
} from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsBoolean,
MinLength,
IsString,
ValidateIf
} from 'class-validator';
import { HubUsersService } from '../services/hub-users.service';
import { UserRole, UserType } from '../../auth/services/keycloak-user.model';
import { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes';
import { Resource, Scopes } from 'nest-keycloak-connect';
import { CreateUserData, User } from '../models/hub-user.model';
// ===== DTO SPÉCIFIQUES AUX HUB USERS =====
export class CreateHubUserDto {
@ApiProperty({ description: 'Username for the user' })
@IsNotEmpty({ message: 'Username is required' })
@IsString()
@MinLength(3, { message: 'Username must be at least 3 characters' })
username: string;
@ApiProperty({ description: 'Email address' })
@IsNotEmpty({ message: 'Email is required' })
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@ApiProperty({ description: 'First name' })
@IsNotEmpty({ message: 'First name is required' })
@IsString()
firstName: string;
@ApiProperty({ description: 'Last name' })
@IsNotEmpty({ message: 'Last name is required' })
@IsString()
lastName: string;
@ApiProperty({ description: 'Password for the user' })
@IsNotEmpty({ message: 'Password is required' })
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
@ApiProperty({
enum: UserRole,
description: 'Role for the user',
examples: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER]
})
@IsEnum(UserRole, { message: 'Invalid role' })
@IsNotEmpty({ message: 'Role is required' })
role: UserRole;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean({ message: 'Enabled must be a boolean' })
enabled?: boolean = true;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean({ message: 'EmailVerified must be a boolean' })
emailVerified?: boolean = true;
@ApiProperty({
enum: UserType,
description: 'Type of user',
example: UserType.HUB
})
@IsEnum(UserType, { message: 'Invalid user type' })
@IsNotEmpty({ message: 'User type is required' })
userType: UserType;
// Pas de merchantPartnerId pour les hub users
}
export class UpdateHubUserDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
firstName?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
lastName?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class ResetHubUserPasswordDto {
@ApiProperty({ description: 'New password' })
@IsNotEmpty()
@IsString()
@MinLength(8)
newPassword: string;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean()
temporary?: boolean = true;
}
export class UpdateHubUserRoleDto {
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'New role for the user'
})
@IsEnum(UserRole, { message: 'Invalid role' })
@IsNotEmpty({ message: 'Role is required' })
role: UserRole;
}
export class HubUserResponse {
@ApiProperty({ description: 'User ID' })
id: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'User role'
})
role: UserRole;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ description: 'User creator ID' })
createdBy: string;
@ApiProperty({ description: 'User creator username' })
createdByUsername: string;
@ApiProperty({ enum: ['HUB'], description: 'User type' })
userType: UserType;
@ApiProperty({ description: 'Creation timestamp' })
createdTimestamp: number;
@ApiProperty({ required: false, description: 'Last login timestamp' })
lastLogin?: number;
}
export class MerchantUserResponse {
@ApiProperty({ description: 'User ID' })
id: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
description: 'User role'
})
role: UserRole;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ required: false, description: 'Merchant partner ID' })
merchantPartnerId?: string;
@ApiProperty({ description: 'User creator ID' })
createdBy: string;
@ApiProperty({ description: 'User creator username' })
createdByUsername: string;
@ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' })
userType: 'HUB' | 'MERCHANT';
@ApiProperty({ description: 'Creation timestamp' })
createdTimestamp: number;
@ApiProperty({ required: false, description: 'Last login timestamp' })
lastLogin?: number;
}
export class HubUserProfileResponse {
@ApiProperty({ description: 'User ID' })
id: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
@ApiProperty({
description: 'Client roles',
type: [String],
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER]
})
clientRoles: string[];
@ApiProperty({ required: false, description: 'User creator ID' })
createdBy?: string;
@ApiProperty({ required: false, description: 'User creator username' })
createdByUsername?: string;
}
export class MessageResponse {
@ApiProperty({ description: 'Response message' })
message: string;
}
// Mapper functions
function mapToHubUserResponse(user: User): HubUserResponse {
return {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
enabled: user.enabled,
emailVerified: user.emailVerified,
createdBy: user.createdBy,
createdByUsername: user.createdByUsername,
userType: user.userType,
createdTimestamp: user.createdTimestamp,
lastLogin: user.lastLogin,
};
}
// Mapper functions
function mapToMerchantUserResponse(user: User): MerchantUserResponse {
return {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
enabled: user.enabled,
emailVerified: user.emailVerified,
merchantPartnerId: user.merchantPartnerId,
createdBy: user.createdBy,
createdByUsername: user.createdByUsername,
userType: user.userType,
createdTimestamp: user.createdTimestamp,
lastLogin: user.lastLogin,
};
}
function mapToHubUserProfileResponse(profile: any): HubUserProfileResponse {
return {
id: profile.id,
username: profile.username,
email: profile.email,
firstName: profile.firstName,
lastName: profile.lastName,
emailVerified: profile.emailVerified,
enabled: profile.enabled,
clientRoles: profile.clientRoles,
createdBy: profile.createdBy,
createdByUsername: profile.createdByUsername,
};
}
// ===== CONTROLLER POUR LES UTILISATEURS HUB =====
@ApiTags('Hub Users')
@ApiBearerAuth()
@Controller('hub-users')
@Resource(RESOURCES.HUB_USER)
export class HubUsersController {
constructor(private readonly usersService: HubUsersService) {}
private readonly logger = new Logger(HubUsersController.name);
// ===== ROUTES SANS PARAMÈTRES =====
@Get()
@ApiOperation({
summary: 'Get all hub users',
description: 'Returns all hub users (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
})
@ApiResponse({
status: 200,
description: 'Hub users retrieved successfully',
type: [HubUserResponse]
})
@Scopes(SCOPES.READ)
async getAllHubUsers(@Request() req): Promise<HubUserResponse[]> {
const userId = req.user.sub;
const users = await this.usersService.getAllHubUsers(userId);
return users.map(mapToHubUserResponse);
}
@Get('partners/dcb-partners')
@ApiOperation({
summary: 'Get all DCB_PARTNER users only',
description: 'Returns only DCB_PARTNER users (excludes DCB_ADMIN and DCB_SUPPORT)'
})
@ApiResponse({
status: 200,
description: 'DCB_PARTNER users retrieved successfully',
type: [HubUserResponse]
})
@Scopes(SCOPES.READ)
async getAllDcbPartners(@Request() req): Promise<HubUserResponse[]> {
const userId = req.user.sub;
const users = await this.usersService.getAllDcbPartners(userId);
return users.map(mapToHubUserResponse);
}
@Post()
@ApiOperation({
summary: 'Create a new hub user',
description: 'Create a hub user with specific role (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
})
@ApiResponse({
status: 201,
description: 'Hub user created successfully',
type: HubUserResponse
})
@Scopes(SCOPES.WRITE)
async createHubUser(
@Body() createUserDto: CreateHubUserDto,
@Request() req
): Promise<HubUserResponse> {
// Debug complet
this.logger.debug('🔍 === CONTROLLER - CREATE HUB USER ===');
this.logger.debug('Request headers:', req.headers);
this.logger.debug('Content-Type:', req.headers['content-type']);
this.logger.debug('Raw body exists:', !!req.body);
this.logger.debug('CreateHubUserDto received:', createUserDto);
this.logger.debug('DTO structure:', {
username: createUserDto.username,
email: createUserDto.email,
firstName: createUserDto.firstName,
lastName: createUserDto.lastName,
role: createUserDto.role,
userType: createUserDto.userType,
});
this.logger.debug('====================================');
// Validation manuelle renforcée
const requiredFields = ['username', 'email', 'firstName', 'lastName', 'password', 'role', 'userType'];
const missingFields = requiredFields.filter(field => !createUserDto[field]);
if (missingFields.length > 0) {
throw new BadRequestException(`Missing required fields: ${missingFields.join(', ')}`);
}
if (createUserDto.userType !== UserType.HUB) {
throw new BadRequestException('User type must be HUB for hub users');
}
const userId = req.user.sub;
const userData: CreateUserData = {
...createUserDto,
};
this.logger.debug('UserData passed to service:', userData);
try {
const user = await this.usersService.createHubUser(userId, userData);
return mapToHubUserResponse(user);
} catch (error) {
this.logger.error('Error creating hub user:', error);
throw error;
}
}
// ===== ROUTES AVEC PARAMÈTRES STATIQUES =====
@Get('all-users')
@ApiOperation({
summary: 'Get global users overview',
description: 'Returns hub users and all merchant users (Admin only)'
})
@ApiResponse({
status: 200,
description: 'Global overview retrieved successfully',
schema: {
type: 'object',
properties: {
hubUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } },
merchantUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } },
statistics: {
type: 'object',
properties: {
totalHubUsers: { type: 'number' },
totalMerchantUsers: { type: 'number' },
totalUsers: { type: 'number' }
}
}
}
}
})
@Scopes(SCOPES.READ)
async getGlobalUsersOverview(@Request() req): Promise<any> {
const userId = req.user.sub;
const isAdmin = await this.usersService.isUserHubAdminOrSupport(userId);
if (!isAdmin) {
throw new ForbiddenException('Only Hub administrators can access global overview');
}
const hubUsers = await this.usersService.getAllHubUsers(userId);
const merchantUsers = await this.usersService.getMyMerchantUsers(userId);
return {
hubUsers: hubUsers.map(mapToHubUserResponse),
merchantUsers: merchantUsers.map(mapToMerchantUserResponse),
statistics: {
totalHubUsers: hubUsers.length,
totalMerchantUsers: merchantUsers.length,
totalUsers: hubUsers.length + merchantUsers.length
}
};
}
@Get('profile/:id')
@ApiOperation({ summary: 'Get complete user profile' })
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
type: HubUserProfileResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.READ)
async getCompleteUserProfile(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<HubUserProfileResponse> {
const tokenUser = req.user;
const profile = await this.usersService.getCompleteUserProfile(id, tokenUser);
return mapToHubUserProfileResponse(profile);
}
@Get('role/:role')
@ApiOperation({ summary: 'Get hub users by role' })
@ApiResponse({
status: 200,
description: 'Hub users retrieved successfully',
type: [HubUserResponse]
})
@ApiParam({
name: 'role',
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'User role'
})
@Scopes(SCOPES.READ)
async getHubUsersByRole(
@Param('role') role: UserRole,
@Request() req
): Promise<HubUserResponse[]> {
const userId = req.user.sub;
const users = await this.usersService.getHubUsersByRole(role, userId);
return users.map(mapToHubUserResponse);
}
// ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES =====
@Get(':id')
@ApiOperation({ summary: 'Get hub user by ID' })
@ApiResponse({
status: 200,
description: 'Hub user retrieved successfully',
type: HubUserResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.READ)
async getHubUserById(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const user = await this.usersService.getHubUserById(id, userId);
return mapToHubUserResponse(user);
}
@Put(':id')
@ApiOperation({ summary: 'Update a hub user' })
@ApiResponse({
status: 200,
description: 'Hub user updated successfully',
type: HubUserResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async updateHubUser(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateUserDto: UpdateHubUserDto,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const user = await this.usersService.updateHubUser(id, updateUserDto, userId);
return mapToHubUserResponse(user);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a hub user' })
@ApiResponse({ status: 200, description: 'Hub user deleted successfully' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.DELETE)
async deleteHubUser(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.usersService.deleteHubUser(id, userId);
return { message: 'Hub user deleted successfully' };
}
@Put(':id/role')
@ApiOperation({ summary: 'Update hub user role' })
@ApiResponse({
status: 200,
description: 'User role updated successfully',
type: HubUserResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async updateHubUserRole(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateRoleDto: UpdateHubUserRoleDto,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const user = await this.usersService.updateHubUserRole(id, updateRoleDto.role, userId);
return mapToHubUserResponse(user);
}
@Post(':id/reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset hub user password' })
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async resetHubUserPassword(
@Param('id', ParseUUIDPipe) id: string,
@Body() resetPasswordDto: ResetHubUserPasswordDto,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.usersService.resetUserPassword(
id,
resetPasswordDto.newPassword,
resetPasswordDto.temporary,
userId
);
return { message: 'Password reset successfully' };
}
}

View File

@ -0,0 +1,479 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Request,
HttpCode,
HttpStatus,
ParseUUIDPipe,
ForbiddenException,
Logger,
BadRequestException,
InternalServerErrorException
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiProperty
} from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsBoolean,
MinLength,
IsString,
ValidateIf
} from 'class-validator';
import { HubUsersService } from '../services/hub-users.service';
import { UserRole, UserType } from '../../auth/services/keycloak-user.model';
import { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes';
import { Resource, Scopes } from 'nest-keycloak-connect';
import { CreateUserData, User } from '../models/hub-user.model';
// ===== DTO SPÉCIFIQUES AUX MERCHANT USERS =====
export class CreateMerchantUserDto {
@ApiProperty({ description: 'Username for the user' })
@IsNotEmpty({ message: 'Username is required' })
@IsString()
@MinLength(3, { message: 'Username must be at least 3 characters' })
username: string;
@ApiProperty({ description: 'Email address' })
@IsNotEmpty({ message: 'Email is required' })
@IsEmail({}, { message: 'Invalid email format' })
email: string;
@ApiProperty({ description: 'First name' })
@IsNotEmpty({ message: 'First name is required' })
@IsString()
firstName: string;
@ApiProperty({ description: 'Last name' })
@IsNotEmpty({ message: 'Last name is required' })
@IsString()
lastName: string;
@ApiProperty({ description: 'Password for the user' })
@IsNotEmpty({ message: 'Password is required' })
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string;
@ApiProperty({
enum: UserRole,
description: 'Role for the user',
examples: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
})
@IsEnum(UserRole, { message: 'Invalid role' })
@IsNotEmpty({ message: 'Role is required' })
role: UserRole;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean({ message: 'Enabled must be a boolean' })
enabled?: boolean = true;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean({ message: 'EmailVerified must be a boolean' })
emailVerified?: boolean = true;
@ApiProperty({
enum: UserType,
description: 'Type of user',
example: UserType.MERCHANT_PARTNER
})
@IsEnum(UserType, { message: 'Invalid user type' })
@IsNotEmpty({ message: 'User type is required' })
userType: UserType;
@ApiProperty({ required: false })
@IsOptional()
@ValidateIf((o) => o.userType === UserType.MERCHANT_PARTNER && o.role !== UserRole.DCB_PARTNER)
@IsString({ message: 'Merchant partner ID must be a string' })
merchantPartnerId?: string | null;
}
export class ResetMerchantUserPasswordDto {
@ApiProperty({ description: 'New password' })
@IsNotEmpty()
@IsString()
@MinLength(8)
newPassword: string;
@ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean()
temporary?: boolean = true;
}
export class UpdateMerchantUserDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
firstName?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
lastName?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class UpdateMerchantUserRoleDto {
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'New role for the user'
})
@IsEnum(UserRole, { message: 'Invalid role' })
@IsNotEmpty({ message: 'Role is required' })
role: UserRole;
}
export class MerchantUserResponse {
@ApiProperty({ description: 'User ID' })
id: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
description: 'User role'
})
role: UserRole;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ required: false, description: 'Merchant partner ID' })
merchantPartnerId?: string;
@ApiProperty({ description: 'User creator ID' })
createdBy: string;
@ApiProperty({ description: 'User creator username' })
createdByUsername: string;
@ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' })
userType: 'HUB' | 'MERCHANT';
@ApiProperty({ description: 'Creation timestamp' })
createdTimestamp: number;
@ApiProperty({ required: false, description: 'Last login timestamp' })
lastLogin?: number;
}
export class UserProfileResponse {
@ApiProperty({ description: 'User ID' })
id: string;
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Email address' })
email: string;
@ApiProperty({ description: 'First name' })
firstName: string;
@ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
@ApiProperty({ description: 'Client roles', type: [String] })
clientRoles: string[];
@ApiProperty({ required: false, description: 'Merchant partner ID' })
merchantPartnerId?: string;
@ApiProperty({ required: false, description: 'User creator ID' })
createdBy?: string;
@ApiProperty({ required: false, description: 'User creator username' })
createdByUsername?: string;
}
export class MessageResponse {
@ApiProperty({ description: 'Response message' })
message: string;
}
// Mapper functions
function mapToMerchantUserResponse(user: User): MerchantUserResponse {
return {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
enabled: user.enabled,
emailVerified: user.emailVerified,
merchantPartnerId: user.merchantPartnerId,
createdBy: user.createdBy,
createdByUsername: user.createdByUsername,
userType: user.userType,
createdTimestamp: user.createdTimestamp,
lastLogin: user.lastLogin,
};
}
function mapToUserProfileResponse(profile: any): UserProfileResponse {
return {
id: profile.id,
username: profile.username,
email: profile.email,
firstName: profile.firstName,
lastName: profile.lastName,
emailVerified: profile.emailVerified,
enabled: profile.enabled,
clientRoles: profile.clientRoles,
merchantPartnerId: profile.merchantPartnerId,
createdBy: profile.createdBy,
createdByUsername: profile.createdByUsername,
};
}
// ===== CONTROLLER POUR LES UTILISATEURS MERCHANT =====
@ApiTags('Merchant Users')
@ApiBearerAuth()
@Controller('merchant-users')
@Resource(RESOURCES.MERCHANT_USER)
export class MerchantUsersController {
constructor(private readonly usersService: HubUsersService) {}
// ===== ROUTES SANS PARAMÈTRES D'ABORD =====
@Get()
@ApiOperation({
summary: 'Get merchant users for current user merchant',
description: 'Returns merchant users. Hub admins/support see all merchants users, others see only their own merchant users.'
})
@ApiResponse({
status: 200,
description: 'Merchant users retrieved successfully',
type: [MerchantUserResponse]
})
@Scopes(SCOPES.READ)
async getMyMerchantUsers(@Request() req): Promise<MerchantUserResponse[]> {
const userId = req.user.sub;
try {
const users = await this.usersService.getMyMerchantUsers(userId);
return users.map(mapToMerchantUserResponse);
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
}
throw new InternalServerErrorException('Could not retrieve merchant users');
}
}
@Post()
@ApiOperation({
summary: 'Create a new merchant user',
description: 'Create a merchant user with specific role and merchant partner association'
})
@ApiResponse({
status: 201,
description: 'Merchant user created successfully',
type: MerchantUserResponse
})
@Scopes(SCOPES.WRITE)
async createMerchantUser(
@Body() createUserDto: CreateMerchantUserDto,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
if (!createUserDto.merchantPartnerId && !createUserDto.role.includes(UserRole.DCB_PARTNER)) {
throw new BadRequestException('merchantPartnerId is required for merchant users except DCB_PARTNER');
}
const userData: CreateUserData = {
...createUserDto,
};
const user = await this.usersService.createMerchantUser(userId, userData);
return mapToMerchantUserResponse(user);
}
// ===== ROUTES AVEC PARAMÈTRES STATIQUES AVANT LES PARAMÈTRES DYNAMIQUES =====
@Get('profile/:id')
@ApiOperation({ summary: 'Get complete user profile' })
@ApiResponse({
status: 200,
description: 'User profile retrieved successfully',
type: UserProfileResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.READ)
async getCompleteUserProfile(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<UserProfileResponse> {
const tokenUser = req.user;
const profile = await this.usersService.getCompleteUserProfile(id, tokenUser);
return mapToUserProfileResponse(profile);
}
@Get('merchant-partner/:userId')
@ApiOperation({ summary: 'Get merchant partner ID for a user' })
@ApiResponse({
status: 200,
description: 'Merchant partner ID retrieved successfully',
schema: {
type: 'object',
properties: {
merchantPartnerId: { type: 'string', nullable: true }
}
}
})
@ApiParam({ name: 'userId', description: 'User ID' })
@Scopes(SCOPES.READ)
async getUserMerchantPartnerId(
@Param('userId', ParseUUIDPipe) userId: string
): Promise<{ merchantPartnerId: string | null }> {
const merchantPartnerId = await this.usersService.getUserMerchantPartnerId(userId);
return { merchantPartnerId };
}
// ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES EN DERNIER =====
@Get(':id')
@ApiOperation({ summary: 'Get merchant user by ID' })
@ApiResponse({
status: 200,
description: 'Merchant user retrieved successfully',
type: MerchantUserResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.READ)
async getMerchantUserById(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
const user = await this.usersService.getMerchantUserById(id, userId);
return mapToMerchantUserResponse(user);
}
@Put(':id')
@ApiOperation({ summary: 'Update a merchant user' })
@ApiResponse({
status: 200,
description: 'Merchant user updated successfully',
type: MerchantUserResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async updateMerchantUser(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateUserDto: UpdateMerchantUserDto,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
const user = await this.usersService.updateMerchantUser(id, updateUserDto, userId);
return mapToMerchantUserResponse(user);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a merchant user' })
@ApiResponse({ status: 200, description: 'Merchant user deleted successfully' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.DELETE)
async deleteMerchantUser(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.usersService.deleteMerchantUser(id, userId);
return { message: 'Merchant user deleted successfully' };
}
@Put(':id/role')
@ApiOperation({ summary: 'Update merchant user role' })
@ApiResponse({
status: 200,
description: 'User role updated successfully',
type: MerchantUserResponse
})
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async updateMerchantUserRole(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateRoleDto: UpdateMerchantUserRoleDto,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
const user = await this.usersService.updateMerchantUserRole(id, updateRoleDto.role, userId);
return mapToMerchantUserResponse(user);
}
@Post(':id/reset-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset merchant user password' })
@ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async resetMerchantUserPassword(
@Param('id', ParseUUIDPipe) id: string,
@Body() resetPasswordDto: ResetMerchantUserPasswordDto,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.usersService.resetUserPassword(
id,
resetPasswordDto.newPassword,
resetPasswordDto.temporary,
userId
);
return { message: 'Password reset successfully' };
}
}

View File

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

View File

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

View File

@ -0,0 +1,641 @@
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
import { CreateUserData as KeycloakCreateUserData, UserRole, KeycloakUser, LoginDto, TokenResponse, KeycloakRole } from '../../auth/services/keycloak-user.model';
import { CreateUserData, User, UserType } from '../models/hub-user.model';
// Configuration Centralisée
const SECURITY_CONFIG = {
ROLES: {
HUB: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
MERCHANT: [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT,
]
},
VALIDATION: {
MIN_PASSWORD_LENGTH: 8,
MAX_USERNAME_LENGTH: 50,
MIN_USERNAME_LENGTH: 3
}
};
@Injectable()
export class HubUsersService {
private readonly logger = new Logger(HubUsersService.name);
constructor(private readonly keycloakApi: KeycloakApiService) {}
// === PUBLIC INTERFACE ===
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
}
async getCompleteUserProfile(userId: string, tokenUser: any) {
try {
const [userDetails, userRoles] = await Promise.all([
this.keycloakApi.getUserById(userId, userId),
this.keycloakApi.getUserClientRoles(userId)
]);
return this.buildUserProfile(userId, tokenUser, userDetails, userRoles);
} catch (error) {
throw new InternalServerErrorException('Could not retrieve user profile');
}
}
// === HUB USERS MANAGEMENT ===
async getAllHubUsers(requesterId: string): Promise<User[]> {
await this.validateHubUserAccess(requesterId);
return this.processUsersByType(await this.keycloakApi.getAllUsers(), UserType.HUB);
}
/**
* Récupère uniquement les utilisateurs DCB_PARTNER
*/
async getAllDcbPartners(requesterId: string): Promise<User[]> {
await this.validateHubUserAccess(requesterId);
const allUsers = await this.keycloakApi.getAllUsers();
const dcbPartners: User[] = [];
for (const user of allUsers) {
if (!user.id) continue;
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
// Vérifier si l'utilisateur est un DCB_PARTNER
const isDcbPartner = userRoles.some(role =>
role.name === UserRole.DCB_PARTNER
);
if (isDcbPartner) {
const mappedUser = this.mapToUser(user, userRoles);
dcbPartners.push(mappedUser);
}
} catch (error) {
this.logger.warn(`Could not process user ${user.id} for DCB_PARTNER filter: ${error.message}`);
}
}
this.logger.log(`Retrieved ${dcbPartners.length} DCB_PARTNER users`);
return dcbPartners;
}
async getHubUserById(userId: string, requesterId: string): Promise<User> {
await this.validateHubUserAccess(requesterId);
return this.getValidatedUser(userId, requesterId, UserType.HUB);
}
async createHubUser(creatorId: string, userData: CreateUserData): Promise<User> {
this.validateUserCreationData(userData, UserType.HUB);
await this.validateHubUserAccess(creatorId);
await this.validateUserUniqueness(userData.username, userData.email);
const keycloakUserData = this.buildKeycloakUserData(userData);
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
this.logger.log(`Hub user created: ${userData.username}`);
return this.getHubUserById(userId, creatorId);
}
async updateHubUser(
userId: string,
updates: Partial<Pick<User, 'firstName' | 'lastName' | 'email' | 'enabled'>>,
requesterId: string
): Promise<User> {
await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => {
await this.keycloakApi.updateUser(userId, updates, requesterId);
});
return this.getHubUserById(userId, requesterId);
}
async deleteHubUser(userId: string, requesterId: string): Promise<void> {
await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => {
await this.keycloakApi.deleteUser(userId, requesterId);
});
this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`);
}
async getHubUsersByRole(role: UserRole, requesterId: string): Promise<User[]> {
await this.validateHubUserAccess(requesterId);
const allHubUsers = await this.getAllHubUsers(requesterId);
return allHubUsers.filter(user => user.role === role);
}
async updateHubUserRole(userId: string, newRole: UserRole, requesterId: string): Promise<User> {
await this.validateRoleChangePermission(requesterId);
await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => {
await this.keycloakApi.setClientRoles(userId, [newRole]);
});
return this.getHubUserById(userId, requesterId);
}
// === MERCHANT USERS MANAGEMENT ===
/**
* Vérifie si un utilisateur est Hub Admin ou Support
*/
async isUserHubAdminOrSupport(userId: string): Promise<boolean> {
try {
const userRoles = await this.keycloakApi.getUserClientRoles(userId);
const hubAdminSupportRoles = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT];
return userRoles.some(role =>
hubAdminSupportRoles.includes(role.name as UserRole)
);
} catch (error) {
this.logger.error(`Error checking Hub Admin/Support status for user ${userId}:`, error);
return false;
}
}
/**
* Vérifie si un utilisateur est Hub Admin ou Support
*/
async isUserMerchantPartner(userId: string): Promise<boolean> {
try {
const userRoles = await this.keycloakApi.getUserClientRoles(userId);
const hubMerchantPartnerRoles = [UserRole.DCB_PARTNER];
return userRoles.some(role =>
hubMerchantPartnerRoles.includes(role.name as UserRole)
);
} catch (error) {
this.logger.error(`Error checking Merchant Partner status for user ${userId}:`, error);
return false;
}
}
/**
* Récupère les utilisateurs marchands selon les permissions de l'utilisateur
* - Hub Admin/Support: tous les utilisateurs marchands de tous les merchants
* - Autres: seulement les utilisateurs de leur propre merchant
*/
async getMyMerchantUsers(userId: string): Promise<User[]> {
try {
// Vérifier si l'utilisateur est un admin ou support Hub
const isHubAdminOrSupport = await this.isUserHubAdminOrSupport(userId)
if (isHubAdminOrSupport) {
// Hub Admin/Support peuvent voir TOUS les utilisateurs marchands
return await this.getAllMerchantUsersForHubAdmin(userId);
}
// Pour les autres utilisateurs (DCB_PARTNER, DCB_PARTNER_ADMIN, etc.)
return await this.getUsersForMerchants(userId);
} catch (error) {
this.logger.error(`Error in getMyMerchantUsers for user ${userId}:`, error);
throw error;
}
}
/**
* Récupère TOUS les utilisateurs marchands pour les Hub Admin/Support
*/
private async getAllMerchantUsersForHubAdmin(adminUserId: string): Promise<User[]> {
this.logger.log(`Hub Admin/Support ${adminUserId} accessing ALL merchant users`);
// Valider que l'utilisateur a bien les droits Hub
await this.validateHubUserAccess(adminUserId);
// Récupérer tous les utilisateurs du système
const allUsers = await this.keycloakApi.getAllUsers();
const merchantUsers: User[] = [];
// Filtrer pour ne garder que les utilisateurs marchands
for (const user of allUsers) {
if (!user.id) continue;
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
// Vérifier si l'utilisateur a un rôle marchand
const hasMerchantRole = userRoles.some(role =>
[UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
.includes(role.name as UserRole)
);
if (hasMerchantRole) {
const mappedUser = this.mapToUser(user, userRoles);
merchantUsers.push(mappedUser);
}
} catch (error) {
this.logger.warn(`Could not process user ${user.id} for hub admin view: ${error.message}`);
continue;
}
}
this.logger.log(`Hub Admin/Support retrieved ${merchantUsers.length} merchant users from all merchants`);
return merchantUsers;
}
/**
* Récupère les utilisateurs marchands pour les utilisateurs réguliers (non Hub Admin/Support)
*/
private async getUsersForMerchants(userId: string): Promise<User[]> {
// Récupérer le merchantPartnerId de l'utilisateur
let userMerchantId = await this.getUserMerchantPartnerId(userId);
// Vérifier si l'utilisateur est un admin ou support Hub
const isUserMerchantPartner = await this.isUserMerchantPartner(userId);
if(isUserMerchantPartner){
userMerchantId = userId;
}
if (!userMerchantId) {
throw new BadRequestException('Current user is not associated with a merchant partner');
}
this.logger.log(`User ${userId} accessing merchant users for partner ${userMerchantId}`);
// Utiliser la méthode existante pour récupérer les utilisateurs du merchant spécifique
const users = await this.getMerchantUsersByPartner(userMerchantId, userId);
this.logger.log(`User ${userId} retrieved ${users.length} merchant users for partner ${userMerchantId}`);
return users;
}
async getMerchantUsersByPartner(merchantPartnerId: string, requesterId: string): Promise<User[]> {
await this.keycloakApi.validateUserAccess(requesterId, merchantPartnerId);
const allUsers = await this.keycloakApi.getAllUsers();
const merchantUsers = allUsers.filter(user =>
user.attributes?.merchantPartnerId?.[0] === merchantPartnerId
);
return this.processUsersByType(merchantUsers, UserType.MERCHANT_PARTNER);
}
async getMerchantUserById(userId: string, requesterId: string): Promise<User> {
return this.getValidatedUser(userId, requesterId, UserType.MERCHANT_PARTNER);
}
async createMerchantUser(creatorId: string, userData: CreateUserData): Promise<User> {
this.validateUserCreationData(userData, UserType.MERCHANT_PARTNER);
await this.validateMerchantUserCreation(creatorId, userData);
await this.validateUserUniqueness(userData.username, userData.email);
const keycloakUserData = this.buildKeycloakUserData(userData, userData.merchantPartnerId!);
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
this.logger.log(`Merchant user created: ${userData.username}`);
return this.getMerchantUserById(userId, creatorId);
}
async updateMerchantUser(
userId: string,
updates: Partial<Pick<User, 'firstName' | 'lastName' | 'email' | 'enabled'>>,
requesterId: string
): Promise<User> {
await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => {
await this.keycloakApi.updateUser(userId, updates, requesterId);
});
return this.getMerchantUserById(userId, requesterId);
}
async updateMerchantUserRole(userId: string, newRole: UserRole, requesterId: string): Promise<User> {
await this.validateRoleChangePermission(requesterId);
await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => {
await this.keycloakApi.setClientRoles(userId, [newRole]);
});
return this.getMerchantUserById(userId, requesterId);
}
async deleteMerchantUser(userId: string, requesterId: string): Promise<void> {
await this.validateSelfDeletion(userId, requesterId);
await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => {
await this.keycloakApi.deleteUser(userId, requesterId);
});
this.logger.log(`Merchant user deleted: ${userId} by ${requesterId}`);
}
// === COMMON OPERATIONS ===
async resetUserPassword(
userId: string,
newPassword: string,
temporary: boolean = true,
requesterId: string
): Promise<void> {
await this.ensureUserExists(userId, requesterId);
await this.keycloakApi.resetUserPassword(userId, newPassword, temporary);
this.logger.log(`Password reset for user: ${userId}`);
}
async getUserMerchantPartnerId(userId: string): Promise<string | null> {
return this.keycloakApi.getUserMerchantPartnerId(userId);
}
// === PRIVATE CORE METHODS ===
private async getValidatedUser(
userId: string,
requesterId: string,
userType: UserType.HUB | UserType.MERCHANT_PARTNER
): Promise<User> {
const [user, userRoles] = await Promise.all([
this.keycloakApi.getUserById(userId, requesterId),
this.keycloakApi.getUserClientRoles(userId)
]);
this.validateUserType(userRoles, userType, userId);
return this.mapToUser(user, userRoles);
}
private async processUsersByType(users: KeycloakUser[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): Promise<User[]> {
const result: User[] = [];
for (const user of users) {
if (!user.id) continue;
try {
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
if (this.isUserType(userRoles, userType)) {
result.push(this.mapToUser(user, userRoles));
}
} catch (error) {
this.logger.warn(`Could not process user ${user.id}: ${error.message}`);
}
}
return result;
}
private async executeWithValidation(
userId: string,
requesterId: string,
userType: UserType.HUB | UserType.MERCHANT_PARTNER,
operation: () => Promise<void>
): Promise<void> {
await this.getValidatedUser(userId, requesterId, userType);
await operation();
}
// === USER MAPPING AND VALIDATION ===
private mapToUser(user: KeycloakUser, roles: KeycloakRole[]): User {
if (!user.id || !user.email) {
throw new Error('User ID and email are required');
}
const role = this.determineUserRole(roles);
const userType = this.determineUserType(roles);
return {
id: user.id,
username: user.username,
email: user.email,
firstName: user.firstName || '',
lastName: user.lastName || '',
role,
enabled: user.enabled,
emailVerified: user.emailVerified,
merchantPartnerId: user.attributes?.merchantPartnerId?.[0],
createdBy: user.attributes?.createdBy?.[0] || 'unknown',
createdByUsername: user.attributes?.createdByUsername?.[0] || 'unknown',
createdTimestamp: user.createdTimestamp || Date.now(),
lastLogin: this.parseTimestamp(user.attributes?.lastLogin),
userType,
};
}
private determineUserRole(roles: KeycloakRole[]): UserRole {
const allRoles = [...SECURITY_CONFIG.ROLES.HUB, ...SECURITY_CONFIG.ROLES.MERCHANT];
const userRole = roles.find(role => allRoles.includes(role.name as UserRole));
if (!userRole) {
throw new Error('No valid role found for user');
}
return userRole.name as UserRole;
}
private determineUserType(roles: KeycloakRole[]): UserType {
return roles.some(role => SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole))
? UserType.HUB
: UserType.MERCHANT_PARTNER;
}
private isUserType(roles: KeycloakRole[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): boolean {
const targetRoles = userType === UserType.HUB
? SECURITY_CONFIG.ROLES.HUB
: SECURITY_CONFIG.ROLES.MERCHANT;
return roles.some(role => targetRoles.includes(role.name as UserRole));
}
private validateUserType(roles: KeycloakRole[], expectedType: UserType.HUB | UserType.MERCHANT_PARTNER, userId: string): void {
if (!this.isUserType(roles, expectedType)) {
throw new BadRequestException(`User ${userId} is not a ${expectedType.toLowerCase()} user`);
}
}
// === VALIDATION METHODS ===
private async validateHubUserAccess(requesterId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
const hasHubAccess = requesterRoles.some(role =>
SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole)
);
if (!hasHubAccess) {
throw new ForbiddenException('Only hub administrators can manage hub users');
}
}
private validateUserCreationData(userData: CreateUserData, userType: UserType.HUB | UserType.MERCHANT_PARTNER): void {
// 🔍 DEBUG COMPLET
this.logger.debug('🔍 === VALIDATION USER CREATION DATA ===');
this.logger.debug('UserType:', userType);
this.logger.debug('UserData complet:', JSON.stringify(userData, null, 2));
this.logger.debug('userData.role:', userData.role);
this.logger.debug('Type de role:', typeof userData.role);
this.logger.debug('Est un tableau?:', Array.isArray(userData.role));
this.logger.debug('Valeur brute role:', userData.role);
// Afficher les rôles valides configurés
const validRoles = userType === UserType.HUB
? SECURITY_CONFIG.ROLES.HUB
: SECURITY_CONFIG.ROLES.MERCHANT;
this.logger.debug('Rôles valides pour', userType, ':', validRoles);
this.logger.debug('merchantPartnerId:', userData.merchantPartnerId);
this.logger.debug('====================================');
// Validation des rôles
if (!validRoles.includes(userData.role)) {
console.error(`❌ Rôle invalide: ${userData.role} pour le type ${userType}`);
console.error(`Rôles autorisés: ${validRoles.join(', ')}`);
throw new BadRequestException(`Invalid ${userType.toLowerCase()} role: ${userData.role}`);
}
// Validation merchantPartnerId pour HUB
if (userType === UserType.HUB && userData.merchantPartnerId) {
console.error('❌ merchantPartnerId fourni pour un utilisateur HUB');
throw new BadRequestException('merchantPartnerId should not be provided for hub users');
}
// Validation merchantPartnerId pour MERCHANT
// Vérifier d'abord si role est un tableau ou une valeur simple
const isDCBPartner = Array.isArray(userData.role)
? userData.role.includes(UserRole.DCB_PARTNER)
: userData.role === UserRole.DCB_PARTNER;
this.logger.debug('Est DCB_PARTNER?:', isDCBPartner);
if (userType === UserType.MERCHANT_PARTNER && !userData.merchantPartnerId && !isDCBPartner) {
console.error('❌ merchantPartnerId manquant pour un utilisateur MERCHANT');
throw new BadRequestException('merchantPartnerId is required for merchant users');
}
this.logger.debug('✅ Validation réussie');
}
private async validateUserUniqueness(username: string, email: string): Promise<void> {
const [existingUsers, existingEmails] = await Promise.all([
this.keycloakApi.findUserByUsername(username),
this.keycloakApi.findUserByEmail(email)
]);
if (existingUsers.length > 0) {
throw new BadRequestException(`User with username ${username} already exists`);
}
if (existingEmails.length > 0) {
throw new BadRequestException(`User with email ${email} already exists`);
}
}
private async validateRoleChangePermission(requesterId: string): Promise<void> {
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN || UserRole.DCB_PARTNER || UserRole.DCB_PARTNER_ADMIN);
if (!isRequesterAdmin) {
throw new ForbiddenException('Only DCB_ADMIN can change user roles');
}
}
private async validateSelfDeletion(userId: string, requesterId: string): Promise<void> {
if (userId === requesterId) {
throw new BadRequestException('Cannot delete your own account');
}
}
private async ensureUserExists(userId: string, requesterId: string): Promise<void> {
try {
await this.getHubUserById(userId, requesterId);
} catch {
try {
await this.getMerchantUserById(userId, requesterId);
} catch {
throw new NotFoundException(`User ${userId} not found`);
}
}
}
private buildUserProfile(
userId: string,
tokenUser: any,
userDetails: KeycloakUser,
userRoles: KeycloakRole[]
) {
return {
id: userId,
username: tokenUser.preferred_username,
email: tokenUser.email,
firstName: tokenUser.given_name,
lastName: tokenUser.family_name,
emailVerified: tokenUser.email_verified,
enabled: userDetails.enabled,
clientRoles: userRoles.map(role => role.name),
merchantPartnerId: userDetails.attributes?.merchantPartnerId?.[0],
createdBy: userDetails.attributes?.createdBy?.[0],
createdByUsername: userDetails.attributes?.createdByUsername?.[0]
};
}
private buildKeycloakUserData(
userData: CreateUserData,
merchantPartnerId?: string
): KeycloakCreateUserData {
return {
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
password: userData.password,
enabled: userData.enabled ?? true,
emailVerified: userData.emailVerified ?? false,
merchantPartnerId,
clientRoles: [userData.role]
};
}
private parseTimestamp(value: string[] | undefined): number | undefined {
const strValue = value?.[0];
if (!strValue) return undefined;
const timestamp = parseInt(strValue);
if (!isNaN(timestamp)) return timestamp;
const date = new Date(strValue);
return isNaN(date.getTime()) ? undefined : date.getTime();
}
private async validateMerchantUserCreation(creatorId: string, userData: CreateUserData): Promise<void> {
const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId);
const creationRules = this.getMerchantCreationRules();
for (const rule of creationRules) {
if (creatorRoles.some(role => role.name === rule.role)) {
await rule.validator(creatorId, userData);
return;
}
}
// Vérifier les permissions des administrateurs Hub
if (creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
return;
}
throw new ForbiddenException('Insufficient permissions to create merchant users');
}
private getMerchantCreationRules() {
return [
{
role: UserRole.DCB_PARTNER,
validator: async (creatorId: string, userData: CreateUserData) => {
if (creatorId !== userData.merchantPartnerId) {
throw new ForbiddenException('DCB_PARTNER can only create users for their own merchant');
}
const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
if (!allowedRoles.includes(userData.role)) {
throw new ForbiddenException('DCB_PARTNER can only create MANAGER and SUPPORT roles');
}
}
},
{
role: UserRole.DCB_PARTNER_ADMIN,
validator: async (creatorId: string, userData: CreateUserData) => {
const creatorMerchantId = await this.keycloakApi.getUserMerchantPartnerId(creatorId);
if (creatorMerchantId !== userData.merchantPartnerId) {
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner');
}
const allowedRoles = [UserRole.DCB_PARTNER_SUPPORT];
if (!allowedRoles.includes(userData.role)) {
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create SUPPORT roles');
}
}
}
];
}
}

View File

@ -1,38 +1,93 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common'; import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
import helmet from 'helmet'; import helmet from 'helmet';
import { KeycloakExceptionFilter } from './filters/keycloak-exception.filter'; import { KeycloakExceptionFilter } from './filters/keycloak-exception.filter';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { useContainer } from 'class-validator';
async function bootstrap() { 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');
useContainer(app.select(AppModule), { fallbackOnErrors: true });
// Middlewares de sécurité
app.use(helmet()); app.use(helmet());
app.enableCors(); app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Gestion globale des erreurs et validation
app.useGlobalFilters(new KeycloakExceptionFilter()); app.useGlobalFilters(new KeycloakExceptionFilter());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
// ValidationPipe CORRIGÉ
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
exceptionFactory: (errors) => {
const messages = errors.map((error) => {
// Détails complets de l'erreur
const constraints = error.constraints
? Object.values(error.constraints)
: ['Unknown validation error'];
return {
field: error.property,
errors: constraints,
value: error.value,
children: error.children,
};
});
console.log('🔴 VALIDATION ERRORS:', JSON.stringify(messages, null, 2));
return new BadRequestException({
message: 'Validation failed',
errors: messages,
details:
'Check the errors array for specific field validation issues',
});
},
}),
);
// Préfixe global de l'API
app.setGlobalPrefix('api/v1'); 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')
.addBearerAuth() .addTag('partners', 'Gestion des Partenaires/Marchants')
//.addServer('http://localhost:3000', 'Développement local') .addBearerAuth()
// .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: '*' }) app.getHttpAdapter().get('/api/swagger-json', (req, res) => {
res.json(document);
});
// 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`,
);
console.log(`Swagger docs: http://localhost:${port}/api/swagger-json`);
} }
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,398 +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);
}
}
// =============================================
// === 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,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 {}