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

This commit is contained in:
diallolatoile 2025-11-10 01:27:29 +00:00
parent 389488bf28
commit fefa5aef42
17 changed files with 2195 additions and 3830 deletions

View File

@ -15,7 +15,7 @@ import { TerminusModule } from '@nestjs/terminus';
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config'; import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { HubUsersModule } from './hub-users/hub-users.module'; import { HubUsersModule } from './hub-users/hub-users.module';
import { StartupService } from './auth/services/startup.service'; import { StartupServiceInitialization } from './auth/services/startup.service';
@Module({ @Module({
imports: [ imports: [
@ -72,7 +72,7 @@ import { StartupService } from './auth/services/startup.service';
], ],
providers: [ providers: [
StartupService, StartupServiceInitialization,
// Global Authentication Guard // Global Authentication Guard
{ {
provide: APP_GUARD, provide: APP_GUARD,

View File

@ -324,17 +324,8 @@ export class AuthController {
type: ErrorResponseDto type: ErrorResponseDto
}) })
async getProfile(@AuthenticatedUser() user: any) { async getProfile(@AuthenticatedUser() user: any) {
this.logger.log(`Profile requested for user: ${user.preferred_username}`); return this.usersService.getCompleteUserProfile(user.sub, user);
return {
id: user.sub,
username: user.preferred_username,
email: user.email,
firstName: user.given_name,
lastName: user.family_name,
roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [],
emailVerified: user.email_verified,
};
} }
/** ------------------------------- /** -------------------------------

File diff suppressed because it is too large Load Diff

View File

@ -11,8 +11,6 @@ export interface KeycloakUser {
createdBy?: string[]; createdBy?: string[];
createdByUsername?: string[]; createdByUsername?: string[];
userType?: string[]; userType?: string[];
userStatus?: string[];
lastLogin?: string[];
[key: string]: string[] | undefined; [key: string]: string[] | undefined;
}; };
createdTimestamp?: number; createdTimestamp?: number;
@ -36,16 +34,22 @@ export interface CreateUserData {
passwordTemporary?: boolean; passwordTemporary?: boolean;
enabled?: boolean; enabled?: boolean;
emailVerified?: boolean; emailVerified?: boolean;
merchantPartnerId?: string; merchantPartnerId?: string | null;
clientRoles: UserRole[]; clientRoles: UserRole[];
createdBy?: string; attributes?: {
createdByUsername?: string; userStatus?: string[];
initialStatus?: string; lastLogin?: string[];
merchantPartnerId?: string[];
createdBy?: string[];
createdByUsername?: string[];
userType?: string[];
[key: string]: string[] | undefined;
};
} }
export enum UserType { export enum UserType {
HUB = 'hub', HUB = 'HUB',
MERCHANT_PARTNER = 'merchant_partner' MERCHANT_PARTNER = 'MERCHANT'
} }
export enum UserRole { export enum UserRole {
@ -72,8 +76,7 @@ export interface HubUser {
createdBy: string; createdBy: string;
createdByUsername: string; createdByUsername: string;
createdTimestamp: number; createdTimestamp: number;
lastLogin?: number; userType: string;
userType: 'HUB';
attributes?: { attributes?: {
userStatus?: string[]; userStatus?: string[];
lastLogin?: string[]; lastLogin?: string[];
@ -94,32 +97,57 @@ export interface CreateHubUserData {
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
enabled?: boolean; enabled?: boolean;
emailVerified?: boolean; emailVerified?: boolean;
createdBy: string;
} }
export interface HubUserStats { export interface HubUserStats {
totalAdmins: number; totalAdmins: number;
totalSupport: number; totalSupports: number;
activeUsers: number; activeUsers: number;
inactiveUsers: number; inactiveUsers: number;
pendingActivation: number;
} }
export interface MerchantStats { export interface MerchantStats {
totalMerchants: number; totalAdmins: number;
activeMerchants: number; totalManagers: number;
suspendedMerchants: number; totalSupports: number;
pendingMerchants: number; activeUsers: number;
totalUsers: number; inactiveUsers: number;
} }
export interface HubUserActivity { export interface UserQueryDto {
user: HubUser; page?: number;
lastLogin?: Date; limit?: number;
search?: string;
userType?: UserType;
merchantPartnerId?: string;
enabled?: boolean;
} }
export interface HubHealthStatus { export interface UserResponse {
status: 'healthy' | 'degraded' | 'unhealthy'; user: KeycloakUser;
issues: string[]; message: string;
stats: HubUserStats; }
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,76 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { KeycloakApiService } from './keycloak-api.service';
interface TestResults {
connection: { [key: string]: string };
}
@Injectable()
export class StartupServiceInitialization implements OnModuleInit {
private readonly logger = new Logger(StartupServiceInitialization.name);
private isInitialized = false;
private initializationError: string | null = null;
private testResults: TestResults = {
connection: {},
};
constructor(
private readonly keycloakApiService: KeycloakApiService,
) {}
async onModuleInit() {
this.logger.log('🚀 Démarrage des tests de connexion');
try {
await this.validateKeycloakConnection();
this.isInitialized = true;
this.logger.log('✅ Tests de connexion terminés avec succès');
} catch (error: any) {
this.initializationError = error.message;
this.logger.error(`❌ Échec des tests de connexion: ${error.message}`);
}
}
// === VALIDATION CONNEXION KEYCLOAK ===
private async validateKeycloakConnection() {
this.logger.log('🔌 Test de connexion Keycloak...');
try {
const isKeycloakAccessible = await this.keycloakApiService.checkKeycloakAvailability();
if (!isKeycloakAccessible) {
throw new Error('Keycloak inaccessible');
}
const isServiceConnected = await this.keycloakApiService.checkServiceConnection();
if (!isServiceConnected) {
throw new Error('Connexion service Keycloak échouée');
}
this.testResults.connection.keycloak = 'SUCCESS';
this.logger.log('✅ Connexion Keycloak validée');
} catch (error: any) {
this.testResults.connection.keycloak = 'FAILED';
throw new Error(`Connexion Keycloak échouée: ${error.message}`);
}
}
// === METHODES STATUT ===
getStatus() {
return {
status: this.isInitialized ? 'healthy' : 'unhealthy',
keycloakConnected: this.isInitialized,
testResults: this.testResults,
timestamp: new Date(),
error: this.initializationError,
};
}
isHealthy(): boolean {
return this.isInitialized;
}
getTestResults(): TestResults {
return this.testResults;
}
}

View File

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

View File

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

View File

@ -23,9 +23,6 @@ export interface DecodedToken {
realm_access?: { roles: string[] }; realm_access?: { roles: string[] };
resource_access?: { [key: string]: { roles: string[] } }; resource_access?: { [key: string]: { roles: string[] } };
merchantPartnerId?: string; merchantPartnerId?: string;
// Ajout des claims personnalisés
'merchant-partner-id'?: string;
'user-type'?: string;
} }

View File

@ -6,12 +6,12 @@ import {
Delete, Delete,
Body, Body,
Param, Param,
Query,
UseGuards,
Request, Request,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
ParseUUIDPipe, ParseUUIDPipe,
ForbiddenException,
Logger,
BadRequestException BadRequestException
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
@ -20,110 +20,134 @@ import {
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
ApiQuery, ApiProperty,
ApiProperty getSchemaPath
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import {
IsEmail,
IsEnum,
IsNotEmpty,
IsOptional,
IsBoolean,
MinLength,
IsString,
ValidateIf
} from 'class-validator';
import { HubUsersService } from '../services/hub-users.service'; import { HubUsersService } from '../services/hub-users.service';
import { UserRole, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model'; import { UserRole, UserType } from '../../auth/services/keycloak-user.model';
import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
import { RESOURCES } from '../../constants/resources'; import { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes'; import { SCOPES } from '../../constants/scopes';
import { Resource, Scopes } from 'nest-keycloak-connect'; import { Resource, Scopes } from 'nest-keycloak-connect';
import { CreateUserData, User } from '../models/hub-user.model';
export class LoginDto { // ===== DTO SPÉCIFIQUES AUX HUB USERS =====
@ApiProperty({ description: 'Username' })
username: string;
@ApiProperty({ description: 'Password' })
password: string;
}
export class TokenResponseDto {
@ApiProperty({ description: 'Access token' })
access_token: string;
@ApiProperty({ description: 'Refresh token' })
refresh_token?: string;
@ApiProperty({ description: 'Token type' })
token_type: string;
@ApiProperty({ description: 'Expires in (seconds)' })
expires_in: number;
@ApiProperty({ description: 'Refresh expires in (seconds)' })
refresh_expires_in?: number;
@ApiProperty({ description: 'Scope' })
scope?: string;
}
// DTOs pour les utilisateurs Hub
export class CreateHubUserDto { export class CreateHubUserDto {
@ApiProperty({ description: 'Username for the hub user' }) @ApiProperty({ description: 'Username for the user' })
@IsNotEmpty({ message: 'Username is required' })
@IsString()
@MinLength(3, { message: 'Username must be at least 3 characters' })
username: string; username: string;
@ApiProperty({ description: 'Email address' }) @ApiProperty({ description: 'Email address' })
@IsNotEmpty({ message: 'Email is required' })
@IsEmail({}, { message: 'Invalid email format' })
email: string; email: string;
@ApiProperty({ description: 'First name' }) @ApiProperty({ description: 'First name' })
@IsNotEmpty({ message: 'First name is required' })
@IsString()
firstName: string; firstName: string;
@ApiProperty({ description: 'Last name' }) @ApiProperty({ description: 'Last name' })
@IsNotEmpty({ message: 'Last name is required' })
@IsString()
lastName: string; lastName: string;
@ApiProperty({ description: 'Password for the user' }) @ApiProperty({ description: 'Password for the user' })
@IsNotEmpty({ message: 'Password is required' })
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string; password: string;
@ApiProperty({ @ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], enum: UserRole,
description: 'Role for the hub user' description: 'Role for the user',
examples: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER]
}) })
role: 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 }) @ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean({ message: 'Enabled must be a boolean' })
enabled?: boolean = true; enabled?: boolean = true;
@ApiProperty({ required: false, default: false }) @ApiProperty({ required: false, default: true })
emailVerified?: boolean = false; @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 { export class UpdateHubUserDto {
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsString()
firstName?: string; firstName?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsString()
lastName?: string; lastName?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsEmail()
email?: string; email?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
enabled?: boolean; enabled?: boolean;
} }
export class UpdateUserRoleDto { 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({ @ApiProperty({
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'New role for the user' description: 'New role for the user'
}) })
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; @IsEnum(UserRole, { message: 'Invalid role' })
@IsNotEmpty({ message: 'Role is required' })
role: UserRole;
} }
export class ResetPasswordDto {
@ApiProperty({ description: 'New password' })
newPassword: string;
@ApiProperty({ required: false, default: true })
temporary?: boolean = true;
}
export class SuspendMerchantDto {
@ApiProperty({ description: 'Reason for suspension' })
reason: string;
}
// DTOs pour les réponses
export class HubUserResponse { export class HubUserResponse {
@ApiProperty({ description: 'User ID' }) @ApiProperty({ description: 'User ID' })
id: string; id: string;
@ -144,7 +168,7 @@ export class HubUserResponse {
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER], enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'User role' description: 'User role'
}) })
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER; role: UserRole;
@ApiProperty({ description: 'Whether the user is enabled' }) @ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean; enabled: boolean;
@ -158,95 +182,50 @@ export class HubUserResponse {
@ApiProperty({ description: 'User creator username' }) @ApiProperty({ description: 'User creator username' })
createdByUsername: string; createdByUsername: string;
@ApiProperty({ enum: ['HUB'], description: 'User type' })
userType: UserType;
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdTimestamp: number; createdTimestamp: number;
@ApiProperty({ required: false, description: 'Last login timestamp' }) @ApiProperty({ required: false, description: 'Last login timestamp' })
lastLogin?: number; lastLogin?: number;
@ApiProperty({ enum: ['HUB'], description: 'User type' })
userType: 'HUB';
} }
export class HubUsersStatsResponse { export class HubUserProfileResponse {
@ApiProperty({ description: 'Total admin users' })
totalAdmins: number;
@ApiProperty({ description: 'Total support users' })
totalSupport: number;
@ApiProperty({ description: 'Active users count' })
activeUsers: number;
@ApiProperty({ description: 'Inactive users count' })
inactiveUsers: number;
@ApiProperty({ description: 'Users pending activation' })
pendingActivation: number;
}
export class MerchantStatsResponse {
@ApiProperty({ description: 'Total merchants' })
totalMerchants: number;
@ApiProperty({ description: 'Active merchants count' })
activeMerchants: number;
@ApiProperty({ description: 'Suspended merchants count' })
suspendedMerchants: number;
@ApiProperty({ description: 'Pending merchants count' })
pendingMerchants: number;
@ApiProperty({ description: 'Total merchant users' })
totalUsers: number;
}
export class HealthStatusResponse {
@ApiProperty({ enum: ['healthy', 'degraded', 'unhealthy'] })
status: string;
@ApiProperty({ type: [String], description: 'Health issues detected' })
issues: string[];
@ApiProperty({ description: 'System statistics' })
stats: HubUsersStatsResponse;
}
export class UserActivityResponse {
@ApiProperty({ description: 'User information' })
user: HubUserResponse;
@ApiProperty({ required: false, description: 'Last login date' })
lastLogin?: Date;
}
export class SessionResponse {
@ApiProperty({ description: 'User ID' }) @ApiProperty({ description: 'User ID' })
userId: string; id: string;
@ApiProperty({ description: 'Username' }) @ApiProperty({ description: 'Username' })
username: string; username: string;
@ApiProperty({ description: 'Last access date' }) @ApiProperty({ description: 'Email address' })
lastAccess: Date; email: string;
}
export class PermissionResponse { @ApiProperty({ description: 'First name' })
@ApiProperty({ description: 'Whether user can manage hub users' }) firstName: string;
canManageHubUsers: boolean;
} @ApiProperty({ description: 'Last name' })
lastName: string;
@ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean;
@ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean;
export class AvailableRolesResponse {
@ApiProperty({ @ApiProperty({
type: [Object], description: 'Client roles',
description: 'Available roles' type: [String],
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER]
}) })
roles: Array<{ clientRoles: string[];
value: UserRole;
label: string; @ApiProperty({ required: false, description: 'User creator ID' })
description: string; createdBy?: string;
}>;
@ApiProperty({ required: false, description: 'User creator username' })
createdByUsername?: string;
} }
export class MessageResponse { export class MessageResponse {
@ -255,39 +234,50 @@ export class MessageResponse {
} }
// Mapper functions // Mapper functions
function mapToHubUserResponse(hubUser: HubUser): HubUserResponse { function mapToHubUserResponse(user: User): HubUserResponse {
return { return {
id: hubUser.id, id: user.id,
username: hubUser.username, username: user.username,
email: hubUser.email, email: user.email,
firstName: hubUser.firstName, firstName: user.firstName,
lastName: hubUser.lastName, lastName: user.lastName,
role: hubUser.role, role: user.role,
enabled: hubUser.enabled, enabled: user.enabled,
emailVerified: hubUser.emailVerified, emailVerified: user.emailVerified,
createdBy: hubUser.createdBy, createdBy: user.createdBy,
createdByUsername: hubUser.createdByUsername, createdByUsername: user.createdByUsername,
createdTimestamp: hubUser.createdTimestamp, userType: user.userType,
lastLogin: hubUser.lastLogin, createdTimestamp: user.createdTimestamp,
userType: hubUser.userType, lastLogin: user.lastLogin,
}; };
} }
function mapToUserActivityResponse(activity: HubUserActivity): UserActivityResponse { function mapToHubUserProfileResponse(profile: any): HubUserProfileResponse {
return { return {
user: mapToHubUserResponse(activity.user), id: profile.id,
lastLogin: activity.lastLogin 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') @ApiTags('Hub Users')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('hub-users') @Controller('hub-users')
@Resource(RESOURCES.HUB_USER || RESOURCES.MERCHANT_USER) @Resource(RESOURCES.HUB_USER)
export class HubUsersController { export class HubUsersController {
constructor(private readonly hubUsersService: HubUsersService) {} constructor(private readonly usersService: HubUsersService) {}
private readonly logger = new Logger(HubUsersController.name);
// ===== GESTION DES UTILISATEURS HUB ===== // ===== ROUTES SANS PARAMÈTRES =====
@Get() @Get()
@ApiOperation({ @ApiOperation({
@ -299,56 +289,30 @@ export class HubUsersController {
description: 'Hub users retrieved successfully', description: 'Hub users retrieved successfully',
type: [HubUserResponse] type: [HubUserResponse]
}) })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Scopes(SCOPES.READ) @Scopes(SCOPES.READ)
async getAllHubUsers(@Request() req): Promise<HubUserResponse[]> { async getAllHubUsers(@Request() req): Promise<HubUserResponse[]> {
const userId = req.user.sub; const userId = req.user.sub;
const users = await this.hubUsersService.getAllHubUsers(userId); const users = await this.usersService.getAllHubUsers(userId);
return users.map(mapToHubUserResponse); return users.map(mapToHubUserResponse);
} }
@Get('role/:role') @Get('partners/dcb-partners')
@ApiOperation({ summary: 'Get hub users by role' }) @ApiOperation({
summary: 'Get all DCB_PARTNER users only',
description: 'Returns only DCB_PARTNER users (excludes DCB_ADMIN and DCB_SUPPORT)'
})
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Hub users retrieved successfully', description: 'DCB_PARTNER users retrieved successfully',
type: [HubUserResponse] type: [HubUserResponse]
}) })
@ApiResponse({ status: 400, description: 'Invalid role' })
@ApiParam({ name: 'role', enum: UserRole, description: 'User role' })
@Scopes(SCOPES.READ) @Scopes(SCOPES.READ)
async getHubUsersByRole( async getAllDcbPartners(@Request() req): Promise<HubUserResponse[]> {
@Param('role') role: UserRole,
@Request() req
): Promise<HubUserResponse[]> {
const userId = req.user.sub; const userId = req.user.sub;
const validRole = this.hubUsersService.validateHubRoleFromString(role); const users = await this.usersService.getAllDcbPartners(userId);
const users = await this.hubUsersService.getHubUsersByRole(validRole, userId);
return users.map(mapToHubUserResponse); return users.map(mapToHubUserResponse);
} }
@Get(':id')
@ApiOperation({ summary: 'Get hub user by ID' })
@ApiResponse({
status: 200,
description: 'Hub user retrieved successfully',
type: HubUserResponse
})
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.READ)
async getHubUserById(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const user = await this.hubUsersService.getHubUserById(id, userId);
return mapToHubUserResponse(user);
}
@Post() @Post()
@ApiOperation({ @ApiOperation({
summary: 'Create a new hub user', summary: 'Create a new hub user',
@ -359,22 +323,162 @@ export class HubUsersController {
description: 'Hub user created successfully', description: 'Hub user created successfully',
type: HubUserResponse type: HubUserResponse
}) })
@ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Scopes(SCOPES.WRITE) @Scopes(SCOPES.WRITE)
async createHubUser( async createHubUser(
@Body() createHubUserDto: CreateHubUserDto, @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(mapToHubUserResponse),
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 @Request() req
): Promise<HubUserResponse> { ): Promise<HubUserResponse> {
const userId = req.user.sub; const userId = req.user.sub;
const user = await this.usersService.getHubUserById(id, userId);
const userData: CreateHubUserData = {
...createHubUserDto,
createdBy: userId,
};
const user = await this.hubUsersService.createHubUser(userId, userData);
return mapToHubUserResponse(user); return mapToHubUserResponse(user);
} }
@ -385,20 +489,32 @@ export class HubUsersController {
description: 'Hub user updated successfully', description: 'Hub user updated successfully',
type: HubUserResponse type: HubUserResponse
}) })
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' }) @ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE) @Scopes(SCOPES.WRITE)
async updateHubUser( async updateHubUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() updateHubUserDto: UpdateHubUserDto, @Body() updateUserDto: UpdateHubUserDto,
@Request() req @Request() req
): Promise<HubUserResponse> { ): Promise<HubUserResponse> {
const userId = req.user.sub; const userId = req.user.sub;
const user = await this.hubUsersService.updateHubUser(id, updateHubUserDto, userId); const user = await this.usersService.updateHubUser(id, updateUserDto, userId);
return mapToHubUserResponse(user); 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') @Put(':id/role')
@ApiOperation({ summary: 'Update hub user role' }) @ApiOperation({ summary: 'Update hub user role' })
@ApiResponse({ @ApiResponse({
@ -406,54 +522,31 @@ export class HubUsersController {
description: 'User role updated successfully', description: 'User role updated successfully',
type: HubUserResponse type: HubUserResponse
}) })
@ApiResponse({ status: 403, description: 'Forbidden - only DCB_ADMIN can change roles' })
@ApiParam({ name: 'id', description: 'User ID' }) @ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE) @Scopes(SCOPES.WRITE)
async updateHubUserRole( async updateHubUserRole(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() updateRoleDto: UpdateUserRoleDto, @Body() updateRoleDto: UpdateHubUserRoleDto,
@Request() req @Request() req
): Promise<HubUserResponse> { ): Promise<HubUserResponse> {
const userId = req.user.sub; const userId = req.user.sub;
const user = await this.hubUsersService.updateHubUserRole(id, updateRoleDto.role, userId); const user = await this.usersService.updateHubUserRole(id, updateRoleDto.role, userId);
return mapToHubUserResponse(user); return mapToHubUserResponse(user);
} }
@Delete(':id')
@ApiOperation({ summary: 'Delete a hub user' })
@ApiResponse({ status: 200, description: 'Hub user deleted successfully' })
@ApiResponse({ status: 400, description: 'Cannot delete own account or last admin' })
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.DELETE)
async deleteHubUser(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.hubUsersService.deleteHubUser(id, userId);
return { message: 'Hub user deleted successfully' };
}
// ===== GESTION DES MOTS DE PASSE =====
@Post(':id/reset-password') @Post(':id/reset-password')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset hub user password' }) @ApiOperation({ summary: 'Reset hub user password' })
@ApiResponse({ status: 200, description: 'Password reset successfully' }) @ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' }) @ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE) @Scopes(SCOPES.WRITE)
async resetHubUserPassword( async resetHubUserPassword(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() resetPasswordDto: ResetPasswordDto, @Body() resetPasswordDto: ResetHubUserPasswordDto,
@Request() req @Request() req
): Promise<MessageResponse> { ): Promise<MessageResponse> {
const userId = req.user.sub; const userId = req.user.sub;
await this.hubUsersService.resetHubUserPassword( await this.usersService.resetUserPassword(
id, id,
resetPasswordDto.newPassword, resetPasswordDto.newPassword,
resetPasswordDto.temporary, resetPasswordDto.temporary,
@ -461,213 +554,4 @@ export class HubUsersController {
); );
return { message: 'Password reset successfully' }; return { message: 'Password reset successfully' };
} }
@Post(':id/send-reset-email')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send password reset email to hub user' })
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
@ApiResponse({ status: 404, description: 'Hub user not found' })
@ApiParam({ name: 'id', description: 'User ID' })
@Scopes(SCOPES.WRITE)
async sendHubUserPasswordResetEmail(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.hubUsersService.sendHubUserPasswordResetEmail(id, userId);
return { message: 'Password reset email sent successfully' };
}
// ===== GESTION DES MERCHANTS (DCB_PARTNER) =====
@Get('merchants/all')
@ApiOperation({ summary: 'Get all merchant partners' })
@ApiResponse({
status: 200,
description: 'Merchant partners retrieved successfully',
type: [HubUserResponse]
})
@Scopes(SCOPES.READ)
async getAllMerchants(@Request() req): Promise<HubUserResponse[]> {
const userId = req.user.sub;
const merchants = await this.hubUsersService.getAllMerchants(userId);
return merchants.map(mapToHubUserResponse);
}
@Get('merchants/:merchantId')
@ApiOperation({ summary: 'Get merchant partner by ID' })
@ApiResponse({
status: 200,
description: 'Merchant partner retrieved successfully',
type: HubUserResponse
})
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
@Scopes(SCOPES.READ)
async getMerchantPartnerById(
@Param('merchantId', ParseUUIDPipe) merchantId: string,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const merchant = await this.hubUsersService.getMerchantPartnerById(merchantId, userId);
return mapToHubUserResponse(merchant);
}
@Put('merchants/:merchantId')
@ApiOperation({ summary: 'Update a merchant partner' })
@ApiResponse({
status: 200,
description: 'Merchant partner updated successfully',
type: HubUserResponse
})
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
@Scopes(SCOPES.WRITE)
async updateMerchantPartner(
@Param('merchantId', ParseUUIDPipe) merchantId: string,
@Body() updateHubUserDto: UpdateHubUserDto,
@Request() req
): Promise<HubUserResponse> {
const userId = req.user.sub;
const merchant = await this.hubUsersService.updateMerchantPartner(merchantId, updateHubUserDto, userId);
return mapToHubUserResponse(merchant);
}
@Post('merchants/:merchantId/suspend')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Suspend a merchant partner and all its users' })
@ApiResponse({ status: 200, description: 'Merchant partner suspended successfully' })
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
@Scopes(SCOPES.WRITE)
async suspendMerchantPartner(
@Param('merchantId', ParseUUIDPipe) merchantId: string,
@Body() suspendMerchantDto: SuspendMerchantDto,
@Request() req
): Promise<MessageResponse> {
const userId = req.user.sub;
await this.hubUsersService.suspendMerchantPartner(merchantId, suspendMerchantDto.reason, userId);
return { message: 'Merchant partner suspended successfully' };
}
// ===== STATISTIQUES ET RAPPORTS =====
@Get('stats/overview')
@ApiOperation({ summary: 'Get hub users statistics overview' })
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: HubUsersStatsResponse
})
@Scopes(SCOPES.READ)
async getHubUsersStats(@Request() req): Promise<HubUsersStatsResponse> {
const userId = req.user.sub;
return this.hubUsersService.getHubUsersStats(userId);
}
@Get('stats/merchants')
@ApiOperation({ summary: 'Get merchants statistics' })
@ApiResponse({
status: 200,
description: 'Merchants statistics retrieved successfully',
type: MerchantStatsResponse
})
@Scopes(SCOPES.READ)
async getMerchantStats(@Request() req): Promise<MerchantStatsResponse> {
const userId = req.user.sub;
return this.hubUsersService.getMerchantStats(userId);
}
@Get('activity/recent')
@ApiOperation({ summary: 'Get recent hub user activity' })
@ApiResponse({
status: 200,
description: 'Activity retrieved successfully',
type: [UserActivityResponse]
})
@Scopes(SCOPES.READ)
async getHubUserActivity(@Request() req): Promise<UserActivityResponse[]> {
const userId = req.user.sub;
const activities = await this.hubUsersService.getHubUserActivity(userId);
return activities.map(mapToUserActivityResponse);
}
@Get('sessions/active')
@ApiOperation({ summary: 'Get active hub sessions' })
@ApiResponse({
status: 200,
description: 'Active sessions retrieved successfully',
type: [SessionResponse]
})
@Scopes(SCOPES.READ)
async getActiveHubSessions(@Request() req): Promise<SessionResponse[]> {
const userId = req.user.sub;
const sessions = await this.hubUsersService.getActiveHubSessions(userId);
return sessions.map(session => ({
userId: session.userId,
username: session.username,
lastAccess: session.lastAccess
}));
}
// ===== SANTÉ ET UTILITAIRES =====
@Get('health/status')
@ApiOperation({ summary: 'Get hub users health status' })
@ApiResponse({
status: 200,
description: 'Health status retrieved successfully',
type: HealthStatusResponse
})
@Scopes(SCOPES.READ)
async checkHubUsersHealth(@Request() req): Promise<HealthStatusResponse> {
const userId = req.user.sub;
return this.hubUsersService.checkHubUsersHealth(userId);
}
@Get('me/permissions')
@ApiOperation({ summary: 'Check if current user can manage hub users' })
@ApiResponse({ status: 200, description: 'Permissions check completed' })
async canUserManageHubUsers(@Request() req): Promise<PermissionResponse> {
const userId = req.user.sub;
const canManage = await this.hubUsersService.canUserManageHubUsers(userId);
return { canManageHubUsers: canManage };
}
@Get('roles/available')
@ApiOperation({ summary: 'Get available hub roles' })
@ApiResponse({ status: 200, description: 'Available roles retrieved successfully' })
@Scopes(SCOPES.READ)
async getAvailableHubRoles(): Promise<AvailableRolesResponse> {
const roles = [
{
value: UserRole.DCB_ADMIN,
label: 'DCB Admin',
description: 'Full administrative access to the entire system'
},
{
value: UserRole.DCB_SUPPORT,
label: 'DCB Support',
description: 'Support access with limited administrative capabilities'
},
{
value: UserRole.DCB_PARTNER,
label: 'DCB Partner',
description: 'Merchant partner with access to their own merchant ecosystem'
}
];
return { roles };
}
} }

View File

@ -6,13 +6,14 @@ import {
Delete, Delete,
Body, Body,
Param, Param,
Query,
UseGuards,
Request, Request,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
ParseUUIDPipe, ParseUUIDPipe,
BadRequestException ForbiddenException,
Logger,
BadRequestException,
InternalServerErrorException
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiTags, ApiTags,
@ -20,68 +21,136 @@ import {
ApiResponse, ApiResponse,
ApiBearerAuth, ApiBearerAuth,
ApiParam, ApiParam,
ApiQuery,
ApiProperty ApiProperty
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { MerchantUsersService, MerchantUser, CreateMerchantUserData } from '../services/merchant-users.service';
import { JwtAuthGuard } from '../../auth/guards/jwt.guard'; import {
import { UserRole } from '../../auth/services/keycloak-user.model'; 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 { RESOURCES } from '../../constants/resources';
import { SCOPES } from '../../constants/scopes'; import { SCOPES } from '../../constants/scopes';
import { Resource, Scopes } from 'nest-keycloak-connect'; import { Resource, Scopes } from 'nest-keycloak-connect';
import { CreateUserData, User } from '../models/hub-user.model';
// ===== DTO SPÉCIFIQUES AUX MERCHANT USERS =====
export class CreateMerchantUserDto { export class CreateMerchantUserDto {
@ApiProperty({ description: 'Username for the merchant user' }) @ApiProperty({ description: 'Username for the user' })
@IsNotEmpty({ message: 'Username is required' })
@IsString()
@MinLength(3, { message: 'Username must be at least 3 characters' })
username: string; username: string;
@ApiProperty({ description: 'Email address' }) @ApiProperty({ description: 'Email address' })
@IsNotEmpty({ message: 'Email is required' })
@IsEmail({}, { message: 'Invalid email format' })
email: string; email: string;
@ApiProperty({ description: 'First name' }) @ApiProperty({ description: 'First name' })
@IsNotEmpty({ message: 'First name is required' })
@IsString()
firstName: string; firstName: string;
@ApiProperty({ description: 'Last name' }) @ApiProperty({ description: 'Last name' })
@IsNotEmpty({ message: 'Last name is required' })
@IsString()
lastName: string; lastName: string;
@ApiProperty({ description: 'Password for the user' }) @ApiProperty({ description: 'Password for the user' })
@IsNotEmpty({ message: 'Password is required' })
@IsString()
@MinLength(8, { message: 'Password must be at least 8 characters' })
password: string; password: string;
@ApiProperty({ @ApiProperty({
enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], enum: UserRole,
description: 'Role for the merchant user' description: 'Role for the user',
examples: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
}) })
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; @IsEnum(UserRole, { message: 'Invalid role' })
@IsNotEmpty({ message: 'Role is required' })
role: UserRole;
@ApiProperty({ required: false, default: true }) @ApiProperty({ required: false, default: true })
@IsOptional()
@IsBoolean({ message: 'Enabled must be a boolean' })
enabled?: boolean = true; enabled?: boolean = true;
@ApiProperty({ required: false, default: false }) @ApiProperty({ required: false, default: true })
emailVerified?: boolean = false; @IsOptional()
@IsBoolean({ message: 'EmailVerified must be a boolean' })
emailVerified?: boolean = true;
@ApiProperty({ description: 'Merchant partner ID' }) @ApiProperty({
merchantPartnerId: string; 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 { export class UpdateMerchantUserDto {
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsString()
firstName?: string; firstName?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsString()
lastName?: string; lastName?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsEmail()
email?: string; email?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional()
@IsBoolean()
enabled?: boolean; enabled?: boolean;
} }
export class ResetPasswordDto { export class UpdateMerchantUserRoleDto {
@ApiProperty({ description: 'New password' }) @ApiProperty({
newPassword: string; enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
description: 'New role for the user'
@ApiProperty({ required: false, default: true }) })
temporary?: boolean = true; @IsEnum(UserRole, { message: 'Invalid role' })
@IsNotEmpty({ message: 'Role is required' })
role: UserRole;
} }
export class MerchantUserResponse { export class MerchantUserResponse {
@ -101,10 +170,10 @@ export class MerchantUserResponse {
lastName: string; lastName: string;
@ApiProperty({ @ApiProperty({
enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT], 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' description: 'User role'
}) })
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT; role: UserRole;
@ApiProperty({ description: 'Whether the user is enabled' }) @ApiProperty({ description: 'Whether the user is enabled' })
enabled: boolean; enabled: boolean;
@ -112,8 +181,8 @@ export class MerchantUserResponse {
@ApiProperty({ description: 'Whether the email is verified' }) @ApiProperty({ description: 'Whether the email is verified' })
emailVerified: boolean; emailVerified: boolean;
@ApiProperty({ description: 'Merchant partner ID' }) @ApiProperty({ required: false, description: 'Merchant partner ID' })
merchantPartnerId: string; merchantPartnerId?: string;
@ApiProperty({ description: 'User creator ID' }) @ApiProperty({ description: 'User creator ID' })
createdBy: string; createdBy: string;
@ -121,150 +190,130 @@ export class MerchantUserResponse {
@ApiProperty({ description: 'User creator username' }) @ApiProperty({ description: 'User creator username' })
createdByUsername: string; createdByUsername: string;
@ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' })
userType: 'HUB' | 'MERCHANT';
@ApiProperty({ description: 'Creation timestamp' }) @ApiProperty({ description: 'Creation timestamp' })
createdTimestamp: number; createdTimestamp: number;
@ApiProperty({ required: false, description: 'Last login timestamp' }) @ApiProperty({ required: false, description: 'Last login timestamp' })
lastLogin?: number; lastLogin?: number;
@ApiProperty({ enum: ['MERCHANT'], description: 'User type' })
userType: 'MERCHANT';
} }
export class MerchantUsersStatsResponse { export class UserProfileResponse {
@ApiProperty({ description: 'Total admin users' }) @ApiProperty({ description: 'User ID' })
totalAdmins: number; id: string;
@ApiProperty({ description: 'Total manager users' }) @ApiProperty({ description: 'Username' })
totalManagers: number; username: string;
@ApiProperty({ description: 'Total support users' }) @ApiProperty({ description: 'Email address' })
totalSupport: number; email: string;
@ApiProperty({ description: 'Total users' }) @ApiProperty({ description: 'First name' })
totalUsers: number; firstName: string;
@ApiProperty({ description: 'Active users count' }) @ApiProperty({ description: 'Last name' })
activeUsers: number; lastName: string;
@ApiProperty({ description: 'Inactive users count' }) @ApiProperty({ description: 'Whether the email is verified' })
inactiveUsers: number; 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 AvailableRolesResponse { export class MessageResponse {
@ApiProperty({ @ApiProperty({ description: 'Response message' })
type: [Object], message: string;
description: 'Available roles with permissions'
})
roles: Array<{
value: UserRole;
label: string;
description: string;
allowedForCreation: boolean;
}>;
} }
// Mapper function pour convertir MerchantUser en MerchantUserResponse // Mapper functions
function mapToMerchantUserResponse(merchantUser: MerchantUser): MerchantUserResponse { function mapToMerchantUserResponse(user: User): MerchantUserResponse {
return { return {
id: merchantUser.id, id: user.id,
username: merchantUser.username, username: user.username,
email: merchantUser.email, email: user.email,
firstName: merchantUser.firstName, firstName: user.firstName,
lastName: merchantUser.lastName, lastName: user.lastName,
role: merchantUser.role, role: user.role,
enabled: merchantUser.enabled, enabled: user.enabled,
emailVerified: merchantUser.emailVerified, emailVerified: user.emailVerified,
merchantPartnerId: merchantUser.merchantPartnerId, merchantPartnerId: user.merchantPartnerId,
createdBy: merchantUser.createdBy, createdBy: user.createdBy,
createdByUsername: merchantUser.createdByUsername, createdByUsername: user.createdByUsername,
createdTimestamp: merchantUser.createdTimestamp, userType: user.userType,
lastLogin: merchantUser.lastLogin, createdTimestamp: user.createdTimestamp,
userType: merchantUser.userType, 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') @ApiTags('Merchant Users')
@ApiBearerAuth() @ApiBearerAuth()
@Controller('merchant-users') @Controller('merchant-users')
@Resource(RESOURCES.MERCHANT_USER) @Resource(RESOURCES.MERCHANT_USER)
export class MerchantUsersController { export class MerchantUsersController {
constructor(private readonly merchantUsersService: MerchantUsersService) {} constructor(private readonly usersService: HubUsersService) {}
// ===== RÉCUPÉRATION D'UTILISATEURS ===== // ===== ROUTES SANS PARAMÈTRES D'ABORD =====
@Get() @Get()
@ApiOperation({ @ApiOperation({
summary: 'Get merchant users for current user merchant', summary: 'Get merchant users for current user merchant',
description: 'Returns merchant users based on the current user merchant partner ID' description: 'Returns merchant users. Hub admins/support see all merchants users, others see only their own merchant users.'
}) })
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
description: 'Merchant users retrieved successfully', description: 'Merchant users retrieved successfully',
type: [MerchantUserResponse] type: [MerchantUserResponse]
}) })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ) @Scopes(SCOPES.READ)
async getMyMerchantUsers(@Request() req): Promise<MerchantUserResponse[]> { async getMyMerchantUsers(@Request() req): Promise<MerchantUserResponse[]> {
const userId = req.user.sub; const userId = req.user.sub;
// Récupérer le merchantPartnerId de l'utilisateur courant try {
const userMerchantId = await this.getUserMerchantPartnerId(userId); const users = await this.usersService.getMyMerchantUsers(userId);
if (!userMerchantId) {
throw new BadRequestException('Current user is not associated with a merchant partner');
}
const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
return users.map(mapToMerchantUserResponse); return users.map(mapToMerchantUserResponse);
} catch (error) {
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
throw error;
} }
@Get('partner/:partnerId') throw new InternalServerErrorException('Could not retrieve merchant users');
@ApiOperation({
summary: 'Get merchant users by partner ID',
description: 'Returns all merchant users for a specific merchant partner'
})
@ApiResponse({
status: 200,
description: 'Merchant users retrieved successfully',
type: [MerchantUserResponse]
})
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
@ApiParam({ name: 'partnerId', description: 'Merchant Partner ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getMerchantUsersByPartner(
@Param('partnerId', ParseUUIDPipe) partnerId: string,
@Request() req
): Promise<MerchantUserResponse[]> {
const userId = req.user.sub;
const users = await this.merchantUsersService.getMerchantUsersByPartner(partnerId, userId);
return users.map(mapToMerchantUserResponse);
} }
@Get(':id')
@ApiOperation({ summary: 'Get merchant user by ID' })
@ApiResponse({
status: 200,
description: 'Merchant user retrieved successfully',
type: MerchantUserResponse
})
@ApiResponse({ status: 404, description: 'Merchant user not found' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getMerchantUserById(
@Param('id', ParseUUIDPipe) id: string,
@Request() req
): Promise<MerchantUserResponse> {
const userId = req.user.sub;
const user = await this.merchantUsersService.getMerchantUserById(id, userId);
return mapToMerchantUserResponse(user);
} }
// ===== CRÉATION D'UTILISATEURS =====
@Post() @Post()
@ApiOperation({ @ApiOperation({
summary: 'Create a new merchant user', summary: 'Create a new merchant user',
@ -275,26 +324,85 @@ export class MerchantUsersController {
description: 'Merchant user created successfully', description: 'Merchant user created successfully',
type: MerchantUserResponse type: MerchantUserResponse
}) })
@ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' })
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.WRITE) @Scopes(SCOPES.WRITE)
async createMerchantUser( async createMerchantUser(
@Body() createMerchantUserDto: CreateMerchantUserDto, @Body() createUserDto: CreateMerchantUserDto,
@Request() req @Request() req
): Promise<MerchantUserResponse> { ): Promise<MerchantUserResponse> {
const userId = req.user.sub; const userId = req.user.sub;
const userData: CreateMerchantUserData = { if (!createUserDto.merchantPartnerId && !createUserDto.role.includes(UserRole.DCB_PARTNER)) {
...createMerchantUserDto, throw new BadRequestException('merchantPartnerId is required for merchant users except DCB_PARTNER');
createdBy: userId, }
const userData: CreateUserData = {
...createUserDto,
}; };
const user = await this.merchantUsersService.createMerchantUser(userId, userData); const user = await this.usersService.createMerchantUser(userId, userData);
return mapToMerchantUserResponse(user); return mapToMerchantUserResponse(user);
} }
// ===== MISE À JOUR D'UTILISATEURS ===== // ===== 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') @Put(':id')
@ApiOperation({ summary: 'Update a merchant user' }) @ApiOperation({ summary: 'Update a merchant user' })
@ -303,212 +411,69 @@ export class MerchantUsersController {
description: 'Merchant user updated successfully', description: 'Merchant user updated successfully',
type: MerchantUserResponse type: MerchantUserResponse
}) })
@ApiResponse({ status: 404, description: 'Merchant user not found' }) @ApiParam({ name: 'id', description: 'User ID' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.WRITE) @Scopes(SCOPES.WRITE)
async updateMerchantUser( async updateMerchantUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() updateMerchantUserDto: UpdateMerchantUserDto, @Body() updateUserDto: UpdateMerchantUserDto,
@Request() req @Request() req
): Promise<MerchantUserResponse> { ): Promise<MerchantUserResponse> {
const userId = req.user.sub; const userId = req.user.sub;
const user = await this.usersService.updateMerchantUser(id, updateUserDto, userId);
// Pour l'instant, on suppose que la mise à jour se fait via Keycloak return mapToMerchantUserResponse(user);
// Vous devrez implémenter updateMerchantUser dans le service
throw new BadRequestException('Update merchant user not implemented yet');
} }
// ===== SUPPRESSION D'UTILISATEURS =====
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'Delete a merchant user' }) @ApiOperation({ summary: 'Delete a merchant user' })
@ApiResponse({ status: 200, description: 'Merchant user deleted successfully' }) @ApiResponse({ status: 200, description: 'Merchant user deleted successfully' })
@ApiResponse({ status: 404, description: 'Merchant user not found' }) @ApiParam({ name: 'id', description: 'User ID' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.DELETE) @Scopes(SCOPES.DELETE)
async deleteMerchantUser( async deleteMerchantUser(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Request() req @Request() req
): Promise<{ message: string }> { ): Promise<MessageResponse> {
const userId = req.user.sub; const userId = req.user.sub;
await this.usersService.deleteMerchantUser(id, userId);
// Vous devrez implémenter deleteMerchantUser dans le service return { message: 'Merchant user deleted successfully' };
throw new BadRequestException('Delete merchant user not implemented yet');
} }
// ===== GESTION DES MOTS DE PASSE ===== @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') @Post(':id/reset-password')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Reset merchant user password' }) @ApiOperation({ summary: 'Reset merchant user password' })
@ApiResponse({ status: 200, description: 'Password reset successfully' }) @ApiResponse({ status: 200, description: 'Password reset successfully' })
@ApiResponse({ status: 404, description: 'Merchant user not found' }) @ApiParam({ name: 'id', description: 'User ID' })
@ApiParam({ name: 'id', description: 'Merchant User ID' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.WRITE) @Scopes(SCOPES.WRITE)
async resetMerchantUserPassword( async resetMerchantUserPassword(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@Body() resetPasswordDto: ResetPasswordDto, @Body() resetPasswordDto: ResetMerchantUserPasswordDto,
@Request() req @Request() req
): Promise<{ message: string }> { ): Promise<MessageResponse> {
const userId = req.user.sub; const userId = req.user.sub;
await this.usersService.resetUserPassword(
// Vous devrez implémenter resetMerchantUserPassword dans le service id,
throw new BadRequestException('Reset merchant user password not implemented yet'); resetPasswordDto.newPassword,
} resetPasswordDto.temporary,
userId
// ===== STATISTIQUES ET RAPPORTS =====
@Get('stats/overview')
@ApiOperation({ summary: 'Get merchant users statistics overview' })
@ApiResponse({
status: 200,
description: 'Statistics retrieved successfully',
type: MerchantUsersStatsResponse
})
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getMerchantUsersStats(@Request() req): Promise<MerchantUsersStatsResponse> {
const userId = req.user.sub;
// Récupérer le merchantPartnerId de l'utilisateur courant
const userMerchantId = await this.getUserMerchantPartnerId(userId);
if (!userMerchantId) {
throw new BadRequestException('Current user is not associated with a merchant partner');
}
const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
const stats: MerchantUsersStatsResponse = {
totalAdmins: users.filter(user => user.role === UserRole.DCB_PARTNER_ADMIN).length,
totalManagers: users.filter(user => user.role === UserRole.DCB_PARTNER_MANAGER).length,
totalSupport: users.filter(user => user.role === UserRole.DCB_PARTNER_SUPPORT).length,
totalUsers: users.length,
activeUsers: users.filter(user => user.enabled).length,
inactiveUsers: users.filter(user => !user.enabled).length,
};
return stats;
}
@Get('search')
@ApiOperation({ summary: 'Search merchant users' })
@ApiResponse({
status: 200,
description: 'Search results retrieved successfully',
type: [MerchantUserResponse]
})
@ApiQuery({ name: 'query', required: false, description: 'Search query (username, email, first name, last name)' })
@ApiQuery({ name: 'role', required: false, enum: UserRole, description: 'Filter by role' })
@ApiQuery({ name: 'enabled', required: false, type: Boolean, description: 'Filter by enabled status' })
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async searchMerchantUsers(
@Request() req,
@Query('query') query?: string,
@Query('role') role?: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT,
@Query('enabled') enabled?: boolean
): Promise<MerchantUserResponse[]> {
const userId = req.user.sub;
// Récupérer le merchantPartnerId de l'utilisateur courant
const userMerchantId = await this.getUserMerchantPartnerId(userId);
if (!userMerchantId) {
throw new BadRequestException('Current user is not associated with a merchant partner');
}
let users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
// Appliquer les filtres
if (query) {
const lowerQuery = query.toLowerCase();
users = users.filter(user =>
user.username.toLowerCase().includes(lowerQuery) ||
user.email.toLowerCase().includes(lowerQuery) ||
user.firstName.toLowerCase().includes(lowerQuery) ||
user.lastName.toLowerCase().includes(lowerQuery)
); );
} return { message: 'Password reset successfully' };
if (role) {
users = users.filter(user => user.role === role);
}
if (enabled !== undefined) {
users = users.filter(user => user.enabled === enabled);
}
return users.map(mapToMerchantUserResponse);
}
// ===== UTILITAIRES =====
@Get('roles/available')
@ApiOperation({ summary: 'Get available merchant roles' })
@ApiResponse({
status: 200,
description: 'Available roles retrieved successfully',
type: AvailableRolesResponse
})
@Resource(RESOURCES.MERCHANT_USER)
@Scopes(SCOPES.READ)
async getAvailableMerchantRoles(@Request() req): Promise<AvailableRolesResponse> {
const userId = req.user.sub;
const userRoles = await this.getUserRoles(userId);
const isPartner = userRoles.includes(UserRole.DCB_PARTNER);
const isPartnerAdmin = userRoles.includes(UserRole.DCB_PARTNER_ADMIN);
const isHubAdmin = userRoles.some(role =>
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role)
);
const roles = [
{
value: UserRole.DCB_PARTNER_ADMIN,
label: 'Partner Admin',
description: 'Full administrative access within the merchant partner',
allowedForCreation: isPartner || isHubAdmin
},
{
value: UserRole.DCB_PARTNER_MANAGER,
label: 'Partner Manager',
description: 'Manager access with limited administrative capabilities',
allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin
},
{
value: UserRole.DCB_PARTNER_SUPPORT,
label: 'Partner Support',
description: 'Support role with read-only and basic operational access',
allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin
}
];
return { roles };
}
// ===== MÉTHODES PRIVÉES D'ASSISTANCE =====
private async getUserMerchantPartnerId(userId: string): Promise<string | null> {
// Implémentez cette méthode pour récupérer le merchantPartnerId de l'utilisateur
// Cela dépend de votre implémentation Keycloak
try {
// Exemple - à adapter selon votre implémentation
const user = await this.merchantUsersService['keycloakApi'].getUserById(userId, userId);
return user.attributes?.merchantPartnerId?.[0] || null;
} catch (error) {
return null;
}
}
private async getUserRoles(userId: string): Promise<UserRole[]> {
// Implémentez cette méthode pour récupérer les rôles de l'utilisateur
try {
const roles = await this.merchantUsersService['keycloakApi'].getUserClientRoles(userId);
return roles.map(role => role.name as UserRole);
} catch (error) {
return [];
}
} }
} }

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import { HttpModule } from '@nestjs/axios';
import { TokenService } from '../auth/services/token.service' import { TokenService } from '../auth/services/token.service'
import { HubUsersService } from './services/hub-users.service' import { HubUsersService } from './services/hub-users.service'
import { HubUsersController } from './controllers/hub-users.controller' import { HubUsersController } from './controllers/hub-users.controller'
import { MerchantUsersService } from './services/merchant-users.service'
import { MerchantUsersController } from './controllers/merchant-users.controller' import { MerchantUsersController } from './controllers/merchant-users.controller'
import { KeycloakApiService } from '../auth/services/keycloak-api.service'; import { KeycloakApiService } from '../auth/services/keycloak-api.service';
@ -14,9 +13,9 @@ import { KeycloakApiService } from '../auth/services/keycloak-api.service';
HttpModule, HttpModule,
JwtModule.register({}), JwtModule.register({}),
], ],
providers: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService], providers: [HubUsersService, KeycloakApiService, TokenService],
controllers: [HubUsersController, MerchantUsersController], controllers: [HubUsersController, MerchantUsersController ],
exports: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService, JwtModule], exports: [HubUsersService, KeycloakApiService, TokenService, JwtModule],
}) })
export class HubUsersModule {} export class HubUsersModule {}

View File

@ -1,23 +1,37 @@
// user.models.ts // user.models.ts
// Interfaces et Constantes Centralisées
export interface User { export interface User {
id: string; id: string;
username: string; username: string;
email: string; email: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: UserRole;
enabled: boolean; enabled: boolean;
emailVerified: boolean; emailVerified: boolean;
userType: UserType;
merchantPartnerId?: string; merchantPartnerId?: string;
clientRoles: UserRole[]; createdBy: string;
createdBy?: string; createdByUsername: string;
createdByUsername?: string;
createdTimestamp: number; 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 { export enum UserType {
HUB = 'hub', HUB = 'HUB',
MERCHANT_PARTNER = 'merchant_partner' MERCHANT_PARTNER = 'MERCHANT'
} }
export enum UserRole { export enum UserRole {

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,23 +1,56 @@
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é // Middlewares de sécurité
app.use(helmet()); app.use(helmet());
app.enableCors({ origin: '*' }); app.enableCors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization']
});
// Gestion globale des erreurs et validation // Gestion globale des erreurs et validation
app.useGlobalFilters(new KeycloakExceptionFilter()); app.useGlobalFilters(new KeycloakExceptionFilter());
// ValidationPipe CORRIGÉ
app.useGlobalPipes(new ValidationPipe({ app.useGlobalPipes(new ValidationPipe({
whitelist: true, whitelist: true,
transform: 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 // Préfixe global de l'API
@ -43,4 +76,4 @@ async function bootstrap() {
logger.log(`Application running on http://localhost:${port}`); logger.log(`Application running on http://localhost:${port}`);
logger.log(`Swagger documentation available at http://localhost:${port}/api-docs`); logger.log(`Swagger documentation available at http://localhost:${port}/api-docs`);
} }
bootstrap() bootstrap();