feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature
This commit is contained in:
parent
bc47bca6b5
commit
65494d5af2
@ -21,7 +21,7 @@ KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin
|
|||||||
KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025
|
KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025
|
||||||
|
|
||||||
KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant
|
KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant
|
||||||
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025
|
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025
|
||||||
|
|
||||||
KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support
|
KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support
|
||||||
KEYCLOAK_TEST_PASSWORD=@BOSupport2025
|
KEYCLOAK_TEST_PASSWORD=@BOSupport2025
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { AuthModule } from '../auth/auth.module';
|
|
||||||
|
|
||||||
import { ApiController } from './controllers/api.controller';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [AuthModule],
|
|
||||||
controllers: [ApiController],
|
|
||||||
})
|
|
||||||
export class ApiModule {}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
import { Controller, Get, Logger } from '@nestjs/common';
|
|
||||||
import { AuthenticatedUser, Roles, Resource, Scopes } from 'nest-keycloak-connect';
|
|
||||||
import { RESOURCES } from '../../constants/resouces';
|
|
||||||
import { SCOPES } from '../../constants/scopes';
|
|
||||||
|
|
||||||
@Controller('api')
|
|
||||||
@Resource(RESOURCES.USER)
|
|
||||||
export class ApiController {
|
|
||||||
private readonly logger = new Logger(ApiController.name);
|
|
||||||
|
|
||||||
@Get('secure')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
getSecure(@AuthenticatedUser() user: any) {
|
|
||||||
this.logger.log(`User ${user?.preferred_username} accessed /secure`);
|
|
||||||
return {
|
|
||||||
message: 'Accès autorisé',
|
|
||||||
user,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('token-details')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
tokenDetails(@AuthenticatedUser() user: any) {
|
|
||||||
return {
|
|
||||||
username: user.preferred_username,
|
|
||||||
client_id: user.client_id,
|
|
||||||
realmRoles: user.realm_access?.roles || [],
|
|
||||||
resourceRoles: user.resource_access?.[user.client_id]?.roles || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Get('protected')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
getProtected() {
|
|
||||||
this.logger.log('Accessed protected route');
|
|
||||||
return {
|
|
||||||
message: 'Protected route accessed successfully',
|
|
||||||
time: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('public')
|
|
||||||
getPublic() {
|
|
||||||
return { message: 'Accès public' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,8 +14,7 @@ import { TerminusModule } from '@nestjs/terminus';
|
|||||||
|
|
||||||
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
|
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { ApiModule } from './api/api.module';
|
import { HubUsersModule } from './hub-users/hub-users.module';
|
||||||
import { UsersModule } from './users/users.module';
|
|
||||||
import { StartupService } from './auth/services/startup.service';
|
import { StartupService } from './auth/services/startup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -69,8 +68,7 @@ import { StartupService } from './auth/services/startup.service';
|
|||||||
|
|
||||||
// Feature Modules
|
// Feature Modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
ApiModule,
|
HubUsersModule,
|
||||||
UsersModule,
|
|
||||||
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -4,8 +4,7 @@ import { JwtModule } from '@nestjs/jwt';
|
|||||||
import { TokenService } from './services/token.service';
|
import { TokenService } from './services/token.service';
|
||||||
import { KeycloakApiService } from './services/keycloak-api.service';
|
import { KeycloakApiService } from './services/keycloak-api.service';
|
||||||
import { AuthController } from './controllers/auth.controller';
|
import { AuthController } from './controllers/auth.controller';
|
||||||
import { UsersService } from '../users/services/users.service';
|
import { HubUsersService } from '../hub-users/services/hub-users.service';
|
||||||
import { MerchantTeamService } from 'src/users/services/merchant-team.service';
|
|
||||||
import { JwtAuthGuard } from './guards/jwt.guard';
|
import { JwtAuthGuard } from './guards/jwt.guard';
|
||||||
|
|
||||||
|
|
||||||
@ -19,10 +18,9 @@ import { JwtAuthGuard } from './guards/jwt.guard';
|
|||||||
JwtAuthGuard,
|
JwtAuthGuard,
|
||||||
TokenService,
|
TokenService,
|
||||||
KeycloakApiService,
|
KeycloakApiService,
|
||||||
UsersService,
|
HubUsersService
|
||||||
MerchantTeamService
|
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
exports: [JwtAuthGuard, TokenService, KeycloakApiService, UsersService, MerchantTeamService, JwtModule],
|
exports: [JwtAuthGuard, TokenService, KeycloakApiService, HubUsersService, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -9,14 +9,75 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiHeader,
|
||||||
|
ApiProperty
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect';
|
import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect';
|
||||||
import { TokenService } from '../services/token.service';
|
import { TokenService } from '../services/token.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { UsersService } from '../../users/services/users.service';
|
import { HubUsersService } from '../../hub-users/services/hub-users.service';
|
||||||
import * as user from '../../users/models/user';
|
import * as user from '../../hub-users/models/hub-user.model';
|
||||||
|
|
||||||
|
// DTOs pour Swagger
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ description: 'Username', example: 'admin@dcb.com' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Password', example: 'your_password' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RefreshTokenDto {
|
||||||
|
@ApiProperty({ description: 'Refresh token' })
|
||||||
|
refresh_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoginResponseDto {
|
||||||
|
@ApiProperty({ description: 'Access token JWT' })
|
||||||
|
access_token: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Refresh token' })
|
||||||
|
refresh_token: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Token expiration in seconds' })
|
||||||
|
expires_in: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Token type', example: 'Bearer' })
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LogoutResponseDto {
|
||||||
|
@ApiProperty({ description: 'Success message' })
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthStatusResponseDto {
|
||||||
|
@ApiProperty({ description: 'Whether user is authenticated' })
|
||||||
|
authenticated: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Authentication status message' })
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorResponseDto {
|
||||||
|
@ApiProperty({ description: 'Error message' })
|
||||||
|
message: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'HTTP status code' })
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Error type' })
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Authentication')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
private readonly logger = new Logger(AuthController.name);
|
private readonly logger = new Logger(AuthController.name);
|
||||||
@ -24,16 +85,40 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly usersService: UsersService
|
private readonly usersService: HubUsersService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** -------------------------------
|
/** -------------------------------
|
||||||
* LOGIN (Resource Owner Password Credentials)
|
* LOGIN (Resource Owner Password Credentials)
|
||||||
* ------------------------------- */
|
* ------------------------------- */
|
||||||
|
|
||||||
// === AUTHENTIFICATION ===
|
|
||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'User login',
|
||||||
|
description: 'Authenticate user with username and password using Resource Owner Password Credentials flow'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: LoginDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Login successful',
|
||||||
|
type: LoginResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: 'Bad request - missing credentials',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - invalid credentials',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 403,
|
||||||
|
description: 'Forbidden - account disabled or not fully set up',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
async login(@Body() loginDto: user.LoginDto) {
|
async login(@Body() loginDto: user.LoginDto) {
|
||||||
|
|
||||||
this.logger.log(`User login attempt: ${loginDto.username}`);
|
this.logger.log(`User login attempt: ${loginDto.username}`);
|
||||||
@ -78,6 +163,36 @@ export class AuthController {
|
|||||||
* LOGOUT
|
* LOGOUT
|
||||||
* ------------------------------- */
|
* ------------------------------- */
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'User logout',
|
||||||
|
description: 'Logout user by revoking refresh token and clearing session'
|
||||||
|
})
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiHeader({
|
||||||
|
name: 'Authorization',
|
||||||
|
description: 'Bearer token',
|
||||||
|
required: true
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Logout successful',
|
||||||
|
type: LogoutResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: 'Bad request - no token provided',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - invalid token',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 500,
|
||||||
|
description: 'Internal server error',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
async logout(@Req() req: Request) {
|
async logout(@Req() req: Request) {
|
||||||
const token = req.headers['authorization']?.split(' ')[1];
|
const token = req.headers['authorization']?.split(' ')[1];
|
||||||
if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST);
|
if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST);
|
||||||
@ -120,6 +235,26 @@ export class AuthController {
|
|||||||
* ------------------------------- */
|
* ------------------------------- */
|
||||||
@Public()
|
@Public()
|
||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Refresh access token',
|
||||||
|
description: 'Obtain new access token using refresh token'
|
||||||
|
})
|
||||||
|
@ApiBody({ type: RefreshTokenDto })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Token refreshed successfully',
|
||||||
|
type: LoginResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 400,
|
||||||
|
description: 'Bad request - refresh token missing',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - invalid refresh token',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
async refreshToken(@Body() body: { refresh_token: string }) {
|
async refreshToken(@Body() body: { refresh_token: string }) {
|
||||||
const { refresh_token } = body;
|
const { refresh_token } = body;
|
||||||
if (!refresh_token) throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST);
|
if (!refresh_token) throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST);
|
||||||
@ -143,6 +278,20 @@ export class AuthController {
|
|||||||
* ------------------------------- */
|
* ------------------------------- */
|
||||||
@Public()
|
@Public()
|
||||||
@Get('status')
|
@Get('status')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Check authentication status',
|
||||||
|
description: 'Verify if the provided token is valid'
|
||||||
|
})
|
||||||
|
@ApiHeader({
|
||||||
|
name: 'Authorization',
|
||||||
|
description: 'Bearer token (optional)',
|
||||||
|
required: false
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Authentication status retrieved',
|
||||||
|
type: AuthStatusResponseDto
|
||||||
|
})
|
||||||
async getAuthStatus(@Req() req: Request) {
|
async getAuthStatus(@Req() req: Request) {
|
||||||
const token = req.headers['authorization']?.replace('Bearer ', '');
|
const token = req.headers['authorization']?.replace('Bearer ', '');
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
@ -155,4 +304,71 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' };
|
return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** -------------------------------
|
||||||
|
* USER PROFILE (protected)
|
||||||
|
* ------------------------------- */
|
||||||
|
@Get('profile')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get current user profile',
|
||||||
|
description: 'Retrieve profile information of authenticated user'
|
||||||
|
})
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'User profile retrieved successfully'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - invalid token',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
|
async getProfile(@AuthenticatedUser() user: any) {
|
||||||
|
this.logger.log(`Profile requested for user: ${user.preferred_username}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.sub,
|
||||||
|
username: user.preferred_username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.given_name,
|
||||||
|
lastName: user.family_name,
|
||||||
|
roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [],
|
||||||
|
emailVerified: user.email_verified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** -------------------------------
|
||||||
|
* VALIDATE TOKEN (protected)
|
||||||
|
* ------------------------------- */
|
||||||
|
@Get('validate')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Validate token',
|
||||||
|
description: 'Check if the current token is valid and get user information'
|
||||||
|
})
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Token is valid'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 401,
|
||||||
|
description: 'Unauthorized - invalid token',
|
||||||
|
type: ErrorResponseDto
|
||||||
|
})
|
||||||
|
async validateToken(@AuthenticatedUser() user: any) {
|
||||||
|
this.logger.log(`Token validation requested for user: ${user.preferred_username}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
user: {
|
||||||
|
id: user.sub,
|
||||||
|
username: user.preferred_username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.given_name,
|
||||||
|
lastName: user.family_name,
|
||||||
|
roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [],
|
||||||
|
},
|
||||||
|
expires_in: user.exp ? user.exp - Math.floor(Date.now() / 1000) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
||||||
import { MerchantTeamService } from '../../users/services/merchant-team.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MerchantOwnerGuard implements CanActivate {
|
|
||||||
constructor(private readonly merchantTeamService: MerchantTeamService) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
const targetUserId = request.params.userId;
|
|
||||||
|
|
||||||
if (!user || !targetUserId) {
|
|
||||||
throw new ForbiddenException('Invalid request');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Vérifier que l'utilisateur cible appartient bien au marchand
|
|
||||||
const isInTeam = await this.merchantTeamService.isUserInMerchantTeam(
|
|
||||||
targetUserId,
|
|
||||||
user.sub
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isInTeam) {
|
|
||||||
throw new ForbiddenException('Access denied: User not in your team');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
throw new ForbiddenException('Failed to verify team membership');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
||||||
import { UsersService } from '../../users/services/users.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MerchantGuard implements CanActivate {
|
|
||||||
constructor(private readonly usersService: UsersService) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new ForbiddenException('User not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userRoles = await this.usersService.getUserClientRoles(user.sub);
|
|
||||||
|
|
||||||
// Autoriser les admins et les marchands
|
|
||||||
if (userRoles.includes('admin') || userRoles.includes('merchant')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ForbiddenException('Merchant access required');
|
|
||||||
} catch (error) {
|
|
||||||
throw new ForbiddenException('Failed to verify merchant permissions');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +1,18 @@
|
|||||||
import { Injectable, Logger, HttpException, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, Logger, HttpException, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { firstValueFrom, Observable, timeout as rxjsTimeout } from 'rxjs';
|
import { firstValueFrom, timeout as rxjsTimeout } from 'rxjs';
|
||||||
import { TokenService } from './token.service';
|
import { TokenService } from './token.service';
|
||||||
|
import { KeycloakUser, KeycloakRole, CreateUserData, UserRole, UserType } from './keycloak-user.model';
|
||||||
|
|
||||||
export interface KeycloakUser {
|
// Interface pour la hiérarchie des rôles
|
||||||
id?: string;
|
interface RoleHierarchy {
|
||||||
username: string;
|
role: UserRole;
|
||||||
email?: string;
|
canCreate: UserRole[];
|
||||||
firstName?: string;
|
requiresMerchantPartner?: boolean;
|
||||||
lastName?: string;
|
|
||||||
enabled: boolean;
|
|
||||||
emailVerified: boolean;
|
|
||||||
attributes?: Record<string, string[]>;
|
|
||||||
createdTimestamp?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeycloakRole {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support' | 'merchant-user';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class KeycloakApiService {
|
export class KeycloakApiService {
|
||||||
private readonly logger = new Logger(KeycloakApiService.name);
|
private readonly logger = new Logger(KeycloakApiService.name);
|
||||||
@ -32,6 +20,40 @@ export class KeycloakApiService {
|
|||||||
private readonly realm: string;
|
private readonly realm: string;
|
||||||
private readonly clientId: string;
|
private readonly clientId: string;
|
||||||
|
|
||||||
|
// Hiérarchie des rôles - CORRIGÉE selon votre analyse
|
||||||
|
private readonly roleHierarchy: RoleHierarchy[] = [
|
||||||
|
{
|
||||||
|
role: UserRole.DCB_ADMIN,
|
||||||
|
canCreate: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
|
||||||
|
requiresMerchantPartner: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: UserRole.DCB_SUPPORT,
|
||||||
|
canCreate: [UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||||
|
requiresMerchantPartner: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: UserRole.DCB_PARTNER,
|
||||||
|
canCreate: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
|
||||||
|
requiresMerchantPartner: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN,
|
||||||
|
canCreate: [UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
|
||||||
|
requiresMerchantPartner: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
canCreate: [],
|
||||||
|
requiresMerchantPartner: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: UserRole.DCB_PARTNER_SUPPORT,
|
||||||
|
canCreate: [],
|
||||||
|
requiresMerchantPartner: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@ -39,12 +61,7 @@ export class KeycloakApiService {
|
|||||||
) {
|
) {
|
||||||
this.keycloakBaseUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL') || 'http://localhost:8080';
|
this.keycloakBaseUrl = this.configService.get<string>('KEYCLOAK_SERVER_URL') || 'http://localhost:8080';
|
||||||
this.realm = this.configService.get<string>('KEYCLOAK_REALM') || 'master';
|
this.realm = this.configService.get<string>('KEYCLOAK_REALM') || 'master';
|
||||||
this.clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID') || 'admin-cli';
|
this.clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID') || 'dcb-admin-cli';
|
||||||
}
|
|
||||||
|
|
||||||
// ===== MÉTHODE POUR L'AUTHENTIFICATION UTILISATEUR =====
|
|
||||||
async authenticateUser(username: string, password: string) {
|
|
||||||
return this.tokenService.acquireUserToken(username, password);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== CORE REQUEST METHOD =====
|
// ===== CORE REQUEST METHOD =====
|
||||||
@ -110,79 +127,375 @@ export class KeycloakApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== USER CRUD OPERATIONS =====
|
// ===== AUTHENTICATION METHODS =====
|
||||||
async createUser(userData: {
|
async authenticateUser(username: string, password: string) {
|
||||||
username: string;
|
return this.tokenService.acquireUserToken(username, password);
|
||||||
email?: string;
|
}
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
// ===== USER LIFECYCLE MANAGEMENT =====
|
||||||
password?: string;
|
async updateUserStatus(
|
||||||
enabled?: boolean;
|
userId: string,
|
||||||
emailVerified?: boolean;
|
status: string,
|
||||||
attributes?: Record<string, string[]>;
|
reason?: string,
|
||||||
credentials?: any[];
|
performedBy?: string
|
||||||
}): Promise<string> {
|
): Promise<void> {
|
||||||
|
const attributes: Record<string, string[]> = {
|
||||||
|
userStatus: [status],
|
||||||
|
lastStatusChange: [new Date().toISOString()],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
attributes.statusChangeReason = [reason];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (performedBy) {
|
||||||
|
attributes.lastStatusChangeBy = [performedBy];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.setUserAttributes(userId, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserStatus(userId: string): Promise<string> {
|
||||||
|
return await this.getUserAttribute(userId, 'userStatus') || 'PENDING_ACTIVATION';
|
||||||
|
}
|
||||||
|
|
||||||
|
async setUserAttributes(userId: string, attributes: Record<string, string[]>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await this.getUserById(userId, userId); // Self-access pour les attributs
|
||||||
|
const updatedUser = {
|
||||||
|
...user,
|
||||||
|
attributes: {
|
||||||
|
...user.attributes,
|
||||||
|
...attributes
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, updatedUser);
|
||||||
|
this.logger.log(`Attributes set for user ${userId}: ${Object.keys(attributes).join(', ')}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserAttribute(userId: string, attributeName: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const user = await this.getUserById(userId, userId);
|
||||||
|
const attributes = user.attributes || {};
|
||||||
|
return attributes[attributeName]?.[0] || null;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to get attribute ${attributeName} for user ${userId}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PASSWORD MANAGEMENT =====
|
||||||
|
async resetUserPassword(userId: string, newPassword: string, temporary: boolean = true): Promise<void> {
|
||||||
|
const requesterId = userId; // Self-service ou via admin
|
||||||
|
await this.validateUserAccess(requesterId, await this.getUserMerchantPartnerId(userId));
|
||||||
|
|
||||||
|
const passwordPayload = {
|
||||||
|
type: 'password',
|
||||||
|
value: newPassword,
|
||||||
|
temporary: temporary,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/admin/realms/${this.realm}/users/${userId}/reset-password`,
|
||||||
|
passwordPayload
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mettre à jour les attributs de cycle de vie
|
||||||
|
await this.setUserAttributes(userId, {
|
||||||
|
lastPasswordChange: [new Date().toISOString()],
|
||||||
|
temporaryPassword: [temporary.toString()],
|
||||||
|
passwordChangeRequired: [temporary.toString()],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Password reset for user ${userId}, temporary: ${temporary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPasswordResetEmail(userEmail: string): Promise<void> {
|
||||||
|
const users = await this.findUserByEmail(userEmail);
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = users[0].id!;
|
||||||
|
const status = await this.getUserStatus(userId);
|
||||||
|
|
||||||
|
if (status !== 'ACTIVE') {
|
||||||
|
throw new BadRequestException('User account is not active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keycloak gère l'envoi d'email de reset
|
||||||
|
await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/admin/realms/${this.realm}/users/${userId}/execute-actions-email`,
|
||||||
|
['UPDATE_PASSWORD']
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Password reset email sent to: ${userEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== COMPLETE USER LIFECYCLE =====
|
||||||
|
async suspendUser(userId: string, reason: string, performedBy: string): Promise<void> {
|
||||||
|
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
await this.validateUserAccess(performedBy, merchantPartnerId);
|
||||||
|
|
||||||
|
await this.updateUser(userId, { enabled: false }, performedBy);
|
||||||
|
await this.updateUserStatus(userId, 'SUSPENDED', reason, performedBy);
|
||||||
|
|
||||||
|
this.logger.log(`User suspended: ${userId}, reason: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivateUser(userId: string, performedBy: string): Promise<void> {
|
||||||
|
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
await this.validateUserAccess(performedBy, merchantPartnerId);
|
||||||
|
|
||||||
|
await this.updateUser(userId, { enabled: true }, performedBy);
|
||||||
|
await this.updateUserStatus(userId, 'ACTIVE', 'User reactivated', performedBy);
|
||||||
|
|
||||||
|
this.logger.log(`User reactivated: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateUser(userId: string, reason: string, performedBy: string): Promise<void> {
|
||||||
|
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
await this.validateUserAccess(performedBy, merchantPartnerId);
|
||||||
|
|
||||||
|
await this.updateUser(userId, { enabled: false }, performedBy);
|
||||||
|
await this.updateUserStatus(userId, 'DEACTIVATED', reason, performedBy);
|
||||||
|
|
||||||
|
this.logger.log(`User deactivated: ${userId}, reason: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Méthode activateUser corrigée aussi
|
||||||
|
async activateUser(userId: string, activationData: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
termsAccepted: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const currentStatus = await this.getUserStatus(userId);
|
||||||
|
if (currentStatus !== 'PENDING_ACTIVATION') {
|
||||||
|
throw new BadRequestException('User cannot be activated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mettre à jour le profil - pas besoin de validation d'accès car self-service
|
||||||
|
await this.updateUser(userId, {
|
||||||
|
firstName: activationData.firstName,
|
||||||
|
lastName: activationData.lastName,
|
||||||
|
emailVerified: true,
|
||||||
|
}, userId);
|
||||||
|
|
||||||
|
// Mettre à jour le statut
|
||||||
|
await this.updateUserStatus(userId, 'ACTIVE', 'User activated', userId);
|
||||||
|
await this.setUserAttributes(userId, {
|
||||||
|
termsAccepted: [activationData.termsAccepted.toString()],
|
||||||
|
profileCompleted: ['true'],
|
||||||
|
activatedAt: [new Date().toISOString()],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`User activated: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== VALIDATION DES PERMISSIONS =====
|
||||||
|
async validateUserAccess(requesterId: string, targetMerchantPartnerId?: string | null): Promise<void> {
|
||||||
|
const requesterRoles = await this.getUserClientRoles(requesterId);
|
||||||
|
|
||||||
|
// Les admins Hub ont accès complet (peu importe le merchantPartnerId)
|
||||||
|
if (requesterRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas de merchantPartnerId cible, seul le Hub peut accéder
|
||||||
|
if (!targetMerchantPartnerId) {
|
||||||
|
throw new ForbiddenException('Access to hub resources requires DCB_ADMIN or DCB_SUPPORT role');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur est un DCB_PARTNER (propriétaire)
|
||||||
|
const isDcbPartner = requesterRoles.some(role => role.name === UserRole.DCB_PARTNER);
|
||||||
|
if (isDcbPartner) {
|
||||||
|
// Pour DCB_PARTNER, l'ID utilisateur DOIT être égal au merchantPartnerId
|
||||||
|
if (requesterId === targetMerchantPartnerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur a un rôle merchant et accède au même merchant
|
||||||
|
const requesterMerchantPartnerId = await this.getUserMerchantPartnerId(requesterId);
|
||||||
|
if (requesterMerchantPartnerId && requesterMerchantPartnerId === targetMerchantPartnerId) {
|
||||||
|
// Vérifier que l'utilisateur a un rôle merchant valide
|
||||||
|
const merchantRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
|
||||||
|
if (requesterRoles.some(role => merchantRoles.includes(role.name as UserRole))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Insufficient permissions to access this resource');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateUserCreation(creatorId: string, userData: CreateUserData): Promise<void> {
|
||||||
|
const creatorRoles = await this.getUserClientRoles(creatorId);
|
||||||
|
const targetRoles = userData.clientRoles || [];
|
||||||
|
|
||||||
|
this.logger.debug(`Validating user creation: creator=${creatorId}, roles=${targetRoles.join(',')}`);
|
||||||
|
this.logger.debug(`Creator roles: ${creatorRoles.map(r => r.name).join(', ')}`);
|
||||||
|
|
||||||
|
// Validation: au moins un rôle doit être spécifié
|
||||||
|
if (targetRoles.length === 0) {
|
||||||
|
throw new BadRequestException('At least one client role must be specified');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que le créateur peut créer ces rôles
|
||||||
|
for (const targetRole of targetRoles) {
|
||||||
|
let canCreate = false;
|
||||||
|
|
||||||
|
for (const creatorRole of creatorRoles) {
|
||||||
|
if (this.canRoleCreateRole(creatorRole.name as UserRole, targetRole)) {
|
||||||
|
canCreate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canCreate) {
|
||||||
|
this.logger.error(`Creator cannot create role: ${targetRole}`);
|
||||||
|
this.logger.error(`Creator roles: ${creatorRoles.map(r => r.name).join(', ')}`);
|
||||||
|
throw new ForbiddenException(`Cannot create user with role: ${targetRole}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation du merchantPartnerId selon les règles
|
||||||
|
await this.validateMerchantPartnerForCreation(creatorId, creatorRoles, userData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private canRoleCreateRole(creatorRole: UserRole, targetRole: UserRole): boolean {
|
||||||
|
const hierarchy = this.roleHierarchy.find(h => h.role === creatorRole);
|
||||||
|
if (!hierarchy) {
|
||||||
|
this.logger.warn(`No hierarchy found for role: ${creatorRole}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canCreate = hierarchy.canCreate.includes(targetRole);
|
||||||
|
this.logger.debug(`Role ${creatorRole} can create ${targetRole}: ${canCreate}`);
|
||||||
|
return canCreate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateMerchantPartnerForCreation(
|
||||||
|
creatorId: string,
|
||||||
|
creatorRoles: KeycloakRole[],
|
||||||
|
userData: CreateUserData
|
||||||
|
): Promise<void> {
|
||||||
|
const targetRoles = userData.clientRoles || [];
|
||||||
|
const requiresMerchantPartner = targetRoles.some(role =>
|
||||||
|
this.roleHierarchy.find(h => h.role === role)?.requiresMerchantPartner
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si le rôle cible nécessite un merchantPartnerId
|
||||||
|
if (requiresMerchantPartner) {
|
||||||
|
if (!userData.merchantPartnerId) {
|
||||||
|
throw new BadRequestException('merchantPartnerId is required for merchant partner roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCB_ADMIN/SUPPORT peuvent créer pour n'importe quel merchant
|
||||||
|
if (creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCB_PARTNER ne peut créer que pour son propre merchant
|
||||||
|
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
|
||||||
|
if (creatorId !== userData.merchantPartnerId) {
|
||||||
|
throw new ForbiddenException('DCB_PARTNER can only create users for their own merchant partner');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCB_PARTNER_ADMIN ne peut créer que pour son merchant
|
||||||
|
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER_ADMIN)) {
|
||||||
|
const creatorMerchantId = await this.getUserMerchantPartnerId(creatorId);
|
||||||
|
if (creatorMerchantId !== userData.merchantPartnerId) {
|
||||||
|
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Insufficient permissions to create merchant partner users');
|
||||||
|
} else {
|
||||||
|
// Les rôles Hub ne doivent PAS avoir de merchantPartnerId
|
||||||
|
if (userData.merchantPartnerId) {
|
||||||
|
throw new BadRequestException('merchantPartnerId should not be provided for hub roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seul DCB_ADMIN/SUPPORT peut créer des rôles Hub
|
||||||
|
if (!creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
|
||||||
|
throw new ForbiddenException('Only hub admins can create hub roles');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== USER CRUD OPERATIONS WITH ACCESS CONTROL =====
|
||||||
|
async createUser(creatorId: string, userData: CreateUserData): Promise<string> {
|
||||||
|
// Validation des permissions du créateur
|
||||||
|
await this.validateUserCreation(creatorId, userData);
|
||||||
|
|
||||||
this.logger.debug(`CREATE USER - Input data:`, {
|
this.logger.debug(`CREATE USER - Input data:`, {
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
hasPassword: !!userData.password,
|
merchantPartnerId: userData.merchantPartnerId,
|
||||||
hasCredentials: !!userData.credentials,
|
createdBy: creatorId,
|
||||||
credentialsLength: userData.credentials ? userData.credentials.length : 0
|
clientRoles: userData.clientRoles
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Récupérer le username du créateur AVANT la création
|
||||||
|
let creatorUsername = '';
|
||||||
|
try {
|
||||||
|
const creatorUser = await this.getUserById(creatorId, creatorId);
|
||||||
|
creatorUsername = creatorUser.username;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not fetch creator username: ${error.message}`);
|
||||||
|
creatorUsername = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
const userPayload: any = {
|
const userPayload: any = {
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
firstName: userData.firstName,
|
firstName: userData.firstName,
|
||||||
lastName: userData.lastName,
|
lastName: userData.lastName,
|
||||||
enabled: userData.enabled ?? true,
|
enabled: userData.enabled ?? true,
|
||||||
emailVerified: userData.emailVerified ?? true,
|
emailVerified: userData.emailVerified ?? false,
|
||||||
|
attributes: this.buildUserAttributes(userData, creatorId, creatorUsername),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userData.password) {
|
if (userData.password) {
|
||||||
// Format direct : password field
|
|
||||||
userPayload.credentials = [{
|
userPayload.credentials = [{
|
||||||
type: 'password',
|
type: 'password',
|
||||||
value: userData.password,
|
value: userData.password,
|
||||||
temporary: false,
|
temporary: userData.passwordTemporary ?? false,
|
||||||
}];
|
}];
|
||||||
} else if (userData.credentials && userData.credentials.length > 0) {
|
|
||||||
// Format credentials array (venant de UsersService)
|
|
||||||
userPayload.credentials = userData.credentials;
|
|
||||||
} else {
|
|
||||||
throw new BadRequestException('Password is required');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`CREATE USER - Final Keycloak payload:`, JSON.stringify(userPayload, null, 2));
|
this.logger.debug(`CREATE USER - Final Keycloak payload:`, JSON.stringify(userPayload, null, 2));
|
||||||
|
|
||||||
// Format correct des attributs
|
|
||||||
if (userData.attributes) {
|
|
||||||
const formattedAttributes: Record<string, string[]> = {};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(userData.attributes)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
formattedAttributes[key] = value;
|
|
||||||
} else {
|
|
||||||
formattedAttributes[key] = [String(value)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
userPayload.attributes = formattedAttributes;
|
|
||||||
this.logger.debug(`CREATE USER - Formatted attributes:`, formattedAttributes);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Creating user in Keycloak: ${userData.username}`);
|
this.logger.log(`Creating user in Keycloak: ${userData.username}`);
|
||||||
|
|
||||||
await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload);
|
await this.request('POST', `/admin/realms/${this.realm}/users`, userPayload);
|
||||||
|
|
||||||
// Récupérer l'ID
|
|
||||||
const users = await this.findUserByUsername(userData.username);
|
const users = await this.findUserByUsername(userData.username);
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
throw new Error('User not found after creation');
|
throw new Error('User not found after creation');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`User created successfully with ID: ${users[0].id}`);
|
const userId = users[0].id!;
|
||||||
return users[0].id!;
|
this.logger.log(`User created successfully with ID: ${userId}`);
|
||||||
|
|
||||||
|
// Assigner les rôles client
|
||||||
|
if (userData.clientRoles && userData.clientRoles.length > 0) {
|
||||||
|
await this.setClientRoles(userId, userData.clientRoles);
|
||||||
|
this.logger.log(`Client roles assigned to user ${userId}: ${userData.clientRoles.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`FAILED to create user in Keycloak: ${error.message}`);
|
this.logger.error(`FAILED to create user in Keycloak: ${error.message}`);
|
||||||
if (error.response?.data) {
|
if (error.response?.data) {
|
||||||
@ -192,121 +505,95 @@ export class KeycloakApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserById(userId: string): Promise<KeycloakUser> {
|
async getUserById(userId: string, requesterId: string): Promise<KeycloakUser> {
|
||||||
return this.request<KeycloakUser>('GET', `/admin/realms/${this.realm}/users/${userId}`);
|
const user = await this.request<KeycloakUser>('GET', `/admin/realms/${this.realm}/users/${userId}`);
|
||||||
|
|
||||||
|
// Valider l'accès du requester à cet utilisateur
|
||||||
|
const userMerchantPartnerId = user.attributes?.merchantPartnerId?.[0];
|
||||||
|
await this.validateUserAccess(requesterId, userMerchantPartnerId);
|
||||||
|
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllUsers(): Promise<KeycloakUser[]> {
|
async updateUser(userId: string, userData: Partial<KeycloakUser>, requesterId: string): Promise<void> {
|
||||||
return this.request<KeycloakUser[]>('GET', `/admin/realms/${this.realm}/users`);
|
// Valider l'accès du requester à cet utilisateur
|
||||||
}
|
const currentUser = await this.getUserById(userId, requesterId);
|
||||||
|
const userMerchantPartnerId = currentUser.attributes?.merchantPartnerId?.[0];
|
||||||
|
await this.validateUserAccess(requesterId, userMerchantPartnerId);
|
||||||
|
|
||||||
async findUserByUsername(username: string): Promise<KeycloakUser[]> {
|
|
||||||
return this.request<KeycloakUser[]>(
|
|
||||||
'GET',
|
|
||||||
`/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findUserByEmail(email: string): Promise<KeycloakUser[]> {
|
|
||||||
return this.request<KeycloakUser[]>(
|
|
||||||
'GET',
|
|
||||||
`/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUser(userId: string, userData: Partial<KeycloakUser>): Promise<void> {
|
|
||||||
return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData);
|
return this.request('PUT', `/admin/realms/${this.realm}/users/${userId}`, userData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUser(userId: string): Promise<void> {
|
async deleteUser(userId: string, requesterId: string): Promise<void> {
|
||||||
|
const userMerchantPartnerId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
await this.validateUserAccess(requesterId, userMerchantPartnerId);
|
||||||
|
|
||||||
return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`);
|
return this.request('DELETE', `/admin/realms/${this.realm}/users/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setUserMerchantPartnerId(userId: string, merchantPartnerId: string, requesterId: string): Promise<void> {
|
||||||
|
await this.validateUserAccess(requesterId, merchantPartnerId);
|
||||||
|
|
||||||
|
await this.setUserAttributes(userId, {
|
||||||
|
merchantPartnerId: [merchantPartnerId]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ===== ATTRIBUTES MANAGEMENT =====
|
// ===== ATTRIBUTES MANAGEMENT =====
|
||||||
async setUserAttributes(userId: string, attributes: Record<string, string[]>): Promise<void> {
|
private buildUserAttributes(
|
||||||
|
userData: CreateUserData,
|
||||||
|
creatorId: string,
|
||||||
|
creatorUsername: string
|
||||||
|
): Record<string, string[]> {
|
||||||
|
const attributes: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
// Merchant Partner ID
|
||||||
|
if (userData.merchantPartnerId !== undefined) {
|
||||||
|
attributes.merchantPartnerId = [userData.merchantPartnerId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracking de création
|
||||||
|
attributes.createdBy = [creatorId];
|
||||||
|
attributes.createdByUsername = [creatorUsername];
|
||||||
|
|
||||||
|
// Type d'utilisateur (Hub/Merchant)
|
||||||
|
if (userData.clientRoles) {
|
||||||
|
const isHubUser = userData.clientRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role)
|
||||||
|
);
|
||||||
|
attributes.userType = [isHubUser ? 'HUB' : 'MERCHANT'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle de vie
|
||||||
|
attributes.userStatus = [userData.initialStatus || 'PENDING_ACTIVATION'];
|
||||||
|
attributes.accountCreatedAt = [new Date().toISOString()];
|
||||||
|
attributes.termsAccepted = ['false'];
|
||||||
|
attributes.profileCompleted = ['false'];
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MERCHANT PARTNER SPECIFIC METHODS =====
|
||||||
|
async getUsersByMerchantPartnerId(merchantPartnerId: string, requesterId: string): Promise<KeycloakUser[]> {
|
||||||
|
await this.validateUserAccess(requesterId, merchantPartnerId);
|
||||||
|
|
||||||
|
const allUsers = await this.getAllUsers();
|
||||||
|
return allUsers.filter(user =>
|
||||||
|
user.attributes?.merchantPartnerId?.includes(merchantPartnerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserMerchantPartnerId(userId: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const user = await this.getUserById(userId);
|
const user = await this.request<KeycloakUser>('GET', `/admin/realms/${this.realm}/users/${userId}`);
|
||||||
const updatedUser = {
|
return user.attributes?.merchantPartnerId?.[0] || null;
|
||||||
...user,
|
|
||||||
attributes: {
|
|
||||||
...user.attributes,
|
|
||||||
...attributes
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateUser(userId, updatedUser);
|
|
||||||
this.logger.log(`Attributes set for user ${userId}: ${Object.keys(attributes).join(', ')}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to set attributes for user ${userId}: ${error.message}`);
|
this.logger.error(`Failed to get merchantPartnerId for user ${userId}: ${error.message}`);
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUserAttributes(userId: string, attributes: Record<string, string[]>): Promise<void> {
|
|
||||||
try {
|
|
||||||
const user = await this.getUserById(userId);
|
|
||||||
const currentAttributes = user.attributes || {};
|
|
||||||
|
|
||||||
const updatedUser = {
|
|
||||||
...user,
|
|
||||||
attributes: {
|
|
||||||
...currentAttributes,
|
|
||||||
...attributes
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateUser(userId, updatedUser);
|
|
||||||
this.logger.log(`Attributes updated for user ${userId}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to update attributes for user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeUserAttribute(userId: string, attributeName: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const user = await this.getUserById(userId);
|
|
||||||
const currentAttributes = { ...user.attributes };
|
|
||||||
|
|
||||||
if (currentAttributes && currentAttributes[attributeName]) {
|
|
||||||
delete currentAttributes[attributeName];
|
|
||||||
|
|
||||||
const updatedUser = {
|
|
||||||
...user,
|
|
||||||
attributes: currentAttributes
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.updateUser(userId, updatedUser);
|
|
||||||
this.logger.log(`Attribute ${attributeName} removed from user ${userId}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to remove attribute ${attributeName} from user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserAttribute(userId: string, attributeName: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const user = await this.getUserById(userId);
|
|
||||||
const attributes = user.attributes || {};
|
|
||||||
return attributes[attributeName]?.[0] || null;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get attribute ${attributeName} for user ${userId}: ${error.message}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserAttributes(userId: string): Promise<Record<string, string[]>> {
|
// ===== ROLE MANAGEMENT =====
|
||||||
try {
|
|
||||||
const user = await this.getUserById(userId);
|
|
||||||
return user.attributes || {};
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to get attributes for user ${userId}: ${error.message}`);
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== CLIENT ROLE OPERATIONS =====
|
|
||||||
async getUserClientRoles(userId: string): Promise<KeycloakRole[]> {
|
async getUserClientRoles(userId: string): Promise<KeycloakRole[]> {
|
||||||
try {
|
try {
|
||||||
const clients = await this.getClient();
|
const clients = await this.getClient();
|
||||||
@ -320,41 +607,7 @@ export class KeycloakApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async assignClientRole(userId: string, role: ClientRole): Promise<void> {
|
async setClientRoles(userId: string, roles: UserRole[]): Promise<void> {
|
||||||
try {
|
|
||||||
const clients = await this.getClient();
|
|
||||||
const targetRole = await this.getRole(role, clients[0].id);
|
|
||||||
|
|
||||||
await this.request(
|
|
||||||
'POST',
|
|
||||||
`/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`,
|
|
||||||
[targetRole]
|
|
||||||
);
|
|
||||||
this.logger.log(`Role ${role} assigned to user ${userId}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to assign role ${role} to user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeClientRole(userId: string, role: ClientRole): Promise<void> {
|
|
||||||
try {
|
|
||||||
const clients = await this.getClient();
|
|
||||||
const targetRole = await this.getRole(role, clients[0].id);
|
|
||||||
|
|
||||||
await this.request(
|
|
||||||
'DELETE',
|
|
||||||
`/admin/realms/${this.realm}/users/${userId}/role-mappings/clients/${clients[0].id}`,
|
|
||||||
[targetRole]
|
|
||||||
);
|
|
||||||
this.logger.log(`Role ${role} removed from user ${userId}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to remove role ${role} from user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setClientRoles(userId: string, roles: ClientRole[]): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
const clients = await this.getClient();
|
const clients = await this.getClient();
|
||||||
const clientId = clients[0].id;
|
const clientId = clients[0].id;
|
||||||
@ -362,7 +615,7 @@ export class KeycloakApiService {
|
|||||||
// Récupérer les rôles actuels
|
// Récupérer les rôles actuels
|
||||||
const currentRoles = await this.getUserClientRoles(userId);
|
const currentRoles = await this.getUserClientRoles(userId);
|
||||||
|
|
||||||
// Supprimer tous les rôles actuels si nécessaire
|
// Supprimer les rôles actuels si existants
|
||||||
if (currentRoles.length > 0) {
|
if (currentRoles.length > 0) {
|
||||||
await this.request(
|
await this.request(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
@ -391,59 +644,28 @@ export class KeycloakApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addClientRoles(userId: string, roles: ClientRole[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
for (const role of roles) {
|
|
||||||
await this.assignClientRole(userId, role);
|
|
||||||
}
|
|
||||||
this.logger.log(`Client roles added to user ${userId}: ${roles.join(', ')}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to add client roles to user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeClientRoles(userId: string, roles: ClientRole[]): Promise<void> {
|
|
||||||
try {
|
|
||||||
for (const role of roles) {
|
|
||||||
await this.removeClientRole(userId, role);
|
|
||||||
}
|
|
||||||
this.logger.log(`Client roles removed from user ${userId}: ${roles.join(', ')}`);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to remove client roles from user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== UTILITY METHODS =====
|
// ===== UTILITY METHODS =====
|
||||||
async userExists(username: string): Promise<boolean> {
|
async getAllUsers(): Promise<KeycloakUser[]> {
|
||||||
try {
|
return this.request<KeycloakUser[]>('GET', `/admin/realms/${this.realm}/users`);
|
||||||
const users = await this.findUserByUsername(username);
|
|
||||||
return users.length > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async enableUser(userId: string): Promise<void> {
|
async findUserByUsername(username: string): Promise<KeycloakUser[]> {
|
||||||
await this.updateUser(userId, { enabled: true });
|
const users = await this.request<KeycloakUser[]>(
|
||||||
this.logger.log(`User ${userId} enabled`);
|
'GET',
|
||||||
|
`/admin/realms/${this.realm}/users?username=${encodeURIComponent(username)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keycloak fait une recherche partielle, on filtre pour une correspondance exacte
|
||||||
|
return users.filter(user => user.username === username);
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableUser(userId: string): Promise<void> {
|
async findUserByEmail(email: string): Promise<KeycloakUser[]> {
|
||||||
await this.updateUser(userId, { enabled: false });
|
const users = await this.request<KeycloakUser[]>(
|
||||||
this.logger.log(`User ${userId} disabled`);
|
'GET',
|
||||||
}
|
`/admin/realms/${this.realm}/users?email=${encodeURIComponent(email)}`
|
||||||
|
);
|
||||||
|
|
||||||
async resetPassword(userId: string, newPassword: string): Promise<void> {
|
return users.filter(user => user.email === email);
|
||||||
const credentials = {
|
|
||||||
type: 'password',
|
|
||||||
value: newPassword,
|
|
||||||
temporary: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.request('PUT', `/admin/realms/${this.realm}/users/${userId}/reset-password`, credentials);
|
|
||||||
this.logger.log(`Password reset for user ${userId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUsersByAttribute(attributeName: string, attributeValue: string): Promise<KeycloakUser[]> {
|
async getUsersByAttribute(attributeName: string, attributeValue: string): Promise<KeycloakUser[]> {
|
||||||
@ -454,12 +676,68 @@ export class KeycloakApiService {
|
|||||||
user.attributes[attributeName] &&
|
user.attributes[attributeName] &&
|
||||||
user.attributes[attributeName].includes(attributeValue)
|
user.attributes[attributeName].includes(attributeValue)
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Failed to get users by attribute ${attributeName}: ${error.message}`);
|
this.logger.error(`Failed to get users by attribute ${attributeName}: ${error.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== PRIVATE HELPERS =====
|
||||||
|
private async getClient(): Promise<any[]> {
|
||||||
|
const clients = await this.request<any[]>('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`);
|
||||||
|
if (!clients || clients.length === 0) {
|
||||||
|
throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`);
|
||||||
|
}
|
||||||
|
return clients;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRole(role: UserRole, clientId: string): Promise<KeycloakRole> {
|
||||||
|
const roles = await this.request<KeycloakRole[]>('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`);
|
||||||
|
const targetRole = roles.find(r => r.name === role);
|
||||||
|
|
||||||
|
if (!targetRole) {
|
||||||
|
throw new BadRequestException(`Role '${role}' not found in client '${this.clientId}'`);
|
||||||
|
}
|
||||||
|
return targetRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PERMISSION CHECKERS (pour usage externe) =====
|
||||||
|
async canUserCreateRole(creatorId: string, targetRole: UserRole): Promise<boolean> {
|
||||||
|
const creatorRoles = await this.getUserClientRoles(creatorId);
|
||||||
|
return creatorRoles.some(creatorRole =>
|
||||||
|
this.canRoleCreateRole(creatorRole.name as UserRole, targetRole)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPermissions(userId: string): Promise<{
|
||||||
|
canCreateMerchantPartners: boolean;
|
||||||
|
canManageUsers: boolean;
|
||||||
|
accessibleMerchantPartnerIds: string[];
|
||||||
|
}> {
|
||||||
|
const roles = await this.getUserClientRoles(userId);
|
||||||
|
const merchantPartnerId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
|
||||||
|
const canCreateMerchantPartners = roles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
const canManageUsers = roles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessibleMerchantPartnerIds = canCreateMerchantPartners
|
||||||
|
? [] // Accès à tous les merchants
|
||||||
|
: merchantPartnerId
|
||||||
|
? [merchantPartnerId]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
canCreateMerchantPartners,
|
||||||
|
canManageUsers,
|
||||||
|
accessibleMerchantPartnerIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ===== HEALTH CHECKS =====
|
// ===== HEALTH CHECKS =====
|
||||||
async checkKeycloakAvailability(): Promise<boolean> {
|
async checkKeycloakAvailability(): Promise<boolean> {
|
||||||
const url = `${this.keycloakBaseUrl}/realms/${this.realm}`;
|
const url = `${this.keycloakBaseUrl}/realms/${this.realm}`;
|
||||||
@ -499,68 +777,4 @@ export class KeycloakApiService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== PRIVATE HELPERS =====
|
|
||||||
private async getClient(): Promise<any[]> {
|
|
||||||
const clients = await this.request<any[]>('GET', `/admin/realms/${this.realm}/clients?clientId=${this.clientId}`);
|
|
||||||
if (!clients || clients.length === 0) {
|
|
||||||
throw new Error(`Client '${this.clientId}' not found in realm '${this.realm}'`);
|
|
||||||
}
|
|
||||||
return clients;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getRole(role: ClientRole, clientId: string): Promise<KeycloakRole> {
|
|
||||||
const roles = await this.request<KeycloakRole[]>('GET', `/admin/realms/${this.realm}/clients/${clientId}/roles`);
|
|
||||||
const targetRole = roles.find(r => r.name === role);
|
|
||||||
|
|
||||||
if (!targetRole) {
|
|
||||||
throw new BadRequestException(`Role '${role}' not found in client '${this.clientId}'`);
|
|
||||||
}
|
|
||||||
return targetRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== MÉTHODES POUR LE STARTUP SERVICE (compatibilité) =====
|
|
||||||
async checkHealth(username: string, password: string): Promise<{ status: string }> {
|
|
||||||
try {
|
|
||||||
const isAvailable = await this.checkKeycloakAvailability();
|
|
||||||
const isConnected = await this.checkServiceConnection();
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: isAvailable && isConnected ? 'healthy' : 'unhealthy'
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { status: 'unhealthy' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRealmClients(realm: string, username: string, password: string): Promise<any[]> {
|
|
||||||
return this.request<any[]>('GET', `/admin/realms/${realm}/clients`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getRealmInfo(realm: string, username: string, password: string): Promise<any> {
|
|
||||||
return this.request<any>('GET', `/admin/realms/${realm}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUsers(realm: string, username: string, password: string, options?: any): Promise<KeycloakUser[]> {
|
|
||||||
let url = `/admin/realms/${realm}/users`;
|
|
||||||
if (options?.max) {
|
|
||||||
url += `?max=${options.max}`;
|
|
||||||
}
|
|
||||||
if (options?.username) {
|
|
||||||
url += `${url.includes('?') ? '&' : '?'}username=${encodeURIComponent(options.username)}`;
|
|
||||||
}
|
|
||||||
return this.request<KeycloakUser[]>(url.includes('?') ? 'GET' : 'GET', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserProfile(realm: string, token: string): Promise<any> {
|
|
||||||
const config = {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = `${this.keycloakBaseUrl}/realms/${realm}/protocol/openid-connect/userinfo`;
|
|
||||||
const response = await firstValueFrom(this.httpService.get(url, config));
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... autres méthodes de compatibilité pour le StartupService
|
|
||||||
}
|
}
|
||||||
125
src/auth/services/keycloak-user.model.ts
Normal file
125
src/auth/services/keycloak-user.model.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
export interface KeycloakUser {
|
||||||
|
id?: string;
|
||||||
|
username: string;
|
||||||
|
email: string; // Rendre obligatoire
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
emailVerified: boolean;
|
||||||
|
attributes?: {
|
||||||
|
merchantPartnerId?: string[];
|
||||||
|
createdBy?: string[];
|
||||||
|
createdByUsername?: string[];
|
||||||
|
userType?: string[];
|
||||||
|
userStatus?: string[];
|
||||||
|
lastLogin?: string[];
|
||||||
|
[key: string]: string[] | undefined;
|
||||||
|
};
|
||||||
|
createdTimestamp?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeycloakRole {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
composite?: boolean;
|
||||||
|
clientRole?: boolean;
|
||||||
|
containerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserData {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password?: string;
|
||||||
|
passwordTemporary?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
merchantPartnerId?: string;
|
||||||
|
clientRoles: UserRole[];
|
||||||
|
createdBy?: string;
|
||||||
|
createdByUsername?: string;
|
||||||
|
initialStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserType {
|
||||||
|
HUB = 'hub',
|
||||||
|
MERCHANT_PARTNER = 'merchant_partner'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
// Rôles Hub (sans merchantPartnerId)
|
||||||
|
DCB_ADMIN = 'dcb-admin',
|
||||||
|
DCB_SUPPORT = 'dcb-support',
|
||||||
|
DCB_PARTNER = 'dcb-partner',
|
||||||
|
|
||||||
|
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
|
||||||
|
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
|
||||||
|
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
|
||||||
|
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||||
|
enabled: boolean;
|
||||||
|
emailVerified: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
createdByUsername: string;
|
||||||
|
createdTimestamp: number;
|
||||||
|
lastLogin?: number;
|
||||||
|
userType: 'HUB';
|
||||||
|
attributes?: {
|
||||||
|
userStatus?: string[];
|
||||||
|
lastLogin?: string[];
|
||||||
|
merchantPartnerId?: string[];
|
||||||
|
createdBy?: string[];
|
||||||
|
createdByUsername?: string[];
|
||||||
|
userType?: string[];
|
||||||
|
[key: string]: string[] | undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHubUserData {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password?: string;
|
||||||
|
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||||
|
enabled?: boolean;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubUserStats {
|
||||||
|
totalAdmins: number;
|
||||||
|
totalSupport: number;
|
||||||
|
activeUsers: number;
|
||||||
|
inactiveUsers: number;
|
||||||
|
pendingActivation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantStats {
|
||||||
|
totalMerchants: number;
|
||||||
|
activeMerchants: number;
|
||||||
|
suspendedMerchants: number;
|
||||||
|
pendingMerchants: number;
|
||||||
|
totalUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubUserActivity {
|
||||||
|
user: HubUser;
|
||||||
|
lastLogin?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubHealthStatus {
|
||||||
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
|
issues: string[];
|
||||||
|
stats: HubUserStats;
|
||||||
|
}
|
||||||
76
src/auth/services/startup.service-crud.ts
Normal file
76
src/auth/services/startup.service-crud.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { KeycloakApiService } from './keycloak-api.service';
|
||||||
|
|
||||||
|
interface TestResults {
|
||||||
|
connection: { [key: string]: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StartupServiceInitialization implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(StartupServiceInitialization.name);
|
||||||
|
private isInitialized = false;
|
||||||
|
private initializationError: string | null = null;
|
||||||
|
private testResults: TestResults = {
|
||||||
|
connection: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly keycloakApiService: KeycloakApiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('🚀 Démarrage des tests de connexion');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.validateKeycloakConnection();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
this.logger.log('✅ Tests de connexion terminés avec succès');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.initializationError = error.message;
|
||||||
|
this.logger.error(`❌ Échec des tests de connexion: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === VALIDATION CONNEXION KEYCLOAK ===
|
||||||
|
private async validateKeycloakConnection() {
|
||||||
|
this.logger.log('🔌 Test de connexion Keycloak...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isKeycloakAccessible = await this.keycloakApiService.checkKeycloakAvailability();
|
||||||
|
if (!isKeycloakAccessible) {
|
||||||
|
throw new Error('Keycloak inaccessible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isServiceConnected = await this.keycloakApiService.checkServiceConnection();
|
||||||
|
if (!isServiceConnected) {
|
||||||
|
throw new Error('Connexion service Keycloak échouée');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.testResults.connection.keycloak = 'SUCCESS';
|
||||||
|
this.logger.log('✅ Connexion Keycloak validée');
|
||||||
|
} catch (error: any) {
|
||||||
|
this.testResults.connection.keycloak = 'FAILED';
|
||||||
|
throw new Error(`Connexion Keycloak échouée: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === METHODES STATUT ===
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
status: this.isInitialized ? 'healthy' : 'unhealthy',
|
||||||
|
keycloakConnected: this.isInitialized,
|
||||||
|
testResults: this.testResults,
|
||||||
|
timestamp: new Date(),
|
||||||
|
error: this.initializationError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy(): boolean {
|
||||||
|
return this.isInitialized;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTestResults(): TestResults {
|
||||||
|
return this.testResults;
|
||||||
|
}
|
||||||
|
}
|
||||||
710
src/auth/services/startup.service-final.ts
Normal file
710
src/auth/services/startup.service-final.ts
Normal file
@ -0,0 +1,710 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { HubUsersService} from '../../hub-users/services/hub-users.service';
|
||||||
|
import { MerchantUsersService, CreateMerchantUserData } from '../../hub-users/services/merchant-users.service';
|
||||||
|
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||||
|
import { TokenService } from '../../auth/services/token.service';
|
||||||
|
import { UserRole, CreateHubUserData } from '../../auth/services/keycloak-user.model';
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
testName: string;
|
||||||
|
success: boolean;
|
||||||
|
duration: number;
|
||||||
|
error?: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartupTestSummary {
|
||||||
|
totalTests: number;
|
||||||
|
passedTests: number;
|
||||||
|
failedTests: number;
|
||||||
|
totalDuration: number;
|
||||||
|
results: TestResult[];
|
||||||
|
healthStatus?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HubUserRole =
|
||||||
|
| UserRole.DCB_ADMIN
|
||||||
|
| UserRole.DCB_SUPPORT
|
||||||
|
| UserRole.DCB_PARTNER;
|
||||||
|
|
||||||
|
type MerchantUserRole =
|
||||||
|
| UserRole.DCB_PARTNER_ADMIN
|
||||||
|
| UserRole.DCB_PARTNER_MANAGER
|
||||||
|
| UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StartupServiceFinal implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(StartupServiceFinal.name);
|
||||||
|
|
||||||
|
// Stockage des données de test
|
||||||
|
private testUsers: { [key: string]: { id: string; username: string; role: UserRole } } = {};
|
||||||
|
private testMerchants: { [key: string]: { id: string; username: string; role: UserRole } } = {};
|
||||||
|
private testMerchantUsers: { [key: string]: { id: string; username: string; role: UserRole; merchantPartnerId: string } } = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly hubUsersService: HubUsersService,
|
||||||
|
private readonly merchantUsersService: MerchantUsersService,
|
||||||
|
private readonly keycloakApi: KeycloakApiService,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
if (process.env.RUN_STARTUP_TESTS === 'true') {
|
||||||
|
this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...');
|
||||||
|
await this.runAllTests();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 1. Tests de base
|
||||||
|
await this.testKeycloakConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MÉTHODES DE TEST PRINCIPALES =====
|
||||||
|
async runAllTests(): Promise<StartupTestSummary> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Tests de base
|
||||||
|
results.push(await this.testKeycloakConnection());
|
||||||
|
results.push(await this.testServiceAccountPermissions());
|
||||||
|
|
||||||
|
// 2. Tests de création en parallèle avec isolation
|
||||||
|
const parallelTests = await this.runParallelIsolationTests();
|
||||||
|
results.push(...parallelTests);
|
||||||
|
|
||||||
|
// 3. Tests avancés
|
||||||
|
results.push(await this.testStatsAndReports());
|
||||||
|
results.push(await this.testHealthCheck());
|
||||||
|
results.push(await this.testSecurityValidations());
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Critical error during startup tests:', error);
|
||||||
|
} finally {
|
||||||
|
await this.cleanupTestUsers();
|
||||||
|
await this.cleanupTestMerchants();
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
const passedTests = results.filter(r => r.success).length;
|
||||||
|
const failedTests = results.filter(r => !r.success).length;
|
||||||
|
|
||||||
|
const summary: StartupTestSummary = {
|
||||||
|
totalTests: results.length,
|
||||||
|
passedTests,
|
||||||
|
failedTests,
|
||||||
|
totalDuration,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logTestSummary(summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TESTS DE BASE =====
|
||||||
|
private async testKeycloakConnection(): Promise<TestResult> {
|
||||||
|
const testName = 'Keycloak Connection Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const isValid = await this.tokenService.validateToken(token);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Service account token validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return { testName, success: true, duration };
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testServiceAccountPermissions(): Promise<TestResult> {
|
||||||
|
const testName = 'Service Account Permissions Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
if (!serviceAccountId) {
|
||||||
|
throw new Error('Could not extract service account ID from token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les rôles du service account
|
||||||
|
const roles = await this.keycloakApi.getUserClientRoles(serviceAccountId);
|
||||||
|
const roleNames = roles.map(r => r.name);
|
||||||
|
|
||||||
|
this.logger.log(`Service account roles: ${roleNames.join(', ')}`);
|
||||||
|
|
||||||
|
// Le service account doit avoir au moins DCB_ADMIN pour créer des utilisateurs
|
||||||
|
const hasRequiredRole = roleNames.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN].includes(role as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
throw new Error(`Service account missing required roles. Has: ${roleNames.join(', ')}, Needs: ${UserRole.DCB_ADMIN}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 - Service Account crée un ADMIN DCB-ADMIN
|
||||||
|
const adminData: CreateHubUserData = {
|
||||||
|
username: `test-dcb-admin-${Date.now()}`,
|
||||||
|
email: `test-dcb-admin-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'DCB Admin',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_ADMIN,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdBy: 'service-account',
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminUser = await this.hubUsersService.createHubUser(serviceAccountId, adminData);
|
||||||
|
this.testUsers['dcb-admin'] = {
|
||||||
|
id: adminUser.id,
|
||||||
|
username: adminUser.username,
|
||||||
|
role: UserRole.DCB_ADMIN
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: {
|
||||||
|
serviceAccountId,
|
||||||
|
roles: roleNames,
|
||||||
|
createdAdmin: adminUser.username
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TESTS PARALLÈLES AVEC ISOLATION =====
|
||||||
|
private async runParallelIsolationTests(): Promise<TestResult[]> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exécuter les tests pour deux merchants différents en parallèle
|
||||||
|
const [teamAResults, teamBResults] = await Promise.all([
|
||||||
|
this.runMerchantTeamTests('TeamA'),
|
||||||
|
this.runMerchantTeamTests('TeamB')
|
||||||
|
]);
|
||||||
|
|
||||||
|
results.push(...teamAResults);
|
||||||
|
results.push(...teamBResults);
|
||||||
|
|
||||||
|
// Test d'isolation entre les deux équipes
|
||||||
|
results.push(await this.testCrossTeamIsolation());
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Parallel isolation tests failed: ${error.message}`);
|
||||||
|
results.push({
|
||||||
|
testName: 'Parallel Isolation Tests',
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMerchantTeamTests(teamName: string): Promise<TestResult[]> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
const teamPrefix = teamName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2 - ADMIN DCB-ADMIN crée DCB-SUPPORT et DCB-PARTNER pour cette équipe
|
||||||
|
const dcbAdmin = this.testUsers['dcb-admin'];
|
||||||
|
if (!dcbAdmin) {
|
||||||
|
throw new Error('DCB Admin not found for team tests');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer DCB-SUPPORT
|
||||||
|
const supportData: CreateHubUserData = {
|
||||||
|
username: `test-${teamPrefix}-support-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-support-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Support',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_SUPPORT,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdBy: dcbAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportUser = await this.hubUsersService.createHubUser(dcbAdmin.id, supportData);
|
||||||
|
this.testUsers[`${teamPrefix}-support`] = {
|
||||||
|
id: supportUser.id,
|
||||||
|
username: supportUser.username,
|
||||||
|
role: UserRole.DCB_SUPPORT
|
||||||
|
};
|
||||||
|
|
||||||
|
// Créer DCB-PARTNER (Merchant Owner)
|
||||||
|
const partnerData: CreateHubUserData = {
|
||||||
|
username: `test-${teamPrefix}-partner-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-partner-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Partner',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdBy: dcbAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const partnerUser = await this.hubUsersService.createHubUser(dcbAdmin.id, partnerData);
|
||||||
|
this.testMerchants[`${teamPrefix}-partner`] = {
|
||||||
|
id: partnerUser.id,
|
||||||
|
username: partnerUser.username,
|
||||||
|
role: UserRole.DCB_PARTNER
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Admin creates Support and Partner`,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
supportUser: supportUser.username,
|
||||||
|
partnerUser: partnerUser.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 - ADMIN DCB-ADMIN crée DCB-PARTNER-ADMIN avec merchantPartnerId du DCB-PARTNER
|
||||||
|
const partnerAdminData: CreateMerchantUserData = {
|
||||||
|
username: `test-${teamPrefix}-partner-admin-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-partner-admin-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Partner Admin',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: partnerUser.id, // Utilise l'ID du DCB-PARTNER
|
||||||
|
createdBy: dcbAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const partnerAdminUser = await this.merchantUsersService.createMerchantUser(
|
||||||
|
dcbAdmin.id,
|
||||||
|
partnerAdminData
|
||||||
|
);
|
||||||
|
|
||||||
|
this.testMerchantUsers[`${teamPrefix}-partner-admin`] = {
|
||||||
|
id: partnerAdminUser.id,
|
||||||
|
username: partnerAdminUser.username,
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN,
|
||||||
|
merchantPartnerId: partnerUser.id
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Admin creates Partner Admin`,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
partnerAdmin: partnerAdminUser.username,
|
||||||
|
merchantPartnerId: partnerUser.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4 - DCB-PARTNER crée ses trois types d'utilisateurs
|
||||||
|
const partnerCreatedUsers = await this.testPartnerUserCreation(teamName, partnerUser.id);
|
||||||
|
results.push(...partnerCreatedUsers);
|
||||||
|
|
||||||
|
// 5 - DCB-PARTNER-ADMIN crée un manager
|
||||||
|
const adminCreatedManager = await this.testPartnerAdminCreatesManager(teamName, partnerUser.id);
|
||||||
|
results.push(adminCreatedManager);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Team Tests`,
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis utilisez-le dans votre méthode
|
||||||
|
private async testPartnerUserCreation(teamName: string, partnerId: string): Promise<TestResult[]> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
const teamPrefix = teamName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partner = this.testMerchants[`${teamPrefix}-partner`];
|
||||||
|
if (!partner) {
|
||||||
|
throw new Error(`${teamName} Partner not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types d'utilisateurs à créer par le PARTNER
|
||||||
|
const userTypes: { role: MerchantUserRole; key: string }[] = [
|
||||||
|
{ role: UserRole.DCB_PARTNER_ADMIN, key: 'partner-admin-by-partner' },
|
||||||
|
{ role: UserRole.DCB_PARTNER_MANAGER, key: 'partner-manager-by-partner' },
|
||||||
|
{ role: UserRole.DCB_PARTNER_SUPPORT, key: 'partner-support-by-partner' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const userType of userTypes) {
|
||||||
|
const userData: CreateMerchantUserData = {
|
||||||
|
username: `test-${teamPrefix}-${userType.key}-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-${userType.key}-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: userType.role.split('_').pop() || 'User',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: userType.role, // Type compatible maintenant
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: partnerId,
|
||||||
|
createdBy: partner.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await this.merchantUsersService.createMerchantUser(partner.id, userData);
|
||||||
|
|
||||||
|
this.testMerchantUsers[`${teamPrefix}-${userType.key}`] = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: userType.role,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Partner creates ${userType.role}`,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
createdUser: user.username,
|
||||||
|
role: userType.role,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Partner User Creation`,
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testPartnerAdminCreatesManager(teamName: string, partnerId: string): Promise<TestResult> {
|
||||||
|
const testName = `${teamName} - Partner Admin creates Manager`;
|
||||||
|
const teamPrefix = teamName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partnerAdmin = this.testMerchantUsers[`${teamPrefix}-partner-admin`];
|
||||||
|
if (!partnerAdmin) {
|
||||||
|
throw new Error(`${teamName} Partner Admin not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 - DCB-PARTNER-ADMIN crée un manager avec l'ID de son DCB-PARTNER
|
||||||
|
const managerData: CreateMerchantUserData = {
|
||||||
|
username: `test-${teamPrefix}-manager-by-admin-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-manager-by-admin-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Manager by Admin',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: partnerId, // Utilise l'ID du DCB-PARTNER (pas son propre ID)
|
||||||
|
createdBy: partnerAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const managerUser = await this.merchantUsersService.createMerchantUser(
|
||||||
|
partnerAdmin.id,
|
||||||
|
managerData
|
||||||
|
);
|
||||||
|
|
||||||
|
this.testMerchantUsers[`${teamPrefix}-manager-by-admin`] = {
|
||||||
|
id: managerUser.id,
|
||||||
|
username: managerUser.username,
|
||||||
|
role: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
createdManager: managerUser.username,
|
||||||
|
createdBy: partnerAdmin.username,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testCrossTeamIsolation(): Promise<TestResult> {
|
||||||
|
const testName = 'Cross-Team Isolation Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const teamAPartnerAdmin = this.testMerchantUsers['teama-partner-admin'];
|
||||||
|
const teamBPartner = this.testMerchants['teamb-partner'];
|
||||||
|
|
||||||
|
if (!teamAPartnerAdmin || !teamBPartner) {
|
||||||
|
throw new Error('Team users not found for isolation test');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenter de créer un utilisateur dans l'autre équipe - devrait échouer
|
||||||
|
try {
|
||||||
|
const crossTeamUserData: CreateMerchantUserData = {
|
||||||
|
username: `test-cross-team-attempt-${Date.now()}`,
|
||||||
|
email: `test-cross-team-attempt-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: 'Cross',
|
||||||
|
lastName: 'Team Attempt',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: teamBPartner.id, // ID d'une autre équipe
|
||||||
|
createdBy: teamAPartnerAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.merchantUsersService.createMerchantUser(
|
||||||
|
teamAPartnerAdmin.id,
|
||||||
|
crossTeamUserData
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si on arrive ici, l'isolation a échoué
|
||||||
|
throw new Error('Isolation failed - User from TeamA could create user in TeamB');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Comportement attendu - l'accès doit être refusé
|
||||||
|
if (error.message.includes('Forbidden') ||
|
||||||
|
error.message.includes('Insufficient permissions') ||
|
||||||
|
error.message.includes('not authorized') ||
|
||||||
|
error.message.includes('own merchant')) {
|
||||||
|
// Succès - l'isolation fonctionne
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: { isolationWorking: true }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Erreur inattendue
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TESTS AVANCÉS (conservés depuis la version originale) =====
|
||||||
|
private async testStatsAndReports(): Promise<TestResult> {
|
||||||
|
const testName = 'Stats and Reports Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
const stats = await this.hubUsersService.getHubUsersStats(serviceAccountId);
|
||||||
|
const activity = await this.hubUsersService.getHubUserActivity(serviceAccountId);
|
||||||
|
const sessions = await this.hubUsersService.getActiveHubSessions(serviceAccountId);
|
||||||
|
|
||||||
|
// Validation basique des stats
|
||||||
|
if (typeof stats.totalAdmins !== 'number' || typeof stats.totalSupport !== 'number') {
|
||||||
|
throw new Error('Stats validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: {
|
||||||
|
stats,
|
||||||
|
activityCount: activity.length,
|
||||||
|
sessionCount: sessions.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testHealthCheck(): Promise<TestResult> {
|
||||||
|
const testName = 'Health Check Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
const health = await this.hubUsersService.checkHubUsersHealth(serviceAccountId);
|
||||||
|
|
||||||
|
if (!health.status || !health.stats || !Array.isArray(health.issues)) {
|
||||||
|
throw new Error('Health check validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: { healthStatus: health.status }
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testSecurityValidations(): Promise<TestResult> {
|
||||||
|
const testName = 'Security Validations Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
// Test de la méthode canUserManageHubUsers
|
||||||
|
const canManage = await this.hubUsersService.canUserManageHubUsers(serviceAccountId);
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
throw new Error('Service account should be able to manage hub users');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: { canManageHubUsers: canManage }
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== NETTOYAGE =====
|
||||||
|
private async cleanupTestUsers(): Promise<void> {
|
||||||
|
this.logger.log('🧹 Cleaning up test users...');
|
||||||
|
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
// Nettoyer les utilisateurs hub
|
||||||
|
for (const [key, userInfo] of Object.entries(this.testUsers)) {
|
||||||
|
try {
|
||||||
|
await this.hubUsersService.deleteHubUser(userInfo.id, serviceAccountId);
|
||||||
|
this.logger.log(`✅ Deleted test user: ${key} (${userInfo.username})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`⚠️ Could not delete test user ${key}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.testUsers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupTestMerchants(): Promise<void> {
|
||||||
|
this.logger.log('🧹 Cleaning up test merchants...');
|
||||||
|
|
||||||
|
// Implémentez la logique de nettoyage des merchants de test
|
||||||
|
this.testMerchants = {};
|
||||||
|
this.testMerchantUsers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LOGGING ET RAPPORTS =====
|
||||||
|
private logTestSummary(summary: StartupTestSummary): void {
|
||||||
|
this.logger.log('='.repeat(60));
|
||||||
|
this.logger.log('🎯 PARALLEL ISOLATION STARTUP TEST SUMMARY');
|
||||||
|
this.logger.log('='.repeat(60));
|
||||||
|
this.logger.log(`📊 Total Tests: ${summary.totalTests}`);
|
||||||
|
this.logger.log(`✅ Passed: ${summary.passedTests}`);
|
||||||
|
this.logger.log(`❌ Failed: ${summary.failedTests}`);
|
||||||
|
this.logger.log(`⏱️ Total Duration: ${summary.totalDuration}ms`);
|
||||||
|
this.logger.log('-'.repeat(60));
|
||||||
|
|
||||||
|
summary.results.forEach(result => {
|
||||||
|
const status = result.success ? '✅' : '❌';
|
||||||
|
this.logger.log(`${status} ${result.testName}: ${result.duration}ms`);
|
||||||
|
if (!result.success) {
|
||||||
|
this.logger.log(` ERROR: ${result.error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('='.repeat(60));
|
||||||
|
|
||||||
|
if (summary.failedTests === 0) {
|
||||||
|
this.logger.log('🚀 ALL TESTS PASSED! System is ready with proper isolation.');
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`⚠️ ${summary.failedTests} test(s) failed. Please check the logs above.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MÉTHODES PUBLIQUES POUR USAGE MANUEL =====
|
||||||
|
async runQuickTest(): Promise<StartupTestSummary> {
|
||||||
|
this.logger.log('🔍 Running quick startup test...');
|
||||||
|
return this.runAllTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTestStatus(): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; details: string }> {
|
||||||
|
try {
|
||||||
|
const summary = await this.runAllTests();
|
||||||
|
const successRate = (summary.passedTests / summary.totalTests) * 100;
|
||||||
|
|
||||||
|
if (successRate === 100) {
|
||||||
|
return { status: 'healthy', details: 'All tests passed successfully' };
|
||||||
|
} else if (successRate >= 80) {
|
||||||
|
return { status: 'degraded', details: `${summary.failedTests} test(s) failed` };
|
||||||
|
} else {
|
||||||
|
return { status: 'unhealthy', details: 'Multiple test failures detected' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'unhealthy', details: `Test execution failed: ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,42 +1,706 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { KeycloakApiService } from './keycloak-api.service';
|
import { HubUsersService} from '../../hub-users/services/hub-users.service';
|
||||||
|
import { MerchantUsersService, CreateMerchantUserData } from '../../hub-users/services/merchant-users.service';
|
||||||
|
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||||
|
import { TokenService } from '../../auth/services/token.service';
|
||||||
|
import { UserRole, CreateHubUserData } from '../../auth/services/keycloak-user.model';
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
testName: string;
|
||||||
|
success: boolean;
|
||||||
|
duration: number;
|
||||||
|
error?: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartupTestSummary {
|
||||||
|
totalTests: number;
|
||||||
|
passedTests: number;
|
||||||
|
failedTests: number;
|
||||||
|
totalDuration: number;
|
||||||
|
results: TestResult[];
|
||||||
|
healthStatus?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HubUserRole =
|
||||||
|
| UserRole.DCB_ADMIN
|
||||||
|
| UserRole.DCB_SUPPORT
|
||||||
|
| UserRole.DCB_PARTNER;
|
||||||
|
|
||||||
|
type MerchantUserRole =
|
||||||
|
| UserRole.DCB_PARTNER_ADMIN
|
||||||
|
| UserRole.DCB_PARTNER_MANAGER
|
||||||
|
| UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StartupService implements OnModuleInit {
|
export class StartupService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(StartupService.name);
|
private readonly logger = new Logger(StartupService.name);
|
||||||
private initialized = false;
|
|
||||||
private error: string | null = null;
|
|
||||||
|
|
||||||
constructor(private readonly keycloakApiService: KeycloakApiService) {}
|
// Stockage des données de test
|
||||||
|
private testUsers: { [key: string]: { id: string; username: string; role: UserRole } } = {};
|
||||||
|
private testMerchants: { [key: string]: { id: string; username: string; role: UserRole } } = {};
|
||||||
|
private testMerchantUsers: { [key: string]: { id: string; username: string; role: UserRole; merchantPartnerId: string } } = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly hubUsersService: HubUsersService,
|
||||||
|
private readonly merchantUsersService: MerchantUsersService,
|
||||||
|
private readonly keycloakApi: KeycloakApiService,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log('Vérification de la disponibilité de Keycloak...');
|
if (process.env.RUN_STARTUP_TESTS === 'true') {
|
||||||
|
this.logger.log('🚀 Starting comprehensive tests (Hub + Merchants with isolation)...');
|
||||||
|
await this.runAllTests();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MÉTHODES DE TEST PRINCIPALES =====
|
||||||
|
async runAllTests(): Promise<StartupTestSummary> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const available = await this.keycloakApiService.checkKeycloakAvailability();
|
// 1. Tests de base
|
||||||
if (!available) throw new Error('Keycloak non accessible');
|
results.push(await this.testKeycloakConnection());
|
||||||
|
results.push(await this.testServiceAccountPermissions());
|
||||||
|
|
||||||
const serviceConnected = await this.keycloakApiService.checkServiceConnection();
|
// 2. Tests de création en parallèle avec isolation
|
||||||
if (!serviceConnected) throw new Error('Échec de la connexion du service à Keycloak');
|
const parallelTests = await this.runParallelIsolationTests();
|
||||||
|
results.push(...parallelTests);
|
||||||
|
|
||||||
this.initialized = true;
|
// 3. Tests avancés
|
||||||
this.logger.log('Keycloak disponible et connexion du service réussie');
|
results.push(await this.testStatsAndReports());
|
||||||
} catch (err: any) {
|
results.push(await this.testHealthCheck());
|
||||||
this.error = err.message;
|
results.push(await this.testSecurityValidations());
|
||||||
this.logger.error('Échec de la vérification de Keycloak', err);
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Critical error during startup tests:', error);
|
||||||
|
} finally {
|
||||||
|
await this.cleanupTestUsers();
|
||||||
|
await this.cleanupTestMerchants();
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
const passedTests = results.filter(r => r.success).length;
|
||||||
|
const failedTests = results.filter(r => !r.success).length;
|
||||||
|
|
||||||
|
const summary: StartupTestSummary = {
|
||||||
|
totalTests: results.length,
|
||||||
|
passedTests,
|
||||||
|
failedTests,
|
||||||
|
totalDuration,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logTestSummary(summary);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TESTS DE BASE =====
|
||||||
|
private async testKeycloakConnection(): Promise<TestResult> {
|
||||||
|
const testName = 'Keycloak Connection Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const isValid = await this.tokenService.validateToken(token);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('Service account token validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return { testName, success: true, duration };
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatus() {
|
private async testServiceAccountPermissions(): Promise<TestResult> {
|
||||||
|
const testName = 'Service Account Permissions Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
if (!serviceAccountId) {
|
||||||
|
throw new Error('Could not extract service account ID from token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les rôles du service account
|
||||||
|
const roles = await this.keycloakApi.getUserClientRoles(serviceAccountId);
|
||||||
|
const roleNames = roles.map(r => r.name);
|
||||||
|
|
||||||
|
this.logger.log(`Service account roles: ${roleNames.join(', ')}`);
|
||||||
|
|
||||||
|
// Le service account doit avoir au moins DCB_ADMIN pour créer des utilisateurs
|
||||||
|
const hasRequiredRole = roleNames.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN].includes(role as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
throw new Error(`Service account missing required roles. Has: ${roleNames.join(', ')}, Needs: ${UserRole.DCB_ADMIN}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 - Service Account crée un ADMIN DCB-ADMIN
|
||||||
|
const adminData: CreateHubUserData = {
|
||||||
|
username: `test-dcb-admin-${Date.now()}`,
|
||||||
|
email: `test-dcb-admin-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: 'Test',
|
||||||
|
lastName: 'DCB Admin',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_ADMIN,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdBy: 'service-account',
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminUser = await this.hubUsersService.createHubUser(serviceAccountId, adminData);
|
||||||
|
this.testUsers['dcb-admin'] = {
|
||||||
|
id: adminUser.id,
|
||||||
|
username: adminUser.username,
|
||||||
|
role: UserRole.DCB_ADMIN
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: this.initialized ? 'healthy' : 'unhealthy',
|
testName,
|
||||||
keycloakConnected: this.initialized,
|
success: true,
|
||||||
timestamp: new Date(),
|
duration,
|
||||||
error: this.error,
|
data: {
|
||||||
|
serviceAccountId,
|
||||||
|
roles: roleNames,
|
||||||
|
createdAdmin: adminUser.username
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TESTS PARALLÈLES AVEC ISOLATION =====
|
||||||
|
private async runParallelIsolationTests(): Promise<TestResult[]> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exécuter les tests pour deux merchants différents en parallèle
|
||||||
|
const [teamAResults, teamBResults] = await Promise.all([
|
||||||
|
this.runMerchantTeamTests('TeamA'),
|
||||||
|
this.runMerchantTeamTests('TeamB')
|
||||||
|
]);
|
||||||
|
|
||||||
|
results.push(...teamAResults);
|
||||||
|
results.push(...teamBResults);
|
||||||
|
|
||||||
|
// Test d'isolation entre les deux équipes
|
||||||
|
results.push(await this.testCrossTeamIsolation());
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Parallel isolation tests failed: ${error.message}`);
|
||||||
|
results.push({
|
||||||
|
testName: 'Parallel Isolation Tests',
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMerchantTeamTests(teamName: string): Promise<TestResult[]> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
const teamPrefix = teamName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2 - ADMIN DCB-ADMIN crée DCB-SUPPORT et DCB-PARTNER pour cette équipe
|
||||||
|
const dcbAdmin = this.testUsers['dcb-admin'];
|
||||||
|
if (!dcbAdmin) {
|
||||||
|
throw new Error('DCB Admin not found for team tests');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer DCB-SUPPORT
|
||||||
|
const supportData: CreateHubUserData = {
|
||||||
|
username: `test-${teamPrefix}-support-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-support-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Support',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_SUPPORT,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdBy: dcbAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportUser = await this.hubUsersService.createHubUser(dcbAdmin.id, supportData);
|
||||||
|
this.testUsers[`${teamPrefix}-support`] = {
|
||||||
|
id: supportUser.id,
|
||||||
|
username: supportUser.username,
|
||||||
|
role: UserRole.DCB_SUPPORT
|
||||||
|
};
|
||||||
|
|
||||||
|
// Créer DCB-PARTNER (Merchant Owner)
|
||||||
|
const partnerData: CreateHubUserData = {
|
||||||
|
username: `test-${teamPrefix}-partner-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-partner-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Partner',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdBy: dcbAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const partnerUser = await this.hubUsersService.createHubUser(dcbAdmin.id, partnerData);
|
||||||
|
this.testMerchants[`${teamPrefix}-partner`] = {
|
||||||
|
id: partnerUser.id,
|
||||||
|
username: partnerUser.username,
|
||||||
|
role: UserRole.DCB_PARTNER
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Admin creates Support and Partner`,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
supportUser: supportUser.username,
|
||||||
|
partnerUser: partnerUser.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 - ADMIN DCB-ADMIN crée DCB-PARTNER-ADMIN avec merchantPartnerId du DCB-PARTNER
|
||||||
|
const partnerAdminData: CreateMerchantUserData = {
|
||||||
|
username: `test-${teamPrefix}-partner-admin-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-partner-admin-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Partner Admin',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: partnerUser.id, // Utilise l'ID du DCB-PARTNER
|
||||||
|
createdBy: dcbAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const partnerAdminUser = await this.merchantUsersService.createMerchantUser(
|
||||||
|
dcbAdmin.id,
|
||||||
|
partnerAdminData
|
||||||
|
);
|
||||||
|
|
||||||
|
this.testMerchantUsers[`${teamPrefix}-partner-admin`] = {
|
||||||
|
id: partnerAdminUser.id,
|
||||||
|
username: partnerAdminUser.username,
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN,
|
||||||
|
merchantPartnerId: partnerUser.id
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Admin creates Partner Admin`,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
partnerAdmin: partnerAdminUser.username,
|
||||||
|
merchantPartnerId: partnerUser.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4 - DCB-PARTNER crée ses trois types d'utilisateurs
|
||||||
|
const partnerCreatedUsers = await this.testPartnerUserCreation(teamName, partnerUser.id);
|
||||||
|
results.push(...partnerCreatedUsers);
|
||||||
|
|
||||||
|
// 5 - DCB-PARTNER-ADMIN crée un manager
|
||||||
|
const adminCreatedManager = await this.testPartnerAdminCreatesManager(teamName, partnerUser.id);
|
||||||
|
results.push(adminCreatedManager);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Team Tests`,
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis utilisez-le dans votre méthode
|
||||||
|
private async testPartnerUserCreation(teamName: string, partnerId: string): Promise<TestResult[]> {
|
||||||
|
const results: TestResult[] = [];
|
||||||
|
const teamPrefix = teamName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partner = this.testMerchants[`${teamPrefix}-partner`];
|
||||||
|
if (!partner) {
|
||||||
|
throw new Error(`${teamName} Partner not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types d'utilisateurs à créer par le PARTNER
|
||||||
|
const userTypes: { role: MerchantUserRole; key: string }[] = [
|
||||||
|
{ role: UserRole.DCB_PARTNER_ADMIN, key: 'partner-admin-by-partner' },
|
||||||
|
{ role: UserRole.DCB_PARTNER_MANAGER, key: 'partner-manager-by-partner' },
|
||||||
|
{ role: UserRole.DCB_PARTNER_SUPPORT, key: 'partner-support-by-partner' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const userType of userTypes) {
|
||||||
|
const userData: CreateMerchantUserData = {
|
||||||
|
username: `test-${teamPrefix}-${userType.key}-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-${userType.key}-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: userType.role.split('_').pop() || 'User',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: userType.role, // Type compatible maintenant
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: partnerId,
|
||||||
|
createdBy: partner.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await this.merchantUsersService.createMerchantUser(partner.id, userData);
|
||||||
|
|
||||||
|
this.testMerchantUsers[`${teamPrefix}-${userType.key}`] = {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: userType.role,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Partner creates ${userType.role}`,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
createdUser: user.username,
|
||||||
|
role: userType.role,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
results.push({
|
||||||
|
testName: `${teamName} - Partner User Creation`,
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testPartnerAdminCreatesManager(teamName: string, partnerId: string): Promise<TestResult> {
|
||||||
|
const testName = `${teamName} - Partner Admin creates Manager`;
|
||||||
|
const teamPrefix = teamName.toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const partnerAdmin = this.testMerchantUsers[`${teamPrefix}-partner-admin`];
|
||||||
|
if (!partnerAdmin) {
|
||||||
|
throw new Error(`${teamName} Partner Admin not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5 - DCB-PARTNER-ADMIN crée un manager avec l'ID de son DCB-PARTNER
|
||||||
|
const managerData: CreateMerchantUserData = {
|
||||||
|
username: `test-${teamPrefix}-manager-by-admin-${Date.now()}`,
|
||||||
|
email: `test-${teamPrefix}-manager-by-admin-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: `${teamName}`,
|
||||||
|
lastName: 'Manager by Admin',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: partnerId, // Utilise l'ID du DCB-PARTNER (pas son propre ID)
|
||||||
|
createdBy: partnerAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const managerUser = await this.merchantUsersService.createMerchantUser(
|
||||||
|
partnerAdmin.id,
|
||||||
|
managerData
|
||||||
|
);
|
||||||
|
|
||||||
|
this.testMerchantUsers[`${teamPrefix}-manager-by-admin`] = {
|
||||||
|
id: managerUser.id,
|
||||||
|
username: managerUser.username,
|
||||||
|
role: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration: 0,
|
||||||
|
data: {
|
||||||
|
createdManager: managerUser.username,
|
||||||
|
createdBy: partnerAdmin.username,
|
||||||
|
merchantPartnerId: partnerId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: false,
|
||||||
|
duration: 0,
|
||||||
|
error: error.message
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isHealthy(): boolean {
|
private async testCrossTeamIsolation(): Promise<TestResult> {
|
||||||
return this.initialized;
|
const testName = 'Cross-Team Isolation Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const teamAPartnerAdmin = this.testMerchantUsers['teama-partner-admin'];
|
||||||
|
const teamBPartner = this.testMerchants['teamb-partner'];
|
||||||
|
|
||||||
|
if (!teamAPartnerAdmin || !teamBPartner) {
|
||||||
|
throw new Error('Team users not found for isolation test');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenter de créer un utilisateur dans l'autre équipe - devrait échouer
|
||||||
|
try {
|
||||||
|
const crossTeamUserData: CreateMerchantUserData = {
|
||||||
|
username: `test-cross-team-attempt-${Date.now()}`,
|
||||||
|
email: `test-cross-team-attempt-${Date.now()}@dcb-test.com`,
|
||||||
|
firstName: 'Cross',
|
||||||
|
lastName: 'Team Attempt',
|
||||||
|
password: 'TempPassword123!',
|
||||||
|
role: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
merchantPartnerId: teamBPartner.id, // ID d'une autre équipe
|
||||||
|
createdBy: teamAPartnerAdmin.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.merchantUsersService.createMerchantUser(
|
||||||
|
teamAPartnerAdmin.id,
|
||||||
|
crossTeamUserData
|
||||||
|
);
|
||||||
|
|
||||||
|
// Si on arrive ici, l'isolation a échoué
|
||||||
|
throw new Error('Isolation failed - User from TeamA could create user in TeamB');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// Comportement attendu - l'accès doit être refusé
|
||||||
|
if (error.message.includes('Forbidden') ||
|
||||||
|
error.message.includes('Insufficient permissions') ||
|
||||||
|
error.message.includes('not authorized') ||
|
||||||
|
error.message.includes('own merchant')) {
|
||||||
|
// Succès - l'isolation fonctionne
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: { isolationWorking: true }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Erreur inattendue
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TESTS AVANCÉS (conservés depuis la version originale) =====
|
||||||
|
private async testStatsAndReports(): Promise<TestResult> {
|
||||||
|
const testName = 'Stats and Reports Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
const stats = await this.hubUsersService.getHubUsersStats(serviceAccountId);
|
||||||
|
const activity = await this.hubUsersService.getHubUserActivity(serviceAccountId);
|
||||||
|
const sessions = await this.hubUsersService.getActiveHubSessions(serviceAccountId);
|
||||||
|
|
||||||
|
// Validation basique des stats
|
||||||
|
if (typeof stats.totalAdmins !== 'number' || typeof stats.totalSupport !== 'number') {
|
||||||
|
throw new Error('Stats validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: {
|
||||||
|
stats,
|
||||||
|
activityCount: activity.length,
|
||||||
|
sessionCount: sessions.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testHealthCheck(): Promise<TestResult> {
|
||||||
|
const testName = 'Health Check Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
const health = await this.hubUsersService.checkHubUsersHealth(serviceAccountId);
|
||||||
|
|
||||||
|
if (!health.status || !health.stats || !Array.isArray(health.issues)) {
|
||||||
|
throw new Error('Health check validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: { healthStatus: health.status }
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testSecurityValidations(): Promise<TestResult> {
|
||||||
|
const testName = 'Security Validations Test';
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
// Test de la méthode canUserManageHubUsers
|
||||||
|
const canManage = await this.hubUsersService.canUserManageHubUsers(serviceAccountId);
|
||||||
|
|
||||||
|
if (!canManage) {
|
||||||
|
throw new Error('Service account should be able to manage hub users');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.log(`✅ ${testName} - Success (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testName,
|
||||||
|
success: true,
|
||||||
|
duration,
|
||||||
|
data: { canManageHubUsers: canManage }
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.error(`❌ ${testName} - Failed: ${error.message}`);
|
||||||
|
return { testName, success: false, duration, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== NETTOYAGE =====
|
||||||
|
private async cleanupTestUsers(): Promise<void> {
|
||||||
|
this.logger.log('🧹 Cleaning up test users...');
|
||||||
|
|
||||||
|
const serviceToken = await this.tokenService.acquireServiceAccountToken();
|
||||||
|
const decodedToken = this.tokenService.decodeToken(serviceToken);
|
||||||
|
const serviceAccountId = decodedToken.sub;
|
||||||
|
|
||||||
|
// Nettoyer les utilisateurs hub
|
||||||
|
for (const [key, userInfo] of Object.entries(this.testUsers)) {
|
||||||
|
try {
|
||||||
|
await this.hubUsersService.deleteHubUser(userInfo.id, serviceAccountId);
|
||||||
|
this.logger.log(`✅ Deleted test user: ${key} (${userInfo.username})`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`⚠️ Could not delete test user ${key}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.testUsers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupTestMerchants(): Promise<void> {
|
||||||
|
this.logger.log('🧹 Cleaning up test merchants...');
|
||||||
|
|
||||||
|
// Implémentez la logique de nettoyage des merchants de test
|
||||||
|
this.testMerchants = {};
|
||||||
|
this.testMerchantUsers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LOGGING ET RAPPORTS =====
|
||||||
|
private logTestSummary(summary: StartupTestSummary): void {
|
||||||
|
this.logger.log('='.repeat(60));
|
||||||
|
this.logger.log('🎯 PARALLEL ISOLATION STARTUP TEST SUMMARY');
|
||||||
|
this.logger.log('='.repeat(60));
|
||||||
|
this.logger.log(`📊 Total Tests: ${summary.totalTests}`);
|
||||||
|
this.logger.log(`✅ Passed: ${summary.passedTests}`);
|
||||||
|
this.logger.log(`❌ Failed: ${summary.failedTests}`);
|
||||||
|
this.logger.log(`⏱️ Total Duration: ${summary.totalDuration}ms`);
|
||||||
|
this.logger.log('-'.repeat(60));
|
||||||
|
|
||||||
|
summary.results.forEach(result => {
|
||||||
|
const status = result.success ? '✅' : '❌';
|
||||||
|
this.logger.log(`${status} ${result.testName}: ${result.duration}ms`);
|
||||||
|
if (!result.success) {
|
||||||
|
this.logger.log(` ERROR: ${result.error}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log('='.repeat(60));
|
||||||
|
|
||||||
|
if (summary.failedTests === 0) {
|
||||||
|
this.logger.log('🚀 ALL TESTS PASSED! System is ready with proper isolation.');
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`⚠️ ${summary.failedTests} test(s) failed. Please check the logs above.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MÉTHODES PUBLIQUES POUR USAGE MANUEL =====
|
||||||
|
async runQuickTest(): Promise<StartupTestSummary> {
|
||||||
|
this.logger.log('🔍 Running quick startup test...');
|
||||||
|
return this.runAllTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTestStatus(): Promise<{ status: 'healthy' | 'degraded' | 'unhealthy'; details: string }> {
|
||||||
|
try {
|
||||||
|
const summary = await this.runAllTests();
|
||||||
|
const successRate = (summary.passedTests / summary.totalTests) * 100;
|
||||||
|
|
||||||
|
if (successRate === 100) {
|
||||||
|
return { status: 'healthy', details: 'All tests passed successfully' };
|
||||||
|
} else if (successRate >= 80) {
|
||||||
|
return { status: 'degraded', details: `${summary.failedTests} test(s) failed` };
|
||||||
|
} else {
|
||||||
|
return { status: 'unhealthy', details: 'Multiple test failures detected' };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'unhealthy', details: `Test execution failed: ${error.message}` };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
@ -10,11 +10,27 @@ export interface KeycloakTokenResponse {
|
|||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
|
refresh_expire_in?: number;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DecodedToken {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
given_name?: string;
|
||||||
|
family_name?: string;
|
||||||
|
realm_access?: { roles: string[] };
|
||||||
|
resource_access?: { [key: string]: { roles: string[] } };
|
||||||
|
merchantPartnerId?: string;
|
||||||
|
// Ajout des claims personnalisés
|
||||||
|
'merchant-partner-id'?: string;
|
||||||
|
'user-type'?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TokenService {
|
export class TokenService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(TokenService.name);
|
private readonly logger = new Logger(TokenService.name);
|
||||||
private readonly keycloakConfig: KeycloakConfig;
|
private readonly keycloakConfig: KeycloakConfig;
|
||||||
|
|
||||||
@ -22,11 +38,16 @@ export class TokenService {
|
|||||||
private serviceAccountToken: string | null = null;
|
private serviceAccountToken: string | null = null;
|
||||||
private serviceTokenExpiry: number = 0;
|
private serviceTokenExpiry: number = 0;
|
||||||
|
|
||||||
|
|
||||||
// === TOKEN STORAGE ===
|
// === TOKEN STORAGE ===
|
||||||
private userToken: string | null = null;
|
private userToken: string | null = null;
|
||||||
private userTokenExpiry: Date | null = null;
|
private userTokenExpiry: Date | null = null;
|
||||||
private userRefreshToken: string | null = null;
|
private userRefreshToken: string | null = null;
|
||||||
|
|
||||||
|
// Cache pour les clés publiques
|
||||||
|
private publicKeys: { [key: string]: string } = {};
|
||||||
|
private keysLastFetched: number = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly httpService: HttpService,
|
private readonly httpService: HttpService,
|
||||||
@ -34,6 +55,10 @@ export class TokenService {
|
|||||||
this.keycloakConfig = this.getKeycloakConfig();
|
this.keycloakConfig = this.getKeycloakConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.fetchPublicKeys();
|
||||||
|
}
|
||||||
|
|
||||||
// === CONFIGURATION ===
|
// === CONFIGURATION ===
|
||||||
private getKeycloakConfig(): KeycloakConfig {
|
private getKeycloakConfig(): KeycloakConfig {
|
||||||
const config = this.configService.get<KeycloakConfig>('keycloak');
|
const config = this.configService.get<KeycloakConfig>('keycloak');
|
||||||
@ -47,6 +72,32 @@ export class TokenService {
|
|||||||
return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`;
|
return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/token`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCertsEndpoint(): string {
|
||||||
|
return `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}/protocol/openid-connect/certs`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === GESTION DES CLÉS PUBLIQUES ===
|
||||||
|
private async fetchPublicKeys(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.get<{ keys: any[] }>(this.getCertsEndpoint())
|
||||||
|
);
|
||||||
|
|
||||||
|
response.data.keys.forEach((key: any) => {
|
||||||
|
this.publicKeys[key.kid] = key;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.keysLastFetched = Date.now();
|
||||||
|
this.logger.log('Public keys fetched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to fetch public keys', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldRefreshKeys(): boolean {
|
||||||
|
return Date.now() - this.keysLastFetched > 3600000; // 1 heure
|
||||||
|
}
|
||||||
|
|
||||||
// === CACHE MANAGEMENT ===
|
// === CACHE MANAGEMENT ===
|
||||||
private isServiceTokenValid(): boolean {
|
private isServiceTokenValid(): boolean {
|
||||||
if (!this.serviceAccountToken) return false;
|
if (!this.serviceAccountToken) return false;
|
||||||
@ -62,7 +113,6 @@ export class TokenService {
|
|||||||
|
|
||||||
// === TOKEN ACQUISITION ===
|
// === TOKEN ACQUISITION ===
|
||||||
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
|
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
grant_type: 'password',
|
grant_type: 'password',
|
||||||
client_id: this.keycloakConfig.authClientId,
|
client_id: this.keycloakConfig.authClientId,
|
||||||
@ -228,30 +278,7 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> {
|
// === TOKEN VALIDATION AMÉLIORÉE ===
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'refresh_token',
|
|
||||||
client_id: this.keycloakConfig.authClientId,
|
|
||||||
client_secret: this.keycloakConfig.authClientSecret,
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await firstValueFrom(
|
|
||||||
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log('Token refreshed successfully');
|
|
||||||
return response.data;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error('Token refresh failed', error.response?.data);
|
|
||||||
throw new Error(error.response?.data?.error_description || 'Token refresh failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === TOKEN VALIDATION ===
|
|
||||||
async validateToken(token: string): Promise<boolean> {
|
async validateToken(token: string): Promise<boolean> {
|
||||||
const mode = this.keycloakConfig.validationMode || 'online';
|
const mode = this.keycloakConfig.validationMode || 'online';
|
||||||
|
|
||||||
@ -283,37 +310,170 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private validateOffline(token: string): boolean {
|
private async validateOffline(token: string): Promise<boolean> {
|
||||||
if (!this.keycloakConfig.publicKey) {
|
if (this.shouldRefreshKeys()) {
|
||||||
this.logger.error('Missing public key for offline validation');
|
await this.fetchPublicKeys();
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formattedKey = `-----BEGIN PUBLIC KEY-----\n${this.keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`;
|
const decoded = jwt.decode(token, { complete: true }) as any;
|
||||||
|
if (!decoded?.header?.kid) {
|
||||||
|
throw new Error('Token missing key ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = this.publicKeys[decoded.header.kid];
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new Error(`Public key not found for kid: ${decoded.header.kid}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedKey = this.formatPublicKey(publicKey);
|
||||||
|
|
||||||
jwt.verify(token, formattedKey, {
|
jwt.verify(token, formattedKey, {
|
||||||
algorithms: ['RS256'],
|
algorithms: ['RS256'],
|
||||||
audience: this.keycloakConfig.authClientId,
|
//audience: this.keycloakConfig.authClientId,
|
||||||
|
issuer: `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}`,
|
||||||
|
ignoreExpiration: false,
|
||||||
|
ignoreNotBefore: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error('Offline token validation failed:', error.message);
|
this.logger.error('Offline token validation failed:', error.message);
|
||||||
|
|
||||||
|
// Fallback: validation basique du token
|
||||||
|
try {
|
||||||
|
const decoded = this.decodeToken(token);
|
||||||
|
return !!decoded && !!decoded.sub;
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === TOKEN UTILITIES ===
|
private formatPublicKey(key: any): string {
|
||||||
decodeToken(token: string): any {
|
if (key.x5c && key.x5c[0]) {
|
||||||
|
return `-----BEGIN CERTIFICATE-----\n${key.x5c[0]}\n-----END CERTIFICATE-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to public key format
|
||||||
|
const n = Buffer.from(key.n, 'base64');
|
||||||
|
const e = Buffer.from(key.e, 'base64');
|
||||||
|
|
||||||
|
const jwk = {
|
||||||
|
kty: key.kty,
|
||||||
|
n: n.toString('base64'),
|
||||||
|
e: e.toString('base64'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return `-----BEGIN PUBLIC KEY-----\n${jwk.n}\n-----END PUBLIC KEY-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === DECODAGE AVEC TYPAGE FORT ===
|
||||||
|
decodeToken(token: string): DecodedToken {
|
||||||
try {
|
try {
|
||||||
return jwt.decode(token);
|
const decoded = jwt.decode(token) as DecodedToken;
|
||||||
|
|
||||||
|
// Normalisation des claims personnalisés
|
||||||
|
if (decoded) {
|
||||||
|
// Support pour les claims personnalisés Keycloak
|
||||||
|
decoded.merchantPartnerId = decoded['merchant-partner-id'] || decoded.merchantPartnerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error('Failed to decode token', error.message);
|
this.logger.error('Failed to decode token', error.message);
|
||||||
throw new Error('Invalid token format');
|
throw new Error('Invalid token format');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === EXTRACTION D'INFORMATIONS SPÉCIFIQUES ===
|
||||||
|
extractMerchantPartnerId(token: string): string | null {
|
||||||
|
const decoded = this.decodeToken(token);
|
||||||
|
return decoded.merchantPartnerId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractUserRoles(token: string): string[] {
|
||||||
|
const decoded = this.decodeToken(token);
|
||||||
|
const roles: string[] = [];
|
||||||
|
|
||||||
|
// Rôles realm
|
||||||
|
if (decoded.realm_access?.roles) {
|
||||||
|
roles.push(...decoded.realm_access.roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rôles client
|
||||||
|
if (decoded.resource_access) {
|
||||||
|
Object.values(decoded.resource_access).forEach(client => {
|
||||||
|
if (client.roles) {
|
||||||
|
roles.push(...client.roles);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
extractUserId(token: string): string | null {
|
||||||
|
const decoded = this.decodeToken(token);
|
||||||
|
return decoded.sub || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === UTILITAIRES DE PERMISSIONS ===
|
||||||
|
hasRole(token: string, role: string): boolean {
|
||||||
|
const roles = this.extractUserRoles(token);
|
||||||
|
return roles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyRole(token: string, roles: string[]): boolean {
|
||||||
|
const userRoles = this.extractUserRoles(token);
|
||||||
|
return roles.some(role => userRoles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAllRoles(token: string, roles: string[]): boolean {
|
||||||
|
const userRoles = this.extractUserRoles(token);
|
||||||
|
return roles.every(role => userRoles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
isHubAdmin(token: string): boolean {
|
||||||
|
return this.hasRole(token, 'DCB_ADMIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
isHubSupport(token: string): boolean {
|
||||||
|
return this.hasRole(token, 'DCB_SUPPORT');
|
||||||
|
}
|
||||||
|
|
||||||
|
isMerchantPartner(token: string): boolean {
|
||||||
|
return this.hasRole(token, 'DCB_PARTNER');
|
||||||
|
}
|
||||||
|
|
||||||
|
isMerchantPartnerAdmin(token: string): boolean {
|
||||||
|
return this.hasRole(token, 'DCB_PARTNER_ADMIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AUTRES MÉTHODES (inchangées) ===
|
||||||
|
async refreshToken(refreshToken: string): Promise<KeycloakTokenResponse> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: this.keycloakConfig.authClientId,
|
||||||
|
client_secret: this.keycloakConfig.authClientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post<KeycloakTokenResponse>(this.getTokenEndpoint(), params, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log('Token refreshed successfully');
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error('Token refresh failed', error.response?.data);
|
||||||
|
throw new Error(error.response?.data?.error_description || 'Token refresh failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async revokeToken(token: string): Promise<void> {
|
async revokeToken(token: string): Promise<void> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: this.keycloakConfig.authClientId,
|
client_id: this.keycloakConfig.authClientId,
|
||||||
@ -337,7 +497,6 @@ export class TokenService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === SERVICE MANAGEMENT ===
|
|
||||||
clearServiceToken(): void {
|
clearServiceToken(): void {
|
||||||
this.serviceAccountToken = null;
|
this.serviceAccountToken = null;
|
||||||
this.serviceTokenExpiry = 0;
|
this.serviceTokenExpiry = 0;
|
||||||
|
|||||||
@ -5,10 +5,6 @@ export interface KeycloakConfig {
|
|||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
realm: string;
|
realm: string;
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
// Client pour l'API Admin (Service Account - client_credentials)
|
|
||||||
//adminClientId: string;
|
|
||||||
//adminClientSecret: string;
|
|
||||||
// Client pour l'authentification utilisateur (Password Grant)
|
|
||||||
authClientId: string;
|
authClientId: string;
|
||||||
authClientSecret: string;
|
authClientSecret: string;
|
||||||
validationMode: string;
|
validationMode: string;
|
||||||
@ -16,14 +12,10 @@ export interface KeycloakConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default registerAs('keycloak', (): KeycloakConfig => ({
|
export default registerAs('keycloak', (): KeycloakConfig => ({
|
||||||
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://keycloak-dcb.app.cameleonapp.com',
|
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://iam.dcb.pixpay.sn',
|
||||||
realm: process.env.KEYCLOAK_REALM || 'dcb-dev',
|
realm: process.env.KEYCLOAK_REALM || 'dcb-prod',
|
||||||
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
|
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
|
||||||
// Client pour Service Account (API Admin)
|
authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-cc-app',
|
||||||
//adminClientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID || 'dcb-user-service-cc',
|
|
||||||
//adminClientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET || '',
|
|
||||||
// Client pour Password Grant (Authentification utilisateur)
|
|
||||||
authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-pwd',
|
|
||||||
authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
|
authClientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
|
||||||
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
|
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
|
||||||
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
|
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
|
||||||
@ -53,20 +45,6 @@ export const keycloakConfigValidationSchema = Joi.object({
|
|||||||
'any.required': 'KEYCLOAK_PUBLIC_KEY is required'
|
'any.required': 'KEYCLOAK_PUBLIC_KEY is required'
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/*KEYCLOAK_ADMIN_CLIENT_ID: Joi.string()
|
|
||||||
.required()
|
|
||||||
.messages({
|
|
||||||
'any.required': 'KEYCLOAK_ADMIN_CLIENT_ID is required'
|
|
||||||
}),
|
|
||||||
|
|
||||||
KEYCLOAK_ADMIN_CLIENT_SECRET: Joi.string()
|
|
||||||
.required()
|
|
||||||
.min(1)
|
|
||||||
.messages({
|
|
||||||
'any.required': 'KEYCLOAK_ADMIN_CLIENT_SECRET is required',
|
|
||||||
'string.min': 'KEYCLOAK_ADMIN_CLIENT_SECRET cannot be empty'
|
|
||||||
}),*/
|
|
||||||
|
|
||||||
KEYCLOAK_CLIENT_ID: Joi.string()
|
KEYCLOAK_CLIENT_ID: Joi.string()
|
||||||
.required()
|
.required()
|
||||||
.messages({
|
.messages({
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
export const RESOURCES = {
|
|
||||||
USER: 'user', // user resource for /users/* endpoints
|
|
||||||
MERCHANT: 'merchants' // merchant resource for /merchants/* endpoints
|
|
||||||
};
|
|
||||||
4
src/constants/resources.ts
Normal file
4
src/constants/resources.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const RESOURCES = {
|
||||||
|
HUB_USER: 'user', // user resource for /users/* endpoints
|
||||||
|
MERCHANT_USER: 'partner' // merchant resource for /merchants/* endpoints
|
||||||
|
};
|
||||||
673
src/hub-users/controllers/hub-users.controller.ts
Normal file
673
src/hub-users/controllers/hub-users.controller.ts
Normal file
@ -0,0 +1,673 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
BadRequestException
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiProperty
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { HubUsersService } from '../services/hub-users.service';
|
||||||
|
import { UserRole, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model';
|
||||||
|
import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
|
||||||
|
import { RESOURCES } from '../../constants/resources';
|
||||||
|
import { SCOPES } from '../../constants/scopes';
|
||||||
|
import { Resource, Scopes } from 'nest-keycloak-connect';
|
||||||
|
|
||||||
|
export class LoginDto {
|
||||||
|
@ApiProperty({ description: 'Username' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Password' })
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TokenResponseDto {
|
||||||
|
@ApiProperty({ description: 'Access token' })
|
||||||
|
access_token: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Refresh token' })
|
||||||
|
refresh_token?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Token type' })
|
||||||
|
token_type: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Expires in (seconds)' })
|
||||||
|
expires_in: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Refresh expires in (seconds)' })
|
||||||
|
refresh_expires_in?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Scope' })
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs pour les utilisateurs Hub
|
||||||
|
export class CreateHubUserDto {
|
||||||
|
@ApiProperty({ description: 'Username for the hub user' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Email address' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'First name' })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last name' })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Password for the user' })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||||
|
description: 'Role for the hub user'
|
||||||
|
})
|
||||||
|
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: true })
|
||||||
|
enabled?: boolean = true;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: false })
|
||||||
|
emailVerified?: boolean = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateHubUserDto {
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateUserRoleDto {
|
||||||
|
@ApiProperty({
|
||||||
|
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||||
|
description: 'New role for the user'
|
||||||
|
})
|
||||||
|
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@ApiProperty({ description: 'New password' })
|
||||||
|
newPassword: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: true })
|
||||||
|
temporary?: boolean = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SuspendMerchantDto {
|
||||||
|
@ApiProperty({ description: 'Reason for suspension' })
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs pour les réponses
|
||||||
|
export class HubUserResponse {
|
||||||
|
@ApiProperty({ description: 'User ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Username' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Email address' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'First name' })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last name' })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||||
|
description: 'User role'
|
||||||
|
})
|
||||||
|
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Whether the user is enabled' })
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Whether the email is verified' })
|
||||||
|
emailVerified: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'User creator ID' })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'User creator username' })
|
||||||
|
createdByUsername: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Creation timestamp' })
|
||||||
|
createdTimestamp: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Last login timestamp' })
|
||||||
|
lastLogin?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['HUB'], description: 'User type' })
|
||||||
|
userType: 'HUB';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HubUsersStatsResponse {
|
||||||
|
@ApiProperty({ description: 'Total admin users' })
|
||||||
|
totalAdmins: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Total support users' })
|
||||||
|
totalSupport: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Active users count' })
|
||||||
|
activeUsers: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Inactive users count' })
|
||||||
|
inactiveUsers: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Users pending activation' })
|
||||||
|
pendingActivation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MerchantStatsResponse {
|
||||||
|
@ApiProperty({ description: 'Total merchants' })
|
||||||
|
totalMerchants: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Active merchants count' })
|
||||||
|
activeMerchants: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Suspended merchants count' })
|
||||||
|
suspendedMerchants: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Pending merchants count' })
|
||||||
|
pendingMerchants: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Total merchant users' })
|
||||||
|
totalUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthStatusResponse {
|
||||||
|
@ApiProperty({ enum: ['healthy', 'degraded', 'unhealthy'] })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String], description: 'Health issues detected' })
|
||||||
|
issues: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'System statistics' })
|
||||||
|
stats: HubUsersStatsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserActivityResponse {
|
||||||
|
@ApiProperty({ description: 'User information' })
|
||||||
|
user: HubUserResponse;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Last login date' })
|
||||||
|
lastLogin?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionResponse {
|
||||||
|
@ApiProperty({ description: 'User ID' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Username' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last access date' })
|
||||||
|
lastAccess: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PermissionResponse {
|
||||||
|
@ApiProperty({ description: 'Whether user can manage hub users' })
|
||||||
|
canManageHubUsers: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AvailableRolesResponse {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [Object],
|
||||||
|
description: 'Available roles'
|
||||||
|
})
|
||||||
|
roles: Array<{
|
||||||
|
value: UserRole;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageResponse {
|
||||||
|
@ApiProperty({ description: 'Response message' })
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapper functions
|
||||||
|
function mapToHubUserResponse(hubUser: HubUser): HubUserResponse {
|
||||||
|
return {
|
||||||
|
id: hubUser.id,
|
||||||
|
username: hubUser.username,
|
||||||
|
email: hubUser.email,
|
||||||
|
firstName: hubUser.firstName,
|
||||||
|
lastName: hubUser.lastName,
|
||||||
|
role: hubUser.role,
|
||||||
|
enabled: hubUser.enabled,
|
||||||
|
emailVerified: hubUser.emailVerified,
|
||||||
|
createdBy: hubUser.createdBy,
|
||||||
|
createdByUsername: hubUser.createdByUsername,
|
||||||
|
createdTimestamp: hubUser.createdTimestamp,
|
||||||
|
lastLogin: hubUser.lastLogin,
|
||||||
|
userType: hubUser.userType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToUserActivityResponse(activity: HubUserActivity): UserActivityResponse {
|
||||||
|
return {
|
||||||
|
user: mapToHubUserResponse(activity.user),
|
||||||
|
lastLogin: activity.lastLogin
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Hub Users')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('hub-users')
|
||||||
|
@Resource(RESOURCES.HUB_USER || RESOURCES.MERCHANT_USER)
|
||||||
|
export class HubUsersController {
|
||||||
|
constructor(private readonly hubUsersService: HubUsersService) {}
|
||||||
|
|
||||||
|
// ===== GESTION DES UTILISATEURS HUB =====
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get all hub users',
|
||||||
|
description: 'Returns all hub users (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Hub users retrieved successfully',
|
||||||
|
type: [HubUserResponse]
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getAllHubUsers(@Request() req): Promise<HubUserResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const users = await this.hubUsersService.getAllHubUsers(userId);
|
||||||
|
return users.map(mapToHubUserResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('role/:role')
|
||||||
|
@ApiOperation({ summary: 'Get hub users by role' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Hub users retrieved successfully',
|
||||||
|
type: [HubUserResponse]
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Invalid role' })
|
||||||
|
@ApiParam({ name: 'role', enum: UserRole, description: 'User role' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getHubUsersByRole(
|
||||||
|
@Param('role') role: UserRole,
|
||||||
|
@Request() req
|
||||||
|
): Promise<HubUserResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const validRole = this.hubUsersService.validateHubRoleFromString(role);
|
||||||
|
const users = await this.hubUsersService.getHubUsersByRole(validRole, userId);
|
||||||
|
return users.map(mapToHubUserResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get hub user by ID' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Hub user retrieved successfully',
|
||||||
|
type: HubUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'Hub user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'User ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getHubUserById(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Request() req
|
||||||
|
): Promise<HubUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const user = await this.hubUsersService.getHubUserById(id, userId);
|
||||||
|
return mapToHubUserResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Create a new hub user',
|
||||||
|
description: 'Create a hub user with specific role (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Hub user created successfully',
|
||||||
|
type: HubUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async createHubUser(
|
||||||
|
@Body() createHubUserDto: CreateHubUserDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<HubUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
const userData: CreateHubUserData = {
|
||||||
|
...createHubUserDto,
|
||||||
|
createdBy: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await this.hubUsersService.createHubUser(userId, userData);
|
||||||
|
return mapToHubUserResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a hub user' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Hub user updated successfully',
|
||||||
|
type: HubUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'Hub user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'User ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async updateHubUser(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() updateHubUserDto: UpdateHubUserDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<HubUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const user = await this.hubUsersService.updateHubUser(id, updateHubUserDto, userId);
|
||||||
|
return mapToHubUserResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/role')
|
||||||
|
@ApiOperation({ summary: 'Update hub user role' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'User role updated successfully',
|
||||||
|
type: HubUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden - only DCB_ADMIN can change roles' })
|
||||||
|
@ApiParam({ name: 'id', description: 'User ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async updateHubUserRole(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() updateRoleDto: UpdateUserRoleDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<HubUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const user = await this.hubUsersService.updateHubUserRole(id, updateRoleDto.role, userId);
|
||||||
|
return mapToHubUserResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a hub user' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Hub user deleted successfully' })
|
||||||
|
@ApiResponse({ status: 400, description: 'Cannot delete own account or last admin' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Hub user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'User ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.DELETE)
|
||||||
|
async deleteHubUser(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MessageResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
await this.hubUsersService.deleteHubUser(id, userId);
|
||||||
|
return { message: 'Hub user deleted successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== GESTION DES MOTS DE PASSE =====
|
||||||
|
|
||||||
|
@Post(':id/reset-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Reset hub user password' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Hub user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'User ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async resetHubUserPassword(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() resetPasswordDto: ResetPasswordDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MessageResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
await this.hubUsersService.resetHubUserPassword(
|
||||||
|
id,
|
||||||
|
resetPasswordDto.newPassword,
|
||||||
|
resetPasswordDto.temporary,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return { message: 'Password reset successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/send-reset-email')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Send password reset email to hub user' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Password reset email sent successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Hub user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'User ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async sendHubUserPasswordResetEmail(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MessageResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
await this.hubUsersService.sendHubUserPasswordResetEmail(id, userId);
|
||||||
|
return { message: 'Password reset email sent successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== GESTION DES MERCHANTS (DCB_PARTNER) =====
|
||||||
|
|
||||||
|
@Get('merchants/all')
|
||||||
|
@ApiOperation({ summary: 'Get all merchant partners' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchant partners retrieved successfully',
|
||||||
|
type: [HubUserResponse]
|
||||||
|
})
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getAllMerchants(@Request() req): Promise<HubUserResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const merchants = await this.hubUsersService.getAllMerchants(userId);
|
||||||
|
return merchants.map(mapToHubUserResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('merchants/:merchantId')
|
||||||
|
@ApiOperation({ summary: 'Get merchant partner by ID' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchant partner retrieved successfully',
|
||||||
|
type: HubUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
|
||||||
|
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMerchantPartnerById(
|
||||||
|
@Param('merchantId', ParseUUIDPipe) merchantId: string,
|
||||||
|
@Request() req
|
||||||
|
): Promise<HubUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const merchant = await this.hubUsersService.getMerchantPartnerById(merchantId, userId);
|
||||||
|
return mapToHubUserResponse(merchant);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('merchants/:merchantId')
|
||||||
|
@ApiOperation({ summary: 'Update a merchant partner' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchant partner updated successfully',
|
||||||
|
type: HubUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
|
||||||
|
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async updateMerchantPartner(
|
||||||
|
@Param('merchantId', ParseUUIDPipe) merchantId: string,
|
||||||
|
@Body() updateHubUserDto: UpdateHubUserDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<HubUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const merchant = await this.hubUsersService.updateMerchantPartner(merchantId, updateHubUserDto, userId);
|
||||||
|
return mapToHubUserResponse(merchant);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('merchants/:merchantId/suspend')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Suspend a merchant partner and all its users' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Merchant partner suspended successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
|
||||||
|
@ApiParam({ name: 'merchantId', description: 'Merchant Partner ID' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async suspendMerchantPartner(
|
||||||
|
@Param('merchantId', ParseUUIDPipe) merchantId: string,
|
||||||
|
@Body() suspendMerchantDto: SuspendMerchantDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MessageResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
await this.hubUsersService.suspendMerchantPartner(merchantId, suspendMerchantDto.reason, userId);
|
||||||
|
return { message: 'Merchant partner suspended successfully' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATISTIQUES ET RAPPORTS =====
|
||||||
|
|
||||||
|
@Get('stats/overview')
|
||||||
|
@ApiOperation({ summary: 'Get hub users statistics overview' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Statistics retrieved successfully',
|
||||||
|
type: HubUsersStatsResponse
|
||||||
|
})
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getHubUsersStats(@Request() req): Promise<HubUsersStatsResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
return this.hubUsersService.getHubUsersStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stats/merchants')
|
||||||
|
@ApiOperation({ summary: 'Get merchants statistics' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchants statistics retrieved successfully',
|
||||||
|
type: MerchantStatsResponse
|
||||||
|
})
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMerchantStats(@Request() req): Promise<MerchantStatsResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
return this.hubUsersService.getMerchantStats(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('activity/recent')
|
||||||
|
@ApiOperation({ summary: 'Get recent hub user activity' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Activity retrieved successfully',
|
||||||
|
type: [UserActivityResponse]
|
||||||
|
})
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getHubUserActivity(@Request() req): Promise<UserActivityResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const activities = await this.hubUsersService.getHubUserActivity(userId);
|
||||||
|
return activities.map(mapToUserActivityResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sessions/active')
|
||||||
|
@ApiOperation({ summary: 'Get active hub sessions' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Active sessions retrieved successfully',
|
||||||
|
type: [SessionResponse]
|
||||||
|
})
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getActiveHubSessions(@Request() req): Promise<SessionResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const sessions = await this.hubUsersService.getActiveHubSessions(userId);
|
||||||
|
return sessions.map(session => ({
|
||||||
|
userId: session.userId,
|
||||||
|
username: session.username,
|
||||||
|
lastAccess: session.lastAccess
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SANTÉ ET UTILITAIRES =====
|
||||||
|
|
||||||
|
@Get('health/status')
|
||||||
|
@ApiOperation({ summary: 'Get hub users health status' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Health status retrieved successfully',
|
||||||
|
type: HealthStatusResponse
|
||||||
|
})
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async checkHubUsersHealth(@Request() req): Promise<HealthStatusResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
return this.hubUsersService.checkHubUsersHealth(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me/permissions')
|
||||||
|
@ApiOperation({ summary: 'Check if current user can manage hub users' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Permissions check completed' })
|
||||||
|
async canUserManageHubUsers(@Request() req): Promise<PermissionResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const canManage = await this.hubUsersService.canUserManageHubUsers(userId);
|
||||||
|
return { canManageHubUsers: canManage };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('roles/available')
|
||||||
|
@ApiOperation({ summary: 'Get available hub roles' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Available roles retrieved successfully' })
|
||||||
|
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getAvailableHubRoles(): Promise<AvailableRolesResponse> {
|
||||||
|
const roles = [
|
||||||
|
{
|
||||||
|
value: UserRole.DCB_ADMIN,
|
||||||
|
label: 'DCB Admin',
|
||||||
|
description: 'Full administrative access to the entire system'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: UserRole.DCB_SUPPORT,
|
||||||
|
label: 'DCB Support',
|
||||||
|
description: 'Support access with limited administrative capabilities'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: UserRole.DCB_PARTNER,
|
||||||
|
label: 'DCB Partner',
|
||||||
|
description: 'Merchant partner with access to their own merchant ecosystem'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return { roles };
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/hub-users/controllers/merchant-partners.controller.ts
Normal file
298
src/hub-users/controllers/merchant-partners.controller.ts
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
HttpStatus,
|
||||||
|
HttpCode,
|
||||||
|
Logger,
|
||||||
|
DefaultValuePipe,
|
||||||
|
ParseIntPipe,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MerchantPartnersService,
|
||||||
|
MerchantPartner,
|
||||||
|
CreateMerchantPartnerData,
|
||||||
|
MerchantStats,
|
||||||
|
} from '../services/merchant-partners.service';
|
||||||
|
import { RESOURCES } from '../../constants/resources';
|
||||||
|
import { SCOPES } from '../../constants/scopes';
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
import {
|
||||||
|
CreateMerchantPartnerDto,
|
||||||
|
UpdateMerchantPartnerDto,
|
||||||
|
SuspendMerchantPartnerDto,
|
||||||
|
} from '../dto/merchant-partners.dto';
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T = any> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('partners')
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
export class MerchantPartnersController {
|
||||||
|
private readonly logger = new Logger(MerchantPartnersController.name);
|
||||||
|
|
||||||
|
constructor(private readonly merchantPartnersService: MerchantPartnersService) {}
|
||||||
|
|
||||||
|
private createApiResponse<T>(
|
||||||
|
success: boolean,
|
||||||
|
message: string,
|
||||||
|
data?: T,
|
||||||
|
): ApiResponse<T> {
|
||||||
|
return {
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CRÉATION DE MERCHANT PARTNERS =====
|
||||||
|
@Post()
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async createMerchantPartner(
|
||||||
|
@Body() createMerchantDto: CreateMerchantPartnerDto,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<ApiResponse<MerchantPartner>> {
|
||||||
|
this.logger.log(`Creating merchant partner: ${createMerchantDto.name}`);
|
||||||
|
|
||||||
|
const creatorId = req.user.sub;
|
||||||
|
|
||||||
|
const merchantData: CreateMerchantPartnerData = {
|
||||||
|
...createMerchantDto,
|
||||||
|
dcbPartnerOwner: {
|
||||||
|
username: createMerchantDto.dcbPartnerOwnerUsername,
|
||||||
|
email: createMerchantDto.dcbPartnerOwnerEmail,
|
||||||
|
firstName: createMerchantDto.dcbPartnerOwnerFirstName,
|
||||||
|
lastName: createMerchantDto.dcbPartnerOwnerLastName,
|
||||||
|
password: createMerchantDto.dcbPartnerOwnerPassword,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const merchant = await this.merchantPartnersService.createMerchantPartner(creatorId, merchantData);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner created successfully: ${merchant.name} (ID: ${merchant.id})`);
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchant partner created successfully',
|
||||||
|
merchant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RÉCUPÉRATION DE MERCHANT PARTNERS =====
|
||||||
|
@Get()
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getAllMerchantPartners(
|
||||||
|
@Request() req: any,
|
||||||
|
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||||
|
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||||
|
@Query('status') status?: string,
|
||||||
|
): Promise<ApiResponse<PaginatedResponse<MerchantPartner>>> {
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
|
||||||
|
const safeLimit = Math.min(limit, 100);
|
||||||
|
const safePage = Math.max(page, 1);
|
||||||
|
|
||||||
|
let merchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId);
|
||||||
|
|
||||||
|
// Filtrage par statut
|
||||||
|
if (status && ['ACTIVE', 'SUSPENDED', 'PENDING'].includes(status)) {
|
||||||
|
merchants = merchants.filter(merchant => merchant.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const startIndex = (safePage - 1) * safeLimit;
|
||||||
|
const endIndex = startIndex + safeLimit;
|
||||||
|
const paginatedMerchants = merchants.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const response: PaginatedResponse<MerchantPartner> = {
|
||||||
|
items: paginatedMerchants,
|
||||||
|
total: merchants.length,
|
||||||
|
page: safePage,
|
||||||
|
limit: safeLimit,
|
||||||
|
totalPages: Math.ceil(merchants.length / safeLimit),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchant partners retrieved successfully',
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMerchantStats(
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<ApiResponse<MerchantStats>> {
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
const stats = await this.merchantPartnersService.getMerchantStats(requesterId);
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchant statistics retrieved successfully',
|
||||||
|
stats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMerchantPartnerById(
|
||||||
|
@Param('id') merchantId: string,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<ApiResponse<MerchantPartner>> {
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
const merchant = await this.merchantPartnersService.getMerchantPartnerById(merchantId, requesterId);
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchant partner retrieved successfully',
|
||||||
|
merchant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MISE À JOUR DE MERCHANT PARTNERS =====
|
||||||
|
@Put(':id')
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async updateMerchantPartner(
|
||||||
|
@Param('id') merchantId: string,
|
||||||
|
@Body() updateMerchantDto: UpdateMerchantPartnerDto,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<ApiResponse<MerchantPartner>> {
|
||||||
|
this.logger.log(`Updating merchant partner: ${merchantId}`);
|
||||||
|
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
const merchant = await this.merchantPartnersService.updateMerchantPartner(
|
||||||
|
merchantId,
|
||||||
|
updateMerchantDto,
|
||||||
|
requesterId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner updated successfully: ${merchant.name}`);
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchant partner updated successfully',
|
||||||
|
merchant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/suspend')
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async suspendMerchantPartner(
|
||||||
|
@Param('id') merchantId: string,
|
||||||
|
@Body() suspendDto: SuspendMerchantPartnerDto,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<ApiResponse<void>> {
|
||||||
|
this.logger.log(`Suspending merchant partner: ${merchantId}`);
|
||||||
|
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
await this.merchantPartnersService.suspendMerchantPartner(
|
||||||
|
merchantId,
|
||||||
|
suspendDto.reason,
|
||||||
|
requesterId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner suspended successfully: ${merchantId}`);
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchant partner suspended successfully',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/activate')
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async activateMerchantPartner(
|
||||||
|
@Param('id') merchantId: string,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<ApiResponse<MerchantPartner>> {
|
||||||
|
this.logger.log(`Activating merchant partner: ${merchantId}`);
|
||||||
|
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
const merchant = await this.merchantPartnersService.updateMerchantPartner(
|
||||||
|
merchantId,
|
||||||
|
{ status: 'ACTIVE' },
|
||||||
|
requesterId,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner activated successfully: ${merchant.name}`);
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchant partner activated successfully',
|
||||||
|
merchant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SUPPRESSION DE MERCHANT PARTNERS =====
|
||||||
|
@Delete(':id')
|
||||||
|
@Scopes(SCOPES.DELETE)
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deleteMerchantPartner(
|
||||||
|
@Param('id') merchantId: string,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.log(`Deleting merchant partner: ${merchantId}`);
|
||||||
|
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
// Implémentez la logique de suppression si nécessaire
|
||||||
|
// await this.merchantPartnersService.deleteMerchantPartner(merchantId, requesterId);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner deletion scheduled: ${merchantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RECHERCHE ET FILTRES =====
|
||||||
|
@Get('search/by-name')
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async searchMerchantsByName(
|
||||||
|
@Query('name') name: string,
|
||||||
|
@Request() req: any,
|
||||||
|
): Promise<ApiResponse<MerchantPartner[]>> {
|
||||||
|
if (!name || name.length < 2) {
|
||||||
|
throw new BadRequestException('Name must be at least 2 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Searching merchants by name: ${name}`);
|
||||||
|
|
||||||
|
const requesterId = req.user.sub;
|
||||||
|
const allMerchants = await this.merchantPartnersService.getAllMerchantPartners(requesterId);
|
||||||
|
|
||||||
|
const filteredMerchants = allMerchants.filter(merchant =>
|
||||||
|
merchant.name.toLowerCase().includes(name.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Found ${filteredMerchants.length} merchants matching name: ${name}`);
|
||||||
|
|
||||||
|
return this.createApiResponse(
|
||||||
|
true,
|
||||||
|
'Merchants search completed successfully',
|
||||||
|
filteredMerchants,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
514
src/hub-users/controllers/merchant-users.controller.ts
Normal file
514
src/hub-users/controllers/merchant-users.controller.ts
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
BadRequestException
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiParam,
|
||||||
|
ApiQuery,
|
||||||
|
ApiProperty
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { MerchantUsersService, MerchantUser, CreateMerchantUserData } from '../services/merchant-users.service';
|
||||||
|
import { JwtAuthGuard } from '../../auth/guards/jwt.guard';
|
||||||
|
import { UserRole } from '../../auth/services/keycloak-user.model';
|
||||||
|
import { RESOURCES } from '../../constants/resources';
|
||||||
|
import { SCOPES } from '../../constants/scopes';
|
||||||
|
import { Resource, Scopes } from 'nest-keycloak-connect';
|
||||||
|
|
||||||
|
export class CreateMerchantUserDto {
|
||||||
|
@ApiProperty({ description: 'Username for the merchant user' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Email address' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'First name' })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last name' })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Password for the user' })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
|
||||||
|
description: 'Role for the merchant user'
|
||||||
|
})
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: true })
|
||||||
|
enabled?: boolean = true;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: false })
|
||||||
|
emailVerified?: boolean = false;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Merchant partner ID' })
|
||||||
|
merchantPartnerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateMerchantUserDto {
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@ApiProperty({ description: 'New password' })
|
||||||
|
newPassword: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, default: true })
|
||||||
|
temporary?: boolean = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MerchantUserResponse {
|
||||||
|
@ApiProperty({ description: 'User ID' })
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Username' })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Email address' })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'First name' })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Last name' })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
enum: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
|
||||||
|
description: 'User role'
|
||||||
|
})
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Whether the user is enabled' })
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Whether the email is verified' })
|
||||||
|
emailVerified: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Merchant partner ID' })
|
||||||
|
merchantPartnerId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'User creator ID' })
|
||||||
|
createdBy: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'User creator username' })
|
||||||
|
createdByUsername: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Creation timestamp' })
|
||||||
|
createdTimestamp: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Last login timestamp' })
|
||||||
|
lastLogin?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['MERCHANT'], description: 'User type' })
|
||||||
|
userType: 'MERCHANT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MerchantUsersStatsResponse {
|
||||||
|
@ApiProperty({ description: 'Total admin users' })
|
||||||
|
totalAdmins: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Total manager users' })
|
||||||
|
totalManagers: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Total support users' })
|
||||||
|
totalSupport: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Total users' })
|
||||||
|
totalUsers: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Active users count' })
|
||||||
|
activeUsers: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Inactive users count' })
|
||||||
|
inactiveUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AvailableRolesResponse {
|
||||||
|
@ApiProperty({
|
||||||
|
type: [Object],
|
||||||
|
description: 'Available roles with permissions'
|
||||||
|
})
|
||||||
|
roles: Array<{
|
||||||
|
value: UserRole;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
allowedForCreation: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapper function pour convertir MerchantUser en MerchantUserResponse
|
||||||
|
function mapToMerchantUserResponse(merchantUser: MerchantUser): MerchantUserResponse {
|
||||||
|
return {
|
||||||
|
id: merchantUser.id,
|
||||||
|
username: merchantUser.username,
|
||||||
|
email: merchantUser.email,
|
||||||
|
firstName: merchantUser.firstName,
|
||||||
|
lastName: merchantUser.lastName,
|
||||||
|
role: merchantUser.role,
|
||||||
|
enabled: merchantUser.enabled,
|
||||||
|
emailVerified: merchantUser.emailVerified,
|
||||||
|
merchantPartnerId: merchantUser.merchantPartnerId,
|
||||||
|
createdBy: merchantUser.createdBy,
|
||||||
|
createdByUsername: merchantUser.createdByUsername,
|
||||||
|
createdTimestamp: merchantUser.createdTimestamp,
|
||||||
|
lastLogin: merchantUser.lastLogin,
|
||||||
|
userType: merchantUser.userType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Merchant Users')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('merchant-users')
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
export class MerchantUsersController {
|
||||||
|
constructor(private readonly merchantUsersService: MerchantUsersService) {}
|
||||||
|
|
||||||
|
// ===== RÉCUPÉRATION D'UTILISATEURS =====
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get merchant users for current user merchant',
|
||||||
|
description: 'Returns merchant users based on the current user merchant partner ID'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchant users retrieved successfully',
|
||||||
|
type: [MerchantUserResponse]
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMyMerchantUsers(@Request() req): Promise<MerchantUserResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
// Récupérer le merchantPartnerId de l'utilisateur courant
|
||||||
|
const userMerchantId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
if (!userMerchantId) {
|
||||||
|
throw new BadRequestException('Current user is not associated with a merchant partner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
|
||||||
|
return users.map(mapToMerchantUserResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('partner/:partnerId')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Get merchant users by partner ID',
|
||||||
|
description: 'Returns all merchant users for a specific merchant partner'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchant users retrieved successfully',
|
||||||
|
type: [MerchantUserResponse]
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant partner not found' })
|
||||||
|
@ApiParam({ name: 'partnerId', description: 'Merchant Partner ID' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMerchantUsersByPartner(
|
||||||
|
@Param('partnerId', ParseUUIDPipe) partnerId: string,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MerchantUserResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const users = await this.merchantUsersService.getMerchantUsersByPartner(partnerId, userId);
|
||||||
|
return users.map(mapToMerchantUserResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get merchant user by ID' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchant user retrieved successfully',
|
||||||
|
type: MerchantUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Merchant User ID' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMerchantUserById(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MerchantUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const user = await this.merchantUsersService.getMerchantUserById(id, userId);
|
||||||
|
return mapToMerchantUserResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CRÉATION D'UTILISATEURS =====
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Create a new merchant user',
|
||||||
|
description: 'Create a merchant user with specific role and merchant partner association'
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: 201,
|
||||||
|
description: 'Merchant user created successfully',
|
||||||
|
type: MerchantUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Bad request - invalid data or duplicate user' })
|
||||||
|
@ApiResponse({ status: 403, description: 'Forbidden - insufficient permissions' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async createMerchantUser(
|
||||||
|
@Body() createMerchantUserDto: CreateMerchantUserDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MerchantUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
const userData: CreateMerchantUserData = {
|
||||||
|
...createMerchantUserDto,
|
||||||
|
createdBy: userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await this.merchantUsersService.createMerchantUser(userId, userData);
|
||||||
|
return mapToMerchantUserResponse(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MISE À JOUR D'UTILISATEURS =====
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a merchant user' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Merchant user updated successfully',
|
||||||
|
type: MerchantUserResponse
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Merchant User ID' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async updateMerchantUser(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() updateMerchantUserDto: UpdateMerchantUserDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<MerchantUserResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
// Pour l'instant, on suppose que la mise à jour se fait via Keycloak
|
||||||
|
// Vous devrez implémenter updateMerchantUser dans le service
|
||||||
|
throw new BadRequestException('Update merchant user not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SUPPRESSION D'UTILISATEURS =====
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Delete a merchant user' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Merchant user deleted successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Merchant User ID' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.DELETE)
|
||||||
|
async deleteMerchantUser(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Request() req
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
// Vous devrez implémenter deleteMerchantUser dans le service
|
||||||
|
throw new BadRequestException('Delete merchant user not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== GESTION DES MOTS DE PASSE =====
|
||||||
|
|
||||||
|
@Post(':id/reset-password')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ summary: 'Reset merchant user password' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Merchant user not found' })
|
||||||
|
@ApiParam({ name: 'id', description: 'Merchant User ID' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.WRITE)
|
||||||
|
async resetMerchantUserPassword(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() resetPasswordDto: ResetPasswordDto,
|
||||||
|
@Request() req
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
// Vous devrez implémenter resetMerchantUserPassword dans le service
|
||||||
|
throw new BadRequestException('Reset merchant user password not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATISTIQUES ET RAPPORTS =====
|
||||||
|
|
||||||
|
@Get('stats/overview')
|
||||||
|
@ApiOperation({ summary: 'Get merchant users statistics overview' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Statistics retrieved successfully',
|
||||||
|
type: MerchantUsersStatsResponse
|
||||||
|
})
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getMerchantUsersStats(@Request() req): Promise<MerchantUsersStatsResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
// Récupérer le merchantPartnerId de l'utilisateur courant
|
||||||
|
const userMerchantId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
if (!userMerchantId) {
|
||||||
|
throw new BadRequestException('Current user is not associated with a merchant partner');
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
|
||||||
|
|
||||||
|
const stats: MerchantUsersStatsResponse = {
|
||||||
|
totalAdmins: users.filter(user => user.role === UserRole.DCB_PARTNER_ADMIN).length,
|
||||||
|
totalManagers: users.filter(user => user.role === UserRole.DCB_PARTNER_MANAGER).length,
|
||||||
|
totalSupport: users.filter(user => user.role === UserRole.DCB_PARTNER_SUPPORT).length,
|
||||||
|
totalUsers: users.length,
|
||||||
|
activeUsers: users.filter(user => user.enabled).length,
|
||||||
|
inactiveUsers: users.filter(user => !user.enabled).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
@ApiOperation({ summary: 'Search merchant users' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Search results retrieved successfully',
|
||||||
|
type: [MerchantUserResponse]
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: 'query', required: false, description: 'Search query (username, email, first name, last name)' })
|
||||||
|
@ApiQuery({ name: 'role', required: false, enum: UserRole, description: 'Filter by role' })
|
||||||
|
@ApiQuery({ name: 'enabled', required: false, type: Boolean, description: 'Filter by enabled status' })
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async searchMerchantUsers(
|
||||||
|
@Request() req,
|
||||||
|
@Query('query') query?: string,
|
||||||
|
@Query('role') role?: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT,
|
||||||
|
@Query('enabled') enabled?: boolean
|
||||||
|
): Promise<MerchantUserResponse[]> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
|
||||||
|
// Récupérer le merchantPartnerId de l'utilisateur courant
|
||||||
|
const userMerchantId = await this.getUserMerchantPartnerId(userId);
|
||||||
|
if (!userMerchantId) {
|
||||||
|
throw new BadRequestException('Current user is not associated with a merchant partner');
|
||||||
|
}
|
||||||
|
|
||||||
|
let users = await this.merchantUsersService.getMerchantUsersByPartner(userMerchantId, userId);
|
||||||
|
|
||||||
|
// Appliquer les filtres
|
||||||
|
if (query) {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
users = users.filter(user =>
|
||||||
|
user.username.toLowerCase().includes(lowerQuery) ||
|
||||||
|
user.email.toLowerCase().includes(lowerQuery) ||
|
||||||
|
user.firstName.toLowerCase().includes(lowerQuery) ||
|
||||||
|
user.lastName.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
users = users.filter(user => user.role === role);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled !== undefined) {
|
||||||
|
users = users.filter(user => user.enabled === enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users.map(mapToMerchantUserResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UTILITAIRES =====
|
||||||
|
|
||||||
|
@Get('roles/available')
|
||||||
|
@ApiOperation({ summary: 'Get available merchant roles' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Available roles retrieved successfully',
|
||||||
|
type: AvailableRolesResponse
|
||||||
|
})
|
||||||
|
@Resource(RESOURCES.MERCHANT_USER)
|
||||||
|
@Scopes(SCOPES.READ)
|
||||||
|
async getAvailableMerchantRoles(@Request() req): Promise<AvailableRolesResponse> {
|
||||||
|
const userId = req.user.sub;
|
||||||
|
const userRoles = await this.getUserRoles(userId);
|
||||||
|
|
||||||
|
const isPartner = userRoles.includes(UserRole.DCB_PARTNER);
|
||||||
|
const isPartnerAdmin = userRoles.includes(UserRole.DCB_PARTNER_ADMIN);
|
||||||
|
const isHubAdmin = userRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role)
|
||||||
|
);
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
{
|
||||||
|
value: UserRole.DCB_PARTNER_ADMIN,
|
||||||
|
label: 'Partner Admin',
|
||||||
|
description: 'Full administrative access within the merchant partner',
|
||||||
|
allowedForCreation: isPartner || isHubAdmin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
label: 'Partner Manager',
|
||||||
|
description: 'Manager access with limited administrative capabilities',
|
||||||
|
allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: UserRole.DCB_PARTNER_SUPPORT,
|
||||||
|
label: 'Partner Support',
|
||||||
|
description: 'Support role with read-only and basic operational access',
|
||||||
|
allowedForCreation: isPartner || isPartnerAdmin || isHubAdmin
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return { roles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MÉTHODES PRIVÉES D'ASSISTANCE =====
|
||||||
|
|
||||||
|
private async getUserMerchantPartnerId(userId: string): Promise<string | null> {
|
||||||
|
// Implémentez cette méthode pour récupérer le merchantPartnerId de l'utilisateur
|
||||||
|
// Cela dépend de votre implémentation Keycloak
|
||||||
|
try {
|
||||||
|
// Exemple - à adapter selon votre implémentation
|
||||||
|
const user = await this.merchantUsersService['keycloakApi'].getUserById(userId, userId);
|
||||||
|
return user.attributes?.merchantPartnerId?.[0] || null;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUserRoles(userId: string): Promise<UserRole[]> {
|
||||||
|
// Implémentez cette méthode pour récupérer les rôles de l'utilisateur
|
||||||
|
try {
|
||||||
|
const roles = await this.merchantUsersService['keycloakApi'].getUserClientRoles(userId);
|
||||||
|
return roles.map(role => role.name as UserRole);
|
||||||
|
} catch (error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/hub-users/dto/hub-user.dto.ts
Normal file
105
src/hub-users/dto/hub-user.dto.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// dto/hub-users.dto.ts
|
||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
Matches,
|
||||||
|
IsUUID,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { UserRole } from '../../auth/services/keycloak-user.model';
|
||||||
|
|
||||||
|
// Utiliser directement UserRole au lieu de créer un enum local
|
||||||
|
export class CreateHubUserDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
|
||||||
|
})
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum([UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], {
|
||||||
|
message: 'Role must be either DCB_ADMIN or DCB_SUPPORT',
|
||||||
|
})
|
||||||
|
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
emailVerified?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateHubUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateHubUserRoleDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum([UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT], {
|
||||||
|
message: 'Role must be either DCB_ADMIN or DCB_SUPPORT',
|
||||||
|
})
|
||||||
|
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResetPasswordDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
temporary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserIdParamDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUUID()
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
96
src/hub-users/dto/merchant-partners.dto.ts
Normal file
96
src/hub-users/dto/merchant-partners.dto.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
Matches,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateMerchantPartnerDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
legalName: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
// Propriétaire DCB_PARTNER
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
dcbPartnerOwnerUsername: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEmail()
|
||||||
|
dcbPartnerOwnerEmail: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
dcbPartnerOwnerFirstName: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
dcbPartnerOwnerLastName: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
|
||||||
|
})
|
||||||
|
dcbPartnerOwnerPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateMerchantPartnerDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
legalName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(['ACTIVE', 'SUSPENDED', 'PENDING'])
|
||||||
|
status?: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SuspendMerchantPartnerDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(5)
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
87
src/hub-users/dto/merchant-users.dto.ts
Normal file
87
src/hub-users/dto/merchant-users.dto.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
IsEmail,
|
||||||
|
IsEnum,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsBoolean,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
Matches,
|
||||||
|
IsUUID,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { UserRole } from '../../auth/services/keycloak-user.model';
|
||||||
|
|
||||||
|
export class CreateMerchantUserDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEmail()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
|
||||||
|
})
|
||||||
|
password?: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsEnum([UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT])
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
emailVerified?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateMerchantUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResetMerchantPasswordDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8)
|
||||||
|
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, {
|
||||||
|
message: 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character',
|
||||||
|
})
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
temporary?: boolean;
|
||||||
|
}
|
||||||
24
src/hub-users/hub-users.module.ts
Normal file
24
src/hub-users/hub-users.module.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
|
import { HttpModule } from '@nestjs/axios';
|
||||||
|
import { TokenService } from '../auth/services/token.service'
|
||||||
|
import { HubUsersService } from './services/hub-users.service'
|
||||||
|
import { HubUsersController } from './controllers/hub-users.controller'
|
||||||
|
import { MerchantUsersService } from './services/merchant-users.service'
|
||||||
|
import { MerchantUsersController } from './controllers/merchant-users.controller'
|
||||||
|
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
|
||||||
|
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HttpModule,
|
||||||
|
JwtModule.register({}),
|
||||||
|
],
|
||||||
|
providers: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService],
|
||||||
|
controllers: [HubUsersController, MerchantUsersController],
|
||||||
|
exports: [HubUsersService, MerchantUsersService, KeycloakApiService, TokenService, JwtModule],
|
||||||
|
})
|
||||||
|
export class HubUsersModule {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
100
src/hub-users/models/hub-user.model.ts
Normal file
100
src/hub-users/models/hub-user.model.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
// user.models.ts
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
emailVerified: boolean;
|
||||||
|
userType: UserType;
|
||||||
|
merchantPartnerId?: string;
|
||||||
|
clientRoles: UserRole[];
|
||||||
|
createdBy?: string;
|
||||||
|
createdByUsername?: string;
|
||||||
|
createdTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserType {
|
||||||
|
HUB = 'hub',
|
||||||
|
MERCHANT_PARTNER = 'merchant_partner'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserRole {
|
||||||
|
// Rôles Hub (sans merchantPartnerId)
|
||||||
|
DCB_ADMIN = 'dcb-admin',
|
||||||
|
DCB_SUPPORT = 'dcb-support',
|
||||||
|
DCB_PARTNER = 'dcb-partner',
|
||||||
|
|
||||||
|
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
|
||||||
|
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
|
||||||
|
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
|
||||||
|
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs pour la création
|
||||||
|
export interface CreateUserDto {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
password: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
userType: UserType;
|
||||||
|
merchantPartnerId?: string;
|
||||||
|
clientRoles: UserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHubUserDto extends CreateUserDto {
|
||||||
|
hubRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMerchantPartnerUserDto extends CreateUserDto {
|
||||||
|
merchantRole: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserDto {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
clientRoles?: UserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserQueryDto {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
userType?: UserType;
|
||||||
|
merchantPartnerId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
user: User;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedUsersResponse {
|
||||||
|
users: User[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pour l'authentification
|
||||||
|
export interface LoginDto {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in: number;
|
||||||
|
token_type: string;
|
||||||
|
refresh_expires_in?: number;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
552
src/hub-users/services/hub-users.service.ts
Normal file
552
src/hub-users/services/hub-users.service.ts
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||||
|
import { CreateUserData, UserRole, KeycloakUser, HubUser, CreateHubUserData, HubUserStats, HubHealthStatus, HubUserActivity, MerchantStats } from '../../auth/services/keycloak-user.model'; // SUPPRIMER import type
|
||||||
|
import { LoginDto, TokenResponse, User } from '../models/hub-user.model';
|
||||||
|
|
||||||
|
// ===== SERVICE PRINCIPAL =====
|
||||||
|
@Injectable()
|
||||||
|
export class HubUsersService {
|
||||||
|
private readonly logger = new Logger(HubUsersService.name);
|
||||||
|
|
||||||
|
// Définir les rôles Hub autorisés comme constante
|
||||||
|
private readonly HUB_ROLES = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER];
|
||||||
|
|
||||||
|
constructor(private readonly keycloakApi: KeycloakApiService) {}
|
||||||
|
|
||||||
|
// === AUTHENTIFICATION UTILISATEUR ===
|
||||||
|
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
|
||||||
|
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== VALIDATION ET UTILITAIRES =====
|
||||||
|
private isValidHubRole(role: UserRole): role is UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER {
|
||||||
|
return this.HUB_ROLES.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateHubRole(role: UserRole): void {
|
||||||
|
if (!this.isValidHubRole(role)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid hub role: ${role}. Hub roles must be DCB_ADMIN or DCB_SUPPORT`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateHubRoleFromString(role: string): UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER {
|
||||||
|
// Vérifier si le rôle est valide
|
||||||
|
if (!Object.values(UserRole).includes(role as UserRole)) {
|
||||||
|
throw new BadRequestException(`Invalid role: ${role}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = role as UserRole;
|
||||||
|
if (!this.isValidHubRole(userRole)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Invalid hub role: ${role}. Must be DCB_ADMIN or DCB_SUPPORT`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return userRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateHubUserAccess(requesterId: string): Promise<void> {
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
const isHubAdmin = requesterRoles.some(role =>
|
||||||
|
this.HUB_ROLES.includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isHubAdmin) {
|
||||||
|
throw new ForbiddenException('Only hub administrators can manage hub users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseKeycloakAttribute(value: string[] | undefined): string | undefined {
|
||||||
|
return value?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseKeycloakTimestamp(value: string[] | undefined): number | undefined {
|
||||||
|
const strValue = this.parseKeycloakAttribute(value);
|
||||||
|
if (!strValue) return undefined;
|
||||||
|
|
||||||
|
const timestamp = parseInt(strValue);
|
||||||
|
if (!isNaN(timestamp)) return timestamp;
|
||||||
|
|
||||||
|
const date = new Date(strValue);
|
||||||
|
return isNaN(date.getTime()) ? undefined : date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToHubUser(user: KeycloakUser, roles: any[]): HubUser {
|
||||||
|
const hubRole = roles.find(role => this.isValidHubRole(role.name as UserRole));
|
||||||
|
|
||||||
|
if (!user.id) throw new Error('User ID is required');
|
||||||
|
if (!user.email) throw new Error('User email is required');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName || '',
|
||||||
|
lastName: user.lastName || '',
|
||||||
|
role: hubRole?.name as UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER,
|
||||||
|
enabled: user.enabled,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
createdBy: this.parseKeycloakAttribute(user.attributes?.createdBy) || 'unknown',
|
||||||
|
createdByUsername: this.parseKeycloakAttribute(user.attributes?.createdByUsername) || 'unknown',
|
||||||
|
createdTimestamp: user.createdTimestamp || Date.now(),
|
||||||
|
lastLogin: this.parseKeycloakTimestamp(user.attributes?.lastLogin),
|
||||||
|
userType: 'HUB',
|
||||||
|
attributes: user.attributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== OPÉRATIONS CRUD =====
|
||||||
|
async createHubUser(creatorId: string, userData: CreateHubUserData): Promise<HubUser> {
|
||||||
|
this.logger.log(`Creating hub user: ${userData.username} with role: ${userData.role}`);
|
||||||
|
|
||||||
|
this.validateHubRole(userData.role);
|
||||||
|
await this.validateHubUserAccess(creatorId);
|
||||||
|
|
||||||
|
// Vérifier les doublons
|
||||||
|
this.checkDuplicateHubUser(userData)
|
||||||
|
|
||||||
|
const keycloakUserData: CreateUserData = {
|
||||||
|
username: userData.username,
|
||||||
|
email: userData.email,
|
||||||
|
firstName: userData.firstName,
|
||||||
|
lastName: userData.lastName,
|
||||||
|
password: userData.password,
|
||||||
|
enabled: userData.enabled ?? true,
|
||||||
|
emailVerified: userData.emailVerified ?? false,
|
||||||
|
merchantPartnerId: undefined,
|
||||||
|
clientRoles: [userData.role],
|
||||||
|
createdBy: creatorId,
|
||||||
|
initialStatus: 'PENDING_ACTIVATION',
|
||||||
|
};
|
||||||
|
|
||||||
|
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
|
||||||
|
const createdUser = await this.getHubUserById(userId, creatorId);
|
||||||
|
|
||||||
|
this.logger.log(`Hub user created successfully: ${userData.username} (ID: ${userId})`);
|
||||||
|
return createdUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkDuplicateHubUser(merchantData: CreateHubUserData): Promise<void> {
|
||||||
|
const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.username);
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
throw new BadRequestException(`Merchant partner with username ${merchantData.username} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.email);
|
||||||
|
if (existingEmails.length > 0) {
|
||||||
|
throw new BadRequestException(`Merchant partner with email ${merchantData.email} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RÉCUPÉRATION DE TOUS LES MERCHANTS (DCB_PARTNER) =====
|
||||||
|
async getAllMerchants(requesterId: string): Promise<HubUser[]> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
const merchants: HubUser[] = [];
|
||||||
|
|
||||||
|
for (const user of allUsers) {
|
||||||
|
if (!user.id) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
||||||
|
const isMerchant = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
|
||||||
|
|
||||||
|
if (isMerchant) {
|
||||||
|
merchants.push(this.mapToHubUser(user, userRoles));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not process merchant ${user.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merchants;
|
||||||
|
}
|
||||||
|
|
||||||
|
async suspendMerchantPartner(merchantId: string, reason: string, requesterId: string): Promise<void> {
|
||||||
|
await this.validateHubAccess(requesterId);
|
||||||
|
|
||||||
|
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
|
||||||
|
|
||||||
|
// Suspendre le merchant et tous ses utilisateurs
|
||||||
|
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, {
|
||||||
|
merchantStatus: ['SUSPENDED'],
|
||||||
|
suspensionReason: [reason],
|
||||||
|
suspendedAt: [new Date().toISOString()],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suspendre tous les utilisateurs du merchant
|
||||||
|
await this.suspendAllMerchantUsers(merchantId, requesterId);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner suspended: ${merchantId}, reason: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMerchantPartnerById(merchantId: string, requesterId: string): Promise<HubUser> {
|
||||||
|
await this.validateMerchantAccess(requesterId, merchantId);
|
||||||
|
|
||||||
|
const merchantPartner = await this.findDcbPartnerByMerchantId(merchantId);
|
||||||
|
|
||||||
|
const merchantPartnerRoles = await this.keycloakApi.getUserClientRoles(merchantId);
|
||||||
|
|
||||||
|
return this.mapToHubUser(merchantPartner, merchantPartnerRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATISTIQUES =====
|
||||||
|
async getMerchantStats(requesterId: string): Promise<MerchantStats> {
|
||||||
|
await this.validateHubAccess(requesterId);
|
||||||
|
|
||||||
|
const allMerchants = await this.getAllMerchants(requesterId);
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
|
||||||
|
let totalUsers = 0;
|
||||||
|
for (const merchant of allMerchants) {
|
||||||
|
const merchantUsers = allUsers.filter(user =>
|
||||||
|
user.attributes?.merchantPartnerId?.[0] === merchant.id
|
||||||
|
);
|
||||||
|
totalUsers += merchantUsers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMerchants: allMerchants.length,
|
||||||
|
activeMerchants: allMerchants.filter(m => m.enabled).length,
|
||||||
|
suspendedMerchants: allMerchants.filter(m => !m.enabled).length,
|
||||||
|
pendingMerchants: allMerchants.filter(m => m.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION'
|
||||||
|
).length,
|
||||||
|
totalUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateHubAccess(requesterId: string): Promise<void> {
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
const isHubAdmin = requesterRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isHubAdmin) {
|
||||||
|
throw new ForbiddenException('Only hub administrators can access this resource');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateMerchantAccess(requesterId: string, merchantId?: string): Promise<void> {
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
|
||||||
|
this.logger.debug(`Validating merchant access: requester=${requesterId}, merchant=${merchantId}`);
|
||||||
|
this.logger.debug(`Requester roles: ${requesterRoles.map(r => r.name).join(', ')}`);
|
||||||
|
|
||||||
|
// Les admins Hub ont accès complet
|
||||||
|
const isHubAdmin = requesterRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isHubAdmin) {
|
||||||
|
this.logger.debug('Hub admin access granted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECTION: Service account est considéré comme admin hub
|
||||||
|
if (requesterId.includes('service-account')) {
|
||||||
|
this.logger.debug('Service account access granted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les DCB_PARTNER n'ont accès qu'à leur propre merchant
|
||||||
|
if (requesterRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
|
||||||
|
if (merchantId && requesterId === merchantId) {
|
||||||
|
this.logger.debug('DCB_PARTNER access to own merchant granted');
|
||||||
|
return; // DCB_PARTNER accède à son propre merchant
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les autres rôles merchants n'ont accès qu'à leur merchant
|
||||||
|
const requesterMerchantId = await this.keycloakApi.getUserMerchantPartnerId(requesterId);
|
||||||
|
if (requesterMerchantId && merchantId && requesterMerchantId === merchantId) {
|
||||||
|
this.logger.debug('Merchant user access to own merchant granted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Insufficient permissions to access this merchant');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateMerchantManagementPermissions(requesterId: string, merchantId: string): Promise<void> {
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
|
||||||
|
// Seuls les admins Hub peuvent modifier les merchants
|
||||||
|
const isHubAdmin = requesterRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isHubAdmin) {
|
||||||
|
throw new ForbiddenException('Only hub administrators can manage merchant partners');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findDcbPartnerByMerchantId(merchantId: string): Promise<KeycloakUser> {
|
||||||
|
const users = await this.keycloakApi.findUserByUsername(merchantId);
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new NotFoundException(`Merchant partner not found: ${merchantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id!);
|
||||||
|
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
|
||||||
|
|
||||||
|
if (!isDcbPartner) {
|
||||||
|
throw new NotFoundException(`User ${merchantId} is not a DCB_PARTNER`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async suspendAllMerchantUsers(merchantId: string, requesterId: string): Promise<void> {
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
const merchantUsers = allUsers.filter(user =>
|
||||||
|
user.attributes?.merchantPartnerId?.[0] === merchantId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const user of merchantUsers) {
|
||||||
|
if (user.id) {
|
||||||
|
try {
|
||||||
|
await this.keycloakApi.updateUser(user.id, { enabled: false }, requesterId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not suspend user ${user.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHubUserById(userId: string, requesterId: string): Promise<HubUser> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
|
||||||
|
const [user, userRoles] = await Promise.all([
|
||||||
|
this.keycloakApi.getUserById(userId, requesterId),
|
||||||
|
this.keycloakApi.getUserClientRoles(userId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isHubUser = userRoles.some(role => this.isValidHubRole(role.name as UserRole));
|
||||||
|
if (!isHubUser) {
|
||||||
|
throw new BadRequestException(`User ${userId} is not a hub user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mapToHubUser(user, userRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllHubUsers(requesterId: string): Promise<HubUser[]> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
const hubUsers: HubUser[] = [];
|
||||||
|
|
||||||
|
for (const user of allUsers) {
|
||||||
|
if (!user.id) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
||||||
|
const hubRole = userRoles.find(role => this.isValidHubRole(role.name as UserRole));
|
||||||
|
|
||||||
|
if (hubRole) {
|
||||||
|
hubUsers.push(this.mapToHubUser(user, userRoles));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not process user ${user.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hubUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHubUsersByRole(role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER, requesterId: string): Promise<HubUser[]> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
|
||||||
|
const allHubUsers = await this.getAllHubUsers(requesterId);
|
||||||
|
return allHubUsers.filter(user => user.role === role);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHubUser(
|
||||||
|
userId: string,
|
||||||
|
updates: Partial<{
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>,
|
||||||
|
requesterId: string
|
||||||
|
): Promise<HubUser> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
await this.getHubUserById(userId, requesterId);
|
||||||
|
|
||||||
|
await this.keycloakApi.updateUser(userId, updates, requesterId);
|
||||||
|
return this.getHubUserById(userId, requesterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateHubUserRole(
|
||||||
|
userId: string,
|
||||||
|
newRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT |UserRole.DCB_PARTNER,
|
||||||
|
requesterId: string
|
||||||
|
): Promise<HubUser> {
|
||||||
|
// Validation implicite via le typage
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN);
|
||||||
|
|
||||||
|
if (!isRequesterAdmin) {
|
||||||
|
throw new ForbiddenException('Only DCB_ADMIN can change user roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.getHubUserById(userId, requesterId);
|
||||||
|
await this.keycloakApi.setClientRoles(userId, [newRole]);
|
||||||
|
|
||||||
|
return this.getHubUserById(userId, requesterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMerchantPartner(
|
||||||
|
merchantId: string,
|
||||||
|
updates: Partial<{
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}>,
|
||||||
|
requesterId: string
|
||||||
|
): Promise<HubUser> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
await this.getHubUserById(merchantId, requesterId);
|
||||||
|
|
||||||
|
await this.validateMerchantManagementPermissions(requesterId, merchantId);
|
||||||
|
|
||||||
|
return this.getMerchantPartnerById(merchantId, requesterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteHubUser(userId: string, requesterId: string): Promise<void> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
await this.getHubUserById(userId, requesterId);
|
||||||
|
|
||||||
|
if (userId === requesterId) {
|
||||||
|
throw new BadRequestException('Cannot delete your own account');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.isLastAdmin(userId)) {
|
||||||
|
throw new BadRequestException('Cannot delete the last DCB_ADMIN user');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.keycloakApi.deleteUser(userId, requesterId);
|
||||||
|
this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== GESTION DES MOTS DE PASSE =====
|
||||||
|
async resetHubUserPassword(
|
||||||
|
userId: string,
|
||||||
|
newPassword: string,
|
||||||
|
temporary: boolean = true,
|
||||||
|
requesterId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
await this.getHubUserById(userId, requesterId);
|
||||||
|
|
||||||
|
await this.keycloakApi.resetUserPassword(userId, newPassword, temporary);
|
||||||
|
this.logger.log(`Password reset for hub user: ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendHubUserPasswordResetEmail(userId: string, requesterId: string): Promise<void> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
const user = await this.getHubUserById(userId, requesterId);
|
||||||
|
|
||||||
|
await this.keycloakApi.sendPasswordResetEmail(user.email);
|
||||||
|
this.logger.log(`Password reset email sent to hub user: ${user.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATISTIQUES ET RAPPORTS =====
|
||||||
|
async getHubUsersStats(requesterId: string): Promise<HubUserStats> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
const allHubUsers = await this.getAllHubUsers(requesterId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAdmins: allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length,
|
||||||
|
totalSupport: allHubUsers.filter(user => user.role === UserRole.DCB_SUPPORT).length,
|
||||||
|
activeUsers: allHubUsers.filter(user => user.enabled).length,
|
||||||
|
inactiveUsers: allHubUsers.filter(user => !user.enabled).length,
|
||||||
|
pendingActivation: allHubUsers.filter(user =>
|
||||||
|
user.attributes?.userStatus?.[0] === 'PENDING_ACTIVATION'
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHubUserActivity(requesterId: string): Promise<HubUserActivity[]> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
const allHubUsers = await this.getAllHubUsers(requesterId);
|
||||||
|
|
||||||
|
return allHubUsers.map(user => ({
|
||||||
|
user,
|
||||||
|
lastLogin: user.lastLogin ? new Date(user.lastLogin) : undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveHubSessions(requesterId: string): Promise<{ userId: string; username: string; lastAccess: Date }[]> {
|
||||||
|
const activity = await this.getHubUserActivity(requesterId);
|
||||||
|
|
||||||
|
return activity
|
||||||
|
.filter(item => item.lastLogin)
|
||||||
|
.map(item => ({
|
||||||
|
userId: item.user.id,
|
||||||
|
username: item.user.username,
|
||||||
|
lastAccess: item.lastLogin!
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SANTÉ ET VALIDATIONS =====
|
||||||
|
async checkHubUsersHealth(requesterId: string): Promise<HubHealthStatus> {
|
||||||
|
await this.validateHubUserAccess(requesterId);
|
||||||
|
const stats = await this.getHubUsersStats(requesterId);
|
||||||
|
const issues: string[] = [];
|
||||||
|
|
||||||
|
if (stats.totalAdmins === 0) {
|
||||||
|
issues.push('No DCB_ADMIN users found - system is vulnerable');
|
||||||
|
}
|
||||||
|
if (stats.totalAdmins === 1) {
|
||||||
|
issues.push('Only one DCB_ADMIN user exists - consider creating backup administrators');
|
||||||
|
}
|
||||||
|
if (stats.inactiveUsers > stats.activeUsers / 2) {
|
||||||
|
issues.push('More than half of hub users are inactive');
|
||||||
|
}
|
||||||
|
if (stats.pendingActivation > 5) {
|
||||||
|
issues.push('Many users are pending activation - review onboarding process');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = issues.length === 0 ? 'healthy' : issues.length <= 2 ? 'degraded' : 'unhealthy';
|
||||||
|
|
||||||
|
return { status, issues, stats };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isLastAdmin(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const [user, userRoles] = await Promise.all([
|
||||||
|
this.keycloakApi.getUserById(userId, userId),
|
||||||
|
this.keycloakApi.getUserClientRoles(userId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isAdmin = userRoles.some(role => role.name === UserRole.DCB_ADMIN);
|
||||||
|
if (!isAdmin) return false;
|
||||||
|
|
||||||
|
const allHubUsers = await this.getAllHubUsers(userId);
|
||||||
|
const adminCount = allHubUsers.filter(user => user.role === UserRole.DCB_ADMIN).length;
|
||||||
|
|
||||||
|
return adminCount <= 1;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Error checking if user is last admin: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async canUserManageHubUsers(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const roles = await this.keycloakApi.getUserClientRoles(userId);
|
||||||
|
return roles.some(role =>
|
||||||
|
this.HUB_ROLES.includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
353
src/hub-users/services/merchant-partners.service.ts
Normal file
353
src/hub-users/services/merchant-partners.service.ts
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||||
|
import { CreateUserData, UserRole, KeycloakUser } from '../../auth/services/keycloak-user.model';
|
||||||
|
|
||||||
|
export interface MerchantPartner {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
status: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
dcbPartnerUserId: string; // ID du user DCB_PARTNER propriétaire
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMerchantPartnerData {
|
||||||
|
name: string;
|
||||||
|
legalName: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
address?: string;
|
||||||
|
dcbPartnerOwner: {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MerchantStats {
|
||||||
|
totalMerchants: number;
|
||||||
|
activeMerchants: number;
|
||||||
|
suspendedMerchants: number;
|
||||||
|
pendingMerchants: number;
|
||||||
|
totalUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MerchantPartnersService {
|
||||||
|
private readonly logger = new Logger(MerchantPartnersService.name);
|
||||||
|
|
||||||
|
constructor(private readonly keycloakApi: KeycloakApiService) {}
|
||||||
|
|
||||||
|
// ===== CRÉATION D'UN MERCHANT PARTNER =====
|
||||||
|
async createMerchantPartner(
|
||||||
|
creatorId: string,
|
||||||
|
merchantData: CreateMerchantPartnerData
|
||||||
|
): Promise<MerchantPartner> {
|
||||||
|
this.logger.log(`Creating merchant partner: ${merchantData.name}`);
|
||||||
|
|
||||||
|
// Validation des permissions du créateur
|
||||||
|
await this.validateMerchantCreationPermissions(creatorId);
|
||||||
|
|
||||||
|
// Vérifier les doublons
|
||||||
|
await this.checkDuplicateMerchant(merchantData);
|
||||||
|
|
||||||
|
// 1. Créer le user DCB_PARTNER (propriétaire)
|
||||||
|
const dcbPartnerUserData: CreateUserData = {
|
||||||
|
username: merchantData.dcbPartnerOwner.username,
|
||||||
|
email: merchantData.dcbPartnerOwner.email,
|
||||||
|
firstName: merchantData.dcbPartnerOwner.firstName,
|
||||||
|
lastName: merchantData.dcbPartnerOwner.lastName,
|
||||||
|
password: merchantData.dcbPartnerOwner.password,
|
||||||
|
enabled: true,
|
||||||
|
emailVerified: true,
|
||||||
|
clientRoles: [UserRole.DCB_PARTNER],
|
||||||
|
createdBy: creatorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dcbPartnerUserId = await this.keycloakApi.createUser(creatorId, dcbPartnerUserData);
|
||||||
|
|
||||||
|
// 2. Créer l'entité Merchant Partner (dans la base de données ou Keycloak attributes)
|
||||||
|
const merchantPartner: MerchantPartner = {
|
||||||
|
id: merchantData.dcbPartnerOwner.username,
|
||||||
|
name: merchantData.name,
|
||||||
|
legalName: merchantData.legalName,
|
||||||
|
email: merchantData.email,
|
||||||
|
phone: merchantData.phone,
|
||||||
|
address: merchantData.address,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
dcbPartnerUserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Stocker les infos du merchant dans les attributs du user DCB_PARTNER
|
||||||
|
await this.keycloakApi.setUserAttributes(dcbPartnerUserId, {
|
||||||
|
merchantPartnerName: [merchantData.name],
|
||||||
|
merchantLegalName: [merchantData.legalName],
|
||||||
|
merchantEmail: [merchantData.email],
|
||||||
|
merchantPhone: [merchantData.phone || ''],
|
||||||
|
merchantAddress: [merchantData.address || ''],
|
||||||
|
merchantStatus: ['ACTIVE'],
|
||||||
|
merchantCreatedAt: [new Date().toISOString()],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner created successfully: ${merchantData.name}`);
|
||||||
|
return merchantPartner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== GESTION DES MERCHANTS =====
|
||||||
|
async getAllMerchantPartners(requesterId: string): Promise<MerchantPartner[]> {
|
||||||
|
await this.validateHubAccess(requesterId);
|
||||||
|
|
||||||
|
// Récupérer tous les users DCB_PARTNER
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
const merchants: MerchantPartner[] = [];
|
||||||
|
|
||||||
|
for (const user of allUsers) {
|
||||||
|
if (!user.id) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
||||||
|
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
|
||||||
|
|
||||||
|
if (isDcbPartner && user.attributes?.merchantPartnerName?.[0]) {
|
||||||
|
merchants.push(this.mapToMerchantPartner(user));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merchants;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMerchantPartnerById(merchantId: string, requesterId: string): Promise<MerchantPartner> {
|
||||||
|
await this.validateMerchantAccess(requesterId, merchantId);
|
||||||
|
|
||||||
|
// Trouver le user DCB_PARTNER correspondant au merchant
|
||||||
|
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
|
||||||
|
return this.mapToMerchantPartner(dcbPartnerUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMerchantPartner(
|
||||||
|
merchantId: string,
|
||||||
|
updates: Partial<{
|
||||||
|
name: string;
|
||||||
|
legalName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
status: 'ACTIVE' | 'SUSPENDED' | 'PENDING';
|
||||||
|
}>,
|
||||||
|
requesterId: string
|
||||||
|
): Promise<MerchantPartner> {
|
||||||
|
await this.validateMerchantManagementPermissions(requesterId, merchantId);
|
||||||
|
|
||||||
|
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
|
||||||
|
|
||||||
|
// Mettre à jour les attributs
|
||||||
|
const attributes: any = {};
|
||||||
|
if (updates.name) attributes.merchantPartnerName = [updates.name];
|
||||||
|
if (updates.legalName) attributes.merchantLegalName = [updates.legalName];
|
||||||
|
if (updates.email) attributes.merchantEmail = [updates.email];
|
||||||
|
if (updates.phone) attributes.merchantPhone = [updates.phone];
|
||||||
|
if (updates.address) attributes.merchantAddress = [updates.address];
|
||||||
|
if (updates.status) attributes.merchantStatus = [updates.status];
|
||||||
|
|
||||||
|
attributes.merchantUpdatedAt = [new Date().toISOString()];
|
||||||
|
|
||||||
|
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, attributes);
|
||||||
|
|
||||||
|
return this.getMerchantPartnerById(merchantId, requesterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async suspendMerchantPartner(merchantId: string, reason: string, requesterId: string): Promise<void> {
|
||||||
|
await this.validateHubAccess(requesterId);
|
||||||
|
|
||||||
|
const dcbPartnerUser = await this.findDcbPartnerByMerchantId(merchantId);
|
||||||
|
|
||||||
|
// Suspendre le merchant et tous ses utilisateurs
|
||||||
|
await this.keycloakApi.setUserAttributes(dcbPartnerUser.id!, {
|
||||||
|
merchantStatus: ['SUSPENDED'],
|
||||||
|
suspensionReason: [reason],
|
||||||
|
suspendedAt: [new Date().toISOString()],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Suspendre tous les utilisateurs du merchant
|
||||||
|
await this.suspendAllMerchantUsers(merchantId, requesterId);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant partner suspended: ${merchantId}, reason: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATISTIQUES =====
|
||||||
|
async getMerchantStats(requesterId: string): Promise<MerchantStats> {
|
||||||
|
await this.validateHubAccess(requesterId);
|
||||||
|
|
||||||
|
const allMerchants = await this.getAllMerchantPartners(requesterId);
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
|
||||||
|
let totalUsers = 0;
|
||||||
|
for (const merchant of allMerchants) {
|
||||||
|
const merchantUsers = allUsers.filter(user =>
|
||||||
|
user.attributes?.merchantPartnerId?.[0] === merchant.id
|
||||||
|
);
|
||||||
|
totalUsers += merchantUsers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMerchants: allMerchants.length,
|
||||||
|
activeMerchants: allMerchants.filter(m => m.status === 'ACTIVE').length,
|
||||||
|
suspendedMerchants: allMerchants.filter(m => m.status === 'SUSPENDED').length,
|
||||||
|
pendingMerchants: allMerchants.filter(m => m.status === 'PENDING').length,
|
||||||
|
totalUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MÉTHODES PRIVÉES =====
|
||||||
|
private async validateMerchantCreationPermissions(creatorId: string): Promise<void> {
|
||||||
|
const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId);
|
||||||
|
const canCreateMerchant = creatorRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canCreateMerchant) {
|
||||||
|
throw new ForbiddenException('Only hub administrators can create merchant partners');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateHubAccess(requesterId: string): Promise<void> {
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
const isHubAdmin = requesterRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isHubAdmin) {
|
||||||
|
throw new ForbiddenException('Only hub administrators can access this resource');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateMerchantAccess(requesterId: string, merchantId?: string): Promise<void> {
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
|
||||||
|
this.logger.debug(`Validating merchant access: requester=${requesterId}, merchant=${merchantId}`);
|
||||||
|
this.logger.debug(`Requester roles: ${requesterRoles.map(r => r.name).join(', ')}`);
|
||||||
|
|
||||||
|
// Les admins Hub ont accès complet
|
||||||
|
const isHubAdmin = requesterRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isHubAdmin) {
|
||||||
|
this.logger.debug('Hub admin access granted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORRECTION: Service account est considéré comme admin hub
|
||||||
|
if (requesterId.includes('service-account')) {
|
||||||
|
this.logger.debug('Service account access granted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les DCB_PARTNER n'ont accès qu'à leur propre merchant
|
||||||
|
if (requesterRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
|
||||||
|
if (merchantId && requesterId === merchantId) {
|
||||||
|
this.logger.debug('DCB_PARTNER access to own merchant granted');
|
||||||
|
return; // DCB_PARTNER accède à son propre merchant
|
||||||
|
}
|
||||||
|
throw new ForbiddenException('DCB_PARTNER can only access their own merchant partner');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les autres rôles merchants n'ont accès qu'à leur merchant
|
||||||
|
const requesterMerchantId = await this.keycloakApi.getUserMerchantPartnerId(requesterId);
|
||||||
|
if (requesterMerchantId && merchantId && requesterMerchantId === merchantId) {
|
||||||
|
this.logger.debug('Merchant user access to own merchant granted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Insufficient permissions to access this merchant');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateMerchantManagementPermissions(requesterId: string, merchantId: string): Promise<void> {
|
||||||
|
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||||
|
|
||||||
|
// Seuls les admins Hub peuvent modifier les merchants
|
||||||
|
const isHubAdmin = requesterRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isHubAdmin) {
|
||||||
|
throw new ForbiddenException('Only hub administrators can manage merchant partners');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkDuplicateMerchant(merchantData: CreateMerchantPartnerData): Promise<void> {
|
||||||
|
const existingUsers = await this.keycloakApi.findUserByUsername(merchantData.dcbPartnerOwner.username);
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
throw new BadRequestException(`Merchant partner with username ${merchantData.dcbPartnerOwner.username} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEmails = await this.keycloakApi.findUserByEmail(merchantData.dcbPartnerOwner.email);
|
||||||
|
if (existingEmails.length > 0) {
|
||||||
|
throw new BadRequestException(`Merchant partner with email ${merchantData.dcbPartnerOwner.email} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findDcbPartnerByMerchantId(merchantId: string): Promise<KeycloakUser> {
|
||||||
|
const users = await this.keycloakApi.findUserByUsername(merchantId);
|
||||||
|
if (users.length === 0) {
|
||||||
|
throw new NotFoundException(`Merchant partner not found: ${merchantId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id!);
|
||||||
|
const isDcbPartner = userRoles.some(role => role.name === UserRole.DCB_PARTNER);
|
||||||
|
|
||||||
|
if (!isDcbPartner) {
|
||||||
|
throw new NotFoundException(`User ${merchantId} is not a DCB_PARTNER`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToMerchantPartner(user: KeycloakUser): MerchantPartner {
|
||||||
|
return {
|
||||||
|
id: user.username,
|
||||||
|
name: user.attributes?.merchantPartnerName?.[0] || user.username,
|
||||||
|
legalName: user.attributes?.merchantLegalName?.[0] || '',
|
||||||
|
email: user.attributes?.merchantEmail?.[0] || user.email,
|
||||||
|
phone: user.attributes?.merchantPhone?.[0],
|
||||||
|
address: user.attributes?.merchantAddress?.[0],
|
||||||
|
status: (user.attributes?.merchantStatus?.[0] as 'ACTIVE' | 'SUSPENDED' | 'PENDING') || 'ACTIVE',
|
||||||
|
createdAt: user.attributes?.merchantCreatedAt?.[0]
|
||||||
|
? new Date(user.attributes.merchantCreatedAt[0])
|
||||||
|
: new Date(user.createdTimestamp || Date.now()),
|
||||||
|
updatedAt: user.attributes?.merchantUpdatedAt?.[0]
|
||||||
|
? new Date(user.attributes.merchantUpdatedAt[0])
|
||||||
|
: new Date(user.createdTimestamp || Date.now()),
|
||||||
|
dcbPartnerUserId: user.id!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async suspendAllMerchantUsers(merchantId: string, requesterId: string): Promise<void> {
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
const merchantUsers = allUsers.filter(user =>
|
||||||
|
user.attributes?.merchantPartnerId?.[0] === merchantId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const user of merchantUsers) {
|
||||||
|
if (user.id) {
|
||||||
|
try {
|
||||||
|
await this.keycloakApi.updateUser(user.id, { enabled: false }, requesterId);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not suspend user ${user.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/hub-users/services/merchant-users.service.ts
Normal file
192
src/hub-users/services/merchant-users.service.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||||
|
import { CreateUserData, UserRole, KeycloakUser } from '../../auth/services/keycloak-user.model';
|
||||||
|
|
||||||
|
export interface MerchantUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
enabled: boolean;
|
||||||
|
emailVerified: boolean;
|
||||||
|
merchantPartnerId: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdByUsername: string;
|
||||||
|
createdTimestamp: number;
|
||||||
|
lastLogin?: number;
|
||||||
|
userType: 'MERCHANT';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMerchantUserData {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password?: string;
|
||||||
|
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
|
||||||
|
enabled?: boolean;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
merchantPartnerId: string;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MerchantUsersService {
|
||||||
|
private readonly logger = new Logger(MerchantUsersService.name);
|
||||||
|
|
||||||
|
private readonly MERCHANT_ROLES = [
|
||||||
|
UserRole.DCB_PARTNER_ADMIN,
|
||||||
|
UserRole.DCB_PARTNER_MANAGER,
|
||||||
|
UserRole.DCB_PARTNER_SUPPORT,
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(private readonly keycloakApi: KeycloakApiService) {}
|
||||||
|
|
||||||
|
// ===== CRÉATION D'UTILISATEURS MERCHANT =====
|
||||||
|
async createMerchantUser(creatorId: string, userData: CreateMerchantUserData): Promise<MerchantUser> {
|
||||||
|
this.logger.log(`Creating merchant user: ${userData.username} for merchant: ${userData.merchantPartnerId}`);
|
||||||
|
|
||||||
|
// Validation des permissions et du merchant
|
||||||
|
await this.validateMerchantUserCreation(creatorId, userData);
|
||||||
|
|
||||||
|
// Vérifier les doublons
|
||||||
|
const existingUsers = await this.keycloakApi.findUserByUsername(userData.username);
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
throw new BadRequestException(`User with username ${userData.username} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keycloakUserData: CreateUserData = {
|
||||||
|
username: userData.username,
|
||||||
|
email: userData.email,
|
||||||
|
firstName: userData.firstName,
|
||||||
|
lastName: userData.lastName,
|
||||||
|
password: userData.password,
|
||||||
|
enabled: userData.enabled ?? true,
|
||||||
|
emailVerified: userData.emailVerified ?? false,
|
||||||
|
merchantPartnerId: userData.merchantPartnerId,
|
||||||
|
clientRoles: [userData.role],
|
||||||
|
createdBy: creatorId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
|
||||||
|
const createdUser = await this.getMerchantUserById(userId, creatorId);
|
||||||
|
|
||||||
|
this.logger.log(`Merchant user created successfully: ${userData.username}`);
|
||||||
|
return createdUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RÉCUPÉRATION D'UTILISATEURS MERCHANT =====
|
||||||
|
async getMerchantUsersByPartner(merchantPartnerId: string, requesterId: string): Promise<MerchantUser[]> {
|
||||||
|
await this.validateMerchantAccess(requesterId, merchantPartnerId);
|
||||||
|
|
||||||
|
const allUsers = await this.keycloakApi.getAllUsers();
|
||||||
|
const merchantUsers: MerchantUser[] = [];
|
||||||
|
|
||||||
|
for (const user of allUsers) {
|
||||||
|
if (!user.id) continue;
|
||||||
|
|
||||||
|
const userMerchantId = user.attributes?.merchantPartnerId?.[0];
|
||||||
|
if (userMerchantId === merchantPartnerId) {
|
||||||
|
try {
|
||||||
|
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
||||||
|
const merchantRole = userRoles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole));
|
||||||
|
|
||||||
|
if (merchantRole) {
|
||||||
|
merchantUsers.push(this.mapToMerchantUser(user, userRoles));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Could not process merchant user ${user.id}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merchantUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMerchantUserById(userId: string, requesterId: string): Promise<MerchantUser> {
|
||||||
|
const user = await this.keycloakApi.getUserById(userId, requesterId);
|
||||||
|
const userRoles = await this.keycloakApi.getUserClientRoles(userId);
|
||||||
|
|
||||||
|
const merchantRole = userRoles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole));
|
||||||
|
if (!merchantRole) {
|
||||||
|
throw new BadRequestException(`User ${userId} is not a merchant user`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merchantPartnerId = user.attributes?.merchantPartnerId?.[0];
|
||||||
|
if (!merchantPartnerId) {
|
||||||
|
throw new BadRequestException(`User ${userId} has no merchant partner association`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.validateMerchantAccess(requesterId, merchantPartnerId);
|
||||||
|
|
||||||
|
return this.mapToMerchantUser(user, userRoles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== VALIDATIONS =====
|
||||||
|
private async validateMerchantUserCreation(creatorId: string, userData: CreateMerchantUserData): Promise<void> {
|
||||||
|
const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId);
|
||||||
|
|
||||||
|
// DCB_PARTNER peut créer des utilisateurs
|
||||||
|
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER)) {
|
||||||
|
if (creatorId !== userData.merchantPartnerId) {
|
||||||
|
throw new ForbiddenException('DCB_PARTNER can only create users for their own ');
|
||||||
|
}
|
||||||
|
// DCB_PARTNER ne peut créer que certains rôles
|
||||||
|
const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
|
||||||
|
if (!allowedRoles.includes(userData.role)) {
|
||||||
|
throw new ForbiddenException('DCB_PARTNER can only create DCB_PARTNER_ADMIN, MANAGER, or SUPPORT roles');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCB_PARTNER_ADMIN peut créer des utilisateurs pour son merchant
|
||||||
|
if (creatorRoles.some(role => role.name === UserRole.DCB_PARTNER_ADMIN)) {
|
||||||
|
const creatorMerchantId = await this.keycloakApi.getUserMerchantPartnerId(creatorId);
|
||||||
|
if (creatorMerchantId !== userData.merchantPartnerId) {
|
||||||
|
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner');
|
||||||
|
}
|
||||||
|
// DCB_PARTNER_ADMIN ne peut créer que certains rôles
|
||||||
|
const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
|
||||||
|
if (!allowedRoles.includes(userData.role)) {
|
||||||
|
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create DCB_PARTNER_ADMIN, DCB_PARTNER_MANAGER or SUPPORT roles');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les admins Hub peuvent créer pour n'importe quel merchant
|
||||||
|
if (creatorRoles.some(role =>
|
||||||
|
[UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole)
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ForbiddenException('Insufficient permissions to create merchant users');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validateMerchantAccess(requesterId: string, merchantPartnerId: string): Promise<void> {
|
||||||
|
await this.keycloakApi.validateUserAccess(requesterId, merchantPartnerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapToMerchantUser(user: KeycloakUser, roles: any[]): MerchantUser {
|
||||||
|
const merchantRole = roles.find(role => this.MERCHANT_ROLES.includes(role.name as UserRole));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id!,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: merchantRole?.name as UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT,
|
||||||
|
enabled: user.enabled,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
merchantPartnerId: user.attributes?.merchantPartnerId?.[0]!,
|
||||||
|
createdBy: user.attributes?.createdBy?.[0] || 'unknown',
|
||||||
|
createdByUsername: user.attributes?.createdByUsername?.[0] || 'unknown',
|
||||||
|
createdTimestamp: user.createdTimestamp || Date.now(),
|
||||||
|
lastLogin: user.attributes?.lastLogin?.[0] ? parseInt(user.attributes.lastLogin[0]) : undefined,
|
||||||
|
userType: 'MERCHANT',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main.ts
26
src/main.ts
@ -9,30 +9,38 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const logger = new Logger('dcb-user-service');
|
const logger = new Logger('dcb-user-service');
|
||||||
|
|
||||||
|
// Middlewares de sécurité
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
app.enableCors();
|
app.enableCors({ origin: '*' });
|
||||||
|
|
||||||
|
// Gestion globale des erreurs et validation
|
||||||
app.useGlobalFilters(new KeycloakExceptionFilter());
|
app.useGlobalFilters(new KeycloakExceptionFilter());
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
app.useGlobalPipes(new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Préfixe global de l'API
|
||||||
app.setGlobalPrefix('api/v1');
|
app.setGlobalPrefix('api/v1');
|
||||||
|
|
||||||
// Swagger Configuration
|
// Configuration Swagger
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('DCB User Service API')
|
.setTitle('DCB User Service API')
|
||||||
.setDescription('API de gestion des utilisateurs pour le système DCB')
|
.setDescription('API de gestion des utilisateurs pour le système DCB')
|
||||||
.setVersion('1.0')
|
.setVersion('1.0')
|
||||||
.addTag('users', 'Gestion des utilisateurs')
|
.addTag('users', 'Gestion des Utilisateurs')
|
||||||
|
.addTag('partners', 'Gestion des Partenaires/Marchants')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
//.addServer('http://localhost:3000', 'Développement local')
|
|
||||||
// .addServer('https://api.example.com', 'Production')
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api-docs', app, document);
|
SwaggerModule.setup('api-docs', app, document);
|
||||||
app.enableCors({ origin: '*' })
|
|
||||||
|
|
||||||
|
// Démarrage du serveur
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|
||||||
logger.log(`Application running on http://localhost:${port}`);
|
logger.log(`Application running on http://localhost:${port}`);
|
||||||
logger.log(`Swagger documentation available at http://localhost:${port}/api/docs`);
|
logger.log(`Swagger documentation available at http://localhost:${port}/api-docs`);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap()
|
||||||
@ -1,396 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Put,
|
|
||||||
Query,
|
|
||||||
Logger,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
|
|
||||||
import { UsersService } from "../services/users.service";
|
|
||||||
import { MerchantTeamService } from "../services/merchant-team.service";
|
|
||||||
import * as user from "../models/user";
|
|
||||||
import { RESOURCES } from '../../constants/resouces';
|
|
||||||
import { SCOPES } from '../../constants/scopes';
|
|
||||||
|
|
||||||
@Controller('merchants')
|
|
||||||
@Resource(RESOURCES.MERCHANT)
|
|
||||||
export class MerchantsController {
|
|
||||||
private readonly logger = new Logger(MerchantsController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly usersService: UsersService,
|
|
||||||
private readonly merchantTeamService: MerchantTeamService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// === CREATE USER ===
|
|
||||||
@Post()
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async createUser(@Body() createUserDto: user.CreateUserDto): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`Creating new user: ${createUserDto.username}`);
|
|
||||||
try {
|
|
||||||
const createdUser = await this.usersService.createUser(createUserDto);
|
|
||||||
return createdUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create user: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to create user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET ALL USERS ===
|
|
||||||
@Get()
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async findAllUsers(@Query() query: user.UserQueryDto): Promise<user.PaginatedUserResponse> {
|
|
||||||
this.logger.log('Fetching users list');
|
|
||||||
try {
|
|
||||||
const result = await this.usersService.findAllUsers(query);
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch users: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch users", HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET USER BY ID ===
|
|
||||||
@Get(':id')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getUserById(@Param('id') id: string): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`Fetching user by ID: ${id}`);
|
|
||||||
try {
|
|
||||||
const user = await this.usersService.getUserById(id);
|
|
||||||
return user;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "User not found", HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET CURRENT USER PROFILE ===
|
|
||||||
@Get('profile/me')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getCurrentUserProfile(@AuthenticatedUser() user: any): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`User ${user.sub} accessing own profile`);
|
|
||||||
try {
|
|
||||||
const userProfile = await this.usersService.getUserProfile(user);
|
|
||||||
return userProfile;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch user profile: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch user profile", HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === UPDATE USER ===
|
|
||||||
@Put(':id')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async updateUser(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() updateUserDto: user.UpdateUserDto
|
|
||||||
): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`Updating user: ${id}`);
|
|
||||||
try {
|
|
||||||
const updatedUser = await this.usersService.updateUser(id, updateUserDto);
|
|
||||||
return updatedUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to update user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === UPDATE CURRENT USER PROFILE ===
|
|
||||||
@Put('profile/me')
|
|
||||||
async updateCurrentUserProfile(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Body() updateUserDto: user.UpdateUserDto
|
|
||||||
): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`User ${user.sub} updating own profile`);
|
|
||||||
try {
|
|
||||||
// Un utilisateur ne peut mettre à jour que son propre profil
|
|
||||||
const updatedUser = await this.usersService.updateUser(user.sub, updateUserDto);
|
|
||||||
return updatedUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update user profile: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to update user profile", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DELETE USER ===
|
|
||||||
@Delete(':id')
|
|
||||||
@Scopes(SCOPES.DELETE)
|
|
||||||
async deleteUser(@Param('id') id: string): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Deleting user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.deleteUser(id);
|
|
||||||
return { message: `User ${id} deleted successfully` };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to delete user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RESET PASSWORD ===
|
|
||||||
@Put(':id/password')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async resetPassword(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() resetPasswordDto: user.ResetPasswordDto
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Resetting password for user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.resetPassword(resetPasswordDto);
|
|
||||||
return { message: 'Password reset successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to reset password for user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ENABLE USER ===
|
|
||||||
@Put(':id/enable')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async enableUser(@Param('id') id: string): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Enabling user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.enableUser(id);
|
|
||||||
return { message: 'User enabled successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to enable user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to enable user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DISABLE USER ===
|
|
||||||
@Put(':id/disable')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async disableUser(@Param('id') id: string): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Disabling user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.disableUser(id);
|
|
||||||
return { message: 'User disabled successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to disable user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to disable user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CHECK USER EXISTS ===
|
|
||||||
@Get('check/:username')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async userExists(@Param('username') username: string): Promise<{ exists: boolean }> {
|
|
||||||
this.logger.log(`Checking if user exists: ${username}`);
|
|
||||||
try {
|
|
||||||
const exists = await this.usersService.userExists(username);
|
|
||||||
return { exists };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to check if user exists ${username}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to check user existence", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SEARCH USERS BY USERNAME ===
|
|
||||||
@Get('search/username/:username')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async findUserByUsername(@Param('username') username: string): Promise<user.UserResponse[]> {
|
|
||||||
this.logger.log(`Searching users by username: ${username}`);
|
|
||||||
try {
|
|
||||||
const users = await this.usersService.findUserByUsername(username);
|
|
||||||
return users;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to search users by username ${username}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SEARCH USERS BY EMAIL ===
|
|
||||||
@Get('search/email/:email')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async findUserByEmail(@Param('email') email: string): Promise<user.UserResponse[]> {
|
|
||||||
this.logger.log(`Searching users by email: ${email}`);
|
|
||||||
try {
|
|
||||||
const users = await this.usersService.findUserByEmail(email);
|
|
||||||
return users;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to search users by email ${email}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET USER ROLES ===
|
|
||||||
@Get(':id/roles')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getUserClientRoles(@Param('id') id: string): Promise<{ roles: string[] }> {
|
|
||||||
this.logger.log(`Fetching roles for user: ${id}`);
|
|
||||||
try {
|
|
||||||
const roles = await this.usersService.getUserClientRoles(id);
|
|
||||||
return { roles };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch roles for user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch user roles", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ASSIGN CLIENT ROLES TO USER ===
|
|
||||||
@Put(':id/roles')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async assignClientRoles(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() assignRolesDto: { roles: string[] }
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Assigning roles to user: ${id}`, assignRolesDto.roles);
|
|
||||||
try {
|
|
||||||
await this.usersService.assignClientRoles(id, assignRolesDto.roles);
|
|
||||||
return { message: 'Roles assigned successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to assign roles", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// === ROUTES SPECIFIQUES POUR LES MARCHANDS ===
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// === CREER UN UTILISATEUR DANS L'EQUIPE MARCHAND ===
|
|
||||||
@Post('merchant/team')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async createMerchantTeamUser(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Body() createMerchantUserDto: any
|
|
||||||
): Promise<any> {
|
|
||||||
this.logger.log(`Merchant ${user.sub} creating team user: ${createMerchantUserDto.username}`);
|
|
||||||
try {
|
|
||||||
const createdUser = await this.merchantTeamService.createMerchantUser(
|
|
||||||
createMerchantUserDto,
|
|
||||||
user.sub
|
|
||||||
);
|
|
||||||
return createdUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create merchant team user: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to create merchant team user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === OBTENIR L'EQUIPE DU MARCHAND ===
|
|
||||||
@Get('merchant/team')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getMerchantTeam(@AuthenticatedUser() user: any): Promise<any[]> {
|
|
||||||
this.logger.log(`Merchant ${user.sub} fetching team`);
|
|
||||||
try {
|
|
||||||
const team = await this.merchantTeamService.getMerchantTeam(user.sub);
|
|
||||||
return team;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch merchant team: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch merchant team", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === METTRE A JOUR UN MEMBRE DE L'EQUIPE ===
|
|
||||||
@Put('merchant/team/:userId')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async updateMerchantTeamUser(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Param('userId') userId: string,
|
|
||||||
@Body() updateData: any
|
|
||||||
): Promise<any> {
|
|
||||||
this.logger.log(`Merchant ${user.sub} updating team user: ${userId}`);
|
|
||||||
try {
|
|
||||||
const updatedUser = await this.merchantTeamService.updateMerchantUser(
|
|
||||||
userId,
|
|
||||||
updateData,
|
|
||||||
user.sub
|
|
||||||
);
|
|
||||||
return updatedUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update merchant team user ${userId}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to update merchant team user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RETIRER UN MEMBRE DE L'EQUIPE ===
|
|
||||||
@Delete('merchant/team/:userId')
|
|
||||||
@Scopes(SCOPES.DELETE)
|
|
||||||
async removeMerchantTeamUser(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Param('userId') userId: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Merchant ${user.sub} removing team user: ${userId}`);
|
|
||||||
try {
|
|
||||||
await this.merchantTeamService.removeMerchantUser(userId, user.sub);
|
|
||||||
return { message: 'Team member removed successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to remove merchant team user ${userId}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to remove merchant team user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === AJOUTER UN ROLE A UN MEMBRE ===
|
|
||||||
@Post('merchant/team/:userId/roles/:role')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async addMerchantRole(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Param('userId') userId: string,
|
|
||||||
@Param('role') role: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Merchant ${user.sub} adding role ${role} to user ${userId}`);
|
|
||||||
try {
|
|
||||||
const result = await this.merchantTeamService.addMerchantRole(userId, role, user.sub);
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to add merchant role to user ${userId}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to add merchant role", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RETIRER UN ROLE D'UN MEMBRE ===
|
|
||||||
@Delete('merchant/team/:userId/roles/:role')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async removeMerchantRole(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Param('userId') userId: string,
|
|
||||||
@Param('role') role: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Merchant ${user.sub} removing role ${role} from user ${userId}`);
|
|
||||||
try {
|
|
||||||
const result = await this.merchantTeamService.removeMerchantRole(userId, role, user.sub);
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to remove merchant role from user ${userId}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to remove merchant role", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === OBTENIR LES ROLES MARCHANDS DISPONIBLES ===
|
|
||||||
@Get('merchant/roles/available')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getAvailableMerchantRoles(): Promise<{ roles: string[] }> {
|
|
||||||
this.logger.log('Fetching available merchant roles');
|
|
||||||
try {
|
|
||||||
const roles = this.merchantTeamService.getAvailableMerchantRoles();
|
|
||||||
return { roles };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch available merchant roles: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch available merchant roles", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === VERIFIER SI UN UTILISATEUR EST DANS L'EQUIPE ===
|
|
||||||
@Get('merchant/team/:userId/check')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async checkUserInTeam(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Param('userId') userId: string
|
|
||||||
): Promise<{ isInTeam: boolean }> {
|
|
||||||
this.logger.log(`Checking if user ${userId} is in merchant ${user.sub} team`);
|
|
||||||
try {
|
|
||||||
const isInTeam = await this.merchantTeamService.isUserInMerchantTeam(userId, user.sub);
|
|
||||||
return { isInTeam };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to check team membership: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to check team membership", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,254 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Put,
|
|
||||||
Query,
|
|
||||||
Logger,
|
|
||||||
HttpException,
|
|
||||||
HttpStatus,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { Roles, AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
|
|
||||||
import { UsersService } from "../services/users.service";
|
|
||||||
import { MerchantTeamService } from "../services/merchant-team.service";
|
|
||||||
import * as user from "../models/user";
|
|
||||||
import { RESOURCES } from '../../constants/resouces';
|
|
||||||
import { SCOPES } from '../../constants/scopes';
|
|
||||||
|
|
||||||
@Controller('users')
|
|
||||||
@Resource(RESOURCES.USER)
|
|
||||||
export class UsersController {
|
|
||||||
private readonly logger = new Logger(UsersController.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly usersService: UsersService,
|
|
||||||
private readonly merchantTeamService: MerchantTeamService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// === CREATE USER ===
|
|
||||||
@Post()
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async createUser(@Body() createUserDto: user.CreateUserDto): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`Creating new user: ${createUserDto.username}`);
|
|
||||||
try {
|
|
||||||
const createdUser = await this.usersService.createUser(createUserDto);
|
|
||||||
return createdUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create user: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to create user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET ALL USERS ===
|
|
||||||
@Get()
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async findAllUsers(@Query() query: user.UserQueryDto): Promise<user.PaginatedUserResponse> {
|
|
||||||
this.logger.log('Fetching users list');
|
|
||||||
try {
|
|
||||||
const result = await this.usersService.findAllUsers(query);
|
|
||||||
return result;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch users: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch users", HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET USER BY ID ===
|
|
||||||
@Get(':id')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getUserById(@Param('id') id: string): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`Fetching user by ID: ${id}`);
|
|
||||||
try {
|
|
||||||
const user = await this.usersService.getUserById(id);
|
|
||||||
return user;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "User not found", HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET CURRENT USER PROFILE ===
|
|
||||||
@Get('profile/me')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getCurrentUserProfile(@AuthenticatedUser() user: any): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`User ${user.sub} accessing own profile`);
|
|
||||||
try {
|
|
||||||
const userProfile = await this.usersService.getUserProfile(user);
|
|
||||||
return userProfile;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch user profile: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch user profile", HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === UPDATE USER ===
|
|
||||||
@Put(':id')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async updateUser(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() updateUserDto: user.UpdateUserDto
|
|
||||||
): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`Updating user: ${id}`);
|
|
||||||
try {
|
|
||||||
const updatedUser = await this.usersService.updateUser(id, updateUserDto);
|
|
||||||
return updatedUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to update user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === UPDATE CURRENT USER PROFILE ===
|
|
||||||
@Put('profile/me')
|
|
||||||
async updateCurrentUserProfile(
|
|
||||||
@AuthenticatedUser() user: any,
|
|
||||||
@Body() updateUserDto: user.UpdateUserDto
|
|
||||||
): Promise<user.UserResponse> {
|
|
||||||
this.logger.log(`User ${user.sub} updating own profile`);
|
|
||||||
try {
|
|
||||||
// Un utilisateur ne peut mettre à jour que son propre profil
|
|
||||||
const updatedUser = await this.usersService.updateUser(user.sub, updateUserDto);
|
|
||||||
return updatedUser;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update user profile: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to update user profile", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DELETE USER ===
|
|
||||||
@Delete(':id')
|
|
||||||
@Scopes(SCOPES.DELETE)
|
|
||||||
async deleteUser(@Param('id') id: string): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Deleting user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.deleteUser(id);
|
|
||||||
return { message: `User ${id} deleted successfully` };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to delete user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RESET PASSWORD ===
|
|
||||||
@Put(':id/password')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async resetPassword(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() resetPasswordDto: user.ResetPasswordDto
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Resetting password for user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.resetPassword(resetPasswordDto);
|
|
||||||
return { message: 'Password reset successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to reset password for user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ENABLE USER ===
|
|
||||||
@Put(':id/enable')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async enableUser(@Param('id') id: string): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Enabling user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.enableUser(id);
|
|
||||||
return { message: 'User enabled successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to enable user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to enable user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DISABLE USER ===
|
|
||||||
@Put(':id/disable')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async disableUser(@Param('id') id: string): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Disabling user: ${id}`);
|
|
||||||
try {
|
|
||||||
await this.usersService.disableUser(id);
|
|
||||||
return { message: 'User disabled successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to disable user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to disable user", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CHECK USER EXISTS ===
|
|
||||||
@Get('check/:username')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async userExists(@Param('username') username: string): Promise<{ exists: boolean }> {
|
|
||||||
this.logger.log(`Checking if user exists: ${username}`);
|
|
||||||
try {
|
|
||||||
const exists = await this.usersService.userExists(username);
|
|
||||||
return { exists };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to check if user exists ${username}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to check user existence", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SEARCH USERS BY USERNAME ===
|
|
||||||
@Get('search/username/:username')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async findUserByUsername(@Param('username') username: string): Promise<user.UserResponse[]> {
|
|
||||||
this.logger.log(`Searching users by username: ${username}`);
|
|
||||||
try {
|
|
||||||
const users = await this.usersService.findUserByUsername(username);
|
|
||||||
return users;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to search users by username ${username}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SEARCH USERS BY EMAIL ===
|
|
||||||
@Get('search/email/:email')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async findUserByEmail(@Param('email') email: string): Promise<user.UserResponse[]> {
|
|
||||||
this.logger.log(`Searching users by email: ${email}`);
|
|
||||||
try {
|
|
||||||
const users = await this.usersService.findUserByEmail(email);
|
|
||||||
return users;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to search users by email ${email}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET USER ROLES ===
|
|
||||||
@Get(':id/roles')
|
|
||||||
@Scopes(SCOPES.READ)
|
|
||||||
async getUserClientRoles(@Param('id') id: string): Promise<{ roles: string[] }> {
|
|
||||||
this.logger.log(`Fetching roles for user: ${id}`);
|
|
||||||
try {
|
|
||||||
const roles = await this.usersService.getUserClientRoles(id);
|
|
||||||
return { roles };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch roles for user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to fetch user roles", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ASSIGN CLIENT ROLES TO USER ===
|
|
||||||
@Put(':id/roles')
|
|
||||||
@Scopes(SCOPES.WRITE)
|
|
||||||
async assignClientRoles(
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() assignRolesDto: { roles: string[] }
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
this.logger.log(`Assigning roles to user: ${id}`, assignRolesDto.roles);
|
|
||||||
try {
|
|
||||||
await this.usersService.assignClientRoles(id, assignRolesDto.roles);
|
|
||||||
return { message: 'Roles assigned successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`);
|
|
||||||
throw new HttpException(error.message || "Failed to assign roles", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class MerchanUser {
|
|
||||||
id?: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
enabled?: boolean = true;
|
|
||||||
emailVerified?: boolean = false;
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
merchantRoles?: string[]; // Rôles client uniquement (merchant-admin, merchant-manager, merchant-support)
|
|
||||||
createdTimestamp?: number;
|
|
||||||
merchantOwnerId: string;
|
|
||||||
|
|
||||||
constructor(partial?: Partial<MerchanUser>) {
|
|
||||||
if (partial) {
|
|
||||||
Object.assign(this, partial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MerchantUserCredentials {
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
temporary: boolean = false;
|
|
||||||
|
|
||||||
constructor(type: string, value: string, temporary: boolean = false) {
|
|
||||||
this.type = type;
|
|
||||||
this.value = value;
|
|
||||||
this.temporary = temporary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateMerchantUserDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(3)
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
firstName: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
lastName: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enabled?: boolean = true;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
emailVerified?: boolean = false;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
merchantRoles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateMerchantUserDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
username?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
email?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
firstName: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
lastName: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enabled?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
emailVerified?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
merchantRoles?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MerchantUserQueryDto {
|
|
||||||
@IsOptional()
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
limit?: number = 10;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enabled?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
emailVerified?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ResetPasswordDto {
|
|
||||||
@IsString()
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
newPassword: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
temporary?: boolean = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MerchantUserResponse {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
enabled: boolean;
|
|
||||||
emailVerified: boolean;
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
merchantRoles: string[]; // Rôles client uniquement
|
|
||||||
createdTimestamp: number;
|
|
||||||
merchantOwnerId: string;
|
|
||||||
|
|
||||||
constructor(user: any) {
|
|
||||||
this.id = user.id;
|
|
||||||
this.username = user.username;
|
|
||||||
this.email = user.email;
|
|
||||||
this.firstName = user.firstName;
|
|
||||||
this.lastName = user.lastName;
|
|
||||||
this.enabled = user.enabled;
|
|
||||||
this.emailVerified = user.emailVerified;
|
|
||||||
this.attributes = user.attributes;
|
|
||||||
this.merchantRoles = user.merchantRoles || [];
|
|
||||||
this.createdTimestamp = user.createdTimestamp;
|
|
||||||
this.merchantOwnerId = user.merchantOwnerId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface pour les réponses paginées
|
|
||||||
export class PaginatedMerchantUserResponse {
|
|
||||||
users: MerchantUserResponse[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
|
|
||||||
constructor(users: MerchantUserResponse[], total: number, page: number, limit: number) {
|
|
||||||
this.users = users;
|
|
||||||
this.total = total;
|
|
||||||
this.page = page;
|
|
||||||
this.limit = limit;
|
|
||||||
this.totalPages = Math.ceil(total / limit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssignRolesDto {
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
roles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types pour les rôles client
|
|
||||||
export type MerchantRole = 'merchant-admin' | 'merchant-manager' | 'merchant-support';
|
|
||||||
|
|
||||||
// Interface pour l'authentification
|
|
||||||
export interface LoginDto {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
expires_in: number;
|
|
||||||
token_type: string;
|
|
||||||
scope?: string;
|
|
||||||
}
|
|
||||||
@ -1,198 +0,0 @@
|
|||||||
import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class User {
|
|
||||||
id?: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
enabled?: boolean = true;
|
|
||||||
emailVerified?: boolean = false;
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
clientRoles?: string[]; // Rôles client uniquement (admin, merchant, support)
|
|
||||||
createdTimestamp?: number;
|
|
||||||
|
|
||||||
constructor(partial?: Partial<User>) {
|
|
||||||
if (partial) {
|
|
||||||
Object.assign(this, partial);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserCredentials {
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
temporary: boolean = false;
|
|
||||||
|
|
||||||
constructor(type: string, value: string, temporary: boolean = false) {
|
|
||||||
this.type = type;
|
|
||||||
this.value = value;
|
|
||||||
this.temporary = temporary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateUserDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(3)
|
|
||||||
username: string;
|
|
||||||
|
|
||||||
@IsEmail()
|
|
||||||
email: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
firstName?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
lastName?: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enabled?: boolean = true;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
emailVerified?: boolean = false;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
clientRoles?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateUserDto {
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
username?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
email?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
firstName?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
lastName?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enabled?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
emailVerified?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
clientRoles?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserQueryDto {
|
|
||||||
@IsOptional()
|
|
||||||
page?: number = 1;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
limit?: number = 10;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
enabled?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
emailVerified?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ResetPasswordDto {
|
|
||||||
@IsString()
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(8)
|
|
||||||
newPassword: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
temporary?: boolean = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserResponse {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
enabled: boolean;
|
|
||||||
emailVerified: boolean;
|
|
||||||
attributes?: Record<string, any>;
|
|
||||||
clientRoles: string[]; // Rôles client uniquement
|
|
||||||
createdTimestamp: number;
|
|
||||||
|
|
||||||
constructor(user: any) {
|
|
||||||
this.id = user.id;
|
|
||||||
this.username = user.username;
|
|
||||||
this.email = user.email;
|
|
||||||
this.firstName = user.firstName;
|
|
||||||
this.lastName = user.lastName;
|
|
||||||
this.enabled = user.enabled;
|
|
||||||
this.emailVerified = user.emailVerified;
|
|
||||||
this.attributes = user.attributes;
|
|
||||||
this.clientRoles = user.clientRoles || [];
|
|
||||||
this.createdTimestamp = user.createdTimestamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface pour les réponses paginées
|
|
||||||
export class PaginatedUserResponse {
|
|
||||||
users: UserResponse[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
totalPages: number;
|
|
||||||
|
|
||||||
constructor(users: UserResponse[], total: number, page: number, limit: number) {
|
|
||||||
this.users = users;
|
|
||||||
this.total = total;
|
|
||||||
this.page = page;
|
|
||||||
this.limit = limit;
|
|
||||||
this.totalPages = Math.ceil(total / limit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AssignRolesDto {
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
roles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types pour les rôles client
|
|
||||||
export type ClientRole = 'admin' | 'merchant' | 'support' | 'merchant-admin' | 'merchant-manager' | 'merchant-support';
|
|
||||||
|
|
||||||
// Interface pour l'authentification
|
|
||||||
export interface LoginDto {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
expires_in: number;
|
|
||||||
token_type: string;
|
|
||||||
scope?: string;
|
|
||||||
}
|
|
||||||
@ -1,352 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
|
||||||
import { UsersService } from './users.service';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CreateMerchantUserDto,
|
|
||||||
UpdateMerchantUserDto,
|
|
||||||
MerchantUserResponse,
|
|
||||||
PaginatedMerchantUserResponse,
|
|
||||||
ResetPasswordDto,
|
|
||||||
MerchantUserQueryDto,
|
|
||||||
LoginDto,
|
|
||||||
TokenResponse,
|
|
||||||
MerchantRole
|
|
||||||
} from '../models/merchant';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MerchantTeamService {
|
|
||||||
private readonly logger = new Logger(MerchantTeamService.name);
|
|
||||||
|
|
||||||
// Rôles spécifiques aux équipes de marchands
|
|
||||||
private readonly availableMerchantRoles = [
|
|
||||||
'merchant-admin',
|
|
||||||
'merchant-manager',
|
|
||||||
'merchant-support',
|
|
||||||
'merchant-user'
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly keycloakApi: KeycloakApiService,
|
|
||||||
private readonly usersService: UsersService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// === VALIDATION DES ROLES MARCHAND ===
|
|
||||||
private validateMerchantRole(role: string): MerchantRole {
|
|
||||||
const validRoles: MerchantRole[] = ['merchant-admin', 'merchant-manager', 'merchant-support'];
|
|
||||||
|
|
||||||
if (!validRoles.includes(role as MerchantRole)) {
|
|
||||||
throw new BadRequestException(`Invalid client role: ${role}. Valid roles are: ${validRoles.join(', ')}`);
|
|
||||||
}
|
|
||||||
return role as MerchantRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateMerchantRoles(roles: string[]): MerchantRole[] {
|
|
||||||
return roles.map(role => this.validateMerchantRole(role));
|
|
||||||
}
|
|
||||||
|
|
||||||
// === VERIFICATION DES DROITS D'ACCÈS ===
|
|
||||||
private async validateMerchantAccess(targetUserId: string | null, requestingUserId: string): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
error?: string
|
|
||||||
}> {
|
|
||||||
try {
|
|
||||||
const requestingUserRoles = await this.usersService.getUserClientRoles(requestingUserId);
|
|
||||||
|
|
||||||
// Les admins peuvent tout faire
|
|
||||||
if (requestingUserRoles.includes('admin')) {
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier que le demandeur est un marchand
|
|
||||||
if (!requestingUserRoles.includes('merchant')) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Access denied: Merchant role required'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pour les opérations sur un utilisateur spécifique
|
|
||||||
if (targetUserId) {
|
|
||||||
const targetOwnerId = await this.usersService.getMerchantOwner(targetUserId);
|
|
||||||
|
|
||||||
// Si l'utilisateur cible n'a pas de merchantOwnerId, c'est un marchand principal
|
|
||||||
if (!targetOwnerId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Access denied: Cannot modify principal merchant accounts'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vérifier que le demandeur est bien le propriétaire
|
|
||||||
if (targetOwnerId !== requestingUserId) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Access denied: Can only manage your own team members'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error: any) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Failed to validate access: ${error.message}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CREATION D'UTILISATEUR MARCHAND ===
|
|
||||||
async createMerchantUser(
|
|
||||||
merchantData: CreateMerchantUserDto,
|
|
||||||
createdByUserId: string
|
|
||||||
): Promise<MerchantUserResponse> {
|
|
||||||
try {
|
|
||||||
await this.validateMerchantAccess(null, createdByUserId);
|
|
||||||
|
|
||||||
const validatedMerchantRoles = this.validateMerchantRoles(merchantData.merchantRoles);
|
|
||||||
|
|
||||||
const userData = {
|
|
||||||
username: merchantData.username,
|
|
||||||
email: merchantData.email,
|
|
||||||
firstName: merchantData.firstName,
|
|
||||||
lastName: merchantData.lastName,
|
|
||||||
password: merchantData.password,
|
|
||||||
enabled: merchantData.enabled ?? true,
|
|
||||||
clientRoles: ['merchant', ...validatedMerchantRoles] as any,
|
|
||||||
attributes: {
|
|
||||||
merchantOwnerId: [createdByUserId]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createdUser = await this.usersService.createUser(userData);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: createdUser.id,
|
|
||||||
username: createdUser.username,
|
|
||||||
email: createdUser.email,
|
|
||||||
firstName: createdUser.firstName,
|
|
||||||
lastName: createdUser.lastName,
|
|
||||||
merchantRoles: validatedMerchantRoles,
|
|
||||||
emailVerified: true,
|
|
||||||
enabled: createdUser.enabled,
|
|
||||||
createdTimestamp: createdUser.createdTimestamp,
|
|
||||||
merchantOwnerId: createdByUserId
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create merchant user: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === OBTENIR L'EQUIPE DU MARCHAND ===
|
|
||||||
async getMerchantTeam(merchantUserId: string): Promise<MerchantUserResponse[]> {
|
|
||||||
try {
|
|
||||||
// Vérifier les droits
|
|
||||||
await this.validateMerchantAccess(null, merchantUserId);
|
|
||||||
|
|
||||||
// Obtenir tous les utilisateurs
|
|
||||||
const allUsers = await this.usersService.findAllUsers({
|
|
||||||
limit: 1000,
|
|
||||||
page: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
const teamMembers = await Promise.all(
|
|
||||||
allUsers.users.map(async (user) => {
|
|
||||||
try {
|
|
||||||
const roles = await this.usersService.getUserClientRoles(user.id);
|
|
||||||
const merchantRoles = roles.filter(role =>
|
|
||||||
role.startsWith('merchant-') && role !== 'merchant'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Récupérer l'owner ID
|
|
||||||
const merchantOwnerId = user.attributes?.merchantOwnerId?.[0];
|
|
||||||
|
|
||||||
// Filtrer : ne retourner que les utilisateurs de CE marchand
|
|
||||||
if (merchantRoles.length > 0 && merchantOwnerId === merchantUserId) {
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
merchantRoles,
|
|
||||||
enabled: user.enabled,
|
|
||||||
createdTimestamp: user.createdTimestamp,
|
|
||||||
merchantOwnerId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to process user ${user.id}: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return teamMembers.filter((member): member is MerchantUserResponse => member !== null);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch merchant team: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to fetch merchant team');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === METTRE A JOUR UN MEMBRE ===
|
|
||||||
async updateMerchantUser(
|
|
||||||
userId: string,
|
|
||||||
updateData: UpdateMerchantUserDto,
|
|
||||||
updatedByUserId: string
|
|
||||||
): Promise<MerchantUserResponse> {
|
|
||||||
try {
|
|
||||||
// Vérifier les droits
|
|
||||||
await this.validateMerchantAccess(userId, updatedByUserId);
|
|
||||||
|
|
||||||
// Préparer les données de mise à jour
|
|
||||||
const updatePayload: any = {};
|
|
||||||
if (updateData.firstName) updatePayload.firstName = updateData.firstName;
|
|
||||||
if (updateData.lastName) updatePayload.lastName = updateData.lastName;
|
|
||||||
if (updateData.email) updatePayload.email = updateData.email;
|
|
||||||
if (updateData.enabled !== undefined) updatePayload.enabled = updateData.enabled;
|
|
||||||
|
|
||||||
// Mettre à jour les rôles si fournis
|
|
||||||
if (updateData.merchantRoles) {
|
|
||||||
const validatedRoles = this.validateMerchantRoles(updateData.merchantRoles);
|
|
||||||
const finalRoles = ['merchant', ...validatedRoles];
|
|
||||||
await this.usersService.assignClientRoles(userId, finalRoles);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre à jour les autres informations
|
|
||||||
if (Object.keys(updatePayload).length > 0) {
|
|
||||||
await this.usersService.updateUser(userId, updatePayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer l'utilisateur mis à jour
|
|
||||||
const updatedUser = await this.usersService.getUserById(userId);
|
|
||||||
const userRoles = await this.usersService.getUserClientRoles(userId);
|
|
||||||
const merchantRoles = userRoles.filter(role =>
|
|
||||||
role.startsWith('merchant-') && role !== 'merchant'
|
|
||||||
);
|
|
||||||
|
|
||||||
const merchantOwnerId = await this.usersService.getMerchantOwner(userId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: updatedUser.id,
|
|
||||||
username: updatedUser.username,
|
|
||||||
email: updatedUser.email,
|
|
||||||
firstName: updatedUser.firstName,
|
|
||||||
lastName: updatedUser.lastName,
|
|
||||||
merchantRoles,
|
|
||||||
enabled: updatedUser.enabled,
|
|
||||||
emailVerified: true,
|
|
||||||
createdTimestamp: updatedUser.createdTimestamp,
|
|
||||||
merchantOwnerId: merchantOwnerId ?? '',
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update merchant user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === SUPPRIMER UN MEMBRE (retirer les rôles marchands) ===
|
|
||||||
async removeMerchantUser(userId: string, removedByUserId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.validateMerchantAccess(userId, removedByUserId);
|
|
||||||
|
|
||||||
// Retirer les rôles marchands mais garder l'utilisateur
|
|
||||||
const userRoles = await this.usersService.getUserClientRoles(userId);
|
|
||||||
const nonMerchantRoles = userRoles.filter(role =>
|
|
||||||
!role.startsWith('merchant-') && role !== 'merchant'
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.usersService.assignClientRoles(userId, nonMerchantRoles);
|
|
||||||
|
|
||||||
// Optionnel: supprimer l'attribut merchantOwnerId
|
|
||||||
await this.keycloakApi.setUserAttributes(userId, {
|
|
||||||
merchantOwnerId: []
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to remove merchant user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === OBTENIR LES ROLES DISPONIBLES ===
|
|
||||||
getAvailableMerchantRoles(): string[] {
|
|
||||||
return [...this.availableMerchantRoles];
|
|
||||||
}
|
|
||||||
|
|
||||||
// === AJOUTER UN ROLE ===
|
|
||||||
async addMerchantRole(
|
|
||||||
userId: string,
|
|
||||||
role: string,
|
|
||||||
addedByUserId: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
try {
|
|
||||||
await this.validateMerchantAccess(userId, addedByUserId);
|
|
||||||
|
|
||||||
const validatedRole = this.validateMerchantRole(role);
|
|
||||||
const currentRoles = await this.usersService.getUserClientRoles(userId);
|
|
||||||
|
|
||||||
if (!currentRoles.includes('merchant')) {
|
|
||||||
currentRoles.push('merchant');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentRoles.includes(validatedRole)) {
|
|
||||||
currentRoles.push(validatedRole);
|
|
||||||
await this.usersService.assignClientRoles(userId, currentRoles as any); // 🔥 CAST ICI
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: `Role ${validatedRole} added successfully` };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to add merchant role to user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RETIRER UN ROLE ===
|
|
||||||
async removeMerchantRole(
|
|
||||||
userId: string,
|
|
||||||
role: string,
|
|
||||||
removedByUserId: string
|
|
||||||
): Promise<{ message: string }> {
|
|
||||||
try {
|
|
||||||
await this.validateMerchantAccess(userId, removedByUserId);
|
|
||||||
|
|
||||||
const currentRoles = await this.usersService.getUserClientRoles(userId);
|
|
||||||
const updatedRoles = currentRoles.filter(r => r !== role);
|
|
||||||
|
|
||||||
const hasOtherMerchantRoles = updatedRoles.some(r =>
|
|
||||||
r.startsWith('merchant-') && r !== 'merchant'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasOtherMerchantRoles) {
|
|
||||||
const finalRoles = updatedRoles.filter(r => r !== 'merchant');
|
|
||||||
await this.usersService.assignClientRoles(userId, finalRoles as any); // 🔥 CAST ICI
|
|
||||||
} else {
|
|
||||||
await this.usersService.assignClientRoles(userId, updatedRoles as any); // 🔥 CAST ICI
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message: `Role ${role} removed successfully` };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to remove merchant role from user ${userId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === VERIFIER SI UN UTILISATEUR EST DANS L'EQUIPE ===
|
|
||||||
async isUserInMerchantTeam(userId: string, merchantUserId: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const merchantOwnerId = await this.usersService.getMerchantOwner(userId);
|
|
||||||
return merchantOwnerId === merchantUserId;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to check if user ${userId} is in merchant ${merchantUserId} team: ${error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,402 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import {
|
|
||||||
CreateUserDto,
|
|
||||||
UpdateUserDto,
|
|
||||||
UserResponse,
|
|
||||||
PaginatedUserResponse,
|
|
||||||
ResetPasswordDto,
|
|
||||||
UserQueryDto,
|
|
||||||
LoginDto,
|
|
||||||
TokenResponse,
|
|
||||||
ClientRole
|
|
||||||
} from '../models/user';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class UsersService {
|
|
||||||
private readonly logger = new Logger(UsersService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly keycloakApi: KeycloakApiService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// === VALIDATION DES ROLES ===
|
|
||||||
private validateClientRole(role: string): ClientRole {
|
|
||||||
const validRoles: ClientRole[] = ['admin', 'merchant', 'support', 'merchant-admin', 'merchant-manager', 'merchant-support'];
|
|
||||||
if (!validRoles.includes(role as ClientRole)) {
|
|
||||||
throw new BadRequestException(`Invalid client role: ${role}. Valid roles are: ${validRoles.join(', ')}`);
|
|
||||||
}
|
|
||||||
return role as ClientRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateClientRoles(roles: string[]): ClientRole[] {
|
|
||||||
return roles.map(role => this.validateClientRole(role));
|
|
||||||
}
|
|
||||||
|
|
||||||
// === AUTHENTIFICATION UTILISATEUR ===
|
|
||||||
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
|
|
||||||
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET USER BY ID ===
|
|
||||||
async getUserById(userId: string): Promise<UserResponse> {
|
|
||||||
try {
|
|
||||||
this.logger.debug(`Fetching user by ID: ${userId}`);
|
|
||||||
|
|
||||||
const user = await this.keycloakApi.getUserById(userId);
|
|
||||||
if (!user) throw new NotFoundException(`User with ID ${userId} not found`);
|
|
||||||
|
|
||||||
const roles = await this.keycloakApi.getUserClientRoles(userId);
|
|
||||||
const clientRoles = roles.map(role => role.name);
|
|
||||||
|
|
||||||
return new UserResponse({
|
|
||||||
...user,
|
|
||||||
clientRoles,
|
|
||||||
attributes: user.attributes || {}
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch user ${userId}: ${error.message}`);
|
|
||||||
if (error instanceof NotFoundException) throw error;
|
|
||||||
throw new NotFoundException(`User with ID ${userId} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === GET PROFILE FROM DECODED TOKEN ===
|
|
||||||
async getUserProfile(decodedToken: any): Promise<UserResponse> {
|
|
||||||
try {
|
|
||||||
const profileData = {
|
|
||||||
id: decodedToken.sub,
|
|
||||||
username: decodedToken.preferred_username || decodedToken.username,
|
|
||||||
email: decodedToken.email,
|
|
||||||
firstName: decodedToken.given_name || decodedToken.firstName,
|
|
||||||
lastName: decodedToken.family_name || decodedToken.lastName,
|
|
||||||
enabled: true,
|
|
||||||
emailVerified: decodedToken.email_verified || false,
|
|
||||||
attributes: decodedToken.attributes || {},
|
|
||||||
clientRoles: decodedToken.resource_access ?
|
|
||||||
Object.values(decodedToken.resource_access).flatMap((client: any) => client.roles || []) : [],
|
|
||||||
realmRoles: decodedToken.realm_access?.roles || [],
|
|
||||||
createdTimestamp: decodedToken.iat ? decodedToken.iam * 1000 : Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
return new UserResponse(profileData);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create user profile from token: ${error.message}`);
|
|
||||||
throw new NotFoundException('Failed to create user profile');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === FIND ALL USERS ===
|
|
||||||
async findAllUsers(query: UserQueryDto): Promise<PaginatedUserResponse> {
|
|
||||||
try {
|
|
||||||
let users = await this.keycloakApi.getAllUsers();
|
|
||||||
|
|
||||||
// Filtre de recherche
|
|
||||||
if (query.search) {
|
|
||||||
const q = query.search.toLowerCase();
|
|
||||||
users = users.filter(
|
|
||||||
(u) =>
|
|
||||||
u.username?.toLowerCase().includes(q) ||
|
|
||||||
u.email?.toLowerCase().includes(q) ||
|
|
||||||
u.firstName?.toLowerCase().includes(q) ||
|
|
||||||
u.lastName?.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre par statut enabled
|
|
||||||
if (query.enabled !== undefined) {
|
|
||||||
users = users.filter((u) => u.enabled === query.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.emailVerified !== undefined) {
|
|
||||||
users = users.filter((u) => u.emailVerified === query.emailVerified);
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = query.page || 1;
|
|
||||||
const limit = query.limit || 10;
|
|
||||||
const startIndex = (page - 1) * limit;
|
|
||||||
const paginatedUsers = users.slice(startIndex, startIndex + limit);
|
|
||||||
|
|
||||||
const usersWithRoles = await Promise.all(
|
|
||||||
paginatedUsers.map(async (u) => {
|
|
||||||
try {
|
|
||||||
const roles = await this.keycloakApi.getUserClientRoles(u.id!);
|
|
||||||
const clientRoles = roles.map(role => role.name);
|
|
||||||
return new UserResponse({ ...u, clientRoles });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to fetch roles for user ${u.id}: ${error.message}`);
|
|
||||||
return new UserResponse({ ...u, clientRoles: [] });
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return new PaginatedUserResponse(usersWithRoles, users.length, page, limit);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to fetch users: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to fetch users');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === CREATE USER ===
|
|
||||||
async createUser(userData: CreateUserDto): Promise<UserResponse> {
|
|
||||||
try {
|
|
||||||
// Vérifier si l'utilisateur existe déjà
|
|
||||||
const existingByUsername = await this.keycloakApi.findUserByUsername(userData.username);
|
|
||||||
|
|
||||||
if (userData.email) {
|
|
||||||
const existingByEmail = await this.keycloakApi.findUserByEmail(userData.email);
|
|
||||||
if (existingByEmail.length > 0) {
|
|
||||||
throw new ConflictException('User with this email already exists');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingByUsername.length > 0) {
|
|
||||||
throw new ConflictException('User with this username already exists');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Préparer les données utilisateur
|
|
||||||
const keycloakUserData: any = {
|
|
||||||
username: userData.username,
|
|
||||||
email: userData.email,
|
|
||||||
firstName: userData.firstName,
|
|
||||||
lastName: userData.lastName,
|
|
||||||
enabled: userData.enabled ?? true,
|
|
||||||
credentials: userData.password ? [
|
|
||||||
{
|
|
||||||
type: 'password',
|
|
||||||
value: userData.password,
|
|
||||||
temporary: false
|
|
||||||
}
|
|
||||||
] : []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ajouter les attributs si présents
|
|
||||||
if (userData.attributes) {
|
|
||||||
keycloakUserData.attributes = userData.attributes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = await this.keycloakApi.createUser(keycloakUserData);
|
|
||||||
|
|
||||||
// Attribution des rôles client
|
|
||||||
if (userData.clientRoles?.length) {
|
|
||||||
const validatedRoles = this.validateClientRoles(userData.clientRoles);
|
|
||||||
await this.keycloakApi.setClientRoles(userId, validatedRoles);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Récupérer l'utilisateur créé
|
|
||||||
const createdUser = await this.keycloakApi.getUserById(userId);
|
|
||||||
const roles = await this.keycloakApi.getUserClientRoles(userId);
|
|
||||||
const clientRoles = roles.map(role => role.name);
|
|
||||||
|
|
||||||
return new UserResponse({
|
|
||||||
...createdUser,
|
|
||||||
clientRoles,
|
|
||||||
attributes: createdUser.attributes || {}
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to create user: ${error.message}`);
|
|
||||||
if (error instanceof ConflictException || error instanceof BadRequestException) throw error;
|
|
||||||
throw new BadRequestException('Failed to create user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === METHODE POUR OBTENIR LE MERCHANT OWNER ===
|
|
||||||
async getMerchantOwner(userId: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
return await this.keycloakApi.getUserAttribute(userId, 'merchantOwnerId');
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to get merchant owner for user ${userId}: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === METHODE POUR DEFINIR LE MERCHANT OWNER ===
|
|
||||||
async setMerchantOwner(userId: string, merchantOwnerId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.keycloakApi.setUserAttributes(userId, {
|
|
||||||
merchantOwnerId: [merchantOwnerId]
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to set merchant owner for user ${userId}: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to set merchant owner');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === UPDATE USER ===
|
|
||||||
async updateUser(id: string, userData: UpdateUserDto): Promise<UserResponse> {
|
|
||||||
try {
|
|
||||||
// Vérifier que l'utilisateur existe
|
|
||||||
await this.keycloakApi.getUserById(id);
|
|
||||||
|
|
||||||
await this.keycloakApi.updateUser(id, userData);
|
|
||||||
|
|
||||||
// Mettre à jour les rôles si fournis
|
|
||||||
if (userData.clientRoles) {
|
|
||||||
const validatedRoles = this.validateClientRoles(userData.clientRoles);
|
|
||||||
await this.keycloakApi.setClientRoles(id, validatedRoles);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await this.keycloakApi.getUserById(id);
|
|
||||||
const roles = await this.keycloakApi.getUserClientRoles(id);
|
|
||||||
const clientRoles = roles.map(role => role.name);
|
|
||||||
|
|
||||||
return new UserResponse({ ...updatedUser, clientRoles });
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
|
||||||
if (error instanceof NotFoundException || error instanceof BadRequestException) throw error;
|
|
||||||
throw new BadRequestException('Failed to update user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ASSIGN CLIENT ROLES TO USER ===
|
|
||||||
async assignClientRoles(userId: string, roles: string[]): Promise<{ message: string }> {
|
|
||||||
try {
|
|
||||||
this.logger.log(`Assigning client roles to user: ${userId}`, roles);
|
|
||||||
|
|
||||||
// Vérifier que l'utilisateur existe
|
|
||||||
await this.keycloakApi.getUserById(userId);
|
|
||||||
|
|
||||||
// Valider et assigner les rôles
|
|
||||||
const validatedRoles = this.validateClientRoles(roles);
|
|
||||||
await this.keycloakApi.setClientRoles(userId, validatedRoles);
|
|
||||||
|
|
||||||
this.logger.log(`Successfully assigned ${validatedRoles.length} roles to user ${userId}`);
|
|
||||||
return { message: 'Roles assigned successfully' };
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to assign roles to user ${userId}: ${error.message}`);
|
|
||||||
if (error instanceof NotFoundException || error instanceof BadRequestException) throw error;
|
|
||||||
throw new BadRequestException('Failed to assign roles to user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === DELETE USER ===
|
|
||||||
async deleteUser(id: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.keycloakApi.getUserById(id);
|
|
||||||
await this.keycloakApi.deleteUser(id);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
|
||||||
if (error instanceof NotFoundException) throw error;
|
|
||||||
throw new BadRequestException('Failed to delete user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === RESET PASSWORD ===
|
|
||||||
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.keycloakApi.getUserById(resetPasswordDto.userId);
|
|
||||||
await this.keycloakApi.resetPassword(resetPasswordDto.userId, resetPasswordDto.newPassword);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to reset password for user ${resetPasswordDto.userId}: ${error.message}`);
|
|
||||||
if (error instanceof NotFoundException) throw error;
|
|
||||||
throw new BadRequestException('Failed to reset password');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === ENABLE/DISABLE USER ===
|
|
||||||
async enableUser(userId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.keycloakApi.enableUser(userId);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to enable user ${userId}: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to enable user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async disableUser(userId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.keycloakApi.disableUser(userId);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to disable user ${userId}: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to disable user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === UTILITY METHODS ===
|
|
||||||
async userExists(username: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const users = await this.keycloakApi.findUserByUsername(username);
|
|
||||||
return users.length > 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUserClientRoles(userId: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const roles = await this.keycloakApi.getUserClientRoles(userId);
|
|
||||||
return roles.map(role => role.name);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to get client roles for user ${userId}: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to get user client roles');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async findUserByUsername(username: string): Promise<UserResponse[]> {
|
|
||||||
try {
|
|
||||||
const users = await this.keycloakApi.findUserByUsername(username);
|
|
||||||
|
|
||||||
const usersWithRoles = await Promise.all(
|
|
||||||
users.map(async (user) => {
|
|
||||||
try {
|
|
||||||
const roles = await this.keycloakApi.getUserClientRoles(user.id!);
|
|
||||||
const clientRoles = roles.map(role => role.name);
|
|
||||||
return new UserResponse({ ...user, clientRoles });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`);
|
|
||||||
return new UserResponse({ ...user, clientRoles: [] });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return usersWithRoles;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to find user by username ${username}: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to find user by username');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async findUserByEmail(email: string): Promise<UserResponse[]> {
|
|
||||||
try {
|
|
||||||
const users = await this.keycloakApi.findUserByEmail(email);
|
|
||||||
|
|
||||||
const usersWithRoles = await Promise.all(
|
|
||||||
users.map(async (user) => {
|
|
||||||
try {
|
|
||||||
const roles = await this.keycloakApi.getUserClientRoles(user.id!);
|
|
||||||
const clientRoles = roles.map(role => role.name);
|
|
||||||
return new UserResponse({ ...user, clientRoles });
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(`Failed to fetch roles for user ${user.id}: ${error.message}`);
|
|
||||||
return new UserResponse({ ...user, clientRoles: [] });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return usersWithRoles;
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to find user by email ${email}: ${error.message}`);
|
|
||||||
throw new BadRequestException('Failed to find user by email');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === PRIVATE METHODS ===
|
|
||||||
private decodeJwtToken(token: string): any {
|
|
||||||
try {
|
|
||||||
const payload = token.split('.')[1];
|
|
||||||
const decoded = JSON.parse(Buffer.from(payload, 'base64').toString());
|
|
||||||
return decoded;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Failed to decode JWT token', error);
|
|
||||||
throw new BadRequestException('Invalid token format');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common'
|
|
||||||
import { JwtModule } from '@nestjs/jwt'
|
|
||||||
import { HttpModule } from '@nestjs/axios';
|
|
||||||
import { TokenService } from '../auth/services/token.service'
|
|
||||||
import { UsersService } from './services/users.service'
|
|
||||||
import { MerchantTeamService } from 'src/users/services/merchant-team.service';
|
|
||||||
|
|
||||||
import { UsersController } from './controllers/users.controller'
|
|
||||||
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
|
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
HttpModule,
|
|
||||||
JwtModule.register({}),
|
|
||||||
],
|
|
||||||
providers: [UsersService, MerchantTeamService, KeycloakApiService, TokenService],
|
|
||||||
controllers: [UsersController],
|
|
||||||
exports: [UsersService, MerchantTeamService, KeycloakApiService, TokenService, JwtModule],
|
|
||||||
})
|
|
||||||
export class UsersModule {}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user