Compare commits
No commits in common. "557c899d0f90bbb8ac46763fe9a328b1a3a3e257" and "ab58c3f577aac0ab43a739c76e9f0d84393f410e" have entirely different histories.
557c899d0f
...
ab58c3f577
56
.env-sample
56
.env-sample
@ -1,60 +1,28 @@
|
||||
# .env
|
||||
# .env-sample
|
||||
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
|
||||
# === CONFIGURATION DES TESTS STARTUP ===
|
||||
RUN_STARTUP_TESTS=false
|
||||
TEST_CLEANUP_DELAY_MS=100
|
||||
TEST_TIMEOUT_MS=30000
|
||||
TEST_USER_PASSWORD=SecureTempPass123!
|
||||
TEST_EMAIL_DOMAIN=dcb-test.com
|
||||
TEST_DEFAULT_PASSWORD=SecureTempPass123!
|
||||
KEYCLOAK_SERVER_URL=https://keycloak-dcb.app.cameleonapp.com
|
||||
KEYCLOAK_REALM=dcb-dev
|
||||
|
||||
# === CONFIGURATION DE SÉCURITÉ ===
|
||||
RUN_SECURITY_TESTS=false
|
||||
SECURITY_TEST_TIMEOUT=300000
|
||||
KEYCLOAK_JWKS_URI=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev/protocol/openid-connect/certs
|
||||
KEYCLOAK_ISSUER=https://keycloak-dcb.app.cameleonapp.com/realms/dcb-dev
|
||||
|
||||
# === VALIDATION DES ENTREES ===
|
||||
MAX_USERNAME_LENGTH=50
|
||||
MIN_USERNAME_LENGTH=3
|
||||
ALLOWED_EMAIL_DOMAINS=dcb-test.com,pixpay.sn
|
||||
|
||||
# === RATE LIMITING ===
|
||||
MAX_REQUESTS_PER_MINUTE=60
|
||||
RATE_LIMIT_BLOCK_DURATION=300000
|
||||
|
||||
# === SÉCURITÉ DES SESSIONS ===
|
||||
SESSION_TIMEOUT=900000
|
||||
JWT_EXPIRATION=3600000
|
||||
|
||||
# === SURVEILLANCE ===
|
||||
LOG_SECURITY_EVENTS=true
|
||||
SECURITY_EVENT_RETENTION_DAYS=30
|
||||
|
||||
# === CONFIGURATION KEYCLOAK ===
|
||||
|
||||
KEYCLOAK_SERVER_URL=https://iam.dcb.pixpay.sn
|
||||
KEYCLOAK_REALM=dcb-prod
|
||||
|
||||
KEYCLOAK_JWKS_URI=https://iam.dcb.pixpay.sn/realms/dcb-prod/protocol/openid-connect/certs
|
||||
KEYCLOAK_ISSUER=https://iam.dcb.pixpay.sn/realms/dcb-prod
|
||||
|
||||
KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA01nspe5Sol9YAzm98wnQO1MvhRgJZSaOhozOHJEBm5VW5wLEEfcTlakzr/xXRjFYB9jySeaDWyhE6qGKuRK2Kx20qt3CuwT52ZSy97dKjJbgCxBCOymxKLJRdDfwtKOAayk5oCHqGp+cJTShnd9jVggYyTdqGqMWlpeiBKqvpgyldndwIfvDxPpPwsx/mwKV7S4sSTsONxSIB6zK+RumeYKOF0BskIxBw4tG3V5eicrECCKX/jP8rYFclBPXhxnLbbaHa21XAwQHfOioip3YfwPYF9GKTJEhM8ziJdTKikAtiwFm/Zvn1foLaF1MDLpV9yLrK0H1oa3y7j5p7tqHbQIDAQAB
|
||||
|
||||
KEYCLOAK_CLIENT_ID=dcb-user-service-cc-app
|
||||
KEYCLOAK_CLIENT_SECRET=IFNQWjBbcW6dXqQO76X5OZb1lL0esO30
|
||||
KEYCLOAK_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwfT6BGerQyJ7EOFcgN1DLxRh/8g3cCN5qNZyeLQc6524Lsw3voMD2HJddvAunCcn6Eux2LTYXPzLvZc8829Sa5ksTzINyPqg9GFZa5+GAifMW6DfvQcxGyl5yvduCWxOSmST3PYN9UkCFP20e3gDLRox9rNe1/17xkDJwByJh/Xld/m07vHgyglDNRGkA/YW3A1JuAKgJjAstLOyeK+UGdMeJmD/5TF/yoBI/FsjW/OjZ78wP3dfkGo5zG2EOkK+39evU7HxB4jgL5SBhw32GLPVhtyCMnUW6IlsQhDSDWXqBdMCO0/hdrjyznyM7ZJqkUN7KAFKqcJsnja9mBNT4QIDAQAB
|
||||
|
||||
KEYCLOAK_CLIENT_ID=dcb-user-service-pwd
|
||||
KEYCLOAK_CLIENT_SECRET=J0VvIiiJST40SD3apiQ206r1xNCERFD2
|
||||
KEYCLOAK_VALIDATION_MODE=offline
|
||||
|
||||
KEYCLOAK_TOKEN_BUFFER_SECONDS=30
|
||||
|
||||
KEYCLOAK_TEST_USER_ADMIN=bo-admin
|
||||
KEYCLOAK_TEST_USER_ADMIN=dev-bo-admin
|
||||
KEYCLOAK_TEST_PASSWORD_ADMIN=@BOAdmin2025
|
||||
|
||||
KEYCLOAK_TEST_USER_MERCHANT=bo-partner
|
||||
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOPartner2025
|
||||
KEYCLOAK_TEST_USER_MERCHANT=dev-bo-merchant
|
||||
KEYCLOAK_TEST_PASSWORD_MERCHANT=@BOMerchant2025
|
||||
|
||||
KEYCLOAK_TEST_USER_SUPPORT=bo-support
|
||||
KEYCLOAK_TEST_USER_SUPPORT=dev-bo-support
|
||||
KEYCLOAK_TEST_PASSWORD=@BOSupport2025
|
||||
|
||||
|
||||
10
src/api/api.module.ts
Normal file
10
src/api/api.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
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 {}
|
||||
47
src/api/controllers/api.controller.ts
Normal file
47
src/api/controllers/api.controller.ts
Normal file
@ -0,0 +1,47 @@
|
||||
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,9 @@ import { TerminusModule } from '@nestjs/terminus';
|
||||
|
||||
import keycloakConfig, { keycloakConfigValidationSchema } from './config/keycloak.config';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { HubUsersModule } from './hub-users/hub-users.module';
|
||||
import { StartupServiceInitialization } from './auth/services/startup.service';
|
||||
import { ApiModule } from './api/api.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { StartupService } from './auth/services/startup.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -68,11 +69,12 @@ import { StartupServiceInitialization } from './auth/services/startup.service';
|
||||
|
||||
// Feature Modules
|
||||
AuthModule,
|
||||
HubUsersModule,
|
||||
ApiModule,
|
||||
UsersModule,
|
||||
|
||||
],
|
||||
providers: [
|
||||
StartupServiceInitialization,
|
||||
StartupService,
|
||||
// Global Authentication Guard
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
|
||||
@ -4,7 +4,8 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { TokenService } from './services/token.service';
|
||||
import { KeycloakApiService } from './services/keycloak-api.service';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { HubUsersService } from '../hub-users/services/hub-users.service';
|
||||
import { UsersService } from '../users/services/users.service';
|
||||
import { MerchantTeamService } from 'src/users/services/merchant-team.service';
|
||||
import { JwtAuthGuard } from './guards/jwt.guard';
|
||||
|
||||
|
||||
@ -18,9 +19,10 @@ import { JwtAuthGuard } from './guards/jwt.guard';
|
||||
JwtAuthGuard,
|
||||
TokenService,
|
||||
KeycloakApiService,
|
||||
HubUsersService
|
||||
UsersService,
|
||||
MerchantTeamService
|
||||
],
|
||||
controllers: [AuthController],
|
||||
exports: [JwtAuthGuard, TokenService, KeycloakApiService, HubUsersService, JwtModule],
|
||||
exports: [JwtAuthGuard, TokenService, KeycloakApiService, UsersService, MerchantTeamService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -9,75 +9,14 @@ import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiHeader,
|
||||
ApiProperty
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthenticatedUser, Public, Roles } from 'nest-keycloak-connect';
|
||||
import { TokenService } from '../services/token.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request } from 'express';
|
||||
import { HubUsersService } from '../../hub-users/services/hub-users.service';
|
||||
import * as user from '../../hub-users/models/hub-user.model';
|
||||
import { UsersService } from '../../users/services/users.service';
|
||||
import * as user from '../../users/models/user';
|
||||
|
||||
// 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')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
@ -85,40 +24,16 @@ export class AuthController {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly usersService: HubUsersService
|
||||
private readonly usersService: UsersService
|
||||
) {}
|
||||
|
||||
/** -------------------------------
|
||||
* LOGIN (Resource Owner Password Credentials)
|
||||
* ------------------------------- */
|
||||
|
||||
// === AUTHENTIFICATION ===
|
||||
@Public()
|
||||
@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) {
|
||||
|
||||
this.logger.log(`User login attempt: ${loginDto.username}`);
|
||||
@ -163,36 +78,6 @@ export class AuthController {
|
||||
* 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) {
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
if (!token) throw new HttpException('No token provided', HttpStatus.BAD_REQUEST);
|
||||
@ -235,26 +120,6 @@ export class AuthController {
|
||||
* ------------------------------- */
|
||||
@Public()
|
||||
@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 }) {
|
||||
const { refresh_token } = body;
|
||||
if (!refresh_token) throw new HttpException('Refresh token is required', HttpStatus.BAD_REQUEST);
|
||||
@ -278,20 +143,6 @@ export class AuthController {
|
||||
* ------------------------------- */
|
||||
@Public()
|
||||
@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) {
|
||||
const token = req.headers['authorization']?.replace('Bearer ', '');
|
||||
let isValid = false;
|
||||
@ -304,62 +155,4 @@ export class AuthController {
|
||||
}
|
||||
return { authenticated: isValid, status: isValid ? 'Token is valid' : 'Token is invalid or expired' };
|
||||
}
|
||||
|
||||
/** -------------------------------
|
||||
* USER PROFILE (protected)
|
||||
* ------------------------------- */
|
||||
@Get('profile')
|
||||
@ApiOperation({
|
||||
summary: 'Get current user profile',
|
||||
description: 'Retrieve profile information of authenticated user'
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User profile retrieved successfully'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'Unauthorized - invalid token',
|
||||
type: ErrorResponseDto
|
||||
})
|
||||
async getProfile(@AuthenticatedUser() user: any) {
|
||||
return this.usersService.getCompleteUserProfile(user.sub, user);
|
||||
|
||||
}
|
||||
|
||||
/** -------------------------------
|
||||
* VALIDATE TOKEN (protected)
|
||||
* ------------------------------- */
|
||||
@Get('validate')
|
||||
@ApiOperation({
|
||||
summary: 'Validate token',
|
||||
description: 'Check if the current token is valid and get user information'
|
||||
})
|
||||
@ApiBearerAuth()
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Token is valid'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 401,
|
||||
description: 'Unauthorized - invalid token',
|
||||
type: ErrorResponseDto
|
||||
})
|
||||
async validateToken(@AuthenticatedUser() user: any) {
|
||||
this.logger.log(`Token validation requested for user: ${user.preferred_username}`);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
user: {
|
||||
id: user.sub,
|
||||
username: user.preferred_username,
|
||||
email: user.email,
|
||||
firstName: user.given_name,
|
||||
lastName: user.family_name,
|
||||
roles: user.resource_access?.[this.configService.get('KEYCLOAK_CLIENT_ID')]?.roles || [],
|
||||
},
|
||||
expires_in: user.exp ? user.exp - Math.floor(Date.now() / 1000) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
src/auth/guards/merchant-owner.guard.ts
Normal file
33
src/auth/guards/merchant-owner.guard.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { MerchantTeamService } from '../../users/services/merchant-team.service';
|
||||
|
||||
@Injectable()
|
||||
export class MerchantOwnerGuard implements CanActivate {
|
||||
constructor(private readonly merchantTeamService: MerchantTeamService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<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');
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/auth/guards/merchant.guard.ts
Normal file
29
src/auth/guards/merchant.guard.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { UsersService } from '../../users/services/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class MerchantGuard implements CanActivate {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
throw new ForbiddenException('User not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const userRoles = await this.usersService.getUserClientRoles(user.sub);
|
||||
|
||||
// Autoriser les admins et les marchands
|
||||
if (userRoles.includes('admin') || userRoles.includes('merchant')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Merchant access required');
|
||||
} catch (error) {
|
||||
throw new ForbiddenException('Failed to verify merchant permissions');
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,153 +0,0 @@
|
||||
export interface KeycloakUser {
|
||||
id?: string;
|
||||
username: string;
|
||||
email: string; // Rendre obligatoire
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
enabled: boolean;
|
||||
emailVerified: boolean;
|
||||
attributes?: {
|
||||
merchantPartnerId?: string[];
|
||||
createdBy?: string[];
|
||||
createdByUsername?: string[];
|
||||
userType?: string[];
|
||||
[key: string]: string[] | undefined;
|
||||
};
|
||||
createdTimestamp?: number;
|
||||
}
|
||||
|
||||
export interface KeycloakRole {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
composite?: boolean;
|
||||
clientRole?: boolean;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserData {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password?: string;
|
||||
passwordTemporary?: boolean;
|
||||
enabled?: boolean;
|
||||
emailVerified?: boolean;
|
||||
merchantPartnerId?: string | null;
|
||||
clientRoles: UserRole[];
|
||||
attributes?: {
|
||||
userStatus?: string[];
|
||||
lastLogin?: string[];
|
||||
merchantPartnerId?: string[];
|
||||
createdBy?: string[];
|
||||
createdByUsername?: string[];
|
||||
userType?: string[];
|
||||
[key: string]: string[] | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export enum UserType {
|
||||
HUB = 'HUB',
|
||||
MERCHANT_PARTNER = 'MERCHANT'
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
// Rôles Hub (sans merchantPartnerId)
|
||||
DCB_ADMIN = 'dcb-admin',
|
||||
DCB_SUPPORT = 'dcb-support',
|
||||
DCB_PARTNER = 'dcb-partner',
|
||||
|
||||
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
|
||||
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
|
||||
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
|
||||
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
|
||||
}
|
||||
|
||||
export interface HubUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||
enabled: boolean;
|
||||
emailVerified: boolean;
|
||||
createdBy: string;
|
||||
createdByUsername: string;
|
||||
createdTimestamp: number;
|
||||
userType: string;
|
||||
attributes?: {
|
||||
userStatus?: string[];
|
||||
lastLogin?: string[];
|
||||
merchantPartnerId?: string[];
|
||||
createdBy?: string[];
|
||||
createdByUsername?: string[];
|
||||
userType?: string[];
|
||||
[key: string]: string[] | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateHubUserData {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password?: string;
|
||||
role: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||
enabled?: boolean;
|
||||
emailVerified?: boolean;
|
||||
|
||||
}
|
||||
|
||||
export interface HubUserStats {
|
||||
totalAdmins: number;
|
||||
totalSupports: number;
|
||||
activeUsers: number;
|
||||
inactiveUsers: number;
|
||||
}
|
||||
|
||||
export interface MerchantStats {
|
||||
totalAdmins: number;
|
||||
totalManagers: number;
|
||||
totalSupports: number;
|
||||
activeUsers: number;
|
||||
inactiveUsers: number;
|
||||
}
|
||||
|
||||
export interface UserQueryDto {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
userType?: UserType;
|
||||
merchantPartnerId?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
user: KeycloakUser;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginatedUsersResponse {
|
||||
users: KeycloakUser[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// Pour l'authentification
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_expires_in?: number;
|
||||
scope?: string;
|
||||
}
|
||||
@ -1,76 +1,42 @@
|
||||
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: {},
|
||||
};
|
||||
export class StartupService implements OnModuleInit {
|
||||
private readonly logger = new Logger(StartupService.name);
|
||||
private initialized = false;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly keycloakApiService: KeycloakApiService,
|
||||
) {}
|
||||
constructor(private readonly keycloakApiService: KeycloakApiService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('🚀 Démarrage des tests de connexion');
|
||||
this.logger.log('Vérification de la disponibilité de Keycloak...');
|
||||
|
||||
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}`);
|
||||
const available = await this.keycloakApiService.checkKeycloakAvailability();
|
||||
if (!available) throw new Error('Keycloak non accessible');
|
||||
|
||||
const serviceConnected = await this.keycloakApiService.checkServiceConnection();
|
||||
if (!serviceConnected) throw new Error('Échec de la connexion du service à Keycloak');
|
||||
|
||||
this.initialized = true;
|
||||
this.logger.log('Keycloak disponible et connexion du service réussie');
|
||||
} catch (err: any) {
|
||||
this.error = err.message;
|
||||
this.logger.error('Échec de la vérification de Keycloak', err);
|
||||
}
|
||||
}
|
||||
|
||||
// === 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,
|
||||
status: this.initialized ? 'healthy' : 'unhealthy',
|
||||
keycloakConnected: this.initialized,
|
||||
timestamp: new Date(),
|
||||
error: this.initializationError,
|
||||
error: this.error,
|
||||
};
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.isInitialized;
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
getTestResults(): TestResults {
|
||||
return this.testResults;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
@ -10,40 +10,22 @@ export interface KeycloakTokenResponse {
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_expire_in?: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface DecodedToken {
|
||||
sub: string;
|
||||
email?: string;
|
||||
preferred_username?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
realm_access?: { roles: string[] };
|
||||
resource_access?: { [key: string]: { roles: string[] } };
|
||||
merchantPartnerId?: string;
|
||||
}
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class TokenService implements OnModuleInit {
|
||||
export class TokenService {
|
||||
private readonly logger = new Logger(TokenService.name);
|
||||
private readonly keycloakConfig: KeycloakConfig;
|
||||
|
||||
// Cache pour le token de service account
|
||||
private serviceAccountToken: string | null = null;
|
||||
private serviceTokenExpiry: number = 0;
|
||||
|
||||
|
||||
// === TOKEN STORAGE ===
|
||||
private userToken: string | null = null;
|
||||
private userTokenExpiry: Date | null = null;
|
||||
private userRefreshToken: string | null = null;
|
||||
|
||||
// Cache pour les clés publiques
|
||||
private publicKeys: { [key: string]: string } = {};
|
||||
private keysLastFetched: number = 0;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@ -52,10 +34,6 @@ export class TokenService implements OnModuleInit {
|
||||
this.keycloakConfig = this.getKeycloakConfig();
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.fetchPublicKeys();
|
||||
}
|
||||
|
||||
// === CONFIGURATION ===
|
||||
private getKeycloakConfig(): KeycloakConfig {
|
||||
const config = this.configService.get<KeycloakConfig>('keycloak');
|
||||
@ -69,32 +47,6 @@ export class TokenService implements OnModuleInit {
|
||||
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 ===
|
||||
private isServiceTokenValid(): boolean {
|
||||
if (!this.serviceAccountToken) return false;
|
||||
@ -110,6 +62,7 @@ export class TokenService implements OnModuleInit {
|
||||
|
||||
// === TOKEN ACQUISITION ===
|
||||
async acquireUserToken(username: string, password: string): Promise<KeycloakTokenResponse> {
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: this.keycloakConfig.authClientId,
|
||||
@ -127,7 +80,7 @@ export class TokenService implements OnModuleInit {
|
||||
|
||||
// Stocker le token et ses métadonnées
|
||||
this.storeUserToken(response.data);
|
||||
|
||||
|
||||
this.logger.log(`User token acquired for: ${username}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
@ -275,7 +228,30 @@ export class TokenService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
// === TOKEN VALIDATION AMÉLIORÉE ===
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
// === TOKEN VALIDATION ===
|
||||
async validateToken(token: string): Promise<boolean> {
|
||||
const mode = this.keycloakConfig.validationMode || 'online';
|
||||
|
||||
@ -307,170 +283,37 @@ export class TokenService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private async validateOffline(token: string): Promise<boolean> {
|
||||
if (this.shouldRefreshKeys()) {
|
||||
await this.fetchPublicKeys();
|
||||
private validateOffline(token: string): boolean {
|
||||
if (!this.keycloakConfig.publicKey) {
|
||||
this.logger.error('Missing public key for offline validation');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
const formattedKey = `-----BEGIN PUBLIC KEY-----\n${this.keycloakConfig.publicKey}\n-----END PUBLIC KEY-----`;
|
||||
|
||||
jwt.verify(token, formattedKey, {
|
||||
algorithms: ['RS256'],
|
||||
//audience: this.keycloakConfig.authClientId,
|
||||
issuer: `${this.keycloakConfig.serverUrl}/realms/${this.keycloakConfig.realm}`,
|
||||
ignoreExpiration: false,
|
||||
ignoreNotBefore: false,
|
||||
audience: this.keycloakConfig.authClientId,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private formatPublicKey(key: any): string {
|
||||
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 {
|
||||
// === TOKEN UTILITIES ===
|
||||
decodeToken(token: string): any {
|
||||
try {
|
||||
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;
|
||||
return jwt.decode(token);
|
||||
} catch (error: any) {
|
||||
this.logger.error('Failed to decode token', error.message);
|
||||
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> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.keycloakConfig.authClientId,
|
||||
@ -494,6 +337,7 @@ export class TokenService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
// === SERVICE MANAGEMENT ===
|
||||
clearServiceToken(): void {
|
||||
this.serviceAccountToken = null;
|
||||
this.serviceTokenExpiry = 0;
|
||||
|
||||
@ -5,6 +5,10 @@ export interface KeycloakConfig {
|
||||
serverUrl: string;
|
||||
realm: 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;
|
||||
authClientSecret: string;
|
||||
validationMode: string;
|
||||
@ -12,10 +16,14 @@ export interface KeycloakConfig {
|
||||
}
|
||||
|
||||
export default registerAs('keycloak', (): KeycloakConfig => ({
|
||||
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://iam.dcb.pixpay.sn',
|
||||
realm: process.env.KEYCLOAK_REALM || 'dcb-prod',
|
||||
serverUrl: process.env.KEYCLOAK_SERVER_URL || 'https://keycloak-dcb.app.cameleonapp.com',
|
||||
realm: process.env.KEYCLOAK_REALM || 'dcb-dev',
|
||||
publicKey: process.env.KEYCLOAK_PUBLIC_KEY,
|
||||
authClientId: process.env.KEYCLOAK_CLIENT_ID || 'dcb-user-service-cc-app',
|
||||
// Client pour Service Account (API Admin)
|
||||
//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 || '',
|
||||
validationMode: process.env.KEYCLOAK_VALIDATION_MODE || 'online',
|
||||
tokenBufferSeconds: Number(process.env.KEYCLOAK_TOKEN_BUFFER_SECONDS) || 30,
|
||||
@ -45,6 +53,20 @@ export const keycloakConfigValidationSchema = Joi.object({
|
||||
'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()
|
||||
.required()
|
||||
.messages({
|
||||
|
||||
4
src/constants/resouces.ts
Normal file
4
src/constants/resouces.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const RESOURCES = {
|
||||
USER: 'user', // user resource for /users/* endpoints
|
||||
MERCHANT: 'merchants' // merchant resource for /merchants/* endpoints
|
||||
};
|
||||
@ -1,4 +0,0 @@
|
||||
export const RESOURCES = {
|
||||
HUB_USER: 'user', // user resource for /users/* endpoints
|
||||
MERCHANT_USER: 'partner' // merchant resource for /merchants/* endpoints
|
||||
};
|
||||
@ -1,624 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
ParseUUIDPipe,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
BadRequestException
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiProperty,
|
||||
getSchemaPath
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
import { HubUsersService } from '../services/hub-users.service';
|
||||
import { UserRole, UserType } from '../../auth/services/keycloak-user.model';
|
||||
|
||||
import { RESOURCES } from '../../constants/resources';
|
||||
import { SCOPES } from '../../constants/scopes';
|
||||
import { Resource, Scopes } from 'nest-keycloak-connect';
|
||||
import { CreateUserData, User } from '../models/hub-user.model';
|
||||
|
||||
// ===== DTO SPÉCIFIQUES AUX HUB USERS =====
|
||||
|
||||
export class CreateHubUserDto {
|
||||
@ApiProperty({ description: 'Username for the user' })
|
||||
@IsNotEmpty({ message: 'Username is required' })
|
||||
@IsString()
|
||||
@MinLength(3, { message: 'Username must be at least 3 characters' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address' })
|
||||
@IsNotEmpty({ message: 'Email is required' })
|
||||
@IsEmail({}, { message: 'Invalid email format' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'First name' })
|
||||
@IsNotEmpty({ message: 'First name is required' })
|
||||
@IsString()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ description: 'Last name' })
|
||||
@IsNotEmpty({ message: 'Last name is required' })
|
||||
@IsString()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ description: 'Password for the user' })
|
||||
@IsNotEmpty({ message: 'Password is required' })
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'Password must be at least 8 characters' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: UserRole,
|
||||
description: 'Role for the user',
|
||||
examples: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER]
|
||||
})
|
||||
@IsEnum(UserRole, { message: 'Invalid role' })
|
||||
@IsNotEmpty({ message: 'Role is required' })
|
||||
role: UserRole;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: 'Enabled must be a boolean' })
|
||||
enabled?: boolean = true;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: 'EmailVerified must be a boolean' })
|
||||
emailVerified?: boolean = true;
|
||||
|
||||
@ApiProperty({
|
||||
enum: UserType,
|
||||
description: 'Type of user',
|
||||
example: UserType.HUB
|
||||
})
|
||||
@IsEnum(UserType, { message: 'Invalid user type' })
|
||||
@IsNotEmpty({ message: 'User type is required' })
|
||||
userType: UserType;
|
||||
|
||||
// Pas de merchantPartnerId pour les hub users
|
||||
}
|
||||
|
||||
export class UpdateHubUserDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class ResetHubUserPasswordDto {
|
||||
@ApiProperty({ description: 'New password' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
temporary?: boolean = true;
|
||||
}
|
||||
|
||||
export class UpdateHubUserRoleDto {
|
||||
@ApiProperty({
|
||||
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||
description: 'New role for the user'
|
||||
})
|
||||
@IsEnum(UserRole, { message: 'Invalid role' })
|
||||
@IsNotEmpty({ message: 'Role is required' })
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export class HubUserResponse {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'Username' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'First name' })
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ description: 'Last name' })
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||
description: 'User role'
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@ApiProperty({ description: 'Whether the user is enabled' })
|
||||
enabled: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Whether the email is verified' })
|
||||
emailVerified: boolean;
|
||||
|
||||
@ApiProperty({ description: 'User creator ID' })
|
||||
createdBy: string;
|
||||
|
||||
@ApiProperty({ description: 'User creator username' })
|
||||
createdByUsername: string;
|
||||
|
||||
@ApiProperty({ enum: ['HUB'], description: 'User type' })
|
||||
userType: UserType;
|
||||
|
||||
@ApiProperty({ description: 'Creation timestamp' })
|
||||
createdTimestamp: number;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Last login timestamp' })
|
||||
lastLogin?: number;
|
||||
}
|
||||
|
||||
export class MerchantUserResponse {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'Username' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'First name' })
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ description: 'Last name' })
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
|
||||
description: 'User role'
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@ApiProperty({ description: 'Whether the user is enabled' })
|
||||
enabled: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Whether the email is verified' })
|
||||
emailVerified: boolean;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Merchant partner ID' })
|
||||
merchantPartnerId?: string;
|
||||
|
||||
@ApiProperty({ description: 'User creator ID' })
|
||||
createdBy: string;
|
||||
|
||||
@ApiProperty({ description: 'User creator username' })
|
||||
createdByUsername: string;
|
||||
|
||||
@ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' })
|
||||
userType: 'HUB' | 'MERCHANT';
|
||||
|
||||
@ApiProperty({ description: 'Creation timestamp' })
|
||||
createdTimestamp: number;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Last login timestamp' })
|
||||
lastLogin?: number;
|
||||
}
|
||||
|
||||
export class HubUserProfileResponse {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'Username' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'First name' })
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ description: 'Last name' })
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether the email is verified' })
|
||||
emailVerified: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Whether the user is enabled' })
|
||||
enabled: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Client roles',
|
||||
type: [String],
|
||||
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER]
|
||||
})
|
||||
clientRoles: string[];
|
||||
|
||||
@ApiProperty({ required: false, description: 'User creator ID' })
|
||||
createdBy?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'User creator username' })
|
||||
createdByUsername?: string;
|
||||
}
|
||||
|
||||
export class MessageResponse {
|
||||
@ApiProperty({ description: 'Response message' })
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Mapper functions
|
||||
function mapToHubUserResponse(user: User): HubUserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
emailVerified: user.emailVerified,
|
||||
createdBy: user.createdBy,
|
||||
createdByUsername: user.createdByUsername,
|
||||
userType: user.userType,
|
||||
createdTimestamp: user.createdTimestamp,
|
||||
lastLogin: user.lastLogin,
|
||||
};
|
||||
}
|
||||
|
||||
// Mapper functions
|
||||
function mapToMerchantUserResponse(user: User): MerchantUserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
emailVerified: user.emailVerified,
|
||||
merchantPartnerId: user.merchantPartnerId,
|
||||
createdBy: user.createdBy,
|
||||
createdByUsername: user.createdByUsername,
|
||||
userType: user.userType,
|
||||
createdTimestamp: user.createdTimestamp,
|
||||
lastLogin: user.lastLogin,
|
||||
};
|
||||
}
|
||||
|
||||
function mapToHubUserProfileResponse(profile: any): HubUserProfileResponse {
|
||||
return {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
email: profile.email,
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
emailVerified: profile.emailVerified,
|
||||
enabled: profile.enabled,
|
||||
clientRoles: profile.clientRoles,
|
||||
createdBy: profile.createdBy,
|
||||
createdByUsername: profile.createdByUsername,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== CONTROLLER POUR LES UTILISATEURS HUB =====
|
||||
|
||||
@ApiTags('Hub Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('hub-users')
|
||||
@Resource(RESOURCES.HUB_USER)
|
||||
export class HubUsersController {
|
||||
constructor(private readonly usersService: HubUsersService) {}
|
||||
private readonly logger = new Logger(HubUsersController.name);
|
||||
|
||||
// ===== ROUTES SANS PARAMÈTRES =====
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Get all hub users',
|
||||
description: 'Returns all hub users (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Hub users retrieved successfully',
|
||||
type: [HubUserResponse]
|
||||
})
|
||||
@Scopes(SCOPES.READ)
|
||||
async getAllHubUsers(@Request() req): Promise<HubUserResponse[]> {
|
||||
const userId = req.user.sub;
|
||||
const users = await this.usersService.getAllHubUsers(userId);
|
||||
return users.map(mapToHubUserResponse);
|
||||
}
|
||||
|
||||
@Get('partners/dcb-partners')
|
||||
@ApiOperation({
|
||||
summary: 'Get all DCB_PARTNER users only',
|
||||
description: 'Returns only DCB_PARTNER users (excludes DCB_ADMIN and DCB_SUPPORT)'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'DCB_PARTNER users retrieved successfully',
|
||||
type: [HubUserResponse]
|
||||
})
|
||||
@Scopes(SCOPES.READ)
|
||||
async getAllDcbPartners(@Request() req): Promise<HubUserResponse[]> {
|
||||
const userId = req.user.sub;
|
||||
const users = await this.usersService.getAllDcbPartners(userId);
|
||||
return users.map(mapToHubUserResponse);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: 'Create a new hub user',
|
||||
description: 'Create a hub user with specific role (DCB_ADMIN, DCB_SUPPORT, DCB_PARTNER)'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Hub user created successfully',
|
||||
type: HubUserResponse
|
||||
})
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async createHubUser(
|
||||
@Body() createUserDto: CreateHubUserDto,
|
||||
@Request() req
|
||||
): Promise<HubUserResponse> {
|
||||
|
||||
// Debug complet
|
||||
this.logger.debug('🔍 === CONTROLLER - CREATE HUB USER ===');
|
||||
this.logger.debug('Request headers:', req.headers);
|
||||
this.logger.debug('Content-Type:', req.headers['content-type']);
|
||||
this.logger.debug('Raw body exists:', !!req.body);
|
||||
this.logger.debug('CreateHubUserDto received:', createUserDto);
|
||||
this.logger.debug('DTO structure:', {
|
||||
username: createUserDto.username,
|
||||
email: createUserDto.email,
|
||||
firstName: createUserDto.firstName,
|
||||
lastName: createUserDto.lastName,
|
||||
role: createUserDto.role,
|
||||
userType: createUserDto.userType,
|
||||
});
|
||||
this.logger.debug('====================================');
|
||||
|
||||
// Validation manuelle renforcée
|
||||
const requiredFields = ['username', 'email', 'firstName', 'lastName', 'password', 'role', 'userType'];
|
||||
const missingFields = requiredFields.filter(field => !createUserDto[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new BadRequestException(`Missing required fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
|
||||
if (createUserDto.userType !== UserType.HUB) {
|
||||
throw new BadRequestException('User type must be HUB for hub users');
|
||||
}
|
||||
|
||||
const userId = req.user.sub;
|
||||
|
||||
const userData: CreateUserData = {
|
||||
...createUserDto,
|
||||
};
|
||||
|
||||
this.logger.debug('UserData passed to service:', userData);
|
||||
|
||||
try {
|
||||
const user = await this.usersService.createHubUser(userId, userData);
|
||||
return mapToHubUserResponse(user);
|
||||
} catch (error) {
|
||||
this.logger.error('Error creating hub user:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// ===== ROUTES AVEC PARAMÈTRES STATIQUES =====
|
||||
|
||||
@Get('all-users')
|
||||
@ApiOperation({
|
||||
summary: 'Get global users overview',
|
||||
description: 'Returns hub users and all merchant users (Admin only)'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Global overview retrieved successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hubUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } },
|
||||
merchantUsers: { type: 'array', items: { $ref: getSchemaPath(HubUserResponse) } },
|
||||
statistics: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalHubUsers: { type: 'number' },
|
||||
totalMerchantUsers: { type: 'number' },
|
||||
totalUsers: { type: 'number' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@Scopes(SCOPES.READ)
|
||||
async getGlobalUsersOverview(@Request() req): Promise<any> {
|
||||
const userId = req.user.sub;
|
||||
|
||||
const isAdmin = await this.usersService.isUserHubAdminOrSupport(userId);
|
||||
if (!isAdmin) {
|
||||
throw new ForbiddenException('Only Hub administrators can access global overview');
|
||||
}
|
||||
|
||||
const hubUsers = await this.usersService.getAllHubUsers(userId);
|
||||
const merchantUsers = await this.usersService.getMyMerchantUsers(userId);
|
||||
|
||||
return {
|
||||
hubUsers: hubUsers.map(mapToHubUserResponse),
|
||||
merchantUsers: merchantUsers.map(mapToMerchantUserResponse),
|
||||
statistics: {
|
||||
totalHubUsers: hubUsers.length,
|
||||
totalMerchantUsers: merchantUsers.length,
|
||||
totalUsers: hubUsers.length + merchantUsers.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Get('profile/:id')
|
||||
@ApiOperation({ summary: 'Get complete user profile' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User profile retrieved successfully',
|
||||
type: HubUserProfileResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.READ)
|
||||
async getCompleteUserProfile(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req
|
||||
): Promise<HubUserProfileResponse> {
|
||||
const tokenUser = req.user;
|
||||
const profile = await this.usersService.getCompleteUserProfile(id, tokenUser);
|
||||
return mapToHubUserProfileResponse(profile);
|
||||
}
|
||||
|
||||
@Get('role/:role')
|
||||
@ApiOperation({ summary: 'Get hub users by role' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Hub users retrieved successfully',
|
||||
type: [HubUserResponse]
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'role',
|
||||
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||
description: 'User role'
|
||||
})
|
||||
@Scopes(SCOPES.READ)
|
||||
async getHubUsersByRole(
|
||||
@Param('role') role: UserRole,
|
||||
@Request() req
|
||||
): Promise<HubUserResponse[]> {
|
||||
const userId = req.user.sub;
|
||||
const users = await this.usersService.getHubUsersByRole(role, userId);
|
||||
return users.map(mapToHubUserResponse);
|
||||
}
|
||||
|
||||
// ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES =====
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get hub user by ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Hub user retrieved successfully',
|
||||
type: HubUserResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.READ)
|
||||
async getHubUserById(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req
|
||||
): Promise<HubUserResponse> {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.usersService.getHubUserById(id, userId);
|
||||
return mapToHubUserResponse(user);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a hub user' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Hub user updated successfully',
|
||||
type: HubUserResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async updateHubUser(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateUserDto: UpdateHubUserDto,
|
||||
@Request() req
|
||||
): Promise<HubUserResponse> {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.usersService.updateHubUser(id, updateUserDto, userId);
|
||||
return mapToHubUserResponse(user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a hub user' })
|
||||
@ApiResponse({ status: 200, description: 'Hub user deleted successfully' })
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.DELETE)
|
||||
async deleteHubUser(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req
|
||||
): Promise<MessageResponse> {
|
||||
const userId = req.user.sub;
|
||||
await this.usersService.deleteHubUser(id, userId);
|
||||
return { message: 'Hub user deleted successfully' };
|
||||
}
|
||||
|
||||
@Put(':id/role')
|
||||
@ApiOperation({ summary: 'Update hub user role' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User role updated successfully',
|
||||
type: HubUserResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async updateHubUserRole(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateRoleDto: UpdateHubUserRoleDto,
|
||||
@Request() req
|
||||
): Promise<HubUserResponse> {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.usersService.updateHubUserRole(id, updateRoleDto.role, userId);
|
||||
return mapToHubUserResponse(user);
|
||||
}
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Reset hub user password' })
|
||||
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async resetHubUserPassword(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() resetPasswordDto: ResetHubUserPasswordDto,
|
||||
@Request() req
|
||||
): Promise<MessageResponse> {
|
||||
const userId = req.user.sub;
|
||||
await this.usersService.resetUserPassword(
|
||||
id,
|
||||
resetPasswordDto.newPassword,
|
||||
resetPasswordDto.temporary,
|
||||
userId
|
||||
);
|
||||
return { message: 'Password reset successfully' };
|
||||
}
|
||||
}
|
||||
@ -1,479 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
ParseUUIDPipe,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
InternalServerErrorException
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
ApiProperty
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
MinLength,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
import { HubUsersService } from '../services/hub-users.service';
|
||||
import { UserRole, UserType } from '../../auth/services/keycloak-user.model';
|
||||
|
||||
import { RESOURCES } from '../../constants/resources';
|
||||
import { SCOPES } from '../../constants/scopes';
|
||||
import { Resource, Scopes } from 'nest-keycloak-connect';
|
||||
import { CreateUserData, User } from '../models/hub-user.model';
|
||||
|
||||
// ===== DTO SPÉCIFIQUES AUX MERCHANT USERS =====
|
||||
|
||||
export class CreateMerchantUserDto {
|
||||
@ApiProperty({ description: 'Username for the user' })
|
||||
@IsNotEmpty({ message: 'Username is required' })
|
||||
@IsString()
|
||||
@MinLength(3, { message: 'Username must be at least 3 characters' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address' })
|
||||
@IsNotEmpty({ message: 'Email is required' })
|
||||
@IsEmail({}, { message: 'Invalid email format' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'First name' })
|
||||
@IsNotEmpty({ message: 'First name is required' })
|
||||
@IsString()
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ description: 'Last name' })
|
||||
@IsNotEmpty({ message: 'Last name is required' })
|
||||
@IsString()
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ description: 'Password for the user' })
|
||||
@IsNotEmpty({ message: 'Password is required' })
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'Password must be at least 8 characters' })
|
||||
password: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: UserRole,
|
||||
description: 'Role for the user',
|
||||
examples: [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
|
||||
})
|
||||
@IsEnum(UserRole, { message: 'Invalid role' })
|
||||
@IsNotEmpty({ message: 'Role is required' })
|
||||
role: UserRole;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: 'Enabled must be a boolean' })
|
||||
enabled?: boolean = true;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: 'EmailVerified must be a boolean' })
|
||||
emailVerified?: boolean = true;
|
||||
|
||||
@ApiProperty({
|
||||
enum: UserType,
|
||||
description: 'Type of user',
|
||||
example: UserType.MERCHANT_PARTNER
|
||||
})
|
||||
@IsEnum(UserType, { message: 'Invalid user type' })
|
||||
@IsNotEmpty({ message: 'User type is required' })
|
||||
userType: UserType;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@ValidateIf((o) => o.userType === UserType.MERCHANT_PARTNER && o.role !== UserRole.DCB_PARTNER)
|
||||
@IsString({ message: 'Merchant partner ID must be a string' })
|
||||
merchantPartnerId?: string | null;
|
||||
}
|
||||
|
||||
export class ResetMerchantUserPasswordDto {
|
||||
@ApiProperty({ description: 'New password' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
|
||||
@ApiProperty({ required: false, default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
temporary?: boolean = true;
|
||||
}
|
||||
|
||||
export class UpdateMerchantUserDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateMerchantUserRoleDto {
|
||||
@ApiProperty({
|
||||
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||
description: 'New role for the user'
|
||||
})
|
||||
@IsEnum(UserRole, { message: 'Invalid role' })
|
||||
@IsNotEmpty({ message: 'Role is required' })
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export class MerchantUserResponse {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'Username' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'First name' })
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ description: 'Last name' })
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER, UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT],
|
||||
description: 'User role'
|
||||
})
|
||||
role: UserRole;
|
||||
|
||||
@ApiProperty({ description: 'Whether the user is enabled' })
|
||||
enabled: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Whether the email is verified' })
|
||||
emailVerified: boolean;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Merchant partner ID' })
|
||||
merchantPartnerId?: string;
|
||||
|
||||
@ApiProperty({ description: 'User creator ID' })
|
||||
createdBy: string;
|
||||
|
||||
@ApiProperty({ description: 'User creator username' })
|
||||
createdByUsername: string;
|
||||
|
||||
@ApiProperty({ enum: ['HUB', 'MERCHANT'], description: 'User type' })
|
||||
userType: 'HUB' | 'MERCHANT';
|
||||
|
||||
@ApiProperty({ description: 'Creation timestamp' })
|
||||
createdTimestamp: number;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Last login timestamp' })
|
||||
lastLogin?: number;
|
||||
}
|
||||
|
||||
export class UserProfileResponse {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'Username' })
|
||||
username: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address' })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ description: 'First name' })
|
||||
firstName: string;
|
||||
|
||||
@ApiProperty({ description: 'Last name' })
|
||||
lastName: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether the email is verified' })
|
||||
emailVerified: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Whether the user is enabled' })
|
||||
enabled: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Client roles', type: [String] })
|
||||
clientRoles: string[];
|
||||
|
||||
@ApiProperty({ required: false, description: 'Merchant partner ID' })
|
||||
merchantPartnerId?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'User creator ID' })
|
||||
createdBy?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'User creator username' })
|
||||
createdByUsername?: string;
|
||||
}
|
||||
|
||||
export class MessageResponse {
|
||||
@ApiProperty({ description: 'Response message' })
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Mapper functions
|
||||
function mapToMerchantUserResponse(user: User): MerchantUserResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
enabled: user.enabled,
|
||||
emailVerified: user.emailVerified,
|
||||
merchantPartnerId: user.merchantPartnerId,
|
||||
createdBy: user.createdBy,
|
||||
createdByUsername: user.createdByUsername,
|
||||
userType: user.userType,
|
||||
createdTimestamp: user.createdTimestamp,
|
||||
lastLogin: user.lastLogin,
|
||||
};
|
||||
}
|
||||
|
||||
function mapToUserProfileResponse(profile: any): UserProfileResponse {
|
||||
return {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
email: profile.email,
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
emailVerified: profile.emailVerified,
|
||||
enabled: profile.enabled,
|
||||
clientRoles: profile.clientRoles,
|
||||
merchantPartnerId: profile.merchantPartnerId,
|
||||
createdBy: profile.createdBy,
|
||||
createdByUsername: profile.createdByUsername,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== CONTROLLER POUR LES UTILISATEURS MERCHANT =====
|
||||
|
||||
@ApiTags('Merchant Users')
|
||||
@ApiBearerAuth()
|
||||
@Controller('merchant-users')
|
||||
@Resource(RESOURCES.MERCHANT_USER)
|
||||
export class MerchantUsersController {
|
||||
constructor(private readonly usersService: HubUsersService) {}
|
||||
|
||||
// ===== ROUTES SANS PARAMÈTRES D'ABORD =====
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Get merchant users for current user merchant',
|
||||
description: 'Returns merchant users. Hub admins/support see all merchants users, others see only their own merchant users.'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Merchant users retrieved successfully',
|
||||
type: [MerchantUserResponse]
|
||||
})
|
||||
@Scopes(SCOPES.READ)
|
||||
async getMyMerchantUsers(@Request() req): Promise<MerchantUserResponse[]> {
|
||||
const userId = req.user.sub;
|
||||
|
||||
try {
|
||||
const users = await this.usersService.getMyMerchantUsers(userId);
|
||||
return users.map(mapToMerchantUserResponse);
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestException || error instanceof ForbiddenException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException('Could not retrieve merchant users');
|
||||
}
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({
|
||||
summary: 'Create a new merchant user',
|
||||
description: 'Create a merchant user with specific role and merchant partner association'
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Merchant user created successfully',
|
||||
type: MerchantUserResponse
|
||||
})
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async createMerchantUser(
|
||||
@Body() createUserDto: CreateMerchantUserDto,
|
||||
@Request() req
|
||||
): Promise<MerchantUserResponse> {
|
||||
const userId = req.user.sub;
|
||||
|
||||
if (!createUserDto.merchantPartnerId && !createUserDto.role.includes(UserRole.DCB_PARTNER)) {
|
||||
throw new BadRequestException('merchantPartnerId is required for merchant users except DCB_PARTNER');
|
||||
}
|
||||
|
||||
const userData: CreateUserData = {
|
||||
...createUserDto,
|
||||
};
|
||||
|
||||
const user = await this.usersService.createMerchantUser(userId, userData);
|
||||
return mapToMerchantUserResponse(user);
|
||||
}
|
||||
|
||||
// ===== ROUTES AVEC PARAMÈTRES STATIQUES AVANT LES PARAMÈTRES DYNAMIQUES =====
|
||||
|
||||
@Get('profile/:id')
|
||||
@ApiOperation({ summary: 'Get complete user profile' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User profile retrieved successfully',
|
||||
type: UserProfileResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.READ)
|
||||
async getCompleteUserProfile(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req
|
||||
): Promise<UserProfileResponse> {
|
||||
const tokenUser = req.user;
|
||||
const profile = await this.usersService.getCompleteUserProfile(id, tokenUser);
|
||||
return mapToUserProfileResponse(profile);
|
||||
}
|
||||
|
||||
@Get('merchant-partner/:userId')
|
||||
@ApiOperation({ summary: 'Get merchant partner ID for a user' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Merchant partner ID retrieved successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
merchantPartnerId: { type: 'string', nullable: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiParam({ name: 'userId', description: 'User ID' })
|
||||
@Scopes(SCOPES.READ)
|
||||
async getUserMerchantPartnerId(
|
||||
@Param('userId', ParseUUIDPipe) userId: string
|
||||
): Promise<{ merchantPartnerId: string | null }> {
|
||||
const merchantPartnerId = await this.usersService.getUserMerchantPartnerId(userId);
|
||||
return { merchantPartnerId };
|
||||
}
|
||||
|
||||
// ===== ROUTES AVEC PARAMÈTRES DYNAMIQUES EN DERNIER =====
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get merchant user by ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Merchant user retrieved successfully',
|
||||
type: MerchantUserResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.READ)
|
||||
async getMerchantUserById(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req
|
||||
): Promise<MerchantUserResponse> {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.usersService.getMerchantUserById(id, userId);
|
||||
return mapToMerchantUserResponse(user);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a merchant user' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Merchant user updated successfully',
|
||||
type: MerchantUserResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async updateMerchantUser(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateUserDto: UpdateMerchantUserDto,
|
||||
@Request() req
|
||||
): Promise<MerchantUserResponse> {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.usersService.updateMerchantUser(id, updateUserDto, userId);
|
||||
return mapToMerchantUserResponse(user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a merchant user' })
|
||||
@ApiResponse({ status: 200, description: 'Merchant user deleted successfully' })
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.DELETE)
|
||||
async deleteMerchantUser(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Request() req
|
||||
): Promise<MessageResponse> {
|
||||
const userId = req.user.sub;
|
||||
await this.usersService.deleteMerchantUser(id, userId);
|
||||
return { message: 'Merchant user deleted successfully' };
|
||||
}
|
||||
|
||||
@Put(':id/role')
|
||||
@ApiOperation({ summary: 'Update merchant user role' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User role updated successfully',
|
||||
type: MerchantUserResponse
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async updateMerchantUserRole(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() updateRoleDto: UpdateMerchantUserRoleDto,
|
||||
@Request() req
|
||||
): Promise<MerchantUserResponse> {
|
||||
const userId = req.user.sub;
|
||||
const user = await this.usersService.updateMerchantUserRole(id, updateRoleDto.role, userId);
|
||||
return mapToMerchantUserResponse(user);
|
||||
}
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Reset merchant user password' })
|
||||
@ApiResponse({ status: 200, description: 'Password reset successfully' })
|
||||
@ApiParam({ name: 'id', description: 'User ID' })
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async resetMerchantUserPassword(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() resetPasswordDto: ResetMerchantUserPasswordDto,
|
||||
@Request() req
|
||||
): Promise<MessageResponse> {
|
||||
const userId = req.user.sub;
|
||||
await this.usersService.resetUserPassword(
|
||||
id,
|
||||
resetPasswordDto.newPassword,
|
||||
resetPasswordDto.temporary,
|
||||
userId
|
||||
);
|
||||
return { message: 'Password reset successfully' };
|
||||
}
|
||||
}
|
||||
@ -1,23 +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 { HubUsersService } from './services/hub-users.service'
|
||||
import { HubUsersController } from './controllers/hub-users.controller'
|
||||
import { MerchantUsersController } from './controllers/merchant-users.controller'
|
||||
import { KeycloakApiService } from '../auth/services/keycloak-api.service';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
HttpModule,
|
||||
JwtModule.register({}),
|
||||
],
|
||||
providers: [HubUsersService, KeycloakApiService, TokenService],
|
||||
controllers: [HubUsersController, MerchantUsersController ],
|
||||
exports: [HubUsersService, KeycloakApiService, TokenService, JwtModule],
|
||||
})
|
||||
export class HubUsersModule {}
|
||||
|
||||
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
// user.models.ts
|
||||
// Interfaces et Constantes Centralisées
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: UserRole;
|
||||
enabled: boolean;
|
||||
emailVerified: boolean;
|
||||
merchantPartnerId?: string;
|
||||
createdBy: string;
|
||||
createdByUsername: string;
|
||||
createdTimestamp: number;
|
||||
lastLogin?: number;
|
||||
userType: UserType;
|
||||
}
|
||||
|
||||
export interface CreateUserData {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password?: string;
|
||||
role: UserRole;
|
||||
enabled?: boolean;
|
||||
emailVerified?: boolean;
|
||||
merchantPartnerId?: string | null;
|
||||
}
|
||||
|
||||
export enum UserType {
|
||||
HUB = 'HUB',
|
||||
MERCHANT_PARTNER = 'MERCHANT'
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
// Rôles Hub (sans merchantPartnerId)
|
||||
DCB_ADMIN = 'dcb-admin',
|
||||
DCB_SUPPORT = 'dcb-support',
|
||||
DCB_PARTNER = 'dcb-partner',
|
||||
|
||||
// Rôles Merchant Partner (avec merchantPartnerId obligatoire)
|
||||
DCB_PARTNER_ADMIN = 'dcb-partner-admin',
|
||||
DCB_PARTNER_MANAGER = 'dcb-partner-manager',
|
||||
DCB_PARTNER_SUPPORT = 'dcb-partner-support'
|
||||
}
|
||||
|
||||
// DTOs pour la création
|
||||
export interface CreateUserDto {
|
||||
username: string;
|
||||
email: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
password: string;
|
||||
enabled?: boolean;
|
||||
userType: UserType;
|
||||
merchantPartnerId?: string;
|
||||
clientRoles: UserRole[];
|
||||
}
|
||||
|
||||
export interface CreateHubUserDto extends CreateUserDto {
|
||||
hubRole: UserRole.DCB_ADMIN | UserRole.DCB_SUPPORT | UserRole.DCB_PARTNER;
|
||||
}
|
||||
|
||||
export interface CreateMerchantPartnerUserDto extends CreateUserDto {
|
||||
merchantRole: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
|
||||
}
|
||||
|
||||
export interface UpdateUserDto {
|
||||
username?: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
enabled?: boolean;
|
||||
clientRoles?: UserRole[];
|
||||
}
|
||||
|
||||
export interface UserQueryDto {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
userType?: UserType;
|
||||
merchantPartnerId?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
user: User;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginatedUsersResponse {
|
||||
users: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// Pour l'authentification
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_expires_in?: number;
|
||||
scope?: string;
|
||||
}
|
||||
@ -1,641 +0,0 @@
|
||||
import { Injectable, Logger, BadRequestException, ForbiddenException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
|
||||
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||
import { CreateUserData as KeycloakCreateUserData, UserRole, KeycloakUser, LoginDto, TokenResponse, KeycloakRole } from '../../auth/services/keycloak-user.model';
|
||||
import { CreateUserData, User, UserType } from '../models/hub-user.model';
|
||||
|
||||
// Configuration Centralisée
|
||||
const SECURITY_CONFIG = {
|
||||
ROLES: {
|
||||
HUB: [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER],
|
||||
MERCHANT: [
|
||||
UserRole.DCB_PARTNER_ADMIN,
|
||||
UserRole.DCB_PARTNER_MANAGER,
|
||||
UserRole.DCB_PARTNER_SUPPORT,
|
||||
]
|
||||
},
|
||||
VALIDATION: {
|
||||
MIN_PASSWORD_LENGTH: 8,
|
||||
MAX_USERNAME_LENGTH: 50,
|
||||
MIN_USERNAME_LENGTH: 3
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class HubUsersService {
|
||||
private readonly logger = new Logger(HubUsersService.name);
|
||||
|
||||
constructor(private readonly keycloakApi: KeycloakApiService) {}
|
||||
|
||||
// === PUBLIC INTERFACE ===
|
||||
|
||||
async authenticateUser(loginDto: LoginDto): Promise<TokenResponse> {
|
||||
return this.keycloakApi.authenticateUser(loginDto.username, loginDto.password);
|
||||
}
|
||||
|
||||
async getCompleteUserProfile(userId: string, tokenUser: any) {
|
||||
try {
|
||||
const [userDetails, userRoles] = await Promise.all([
|
||||
this.keycloakApi.getUserById(userId, userId),
|
||||
this.keycloakApi.getUserClientRoles(userId)
|
||||
]);
|
||||
|
||||
return this.buildUserProfile(userId, tokenUser, userDetails, userRoles);
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException('Could not retrieve user profile');
|
||||
}
|
||||
}
|
||||
|
||||
// === HUB USERS MANAGEMENT ===
|
||||
|
||||
async getAllHubUsers(requesterId: string): Promise<User[]> {
|
||||
await this.validateHubUserAccess(requesterId);
|
||||
return this.processUsersByType(await this.keycloakApi.getAllUsers(), UserType.HUB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère uniquement les utilisateurs DCB_PARTNER
|
||||
*/
|
||||
async getAllDcbPartners(requesterId: string): Promise<User[]> {
|
||||
await this.validateHubUserAccess(requesterId);
|
||||
|
||||
const allUsers = await this.keycloakApi.getAllUsers();
|
||||
const dcbPartners: User[] = [];
|
||||
|
||||
for (const user of allUsers) {
|
||||
if (!user.id) continue;
|
||||
|
||||
try {
|
||||
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
||||
|
||||
// Vérifier si l'utilisateur est un DCB_PARTNER
|
||||
const isDcbPartner = userRoles.some(role =>
|
||||
role.name === UserRole.DCB_PARTNER
|
||||
);
|
||||
|
||||
if (isDcbPartner) {
|
||||
const mappedUser = this.mapToUser(user, userRoles);
|
||||
dcbPartners.push(mappedUser);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not process user ${user.id} for DCB_PARTNER filter: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Retrieved ${dcbPartners.length} DCB_PARTNER users`);
|
||||
|
||||
return dcbPartners;
|
||||
}
|
||||
|
||||
async getHubUserById(userId: string, requesterId: string): Promise<User> {
|
||||
await this.validateHubUserAccess(requesterId);
|
||||
return this.getValidatedUser(userId, requesterId, UserType.HUB);
|
||||
}
|
||||
|
||||
async createHubUser(creatorId: string, userData: CreateUserData): Promise<User> {
|
||||
this.validateUserCreationData(userData, UserType.HUB);
|
||||
await this.validateHubUserAccess(creatorId);
|
||||
await this.validateUserUniqueness(userData.username, userData.email);
|
||||
|
||||
const keycloakUserData = this.buildKeycloakUserData(userData);
|
||||
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
|
||||
|
||||
this.logger.log(`Hub user created: ${userData.username}`);
|
||||
return this.getHubUserById(userId, creatorId);
|
||||
}
|
||||
|
||||
async updateHubUser(
|
||||
userId: string,
|
||||
updates: Partial<Pick<User, 'firstName' | 'lastName' | 'email' | 'enabled'>>,
|
||||
requesterId: string
|
||||
): Promise<User> {
|
||||
await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => {
|
||||
await this.keycloakApi.updateUser(userId, updates, requesterId);
|
||||
});
|
||||
return this.getHubUserById(userId, requesterId);
|
||||
}
|
||||
|
||||
async deleteHubUser(userId: string, requesterId: string): Promise<void> {
|
||||
await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => {
|
||||
await this.keycloakApi.deleteUser(userId, requesterId);
|
||||
});
|
||||
this.logger.log(`Hub user deleted: ${userId} by ${requesterId}`);
|
||||
}
|
||||
|
||||
async getHubUsersByRole(role: UserRole, requesterId: string): Promise<User[]> {
|
||||
await this.validateHubUserAccess(requesterId);
|
||||
const allHubUsers = await this.getAllHubUsers(requesterId);
|
||||
return allHubUsers.filter(user => user.role === role);
|
||||
}
|
||||
|
||||
async updateHubUserRole(userId: string, newRole: UserRole, requesterId: string): Promise<User> {
|
||||
await this.validateRoleChangePermission(requesterId);
|
||||
await this.executeWithValidation(userId, requesterId, UserType.HUB, async () => {
|
||||
await this.keycloakApi.setClientRoles(userId, [newRole]);
|
||||
});
|
||||
return this.getHubUserById(userId, requesterId);
|
||||
}
|
||||
|
||||
// === MERCHANT USERS MANAGEMENT ===
|
||||
|
||||
/**
|
||||
* Vérifie si un utilisateur est Hub Admin ou Support
|
||||
*/
|
||||
async isUserHubAdminOrSupport(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const userRoles = await this.keycloakApi.getUserClientRoles(userId);
|
||||
const hubAdminSupportRoles = [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT];
|
||||
|
||||
return userRoles.some(role =>
|
||||
hubAdminSupportRoles.includes(role.name as UserRole)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error checking Hub Admin/Support status for user ${userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un utilisateur est Hub Admin ou Support
|
||||
*/
|
||||
async isUserMerchantPartner(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const userRoles = await this.keycloakApi.getUserClientRoles(userId);
|
||||
const hubMerchantPartnerRoles = [UserRole.DCB_PARTNER];
|
||||
|
||||
return userRoles.some(role =>
|
||||
hubMerchantPartnerRoles.includes(role.name as UserRole)
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error checking Merchant Partner status for user ${userId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les utilisateurs marchands selon les permissions de l'utilisateur
|
||||
* - Hub Admin/Support: tous les utilisateurs marchands de tous les merchants
|
||||
* - Autres: seulement les utilisateurs de leur propre merchant
|
||||
*/
|
||||
async getMyMerchantUsers(userId: string): Promise<User[]> {
|
||||
try {
|
||||
// Vérifier si l'utilisateur est un admin ou support Hub
|
||||
const isHubAdminOrSupport = await this.isUserHubAdminOrSupport(userId)
|
||||
|
||||
if (isHubAdminOrSupport) {
|
||||
// Hub Admin/Support peuvent voir TOUS les utilisateurs marchands
|
||||
return await this.getAllMerchantUsersForHubAdmin(userId);
|
||||
}
|
||||
|
||||
// Pour les autres utilisateurs (DCB_PARTNER, DCB_PARTNER_ADMIN, etc.)
|
||||
return await this.getUsersForMerchants(userId);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Error in getMyMerchantUsers for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère TOUS les utilisateurs marchands pour les Hub Admin/Support
|
||||
*/
|
||||
private async getAllMerchantUsersForHubAdmin(adminUserId: string): Promise<User[]> {
|
||||
this.logger.log(`Hub Admin/Support ${adminUserId} accessing ALL merchant users`);
|
||||
|
||||
// Valider que l'utilisateur a bien les droits Hub
|
||||
await this.validateHubUserAccess(adminUserId);
|
||||
|
||||
// Récupérer tous les utilisateurs du système
|
||||
const allUsers = await this.keycloakApi.getAllUsers();
|
||||
const merchantUsers: User[] = [];
|
||||
|
||||
// Filtrer pour ne garder que les utilisateurs marchands
|
||||
for (const user of allUsers) {
|
||||
if (!user.id) continue;
|
||||
|
||||
try {
|
||||
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
||||
|
||||
// Vérifier si l'utilisateur a un rôle marchand
|
||||
const hasMerchantRole = userRoles.some(role =>
|
||||
[UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT]
|
||||
.includes(role.name as UserRole)
|
||||
);
|
||||
|
||||
if (hasMerchantRole) {
|
||||
const mappedUser = this.mapToUser(user, userRoles);
|
||||
merchantUsers.push(mappedUser);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not process user ${user.id} for hub admin view: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Hub Admin/Support retrieved ${merchantUsers.length} merchant users from all merchants`);
|
||||
return merchantUsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les utilisateurs marchands pour les utilisateurs réguliers (non Hub Admin/Support)
|
||||
*/
|
||||
private async getUsersForMerchants(userId: string): Promise<User[]> {
|
||||
// Récupérer le merchantPartnerId de l'utilisateur
|
||||
let userMerchantId = await this.getUserMerchantPartnerId(userId);
|
||||
|
||||
// Vérifier si l'utilisateur est un admin ou support Hub
|
||||
const isUserMerchantPartner = await this.isUserMerchantPartner(userId);
|
||||
|
||||
if(isUserMerchantPartner){
|
||||
userMerchantId = userId;
|
||||
}
|
||||
|
||||
if (!userMerchantId) {
|
||||
throw new BadRequestException('Current user is not associated with a merchant partner');
|
||||
}
|
||||
|
||||
this.logger.log(`User ${userId} accessing merchant users for partner ${userMerchantId}`);
|
||||
|
||||
// Utiliser la méthode existante pour récupérer les utilisateurs du merchant spécifique
|
||||
const users = await this.getMerchantUsersByPartner(userMerchantId, userId);
|
||||
|
||||
this.logger.log(`User ${userId} retrieved ${users.length} merchant users for partner ${userMerchantId}`);
|
||||
return users;
|
||||
|
||||
}
|
||||
|
||||
async getMerchantUsersByPartner(merchantPartnerId: string, requesterId: string): Promise<User[]> {
|
||||
await this.keycloakApi.validateUserAccess(requesterId, merchantPartnerId);
|
||||
const allUsers = await this.keycloakApi.getAllUsers();
|
||||
|
||||
const merchantUsers = allUsers.filter(user =>
|
||||
user.attributes?.merchantPartnerId?.[0] === merchantPartnerId
|
||||
);
|
||||
|
||||
return this.processUsersByType(merchantUsers, UserType.MERCHANT_PARTNER);
|
||||
}
|
||||
|
||||
async getMerchantUserById(userId: string, requesterId: string): Promise<User> {
|
||||
return this.getValidatedUser(userId, requesterId, UserType.MERCHANT_PARTNER);
|
||||
}
|
||||
|
||||
async createMerchantUser(creatorId: string, userData: CreateUserData): Promise<User> {
|
||||
this.validateUserCreationData(userData, UserType.MERCHANT_PARTNER);
|
||||
await this.validateMerchantUserCreation(creatorId, userData);
|
||||
await this.validateUserUniqueness(userData.username, userData.email);
|
||||
|
||||
const keycloakUserData = this.buildKeycloakUserData(userData, userData.merchantPartnerId!);
|
||||
const userId = await this.keycloakApi.createUser(creatorId, keycloakUserData);
|
||||
|
||||
this.logger.log(`Merchant user created: ${userData.username}`);
|
||||
return this.getMerchantUserById(userId, creatorId);
|
||||
}
|
||||
|
||||
async updateMerchantUser(
|
||||
userId: string,
|
||||
updates: Partial<Pick<User, 'firstName' | 'lastName' | 'email' | 'enabled'>>,
|
||||
requesterId: string
|
||||
): Promise<User> {
|
||||
await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => {
|
||||
await this.keycloakApi.updateUser(userId, updates, requesterId);
|
||||
});
|
||||
return this.getMerchantUserById(userId, requesterId);
|
||||
}
|
||||
|
||||
async updateMerchantUserRole(userId: string, newRole: UserRole, requesterId: string): Promise<User> {
|
||||
await this.validateRoleChangePermission(requesterId);
|
||||
await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => {
|
||||
await this.keycloakApi.setClientRoles(userId, [newRole]);
|
||||
});
|
||||
return this.getMerchantUserById(userId, requesterId);
|
||||
}
|
||||
|
||||
async deleteMerchantUser(userId: string, requesterId: string): Promise<void> {
|
||||
await this.validateSelfDeletion(userId, requesterId);
|
||||
await this.executeWithValidation(userId, requesterId, UserType.MERCHANT_PARTNER, async () => {
|
||||
await this.keycloakApi.deleteUser(userId, requesterId);
|
||||
});
|
||||
this.logger.log(`Merchant user deleted: ${userId} by ${requesterId}`);
|
||||
}
|
||||
|
||||
// === COMMON OPERATIONS ===
|
||||
|
||||
async resetUserPassword(
|
||||
userId: string,
|
||||
newPassword: string,
|
||||
temporary: boolean = true,
|
||||
requesterId: string
|
||||
): Promise<void> {
|
||||
await this.ensureUserExists(userId, requesterId);
|
||||
await this.keycloakApi.resetUserPassword(userId, newPassword, temporary);
|
||||
this.logger.log(`Password reset for user: ${userId}`);
|
||||
}
|
||||
|
||||
async getUserMerchantPartnerId(userId: string): Promise<string | null> {
|
||||
return this.keycloakApi.getUserMerchantPartnerId(userId);
|
||||
}
|
||||
|
||||
// === PRIVATE CORE METHODS ===
|
||||
|
||||
private async getValidatedUser(
|
||||
userId: string,
|
||||
requesterId: string,
|
||||
userType: UserType.HUB | UserType.MERCHANT_PARTNER
|
||||
): Promise<User> {
|
||||
const [user, userRoles] = await Promise.all([
|
||||
this.keycloakApi.getUserById(userId, requesterId),
|
||||
this.keycloakApi.getUserClientRoles(userId)
|
||||
]);
|
||||
|
||||
this.validateUserType(userRoles, userType, userId);
|
||||
return this.mapToUser(user, userRoles);
|
||||
}
|
||||
|
||||
private async processUsersByType(users: KeycloakUser[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): Promise<User[]> {
|
||||
const result: User[] = [];
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.id) continue;
|
||||
|
||||
try {
|
||||
const userRoles = await this.keycloakApi.getUserClientRoles(user.id);
|
||||
if (this.isUserType(userRoles, userType)) {
|
||||
result.push(this.mapToUser(user, userRoles));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Could not process user ${user.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async executeWithValidation(
|
||||
userId: string,
|
||||
requesterId: string,
|
||||
userType: UserType.HUB | UserType.MERCHANT_PARTNER,
|
||||
operation: () => Promise<void>
|
||||
): Promise<void> {
|
||||
await this.getValidatedUser(userId, requesterId, userType);
|
||||
await operation();
|
||||
}
|
||||
|
||||
// === USER MAPPING AND VALIDATION ===
|
||||
|
||||
private mapToUser(user: KeycloakUser, roles: KeycloakRole[]): User {
|
||||
if (!user.id || !user.email) {
|
||||
throw new Error('User ID and email are required');
|
||||
}
|
||||
|
||||
const role = this.determineUserRole(roles);
|
||||
const userType = this.determineUserType(roles);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
role,
|
||||
enabled: user.enabled,
|
||||
emailVerified: user.emailVerified,
|
||||
merchantPartnerId: user.attributes?.merchantPartnerId?.[0],
|
||||
createdBy: user.attributes?.createdBy?.[0] || 'unknown',
|
||||
createdByUsername: user.attributes?.createdByUsername?.[0] || 'unknown',
|
||||
createdTimestamp: user.createdTimestamp || Date.now(),
|
||||
lastLogin: this.parseTimestamp(user.attributes?.lastLogin),
|
||||
userType,
|
||||
};
|
||||
}
|
||||
|
||||
private determineUserRole(roles: KeycloakRole[]): UserRole {
|
||||
const allRoles = [...SECURITY_CONFIG.ROLES.HUB, ...SECURITY_CONFIG.ROLES.MERCHANT];
|
||||
const userRole = roles.find(role => allRoles.includes(role.name as UserRole));
|
||||
|
||||
if (!userRole) {
|
||||
throw new Error('No valid role found for user');
|
||||
}
|
||||
|
||||
return userRole.name as UserRole;
|
||||
}
|
||||
|
||||
private determineUserType(roles: KeycloakRole[]): UserType {
|
||||
return roles.some(role => SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole))
|
||||
? UserType.HUB
|
||||
: UserType.MERCHANT_PARTNER;
|
||||
}
|
||||
|
||||
private isUserType(roles: KeycloakRole[], userType: UserType.HUB | UserType.MERCHANT_PARTNER): boolean {
|
||||
const targetRoles = userType === UserType.HUB
|
||||
? SECURITY_CONFIG.ROLES.HUB
|
||||
: SECURITY_CONFIG.ROLES.MERCHANT;
|
||||
|
||||
return roles.some(role => targetRoles.includes(role.name as UserRole));
|
||||
}
|
||||
|
||||
private validateUserType(roles: KeycloakRole[], expectedType: UserType.HUB | UserType.MERCHANT_PARTNER, userId: string): void {
|
||||
if (!this.isUserType(roles, expectedType)) {
|
||||
throw new BadRequestException(`User ${userId} is not a ${expectedType.toLowerCase()} user`);
|
||||
}
|
||||
}
|
||||
|
||||
// === VALIDATION METHODS ===
|
||||
|
||||
private async validateHubUserAccess(requesterId: string): Promise<void> {
|
||||
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||
const hasHubAccess = requesterRoles.some(role =>
|
||||
SECURITY_CONFIG.ROLES.HUB.includes(role.name as UserRole)
|
||||
);
|
||||
|
||||
if (!hasHubAccess) {
|
||||
throw new ForbiddenException('Only hub administrators can manage hub users');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private validateUserCreationData(userData: CreateUserData, userType: UserType.HUB | UserType.MERCHANT_PARTNER): void {
|
||||
// 🔍 DEBUG COMPLET
|
||||
this.logger.debug('🔍 === VALIDATION USER CREATION DATA ===');
|
||||
this.logger.debug('UserType:', userType);
|
||||
this.logger.debug('UserData complet:', JSON.stringify(userData, null, 2));
|
||||
this.logger.debug('userData.role:', userData.role);
|
||||
this.logger.debug('Type de role:', typeof userData.role);
|
||||
this.logger.debug('Est un tableau?:', Array.isArray(userData.role));
|
||||
this.logger.debug('Valeur brute role:', userData.role);
|
||||
|
||||
// Afficher les rôles valides configurés
|
||||
const validRoles = userType === UserType.HUB
|
||||
? SECURITY_CONFIG.ROLES.HUB
|
||||
: SECURITY_CONFIG.ROLES.MERCHANT;
|
||||
|
||||
this.logger.debug('Rôles valides pour', userType, ':', validRoles);
|
||||
this.logger.debug('merchantPartnerId:', userData.merchantPartnerId);
|
||||
this.logger.debug('====================================');
|
||||
|
||||
// Validation des rôles
|
||||
if (!validRoles.includes(userData.role)) {
|
||||
console.error(`❌ Rôle invalide: ${userData.role} pour le type ${userType}`);
|
||||
console.error(`Rôles autorisés: ${validRoles.join(', ')}`);
|
||||
throw new BadRequestException(`Invalid ${userType.toLowerCase()} role: ${userData.role}`);
|
||||
}
|
||||
|
||||
// Validation merchantPartnerId pour HUB
|
||||
if (userType === UserType.HUB && userData.merchantPartnerId) {
|
||||
console.error('❌ merchantPartnerId fourni pour un utilisateur HUB');
|
||||
throw new BadRequestException('merchantPartnerId should not be provided for hub users');
|
||||
}
|
||||
|
||||
// Validation merchantPartnerId pour MERCHANT
|
||||
// Vérifier d'abord si role est un tableau ou une valeur simple
|
||||
const isDCBPartner = Array.isArray(userData.role)
|
||||
? userData.role.includes(UserRole.DCB_PARTNER)
|
||||
: userData.role === UserRole.DCB_PARTNER;
|
||||
|
||||
this.logger.debug('Est DCB_PARTNER?:', isDCBPartner);
|
||||
|
||||
if (userType === UserType.MERCHANT_PARTNER && !userData.merchantPartnerId && !isDCBPartner) {
|
||||
console.error('❌ merchantPartnerId manquant pour un utilisateur MERCHANT');
|
||||
throw new BadRequestException('merchantPartnerId is required for merchant users');
|
||||
}
|
||||
|
||||
this.logger.debug('✅ Validation réussie');
|
||||
}
|
||||
|
||||
private async validateUserUniqueness(username: string, email: string): Promise<void> {
|
||||
const [existingUsers, existingEmails] = await Promise.all([
|
||||
this.keycloakApi.findUserByUsername(username),
|
||||
this.keycloakApi.findUserByEmail(email)
|
||||
]);
|
||||
|
||||
if (existingUsers.length > 0) {
|
||||
throw new BadRequestException(`User with username ${username} already exists`);
|
||||
}
|
||||
if (existingEmails.length > 0) {
|
||||
throw new BadRequestException(`User with email ${email} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateRoleChangePermission(requesterId: string): Promise<void> {
|
||||
const requesterRoles = await this.keycloakApi.getUserClientRoles(requesterId);
|
||||
const isRequesterAdmin = requesterRoles.some(role => role.name === UserRole.DCB_ADMIN || UserRole.DCB_PARTNER || UserRole.DCB_PARTNER_ADMIN);
|
||||
|
||||
if (!isRequesterAdmin) {
|
||||
throw new ForbiddenException('Only DCB_ADMIN can change user roles');
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSelfDeletion(userId: string, requesterId: string): Promise<void> {
|
||||
if (userId === requesterId) {
|
||||
throw new BadRequestException('Cannot delete your own account');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserExists(userId: string, requesterId: string): Promise<void> {
|
||||
try {
|
||||
await this.getHubUserById(userId, requesterId);
|
||||
} catch {
|
||||
try {
|
||||
await this.getMerchantUserById(userId, requesterId);
|
||||
} catch {
|
||||
throw new NotFoundException(`User ${userId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildUserProfile(
|
||||
userId: string,
|
||||
tokenUser: any,
|
||||
userDetails: KeycloakUser,
|
||||
userRoles: KeycloakRole[]
|
||||
) {
|
||||
return {
|
||||
id: userId,
|
||||
username: tokenUser.preferred_username,
|
||||
email: tokenUser.email,
|
||||
firstName: tokenUser.given_name,
|
||||
lastName: tokenUser.family_name,
|
||||
emailVerified: tokenUser.email_verified,
|
||||
enabled: userDetails.enabled,
|
||||
clientRoles: userRoles.map(role => role.name),
|
||||
merchantPartnerId: userDetails.attributes?.merchantPartnerId?.[0],
|
||||
createdBy: userDetails.attributes?.createdBy?.[0],
|
||||
createdByUsername: userDetails.attributes?.createdByUsername?.[0]
|
||||
};
|
||||
}
|
||||
|
||||
private buildKeycloakUserData(
|
||||
userData: CreateUserData,
|
||||
merchantPartnerId?: string
|
||||
): KeycloakCreateUserData {
|
||||
return {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
firstName: userData.firstName,
|
||||
lastName: userData.lastName,
|
||||
password: userData.password,
|
||||
enabled: userData.enabled ?? true,
|
||||
emailVerified: userData.emailVerified ?? false,
|
||||
merchantPartnerId,
|
||||
clientRoles: [userData.role]
|
||||
};
|
||||
}
|
||||
|
||||
private parseTimestamp(value: string[] | undefined): number | undefined {
|
||||
const strValue = value?.[0];
|
||||
if (!strValue) return undefined;
|
||||
|
||||
const timestamp = parseInt(strValue);
|
||||
if (!isNaN(timestamp)) return timestamp;
|
||||
|
||||
const date = new Date(strValue);
|
||||
return isNaN(date.getTime()) ? undefined : date.getTime();
|
||||
}
|
||||
|
||||
private async validateMerchantUserCreation(creatorId: string, userData: CreateUserData): Promise<void> {
|
||||
const creatorRoles = await this.keycloakApi.getUserClientRoles(creatorId);
|
||||
const creationRules = this.getMerchantCreationRules();
|
||||
|
||||
for (const rule of creationRules) {
|
||||
if (creatorRoles.some(role => role.name === rule.role)) {
|
||||
await rule.validator(creatorId, userData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier les permissions des administrateurs Hub
|
||||
if (creatorRoles.some(role => [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT].includes(role.name as UserRole))) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Insufficient permissions to create merchant users');
|
||||
}
|
||||
|
||||
private getMerchantCreationRules() {
|
||||
return [
|
||||
{
|
||||
role: UserRole.DCB_PARTNER,
|
||||
validator: async (creatorId: string, userData: CreateUserData) => {
|
||||
if (creatorId !== userData.merchantPartnerId) {
|
||||
throw new ForbiddenException('DCB_PARTNER can only create users for their own merchant');
|
||||
}
|
||||
const allowedRoles = [UserRole.DCB_PARTNER_ADMIN, UserRole.DCB_PARTNER_MANAGER, UserRole.DCB_PARTNER_SUPPORT];
|
||||
if (!allowedRoles.includes(userData.role)) {
|
||||
throw new ForbiddenException('DCB_PARTNER can only create MANAGER and SUPPORT roles');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
role: UserRole.DCB_PARTNER_ADMIN,
|
||||
validator: async (creatorId: string, userData: CreateUserData) => {
|
||||
const creatorMerchantId = await this.keycloakApi.getUserMerchantPartnerId(creatorId);
|
||||
if (creatorMerchantId !== userData.merchantPartnerId) {
|
||||
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create users for their own merchant partner');
|
||||
}
|
||||
const allowedRoles = [UserRole.DCB_PARTNER_SUPPORT];
|
||||
if (!allowedRoles.includes(userData.role)) {
|
||||
throw new ForbiddenException('DCB_PARTNER_ADMIN can only create SUPPORT roles');
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
75
src/main.ts
75
src/main.ts
@ -1,93 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import helmet from 'helmet';
|
||||
import { KeycloakExceptionFilter } from './filters/keycloak-exception.filter';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { useContainer } from 'class-validator';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const logger = new Logger('dcb-user-service');
|
||||
|
||||
useContainer(app.select(AppModule), { fallbackOnErrors: true });
|
||||
|
||||
// Middlewares de sécurité
|
||||
app.use(helmet());
|
||||
app.enableCors({
|
||||
origin: process.env.ALLOWED_ORIGINS?.split(',') || '*',
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Gestion globale des erreurs et validation
|
||||
app.enableCors();
|
||||
app.useGlobalFilters(new KeycloakExceptionFilter());
|
||||
|
||||
// ValidationPipe CORRIGÉ
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
exceptionFactory: (errors) => {
|
||||
const messages = errors.map((error) => {
|
||||
// Détails complets de l'erreur
|
||||
const constraints = error.constraints
|
||||
? Object.values(error.constraints)
|
||||
: ['Unknown validation error'];
|
||||
return {
|
||||
field: error.property,
|
||||
errors: constraints,
|
||||
value: error.value,
|
||||
children: error.children,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('🔴 VALIDATION ERRORS:', JSON.stringify(messages, null, 2));
|
||||
|
||||
return new BadRequestException({
|
||||
message: 'Validation failed',
|
||||
errors: messages,
|
||||
details:
|
||||
'Check the errors array for specific field validation issues',
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Préfixe global de l'API
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// Configuration Swagger
|
||||
// Swagger Configuration
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('DCB User Service API')
|
||||
.setDescription('API de gestion des utilisateurs pour le système DCB')
|
||||
.setVersion('1.0')
|
||||
.addTag('users', 'Gestion des Utilisateurs')
|
||||
.addTag('partners', 'Gestion des Partenaires/Marchants')
|
||||
.addBearerAuth()
|
||||
.addTag('users', 'Gestion des utilisateurs')
|
||||
.addBearerAuth()
|
||||
//.addServer('http://localhost:3000', 'Développement local')
|
||||
// .addServer('https://api.example.com', 'Production')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api-docs', app, document);
|
||||
app.getHttpAdapter().get('/api/swagger-json', (req, res) => {
|
||||
res.json(document);
|
||||
});
|
||||
app.enableCors({ origin: '*' })
|
||||
|
||||
// Démarrage du serveur
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`Application running on http://localhost:${port}`);
|
||||
logger.log(
|
||||
`Swagger documentation available at http://localhost:${port}/api-docs`,
|
||||
);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/swagger-json`);
|
||||
logger.log(`Swagger documentation available at http://localhost:${port}/api/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
396
src/users/controllers/merchants.controller.ts
Normal file
396
src/users/controllers/merchants.controller.ts
Normal file
@ -0,0 +1,396 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Logger,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
|
||||
import { UsersService } from "../services/users.service";
|
||||
import { MerchantTeamService } from "../services/merchant-team.service";
|
||||
import * as user from "../models/user";
|
||||
import { RESOURCES } from '../../constants/resouces';
|
||||
import { SCOPES } from '../../constants/scopes';
|
||||
|
||||
@Controller('merchants')
|
||||
@Resource(RESOURCES.MERCHANT)
|
||||
export class MerchantsController {
|
||||
private readonly logger = new Logger(MerchantsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly merchantTeamService: MerchantTeamService,
|
||||
) {}
|
||||
|
||||
// === CREATE USER ===
|
||||
@Post()
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async createUser(@Body() createUserDto: user.CreateUserDto): Promise<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
398
src/users/controllers/users.controller.ts
Normal file
398
src/users/controllers/users.controller.ts
Normal file
@ -0,0 +1,398 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Logger,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from "@nestjs/common";
|
||||
import { Roles, AuthenticatedUser, Resource, Scopes } from "nest-keycloak-connect";
|
||||
import { UsersService } from "../services/users.service";
|
||||
import { MerchantTeamService } from "../services/merchant-team.service";
|
||||
import * as user from "../models/user";
|
||||
import { RESOURCES } from '../../constants/resouces';
|
||||
import { SCOPES } from '../../constants/scopes';
|
||||
|
||||
@Controller('users')
|
||||
@Resource(RESOURCES.USER)
|
||||
export class UsersController {
|
||||
private readonly logger = new Logger(UsersController.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly merchantTeamService: MerchantTeamService,
|
||||
) {}
|
||||
|
||||
// === CREATE USER ===
|
||||
@Post()
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async createUser(@Body() createUserDto: user.CreateUserDto): Promise<user.UserResponse> {
|
||||
this.logger.log(`Creating new user: ${createUserDto.username}`);
|
||||
try {
|
||||
const createdUser = await this.usersService.createUser(createUserDto);
|
||||
return createdUser;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to create user: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to create user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === GET ALL USERS ===
|
||||
@Get()
|
||||
@Scopes(SCOPES.READ)
|
||||
async findAllUsers(@Query() query: user.UserQueryDto): Promise<user.PaginatedUserResponse> {
|
||||
this.logger.log('Fetching users list');
|
||||
try {
|
||||
const result = await this.usersService.findAllUsers(query);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to fetch users: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to fetch users", HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// === GET USER BY ID ===
|
||||
@Get(':id')
|
||||
@Scopes(SCOPES.READ)
|
||||
async getUserById(@Param('id') id: string): Promise<user.UserResponse> {
|
||||
this.logger.log(`Fetching user by ID: ${id}`);
|
||||
try {
|
||||
const user = await this.usersService.getUserById(id);
|
||||
return user;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to fetch user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "User not found", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
// === GET CURRENT USER PROFILE ===
|
||||
@Get('profile/me')
|
||||
@Scopes(SCOPES.READ)
|
||||
async getCurrentUserProfile(@AuthenticatedUser() user: any): Promise<user.UserResponse> {
|
||||
this.logger.log(`User ${user.sub} accessing own profile`);
|
||||
try {
|
||||
const userProfile = await this.usersService.getUserProfile(user);
|
||||
return userProfile;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to fetch user profile: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to fetch user profile", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
// === UPDATE USER ===
|
||||
@Put(':id')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async updateUser(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: user.UpdateUserDto
|
||||
): Promise<user.UserResponse> {
|
||||
this.logger.log(`Updating user: ${id}`);
|
||||
try {
|
||||
const updatedUser = await this.usersService.updateUser(id, updateUserDto);
|
||||
return updatedUser;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to update user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to update user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === UPDATE CURRENT USER PROFILE ===
|
||||
@Put('profile/me')
|
||||
async updateCurrentUserProfile(
|
||||
@AuthenticatedUser() user: any,
|
||||
@Body() updateUserDto: user.UpdateUserDto
|
||||
): Promise<user.UserResponse> {
|
||||
this.logger.log(`User ${user.sub} updating own profile`);
|
||||
try {
|
||||
// Un utilisateur ne peut mettre à jour que son propre profil
|
||||
const updatedUser = await this.usersService.updateUser(user.sub, updateUserDto);
|
||||
return updatedUser;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to update user profile: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to update user profile", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === DELETE USER ===
|
||||
@Delete(':id')
|
||||
@Scopes(SCOPES.DELETE)
|
||||
async deleteUser(@Param('id') id: string): Promise<{ message: string }> {
|
||||
this.logger.log(`Deleting user: ${id}`);
|
||||
try {
|
||||
await this.usersService.deleteUser(id);
|
||||
return { message: `User ${id} deleted successfully` };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to delete user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to delete user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === RESET PASSWORD ===
|
||||
@Put(':id/password')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async resetPassword(
|
||||
@Param('id') id: string,
|
||||
@Body() resetPasswordDto: user.ResetPasswordDto
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.log(`Resetting password for user: ${id}`);
|
||||
try {
|
||||
await this.usersService.resetPassword(resetPasswordDto);
|
||||
return { message: 'Password reset successfully' };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to reset password for user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to reset password", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === ENABLE USER ===
|
||||
@Put(':id/enable')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async enableUser(@Param('id') id: string): Promise<{ message: string }> {
|
||||
this.logger.log(`Enabling user: ${id}`);
|
||||
try {
|
||||
await this.usersService.enableUser(id);
|
||||
return { message: 'User enabled successfully' };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to enable user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to enable user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === DISABLE USER ===
|
||||
@Put(':id/disable')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async disableUser(@Param('id') id: string): Promise<{ message: string }> {
|
||||
this.logger.log(`Disabling user: ${id}`);
|
||||
try {
|
||||
await this.usersService.disableUser(id);
|
||||
return { message: 'User disabled successfully' };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to disable user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to disable user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === CHECK USER EXISTS ===
|
||||
@Get('check/:username')
|
||||
@Scopes(SCOPES.READ)
|
||||
async userExists(@Param('username') username: string): Promise<{ exists: boolean }> {
|
||||
this.logger.log(`Checking if user exists: ${username}`);
|
||||
try {
|
||||
const exists = await this.usersService.userExists(username);
|
||||
return { exists };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to check if user exists ${username}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to check user existence", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === SEARCH USERS BY USERNAME ===
|
||||
@Get('search/username/:username')
|
||||
@Scopes(SCOPES.READ)
|
||||
async findUserByUsername(@Param('username') username: string): Promise<user.UserResponse[]> {
|
||||
this.logger.log(`Searching users by username: ${username}`);
|
||||
try {
|
||||
const users = await this.usersService.findUserByUsername(username);
|
||||
return users;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to search users by username ${username}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === SEARCH USERS BY EMAIL ===
|
||||
@Get('search/email/:email')
|
||||
@Scopes(SCOPES.READ)
|
||||
async findUserByEmail(@Param('email') email: string): Promise<user.UserResponse[]> {
|
||||
this.logger.log(`Searching users by email: ${email}`);
|
||||
try {
|
||||
const users = await this.usersService.findUserByEmail(email);
|
||||
return users;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to search users by email ${email}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to search users", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === GET USER ROLES ===
|
||||
@Get(':id/roles')
|
||||
@Scopes(SCOPES.READ)
|
||||
async getUserClientRoles(@Param('id') id: string): Promise<{ roles: string[] }> {
|
||||
this.logger.log(`Fetching roles for user: ${id}`);
|
||||
try {
|
||||
const roles = await this.usersService.getUserClientRoles(id);
|
||||
return { roles };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to fetch roles for user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to fetch user roles", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === ASSIGN CLIENT ROLES TO USER ===
|
||||
@Put(':id/roles')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async assignClientRoles(
|
||||
@Param('id') id: string,
|
||||
@Body() assignRolesDto: { roles: string[] }
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.log(`Assigning roles to user: ${id}`, assignRolesDto.roles);
|
||||
try {
|
||||
await this.usersService.assignClientRoles(id, assignRolesDto.roles);
|
||||
return { message: 'Roles assigned successfully' };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to assign roles to user ${id}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to assign roles", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// === ROUTES SPECIFIQUES POUR LES MARCHANDS ===
|
||||
// =============================================
|
||||
|
||||
// === CREER UN UTILISATEUR DANS L'EQUIPE MARCHAND ===
|
||||
@Post('merchant/team')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async createMerchantTeamUser(
|
||||
@AuthenticatedUser() user: any,
|
||||
@Body() createMerchantUserDto: any
|
||||
): Promise<any> {
|
||||
this.logger.log(`Merchant ${user.sub} creating team user: ${createMerchantUserDto.username}`);
|
||||
try {
|
||||
const createdUser = await this.merchantTeamService.createMerchantUser(
|
||||
createMerchantUserDto,
|
||||
user.sub
|
||||
);
|
||||
return createdUser;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to create merchant team user: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to create merchant team user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === OBTENIR L'EQUIPE DU MARCHAND ===
|
||||
@Get('merchant/team')
|
||||
@Scopes(SCOPES.READ)
|
||||
async getMerchantTeam(@AuthenticatedUser() user: any): Promise<any[]> {
|
||||
this.logger.log(`Merchant ${user.sub} fetching team`);
|
||||
try {
|
||||
const team = await this.merchantTeamService.getMerchantTeam(user.sub);
|
||||
return team;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to fetch merchant team: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to fetch merchant team", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === METTRE A JOUR UN MEMBRE DE L'EQUIPE ===
|
||||
@Put('merchant/team/:userId')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async updateMerchantTeamUser(
|
||||
@AuthenticatedUser() user: any,
|
||||
@Param('userId') userId: string,
|
||||
@Body() updateData: any
|
||||
): Promise<any> {
|
||||
this.logger.log(`Merchant ${user.sub} updating team user: ${userId}`);
|
||||
try {
|
||||
const updatedUser = await this.merchantTeamService.updateMerchantUser(
|
||||
userId,
|
||||
updateData,
|
||||
user.sub
|
||||
);
|
||||
return updatedUser;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to update merchant team user ${userId}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to update merchant team user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === RETIRER UN MEMBRE DE L'EQUIPE ===
|
||||
@Delete('merchant/team/:userId')
|
||||
@Scopes(SCOPES.DELETE)
|
||||
async removeMerchantTeamUser(
|
||||
@AuthenticatedUser() user: any,
|
||||
@Param('userId') userId: string
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.log(`Merchant ${user.sub} removing team user: ${userId}`);
|
||||
try {
|
||||
await this.merchantTeamService.removeMerchantUser(userId, user.sub);
|
||||
return { message: 'Team member removed successfully' };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to remove merchant team user ${userId}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to remove merchant team user", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === AJOUTER UN ROLE A UN MEMBRE ===
|
||||
@Post('merchant/team/:userId/roles/:role')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async addMerchantRole(
|
||||
@AuthenticatedUser() user: any,
|
||||
@Param('userId') userId: string,
|
||||
@Param('role') role: string
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.log(`Merchant ${user.sub} adding role ${role} to user ${userId}`);
|
||||
try {
|
||||
const result = await this.merchantTeamService.addMerchantRole(userId, role, user.sub);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to add merchant role to user ${userId}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to add merchant role", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === RETIRER UN ROLE D'UN MEMBRE ===
|
||||
@Delete('merchant/team/:userId/roles/:role')
|
||||
@Scopes(SCOPES.WRITE)
|
||||
async removeMerchantRole(
|
||||
@AuthenticatedUser() user: any,
|
||||
@Param('userId') userId: string,
|
||||
@Param('role') role: string
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.log(`Merchant ${user.sub} removing role ${role} from user ${userId}`);
|
||||
try {
|
||||
const result = await this.merchantTeamService.removeMerchantRole(userId, role, user.sub);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to remove merchant role from user ${userId}: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to remove merchant role", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === OBTENIR LES ROLES MARCHANDS DISPONIBLES ===
|
||||
@Get('merchant/roles/available')
|
||||
@Scopes(SCOPES.READ)
|
||||
async getAvailableMerchantRoles(): Promise<{ roles: string[] }> {
|
||||
this.logger.log('Fetching available merchant roles');
|
||||
try {
|
||||
const roles = this.merchantTeamService.getAvailableMerchantRoles();
|
||||
return { roles };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to fetch available merchant roles: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to fetch available merchant roles", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// === VERIFIER SI UN UTILISATEUR EST DANS L'EQUIPE ===
|
||||
@Get('merchant/team/:userId/check')
|
||||
@Scopes(SCOPES.READ)
|
||||
async checkUserInTeam(
|
||||
@AuthenticatedUser() user: any,
|
||||
@Param('userId') userId: string
|
||||
): Promise<{ isInTeam: boolean }> {
|
||||
this.logger.log(`Checking if user ${userId} is in merchant ${user.sub} team`);
|
||||
try {
|
||||
const isInTeam = await this.merchantTeamService.isUserInMerchantTeam(userId, user.sub);
|
||||
return { isInTeam };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to check team membership: ${error.message}`);
|
||||
throw new HttpException(error.message || "Failed to check team membership", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/users/models/merchant.ts
Normal file
201
src/users/models/merchant.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength } from 'class-validator';
|
||||
|
||||
export class MerchanUser {
|
||||
id?: string;
|
||||
username: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
enabled?: boolean = true;
|
||||
emailVerified?: boolean = false;
|
||||
attributes?: Record<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;
|
||||
}
|
||||
198
src/users/models/user.ts
Normal file
198
src/users/models/user.ts
Normal file
@ -0,0 +1,198 @@
|
||||
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;
|
||||
}
|
||||
352
src/users/services/merchant-team.service.ts
Normal file
352
src/users/services/merchant-team.service.ts
Normal file
@ -0,0 +1,352 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { KeycloakApiService } from '../../auth/services/keycloak-api.service';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
import {
|
||||
CreateMerchantUserDto,
|
||||
UpdateMerchantUserDto,
|
||||
MerchantUserResponse,
|
||||
PaginatedMerchantUserResponse,
|
||||
ResetPasswordDto,
|
||||
MerchantUserQueryDto,
|
||||
LoginDto,
|
||||
TokenResponse,
|
||||
MerchantRole
|
||||
} from '../models/merchant';
|
||||
|
||||
@Injectable()
|
||||
export class MerchantTeamService {
|
||||
private readonly logger = new Logger(MerchantTeamService.name);
|
||||
|
||||
// Rôles spécifiques aux équipes de marchands
|
||||
private readonly availableMerchantRoles = [
|
||||
'merchant-admin',
|
||||
'merchant-manager',
|
||||
'merchant-support',
|
||||
'merchant-user'
|
||||
];
|
||||
|
||||
constructor(
|
||||
private readonly keycloakApi: KeycloakApiService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
// === VALIDATION DES ROLES MARCHAND ===
|
||||
private validateMerchantRole(role: string): MerchantRole {
|
||||
const validRoles: MerchantRole[] = ['merchant-admin', 'merchant-manager', 'merchant-support'];
|
||||
|
||||
if (!validRoles.includes(role as MerchantRole)) {
|
||||
throw new BadRequestException(`Invalid client role: ${role}. Valid roles are: ${validRoles.join(', ')}`);
|
||||
}
|
||||
return role as MerchantRole;
|
||||
}
|
||||
|
||||
private validateMerchantRoles(roles: string[]): MerchantRole[] {
|
||||
return roles.map(role => this.validateMerchantRole(role));
|
||||
}
|
||||
|
||||
// === VERIFICATION DES DROITS D'ACCÈS ===
|
||||
private async validateMerchantAccess(targetUserId: string | null, requestingUserId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const requestingUserRoles = await this.usersService.getUserClientRoles(requestingUserId);
|
||||
|
||||
// Les admins peuvent tout faire
|
||||
if (requestingUserRoles.includes('admin')) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Vérifier que le demandeur est un marchand
|
||||
if (!requestingUserRoles.includes('merchant')) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Access denied: Merchant role required'
|
||||
};
|
||||
}
|
||||
|
||||
// Pour les opérations sur un utilisateur spécifique
|
||||
if (targetUserId) {
|
||||
const targetOwnerId = await this.usersService.getMerchantOwner(targetUserId);
|
||||
|
||||
// Si l'utilisateur cible n'a pas de merchantOwnerId, c'est un marchand principal
|
||||
if (!targetOwnerId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Access denied: Cannot modify principal merchant accounts'
|
||||
};
|
||||
}
|
||||
|
||||
// Vérifier que le demandeur est bien le propriétaire
|
||||
if (targetOwnerId !== requestingUserId) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Access denied: Can only manage your own team members'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to validate access: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// === CREATION D'UTILISATEUR MARCHAND ===
|
||||
async createMerchantUser(
|
||||
merchantData: CreateMerchantUserDto,
|
||||
createdByUserId: string
|
||||
): Promise<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
402
src/users/services/users.service.ts
Normal file
402
src/users/services/users.service.ts
Normal file
@ -0,0 +1,402 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/users/users.module.ts
Normal file
24
src/users/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 { 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