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

This commit is contained in:
diallolatoile 2025-11-03 17:36:59 +00:00
parent 84824e7c28
commit 13a317aab0
80 changed files with 9261 additions and 3392 deletions

33
package-lock.json generated
View File

@ -32,6 +32,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"choices.js": "^11.1.0", "choices.js": "^11.1.0",
"class-validator": "^0.14.2",
"datatables": "^1.10.18", "datatables": "^1.10.18",
"datatables.net": "^2.3.4", "datatables.net": "^2.3.4",
"datatables.net-bs5": "^2.3.4", "datatables.net-bs5": "^2.3.4",
@ -4376,6 +4377,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/validator": {
"version": "13.15.4",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.4.tgz",
"integrity": "sha512-LSFfpSnJJY9wbC0LQxgvfb+ynbHftFo0tMsFOl/J4wexLnYMmDSPaj2ZyDv3TkfL1UePxPrxOWJfbiRS8mQv7A==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-basic-ssl": { "node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz",
@ -5017,6 +5024,17 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/class-validator": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz",
"integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==",
"license": "MIT",
"dependencies": {
"@types/validator": "^13.11.8",
"libphonenumber-js": "^1.11.1",
"validator": "^13.9.0"
}
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@ -7589,6 +7607,12 @@
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/libphonenumber-js": {
"version": "1.12.25",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.25.tgz",
"integrity": "sha512-u90tUu/SEF8b+RaDKCoW7ZNFDakyBtFlX1ex3J+VH+ElWes/UaitJLt/w4jGu8uAE41lltV/s+kMVtywcMEg7g==",
"license": "MIT"
},
"node_modules/lie": { "node_modules/lie": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@ -10599,6 +10623,15 @@
"node": "^18.17.0 || >=20.5.0" "node": "^18.17.0 || >=20.5.0"
} }
}, },
"node_modules/validator": {
"version": "13.15.20",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
"integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@ -35,6 +35,7 @@
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"choices.js": "^11.1.0", "choices.js": "^11.1.0",
"class-validator": "^0.14.2",
"datatables": "^1.10.18", "datatables": "^1.10.18",
"datatables.net": "^2.3.4", "datatables.net": "^2.3.4",
"datatables.net-bs5": "^2.3.4", "datatables.net-bs5": "^2.3.4",

View File

@ -1,37 +1,42 @@
import { Routes } from '@angular/router' import { Routes } from '@angular/router';
import { VerticalLayout } from '@layouts/vertical-layout/vertical-layout' import { VerticalLayout } from '@layouts/vertical-layout/vertical-layout';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
// Redirection racine
{ path: '', redirectTo: '/dcb-dashboard', pathMatch: 'full' }, { path: '', redirectTo: '/dcb-dashboard', pathMatch: 'full' },
// Routes publiques (auth) // ===== ROUTES D'ERREUR (publiques) =====
{ {
path: '', path: 'error',
loadChildren: () => loadChildren: () => import('./modules/auth/error/error.routes').then(mod => mod.ERROR_PAGES_ROUTES),
import('./modules/auth/auth.route').then(mod => mod.Auth_ROUTES),
}, },
// Routes d'erreur (publiques) // ===== ROUTES PUBLIQUES =====
{ {
path: '', path: 'auth',
loadChildren: () => loadChildren: () => import('./modules/auth/auth.routes').then(mod => mod.AUTH_ROUTES),
import('./modules/auth/error/error.route').then(mod => mod.ERROR_PAGES_ROUTES),
}, },
// Routes protégées - SANS guards au niveau parent // ===== ROUTES PROTÉGÉES - Layout Principal =====
{ {
path: '', path: '',
component: VerticalLayout, component: VerticalLayout,
loadChildren: () => canActivate: [authGuard],
import('./modules/modules.routes').then( children: [
m => m.ModulesRoutes {
), path: '',
loadChildren: () => import('./modules/modules.routes').then(m => m.ModulesRoutes),
}
]
}, },
// Redirections pour les erreurs courantes // ===== REDIRECTIONS POUR LES ERREURS =====
{ path: '404', redirectTo: '/error/404' }, { path: '404', redirectTo: '/error/404' },
{ path: '403', redirectTo: '/error/403' }, { path: '403', redirectTo: '/error/403' },
{ path: '401', redirectTo: '/auth/sign-in' },
{ path: 'unauthorized', redirectTo: '/error/403' },
// Catch-all // ===== CATCH-ALL =====
{ path: '**', redirectTo: '/error/404' }, { path: '**', redirectTo: '/error/404' },
]; ];

View File

@ -1,20 +1,126 @@
// src/app/core/guards/auth.guard.ts
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { RoleService } from '../services/role.service';
import { map, catchError, of, tap, switchMap } from 'rxjs';
export const authGuard: CanActivateFn = (route, state) => { export const authGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const authService = inject(AuthService); const authService = inject(AuthService);
const roleService = inject(RoleService);
const router = inject(Router); const router = inject(Router);
console.log('Guard check for:', state.url, 'isAuthenticated:', authService.isAuthenticated()); console.log('🔐 AuthGuard check for:', state.url);
if (authService.isAuthenticated()) { // Attendre que l'initialisation soit terminée
return authService.getInitializedState().pipe(
switchMap(initialized => {
if (!initialized) {
console.log('⏳ AuthService pas encore initialisé, attente...');
return of(false); // Bloquer en attendant l'initialisation
}
// Vérifier l'authentification
if (authService.isAuthenticated()) {
return of(checkRoleAccess(route, roleService, router, state.url));
}
// Tentative de rafraîchissement du token
const refreshToken = authService.getRefreshToken();
if (refreshToken) {
console.log('🔄 Token expiré, tentative de rafraîchissement...');
return authService.refreshToken().pipe(
tap(() => {
console.log('✅ Token rafraîchi avec succès');
roleService.refreshRoles();
}),
map(() => checkRoleAccess(route, roleService, router, state.url)),
catchError((error) => {
console.error('❌ Échec du rafraîchissement du token:', error);
authService.logout().subscribe();
return of(redirectToLogin(router, state.url, 'session_expired'));
})
);
}
// Redirection vers login
console.log('🔒 Redirection vers login depuis:', state.url);
return of(redirectToLogin(router, state.url, 'not_authenticated'));
}),
catchError(error => {
console.error('❌ Erreur dans le guard d\'auth:', error);
return of(redirectToLogin(router, state.url, 'not_authenticated'));
})
);
}
/**
* Vérifie l'accès basé sur les rôles requis
*/
function checkRoleAccess(
route: ActivatedRouteSnapshot,
roleService: RoleService,
router: Router,
currentUrl: string
): boolean {
const requiredRoles = route.data?.['roles'] as string[];
if (!requiredRoles || requiredRoles.length === 0) {
console.log('✅ Accès autorisé (aucun rôle requis):', currentUrl);
return true; return true;
} }
// Rediriger vers login avec l'URL de retour const hasRequiredRole = roleService.hasAnyRole(requiredRoles);
router.navigate(['/auth/sign-in'], { const currentUserRoles = roleService.getCurrentUserRoles();
queryParams: { returnUrl: state.url }
if (hasRequiredRole) {
console.log('✅ Accès autorisé avec rôles:', currentUrl);
console.log(' Rôles requis:', requiredRoles);
console.log(' Rôles actuels:', currentUserRoles);
return true;
}
console.warn('❌ Accès refusé: rôles insuffisants');
console.warn(' URL demandée:', currentUrl);
console.warn(' Rôles requis:', requiredRoles);
console.warn(' Rôles actuels:', currentUserRoles);
// Rediriger vers la page non autorisée
router.navigate(['/unauthorized'], {
queryParams: {
requiredRoles: requiredRoles.join(','),
currentRoles: currentUserRoles.join(','),
attemptedUrl: currentUrl
},
replaceUrl: true
}); });
return false; return false;
}; }
/**
* Redirige vers la page de login avec les paramètres appropriés
*/
function redirectToLogin(
router: Router,
returnUrl: string,
reason: 'not_authenticated' | 'session_expired'
): boolean {
const queryParams: any = {
returnUrl: returnUrl,
reason: reason
};
// Message spécifique selon la raison
if (reason === 'session_expired') {
queryParams.message = 'Votre session a expiré. Veuillez vous reconnecter.';
}
router.navigate(['/auth/sign-in'], {
queryParams,
replaceUrl: true
});
return false;
}

View File

@ -0,0 +1,40 @@
// src/app/core/guards/public.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { map, catchError, of } from 'rxjs';
export const publicGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
// Si l'utilisateur est déjà authentifié, le rediriger vers le dashboard
if (authService.isAuthenticated()) {
console.log('🔄 Utilisateur déjà authentifié, redirection vers le dashboard');
router.navigate(['/dcb-dashboard'], { replaceUrl: true });
return false;
}
// Vérifier si un refresh token est disponible
const refreshToken = authService.getRefreshToken();
if (refreshToken) {
console.log('🔄 Token de rafraîchissement détecté, tentative de reconnexion...');
return authService.refreshToken().pipe(
map(() => {
console.log('✅ Reconnexion automatique réussie');
router.navigate(['/dcb-dashboard'], { replaceUrl: true });
return false;
}),
catchError((error) => {
console.error('❌ Échec de la reconnexion automatique:', error);
// En cas d'erreur, autoriser l'accès à la page publique
return of(true);
})
);
}
// L'utilisateur n'est pas connecté, autoriser l'accès à la page publique
console.log('🌐 Accès public autorisé');
return true;
};

View File

@ -11,8 +11,11 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) =
// Vérifier d'abord l'authentification // Vérifier d'abord l'authentification
if (!authService.isAuthenticated()) { if (!authService.isAuthenticated()) {
console.log('RoleGuard: User not authenticated, redirecting to login'); console.log('RoleGuard: User not authenticated, redirecting to login');
router.navigate(['/auth/sign-in'], { router.navigate(['/auth/login'], {
queryParams: { returnUrl: state.url } queryParams: {
returnUrl: state.url,
reason: 'not_authenticated'
}
}); });
return false; return false;
} }
@ -22,13 +25,15 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) =
if (!userRoles || userRoles.length === 0) { if (!userRoles || userRoles.length === 0) {
console.warn('RoleGuard: User has no roles'); console.warn('RoleGuard: User has no roles');
router.navigate(['/unauthorized']); router.navigate(['/unauthorized'], {
queryParams: { reason: 'no_roles' }
});
return false; return false;
} }
const modulePath = getModulePath(route); const modulePath = getModulePath(route);
console.log('RoleGuard check:', { console.log('🔐 RoleGuard check:', {
module: modulePath, module: modulePath,
userRoles: userRoles, userRoles: userRoles,
url: state.url url: state.url
@ -38,12 +43,18 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) =
const hasAccess = permissionsService.canAccessModule(modulePath, userRoles); const hasAccess = permissionsService.canAccessModule(modulePath, userRoles);
if (!hasAccess) { if (!hasAccess) {
console.warn('RoleGuard: Access denied for', modulePath, 'User roles:', userRoles); console.warn('❌ RoleGuard: Access denied for', modulePath, 'User roles:', userRoles);
router.navigate(['/unauthorized']); router.navigate(['/unauthorized'], {
queryParams: {
module: modulePath,
userRoles: userRoles.join(','),
requiredRoles: getRequiredRolesForModule(permissionsService, modulePath).join(',')
}
});
return false; return false;
} }
console.log('RoleGuard: Access granted for', modulePath); console.log('RoleGuard: Access granted for', modulePath);
return true; return true;
}; };
@ -78,4 +89,12 @@ function buildPathFromUrl(route: ActivatedRouteSnapshot): string {
} }
return segments.join('/'); return segments.join('/');
}
function getRequiredRolesForModule(permissionsService: PermissionsService, modulePath: string): string[] {
// Cette fonction récupère les rôles requis pour un module donné
// Vous devrez peut-être l'adapter selon votre implémentation PermissionsService
const [mainModule] = modulePath.split('/');
const permission = (permissionsService as any).findPermission?.(mainModule);
return permission?.roles || [];
} }

View File

@ -0,0 +1,48 @@
// src/app/core/services/api.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '@environments/environment';
@Injectable({
providedIn: 'root'
})
export class ApiService {
private readonly http = inject(HttpClient);
protected get<T>(endpoint: string, params?: any): Observable<T> {
return this.http.get<T>(`${environment.iamApiUrl}/${endpoint}`, {
params: this.createParams(params)
});
}
protected post<T>(endpoint: string, data: any): Observable<T> {
return this.http.post<T>(`${environment.iamApiUrl}/${endpoint}`, data);
}
protected put<T>(endpoint: string, data: any): Observable<T> {
return this.http.put<T>(`${environment.iamApiUrl}/${endpoint}`, data);
}
protected patch<T>(endpoint: string, data: any): Observable<T> {
return this.http.patch<T>(`${environment.iamApiUrl}/${endpoint}`, data);
}
protected delete<T>(endpoint: string): Observable<T> {
return this.http.delete<T>(`${environment.iamApiUrl}/${endpoint}`);
}
private createParams(params: any): HttpParams {
let httpParams = new HttpParams();
if (params) {
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== undefined) {
httpParams = httpParams.set(key, params[key].toString());
}
});
}
return httpParams;
}
}

View File

@ -1,6 +1,7 @@
// src/app/core/interceptors/auth.interceptor.ts
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpErrorResponse } from '@angular/common/http'; import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service'; import { AuthService, LoginResponseDto } from '../services/auth.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs'; import { catchError, switchMap, throwError } from 'rxjs';
@ -8,21 +9,19 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService); const authService = inject(AuthService);
const router = inject(Router); const router = inject(Router);
// On ignore les requêtes d'authentification // Exclusion des endpoints d'authentification
if (isAuthRequest(req)) { if (isAuthRequest(req)) {
return next(req); return next(req);
} }
const token = authService.getToken(); const token = authService.getAccessToken();
// On ajoute le token uniquement si c'est une requête API et que le token existe
if (token && isApiRequest(req)) { if (token && isApiRequest(req)) {
const cloned = addToken(req, token); const cloned = addToken(req, token);
return next(cloned).pipe( return next(cloned).pipe(
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status === 401) { if (error.status === 401 && !req.url.includes('/auth/refresh')) {
// Token expiré, on tente de le rafraîchir
return handle401Error(authService, router, req, next); return handle401Error(authService, router, req, next);
} }
return throwError(() => error); return throwError(() => error);
@ -33,7 +32,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
return next(req); return next(req);
}; };
// Ajoute le token à la requête // === FONCTIONS UTILITAIRES ===
function addToken(req: HttpRequest<any>, token: string): HttpRequest<any> { function addToken(req: HttpRequest<any>, token: string): HttpRequest<any> {
return req.clone({ return req.clone({
setHeaders: { setHeaders: {
@ -42,7 +42,6 @@ function addToken(req: HttpRequest<any>, token: string): HttpRequest<any> {
}); });
} }
// Gère les erreurs 401 (token expiré)
function handle401Error( function handle401Error(
authService: AuthService, authService: AuthService,
router: Router, router: Router,
@ -50,32 +49,28 @@ function handle401Error(
next: HttpHandlerFn next: HttpHandlerFn
) { ) {
return authService.refreshToken().pipe( return authService.refreshToken().pipe(
switchMap((response: any) => { switchMap((response: LoginResponseDto) => {
// Nouveau token obtenu, on relance la requête originale const newRequest = addToken(req, response.access_token);
const newToken = response.access_token;
const newRequest = addToken(req, newToken);
return next(newRequest); return next(newRequest);
}), }),
catchError((refreshError) => { catchError((refreshError) => {
// Échec du rafraîchissement, on déconnecte authService.logout().subscribe();
authService.logout();
router.navigate(['/auth/sign-in']); router.navigate(['/auth/sign-in']);
return throwError(() => refreshError); return throwError(() => refreshError);
}) })
); );
} }
// Détermine si c'est une requête vers le backend API
function isApiRequest(req: HttpRequest<any>): boolean { function isApiRequest(req: HttpRequest<any>): boolean {
return req.url.includes('/api/') || req.url.includes('/auth/'); return req.url.includes('/api/') || req.url.includes('/auth/');
} }
// Liste des endpoints où le token ne doit pas être ajouté
function isAuthRequest(req: HttpRequest<any>): boolean { function isAuthRequest(req: HttpRequest<any>): boolean {
const url = req.url; const authEndpoints = [
return ( '/auth/login',
url.includes('/auth/login') || '/auth/refresh',
url.endsWith('/auth/refresh') || '/auth/logout'
url.endsWith('/auth/logout') ];
);
} return authEndpoints.some(endpoint => req.url.includes(endpoint));
}

View File

@ -1,282 +1,371 @@
// src/app/core/services/auth.service.ts
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { environment } from '@environments/environment'; import { environment } from '@environments/environment';
import { BehaviorSubject, tap, catchError, Observable, throwError, map } from 'rxjs'; import { BehaviorSubject, Observable, throwError, tap, catchError, map, of } from 'rxjs';
import { jwtDecode } from "jwt-decode";
interface DecodedToken { // Interfaces pour les DTOs de l'API
exp: number; export interface LoginDto {
iat?: number; username: string;
sub?: string; password: string;
preferred_username?: string;
email?: string;
given_name?: string;
family_name?: string;
resource_access?: {
[key: string]: {
roles: string[];
};
};
} }
export interface AuthResponse { export interface RefreshTokenDto {
refresh_token: string;
}
export interface LoginResponseDto {
access_token: string; access_token: string;
expires_in?: number; refresh_token: string;
refresh_token?: string; expires_in: number;
token_type?: string; token_type: string;
} }
@Injectable({ providedIn: 'root' }) export interface LogoutResponseDto {
message: string;
}
export interface AuthStatusResponseDto {
authenticated: boolean;
status: string;
}
export interface UserProfileDto {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
emailVerified: boolean;
}
export interface TokenValidationResponseDto {
valid: boolean;
user: {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
};
expires_in: number;
}
@Injectable({
providedIn: 'root'
})
export class AuthService { export class AuthService {
private readonly tokenKey = 'access_token';
private readonly refreshTokenKey = 'refresh_token';
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly tokenKey = 'access_token';
private readonly refreshTokenKey = 'refresh_token';
private authState$ = new BehaviorSubject<boolean>(this.isAuthenticated()); private authState$ = new BehaviorSubject<boolean>(this.isAuthenticated());
private userProfile$ = new BehaviorSubject<UserProfileDto | null>(null);
private initialized$ = new BehaviorSubject<boolean>(false);
private userRoles$ = new BehaviorSubject<string[]>(this.getRolesFromToken()); // === INITIALISATION DE L'APPLICATION ===
/** /**
* Récupère les rôles depuis le token JWT * Initialise l'authentification au démarrage de l'application
*/ */
private getRolesFromToken(): string[] { async initialize(): Promise<boolean> {
const token = this.getToken(); console.log('🔄 Initialisation du service d\'authentification...');
if (!token) return [];
try { try {
const decoded: DecodedToken = jwtDecode(token); const token = this.getAccessToken();
// Priorité 2: Rôles du client (resource_access) if (!token) {
// Prendre tous les rôles de tous les clients console.log('🔍 Aucun token trouvé, utilisateur non authentifié');
if (decoded.resource_access) { this.initialized$.next(true);
const allClientRoles: string[] = []; return false;
Object.values(decoded.resource_access).forEach(client => {
if (client?.roles) {
allClientRoles.push(...client.roles);
}
});
// Retourner les rôles uniques
return [...new Set(allClientRoles)];
} }
if (this.isTokenExpired(token)) {
console.log('⚠️ Token expiré, tentative de rafraîchissement...');
const refreshSuccess = await this.tryRefreshToken();
this.initialized$.next(true);
return refreshSuccess;
}
// Token valide, vérifier le profil utilisateur
console.log('✅ Token valide détecté, vérification du profil...');
await this.loadUserProfile().toPromise();
return []; this.authState$.next(true);
} catch { this.initialized$.next(true);
return [];
console.log('🎯 Authentification initialisée avec succès');
return true;
} catch (error) {
console.error('❌ Erreur lors de l\'initialisation de l\'auth:', error);
this.clearAuthData();
this.initialized$.next(true);
return false;
} }
} }
/** /**
* Récupère les rôles de l'utilisateur courant * Tente de rafraîchir le token de manière synchrone
*/ */
getCurrentUserRoles(): string[] { private async tryRefreshToken(): Promise<boolean> {
return this.userRoles$.value; const refreshToken = this.getRefreshToken();
if (!refreshToken) {
console.log('🔍 Aucun refresh token disponible');
return false;
}
try {
// Convertir l'Observable en Promise pour l'initialisation
const response = await this.refreshToken().toPromise();
console.log('✅ Token rafraîchi avec succès lors de l\'initialisation');
return true;
} catch (error) {
console.error('❌ Échec du rafraîchissement du token:', error);
this.clearAuthData();
return false;
}
} }
/** /**
* Observable des rôles * Observable pour suivre l'état d'initialisation
*/ */
onRolesChange(): Observable<string[]> { getInitializedState(): Observable<boolean> {
return this.userRoles$.asObservable(); return this.initialized$.asObservable();
} }
// === MÉTHODES EXISTANTES AVEC AMÉLIORATIONS ===
/** /**
* Vérifications rapides par rôle * Connexion utilisateur
*/ */
isAdmin(): boolean { login(credentials: LoginDto): Observable<LoginResponseDto> {
return this.getCurrentUserRoles().includes('admin'); return this.http.post<LoginResponseDto>(
} `${environment.iamApiUrl}/auth/login`,
credentials
isMerchant(): boolean {
return this.getCurrentUserRoles().includes('merchant');
}
isSupport(): boolean {
return this.getCurrentUserRoles().includes('support');
}
hasAnyRole(roles: string[]): boolean {
const userRoles = this.getCurrentUserRoles();
return roles.some(role => userRoles.includes(role));
}
hasAllRoles(roles: string[]): boolean {
const userRoles = this.getCurrentUserRoles();
return roles.every(role => userRoles.includes(role));
}
/**
* Rafraîchir les rôles (utile après modification des rôles)
*/
refreshRoles(): void {
const roles = this.getRolesFromToken();
this.userRoles$.next(roles);
}
/**
* Initialisation simple - à appeler dans app.ts
*/
initialize(): Promise<boolean> {
return new Promise((resolve) => {
const token = this.getToken();
if (!token) {
resolve(false);
return;
}
if (this.isTokenExpired(token)) {
this.refreshToken().subscribe({
next: () => resolve(true),
error: () => {
this.clearSession(false);
resolve(false);
}
});
} else {
resolve(true);
}
});
}
/**
* Authentifie l'utilisateur via le backend NestJS
*/
login(username: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>(
`${environment.iamApiUrl}/auth/login`,
{ username, password }
).pipe( ).pipe(
tap(response => { tap(response => {
this.handleLoginResponse(response); this.handleLoginSuccess(response);
this.loadUserProfile().subscribe(); // Charger le profil après connexion
}), }),
catchError((error: HttpErrorResponse) => { catchError(error => this.handleLoginError(error))
console.error('Login failed:', error);
return throwError(() => this.getErrorMessage(error));
})
); );
} }
/** /**
* Rafraîchit le token d'accès * Rafraîchissement du token
*/ */
refreshToken(): Observable<AuthResponse> { refreshToken(): Observable<LoginResponseDto> {
const refreshToken = localStorage.getItem(this.refreshTokenKey); const refreshToken = this.getRefreshToken();
if (!refreshToken) { if (!refreshToken) {
return throwError(() => 'No refresh token available'); return throwError(() => new Error('No refresh token available'));
} }
return this.http.post<AuthResponse>( return this.http.post<LoginResponseDto>(
`${environment.iamApiUrl}/auth/refresh`, `${environment.iamApiUrl}/auth/refresh`,
{ refresh_token: refreshToken } { refresh_token: refreshToken }
).pipe( ).pipe(
tap(response => { tap(response => {
this.handleLoginResponse(response); this.handleLoginSuccess(response);
console.log('🔄 Token rafraîchi avec succès');
}), }),
catchError((error: HttpErrorResponse) => { catchError(error => {
console.error('Token refresh failed:', error); console.error('❌ Échec du rafraîchissement du token:', error);
this.clearSession(); this.clearAuthData();
return throwError(() => error); return throwError(() => error);
}) })
); );
} }
/** /**
* Déconnecte l'utilisateur * Déconnexion utilisateur
*/ */
logout(redirect = true): void { logout(): Observable<LogoutResponseDto> {
this.clearSession(redirect); return this.http.post<LogoutResponseDto>(
`${environment.iamApiUrl}/auth/logout`,
// Appel API optionnel (ne pas bloquer dessus) {}
this.http.post(`${environment.iamApiUrl}/auth/logout`, {}).subscribe({ ).pipe(
error: () => {} // Ignorer silencieusement les erreurs de logout tap(() => {
}); this.clearAuthData();
console.log('👋 Déconnexion réussie');
}),
catchError(error => {
this.clearAuthData(); // Nettoyer même en cas d'erreur
return throwError(() => error);
})
);
} }
private clearSession(redirect: boolean = true): void { /**
localStorage.removeItem(this.tokenKey); * Chargement du profil utilisateur
localStorage.removeItem(this.refreshTokenKey); */
this.authState$.next(false); loadUserProfile(): Observable<UserProfileDto> {
this.userRoles$.next([]); return this.http.get<UserProfileDto>(
`${environment.iamApiUrl}/auth/profile`
if (redirect) { ).pipe(
this.router.navigate(['/auth/sign-in']); tap(profile => {
} this.userProfile$.next(profile);
console.log('👤 Profil utilisateur chargé:', profile.username);
}),
catchError(error => {
console.error('❌ Erreur lors du chargement du profil:', error);
return throwError(() => error);
})
);
} }
/**
private handleLoginResponse(response: AuthResponse): void { * Gestion de la connexion réussie
if (response?.access_token) { */
private handleLoginSuccess(response: LoginResponseDto): void {
if (response.access_token) {
localStorage.setItem(this.tokenKey, response.access_token); localStorage.setItem(this.tokenKey, response.access_token);
if (response.refresh_token) { if (response.refresh_token) {
localStorage.setItem(this.refreshTokenKey, response.refresh_token); localStorage.setItem(this.refreshTokenKey, response.refresh_token);
} }
// Mettre à jour les rôles après login
const roles = this.getRolesFromToken();
this.userRoles$.next(roles);
this.authState$.next(true); this.authState$.next(true);
console.log('✅ Connexion réussie');
} }
} }
private getErrorMessage(error: HttpErrorResponse): string {
if (error?.error?.message) {
return error.error.message;
}
if (error.status === 401) {
return 'Invalid username or password';
}
return 'Login failed. Please try again.';
}
/** /**
* Retourne le token JWT stocké * Nettoyage des données d'authentification
*/ */
getToken(): string | null { private clearAuthData(): void {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
this.authState$.next(false);
this.userProfile$.next(null);
console.log('🧹 Données d\'authentification nettoyées');
}
/**
* Validation du token
*/
validateToken(): Observable<TokenValidationResponseDto> {
return this.http.get<TokenValidationResponseDto>(
`${environment.iamApiUrl}/auth/validate`
);
}
// === OBSERVABLES POUR COMPOSANTS ===
getAuthState(): Observable<boolean> {
return this.authState$.asObservable();
}
getUserProfile(): Observable<UserProfileDto | null> {
return this.userProfile$.asObservable();
}
/**
* Récupère les rôles de l'utilisateur courant
*/
getCurrentUserRoles(): string[] {
const token = this.getAccessToken();
if (!token) return [];
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const decoded: any = payload;
// Récupérer tous les rôles de tous les clients
if (decoded.resource_access) {
const allRoles: string[] = [];
Object.values(decoded.resource_access).forEach((client: any) => {
if (client?.roles) {
allRoles.push(...client.roles);
}
});
return [...new Set(allRoles)];
}
return [];
} catch {
return [];
}
}
/**
* Observable de l'état d'authentification
*/
onAuthState(): Observable<boolean> {
return this.authState$.asObservable();
}
/**
* Récupère le profil utilisateur
*/
getProfile(): Observable<any> {
return this.getUserProfile();
}
/**
* Vérifie si l'utilisateur a un rôle spécifique
*/
hasRole(role: string): boolean {
return this.getCurrentUserRoles().includes(role);
}
/**
* Vérifie si l'utilisateur a un des rôles spécifiés
*/
hasAnyRole(roles: string[]): boolean {
const userRoles = this.getCurrentUserRoles();
return roles.some(role => userRoles.includes(role));
}
// === GETTERS POUR LES TOKENS ===
getAccessToken(): string | null {
return localStorage.getItem(this.tokenKey); return localStorage.getItem(this.tokenKey);
} }
/** getRefreshToken(): string | null {
* Vérifie si le token est expiré return localStorage.getItem(this.refreshTokenKey);
*/ }
isTokenExpired(token: string): boolean {
// === METHODES PRIVEES ===
private handleLoginError(error: HttpErrorResponse): Observable<never> {
let errorMessage = 'Login failed';
if (error.status === 401) {
errorMessage = 'Invalid username or password';
} else if (error.status === 403) {
errorMessage = 'Account is disabled or not fully set up';
} else if (error.error?.message) {
errorMessage = error.error.message;
}
return throwError(() => new Error(errorMessage));
}
// === VERIFICATIONS D'ETAT ===
isAuthenticated(): boolean {
const token = this.getAccessToken();
return !!token && !this.isTokenExpired(token);
}
private isTokenExpired(token: string): boolean {
try { try {
const decoded: DecodedToken = jwtDecode(token); const payload = JSON.parse(atob(token.split('.')[1]));
const now = Math.floor(Date.now() / 1000); const expiry = payload.exp;
return decoded.exp < (now + 60); // Marge de sécurité de 60 secondes return (Math.floor((new Date).getTime() / 1000)) >= expiry;
} catch { } catch {
return true; return true;
} }
} }
/**
* Vérifie si l'utilisateur est authentifié
*/
isAuthenticated(): boolean {
const token = this.getToken();
if (!token) return false;
return !this.isTokenExpired(token);
}
onAuthState() {
return this.authState$.asObservable();
}
/**
* Récupère les infos utilisateur depuis le backend
*/
getProfile(): Observable<any> {
return this.http.get(`${environment.iamApiUrl}/users/profile/me`).pipe(
catchError(error => {
return throwError(() => error);
})
);
}
} }

View File

@ -77,9 +77,9 @@ export class MenuService {
url: '/transactions', url: '/transactions',
}, },
{ {
label: 'Gestions Marchant', label: 'Gestions Merchants/Partenaires',
icon: 'lucideStore', icon: 'lucideStore',
url: '/merchants' url: '/merchant-partners'
}, },
{ {
label: 'Opérateurs', label: 'Opérateurs',

View File

@ -12,88 +12,150 @@ export class PermissionsService {
// Dashboard // Dashboard
{ {
module: 'dcb-dashboard', module: 'dcb-dashboard',
roles: ['admin', 'merchant', 'support'], roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
],
}, },
// Transactions // Transactions
{ {
module: 'transactions', module: 'transactions',
roles: ['admin', 'merchant', 'support'], roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
],
}, },
// Merchants // Merchants/Partners
{ {
module: 'merchants', module: 'merchant-partners',
roles: ['admin', 'merchant', 'support'], roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'],
}, },
// Operators (Admin only) // Operators (Admin only)
{ {
module: 'operators', module: 'operators',
roles: ['admin'], roles: ['dcb-admin'],
children: { children: {
'config': ['admin'], 'config': ['dcb-admin'],
'stats': ['admin'] 'stats': ['dcb-admin']
} }
}, },
// Webhooks // Webhooks
{ {
module: 'webhooks', module: 'webhooks',
roles: ['admin', 'merchant'], roles: ['dcb-admin', 'dcb-partner'],
children: { children: {
'history': ['admin', 'merchant'], 'history': ['dcb-admin', 'dcb-partner'],
'status': ['admin', 'merchant'], 'status': ['dcb-admin', 'dcb-partner'],
'retry': ['admin'] 'retry': ['dcb-admin']
} }
}, },
// Users (Admin only) // Users (Admin only)
{ {
module: 'users', module: 'users',
roles: ['admin'] roles: ['dcb-admin', 'dcb-support']
}, },
// Support (All authenticated users) // Support (All authenticated users)
{ {
module: 'settings', module: 'settings',
roles: ['admin', 'merchant', 'support'] roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
]
}, },
// Integrations (Admin only) // Integrations (Admin only)
{ {
module: 'integrations', module: 'integrations',
roles: ['admin'] roles: ['dcb-admin']
}, },
// Support (All authenticated users) // Support (All authenticated users)
{ {
module: 'support', module: 'support',
roles: ['admin', 'merchant', 'support'] roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
]
}, },
// Profile (All authenticated users) // Profile (All authenticated users)
{ {
module: 'profile', module: 'profile',
roles: ['admin', 'merchant', 'support'] roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
]
}, },
// Documentation (All authenticated users) // Documentation (All authenticated users)
{ {
module: 'documentation', module: 'documentation',
roles: ['admin', 'merchant', 'support'] roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
]
}, },
// Help (All authenticated users) // Help (All authenticated users)
{ {
module: 'help', module: 'help',
roles: ['admin', 'merchant', 'support'] roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
]
}, },
// About (All authenticated users) // About (All authenticated users)
{ {
module: 'about', module: 'about',
roles: ['admin', 'merchant', 'support'] roles: [
'dcb-admin',
'dcb-partner',
'dcb-support',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-support'
]
} }
]; ];

View File

@ -0,0 +1,378 @@
// src/app/core/services/role-management.service.ts
import { Injectable, inject } from '@angular/core';
import { HubUsersService, UserRole } from '../../modules/users/services/users.service';
import { BehaviorSubject, Observable, map, tap, of, catchError } from 'rxjs';
export interface RolePermission {
canCreateUsers: boolean;
canEditUsers: boolean;
canDeleteUsers: boolean;
canManageRoles: boolean;
canViewStats: boolean;
canManageMerchants: boolean;
canAccessAdmin: boolean;
canAccessSupport: boolean;
canAccessPartner: boolean;
}
// Interface simplifiée pour la réponse API
export interface AvailableRoleResponse {
value: UserRole;
label: string;
description: string;
}
export interface AvailableRolesResponse {
roles: AvailableRoleResponse[];
}
// Interface étendue pour l'usage interne avec les permissions
export interface AvailableRole extends AvailableRoleResponse {
permissions: RolePermission;
}
export interface AvailableRolesWithPermissions {
roles: AvailableRole[];
}
@Injectable({
providedIn: 'root'
})
export class RoleManagementService {
private hubUsersService = inject(HubUsersService);
private availableRoles$ = new BehaviorSubject<AvailableRolesWithPermissions | null>(null);
private currentUserRole$ = new BehaviorSubject<UserRole | null>(null);
/**
* Charge les rôles disponibles depuis l'API et les enrichit avec les permissions
*/
loadAvailableRoles(): Observable<AvailableRolesWithPermissions> {
return this.hubUsersService.getAvailableHubRoles().pipe(
map(apiResponse => {
// Enrichir les rôles de l'API avec les permissions
const rolesWithPermissions: AvailableRole[] = apiResponse.roles.map(role => ({
...role,
permissions: this.getPermissionsForRole(role.value)
}));
const result: AvailableRolesWithPermissions = {
roles: rolesWithPermissions
};
this.availableRoles$.next(result);
return result;
}),
catchError(error => {
console.error('Error loading available roles:', error);
// Fallback avec les rôles par défaut
const defaultRoles: AvailableRolesWithPermissions = {
roles: [
{
value: UserRole.DCB_ADMIN,
label: 'DCB Admin',
description: 'Full administrative access to the entire system',
permissions: this.getPermissionsForRole(UserRole.DCB_ADMIN)
},
{
value: UserRole.DCB_SUPPORT,
label: 'DCB Support',
description: 'Support access with limited administrative capabilities',
permissions: this.getPermissionsForRole(UserRole.DCB_SUPPORT)
},
{
value: UserRole.DCB_PARTNER,
label: 'DCB Partner',
description: 'Merchant partner with access to their own merchant ecosystem',
permissions: this.getPermissionsForRole(UserRole.DCB_PARTNER)
}
]
};
this.availableRoles$.next(defaultRoles);
return of(defaultRoles);
})
);
}
/**
* Récupère les rôles disponibles depuis le cache ou l'API
*/
getAvailableRoles(): Observable<AvailableRolesWithPermissions> {
const cached = this.availableRoles$.value;
if (cached) {
return of(cached);
}
return this.loadAvailableRoles();
}
/**
* Récupère les rôles disponibles sous forme simplifiée (pour les selects)
*/
getAvailableRolesSimple(): Observable<AvailableRoleResponse[]> {
return this.getAvailableRoles().pipe(
map(response => response.roles.map(role => ({
value: role.value,
label: role.label,
description: role.description
})))
);
}
// ... (le reste des méthodes reste identique)
/**
* Définit le rôle de l'utilisateur courant
*/
setCurrentUserRole(role: UserRole): void {
this.currentUserRole$.next(role);
}
/**
* Récupère le rôle de l'utilisateur courant
*/
getCurrentUserRole(): Observable<UserRole | null> {
return this.currentUserRole$.asObservable();
}
/**
* Récupère les permissions détaillées selon le rôle
*/
getPermissionsForRole(role: UserRole): RolePermission {
switch (role) {
case UserRole.DCB_ADMIN:
return {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: true,
canManageRoles: true,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: true,
canAccessSupport: true,
canAccessPartner: true
};
case UserRole.DCB_SUPPORT:
return {
canCreateUsers: true,
canEditUsers: true,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: true,
canManageMerchants: true,
canAccessAdmin: false,
canAccessSupport: true,
canAccessPartner: true
};
case UserRole.DCB_PARTNER:
return {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: false,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: true
};
default:
return {
canCreateUsers: false,
canEditUsers: false,
canDeleteUsers: false,
canManageRoles: false,
canViewStats: false,
canManageMerchants: false,
canAccessAdmin: false,
canAccessSupport: false,
canAccessPartner: false
};
}
}
/**
* Vérifie si un rôle peut être attribué par l'utilisateur courant
*/
canAssignRole(currentUserRole: UserRole | null, targetRole: UserRole): boolean {
if (!currentUserRole) return false;
// Seuls les admins peuvent attribuer tous les rôles
if (currentUserRole === UserRole.DCB_ADMIN) {
return true;
}
// Les supports ne peuvent créer que d'autres supports
if (currentUserRole === UserRole.DCB_SUPPORT) {
return targetRole === UserRole.DCB_SUPPORT;
}
return false;
}
/**
* Vérifie si l'utilisateur courant peut créer des utilisateurs
*/
canCreateUsers(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canCreateUsers : false;
}
/**
* Vérifie si l'utilisateur courant peut éditer des utilisateurs
*/
canEditUsers(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canEditUsers : false;
}
/**
* Vérifie si l'utilisateur courant peut supprimer des utilisateurs
*/
canDeleteUsers(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canDeleteUsers : false;
}
/**
* Vérifie si l'utilisateur courant peut gérer les rôles
*/
canManageRoles(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canManageRoles : false;
}
/**
* Vérifie si l'utilisateur courant peut accéder aux statistiques
*/
canViewStats(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canViewStats : false;
}
/**
* Vérifie si l'utilisateur courant peut gérer les merchants
*/
canManageMerchants(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canManageMerchants : false;
}
/**
* Vérifie si l'utilisateur courant peut accéder à l'admin
*/
canAccessAdmin(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canAccessAdmin : false;
}
/**
* Vérifie si l'utilisateur courant peut accéder au support
*/
canAccessSupport(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canAccessSupport : false;
}
/**
* Vérifie si l'utilisateur courant peut accéder à l'espace partenaire
*/
canAccessPartner(currentUserRole: UserRole | null): boolean {
return currentUserRole ? this.getPermissionsForRole(currentUserRole).canAccessPartner : false;
}
/**
* Récupère le libellé d'un rôle
*/
getRoleLabel(role: UserRole): string {
switch (role) {
case UserRole.DCB_ADMIN:
return 'Administrateur DCB';
case UserRole.DCB_SUPPORT:
return 'Support DCB';
case UserRole.DCB_PARTNER:
return 'Partenaire DCB';
default:
return 'Rôle inconnu';
}
}
/**
* Récupère la classe CSS pour un badge de rôle
*/
getRoleBadgeClass(role: UserRole): string {
switch (role) {
case UserRole.DCB_ADMIN:
return 'bg-danger';
case UserRole.DCB_SUPPORT:
return 'bg-info';
case UserRole.DCB_PARTNER:
return 'bg-success';
default:
return 'bg-secondary';
}
}
/**
* Récupère l'icône pour un rôle
*/
getRoleIcon(role: UserRole): string {
switch (role) {
case UserRole.DCB_ADMIN:
return 'lucideShield';
case UserRole.DCB_SUPPORT:
return 'lucideHeadphones';
case UserRole.DCB_PARTNER:
return 'lucideBuilding';
default:
return 'lucideUser';
}
}
/**
* Vérifie si un rôle est un rôle administrateur
*/
isAdminRole(role: UserRole): boolean {
return role === UserRole.DCB_ADMIN;
}
/**
* Vérifie si un rôle est un rôle support
*/
isSupportRole(role: UserRole): boolean {
return role === UserRole.DCB_SUPPORT;
}
/**
* Vérifie si un rôle est un rôle partenaire
*/
isPartnerRole(role: UserRole): boolean {
return role === UserRole.DCB_PARTNER;
}
/**
* Récupère tous les rôles disponibles sous forme de tableau
*/
getAllRoles(): UserRole[] {
return [UserRole.DCB_ADMIN, UserRole.DCB_SUPPORT, UserRole.DCB_PARTNER];
}
/**
* Récupère les rôles que l'utilisateur courant peut attribuer
*/
getAssignableRoles(currentUserRole: UserRole | null): UserRole[] {
if (!currentUserRole) return [];
if (currentUserRole === UserRole.DCB_ADMIN) {
return this.getAllRoles();
}
if (currentUserRole === UserRole.DCB_SUPPORT) {
return [UserRole.DCB_SUPPORT];
}
return [];
}
/**
* Réinitialise le cache des rôles
*/
clearCache(): void {
this.availableRoles$.next(null);
}
}

View File

@ -0,0 +1,117 @@
// src/app/core/services/role.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { jwtDecode } from 'jwt-decode';
interface DecodedToken {
exp: number;
sub?: string;
preferred_username?: string;
email?: string;
given_name?: string;
family_name?: string;
resource_access?: {
[key: string]: {
roles: string[];
};
};
}
@Injectable({
providedIn: 'root'
})
export class RoleService {
private userRoles$ = new BehaviorSubject<string[]>(this.extractRolesFromToken());
/**
* Récupère les rôles actuels de l'utilisateur
*/
getCurrentUserRoles(): string[] {
return this.userRoles$.value;
}
/**
* Observable des changements de rôles
*/
onRolesChange(): Observable<string[]> {
return this.userRoles$.asObservable();
}
/**
* Rafraîchit les rôles depuis le token
*/
refreshRoles(token?: string): void {
const roles = this.extractRolesFromToken(token);
this.userRoles$.next(roles);
}
// === VERIFICATIONS SPECIFIQUES ===
isAdmin(): boolean {
return this.hasRole('dcb-admin');
}
isPartner(): boolean {
return this.hasRole('dcb-partner');
}
isSupport(): boolean {
return this.hasRole('dcb-support');
}
isPartnerAdmin(): boolean {
return this.hasRole('dcb-partner-admin');
}
isPartnerManager(): boolean {
return this.hasRole('dcb-partner-merchant');
}
isPartnerSupport(): boolean {
return this.hasRole('dcb-partner-support');
}
hasAnyRole(roles: string[]): boolean {
const userRoles = this.getCurrentUserRoles();
return roles.some(role => userRoles.includes(role));
}
hasAllRoles(roles: string[]): boolean {
const userRoles = this.getCurrentUserRoles();
return roles.every(role => userRoles.includes(role));
}
hasRole(role: string): boolean {
return this.getCurrentUserRoles().includes(role);
}
// === METHODES PRIVEES ===
private extractRolesFromToken(token?: string): string[] {
const accessToken = token || localStorage.getItem('access_token');
if (!accessToken) {
return [];
}
try {
const decoded: DecodedToken = jwtDecode(accessToken);
if (decoded.resource_access) {
const allRoles: string[] = [];
Object.values(decoded.resource_access).forEach(client => {
if (client?.roles) {
allRoles.push(...client.roles);
}
});
return [...new Set(allRoles)];
}
return [];
} catch {
return [];
}
}
}

View File

@ -66,9 +66,9 @@ export const menuItems: MenuItemType[] = [
url: '/transactions', url: '/transactions',
}, },
{ {
label: 'Gestions Marchant', label: 'Gestions Merchants/Partners',
icon: 'lucideStore', icon: 'lucideStore',
url: '/merchants' url: '/merchant-partners'
}, },
{ {
label: 'Opérateurs', label: 'Opérateurs',

BIN
src/app/modules.zip Normal file

Binary file not shown.

View File

@ -1,10 +0,0 @@
import { Routes } from '@angular/router'
import { SignIn } from '@/app/modules/auth/sign-in'
export const Auth_ROUTES: Routes = [
{
path: 'auth/sign-in',
component: SignIn,
data: { title: 'Sign In' },
},
]

View File

@ -0,0 +1,41 @@
import { Routes } from '@angular/router'
import { SignIn } from '@/app/modules/auth/sign-in'
import { ResetPassword } from '@/app/modules/auth/reset-password'
import { NewPassword } from '@/app/modules/auth/new-password'
import { publicGuard } from '@core/guards/public.guard';
export const AUTH_ROUTES: Routes = [
{
path: 'sign-in',
component: SignIn,
canActivate: [publicGuard],
data: {
title: 'Connexion - DCB',
module: 'auth'
}
},
{
path: 'reset-password',
component: ResetPassword,
title: 'Réinitialisation Mot de Passe - DCB',
canActivate: [publicGuard],
data: {
title: 'Réinitialisation Mot de Passe - DCB',
module: 'auth'
}
},
{
path: 'forgot-password',
component: NewPassword,
canActivate: [publicGuard],
data: {
title: 'Mot de Passe Oublié - DCB',
module: 'auth'
}
},
{
path: 'logout',
redirectTo: 'sign-in',
data: { module: 'auth' }
}
];

View File

@ -4,18 +4,20 @@ import { Unauthorized } from '../unauthorized'
export const ERROR_PAGES_ROUTES: Routes = [ export const ERROR_PAGES_ROUTES: Routes = [
{ {
path: 'error/404', path: '404', //
component: Error404, component: Error404,
data: { title: 'Page Non Trouvée' }, data: { title: 'Page Non Trouvée' },
}, },
{ {
path: 'error/403', path: '403', //
component: Unauthorized, component: Unauthorized,
data: { title: 'Accès Refusé' }, data: { title: 'Accès Refusé' },
}, },
{ {
path: 'unauthorized', path: '500', //
component: Unauthorized, component: Error404,
data: { title: 'Accès Refusé' }, data: { title: 'Erreur Serveur' },
} },
// Redirection par défaut
{ path: '', redirectTo: '404', pathMatch: 'full' }
] ]

View File

@ -0,0 +1,162 @@
import { Component } from '@angular/core'
import { credits, currentYear } from '@/app/constants'
import { RouterLink } from '@angular/router'
import { PasswordStrengthBar } from '@app/components/password-strength-bar'
import { FormsModule } from '@angular/forms'
import { AppLogo } from '@app/components/app-logo'
import { NgOtpInputModule } from 'ng-otp-input'
@Component({
selector: 'app-new-password',
imports: [
RouterLink,
PasswordStrengthBar,
FormsModule,
AppLogo,
NgOtpInputModule,
],
template: `
<div class="auth-box overflow-hidden align-items-center d-flex">
<div class="container">
<div class="row justify-content-center">
<div class="col-xxl-4 col-md-6 col-sm-8">
<div class="card">
<div class="card-body">
<div class="auth-brand mb-4">
<app-app-logo />
<p class="text-muted mt-3">
We've emailed you a 6-digit verification code. Please enter
it below to confirm your email address
</p>
</div>
<form>
<div class="mb-3">
<label for="userEmail" class="form-label"
>Email address <span class="text-danger">*</span></label
>
<div class="input-group">
<input
type="email"
class="form-control"
id="userEmail"
placeholder="you@example.com"
disabled
/>
</div>
</div>
<div class="mb-3">
<label class="form-label"
>Enter your 6-digit code
<span class="text-danger">*</span></label
>
<ng-otp-input
[config]="{
length: 6,
allowNumbersOnly: true,
inputClass: 'form-control text-center',
}"
>
</ng-otp-input>
</div>
<div class="mb-3" data-password="bar">
<label for="userPassword" class="form-label"
>Password <span class="text-danger">*</span></label
>
<div class="input-group">
<input
type="password"
name="password"
[(ngModel)]="password"
class="form-control"
id="userPassword"
placeholder="••••••••"
required
/>
</div>
<app-password-strength-bar [password]="password" />
<div class="password-bar my-2"></div>
</div>
<div class="mb-3">
<label for="userNewPassword" class="form-label"
>Confirm New Password
<span class="text-danger">*</span></label
>
<div class="input-group">
<input
type="password"
class="form-control"
id="userNewPassword"
placeholder="••••••••"
required
/>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input form-check-input-light fs-14"
type="checkbox"
id="termAndPolicy"
/>
<label class="form-check-label" for="termAndPolicy"
>Agree the Terms & Policy</label
>
</div>
</div>
<div class="d-grid">
<button
type="submit"
class="btn btn-primary fw-semibold py-2"
>
Update Password
</button>
</div>
</form>
<p class="mt-4 text-muted text-center mb-4">
Dont have a code?
<a
href="javascript:void(0);"
class="text-decoration-underline link-offset-2 fw-semibold"
>Resend</a
>
or
<a
href="javascript:void(0);"
class="text-decoration-underline link-offset-2 fw-semibold"
>Call Us</a
>
</p>
<p class="text-muted text-center mb-0">
Return to
<a
routerLink="/auth/sign-in"
class="text-decoration-underline link-offset-3 fw-semibold"
>Sign in</a
>
</p>
</div>
</div>
<p class="text-center text-muted mt-4 mb-0">
© {{ currentYear }} Simple by
<span class="fw-semibold">{{ credits.name }}</span>
</p>
</div>
</div>
</div>
</div>
`,
styles: ``,
})
export class NewPassword {
password: string = ''
protected readonly currentYear = currentYear
protected readonly credits = credits
}

View File

@ -0,0 +1,88 @@
import { Component } from '@angular/core'
import { credits, currentYear } from '@/app/constants'
import { RouterLink } from '@angular/router'
import { AppLogo } from '@app/components/app-logo'
@Component({
selector: 'app-reset-password',
imports: [RouterLink, AppLogo],
template: `
<div class="auth-box overflow-hidden align-items-center d-flex">
<div class="container">
<div class="row justify-content-center">
<div class="col-xxl-4 col-md-6 col-sm-8">
<div class="card">
<div class="card-body">
<div class="auth-brand mb-4">
<app-app-logo />
<p class="text-muted w-lg-75 mt-3">
Enter your email address and we'll send you a link to reset
your password.
</p>
</div>
<form>
<div class="mb-3">
<label for="userEmail" class="form-label"
>Email address <span class="text-danger">*</span></label
>
<div class="input-group">
<input
type="email"
class="form-control"
id="userEmail"
placeholder="you@example.com"
required
/>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input form-check-input-light fs-14"
type="checkbox"
id="termAndPolicy"
/>
<label class="form-check-label" for="termAndPolicy"
>Agree the Terms & Policy</label
>
</div>
</div>
<div class="d-grid">
<button
type="submit"
class="btn btn-primary fw-semibold py-2"
>
Send Request
</button>
</div>
</form>
<p class="text-muted text-center mt-4 mb-0">
Return to
<a
routerLink="/auth/sign-in"
class="text-decoration-underline link-offset-3 fw-semibold"
>Sign in</a
>
</p>
</div>
</div>
<p class="text-center text-muted mt-4 mb-0">
© {{ currentYear }} Simple by
<span class="fw-semibold">{{ credits.name }}</span>
</p>
</div>
</div>
</div>
</div>
`,
styles: ``,
})
export class ResetPassword {
protected readonly currentYear = currentYear
protected readonly credits = credits
}

View File

@ -1,8 +1,11 @@
import { Component, inject, ChangeDetectorRef } from '@angular/core'; // src/app/modules/auth/sign-in/sign-in.ts
import { Component, inject, ChangeDetectorRef, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms'; import { FormsModule, NgForm } from '@angular/forms';
import { Router } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { AuthService } from '@core/services/auth.service'; import { Subject, takeUntil } from 'rxjs';
import { AuthService, LoginDto } from '@core/services/auth.service';
import { RoleService } from '@core/services/role.service';
import { AppLogo } from '@app/components/app-logo'; import { AppLogo } from '@app/components/app-logo';
import { PasswordStrengthBar } from '@app/components/password-strength-bar'; import { PasswordStrengthBar } from '@app/components/password-strength-bar';
import { appName, credits, currentYear } from '@/app/constants'; import { appName, credits, currentYear } from '@/app/constants';
@ -10,7 +13,7 @@ import { appName, credits, currentYear } from '@/app/constants';
@Component({ @Component({
selector: 'app-sign-in', selector: 'app-sign-in',
standalone: true, standalone: true,
imports: [FormsModule, CommonModule, AppLogo, PasswordStrengthBar], imports: [FormsModule, CommonModule, RouterLink, AppLogo, PasswordStrengthBar],
template: ` template: `
<div class="auth-box overflow-hidden align-items-center d-flex"> <div class="auth-box overflow-hidden align-items-center d-flex">
<div class="container"> <div class="container">
@ -25,7 +28,7 @@ import { appName, credits, currentYear } from '@/app/constants';
</p> </p>
</div> </div>
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)" novalidate> <form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)" novalidate autocomplete="on">
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label"> <label for="username" class="form-label">
Username <span class="text-danger">*</span> Username <span class="text-danger">*</span>
@ -35,14 +38,20 @@ import { appName, credits, currentYear } from '@/app/constants';
id="username" id="username"
name="username" name="username"
class="form-control" class="form-control"
placeholder="Enter username" placeholder="Enter your Username"
required required
[(ngModel)]="username" username
[(ngModel)]="credentials.username"
#usernameCtrl="ngModel" #usernameCtrl="ngModel"
[class.is-invalid]="usernameCtrl.invalid && usernameCtrl.touched" [class.is-invalid]="usernameCtrl.invalid && usernameCtrl.touched"
[attr.aria-describedby]="usernameCtrl.invalid && usernameCtrl.touched ? 'username-error' : null"
autocomplete="username"
/> />
<div class="invalid-feedback"> <div *ngIf="usernameCtrl.invalid && usernameCtrl.touched"
Username is required id="username-error"
class="invalid-feedback">
<span *ngIf="usernameCtrl.errors?.['required']">Username is required</span>
<span *ngIf="usernameCtrl.errors?.['username']">Please enter a valid email address</span>
</div> </div>
</div> </div>
@ -57,16 +66,27 @@ import { appName, credits, currentYear } from '@/app/constants';
class="form-control" class="form-control"
placeholder="••••••••" placeholder="••••••••"
required required
[(ngModel)]="password" minlength="6"
[(ngModel)]="credentials.password"
#passwordCtrl="ngModel" #passwordCtrl="ngModel"
[class.is-invalid]="passwordCtrl.invalid && passwordCtrl.touched" [class.is-invalid]="passwordCtrl.invalid && passwordCtrl.touched"
[attr.aria-describedby]="passwordCtrl.invalid && passwordCtrl.touched ? 'password-error' : null"
autocomplete="current-password"
/> />
<div class="invalid-feedback"> <div *ngIf="passwordCtrl.invalid && passwordCtrl.touched"
Password is required id="password-error"
class="invalid-feedback">
<span *ngIf="passwordCtrl.errors?.['required']">Password is required</span>
<span *ngIf="passwordCtrl.errors?.['minlength']">
Password must be at least 6 characters
</span>
</div> </div>
<!-- Password strength bar --> <!-- Password strength bar -->
<app-password-strength-bar [password]="password || ''"></app-password-strength-bar> <app-password-strength-bar
[password]="credentials.password || ''"
class="mt-2"
></app-password-strength-bar>
</div> </div>
<!-- Server-side error message --> <!-- Server-side error message -->
@ -74,6 +94,7 @@ import { appName, credits, currentYear } from '@/app/constants';
id="error-message" id="error-message"
class="alert alert-danger mt-3" class="alert alert-danger mt-3"
role="alert" role="alert"
aria-live="polite"
tabindex="-1"> tabindex="-1">
<i class="ri-error-warning-line me-2"></i> <i class="ri-error-warning-line me-2"></i>
{{ errorMessage }} {{ errorMessage }}
@ -105,7 +126,9 @@ import { appName, credits, currentYear } from '@/app/constants';
type="submit" type="submit"
class="btn btn-primary fw-semibold py-2" class="btn btn-primary fw-semibold py-2"
[disabled]="loginForm.invalid || loading" [disabled]="loginForm.invalid || loading"
[attr.aria-busy]="loading"
> >
<span *ngIf="loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
{{ loading ? 'Signing In...' : 'Sign In' }} {{ loading ? 'Signing In...' : 'Sign In' }}
</button> </button>
</div> </div>
@ -124,50 +147,155 @@ import { appName, credits, currentYear } from '@/app/constants';
</div> </div>
</div> </div>
`, `,
styles: []
}) })
export class SignIn { export class SignIn implements OnInit, OnDestroy {
protected readonly appName = appName; protected readonly appName = appName;
protected readonly currentYear = currentYear; protected readonly currentYear = currentYear;
protected readonly credits = credits; protected readonly credits = credits;
private authService = inject(AuthService); private authService = inject(AuthService);
private roleService = inject(RoleService);
private router = inject(Router); private router = inject(Router);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
username: string = ''; credentials: LoginDto = {
password: string = ''; username: '',
password: ''
};
rememberMe: boolean = false; rememberMe: boolean = false;
loading: boolean = false; loading: boolean = false;
errorMessage: string | null = null; errorMessage: string | null = null;
onSubmit(form: NgForm) { ngOnInit(): void {
// Vérifier si l'utilisateur est déjà connecté
this.checkExistingAuth();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Vérifie si l'utilisateur a déjà une session valide
*/
private checkExistingAuth(): void {
if (this.authService.isAuthenticated()) {
this.redirectBasedOnRoles();
}
}
/**
* Soumission du formulaire de connexion
*/
onSubmit(form: NgForm): void {
// Marquer tous les champs comme touchés pour afficher les erreurs
if (form.invalid) { if (form.invalid) {
form.control.markAllAsTouched(); this.markFormGroupTouched(form);
this.focusFirstInvalidField();
return; return;
} }
this.loading = true; this.loading = true;
this.errorMessage = null; this.errorMessage = null;
this.authService.login(this.username, this.password).subscribe({ this.authService.login(this.credentials)
next: (res) => { .pipe(takeUntil(this.destroy$))
this.router.navigate(['/dcb-dashboard']); .subscribe({
this.loading = false; next: () => {
}, this.handleLoginSuccess();
error: (err) => { },
this.errorMessage = err.error?.message || 'Login failed'; error: (error) => {
this.loading = false; this.handleLoginError(error);
}
// Forcer la mise à jour de la vue });
this.cdRef.detectChanges(); }
// Scroll et focus sur l'erreur /**
this.scrollToError(); * Gestion de la connexion réussie
}, */
private handleLoginSuccess(): void {
this.loading = false;
// Rafraîchir les rôles après connexion
this.roleService.refreshRoles();
// Rediriger en fonction des rôles
this.redirectBasedOnRoles();
}
/**
* Gestion des erreurs de connexion
*/
private handleLoginError(error: any): void {
this.loading = false;
// Messages d'erreur spécifiques selon le type d'erreur
if (error.status === 401) {
this.errorMessage = 'Invalid email or password. Please try again.';
} else if (error.status === 403) {
this.errorMessage = 'Your account is disabled or not fully set up. Please contact support.';
} else if (error.status === 0 || error.status === 500) {
this.errorMessage = 'Server is unavailable. Please try again later.';
} else {
this.errorMessage = error.message || 'An unexpected error occurred. Please try again.';
}
// Forcer la mise à jour de la vue
this.cdRef.detectChanges();
// Scroll et focus sur l'erreur
this.scrollToError();
}
/**
* Redirection basée sur les rôles de l'utilisateur
*/
private redirectBasedOnRoles(): void {
const userRoles = this.roleService.getCurrentUserRoles();
// Logique de redirection selon les rôles
if (userRoles.includes('dcb-admin')) {
this.router.navigate(['/admin/dcb-dashboard']);
} else if (userRoles.includes('dcb-partner-admin')) {
this.router.navigate(['/partner/dcb-dashboard']);
} else if (userRoles.includes('dcb-support')) {
this.router.navigate(['/support/dcb-dashboard']);
} else {
// Route par défaut
this.router.navigate(['/dcb-dashboard']);
}
}
/**
* Marque tous les champs du formulaire comme touchés
*/
private markFormGroupTouched(form: NgForm): void {
Object.keys(form.controls).forEach(key => {
const control = form.controls[key];
control.markAsTouched();
}); });
} }
private scrollToError() { /**
* Focus sur le premier champ invalide
*/
private focusFirstInvalidField(): void {
setTimeout(() => {
const firstInvalidElement = document.querySelector('.is-invalid') as HTMLElement;
if (firstInvalidElement) {
firstInvalidElement.focus();
}
}, 100);
}
/**
* Scroll vers le message d'erreur
*/
private scrollToError(): void {
setTimeout(() => { setTimeout(() => {
const errorElement = document.getElementById('error-message'); const errorElement = document.getElementById('error-message');
if (errorElement) { if (errorElement) {
@ -179,4 +307,14 @@ export class SignIn {
} }
}, 100); }, 100);
} }
/**
* Réinitialise le formulaire
*/
resetForm(form: NgForm): void {
form.resetForm();
this.credentials = { username: '', password: '' };
this.errorMessage = null;
this.rememberMe = false;
}
} }

View File

@ -0,0 +1,92 @@
import { Component } from '@angular/core'
import { AppLogo } from '@app/components/app-logo'
import { NgOtpInputComponent } from 'ng-otp-input'
import { RouterLink } from '@angular/router'
import { credits, currentYear } from '@/app/constants'
@Component({
selector: 'app-two-factor',
imports: [AppLogo, NgOtpInputComponent, RouterLink],
template: `
<div class="auth-box overflow-hidden align-items-center d-flex">
<div class="container">
<div class="row justify-content-center">
<div class="col-xxl-4 col-md-6 col-sm-8">
<div class="card">
<div class="card-body">
<div class="auth-brand mb-4">
<app-app-logo />
<p class="text-muted w-lg-75 mt-3">
We've emailed you a 6-digit verification code we sent to
</p>
</div>
<div class="text-center mb-4">
<div class="fw-bold fs-4">+ (12) ******6789</div>
</div>
<form>
<label class="form-label"
>Enter your 6-digit code
<span class="text-danger">*</span></label
>
<ngx-otp-input
[config]="{
length: 6,
allowNumbersOnly: true,
inputClass: 'form-control text-center mb-3',
}"
>
</ngx-otp-input>
<div class="d-grid">
<button
type="submit"
class="btn btn-primary fw-semibold py-2"
>
Confirm
</button>
</div>
</form>
<p class="mt-4 text-muted text-center mb-4">
Dont have a code?
<a
href="javascript:void(0);"
class="text-decoration-underline link-offset-2 fw-semibold"
>Resend</a
>
or
<a
href="javascript:void(0);"
class="text-decoration-underline link-offset-2 fw-semibold"
>Call Us</a
>
</p>
<p class="text-muted text-center mb-0">
Return to
<a
routerLink="/auth/sign-in"
class="text-decoration-underline link-offset-3 fw-semibold"
>Sign in</a
>
</p>
</div>
</div>
<p class="text-center text-muted mt-4 mb-0">
©
{{ currentYear }}
Simple by <span class="fw-semibold">{{ credits.name }}</span>
</p>
</div>
</div>
</div>
</div>
`,
styles: ``,
})
export class TwoFactor {
protected readonly currentYear = currentYear
protected readonly credits = credits
}

View File

@ -1,6 +1,6 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { UiCard } from '@app/components/ui-card' import { UiCard } from '@app/components/ui-card'
import { wizardSteps } from '@/app/modules/merchants/data' import { wizardSteps } from '@/app/modules/components/data'
import { NgIcon } from '@ng-icons/core' import { NgIcon } from '@ng-icons/core'
@Component({ @Component({

View File

@ -1,253 +1,34 @@
export const paginationIcons = { import { WizardStepType } from '@/app/modules/components/types'
first: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevrons-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M11 7l-5 5l5 5" /><path d="M17 7l-5 5l5 5" /></svg>`,
previous: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 6l-6 6l6 6" /></svg>`,
next: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 6l6 6l-6 6" /></svg>`,
last: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-chevrons-right"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 7l5 5l-5 5" /><path d="M13 7l5 5l-5 5" /></svg>`,
}
export const states = [ export const wizardSteps: WizardStepType[] = [
'Alabama',
'Alaska',
'American Samoa',
'Arizona',
'Arkansas',
'California',
'Colorado',
'Connecticut',
'Delaware',
'District Of Columbia',
'Federated States Of Micronesia',
'Florida',
'Georgia',
'Guam',
'Hawaii',
'Idaho',
'Illinois',
'Indiana',
'Iowa',
'Kansas',
'Kentucky',
'Louisiana',
'Maine',
'Marshall Islands',
'Maryland',
'Massachusetts',
'Michigan',
'Minnesota',
'Mississippi',
'Missouri',
'Montana',
'Nebraska',
'Nevada',
'New Hampshire',
'New Jersey',
'New Mexico',
'New York',
'North Carolina',
'North Dakota',
'Northern Mariana Islands',
'Ohio',
'Oklahoma',
'Oregon',
'Palau',
'Pennsylvania',
'Puerto Rico',
'Rhode Island',
'South Carolina',
'South Dakota',
'Tennessee',
'Texas',
'Utah',
'Vermont',
'Virgin Islands',
'Virginia',
'Washington',
'West Virginia',
'Wisconsin',
'Wyoming',
]
export const statesWithFlags: { name: string; flag: string }[] = [
{ {
name: 'Alabama', id: 'stuInfo',
flag: '5/5c/Flag_of_Alabama.svg/45px-Flag_of_Alabama.svg.png', icon: 'tablerUserCircle',
title: 'Student Info',
subtitle: 'Personal details',
}, },
{ {
name: 'Alaska', id: 'addrInfo',
flag: 'e/e6/Flag_of_Alaska.svg/43px-Flag_of_Alaska.svg.png', icon: 'tablerMapPin',
title: 'Address Info',
subtitle: 'Where you live',
}, },
{ {
name: 'Arizona', id: 'courseInfo',
flag: '9/9d/Flag_of_Arizona.svg/45px-Flag_of_Arizona.svg.png', icon: 'tablerBook',
title: 'Course Info',
subtitle: 'Select your course',
}, },
{ {
name: 'Arkansas', id: 'parentInfo',
flag: '9/9d/Flag_of_Arkansas.svg/45px-Flag_of_Arkansas.svg.png', icon: 'tablerUsers',
title: 'Parent Info',
subtitle: 'Guardian details',
}, },
{ {
name: 'California', id: 'documents',
flag: '0/01/Flag_of_California.svg/45px-Flag_of_California.svg.png', icon: 'tablerFolder',
}, title: 'Documents',
{ subtitle: 'Upload certificates',
name: 'Colorado',
flag: '4/46/Flag_of_Colorado.svg/45px-Flag_of_Colorado.svg.png',
},
{
name: 'Connecticut',
flag: '9/96/Flag_of_Connecticut.svg/39px-Flag_of_Connecticut.svg.png',
},
{
name: 'Delaware',
flag: 'c/c6/Flag_of_Delaware.svg/45px-Flag_of_Delaware.svg.png',
},
{
name: 'Florida',
flag: 'f/f7/Flag_of_Florida.svg/45px-Flag_of_Florida.svg.png',
},
{
name: 'Georgia',
flag: '5/54/Flag_of_Georgia_%28U.S._state%29.svg/46px-Flag_of_Georgia_%28U.S._state%29.svg.png',
},
{
name: 'Hawaii',
flag: 'e/ef/Flag_of_Hawaii.svg/46px-Flag_of_Hawaii.svg.png',
},
{ name: 'Idaho', flag: 'a/a4/Flag_of_Idaho.svg/38px-Flag_of_Idaho.svg.png' },
{
name: 'Illinois',
flag: '0/01/Flag_of_Illinois.svg/46px-Flag_of_Illinois.svg.png',
},
{
name: 'Indiana',
flag: 'a/ac/Flag_of_Indiana.svg/45px-Flag_of_Indiana.svg.png',
},
{ name: 'Iowa', flag: 'a/aa/Flag_of_Iowa.svg/44px-Flag_of_Iowa.svg.png' },
{
name: 'Kansas',
flag: 'd/da/Flag_of_Kansas.svg/46px-Flag_of_Kansas.svg.png',
},
{
name: 'Kentucky',
flag: '8/8d/Flag_of_Kentucky.svg/46px-Flag_of_Kentucky.svg.png',
},
{
name: 'Louisiana',
flag: 'e/e0/Flag_of_Louisiana.svg/46px-Flag_of_Louisiana.svg.png',
},
{ name: 'Maine', flag: '3/35/Flag_of_Maine.svg/45px-Flag_of_Maine.svg.png' },
{
name: 'Maryland',
flag: 'a/a0/Flag_of_Maryland.svg/45px-Flag_of_Maryland.svg.png',
},
{
name: 'Massachusetts',
flag: 'f/f2/Flag_of_Massachusetts.svg/46px-Flag_of_Massachusetts.svg.png',
},
{
name: 'Michigan',
flag: 'b/b5/Flag_of_Michigan.svg/45px-Flag_of_Michigan.svg.png',
},
{
name: 'Minnesota',
flag: 'b/b9/Flag_of_Minnesota.svg/46px-Flag_of_Minnesota.svg.png',
},
{
name: 'Mississippi',
flag: '4/42/Flag_of_Mississippi.svg/45px-Flag_of_Mississippi.svg.png',
},
{
name: 'Missouri',
flag: '5/5a/Flag_of_Missouri.svg/46px-Flag_of_Missouri.svg.png',
},
{
name: 'Montana',
flag: 'c/cb/Flag_of_Montana.svg/45px-Flag_of_Montana.svg.png',
},
{
name: 'Nebraska',
flag: '4/4d/Flag_of_Nebraska.svg/46px-Flag_of_Nebraska.svg.png',
},
{
name: 'Nevada',
flag: 'f/f1/Flag_of_Nevada.svg/45px-Flag_of_Nevada.svg.png',
},
{
name: 'New Hampshire',
flag: '2/28/Flag_of_New_Hampshire.svg/45px-Flag_of_New_Hampshire.svg.png',
},
{
name: 'New Jersey',
flag: '9/92/Flag_of_New_Jersey.svg/45px-Flag_of_New_Jersey.svg.png',
},
{
name: 'New Mexico',
flag: 'c/c3/Flag_of_New_Mexico.svg/45px-Flag_of_New_Mexico.svg.png',
},
{
name: 'New York',
flag: '1/1a/Flag_of_New_York.svg/46px-Flag_of_New_York.svg.png',
},
{
name: 'North Carolina',
flag: 'b/bb/Flag_of_North_Carolina.svg/45px-Flag_of_North_Carolina.svg.png',
},
{
name: 'North Dakota',
flag: 'e/ee/Flag_of_North_Dakota.svg/38px-Flag_of_North_Dakota.svg.png',
},
{ name: 'Ohio', flag: '4/4c/Flag_of_Ohio.svg/46px-Flag_of_Ohio.svg.png' },
{
name: 'Oklahoma',
flag: '6/6e/Flag_of_Oklahoma.svg/45px-Flag_of_Oklahoma.svg.png',
},
{
name: 'Oregon',
flag: 'b/b9/Flag_of_Oregon.svg/46px-Flag_of_Oregon.svg.png',
},
{
name: 'Pennsylvania',
flag: 'f/f7/Flag_of_Pennsylvania.svg/45px-Flag_of_Pennsylvania.svg.png',
},
{
name: 'Rhode Island',
flag: 'f/f3/Flag_of_Rhode_Island.svg/32px-Flag_of_Rhode_Island.svg.png',
},
{
name: 'South Carolina',
flag: '6/69/Flag_of_South_Carolina.svg/45px-Flag_of_South_Carolina.svg.png',
},
{
name: 'South Dakota',
flag: '1/1a/Flag_of_South_Dakota.svg/46px-Flag_of_South_Dakota.svg.png',
},
{
name: 'Tennessee',
flag: '9/9e/Flag_of_Tennessee.svg/46px-Flag_of_Tennessee.svg.png',
},
{ name: 'Texas', flag: 'f/f7/Flag_of_Texas.svg/45px-Flag_of_Texas.svg.png' },
{ name: 'Utah', flag: 'f/f6/Flag_of_Utah.svg/45px-Flag_of_Utah.svg.png' },
{
name: 'Vermont',
flag: '4/49/Flag_of_Vermont.svg/46px-Flag_of_Vermont.svg.png',
},
{
name: 'Virginia',
flag: '4/47/Flag_of_Virginia.svg/44px-Flag_of_Virginia.svg.png',
},
{
name: 'Washington',
flag: '5/54/Flag_of_Washington.svg/46px-Flag_of_Washington.svg.png',
},
{
name: 'West Virginia',
flag: '2/22/Flag_of_West_Virginia.svg/46px-Flag_of_West_Virginia.svg.png',
},
{
name: 'Wisconsin',
flag: '2/22/Flag_of_Wisconsin.svg/45px-Flag_of_Wisconsin.svg.png',
},
{
name: 'Wyoming',
flag: 'b/bc/Flag_of_Wyoming.svg/43px-Flag_of_Wyoming.svg.png',
}, },
] ]

View File

@ -1,254 +0,0 @@
import { Component, ViewChild } from '@angular/core'
import { UiCard } from '@app/components/ui-card'
import { NgIcon } from '@ng-icons/core'
import { FormsModule } from '@angular/forms'
import { NgbTypeahead, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'
import {
debounceTime,
distinctUntilChanged,
filter,
map,
merge,
Observable,
OperatorFunction,
Subject,
} from 'rxjs'
import {
states,
statesWithFlags,
} from '@/app/modules/components/data'
@Component({
selector: 'app-typeaheds',
imports: [UiCard, NgIcon, FormsModule, NgbTypeaheadModule],
template: `
<app-ui-card title="Typeahead" bodyClass="p-0">
<div card-body>
<div class="card-body rounded-bottom-0">
<p class="text-muted mb-2">
A flexible JavaScript library that provides a strong foundation for
building robust typeaheads
</p>
<a
class="btn btn-link shadow-none p-0 fw-semibold"
href="https://twitter.github.io/typeahead.js/"
target="_blank"
>
View Official Website
<ng-icon name="tablerChevronRight" class="ms-1" />
</a>
</div>
<div class="card-body rounded-top-0 border-top-0">
<div class="row g-3">
<div class="col-lg-6">
<h5 class="fw-semibold mb-1">Basic</h5>
</div>
<div class="col-lg-6">
<input
class="form-control typeahead"
type="text"
[(ngModel)]="basicTypeahead"
[ngbTypeahead]="search"
placeholder="Enter states from USA"
/>
</div>
</div>
<div class="my-4 border-top border-dashed"></div>
<div class="row g-3">
<div class="col-lg-6">
<h5 class="fw-semibold mb-1">Open on focus</h5>
</div>
<div class="col-lg-6">
<input
class="form-control bloodhound-typeahead"
type="text"
[(ngModel)]="focusTypeahead"
[ngbTypeahead]="searchFocusTypeahead"
(focus)="focus$.next($any($event).target.value)"
(click)="click$.next($any($event).target.value)"
#instance="ngbTypeahead"
placeholder="Enter states from USA"
/>
</div>
</div>
<div class="my-4 border-top border-dashed"></div>
<div class="row g-3">
<div class="col-lg-6">
<h5 class="fw-semibold mb-1">Formatted results</h5>
</div>
<div class="col-lg-6">
<input
class="form-control"
type="text"
[(ngModel)]="formattedTypeahead"
[ngbTypeahead]="formatterSearch"
[resultFormatter]="formatter"
placeholder="Enter states from USA"
/>
</div>
</div>
<div class="my-4 border-top border-dashed"></div>
<div class="row g-3">
<div class="col-lg-6">
<h5 class="fw-semibold mb-1">Select on exact</h5>
</div>
<div class="col-lg-6">
<input
class="form-control"
type="text"
[(ngModel)]="exactSearchTypeahead"
[ngbTypeahead]="searchExact"
[inputFormatter]="exactFormatter"
[resultFormatter]="exactFormatter"
[selectOnExact]="true"
placeholder="Search Exact"
/>
</div>
</div>
<div class="my-4 border-top border-dashed"></div>
<div class="row g-3">
<div class="col-lg-6">
<h5 class="fw-semibold mb-1">Custom Template</h5>
</div>
<div class="col-lg-6">
<ng-template #rt let-r="result" let-t="term">
<img
[src]="
'https://upload.wikimedia.org/wikipedia/commons/thumb/' +
r['flag']
"
class="me-1"
style="width: 16px"
/>
<ngb-highlight [result]="r.name" [term]="t"></ngb-highlight>
</ng-template>
<input
class="form-control custom-template-typeahead"
type="text"
[(ngModel)]="customTypeahead"
[ngbTypeahead]="searchWithFlags"
[resultTemplate]="rt"
[inputFormatter]="nameFormatter"
placeholder="custom template"
/>
</div>
</div>
</div>
</div>
</app-ui-card>
`,
styles: ``,
})
export class Typeaheds {
basicTypeahead: any
focusTypeahead: any
formattedTypeahead: any
exactSearchTypeahead: any
customTypeahead: any
search: OperatorFunction<string, readonly string[]> = (
text$: Observable<string>
) =>
text$.pipe(
debounceTime(200),
distinctUntilChanged(),
map((term) =>
term.length < 2
? []
: states
.filter((v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1)
.slice(0, 10)
)
)
@ViewChild('instance', { static: true }) instance!: NgbTypeahead
focus$ = new Subject<string>()
click$ = new Subject<string>()
searchFocusTypeahead: OperatorFunction<string, readonly string[]> = (
text$: Observable<string>
) => {
const debouncedText$ = text$.pipe(debounceTime(200), distinctUntilChanged())
const clicksWithClosedPopup$ = this.click$.pipe(
filter(() => !this.instance.isPopupOpen())
)
const inputFocus$ = this.focus$
return merge(debouncedText$, inputFocus$, clicksWithClosedPopup$).pipe(
map((term) =>
(term === ''
? states
: states.filter(
(v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1
)
).slice(0, 10)
)
)
}
formatter = (result: string) => result.toUpperCase()
formatterSearch: OperatorFunction<string, readonly string[]> = (
text$: Observable<string>
) =>
text$.pipe(
debounceTime(200),
distinctUntilChanged(),
map((term) =>
term === ''
? []
: states
.filter((v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1)
.slice(0, 10)
)
)
searchExact: OperatorFunction<string, readonly string[]> = (
text$: Observable<string>
) =>
text$.pipe(
debounceTime(200),
map((term) =>
term === ''
? []
: states
.filter((v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1)
.slice(0, 10)
)
)
exactFormatter = (x: string) => x
searchWithFlags: OperatorFunction<
string,
readonly {
name: string
flag: string
}[]
> = (text$: Observable<string>) =>
text$.pipe(
debounceTime(200),
map((term) =>
term === ''
? []
: statesWithFlags
.filter(
(v) => v.name.toLowerCase().indexOf(term.toLowerCase()) > -1
)
.slice(0, 10)
)
)
nameFormatter = (x: { name: string }) => x.name
}

View File

@ -2,7 +2,7 @@ import { Component } from '@angular/core'
import { UiCard } from '@app/components/ui-card' import { UiCard } from '@app/components/ui-card'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIcon } from '@ng-icons/core' import { NgIcon } from '@ng-icons/core'
import { wizardSteps } from '@/app/modules/merchants/data' import { wizardSteps } from '@/app/modules/components/data'
@Component({ @Component({
selector: 'app-vertical-wizard', selector: 'app-vertical-wizard',

View File

@ -3,7 +3,7 @@ import { NgIcon } from '@ng-icons/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { UiCard } from '@app/components/ui-card' import { UiCard } from '@app/components/ui-card'
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap' import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap'
import { wizardSteps } from '@/app/modules/merchants/data' import { wizardSteps } from '@/app/modules/components/data'
@Component({ @Component({
selector: 'app-wizard-with-progress', selector: 'app-wizard-with-progress',

View File

@ -7,7 +7,11 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<app-basic-wizard />
<app-wizard-with-progress /> <app-wizard-with-progress />
<app-vertical-wizard />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,10 +1,12 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { PageTitle } from '@app/components/page-title/page-title' import { PageTitle } from '@app/components/page-title/page-title'
import { BasicWizard } from '@/app/modules/components/basic-wizard'
import { WizardWithProgress } from '@/app/modules/components/wizard-with-progress' import { WizardWithProgress } from '@/app/modules/components/wizard-with-progress'
import { VerticalWizard } from '@/app/modules/components/vertical-wizard'
@Component({ @Component({
selector: 'app-merchant-wizard', selector: 'app-wizard',
imports: [PageTitle, WizardWithProgress], imports: [PageTitle, BasicWizard, WizardWithProgress, VerticalWizard],
templateUrl: './wizard.html', templateUrl: './wizard.html',
styles: ``, styles: ``,
}) })

View File

@ -25,7 +25,7 @@ interface Transaction {
<div class="card-header justify-content-between align-items-center border-dashed"> <div class="card-header justify-content-between align-items-center border-dashed">
<h4 class="card-title mb-0">Transactions Récentes</h4> <h4 class="card-title mb-0">Transactions Récentes</h4>
<a href="javascript:void(0);" class="btn btn-sm btn-primary"> <a href="javascript:void(0);" class="btn btn-sm btn-primary">
<ng-icon name="lucideFileExport" class="me-1" /> <ng-icon name="lucideFile" class="me-1" />
Exporter Exporter
</a> </a>
</div> </div>

View File

@ -0,0 +1,436 @@
<app-ui-card title="Configuration Partenaire DCB">
<span helper-text class="badge badge-soft-success badge-label fs-xxs py-1">
Payment Hub DCB
</span>
<div class="ins-wizard" card-body>
<!-- Progress Bar -->
<ngb-progressbar
class="mb-4"
[value]="progressValue"
type="primary"
height="6px"
/>
<!-- Navigation Steps -->
<ul class="nav nav-tabs wizard-tabs" role="tablist">
@for (step of wizardSteps; track $index; let i = $index) {
<li class="nav-item">
<a
href="javascript:void(0);"
[class.active]="i === currentStep"
class="nav-link"
[class.disabled]="!isStepAccessible(i)"
[class.wizard-item-done]="i < currentStep"
(click)="goToStep(i)"
>
<span class="d-flex align-items-center">
<ng-icon [name]="step.icon" class="fs-32" />
<span class="flex-grow-1 ms-2 text-truncate">
<span class="mb-0 lh-base d-block fw-semibold text-body fs-base">
{{ step.title }}
</span>
<span class="mb-0 fw-normal">{{ step.subtitle }}</span>
</span>
</span>
</a>
</li>
}
</ul>
<!-- Messages -->
@if (configError) {
<div class="alert alert-danger mt-3">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ configError }}
</div>
}
@if (configSuccess) {
<div class="alert alert-success mt-3">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
{{ configSuccess }}
</div>
}
<!-- Contenu des Steps -->
<div class="tab-content pt-3">
@for (step of wizardSteps; track $index; let i = $index) {
<div
class="tab-pane fade"
[class.show]="currentStep === i"
[class.active]="currentStep === i"
>
<form [formGroup]="partnerForm">
<!-- Step 1: Informations Société -->
@if (i === 0) {
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Nom commercial *</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="name"
placeholder="Nom commercial" />
@if (companyInfo.get('name')?.invalid && companyInfo.get('name')?.touched) {
<div class="text-danger small">Le nom commercial est requis</div>
}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Raison sociale *</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="legalName"
placeholder="Raison sociale" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email *</label>
<div formGroupName="companyInfo">
<input type="email" class="form-control" formControlName="email"
placeholder="email@entreprise.com" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone *</label>
<div formGroupName="companyInfo">
<input type="tel" class="form-control" formControlName="phone"
placeholder="+225 XX XX XX XX" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Site web</label>
<div formGroupName="companyInfo">
<input type="url" class="form-control" formControlName="website"
placeholder="https://..." />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Catégorie *</label>
<div formGroupName="companyInfo">
<select class="form-select" formControlName="category">
@for (cat of categories; track cat.value) {
<option [value]="cat.value">{{ cat.label }}</option>
}
</select>
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Pays *</label>
<div formGroupName="companyInfo">
<select class="form-select" formControlName="country">
@for (country of countries; track country.code) {
<option [value]="country.code">{{ country.name }}</option>
}
</select>
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Devise *</label>
<div formGroupName="companyInfo">
<select class="form-select" formControlName="currency">
@for (currency of currencies; track currency.code) {
<option [value]="currency.code">{{ currency.name }}</option>
}
</select>
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Fuseau horaire *</label>
<div formGroupName="companyInfo">
<select class="form-select" formControlName="timezone">
@for (tz of timezones; track tz.value) {
<option [value]="tz.value">{{ tz.label }}</option>
}
</select>
</div>
</div>
</div>
}
<!-- Step 2: Adresse et Contact -->
@if (i === 1) {
<div class="row">
<div class="col-12 mb-4">
<h6 class="border-bottom pb-2">Adresse de l'entreprise</h6>
</div>
<div class="col-12 mb-3">
<label class="form-label">Rue *</label>
<div formGroupName="addressInfo">
<input type="text" class="form-control" formControlName="street"
placeholder="Adresse complète" />
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Ville *</label>
<div formGroupName="addressInfo">
<input type="text" class="form-control" formControlName="city"
placeholder="Ville" />
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Région *</label>
<div formGroupName="addressInfo">
<input type="text" class="form-control" formControlName="state"
placeholder="Région" />
</div>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Code postal *</label>
<div formGroupName="addressInfo">
<input type="text" class="form-control" formControlName="postalCode"
placeholder="Code postal" />
</div>
</div>
<div class="col-12 mb-4 mt-4">
<h6 class="border-bottom pb-2">Contact technique</h6>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom complet *</label>
<div formGroupName="technicalContact">
<input type="text" class="form-control" formControlName="name"
placeholder="Nom du contact technique" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Email *</label>
<div formGroupName="technicalContact">
<input type="email" class="form-control" formControlName="email"
placeholder="contact@entreprise.com" />
</div>
</div>
<div class="col-12 mb-3">
<label class="form-label">Téléphone *</label>
<div formGroupName="technicalContact">
<input type="tel" class="form-control" formControlName="phone"
placeholder="+225 XX XX XX XX" />
</div>
</div>
</div>
}
<!-- Step 3: Configuration Paiements -->
@if (i === 2) {
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Taux de commission (%) *</label>
<div formGroupName="paymentConfig">
<input type="number" class="form-control" formControlName="commissionRate"
min="0" max="100" step="0.1" />
<div class="form-text">Pourcentage prélevé sur chaque transaction</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Limite quotidienne (XOF) *</label>
<div formGroupName="paymentConfig">
<input type="number" class="form-control" formControlName="dailyLimit"
min="1000" />
<div class="form-text">Plafond total des transactions par jour</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Limite par transaction (XOF) *</label>
<div formGroupName="paymentConfig">
<input type="number" class="form-control" formControlName="transactionLimit"
min="100" max="500000" />
<div class="form-text">Montant maximum par transaction</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Montant minimum (XOF) *</label>
<div formGroupName="paymentConfig">
<input type="number" class="form-control" formControlName="minAmount"
min="1" />
</div>
</div>
<div class="col-12 mb-3">
<label class="form-label">Montant maximum (XOF) *</label>
<div formGroupName="paymentConfig">
<input type="number" class="form-control" formControlName="maxAmount"
min="100" />
</div>
</div>
</div>
}
<!-- Step 4: Webhooks -->
@if (i === 3) {
<div class="row">
<div class="col-12 mb-4">
<h6 class="border-bottom pb-2">Header Enrichment</h6>
<div formGroupName="webhookConfig">
<div formGroupName="headerEnrichment">
<div class="row mb-3">
<div class="col-md-8">
<label class="form-label">URL de callback</label>
<input type="url" class="form-control" formControlName="url"
placeholder="https://votre-domaine.com/api/header-enrichment" />
</div>
<div class="col-md-4">
<label class="form-label">Méthode HTTP</label>
<select class="form-select" formControlName="method">
@for (method of httpMethods; track method.value) {
<option [value]="method.value">{{ method.label }}</option>
}
</select>
</div>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label">Headers HTTP</label>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="addHeader()">
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Ajouter un header
</button>
</div>
@for (header of headerEnrichmentHeaders.controls; track $index; let idx = $index) {
<div class="row mb-2">
<div class="col-md-5">
<!-- CORRECTION ICI : Utilisation de getHeaderControl -->
<input type="text" class="form-control"
[formControl]="getHeaderControl(header, 'key')"
placeholder="Clé (ex: Authorization)" />
</div>
<div class="col-md-5">
<!-- CORRECTION ICI : Utilisation de getHeaderControl -->
<input type="text" class="form-control"
[formControl]="getHeaderControl(header, 'value')"
placeholder="Valeur" />
</div>
<div class="col-md-2">
<button type="button" class="btn btn-sm btn-outline-danger w-100"
(click)="removeHeader(idx)">
<ng-icon name="lucideTrash2"></ng-icon>
</button>
</div>
</div>
}
</div>
</div>
</div>
</div>
<div class="col-12 mb-4">
<h6 class="border-bottom pb-2">Webhooks Abonnements</h6>
<div formGroupName="webhookConfig">
<div formGroupName="subscription">
<div class="row">
<div class="col-md-6 mb-2">
<label class="form-label">Création d'abonnement</label>
<input type="url" class="form-control" formControlName="onCreate"
placeholder="https://votre-domaine.com/webhooks/subscription-created" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Renouvellement</label>
<input type="url" class="form-control" formControlName="onRenew"
placeholder="https://votre-domaine.com/webhooks/subscription-renewed" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Annulation</label>
<input type="url" class="form-control" formControlName="onCancel"
placeholder="https://votre-domaine.com/webhooks/subscription-cancelled" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Expiration</label>
<input type="url" class="form-control" formControlName="onExpire"
placeholder="https://votre-domaine.com/webhooks/subscription-expired" />
</div>
</div>
</div>
</div>
</div>
<div class="col-12 mb-4">
<h6 class="border-bottom pb-2">Webhooks Paiements</h6>
<div formGroupName="webhookConfig">
<div formGroupName="payment">
<div class="row">
<div class="col-md-4 mb-2">
<label class="form-label">Paiement réussi</label>
<input type="url" class="form-control" formControlName="onSuccess"
placeholder="https://votre-domaine.com/webhooks/payment-success" />
</div>
<div class="col-md-4 mb-2">
<label class="form-label">Paiement échoué</label>
<input type="url" class="form-control" formControlName="onFailure"
placeholder="https://votre-domaine.com/webhooks/payment-failed" />
</div>
<div class="col-md-4 mb-2">
<label class="form-label">Remboursement</label>
<input type="url" class="form-control" formControlName="onRefund"
placeholder="https://votre-domaine.com/webhooks/payment-refunded" />
</div>
</div>
</div>
</div>
</div>
</div>
}
<!-- Step 5: Validation -->
@if (i === 4) {
<div class="row">
<div class="col-12">
<div class="alert alert-info">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
Vérifiez les informations avant de créer le partenaire
</div>
<div class="card">
<div class="card-body">
<h6 class="card-title">Récapitulatif</h6>
<div class="row">
<div class="col-md-6">
<strong>Informations Société:</strong><br>
{{ companyInfo.value.name || 'Non renseigné' }}<br>
{{ companyInfo.value.legalName || 'Non renseigné' }}<br>
{{ companyInfo.value.email || 'Non renseigné' }}<br>
{{ companyInfo.value.phone || 'Non renseigné' }}
</div>
<div class="col-md-6">
<strong>Configuration:</strong><br>
Commission: {{ paymentConfig.value.commissionRate || 0 }}%<br>
Limite quotidienne: {{ (paymentConfig.value.dailyLimit || 0) | number }} XOF<br>
Limite transaction: {{ (paymentConfig.value.transactionLimit || 0) | number }} XOF
</div>
</div>
</div>
</div>
</div>
</div>
}
</form>
<!-- Navigation Buttons -->
<div class="d-flex justify-content-between mt-4">
@if (i > 0) {
<button type="button" class="btn btn-secondary" (click)="previousStep()">
← Précédent
</button>
} @else {
<div></div>
}
@if (i < wizardSteps.length - 1) {
<button type="button" class="btn btn-primary" (click)="nextStep()"
[disabled]="!isStepValid(i)">
Suivant →
</button>
} @else {
<button type="button" class="btn btn-success"
(click)="submitForm()" [disabled]="configLoading">
@if (configLoading) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Créer le Partenaire
</button>
}
</div>
</div>
}
</div>
</div>
</app-ui-card>

View File

@ -0,0 +1,2 @@
import { PartnerConfig } from './config';
describe('PartnerConfig', () => {});

View File

@ -0,0 +1,317 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup, FormControl } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap';
import { UiCard } from '@app/components/ui-card';
import { PartnerConfigService } from '../services/partner-config.service';
import { CreatePartnerDto, PartnerCategory } from '../models/partners-config.model';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-partner-config',
standalone: true,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
NgIcon,
NgbProgressbarModule,
UiCard
],
templateUrl: './config.html'
})
export class PartnerConfig implements OnInit {
private fb = inject(FormBuilder);
private PartnerConfigService = inject(PartnerConfigService);
// Configuration wizard
currentStep = 0;
wizardSteps = [
{ id: 'company-info', icon: 'lucideBuilding', title: 'Informations Société', subtitle: 'Détails entreprise' },
{ id: 'contact-info', icon: 'lucideUser', title: 'Contact Principal', subtitle: 'Personne de contact' },
{ id: 'payment-config', icon: 'lucideCreditCard', title: 'Configuration Paiements', subtitle: 'Paramètres DCB' },
{ id: 'webhooks', icon: 'lucideWebhook', title: 'Webhooks', subtitle: 'Notifications et retours' },
{ id: 'review', icon: 'lucideCheckCircle', title: 'Validation', subtitle: 'Vérification finale' }
];
configLoading = false;
configError = '';
configSuccess = '';
// Formulaires
partnerForm = this.fb.group({
companyInfo: this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
legalName: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]],
phone: ['', [Validators.required]],
website: [''],
category: ['E_COMMERCE', [Validators.required]],
country: ['CIV', [Validators.required]],
currency: ['XOF', [Validators.required]],
timezone: ['Africa/Abidjan', [Validators.required]]
}),
addressInfo: this.fb.group({
street: ['', [Validators.required]],
city: ['', [Validators.required]],
state: ['', [Validators.required]],
postalCode: ['', [Validators.required]],
country: ['CIV', [Validators.required]]
}),
technicalContact: this.fb.group({
name: ['', [Validators.required]],
email: ['', [Validators.required, Validators.email]],
phone: ['', [Validators.required]]
}),
paymentConfig: this.fb.group({
commissionRate: [2.5, [Validators.required, Validators.min(0), Validators.max(100)]],
dailyLimit: [1000000, [Validators.required, Validators.min(1000)]],
transactionLimit: [50000, [Validators.required, Validators.min(100), Validators.max(500000)]],
minAmount: [100, [Validators.required, Validators.min(1)]],
maxAmount: [500000, [Validators.required, Validators.min(100)]]
}),
webhookConfig: this.fb.group({
headerEnrichment: this.fb.group({
url: ['', [Validators.pattern('https?://.+')]],
method: ['POST'],
headers: this.fb.array([])
}),
subscription: this.fb.group({
onCreate: ['', [Validators.pattern('https?://.+')]],
onRenew: ['', [Validators.pattern('https?://.+')]],
onCancel: ['', [Validators.pattern('https?://.+')]],
onExpire: ['', [Validators.pattern('https?://.+')]]
}),
payment: this.fb.group({
onSuccess: ['', [Validators.pattern('https?://.+')]],
onFailure: ['', [Validators.pattern('https?://.+')]],
onRefund: ['', [Validators.pattern('https?://.+')]]
}),
authentication: this.fb.group({
onSuccess: ['', [Validators.pattern('https?://.+')]],
onFailure: ['', [Validators.pattern('https?://.+')]]
})
})
});
// Données partagées
countries = [
{ code: 'CIV', name: 'Côte d\'Ivoire' },
{ code: 'SEN', name: 'Sénégal' },
{ code: 'CMR', name: 'Cameroun' },
{ code: 'GHA', name: 'Ghana' },
{ code: 'NGA', name: 'Nigeria' }
];
categories = [
{ value: 'E_COMMERCE', label: 'E-Commerce' },
{ value: 'GAMING', label: 'Jeux & Gaming' },
{ value: 'ENTERTAINMENT', label: 'Divertissement' },
{ value: 'UTILITIES', label: 'Services Publics' },
{ value: 'DIGITAL_CONTENT', label: 'Contenu Digital' },
{ value: 'SERVICES', label: 'Services' },
{ value: 'OTHER', label: 'Autre' }
];
currencies = [
{ code: 'XOF', name: 'Franc CFA' },
{ code: 'EUR', name: 'Euro' },
{ code: 'USD', name: 'Dollar US' }
];
timezones = [
{ value: 'Africa/Abidjan', label: 'Abidjan (GMT)' },
{ value: 'Africa/Lagos', label: 'Lagos (WAT)' },
{ value: 'Africa/Johannesburg', label: 'Johannesburg (SAST)' }
];
httpMethods = [
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' }
];
ngOnInit() {}
// Navigation du wizard
get progressValue(): number {
return ((this.currentStep + 1) / this.wizardSteps.length) * 100;
}
nextStep() {
if (this.currentStep < this.wizardSteps.length - 1 && this.isStepValid(this.currentStep)) {
this.currentStep++;
}
}
previousStep() {
if (this.currentStep > 0) {
this.currentStep--;
}
}
goToStep(index: number) {
if (this.isStepAccessible(index)) {
this.currentStep = index;
}
}
isStepAccessible(index: number): boolean {
if (index === 0) return true;
for (let i = 0; i < index; i++) {
if (!this.isStepValid(i)) {
return false;
}
}
return true;
}
isStepValid(stepIndex: number): boolean {
switch (stepIndex) {
case 0: // Company Info
return this.companyInfo.valid;
case 1: // Contact Info
return this.addressInfo.valid && this.technicalContact.valid;
case 2: // Payment Config
return this.paymentConfig.valid;
case 3: // Webhooks (toujours valide car optionnel)
return true;
case 4: // Review
return this.partnerForm.valid;
default:
return false;
}
}
// Gestion des headers dynamiques - CORRECTION ICI
get headerEnrichmentHeaders(): FormArray {
return this.headerEnrichment.get('headers') as FormArray;
}
// Méthode pour obtenir un FormControl sécurisé - NOUVELLE MÉTHODE
getHeaderControl(header: any, field: string): FormControl {
return header.get(field) as FormControl;
}
addHeader() {
const headerGroup = this.fb.group({
key: ['', Validators.required],
value: ['', Validators.required]
});
this.headerEnrichmentHeaders.push(headerGroup);
}
removeHeader(index: number) {
this.headerEnrichmentHeaders.removeAt(index);
}
// Soumission du formulaire
async submitForm() {
if (this.partnerForm.valid) {
this.configLoading = true;
this.configError = '';
try {
const formData = this.partnerForm.value;
const createPartnerDto: CreatePartnerDto = {
name: this.safeString(formData.companyInfo?.name) || '',
legalName: this.safeString(formData.companyInfo?.legalName) || '',
email: this.safeString(formData.companyInfo?.email) || '',
phone: this.safeString(formData.companyInfo?.phone) || '',
website: this.safeString(formData.companyInfo?.website) || '',
category: (this.safeString(formData.companyInfo?.category) as PartnerCategory) || 'OTHER',
country: this.safeString(formData.companyInfo?.country) || 'CIV',
currency: this.safeString(formData.companyInfo?.currency) || 'XOF',
timezone: this.safeString(formData.companyInfo?.timezone) || 'Africa/Abidjan',
commissionRate: this.safeNumber(formData.paymentConfig?.commissionRate) || 0,
dailyLimit: this.safeNumber(formData.paymentConfig?.dailyLimit) || 0,
transactionLimit: this.safeNumber(formData.paymentConfig?.transactionLimit) || 0,
minAmount: this.safeNumber(formData.paymentConfig?.minAmount) || 0,
maxAmount: this.safeNumber(formData.paymentConfig?.maxAmount) || 0,
};
const response = await firstValueFrom(
this.PartnerConfigService.createPartnerConfig(createPartnerDto)
);
if (response.success && response.data) {
this.configSuccess = `Partenaire créé avec succès! ID: ${response.data.id}`;
this.partnerForm.reset();
this.currentStep = 0;
} else {
this.configError = response.error || 'Erreur lors de la création du partenaire';
}
} catch (error) {
this.configError = 'Erreur lors de la création du partenaire';
console.error('Error creating partner:', error);
} finally {
this.configLoading = false;
}
} else {
this.configError = 'Veuillez corriger les erreurs dans le formulaire';
this.markAllFieldsAsTouched();
}
}
// Méthodes utilitaires
private safeString(value: string | null | undefined): string {
return value || '';
}
private safeNumber(value: number | null | undefined): number {
return value || 0;
}
private markAllFieldsAsTouched() {
Object.keys(this.partnerForm.controls).forEach(key => {
const control = this.partnerForm.get(key);
if (control instanceof FormGroup) {
Object.keys(control.controls).forEach(subKey => {
control.get(subKey)?.markAsTouched();
});
} else {
control?.markAsTouched();
}
});
}
// Getters pour les formulaires - CORRECTION ICI (suppression des ?)
get companyInfo() {
return this.partnerForm.get('companyInfo') as FormGroup;
}
get addressInfo() {
return this.partnerForm.get('addressInfo') as FormGroup;
}
get technicalContact() {
return this.partnerForm.get('technicalContact') as FormGroup;
}
get paymentConfig() {
return this.partnerForm.get('paymentConfig') as FormGroup;
}
get webhookConfig() {
return this.partnerForm.get('webhookConfig') as FormGroup;
}
get headerEnrichment() {
return this.webhookConfig.get('headerEnrichment') as FormGroup;
}
get subscription() {
return this.webhookConfig.get('subscription') as FormGroup;
}
get payment() {
return this.webhookConfig.get('payment') as FormGroup;
}
get authentication() {
return this.webhookConfig.get('authentication') as FormGroup;
}
}

View File

@ -0,0 +1,399 @@
<!-- src/app/modules/merchant-users/list/list.html -->
<app-ui-card title="Équipe Marchande">
<a
helper-text
href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold"
>
@if (canViewAllMerchants) {
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Vue administrative - Tous les utilisateurs marchands
} @else if (isDcbPartner) {
<ng-icon name="lucideUsers" class="me-1"></ng-icon>
Votre équipe marchande
} @else {
<ng-icon name="lucideBuilding" class="me-1"></ng-icon>
Utilisateurs de votre partenaire marchand
}
</a>
<div card-body>
<!-- Indicateur de contexte -->
@if (canViewAllMerchants) {
<div class="alert alert-info mb-3">
<div class="d-flex align-items-center">
<ng-icon name="lucideShield" class="me-2"></ng-icon>
<div>
<strong>Vue administrative DCB :</strong> Vous visualisez tous les utilisateurs marchands de la plateforme
</div>
</div>
</div>
} @else if (isDcbPartner) {
<div class="alert alert-primary mb-3">
<div class="d-flex align-items-center">
<ng-icon name="lucideBuilding" class="me-2"></ng-icon>
<div>
<strong>Vue partenaire marchand :</strong> Vous gérez les utilisateurs de votre propre équipe
<small class="d-block text-muted">Merchant Partner ID: {{ currentMerchantPartnerId }}</small>
</div>
</div>
</div>
}
<!-- Barre d'actions supérieure -->
<div class="row mb-3">
<div class="col-md-6">
<div class="d-flex align-items-center gap-2">
<!-- Statistiques rapides -->
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
[class.active]="roleFilter === 'all'"
(click)="filterByRole('all')"
>
Tous ({{ allUsers.length }})
</button>
<button
type="button"
class="btn btn-outline-danger"
[class.active]="roleFilter === UserRole.DCB_PARTNER_ADMIN"
(click)="filterByRole(UserRole.DCB_PARTNER_ADMIN)"
>
Admins ({{ getUsersCountByRole(UserRole.DCB_PARTNER_ADMIN) }})
</button>
<button
type="button"
class="btn btn-outline-warning text-dark"
[class.active]="roleFilter === UserRole.DCB_PARTNER_MANAGER"
(click)="filterByRole(UserRole.DCB_PARTNER_MANAGER)"
>
Managers ({{ getUsersCountByRole(UserRole.DCB_PARTNER_MANAGER) }})
</button>
<button
type="button"
class="btn btn-outline-info"
[class.active]="roleFilter === UserRole.DCB_PARTNER_SUPPORT"
(click)="filterByRole(UserRole.DCB_PARTNER_SUPPORT)"
>
Support ({{ getUsersCountByRole(UserRole.DCB_PARTNER_SUPPORT) }})
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end gap-2">
@if (!canViewAllMerchants) {
<button
class="btn btn-primary"
(click)="openCreateModal.emit()"
>
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Nouvel Utilisateur
</button>
}
</div>
</div>
</div>
<!-- Barre de recherche et filtres -->
<div class="row mb-3">
<div class="col-md-3">
<div class="input-group">
<span class="input-group-text">
<ng-icon name="lucideSearch"></ng-icon>
</span>
<input
type="text"
class="form-control"
placeholder="Nom, email, username..."
[(ngModel)]="searchTerm"
(keyup.enter)="onSearch()"
>
</div>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="statusFilter" (change)="onSearch()">
<option value="all">Tous les statuts</option>
<option value="enabled">Activés ({{ getEnabledUsersCount() }})</option>
<option value="disabled">Désactivés ({{ getDisabledUsersCount() }})</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="emailVerifiedFilter" (change)="onSearch()">
<option value="all">Tous les emails</option>
<option value="verified">Email vérifié</option>
<option value="not-verified">Email non vérifié</option>
</select>
</div>
<div class="col-md-2">
<select class="form-select" [(ngModel)]="roleFilter" (change)="onSearch()">
@for (role of availableRoles; track role.value) {
<option [value]="role.value">{{ role.label }}</option>
}
</select>
</div>
<div class="col-md-3">
<div class="d-flex gap-2">
<button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter" class="me-1"></ng-icon>
Appliquer
</button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX" class="me-1"></ng-icon>
Réinitialiser
</button>
</div>
</div>
</div>
<!-- Loading State -->
@if (loading) {
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des utilisateurs marchands...</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger" role="alert">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
</div>
</div>
}
<!-- Users Table -->
@if (!loading && !error) {
<div class="table-responsive">
<table class="table table-hover table-striped">
<thead class="table-light">
<tr>
<!-- Colonne Merchant Partner uniquement pour les admins -->
@if (canViewAllMerchants) {
<th (click)="sort('merchantPartnerId')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Merchant Partner</span>
<ng-icon [name]="getSortIcon('merchantPartnerId')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
}
<th (click)="sort('username')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Utilisateur</span>
<ng-icon [name]="getSortIcon('username')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('email')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Email</span>
<ng-icon [name]="getSortIcon('email')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('role')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Rôle</span>
<ng-icon [name]="getSortIcon('role')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('enabled')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Statut</span>
<ng-icon [name]="getSortIcon('enabled')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('createdTimestamp')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Créé le</span>
<ng-icon [name]="getSortIcon('createdTimestamp')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th width="180">Actions</th>
</tr>
</thead>
<tbody>
@for (user of displayedUsers; track user.id) {
<tr>
<!-- Colonne Merchant Partner uniquement pour les admins -->
@if (canViewAllMerchants) {
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideBuilding" class="text-secondary fs-12"></ng-icon>
</div>
<div>
<small class="text-muted font-monospace" [title]="user.merchantPartnerId || 'N/A'">
{{ (user.merchantPartnerId || 'N/A').substring(0, 8) }}...
</small>
</div>
</div>
</td>
}
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<span class="text-primary fw-semibold small">
{{ getUserInitials(user) }}
</span>
</div>
<div>
<strong class="d-block">{{ getUserDisplayName(user) }}</strong>
<small class="text-muted">@{{ user.username }}</small>
</div>
</div>
</td>
<td>
<div class="d-flex align-items-center">
{{ user.email }}
@if (!user.emailVerified) {
<ng-icon
name="lucideAlertTriangle"
class="ms-1 text-warning"
size="16"
title="Email non vérifié"
></ng-icon>
}
</div>
</td>
<td>
<span class="badge d-flex align-items-center" [ngClass]="getRoleBadgeClass(user.role)">
<ng-icon [name]="getRoleIcon(user.role)" class="me-1" size="14"></ng-icon>
{{ getRoleDisplayName(user.role) }}
</span>
</td>
<td>
<span [class]="getStatusBadgeClass(user)">
{{ getStatusText(user) }}
</span>
</td>
<td>
<small class="text-muted">
{{ formatTimestamp(user.createdTimestamp) }}
</small>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary btn-sm"
(click)="viewUserProfile(user.id)"
title="Voir le profil"
>
<ng-icon name="lucideEye"></ng-icon>
</button>
<button
class="btn btn-outline-warning btn-sm"
(click)="resetPassword(user)"
title="Réinitialiser le mot de passe"
>
<ng-icon name="lucideKey"></ng-icon>
</button>
@if (user.enabled) {
<button
class="btn btn-outline-secondary btn-sm"
(click)="disableUser(user)"
title="Désactiver l'utilisateur"
>
<ng-icon name="lucideUserX"></ng-icon>
</button>
} @else {
<button
class="btn btn-outline-success btn-sm"
(click)="enableUser(user)"
title="Activer l'utilisateur"
>
<ng-icon name="lucideUserCheck"></ng-icon>
</button>
}
@if (!canViewAllMerchants) {
<button
class="btn btn-outline-danger btn-sm"
(click)="deleteUser(user)"
title="Supprimer l'utilisateur"
>
<ng-icon name="lucideTrash2"></ng-icon>
</button>
}
</div>
</td>
</tr>
}
@empty {
<tr>
<td [attr.colspan]="canViewAllMerchants ? 7 : 6" class="text-center py-4">
<div class="text-muted">
<ng-icon name="lucideUsers" class="fs-1 mb-3 opacity-50"></ng-icon>
<h5 class="mb-2">Aucun utilisateur marchand trouvé</h5>
<p class="mb-3">Aucun utilisateur ne correspond à vos critères de recherche.</p>
@if (!canViewAllMerchants) {
<button class="btn btn-primary" (click)="openCreateModal.emit()">
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Créer le premier utilisateur
</button>
}
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Pagination -->
@if (totalPages > 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} utilisateurs
</div>
<nav>
<ngb-pagination
[collectionSize]="totalItems"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
[rotate]="true"
[boundaryLinks]="true"
(pageChange)="onPageChange($event)"
/>
</nav>
</div>
}
<!-- Résumé des résultats -->
@if (displayedUsers.length > 0) {
<div class="mt-3 pt-3 border-top">
<div class="row text-center">
<div class="col">
<small class="text-muted">
<strong>Total :</strong> {{ allUsers.length }} utilisateurs
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Actifs :</strong> {{ getEnabledUsersCount() }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Admins :</strong> {{ getUsersCountByRole(UserRole.DCB_PARTNER_ADMIN) }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Managers :</strong> {{ getUsersCountByRole(UserRole.DCB_PARTNER_MANAGER) }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Support :</strong> {{ getUsersCountByRole(UserRole.DCB_PARTNER_SUPPORT) }}
</small>
</div>
</div>
</div>
}
}
</div>
</app-ui-card>

View File

@ -0,0 +1,2 @@
import { PartnerTeamList } from './list';
describe('PartnerTeamList', () => {});

View File

@ -0,0 +1,559 @@
// src/app/modules/merchant-users/list/list.ts
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { catchError, map, of, Subject, takeUntil } from 'rxjs';
import {
MerchantUsersService,
MerchantUserResponse,
UserRole,
} from '../services/merchant-partners.service';
import { HubUsersService, UserRole as HubUserRole } from '../../users/services/users.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';
@Component({
selector: 'app-merchant-users-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UiCard,
NgbPaginationModule,
NgbDropdownModule
],
templateUrl: './list.html',
})
export class MerchantUsersList implements OnInit, OnDestroy {
private merchantUsersService = inject(MerchantUsersService);
private hubUsersService = inject(HubUsersService);
private authService = inject(AuthService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
readonly UserRole = UserRole;
readonly HubUserRole = HubUserRole;
@Output() userSelected = new EventEmitter<string>();
@Output() openCreateModal = new EventEmitter<void>();
@Output() openResetPasswordModal = new EventEmitter<string>();
@Output() openDeleteUserModal = new EventEmitter<string>();
// Données
allUsers: MerchantUserResponse[] = [];
filteredUsers: MerchantUserResponse[] = [];
displayedUsers: MerchantUserResponse[] = [];
// États
loading = false;
error = '';
// Recherche et filtres
searchTerm = '';
statusFilter: 'all' | 'enabled' | 'disabled' = 'all';
emailVerifiedFilter: 'all' | 'verified' | 'not-verified' = 'all';
roleFilter: UserRole | 'all' = 'all';
// Pagination
currentPage = 1;
itemsPerPage = 10;
totalItems = 0;
totalPages = 0;
// Tri
sortField: keyof MerchantUserResponse = 'username';
sortDirection: 'asc' | 'desc' = 'asc';
// Rôles disponibles pour le filtre
availableRoles: { value: UserRole | 'all'; label: string }[] = [
{ value: 'all', label: 'Tous les rôles' },
{ value: UserRole.DCB_PARTNER_ADMIN, label: 'Administrateurs' },
{ value: UserRole.DCB_PARTNER_MANAGER, label: 'Managers' },
{ value: UserRole.DCB_PARTNER_SUPPORT, label: 'Support' }
];
// ID du merchant partner courant et permissions
currentMerchantPartnerId: string = '';
currentUserRole: HubUserRole | null = null;
isHubAdminOrSupport = false;
canViewAllMerchants = false;
isDcbPartner = false;
ngOnInit() {
this.loadCurrentUserPermissions();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadCurrentUserPermissions() {
this.authService.getProfile().subscribe({
next: (user: any) => {
// Méthode robuste pour récupérer le rôle
this.currentUserRole = this.extractUserRole(user);
// Déterminer le type d'utilisateur
this.isHubAdminOrSupport = this.currentUserRole === HubUserRole.DCB_ADMIN ||
this.currentUserRole === HubUserRole.DCB_SUPPORT;
this.isDcbPartner = this.currentUserRole === HubUserRole.DCB_PARTNER;
this.canViewAllMerchants = this.isHubAdminOrSupport;
// Déterminer le merchantPartnerId
this.currentMerchantPartnerId = this.extractMerchantPartnerId(user);
console.log('🎯 Final Permissions:', {
currentUserRole: this.currentUserRole,
isHubAdminOrSupport: this.isHubAdminOrSupport,
isDcbPartner: this.isDcbPartner,
canViewAllMerchants: this.canViewAllMerchants,
currentMerchantPartnerId: this.currentMerchantPartnerId
});
this.loadUsers();
},
error: (error) => {
console.error('❌ Error loading current user permissions:', error);
this.loadUsers(); // Charger quand même les utilisateurs
}
});
}
/**
* Extrait le rôle de l'utilisateur de manière robuste
*/
private extractUserRole(user: any): HubUserRole | null {
// Essayer différentes sources possibles pour le rôle
if (user.roles && user.roles.length > 0) {
return user.roles[0] as HubUserRole;
}
if (user.role) {
return user.role as HubUserRole;
}
console.warn('No role found in user profile');
return null;
}
/**
* Extrait le merchantPartnerId de manière robuste
*/
private extractMerchantPartnerId(user: any): string {
if (this.isDcbPartner) {
// Pour DCB_PARTNER, utiliser son ID comme merchantPartnerId
return user.id || '';
}
// Pour les autres, chercher le merchantPartnerId dans différentes sources
if (user.merchantPartnerId) {
return user.merchantPartnerId;
}
if (user.attributes?.merchantPartnerId?.[0]) {
return user.attributes.merchantPartnerId[0];
}
if (user.attributes?.partnerId?.[0]) {
return user.attributes.partnerId[0];
}
console.warn('No merchantPartnerId found in user profile');
return '';
}
loadUsers() {
this.loading = true;
this.error = '';
console.log('🚀 Loading users with permissions:', {
canViewAllMerchants: this.canViewAllMerchants,
currentMerchantPartnerId: this.currentMerchantPartnerId,
currentUserRole: this.currentUserRole
});
let usersObservable;
if (this.canViewAllMerchants) {
// Admin/Support DCB : charger tous les utilisateurs via HubUsersService
console.log('📊 Loading ALL merchant users (DCB Admin view)');
usersObservable = this.hubUsersService.findAllMerchantUsers(1, 1000).pipe(
map((response: any) => {
console.log('📦 Hub Users API Response:', response);
// Adapter selon la structure de votre API
if (response && response.users) {
return response.users;
} else if (Array.isArray(response)) {
return response;
}
return [];
}),
catchError(error => {
console.error('❌ Error loading hub users:', error);
return of([]);
})
);
} else if (this.currentMerchantPartnerId) {
// Utilisateur marchand (DCB_PARTNER) : charger seulement ses utilisateurs
console.log('🏢 Loading merchant users for partner:', this.currentMerchantPartnerId);
usersObservable = this.merchantUsersService.getMyMerchantUsers().pipe(
catchError(error => {
console.error('❌ Error loading merchant users:', error);
return of([]);
})
);
} else {
this.error = 'Impossible de déterminer les permissions de chargement';
this.loading = false;
console.error('❌ No valid permission scenario');
return;
}
usersObservable
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (users: any[]) => {
this.allUsers = users || [];
this.applyFiltersAndPagination();
this.loading = false;
this.cdRef.detectChanges();
},
error: (error: any) => {
this.error = 'Erreur lors du chargement des utilisateurs marchands';
this.loading = false;
this.allUsers = [];
this.filteredUsers = [];
this.displayedUsers = [];
this.cdRef.detectChanges();
console.error('❌ Error in users subscription:', error);
}
});
}
// Recherche et filtres
onSearch() {
this.currentPage = 1;
this.applyFiltersAndPagination();
}
onClearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.emailVerifiedFilter = 'all';
this.roleFilter = 'all';
this.currentPage = 1;
this.applyFiltersAndPagination();
}
applyFiltersAndPagination() {
// Vérifier que allUsers est défini
if (!this.allUsers) {
this.allUsers = [];
}
// Appliquer les filtres
this.filteredUsers = this.allUsers.filter(user => {
// Filtre de recherche
const matchesSearch = !this.searchTerm ||
user.username.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
(user.firstName && user.firstName.toLowerCase().includes(this.searchTerm.toLowerCase())) ||
(user.lastName && user.lastName.toLowerCase().includes(this.searchTerm.toLowerCase()));
// Filtre par statut
const matchesStatus = this.statusFilter === 'all' ||
(this.statusFilter === 'enabled' && user.enabled) ||
(this.statusFilter === 'disabled' && !user.enabled);
// Filtre par email vérifié
const matchesEmailVerified = this.emailVerifiedFilter === 'all' ||
(this.emailVerifiedFilter === 'verified' && user.emailVerified) ||
(this.emailVerifiedFilter === 'not-verified' && !user.emailVerified);
// Filtre par rôle
const matchesRole = this.roleFilter === 'all' || user.role === this.roleFilter;
return matchesSearch && matchesStatus && matchesEmailVerified && matchesRole;
});
// Appliquer le tri
this.filteredUsers.sort((a, b) => {
const aValue = a[this.sortField];
const bValue = b[this.sortField];
if (aValue === bValue) return 0;
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
comparison = aValue - bValue;
} else if (typeof aValue === 'boolean' && typeof bValue === 'boolean') {
comparison = (aValue === bValue) ? 0 : aValue ? -1 : 1;
}
return this.sortDirection === 'asc' ? comparison : -comparison;
});
// Calculer la pagination
this.totalItems = this.filteredUsers.length;
this.totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
// Appliquer la pagination
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
const endIndex = startIndex + this.itemsPerPage;
this.displayedUsers = this.filteredUsers.slice(startIndex, endIndex);
}
// Tri
sort(field: keyof MerchantUserResponse) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
this.applyFiltersAndPagination();
}
getSortIcon(field: keyof MerchantUserResponse): string {
if (this.sortField !== field) return 'lucideArrowUpDown';
return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown';
}
// Pagination
onPageChange(page: number) {
this.currentPage = page;
this.applyFiltersAndPagination();
}
getStartIndex(): number {
return (this.currentPage - 1) * this.itemsPerPage + 1;
}
getEndIndex(): number {
return Math.min(this.currentPage * this.itemsPerPage, this.totalItems);
}
// Actions
viewUserProfile(userId: string) {
this.userSelected.emit(userId);
}
// Méthode pour réinitialiser le mot de passe
resetPassword(user: MerchantUserResponse) {
this.openResetPasswordModal.emit(user.id);
}
// Méthode pour ouvrir le modal de suppression
deleteUser(user: MerchantUserResponse) {
this.openDeleteUserModal.emit(user.id);
}
// Activer un utilisateur
enableUser(user: MerchantUserResponse) {
this.merchantUsersService.enableMerchantUser(user.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedUser) => {
user.enabled = updatedUser.enabled;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error enabling merchant user:', error);
this.error = 'Erreur lors de l\'activation de l\'utilisateur';
this.cdRef.detectChanges();
}
});
}
// Désactiver un utilisateur
disableUser(user: MerchantUserResponse) {
this.merchantUsersService.disableMerchantUser(user.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedUser) => {
user.enabled = updatedUser.enabled;
this.applyFiltersAndPagination();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error disabling merchant user:', error);
this.error = 'Erreur lors de la désactivation de l\'utilisateur';
this.cdRef.detectChanges();
}
});
}
// ==================== UTILITAIRES D'AFFICHAGE ====================
getStatusBadgeClass(user: MerchantUserResponse): string {
if (!user.enabled) return 'badge bg-danger';
if (!user.emailVerified) return 'badge bg-warning';
return 'badge bg-success';
}
getStatusText(user: MerchantUserResponse): string {
if (!user.enabled) return 'Désactivé';
if (!user.emailVerified) return 'Email non vérifié';
return 'Actif';
}
getRoleBadgeClass(role: UserRole): string {
switch (role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'bg-danger';
case UserRole.DCB_PARTNER_MANAGER:
return 'bg-warning text-dark';
case UserRole.DCB_PARTNER_SUPPORT:
return 'bg-info text-white';
default:
return 'bg-secondary';
}
}
getRoleDisplayName(role: UserRole): string {
const roleNames = {
[UserRole.DCB_PARTNER_ADMIN]: 'Administrateur',
[UserRole.DCB_PARTNER_MANAGER]: 'Manager',
[UserRole.DCB_PARTNER_SUPPORT]: 'Support'
};
return roleNames[role] || role;
}
getRoleIcon(role: UserRole): string {
switch (role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'lucideShield';
case UserRole.DCB_PARTNER_MANAGER:
return 'lucideUserCog';
case UserRole.DCB_PARTNER_SUPPORT:
return 'lucideHeadphones';
default:
return 'lucideUser';
}
}
formatTimestamp(timestamp: number): string {
if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
getUserInitials(user: MerchantUserResponse): string {
return (user.firstName?.charAt(0) || '') + (user.lastName?.charAt(0) || '') || 'U';
}
getUserDisplayName(user: MerchantUserResponse): string {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`;
}
return user.username;
}
// ==================== STATISTIQUES ====================
getUsersCountByRole(role: UserRole): number {
return this.allUsers.filter(user => user.role === role).length;
}
getEnabledUsersCount(): number {
return this.allUsers.filter(user => user.enabled).length;
}
getDisabledUsersCount(): number {
return this.allUsers.filter(user => !user.enabled).length;
}
getEmailVerifiedCount(): number {
return this.allUsers.filter(user => user.emailVerified).length;
}
getTotalUsersCount(): number {
return this.allUsers.length;
}
// ==================== VÉRIFICATIONS DE PERMISSIONS ====================
canManageUser(user: MerchantUserResponse): boolean {
// Logique pour déterminer si l'utilisateur connecté peut gérer cet utilisateur
// À adapter selon votre logique métier
return true;
}
canDeleteUser(user: MerchantUserResponse): boolean {
// Empêcher la suppression de soi-même
// À adapter avec l'ID de l'utilisateur connecté
return user.id !== 'current-user-id';
}
// ==================== MÉTHODES UTILITAIRES ====================
hasRole(user: MerchantUserResponse, role: UserRole): boolean {
return user.role === role;
}
isAdmin(user: MerchantUserResponse): boolean {
return this.hasRole(user, UserRole.DCB_PARTNER_ADMIN);
}
isManager(user: MerchantUserResponse): boolean {
return this.hasRole(user, UserRole.DCB_PARTNER_MANAGER);
}
isSupport(user: MerchantUserResponse): boolean {
return this.hasRole(user, UserRole.DCB_PARTNER_SUPPORT);
}
// Recherche rapide par rôle
filterByRole(role: UserRole | 'all') {
this.roleFilter = role;
this.currentPage = 1;
this.applyFiltersAndPagination();
}
// Recherche via le service (pour des recherches plus complexes)
searchUsers() {
if (this.searchTerm.trim()) {
this.loading = true;
this.merchantUsersService.searchMerchantUsers({
query: this.searchTerm,
role: this.roleFilter !== 'all' ? this.roleFilter as UserRole : undefined,
enabled: this.statusFilter !== 'all' ? this.statusFilter === 'enabled' : undefined
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (users) => {
this.allUsers = users;
this.applyFiltersAndPagination();
this.loading = false;
this.cdRef.detectChanges();
},
error: (error: any) => {
console.error('Error searching users:', error);
this.loading = false;
this.cdRef.detectChanges();
}
});
} else {
this.loadUsers(); // Recharger tous les utilisateurs si la recherche est vide
}
}
}

View File

@ -0,0 +1,519 @@
<!-- src/app/modules/merchant-users/merchant-users.html -->
<div class="container-fluid">
<app-page-title
title="Gestion des Utilisateurs Marchands"
subTitle="Administrez les utilisateurs de votre écosystème marchand"
[badge]="{icon:'lucideUsers', text:'Merchant Users'}"
/>
<!-- Navigation par onglets avec style bordered -->
<div class="row mb-4">
<div class="col-12">
<ul
ngbNav
#merchantUsersNav="ngbNav"
[activeId]="activeTab"
[destroyOnHide]="false"
class="nav nav-tabs nav-justified nav-bordered nav-bordered-primary mb-3"
>
<li [ngbNavItem]="'list'">
<a ngbNavLink (click)="showTab('list')">
<ng-icon name="lucideUsers" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Équipe Marchande</span>
</a>
<ng-template ngbNavContent>
<app-merchant-users-list
(userSelected)="onUserSelected($event)"
(openCreateModal)="openCreateUserModal()"
(openResetPasswordModal)="onResetPasswordRequested($event)"
(openDeleteUserModal)="onDeleteUserRequested($event)"
/>
</ng-template>
</li>
<li [ngbNavItem]="'profile'" [hidden]="activeTab !== 'profile'">
<a ngbNavLink (click)="showTab('profile')">
<ng-icon name="lucideUser" class="fs-lg me-md-1 d-inline-flex align-middle" />
<span class="d-none d-md-inline-block align-middle">Profil Utilisateur</span>
</a>
<ng-template ngbNavContent>
@if (selectedUserId) {
<app-merchant-user-profile
[userId]="selectedUserId"
(back)="backToList()"
(openResetPasswordModal)="onResetPasswordRequested($event)"
/>
}
</ng-template>
</li>
</ul>
<div class="tab-content" [ngbNavOutlet]="merchantUsersNav"></div>
</div>
</div>
</div>
<!-- Modal de création d'utilisateur marchand -->
<ng-template #createUserModal let-modal>
<div class="modal-header">
<h4 class="modal-title">
<ng-icon name="lucideUserPlus" class="me-2"></ng-icon>
Créer un nouvel utilisateur marchand
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
[disabled]="creatingUser"
></button>
</div>
<div class="modal-body">
<!-- Message d'erreur -->
@if (createUserError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ createUserError }}</div>
</div>
}
<form (ngSubmit)="createMerchantUser()" #userForm="ngForm">
<div class="row g-3">
<!-- Informations de base -->
<div class="col-md-6">
<label class="form-label">
Prénom <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="Entrez le prénom"
[(ngModel)]="newMerchantUser.firstName"
name="firstName"
required
[disabled]="creatingUser"
>
</div>
<div class="col-md-6">
<label class="form-label">
Nom <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="Entrez le nom"
[(ngModel)]="newMerchantUser.lastName"
name="lastName"
required
[disabled]="creatingUser"
>
</div>
<div class="col-md-6">
<label class="form-label">
Nom d'utilisateur <span class="text-danger">*</span>
</label>
<input
type="text"
class="form-control"
placeholder="Nom d'utilisateur unique"
[(ngModel)]="newMerchantUser.username"
name="username"
required
[disabled]="creatingUser"
>
<div class="form-text">Doit être unique dans le système</div>
</div>
<div class="col-md-6">
<label class="form-label">
Email <span class="text-danger">*</span>
</label>
<input
type="email"
class="form-control"
placeholder="email@exemple.com"
[(ngModel)]="newMerchantUser.email"
name="email"
required
[disabled]="creatingUser"
>
</div>
<div class="col-12">
<label class="form-label">
Mot de passe <span class="text-danger">*</span>
</label>
<input
type="password"
class="form-control"
placeholder="Mot de passe sécurisé"
[(ngModel)]="newMerchantUser.password"
name="password"
required
minlength="8"
[disabled]="creatingUser"
>
<div class="form-text">
Le mot de passe doit contenir au moins 8 caractères.
</div>
</div>
<!-- Sélection du rôle -->
<div class="col-12">
<label class="form-label">
Rôle <span class="text-danger">*</span>
</label>
<select
class="form-select"
[(ngModel)]="newMerchantUser.role"
name="role"
required
[disabled]="creatingUser"
>
<option value="" disabled>Sélectionnez un rôle</option>
@if (availableRoles) {
@for (role of availableRoles.roles; track role.value) {
<option
[value]="role.value"
[disabled]="!role.allowedForCreation"
>
{{ role.label }} - {{ role.description }}
@if (!role.allowedForCreation) {
(Non autorisé)
}
</option>
}
}
</select>
<div class="form-text">
Sélectionnez le rôle à assigner à cet utilisateur marchand
</div>
</div>
<!-- Aperçu du rôle sélectionné -->
@if (newMerchantUser.role) {
<div class="col-12">
<div class="alert alert-info">
<div class="d-flex align-items-center">
<ng-icon
[name]="getRoleIcon(newMerchantUser.role)"
class="me-2"
></ng-icon>
<div>
<strong>Rôle sélectionné :</strong>
<span class="badge ms-2" [ngClass]="getRoleBadgeClass(newMerchantUser.role)">
{{ getRoleDisplayName(newMerchantUser.role) }}
</span>
<br>
<small class="text-muted">
{{ getRoleDescription(newMerchantUser.role) }}
</small>
</div>
</div>
</div>
</div>
}
<!-- Configuration du compte -->
<div class="col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="enabledSwitch"
[(ngModel)]="newMerchantUser.enabled"
name="enabled"
[disabled]="creatingUser"
checked
>
<label class="form-check-label" for="enabledSwitch">
Compte activé
</label>
</div>
<div class="form-text">L'utilisateur peut se connecter immédiatement</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="emailVerifiedSwitch"
[(ngModel)]="newMerchantUser.emailVerified"
name="emailVerified"
[disabled]="creatingUser"
>
<label class="form-check-label" for="emailVerifiedSwitch">
Email vérifié
</label>
</div>
<div class="form-text">L'utilisateur n'aura pas à vérifier son email</div>
</div>
<!-- Informations système (lecture seule) -->
<div class="col-12">
<div class="alert alert-light">
<small class="text-muted">
<strong>Informations système :</strong><br>
• Merchant Partner ID : {{ currentMerchantPartnerId || 'Chargement...' }}<br>
• Type d'utilisateur : MERCHANT<br>
• Créé par : Utilisateur courant
</small>
</div>
</div>
</div>
<div class="modal-footer mt-4">
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="creatingUser"
>
Annuler
</button>
<button
type="submit"
class="btn btn-primary"
[disabled]="!userForm.form.valid || creatingUser || !isRoleAllowedForCreation(newMerchantUser.role)"
>
@if (creatingUser) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
Création...
} @else {
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Créer l'utilisateur
}
</button>
</div>
</form>
</div>
</ng-template>
<!-- Modal de réinitialisation de mot de passe -->
<ng-template #resetPasswordModal let-modal>
<div class="modal-header">
<h4 class="modal-title">
<ng-icon name="lucideKey" class="me-2"></ng-icon>
Réinitialiser le mot de passe
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
[disabled]="resettingPassword"
></button>
</div>
<div class="modal-body">
<!-- Message de succès -->
@if (resetPasswordSuccess) {
<div class="alert alert-success d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ resetPasswordSuccess }}</div>
</div>
}
<!-- Message d'erreur -->
@if (resetPasswordError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ resetPasswordError }}</div>
</div>
}
@if (!resetPasswordSuccess && selectedUserForReset) {
<div class="alert alert-info">
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3">
<span class="text-primary fw-bold">{{ getUserInitials(selectedUserForReset) }}</span>
</div>
<div>
<strong>{{ selectedUserForReset.username }}</strong>
@if (selectedUserForReset.firstName || selectedUserForReset.lastName) {
<br>
{{ selectedUserForReset.firstName }} {{ selectedUserForReset.lastName }}
}
<br>
<small class="text-muted">
<span class="badge" [ngClass]="getRoleBadgeClass(selectedUserForReset.role)">
{{ getRoleDisplayName(selectedUserForReset.role) }}
</span>
• Merchant Partner: {{ selectedUserForReset.merchantPartnerId }}
</small>
</div>
</div>
</div>
<form (ngSubmit)="confirmResetPassword()" #resetForm="ngForm">
<div class="mb-3">
<label class="form-label">
Nouveau mot de passe <span class="text-danger">*</span>
</label>
<input
type="password"
class="form-control"
placeholder="Entrez le nouveau mot de passe"
[(ngModel)]="newPassword"
name="newPassword"
required
minlength="8"
[disabled]="resettingPassword"
>
<div class="form-text">
Le mot de passe doit contenir au moins 8 caractères.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="temporaryPassword"
[(ngModel)]="temporaryPassword"
name="temporaryPassword"
[disabled]="resettingPassword"
checked
>
<label class="form-check-label" for="temporaryPassword">
Mot de passe temporaire
</label>
</div>
<div class="form-text">
L'utilisateur devra changer son mot de passe à la prochaine connexion.
</div>
</div>
</form>
}
</div>
<div class="modal-footer">
@if (resetPasswordSuccess) {
<button
type="button"
class="btn btn-success"
(click)="modal.close()"
>
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Fermer
</button>
} @else {
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="resettingPassword"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
(click)="confirmResetPassword()"
[disabled]="!newPassword || newPassword.length < 8 || resettingPassword"
>
@if (resettingPassword) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
Réinitialisation...
} @else {
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser le mot de passe
}
</button>
}
</div>
</ng-template>
<!-- Modal de confirmation de suppression -->
<ng-template #deleteUserModal let-modal>
<div class="modal-header">
<h4 class="modal-title text-danger">
<ng-icon name="lucideTrash2" class="me-2"></ng-icon>
Confirmer la suppression
</h4>
<button
type="button"
class="btn-close"
(click)="modal.dismiss()"
></button>
</div>
<div class="modal-body text-center">
<div class="mb-4">
<div class="avatar-lg mx-auto mb-3 bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideUserX" class="text-danger" style="font-size: 2rem;"></ng-icon>
</div>
<h5 class="text-danger mb-2">Êtes-vous sûr de vouloir supprimer cet utilisateur ?</h5>
<p class="text-muted mb-0">
Cette action est irréversible. Toutes les données de cet utilisateur marchand seront définitivement perdues.
</p>
</div>
@if (selectedUserForDelete) {
<div class="alert alert-warning">
<div class="d-flex align-items-start">
<ng-icon name="lucideAlertTriangle" class="me-2 mt-1 text-warning"></ng-icon>
<div>
<strong>Utilisateur :</strong> {{ selectedUserForDelete.username }}
@if (selectedUserForDelete.firstName || selectedUserForDelete.lastName) {
<br>
<strong>Nom :</strong> {{ selectedUserForDelete.firstName }} {{ selectedUserForDelete.lastName }}
}
<br>
<strong>Email :</strong> {{ selectedUserForDelete.email }}
<br>
<strong>Rôle :</strong>
<span class="badge" [ngClass]="getRoleBadgeClass(selectedUserForDelete.role)">
{{ getRoleDisplayName(selectedUserForDelete.role) }}
</span>
<br>
<strong>Merchant Partner :</strong> {{ selectedUserForDelete.merchantPartnerId }}
</div>
</div>
</div>
}
<!-- Message d'erreur -->
@if (deleteUserError) {
<div class="alert alert-danger d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ deleteUserError }}</div>
</div>
}
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="deletingUser"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button>
<button
type="button"
class="btn btn-danger"
(click)="confirmDeleteUser()"
[disabled]="deletingUser"
>
@if (deletingUser) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Suppression...</span>
</div>
Suppression...
} @else {
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer définitivement
}
</button>
</div>
</ng-template>

View File

@ -0,0 +1,2 @@
import { MerchantPartners } from './merchant-partners';
describe('Merchant Partners', () => {});

View File

@ -0,0 +1,486 @@
import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { Subject, takeUntil } from 'rxjs';
import { PageTitle } from '@app/components/page-title/page-title';
import { MerchantUsersList } from './list/list';
import { MerchantUserProfile } from './profile/profile';
import {
MerchantUsersService,
CreateMerchantUserDto,
MerchantUserResponse,
UserRole,
AvailableRolesResponse,
} from './services/merchant-partners.service';
import { AuthService } from '@core/services/auth.service';
@Component({
selector: 'app-merchant-partners',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
NgbNavModule,
NgbModalModule,
PageTitle,
MerchantUsersList,
MerchantUserProfile
],
templateUrl: './merchant-partners.html',
})
export class MerchantPartners implements OnInit, OnDestroy {
private modalService = inject(NgbModal);
private authService = inject(AuthService);
private merchantUsersService = inject(MerchantUsersService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
activeTab: 'list' | 'stats' | 'profile' = 'list';
selectedUserId: string | null = null;
currentMerchantPartnerId: string = '';
// Données pour la création d'utilisateur marchand
newMerchantUser: CreateMerchantUserDto = {
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
role: UserRole.DCB_PARTNER_SUPPORT,
merchantPartnerId: '',
enabled: true,
emailVerified: false
};
availableRoles: AvailableRolesResponse | null = null;
creatingUser = false;
createUserError = '';
// Données pour la réinitialisation de mot de passe
selectedUserForReset: MerchantUserResponse | null = null;
newPassword = '';
temporaryPassword = true;
resettingPassword = false;
resetPasswordError = '';
resetPasswordSuccess = '';
selectedUserForDelete: MerchantUserResponse | null = null;
deletingUser = false;
deleteUserError = '';
ngOnInit() {
this.activeTab = 'list';
this.loadCurrentMerchantPartnerId();
this.loadAvailableRoles();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadCurrentMerchantPartnerId() {
this.authService.getProfile().subscribe({
next: (user) => {
this.currentMerchantPartnerId = user.merchantPartnerId || '';
this.newMerchantUser.merchantPartnerId = this.currentMerchantPartnerId;
},
error: (error) => {
console.error('Error loading current merchant partner ID:', error);
}
});
}
private loadAvailableRoles() {
this.merchantUsersService.getAvailableMerchantRoles()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (roles) => {
this.availableRoles = roles;
// Sélectionner le premier rôle disponible par défaut
const firstAllowedRole = roles.roles.find(role => role.allowedForCreation);
if (firstAllowedRole) {
this.newMerchantUser.role = firstAllowedRole.value as any;
}
},
error: (error) => {
console.error('Error loading available roles:', error);
}
});
}
showTab(tab: 'list' | 'stats' | 'profile', userId?: string) {
this.activeTab = tab;
if (userId) {
this.selectedUserId = userId;
}
}
backToList() {
this.activeTab = 'list';
this.selectedUserId = null;
}
// Méthodes de gestion des événements du composant enfant
onUserSelected(userId: string) {
this.showTab('profile', userId);
}
onResetPasswordRequested(userId: string) {
this.openResetPasswordModal(userId);
}
onDeleteUserRequested(userId: string) {
this.openDeleteUserModal(userId);
}
// Méthodes pour les modals
openModal(content: TemplateRef<any>, size: 'sm' | 'lg' | 'xl' = 'lg') {
this.modalService.open(content, {
size: size,
centered: true,
scrollable: true
});
}
// Méthode pour ouvrir le modal de création d'utilisateur
openCreateUserModal() {
this.resetUserForm();
this.createUserError = '';
this.openModal(this.createUserModal);
}
// Réinitialiser le formulaire de création
private resetUserForm() {
this.newMerchantUser = {
username: '',
email: '',
firstName: '',
lastName: '',
password: '',
role: UserRole.DCB_PARTNER_SUPPORT,
merchantPartnerId: this.currentMerchantPartnerId,
enabled: true,
emailVerified: false
};
}
// Méthode pour ouvrir le modal de réinitialisation de mot de passe
openResetPasswordModal(userId: string) {
this.merchantUsersService.getMerchantUserById(userId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.selectedUserForReset = user;
this.newPassword = '';
this.temporaryPassword = true;
this.resetPasswordError = '';
this.resetPasswordSuccess = '';
this.openModal(this.resetPasswordModal);
},
error: (error) => {
console.error('Error loading user for password reset:', error);
this.resetPasswordError = 'Erreur lors du chargement de l\'utilisateur';
}
});
}
// Méthode pour ouvrir le modal de suppression
openDeleteUserModal(userId: string) {
this.merchantUsersService.getMerchantUserById(userId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.selectedUserForDelete = user;
this.deleteUserError = '';
this.openModal(this.deleteUserModal);
},
error: (error) => {
console.error('Error loading user for deletion:', error);
this.deleteUserError = 'Erreur lors du chargement de l\'utilisateur';
}
});
}
// Créer un utilisateur marchand
createMerchantUser() {
const validation = this.validateUserForm();
if (!validation.isValid) {
this.createUserError = validation.error!;
return;
}
this.creatingUser = true;
this.createUserError = '';
// S'assurer que le merchantPartnerId est défini
this.newMerchantUser.merchantPartnerId = this.currentMerchantPartnerId;
this.merchantUsersService.createMerchantUser(this.newMerchantUser)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (createdUser) => {
this.creatingUser = false;
this.modalService.dismissAll();
this.refreshUsersList();
this.showSuccessMessage(`Utilisateur "${createdUser.username}" créé avec succès`);
},
error: (error) => {
this.creatingUser = false;
this.createUserError = this.getErrorMessage(error);
}
});
}
// Réinitialiser le mot de passe
confirmResetPassword() {
if (!this.selectedUserForReset || !this.newPassword || this.newPassword.length < 8) {
this.resetPasswordError = 'Veuillez saisir un mot de passe valide (au moins 8 caractères).';
return;
}
this.resettingPassword = true;
this.resetPasswordError = '';
this.resetPasswordSuccess = '';
this.merchantUsersService.resetMerchantUserPassword(
this.selectedUserForReset.id,
{
newPassword: this.newPassword,
temporary: this.temporaryPassword
}
).pipe(takeUntil(this.destroy$))
.subscribe({
next: (response) => {
this.resettingPassword = false;
this.resetPasswordSuccess = 'Mot de passe réinitialisé avec succès !';
this.cdRef.detectChanges();
// Fermer le modal après 2 secondes
setTimeout(() => {
this.modalService.dismissAll();
}, 2000);
},
error: (error) => {
this.resettingPassword = false;
this.resetPasswordError = this.getResetPasswordErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
confirmDeleteUser() {
if (!this.selectedUserForDelete) return;
this.deletingUser = true;
this.deleteUserError = '';
this.merchantUsersService.deleteMerchantUser(this.selectedUserForDelete.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.deletingUser = false;
this.modalService.dismissAll();
this.refreshUsersList();
this.showSuccessMessage(`Utilisateur "${this.selectedUserForDelete?.username}" supprimé avec succès`);
this.cdRef.detectChanges();
},
error: (error) => {
this.deletingUser = false;
this.deleteUserError = this.getDeleteErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
@ViewChild(MerchantUsersList) usersListComponent!: MerchantUsersList;
private refreshUsersList(): void {
if (this.usersListComponent && typeof this.usersListComponent.loadUsers === 'function') {
this.usersListComponent.loadUsers();
} else {
console.warn('MerchantUsersList component not available for refresh');
this.showTab('list');
}
}
// ==================== GESTION DES ERREURS ====================
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 400) {
return 'Données invalides. Vérifiez les champs du formulaire.';
}
if (error.status === 409) {
return 'Un utilisateur avec ce nom d\'utilisateur ou email existe déjà.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour créer cet utilisateur.';
}
return 'Erreur lors de la création de l\'utilisateur. Veuillez réessayer.';
}
private getResetPasswordErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 404) {
return 'Utilisateur non trouvé.';
}
if (error.status === 400) {
return 'Le mot de passe ne respecte pas les critères de sécurité.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour réinitialiser ce mot de passe.';
}
return 'Erreur lors de la réinitialisation du mot de passe. Veuillez réessayer.';
}
private getDeleteErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 404) {
return 'Utilisateur non trouvé.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour supprimer cet utilisateur.';
}
if (error.status === 409) {
return 'Impossible de supprimer cet utilisateur car il est associé à des données.';
}
return 'Erreur lors de la suppression de l\'utilisateur. Veuillez réessayer.';
}
// ==================== VALIDATION DU FORMULAIRE ====================
private validateUserForm(): { isValid: boolean; error?: string } {
const requiredFields = [
{ field: this.newMerchantUser.username?.trim(), name: 'Nom d\'utilisateur' },
{ field: this.newMerchantUser.email?.trim(), name: 'Email' },
{ field: this.newMerchantUser.firstName?.trim(), name: 'Prénom' },
{ field: this.newMerchantUser.lastName?.trim(), name: 'Nom' }
];
for (const { field, name } of requiredFields) {
if (!field) {
return { isValid: false, error: `${name} est requis` };
}
}
// Validation email
const email = this.newMerchantUser.email?.trim();
if (!email) {
return { isValid: false, error: 'Email est requis' };
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return { isValid: false, error: 'Format d\'email invalide' };
}
if (!this.newMerchantUser.password || this.newMerchantUser.password.length < 8) {
return { isValid: false, error: 'Le mot de passe doit contenir au moins 8 caractères' };
}
if (!this.newMerchantUser.role) {
return { isValid: false, error: 'Le rôle est requis' };
}
if (!this.newMerchantUser.merchantPartnerId) {
return { isValid: false, error: 'Merchant Partner ID est requis' };
}
return { isValid: true };
}
// ==================== MESSAGES DE SUCCÈS ====================
private showSuccessMessage(message: string) {
// Vous pouvez implémenter un service de notification ici
console.log('Success:', message);
// Exemple avec un toast:
// this.notificationService.success(message);
}
// ==================== MÉTHODES UTILITAIRES ====================
getRoleDisplayName(role: UserRole): string {
const roleNames = {
[UserRole.DCB_PARTNER_ADMIN]: 'Administrateur Partenaire',
[UserRole.DCB_PARTNER_MANAGER]: 'Manager Partenaire',
[UserRole.DCB_PARTNER_SUPPORT]: 'Support Partenaire'
};
return roleNames[role] || role;
}
getRoleDescription(role: UserRole): string {
if (!this.availableRoles) return '';
const roleInfo = this.availableRoles.roles.find(r => r.value === role);
return roleInfo?.description || '';
}
isRoleAllowedForCreation(role: UserRole): boolean {
if (!this.availableRoles) return false;
const roleInfo = this.availableRoles.roles.find(r => r.value === role);
return roleInfo?.allowedForCreation || false;
}
// Méthodes utilitaires pour le template
getUserInitials(user: MerchantUserResponse): string {
return (user.firstName?.charAt(0) || '') + (user.lastName?.charAt(0) || '') || 'U';
}
getUserType(user: MerchantUserResponse): string {
switch (user.role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'Administrateur';
case UserRole.DCB_PARTNER_MANAGER:
return 'Manager';
case UserRole.DCB_PARTNER_SUPPORT:
return 'Support';
default:
return 'Utilisateur';
}
}
getRoleBadgeClass(role: UserRole): string {
switch (role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'bg-danger text-white';
case UserRole.DCB_PARTNER_MANAGER:
return 'bg-warning text-dark';
case UserRole.DCB_PARTNER_SUPPORT:
return 'bg-info text-white';
default:
return 'bg-secondary text-white';
}
}
getRoleIcon(role: UserRole): string {
switch (role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'lucideShield';
case UserRole.DCB_PARTNER_MANAGER:
return 'lucideUserCog';
case UserRole.DCB_PARTNER_SUPPORT:
return 'lucideHeadphones';
default:
return 'lucideUser';
}
}
// ==================== RÉFÉRENCES AUX TEMPLATES ====================
@ViewChild('createUserModal') createUserModal!: TemplateRef<any>;
@ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef<any>;
@ViewChild('deleteUserModal') deleteUserModal!: TemplateRef<any>;
}

View File

@ -0,0 +1,80 @@
export enum UserRole {
DCB_PARTNER_ADMIN = 'DCB_PARTNER_ADMIN',
DCB_PARTNER_MANAGER = 'DCB_PARTNER_MANAGER',
DCB_PARTNER_SUPPORT = 'DCB_PARTNER_SUPPORT',
DCB_PARTNER = 'DCB_PARTNER',
DCB_ADMIN = 'DCB_ADMIN',
DCB_SUPPORT = 'DCB_SUPPORT'
}
export interface CreateMerchantUserDto {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled?: boolean;
emailVerified?: boolean;
merchantPartnerId: string;
}
export interface UpdateMerchantUserDto {
firstName?: string;
lastName?: string;
email?: string;
enabled?: boolean;
}
export interface ResetPasswordDto {
newPassword: string;
temporary?: boolean;
}
export interface MerchantUserResponse {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled: boolean;
emailVerified: boolean;
merchantPartnerId: string;
createdBy: string;
createdByUsername: string;
createdTimestamp: number;
lastLogin?: number;
userType: 'MERCHANT';
}
export interface MerchantUsersStatsResponse {
totalAdmins: number;
totalManagers: number;
totalSupport: number;
totalUsers: number;
activeUsers: number;
inactiveUsers: number;
}
export interface AvailableRole {
value: UserRole;
label: string;
description: string;
allowedForCreation: boolean;
}
export interface AvailableRolesResponse {
roles: AvailableRole[];
}
export interface SearchMerchantUsersParams {
query?: string;
role?: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled?: boolean;
}
export interface RoleOperationResponse {
message: string;
success: boolean;
}

View File

@ -0,0 +1,531 @@
import { IsString, IsEmail, IsBoolean, IsOptional, IsArray, MinLength, IsNumber, IsEnum } from 'class-validator';
// ==================== TYPES AND ENUMS ====================
export type PartnerStatus =
| 'ACTIVE'
| 'INACTIVE'
export type PartnerCategory =
| 'E_COMMERCE'
| 'GAMING'
| 'ENTERTAINMENT'
| 'UTILITIES'
| 'DIGITAL_CONTENT'
| 'SERVICES'
| 'OTHER';
// ==================== CALLBACK CONFIGURATION ====================
export interface CallbackConfiguration {
headerEnrichment?: {
url?: string;
method?: 'GET' | 'POST';
headers?: { [key: string]: string };
};
subscription?: {
onCreate?: string;
onRenew?: string;
onCancel?: string;
onExpire?: string;
};
payment?: {
onSuccess?: string;
onFailure?: string;
onRefund?: string;
};
authentication?: {
onSuccess?: string;
onFailure?: string;
};
}
export class CallbackConfigurationImpl implements CallbackConfiguration {
headerEnrichment?: {
url?: string;
method?: 'GET' | 'POST';
headers?: { [key: string]: string };
} = {};
subscription?: {
onCreate?: string;
onRenew?: string;
onCancel?: string;
onExpire?: string;
} = {};
payment?: {
onSuccess?: string;
onFailure?: string;
onRefund?: string;
} = {};
authentication?: {
onSuccess?: string;
onFailure?: string;
} = {};
constructor(partial?: Partial<CallbackConfiguration>) {
if (partial) {
Object.assign(this, partial);
}
}
}
// ==================== CORE PARTNER MODELS ====================
export class PartnerAddress {
@IsString()
street: string = '';
@IsString()
city: string = '';
@IsString()
state: string = '';
@IsString()
postalCode: string = '';
@IsString()
country: string = '';
constructor(partial?: Partial<PartnerAddress>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class TechnicalContact {
@IsString()
name: string = '';
@IsEmail()
email: string = '';
@IsString()
phone: string = '';
constructor(partial?: Partial<TechnicalContact>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class PartnerStats {
@IsNumber()
totalTransactions: number = 0;
@IsNumber()
totalRevenue: number = 0;
@IsNumber()
successRate: number = 0;
@IsNumber()
refundRate: number = 0;
@IsNumber()
averageAmount: number = 0;
@IsNumber()
todayTransactions: number = 0;
@IsNumber()
todayRevenue: number = 0;
@IsNumber()
activeProducts: number = 0;
lastTransactionDate?: Date;
constructor(partial?: Partial<PartnerStats>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class Partner {
id: string = '';
name: string = '';
legalName: string = '';
email: string = '';
phone: string = '';
website: string = '';
@IsEnum(['ACTIVE', 'INACTIVE'])
status: PartnerStatus = 'ACTIVE';
@IsEnum(['E_COMMERCE', 'GAMING', 'ENTERTAINMENT', 'UTILITIES', 'DIGITAL_CONTENT', 'SERVICES', 'OTHER'])
category: PartnerCategory = 'OTHER';
country: string = '';
currency: string = '';
timezone: string = '';
// Configuration technique
apiKey: string = '';
secretKey: string = '';
webhookUrl: string = '';
callbacks: CallbackConfiguration = new CallbackConfigurationImpl();
// Limites et commissions
@IsNumber()
commissionRate: number = 0;
@IsNumber()
dailyLimit: number = 0;
@IsNumber()
transactionLimit: number = 0;
@IsNumber()
minAmount: number = 0;
@IsNumber()
maxAmount: number = 0;
// Adresse
address: PartnerAddress = new PartnerAddress();
// Contact technique
technicalContact: TechnicalContact = new TechnicalContact();
// Statistiques
stats: PartnerStats = new PartnerStats();
// Métadonnées
createdAt: Date = new Date();
updatedAt: Date = new Date();
createdBy: string = '';
constructor(partial?: Partial<Partner>) {
if (partial) {
Object.assign(this, partial);
}
}
}
// ==================== PARTNER DTOs ====================
export class CreatePartnerDto {
@IsString()
name: string = '';
@IsString()
legalName: string = '';
@IsEmail()
email: string = '';
@IsString()
phone: string = '';
@IsOptional()
@IsString()
website: string = '';
@IsEnum(['E_COMMERCE', 'GAMING', 'ENTERTAINMENT', 'UTILITIES', 'DIGITAL_CONTENT', 'SERVICES', 'OTHER'])
category: PartnerCategory = 'OTHER';
@IsString()
country: string = '';
@IsString()
currency: string = '';
@IsString()
timezone: string = '';
@IsNumber()
commissionRate: number = 0;
@IsNumber()
dailyLimit: number = 0;
@IsNumber()
transactionLimit: number = 0;
@IsNumber()
minAmount: number = 0;
@IsNumber()
maxAmount: number = 0;
@IsOptional()
address?: PartnerAddress;
@IsOptional()
technicalContact?: TechnicalContact;
constructor(partial?: Partial<CreatePartnerDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UpdatePartnerDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
legalName?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
website?: string;
@IsOptional()
@IsEnum(['ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING_VERIFICATION', 'BLOCKED'])
status?: PartnerStatus;
@IsOptional()
@IsEnum(['E_COMMERCE', 'GAMING', 'ENTERTAINMENT', 'UTILITIES', 'DIGITAL_CONTENT', 'SERVICES', 'OTHER'])
category?: PartnerCategory;
@IsOptional()
@IsNumber()
commissionRate?: number;
@IsOptional()
@IsNumber()
dailyLimit?: number;
@IsOptional()
@IsNumber()
transactionLimit?: number;
@IsOptional()
@IsNumber()
minAmount?: number;
@IsOptional()
@IsNumber()
maxAmount?: number;
@IsOptional()
address?: PartnerAddress;
@IsOptional()
technicalContact?: TechnicalContact;
constructor(partial?: Partial<UpdatePartnerDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UpdateCallbacksDto {
@IsOptional()
callbacks?: CallbackConfiguration;
constructor(partial?: Partial<UpdateCallbacksDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
// ==================== QUERY AND PAGINATION ====================
export class PartnerQuery {
@IsOptional()
page: number = 1;
@IsOptional()
limit: number = 10;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsEnum(['ACTIVE', 'INACTIVE', 'SUSPENDED', 'PENDING_VERIFICATION', 'BLOCKED'])
status?: PartnerStatus;
@IsOptional()
@IsEnum(['E_COMMERCE', 'GAMING', 'ENTERTAINMENT', 'UTILITIES', 'DIGITAL_CONTENT', 'SERVICES', 'OTHER'])
category?: PartnerCategory;
@IsOptional()
@IsString()
country?: string;
@IsOptional()
@IsString()
sortBy?: string;
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder: 'asc' | 'desc' = 'desc';
constructor(partial?: Partial<PartnerQuery>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class PaginatedPartners {
data: Partner[] = [];
total: number = 0;
page: number = 1;
limit: number = 10;
totalPages: number = 0;
constructor(data: Partner[] = [], total: number = 0, page: number = 1, limit: number = 10) {
this.data = data;
this.total = total;
this.page = page;
this.limit = limit;
this.totalPages = Math.ceil(total / limit) || 0;
}
}
// ==================== API RESPONSES ====================
export class ApiResponse<T> {
success: boolean = false;
data?: T = undefined;
error?: string = '';
message?: string = '';
constructor(partial?: Partial<ApiResponse<T>>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class ApiKeyResponse {
apiKey: string = '';
secretKey: string = '';
partnerId: string = '';
createdAt: Date = new Date();
constructor(partial?: Partial<ApiKeyResponse>) {
if (partial) {
Object.assign(this, partial);
}
}
}
// ==================== PARTNER FORM DATA ====================
export interface PartnerFormData {
companyInfo: {
name: string;
legalName: string;
taxId: string;
address: string;
country: string;
};
contactInfo: {
email: string;
phone: string;
firstName: string;
lastName: string;
};
paymentConfig: {
supportedOperators: string[];
defaultCurrency: string;
maxTransactionAmount: number;
};
webhookConfig: CallbackConfiguration;
}
export class PartnerFormDataImpl implements PartnerFormData {
companyInfo: {
name: string;
legalName: string;
taxId: string;
address: string;
country: string;
} = {
name: '',
legalName: '',
taxId: '',
address: '',
country: 'CIV'
};
contactInfo: {
email: string;
phone: string;
firstName: string;
lastName: string;
} = {
email: '',
phone: '',
firstName: '',
lastName: ''
};
paymentConfig: {
supportedOperators: string[];
defaultCurrency: string;
maxTransactionAmount: number;
} = {
supportedOperators: [],
defaultCurrency: 'XOF',
maxTransactionAmount: 50000
};
webhookConfig: CallbackConfiguration = new CallbackConfigurationImpl();
constructor(partial?: Partial<PartnerFormData>) {
if (partial) {
Object.assign(this, partial);
}
}
}
// ==================== PARTNER PRODUCT ====================
export class PartnerProduct {
id: string = '';
partnerId: string = '';
@IsString()
name: string = '';
@IsString()
description: string = '';
@IsNumber()
price: number = 0;
@IsString()
currency: string = '';
@IsString()
category: string = '';
@IsEnum(['ACTIVE', 'INACTIVE', 'SUSPENDED'])
status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' = 'ACTIVE';
createdAt: Date = new Date();
updatedAt: Date = new Date();
constructor(partial?: Partial<PartnerProduct>) {
if (partial) {
Object.assign(this, partial);
}
}
}

View File

@ -0,0 +1,511 @@
<!-- src/app/modules/merchant-users/profile/profile.html -->
<div class="container-fluid">
<!-- En-tête avec navigation -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<div>
<h4 class="mb-1">
@if (user) {
{{ getUserDisplayName() }}
} @else {
Profil Utilisateur Marchand
}
</h4>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="javascript:void(0)" (click)="back.emit()" class="text-decoration-none cursor-pointer">
Équipe Marchande
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
@if (user) {
{{ getUserDisplayName() }}
} @else {
Profil
}
</li>
</ol>
</nav>
</div>
<div class="d-flex gap-2">
<!-- Bouton de réinitialisation de mot de passe -->
@if (user && !isEditing) {
<button
class="btn btn-warning"
(click)="resetPassword()"
>
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser MDP
</button>
<!-- Bouton activation/désactivation -->
@if (user.enabled) {
<button
class="btn btn-outline-warning"
(click)="disableUser()"
>
<ng-icon name="lucideUserX" class="me-1"></ng-icon>
Désactiver
</button>
} @else {
<button
class="btn btn-outline-success"
(click)="enableUser()"
>
<ng-icon name="lucideUserCheck" class="me-1"></ng-icon>
Activer
</button>
}
<!-- Bouton modification -->
<button
class="btn btn-primary"
(click)="startEditing()"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier
</button>
}
</div>
</div>
</div>
</div>
<!-- Messages d'alerte -->
@if (error) {
<div class="alert alert-danger">
<div class="d-flex align-items-center">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
</div>
</div>
}
@if (success) {
<div class="alert alert-success">
<div class="d-flex align-items-center">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ success }}</div>
</div>
</div>
}
<div class="row">
<!-- Loading State -->
@if (loading) {
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement du profil...</p>
</div>
}
<!-- User Profile -->
@if (user && !loading) {
<!-- Colonne de gauche - Informations de base -->
<div class="col-xl-4 col-lg-5">
<!-- Carte profil -->
<div class="card">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Profil Utilisateur Marchand</h5>
</div>
<div class="card-body text-center">
<!-- Avatar -->
<div class="avatar-lg mx-auto mb-3">
<div class="avatar-title bg-primary bg-opacity-10 rounded-circle text-primary fs-24">
{{ getUserInitials() }}
</div>
</div>
<h5>{{ getUserDisplayName() }}</h5>
<p class="text-muted mb-2">@{{ user.username }}</p>
<!-- Rôle principal -->
<span class="badge d-flex align-items-center justify-content-center mx-auto mb-3"
[ngClass]="getRoleBadgeClass(user.role)" style="max-width: 150px;">
<ng-icon [name]="getRoleIcon(user.role)" class="me-1"></ng-icon>
{{ getRoleDisplayName(user.role) }}
</span>
<!-- Statut -->
<span [class]="getStatusBadgeClass()" class="mb-3">
{{ getStatusText() }}
</span>
<!-- Informations rapides -->
<div class="mt-4 text-start">
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon>
<small>{{ user.email }}</small>
@if (!user.emailVerified) {
<ng-icon name="lucideAlertTriangle" class="ms-1 text-warning" size="14" title="Email non vérifié"></ng-icon>
}
</div>
<div class="d-flex align-items-center mb-2">
<ng-icon name="lucideBuilding" class="me-2 text-muted"></ng-icon>
<small class="text-truncate" title="Merchant Partner ID">
{{ user.merchantPartnerId }}
</small>
</div>
<div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<small>Créé le {{ getCreationDate() }}</small>
</div>
@if (user.lastLogin) {
<div class="d-flex align-items-center mt-2">
<ng-icon name="lucideLogIn" class="me-2 text-muted"></ng-icon>
<small>Dernière connexion : {{ getLastLoginDate() }}</small>
</div>
}
</div>
</div>
</div>
<!-- Carte rôle utilisateur -->
<div class="card mt-3">
<div class="card-header bg-light">
<h5 class="card-title mb-0">Rôle Utilisateur</h5>
</div>
<div class="card-body">
<!-- Rôle actuel -->
<div class="text-center mb-3">
<span class="badge d-flex align-items-center justify-content-center"
[ngClass]="getRoleBadgeClass(user.role)">
<ng-icon [name]="getRoleIcon(user.role)" class="me-2"></ng-icon>
{{ getRoleDisplayName(user.role) }}
</span>
<small class="text-muted d-block mt-2">
{{ getRoleDescription(user.role) }}
</small>
</div>
<!-- Information sur le rôle -->
<div class="alert alert-light mt-3">
<small>
<strong>Information :</strong> Le rôle de l'utilisateur marchand ne peut pas être modifié directement.
Pour changer le rôle, vous devez recréer l'utilisateur avec le nouveau rôle souhaité.
</small>
</div>
</div>
</div>
<!-- Informations de création -->
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Informations de Création</h6>
</div>
<div class="card-body">
<div class="row g-2 small">
<div class="col-12">
<strong>Créé par :</strong>
<div class="text-muted">{{ getCreatorName() }}</div>
</div>
<div class="col-12">
<strong>Date de création :</strong>
<div class="text-muted">{{ getCreationDate() }}</div>
</div>
<div class="col-12">
<strong>Type d'utilisateur :</strong>
<div class="text-muted">
<span class="badge bg-primary">{{ user.userType }}</span>
</div>
</div>
<div class="col-12">
<strong>Merchant Partner :</strong>
<div class="text-muted font-monospace small">
{{ user.merchantPartnerId }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Colonne de droite - Détails et édition -->
<div class="col-xl-8 col-lg-7">
<div class="card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
@if (isEditing) {
<ng-icon name="lucideEdit" class="me-2"></ng-icon>
Modification du Profil
} @else {
<ng-icon name="lucideUser" class="me-2"></ng-icon>
Détails du Compte
}
</h5>
@if (isEditing) {
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(click)="cancelEditing()"
[disabled]="saving"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button>
<button
type="button"
class="btn btn-success btn-sm"
(click)="saveProfile()"
[disabled]="saving || !isFormValid()"
>
@if (saving) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Enregistrer
</button>
</div>
}
</div>
<div class="card-body">
<div class="row g-3">
<!-- Prénom -->
<div class="col-md-6">
<label class="form-label">Prénom <span class="text-danger">*</span></label>
@if (isEditing) {
<input
type="text"
class="form-control"
[(ngModel)]="editedUser.firstName"
placeholder="Entrez le prénom"
[disabled]="saving"
required
>
} @else {
<div class="form-control-plaintext">
{{ user.firstName || 'Non renseigné' }}
</div>
}
</div>
<!-- Nom -->
<div class="col-md-6">
<label class="form-label">Nom <span class="text-danger">*</span></label>
@if (isEditing) {
<input
type="text"
class="form-control"
[(ngModel)]="editedUser.lastName"
placeholder="Entrez le nom"
[disabled]="saving"
required
>
} @else {
<div class="form-control-plaintext">
{{ user.lastName || 'Non renseigné' }}
</div>
}
</div>
<!-- Nom d'utilisateur -->
<div class="col-md-6">
<label class="form-label">Nom d'utilisateur</label>
<div class="form-control-plaintext font-monospace">
{{ user.username }}
</div>
<div class="form-text">
Le nom d'utilisateur ne peut pas être modifié
</div>
</div>
<!-- Email -->
<div class="col-md-6">
<label class="form-label">Email <span class="text-danger">*</span></label>
@if (isEditing) {
<input
type="email"
class="form-control"
[(ngModel)]="editedUser.email"
placeholder="email@exemple.com"
[disabled]="saving"
required
>
@if (editedUser.email && !isValidEmail(editedUser.email)) {
<div class="text-danger small mt-1">
Format d'email invalide
</div>
}
} @else {
<div class="form-control-plaintext">
{{ user.email }}
@if (!user.emailVerified) {
<span class="badge bg-warning ms-2">Non vérifié</span>
}
</div>
}
</div>
<!-- Rôle (lecture seule) -->
<div class="col-md-6">
<label class="form-label">Rôle</label>
<div class="form-control-plaintext">
<span class="badge" [ngClass]="getRoleBadgeClass(user.role)">
{{ getRoleDisplayName(user.role) }}
</span>
</div>
<div class="form-text">
Rôle assigné à la création
</div>
</div>
<!-- Merchant Partner ID (lecture seule) -->
<div class="col-md-6">
<label class="form-label">Merchant Partner ID</label>
<div class="form-control-plaintext font-monospace small">
{{ user.merchantPartnerId }}
</div>
<div class="form-text">
Identifiant du partenaire marchand
</div>
</div>
<!-- Statut activé -->
@if (isEditing) {
<div class="col-md-6">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
id="enabledSwitch"
[(ngModel)]="editedUser.enabled"
[disabled]="saving"
>
<label class="form-check-label" for="enabledSwitch">
Compte activé
</label>
</div>
<div class="form-text">
L'utilisateur peut se connecter si activé
</div>
</div>
} @else {
<div class="col-md-6">
<label class="form-label">Statut du compte</label>
<div class="form-control-plaintext">
<span [class]="getStatusBadgeClass()">
{{ getStatusText() }}
</span>
</div>
</div>
}
<!-- Informations système -->
@if (!isEditing) {
<div class="col-12">
<hr>
<h6 class="mb-3">
<ng-icon name="lucideSettings" class="me-2"></ng-icon>
Informations Système
</h6>
<div class="row">
<div class="col-md-6">
<label class="form-label">ID Utilisateur</label>
<div class="form-control-plaintext font-monospace small text-truncate">
{{ user.id }}
</div>
</div>
<div class="col-md-6">
<label class="form-label">Date de création</label>
<div class="form-control-plaintext">
{{ getCreationDate() }}
</div>
</div>
<div class="col-md-6">
<label class="form-label">Créé par</label>
<div class="form-control-plaintext">
{{ getCreatorName() }}
</div>
</div>
<div class="col-md-6">
<label class="form-label">Type d'utilisateur</label>
<div class="form-control-plaintext">
<span class="badge bg-primary">{{ user.userType }}</span>
</div>
</div>
@if (user.lastLogin) {
<div class="col-md-6">
<label class="form-label">Dernière connexion</label>
<div class="form-control-plaintext">
{{ getLastLoginDate() }}
</div>
</div>
}
</div>
</div>
}
</div>
</div>
</div>
<!-- Actions supplémentaires -->
@if (!isEditing) {
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Actions de Gestion</h6>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-md-4">
<button
class="btn btn-outline-warning w-100"
(click)="resetPassword()"
>
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser MDP
</button>
</div>
<div class="col-md-4">
@if (user.enabled) {
<button
class="btn btn-outline-secondary w-100"
(click)="disableUser()"
>
<ng-icon name="lucideUserX" class="me-1"></ng-icon>
Désactiver
</button>
} @else {
<button
class="btn btn-outline-success w-100"
(click)="enableUser()"
>
<ng-icon name="lucideUserCheck" class="me-1"></ng-icon>
Activer
</button>
}
</div>
<div class="col-md-4">
<button
class="btn btn-outline-primary w-100"
(click)="startEditing()"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier
</button>
</div>
</div>
<!-- Avertissement pour la suppression -->
<div class="alert alert-light mt-3 mb-0">
<small>
<strong>Note :</strong> Pour supprimer cet utilisateur, utilisez l'action de suppression
disponible dans la liste des utilisateurs marchands.
</small>
</div>
</div>
</div>
}
</div>
}
</div>
</div>

View File

@ -0,0 +1,2 @@
import { PartnerTeamProfile } from './profile';
describe('PartnerTeamProfile', () => {});

View File

@ -0,0 +1,440 @@
// src/app/modules/merchant-users/profile/profile.ts
import { Component, inject, OnInit, Input, Output, EventEmitter, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
import { Subject, takeUntil } from 'rxjs';
import {
MerchantUsersService,
MerchantUserResponse,
UserRole,
UpdateMerchantUserDto
} from '../services/merchant-partners.service';
import { AuthService } from '@core/services/auth.service';
@Component({
selector: 'app-merchant-user-profile',
standalone: true,
imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule],
templateUrl: './profile.html',
styles: [`
.avatar-lg {
width: 80px;
height: 80px;
}
.fs-24 {
font-size: 24px;
}
`]
})
export class MerchantUserProfile implements OnInit, OnDestroy {
private merchantUsersService = inject(MerchantUsersService);
private authService = inject(AuthService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
@Input() userId!: string;
@Output() back = new EventEmitter<void>();
@Output() openResetPasswordModal = new EventEmitter<string>();
user: MerchantUserResponse | null = null;
loading = false;
saving = false;
error = '';
success = '';
// Édition
isEditing = false;
editedUser: UpdateMerchantUserDto = {};
// Gestion des rôles
availableRoles: UserRole[] = [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
];
updatingRole = false;
ngOnInit() {
if (this.userId) {
this.loadUserProfile();
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadUserProfile() {
this.loading = true;
this.error = '';
this.merchantUsersService.getMerchantUserById(this.userId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.user = user;
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement du profil utilisateur';
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading merchant user profile:', error);
}
});
}
startEditing() {
this.isEditing = true;
this.editedUser = {
firstName: this.user?.firstName,
lastName: this.user?.lastName,
email: this.user?.email,
enabled: this.user?.enabled
};
this.cdRef.detectChanges();
}
cancelEditing() {
this.isEditing = false;
this.editedUser = {};
this.error = '';
this.success = '';
this.cdRef.detectChanges();
}
saveProfile() {
if (!this.user) return;
this.saving = true;
this.error = '';
this.success = '';
this.merchantUsersService.updateMerchantUser(this.user.id, this.editedUser)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedUser) => {
this.user = updatedUser;
this.isEditing = false;
this.saving = false;
this.success = 'Profil mis à jour avec succès';
this.editedUser = {};
this.cdRef.detectChanges();
},
error: (error) => {
this.error = this.getErrorMessage(error);
this.saving = false;
this.cdRef.detectChanges();
console.error('Error updating merchant user:', error);
}
});
}
// Gestion des rôles
updateUserRole(newRole: UserRole) {
if (!this.user) return;
this.updatingRole = true;
this.error = '';
this.success = '';
// Pour changer le rôle, on doit recréer l'utilisateur ou utiliser une méthode spécifique
// Pour l'instant, on utilise la mise à jour standard
const updateData: UpdateMerchantUserDto = {
...this.editedUser
// Note: Le rôle n'est pas modifiable via update dans l'API actuelle
// Vous devrez peut-être implémenter une méthode spécifique dans le service
};
this.merchantUsersService.updateMerchantUser(this.user.id, updateData)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedUser) => {
this.user = updatedUser;
this.updatingRole = false;
this.success = 'Profil mis à jour avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.updatingRole = false;
this.error = this.getErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
// Gestion du statut
enableUser() {
if (!this.user) return;
this.error = '';
this.success = '';
this.merchantUsersService.enableMerchantUser(this.user.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedUser) => {
this.user = updatedUser;
this.success = 'Utilisateur activé avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.error = this.getErrorMessage(error);
this.cdRef.detectChanges();
console.error('Error enabling merchant user:', error);
}
});
}
disableUser() {
if (!this.user) return;
this.error = '';
this.success = '';
this.merchantUsersService.disableMerchantUser(this.user.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedUser) => {
this.user = updatedUser;
this.success = 'Utilisateur désactivé avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.error = this.getErrorMessage(error);
this.cdRef.detectChanges();
console.error('Error disabling merchant user:', error);
}
});
}
// Réinitialisation du mot de passe
resetPassword() {
if (this.user) {
this.openResetPasswordModal.emit(this.user.id);
}
}
// ==================== UTILITAIRES D'AFFICHAGE ====================
getStatusBadgeClass(): string {
if (!this.user) return 'badge bg-secondary';
if (!this.user.enabled) return 'badge bg-danger';
if (!this.user.emailVerified) return 'badge bg-warning';
return 'badge bg-success';
}
getStatusText(): string {
if (!this.user) return 'Inconnu';
if (!this.user.enabled) return 'Désactivé';
if (!this.user.emailVerified) return 'Email non vérifié';
return 'Actif';
}
formatTimestamp(timestamp: number): string {
if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
getUserInitials(): string {
if (!this.user) return 'U';
return (this.user.firstName?.charAt(0) || '') + (this.user.lastName?.charAt(0) || '') || 'U';
}
getUserDisplayName(): string {
if (!this.user) return 'Utilisateur';
if (this.user.firstName && this.user.lastName) {
return `${this.user.firstName} ${this.user.lastName}`;
}
return this.user.username;
}
getRoleBadgeClass(role: UserRole): string {
switch (role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'bg-danger';
case UserRole.DCB_PARTNER_MANAGER:
return 'bg-warning text-dark';
case UserRole.DCB_PARTNER_SUPPORT:
return 'bg-info text-white';
default:
return 'bg-secondary';
}
}
getRoleDisplayName(role: UserRole): string {
const roleNames = {
[UserRole.DCB_PARTNER_ADMIN]: 'Administrateur',
[UserRole.DCB_PARTNER_MANAGER]: 'Manager',
[UserRole.DCB_PARTNER_SUPPORT]: 'Support'
};
return roleNames[role] || role;
}
getRoleIcon(role: UserRole): string {
switch (role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'lucideShield';
case UserRole.DCB_PARTNER_MANAGER:
return 'lucideUserCog';
case UserRole.DCB_PARTNER_SUPPORT:
return 'lucideHeadphones';
default:
return 'lucideUser';
}
}
getRoleDescription(role: UserRole): string {
const descriptions = {
[UserRole.DCB_PARTNER_ADMIN]: 'Accès administratif complet au sein du partenaire marchand',
[UserRole.DCB_PARTNER_MANAGER]: 'Accès de gestion avec capacités administratives limitées',
[UserRole.DCB_PARTNER_SUPPORT]: 'Rôle support avec accès en lecture seule et opérations de base'
};
return descriptions[role] || 'Description non disponible';
}
getUserType(): string {
if (!this.user) return 'Utilisateur';
switch (this.user.role) {
case UserRole.DCB_PARTNER_ADMIN:
return 'Administrateur';
case UserRole.DCB_PARTNER_MANAGER:
return 'Manager';
case UserRole.DCB_PARTNER_SUPPORT:
return 'Support';
default:
return 'Utilisateur';
}
}
getUserTypeBadgeClass(): string {
const userType = this.getUserType();
switch (userType) {
case 'Administrateur': return 'bg-danger';
case 'Manager': return 'bg-success';
case 'Support': return 'bg-info';
default: return 'bg-secondary';
}
}
// ==================== GESTION DES ERREURS ====================
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 400) {
return 'Données invalides. Vérifiez les informations saisies.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour effectuer cette action.';
}
if (error.status === 404) {
return 'Utilisateur non trouvé.';
}
if (error.status === 409) {
return 'Conflit de données. Cet utilisateur existe peut-être déjà.';
}
return 'Erreur lors de l\'opération. Veuillez réessayer.';
}
// ==================== VÉRIFICATIONS DE PERMISSIONS ====================
canEditUser(): boolean {
// Logique pour déterminer si l'utilisateur connecté peut éditer cet utilisateur
return true; // Temporaire - à implémenter
}
canManageRoles(): boolean {
// Logique pour déterminer si l'utilisateur connecté peut gérer les rôles
return true; // Temporaire - à implémenter
}
canEnableDisableUser(): boolean {
// Empêcher la désactivation de soi-même
return this.user?.id !== 'current-user-id';
}
// ==================== MÉTHODES DE NAVIGATION ====================
goBack() {
this.back.emit();
}
// ==================== MÉTHODES DE VALIDATION ====================
isFormValid(): boolean {
if (!this.editedUser.firstName?.trim() || !this.editedUser.lastName?.trim()) {
return false;
}
if (!this.editedUser.email?.trim() || !this.isValidEmail(this.editedUser.email)) {
return false;
}
return true;
}
protected isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// ==================== MÉTHODES UTILITAIRES ====================
isAdmin(): boolean {
return this.user?.role === UserRole.DCB_PARTNER_ADMIN;
}
isManager(): boolean {
return this.user?.role === UserRole.DCB_PARTNER_MANAGER;
}
isSupport(): boolean {
return this.user?.role === UserRole.DCB_PARTNER_SUPPORT;
}
refresh() {
this.loadUserProfile();
}
clearMessages() {
this.error = '';
this.success = '';
this.cdRef.detectChanges();
}
// Vérifie si c'est le profil de l'utilisateur courant
isCurrentUserProfile(): boolean {
// Implémentez cette logique selon votre système d'authentification
// Exemple: return this.authService.getCurrentUserId() === this.user?.id;
return false;
}
// Méthode pour obtenir la date de création formatée
getCreationDate(): string {
if (!this.user?.createdTimestamp) return 'Non disponible';
return this.formatTimestamp(this.user.createdTimestamp);
}
// Méthode pour obtenir la date de dernière connexion formatée
getLastLoginDate(): string {
if (!this.user?.lastLogin) return 'Jamais connecté';
return this.formatTimestamp(this.user.lastLogin);
}
// Méthode pour obtenir le nom du créateur
getCreatorName(): string {
if (!this.user?.createdByUsername) return 'Non disponible';
return this.user.createdByUsername;
}
}

View File

@ -0,0 +1,347 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable, map, catchError, throwError, of } from 'rxjs';
// Interfaces alignées avec le contrôleur MerchantUsersController
export interface MerchantUserResponse {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled: boolean;
emailVerified: boolean;
merchantPartnerId: string;
createdBy: string;
createdByUsername: string;
createdTimestamp: number;
lastLogin?: number;
userType: 'MERCHANT';
}
export interface CreateMerchantUserDto {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled?: boolean;
emailVerified?: boolean;
merchantPartnerId: string;
}
export interface UpdateMerchantUserDto {
firstName?: string;
lastName?: string;
email?: string;
enabled?: boolean;
}
export interface ResetPasswordDto {
newPassword: string;
temporary?: boolean;
}
export interface MerchantPartnerStatsResponse {
totalAdmins: number;
totalManagers: number;
totalSupport: number;
totalUsers: number;
activeUsers: number;
inactiveUsers: number;
}
export interface AvailableRole {
value: UserRole;
label: string;
description: string;
allowedForCreation: boolean;
}
export interface AvailableRolesResponse {
roles: AvailableRole[];
}
export interface SearchMerchantUsersParams {
query?: string;
role?: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT;
enabled?: boolean;
}
export enum UserRole {
DCB_PARTNER_ADMIN = 'DCB_PARTNER_ADMIN',
DCB_PARTNER_MANAGER = 'DCB_PARTNER_MANAGER',
DCB_PARTNER_SUPPORT = 'DCB_PARTNER_SUPPORT'
}
@Injectable({ providedIn: 'root' })
export class MerchantUsersService {
private http = inject(HttpClient);
private apiUrl = `${environment.iamApiUrl}/merchant-users`;
// === RÉCUPÉRATION D'UTILISATEURS ===
/**
* Récupère les utilisateurs marchands de l'utilisateur courant
*/
getMyMerchantUsers(): Observable<MerchantUserResponse[]> {
return this.http.get<MerchantUserResponse[]>(this.apiUrl).pipe(
catchError(error => {
console.error('Error loading my merchant users:', error);
return throwError(() => error);
})
);
}
/**
* Récupère les utilisateurs marchands par ID de partenaire
*/
getMerchantUsersByPartner(partnerId: string): Observable<MerchantUserResponse[]> {
return this.http.get<MerchantUserResponse[]>(`${this.apiUrl}/partner/${partnerId}`).pipe(
catchError(error => {
console.error(`Error loading merchant users for partner ${partnerId}:`, error);
return throwError(() => error);
})
);
}
/**
* Récupère un utilisateur marchand par ID
*/
getMerchantUserById(id: string): Observable<MerchantUserResponse> {
return this.http.get<MerchantUserResponse>(`${this.apiUrl}/${id}`).pipe(
catchError(error => {
console.error(`Error loading merchant user ${id}:`, error);
return throwError(() => error);
})
);
}
// === CRÉATION D'UTILISATEURS ===
/**
* Crée un nouvel utilisateur marchand
*/
createMerchantUser(createUserDto: CreateMerchantUserDto): Observable<MerchantUserResponse> {
// Validation
if (!createUserDto.username?.trim()) {
return throwError(() => 'Username is required and cannot be empty');
}
if (!createUserDto.email?.trim()) {
return throwError(() => 'Email is required and cannot be empty');
}
if (!createUserDto.password || createUserDto.password.length < 8) {
return throwError(() => 'Password must be at least 8 characters');
}
if (!createUserDto.role) {
return throwError(() => 'Role is required');
}
if (!createUserDto.merchantPartnerId?.trim()) {
return throwError(() => 'Merchant Partner ID is required');
}
// Nettoyage des données
const payload = {
username: createUserDto.username.trim(),
email: createUserDto.email.trim(),
firstName: (createUserDto.firstName || '').trim(),
lastName: (createUserDto.lastName || '').trim(),
password: createUserDto.password,
role: createUserDto.role,
merchantPartnerId: createUserDto.merchantPartnerId.trim(),
enabled: createUserDto.enabled !== undefined ? createUserDto.enabled : true,
emailVerified: createUserDto.emailVerified !== undefined ? createUserDto.emailVerified : false,
};
return this.http.post<MerchantUserResponse>(this.apiUrl, payload).pipe(
catchError(error => {
console.error('Error creating merchant user:', error);
return throwError(() => error);
})
);
}
// === MISE À JOUR D'UTILISATEURS ===
/**
* Met à jour un utilisateur marchand
*/
updateMerchantUser(id: string, updateUserDto: UpdateMerchantUserDto): Observable<MerchantUserResponse> {
return this.http.put<MerchantUserResponse>(`${this.apiUrl}/${id}`, updateUserDto).pipe(
catchError(error => {
console.error(`Error updating merchant user ${id}:`, error);
return throwError(() => error);
})
);
}
// === SUPPRESSION D'UTILISATEURS ===
/**
* Supprime un utilisateur marchand
*/
deleteMerchantUser(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`).pipe(
catchError(error => {
console.error(`Error deleting merchant user ${id}:`, error);
return throwError(() => error);
})
);
}
// === GESTION DES MOTS DE PASSE ===
/**
* Réinitialise le mot de passe d'un utilisateur marchand
*/
resetMerchantUserPassword(id: string, resetPasswordDto: ResetPasswordDto): Observable<{ message: string }> {
return this.http.post<{ message: string }>(
`${this.apiUrl}/${id}/reset-password`,
resetPasswordDto
).pipe(
catchError(error => {
console.error(`Error resetting password for merchant user ${id}:`, error);
return throwError(() => error);
})
);
}
// === STATISTIQUES ET RAPPORTS ===
/**
* Récupère les statistiques des utilisateurs marchands
*/
getMerchantUsersStats(): Observable<MerchantPartnerStatsResponse> {
return this.http.get<MerchantPartnerStatsResponse>(`${this.apiUrl}/stats/overview`).pipe(
catchError(error => {
console.error('Error loading merchant users stats:', error);
return throwError(() => error);
})
);
}
// === RECHERCHE ET FILTRES ===
/**
* Recherche des utilisateurs marchands avec filtres
*/
searchMerchantUsers(params: SearchMerchantUsersParams): Observable<MerchantUserResponse[]> {
let httpParams = new HttpParams();
if (params.query) {
httpParams = httpParams.set('query', params.query);
}
if (params.role) {
httpParams = httpParams.set('role', params.role);
}
if (params.enabled !== undefined) {
httpParams = httpParams.set('enabled', params.enabled.toString());
}
return this.http.get<MerchantUserResponse[]>(`${this.apiUrl}/search`, { params: httpParams }).pipe(
catchError(error => {
console.error('Error searching merchant users:', error);
return throwError(() => error);
})
);
}
// === GESTION DES RÔLES ===
/**
* Récupère les rôles marchands disponibles
*/
getAvailableMerchantRoles(): Observable<AvailableRolesResponse> {
return this.http.get<AvailableRolesResponse>(`${this.apiUrl}/roles/available`).pipe(
catchError(error => {
console.error('Error loading available merchant roles:', error);
// Fallback en cas d'erreur
return of({
roles: [
{
value: UserRole.DCB_PARTNER_ADMIN,
label: 'Partner Admin',
description: 'Full administrative access within the merchant partner',
allowedForCreation: true
},
{
value: UserRole.DCB_PARTNER_MANAGER,
label: 'Partner Manager',
description: 'Manager access with limited administrative capabilities',
allowedForCreation: true
},
{
value: UserRole.DCB_PARTNER_SUPPORT,
label: 'Partner Support',
description: 'Support role with read-only and basic operational access',
allowedForCreation: true
}
]
});
})
);
}
// === GESTION DU STATUT ===
/**
* Active un utilisateur marchand
*/
enableMerchantUser(id: string): Observable<MerchantUserResponse> {
return this.updateMerchantUser(id, { enabled: true });
}
/**
* Désactive un utilisateur marchand
*/
disableMerchantUser(id: string): Observable<MerchantUserResponse> {
return this.updateMerchantUser(id, { enabled: false });
}
// === UTILITAIRES ===
/**
* Vérifie si un nom d'utilisateur existe parmi les utilisateurs marchands
*/
merchantUserExists(username: string): Observable<{ exists: boolean }> {
return this.getMyMerchantUsers().pipe(
map(users => ({
exists: users.some(user => user.username === username)
})),
catchError(error => {
console.error('Error checking if merchant user exists:', error);
return of({ exists: false });
})
);
}
/**
* Récupère les utilisateurs par rôle spécifique
*/
getMerchantUsersByRole(role: UserRole.DCB_PARTNER_ADMIN | UserRole.DCB_PARTNER_MANAGER | UserRole.DCB_PARTNER_SUPPORT): Observable<MerchantUserResponse[]> {
return this.searchMerchantUsers({ role });
}
/**
* Récupère uniquement les utilisateurs actifs
*/
getActiveMerchantUsers(): Observable<MerchantUserResponse[]> {
return this.searchMerchantUsers({ enabled: true });
}
/**
* Récupère uniquement les utilisateurs inactifs
*/
getInactiveMerchantUsers(): Observable<MerchantUserResponse[]> {
return this.searchMerchantUsers({ enabled: false });
}
}

View File

@ -0,0 +1,233 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { catchError, Observable, of, throwError } from 'rxjs';
import {
Partner,
CreatePartnerDto,
UpdatePartnerDto,
PartnerQuery,
PaginatedPartners,
ApiResponse,
CallbackConfiguration,
UpdateCallbacksDto,
ApiKeyResponse,
PartnerStats
} from '../models/partners-config.model';
@Injectable({ providedIn: 'root' })
export class PartnerConfigService {
private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/partners/config`;
// ==================== GESTION DES MARCHANDS (ADMIN) ====================
/**
* Créer une config marchand
*/
createPartnerConfig(createPartnerDto: CreatePartnerDto): Observable<ApiResponse<Partner>> {
return this.http.post<ApiResponse<Partner>>(`${this.apiUrl}`, createPartnerDto).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Obtenir toutes les une config marchands avec pagination
*/
findAllPartnersConfig(query: PartnerQuery = new PartnerQuery()): Observable<PaginatedPartners> {
const params = this.buildQueryParams(query);
return this.http.get<PaginatedPartners>(`${this.apiUrl}`, { params }).pipe(
catchError(error => {
console.error('Error loading merchants:', error);
return of(new PaginatedPartners());
})
);
}
/**
* Obtenir une config marchand par son ID
*/
getPartnerConfigById(merchantId: string): Observable<ApiResponse<Partner>> {
return this.http.get<ApiResponse<Partner>>(`${this.apiUrl}/${merchantId}`).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Mettre à jour une config marchand
*/
updatePartnerConfig(merchantId: string, updateData: UpdatePartnerDto): Observable<ApiResponse<Partner>> {
return this.http.put<ApiResponse<Partner>>(`${this.apiUrl}/${merchantId}`, updateData).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Supprimer une config marchand
*/
deletePartnerConfig(merchantId: string): Observable<ApiResponse<void>> {
return this.http.delete<ApiResponse<void>>(`${this.apiUrl}/${merchantId}`).pipe(
catchError(error => throwError(() => error))
);
}
// ==================== GESTION DES CALLBACKS ====================
/**
* Mettre à jour la configuration des callbacks d'un marchand
*/
updateCallbacksConfig(merchantId: string, updateData: UpdateCallbacksDto): Observable<ApiResponse<Partner>> {
return this.http.put<ApiResponse<Partner>>(
`${this.apiUrl}/${merchantId}/callbacks`,
updateData
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Obtenir la configuration des callbacks d'un marchand
*/
getCallbacksConfig(merchantId: string): Observable<ApiResponse<CallbackConfiguration>> {
return this.http.get<ApiResponse<CallbackConfiguration>>(
`${this.apiUrl}/${merchantId}/callbacks`
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Tester un webhook spécifique
*/
testWebhookConfig(merchantId: string, webhookType: string, payload: any = {}): Observable<ApiResponse<any>> {
return this.http.post<ApiResponse<any>>(
`${this.apiUrl}/${merchantId}/callbacks/test/${webhookType}`,
payload
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Obtenir les logs des webhooks d'un marchand
*/
getWebhookLogsConfig(merchantId: string, query: any = {}): Observable<ApiResponse<any>> {
const params = this.buildQueryParams(query);
return this.http.get<ApiResponse<any>>(
`${this.apiUrl}/${merchantId}/callbacks/logs`,
{ params }
).pipe(
catchError(error => throwError(() => error))
);
}
// ==================== GESTION DES STATISTIQUES ====================
/**
* Obtenir les statistiques d'un marchand
*/
getPartnerStats(merchantId: string): Observable<ApiResponse<PartnerStats>> {
return this.http.get<ApiResponse<PartnerStats>>(
`${this.apiUrl}/${merchantId}/stats`
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Obtenir mes statistiques (marchand connecté)
*/
getMyStats(): Observable<ApiResponse<PartnerStats>> {
return this.http.get<ApiResponse<PartnerStats>>(
`${this.apiUrl}/me/stats`
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Obtenir les statistiques globales (admin seulement)
*/
getGlobalStats(): Observable<ApiResponse<any>> {
return this.http.get<ApiResponse<any>>(
`${this.apiUrl}/stats/global`
).pipe(
catchError(error => throwError(() => error))
);
}
// ==================== GESTION DES CLÉS API ====================
/**
* Générer de nouvelles clés API pour un marchand
*/
generateApiKeys(merchantId: string): Observable<ApiResponse<ApiKeyResponse>> {
return this.http.post<ApiResponse<ApiKeyResponse>>(
`${this.apiUrl}/${merchantId}/api-keys`,
{}
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Révoker les clés API d'un marchand
*/
revokeApiKeys(merchantId: string): Observable<ApiResponse<void>> {
return this.http.delete<ApiResponse<void>>(
`${this.apiUrl}/${merchantId}/api-keys`
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Régénérer la clé secrète d'un marchand
*/
regenerateSecretKey(merchantId: string): Observable<ApiResponse<ApiKeyResponse>> {
return this.http.put<ApiResponse<ApiKeyResponse>>(
`${this.apiUrl}/${merchantId}/api-keys/regenerate-secret`,
{}
).pipe(
catchError(error => throwError(() => error))
);
}
/**
* Obtenir les clés API d'un marchand
*/
getApiKeys(merchantId: string): Observable<ApiResponse<ApiKeyResponse>> {
return this.http.get<ApiResponse<ApiKeyResponse>>(
`${this.apiUrl}/${merchantId}/api-keys`
).pipe(
catchError(error => throwError(() => error))
);
}
// ==================== MÉTHODES UTILITAIRES ====================
private buildQueryParams(query: any): { [key: string]: string } {
const params: { [key: string]: string } = {};
Object.keys(query).forEach(key => {
if (query[key] !== undefined && query[key] !== null && query[key] !== '') {
params[key] = query[key].toString();
}
});
return params;
}
/**
* Valider la configuration d'un marchand
*/
validatePartnerConfig(merchantId: string): Observable<ApiResponse<{ isValid: boolean; errors: string[] }>> {
return this.http.get<ApiResponse<{ isValid: boolean; errors: string[] }>>(
`${this.apiUrl}/${merchantId}/validate`
).pipe(
catchError(error => throwError(() => error))
);
}
}

View File

@ -0,0 +1,249 @@
<!-- src/app/modules/merchant-users/stats/stats.html -->
<app-ui-card title="Statistiques de l'Équipe Marchande">
<a
helper-text
href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold"
>Vue d'ensemble des utilisateurs de votre écosystème marchand
</a>
<div card-body>
@if (!stats) {
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des statistiques...</p>
</div>
}
@if (stats) {
<div class="row">
<!-- KPI Cards -->
<div class="col-xl-3 col-md-6">
<div class="card card-animate">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<p class="text-uppercase fw-medium text-muted mb-0">Total Utilisateurs</p>
<h4 class="mt-2 mb-0 text-primary">{{ stats.totalUsers }}</h4>
<p class="mb-0">
<span class="badge bg-success-subtle text-success mt-1">
<ng-icon name="lucideUsers" class="me-1"></ng-icon>
Équipe complète
</span>
</p>
</div>
<div class="flex-shrink-0">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideUsers" class="text-primary fs-20"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card card-animate">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<p class="text-uppercase fw-medium text-muted mb-0">Administrateurs</p>
<h4 class="mt-2 mb-0 text-danger">{{ stats.totalAdmins }}</h4>
<p class="mb-0">
<span class="badge bg-danger-subtle text-danger mt-1">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Accès complet
</span>
</p>
</div>
<div class="flex-shrink-0">
<div class="avatar-sm bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideShield" class="text-danger fs-20"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card card-animate">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<p class="text-uppercase fw-medium text-muted mb-0">Managers</p>
<h4 class="mt-2 mb-0 text-warning">{{ stats.totalManagers }}</h4>
<p class="mb-0">
<span class="badge bg-warning-subtle text-warning mt-1">
<ng-icon name="lucideUserCog" class="me-1"></ng-icon>
Gestion opérationnelle
</span>
</p>
</div>
<div class="flex-shrink-0">
<div class="avatar-sm bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideUserCog" class="text-warning fs-20"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card card-animate">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<p class="text-uppercase fw-medium text-muted mb-0">Support</p>
<h4 class="mt-2 mb-0 text-info">{{ stats.totalSupport }}</h4>
<p class="mb-0">
<span class="badge bg-info-subtle text-info mt-1">
<ng-icon name="lucideHeadphones" class="me-1"></ng-icon>
Assistance client
</span>
</p>
</div>
<div class="flex-shrink-0">
<div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center">
<ng-icon name="lucideHeadphones" class="text-info fs-20"></ng-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Statistiques d'activité -->
<div class="col-xl-4 col-md-6">
<div class="card">
<div class="card-body text-center">
<h6 class="card-title mb-3">Utilisateurs Actifs</h6>
<div class="mb-3">
<h2 class="text-success">{{ stats.activeUsers }}</h2>
<p class="text-muted mb-0">Comptes activés</p>
</div>
<div class="progress mb-2">
<div class="progress-bar bg-success"
[style.width]="(stats.activeUsers / stats.totalUsers * 100) + '%'">
{{ (stats.activeUsers / stats.totalUsers * 100).toFixed(1) }}%
</div>
</div>
<small class="text-muted">
{{ stats.activeUsers }} sur {{ stats.totalUsers }} utilisateurs
</small>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6">
<div class="card">
<div class="card-body text-center">
<h6 class="card-title mb-3">Utilisateurs Inactifs</h6>
<div class="mb-3">
<h2 class="text-danger">{{ stats.inactiveUsers }}</h2>
<p class="text-muted mb-0">Comptes désactivés</p>
</div>
<div class="progress mb-2">
<div class="progress-bar bg-danger"
[style.width]="(stats.inactiveUsers / stats.totalUsers * 100) + '%'">
{{ (stats.inactiveUsers / stats.totalUsers * 100).toFixed(1) }}%
</div>
</div>
<small class="text-muted">
{{ stats.inactiveUsers }} sur {{ stats.totalUsers }} utilisateurs
</small>
</div>
</div>
</div>
<div class="col-xl-4 col-md-12">
<div class="card">
<div class="card-body text-center">
<h6 class="card-title mb-3">Répartition des Rôles</h6>
<div class="d-flex justify-content-around text-center">
<div>
<h4 class="text-danger mb-1">{{ stats.totalAdmins }}</h4>
<small class="text-muted">Admins</small>
</div>
<div>
<h4 class="text-warning mb-1">{{ stats.totalManagers }}</h4>
<small class="text-muted">Managers</small>
</div>
<div>
<h4 class="text-info mb-1">{{ stats.totalSupport }}</h4>
<small class="text-muted">Support</small>
</div>
</div>
<div class="mt-3">
<div class="progress" style="height: 8px;">
<div class="progress-bar bg-danger"
[style.width]="(stats.totalAdmins / stats.totalUsers * 100) + '%'"
title="Administrateurs">
</div>
<div class="progress-bar bg-warning"
[style.width]="(stats.totalManagers / stats.totalUsers * 100) + '%'"
title="Managers">
</div>
<div class="progress-bar bg-info"
[style.width]="(stats.totalSupport / stats.totalUsers * 100) + '%'"
title="Support">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Résumé textuel -->
<div class="col-12">
<div class="card">
<div class="card-body">
<h6 class="card-title mb-3">Synthèse de l'Équipe</h6>
<div class="row text-center">
<div class="col-md-3">
<div class="border-end">
<h5 class="text-primary mb-1">{{ stats.totalUsers }}</h5>
<small class="text-muted">Total Membres</small>
</div>
</div>
<div class="col-md-3">
<div class="border-end">
<h5 class="text-success mb-1">{{ stats.activeUsers }}</h5>
<small class="text-muted">Actifs</small>
</div>
</div>
<div class="col-md-3">
<div class="border-end">
<h5 class="text-danger mb-1">{{ stats.inactiveUsers }}</h5>
<small class="text-muted">Inactifs</small>
</div>
</div>
<div class="col-md-3">
<h5 class="text-info mb-1">{{ stats.totalAdmins + stats.totalManagers + stats.totalSupport }}</h5>
<small class="text-muted">Avec Rôle Défini</small>
</div>
</div>
<!-- Message d'information -->
<div class="alert alert-light mt-3 mb-0">
<div class="d-flex align-items-center">
<ng-icon name="lucideInfo" class="me-2 text-info"></ng-icon>
<div>
<small>
Votre équipe marchande est composée de <strong>{{ stats.totalAdmins }} administrateurs</strong>,
<strong>{{ stats.totalManagers }} managers</strong> et <strong>{{ stats.totalSupport }} agents de support</strong>.
<strong>{{ stats.activeUsers }} utilisateurs</strong> sont actuellement actifs.
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
</app-ui-card>

View File

@ -0,0 +1,2 @@
import { MerchantPartnerStats } from './stats';
describe('Merchant Partner Stats', () => {});

View File

@ -0,0 +1,15 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgIcon } from '@ng-icons/core';
import { UiCard } from '@app/components/ui-card';
import { MerchantPartnerStatsResponse } from '../services/merchant-partners.service';
@Component({
selector: 'app-merchant-users-stats',
standalone: true,
imports: [CommonModule, NgIcon, UiCard],
templateUrl: './stats.html'
})
export class MerchantPartnerStats {
@Input() stats: MerchantPartnerStatsResponse | null = null;
}

View File

@ -0,0 +1,6 @@
export type WizardStepType = {
id: string
icon: string
title: string
subtitle: string
}

View File

@ -1,34 +0,0 @@
import { WizardStepType } from './types'
export const wizardSteps: WizardStepType[] = [
{
id: 'stuInfo',
icon: 'tablerUserCircle',
title: 'Student Info',
subtitle: 'Personal details',
},
{
id: 'addrInfo',
icon: 'tablerMapPin',
title: 'Address Info',
subtitle: 'Where you live',
},
{
id: 'courseInfo',
icon: 'tablerBook',
title: 'Course Info',
subtitle: 'Select your course',
},
{
id: 'parentInfo',
icon: 'tablerUsers',
title: 'Parent Info',
subtitle: 'Guardian details',
},
{
id: 'documents',
icon: 'tablerFolder',
title: 'Documents',
subtitle: 'Upload certificates',
},
]

View File

@ -1,588 +0,0 @@
<div class="container-fluid">
<app-page-title
title="Gestion des Merchants"
subTitle="Administrez vos partenaires marchands DCB - Liste, Configuration et Statistiques"
[badge]="{icon:'lucideStore', text:'Partner Management'}"
/>
<!-- Navigation par onglets -->
<div class="row mb-4">
<div class="col-12">
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav nav-tabs nav-bordered">
<li [ngbNavItem]="'list'">
<a ngbNavLink (click)="onTabChange('list')">
<ng-icon name="lucideList" class="me-1"></ng-icon>
Liste des Merchants
</a>
<ng-template ngbNavContent>
<!-- CONTENU LISTE -->
<div class="row mb-4">
<div class="col-md-6">
<div class="d-flex gap-2">
<button class="btn btn-primary" (click)="activeTab = 'config'">
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Nouveau Merchant
</button>
<button class="btn btn-outline-secondary" (click)="loadMerchants()">
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
Actualiser
</button>
</div>
</div>
<div class="col-md-6">
<div class="row g-2">
<div class="col-md-4">
<input type="text" class="form-control" placeholder="Rechercher..."
[(ngModel)]="searchTerm" (input)="currentPage = 1">
</div>
<div class="col-md-4">
<select class="form-select" [(ngModel)]="statusFilter" (change)="currentPage = 1">
<option value="all">Tous les statuts</option>
<option value="ACTIVE">Actifs</option>
<option value="PENDING">En attente</option>
<option value="SUSPENDED">Suspendus</option>
</select>
</div>
<div class="col-md-4">
<select class="form-select" [(ngModel)]="countryFilter" (change)="currentPage = 1">
@for (country of countries; track country.code) {
<option [value]="country.code">{{ country.name }}</option>
}
</select>
</div>
</div>
</div>
</div>
<!-- Loading State -->
@if (loading) {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des merchants...</p>
</div>
}
<!-- Error State -->
@if (error && !loading) {
<div class="alert alert-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ error }}
</div>
}
<!-- Tableau des Merchants -->
@if (!loading && !error) {
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-centered table-hover mb-0">
<thead class="table-light">
<tr>
<th>Merchant</th>
<th>Contact</th>
<th>Pays</th>
<th>Statut</th>
<th>Date création</th>
<th width="120">Actions</th>
</tr>
</thead>
<tbody>
@for (merchant of displayedMerchants; track merchant.partnerId) {
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideStore" class="text-white fs-14"></ng-icon>
</div>
<div>
<strong>{{ merchant.name }}</strong>
@if (merchant.companyInfo && merchant.companyInfo.legalName) {
<div class="text-muted small">{{ merchant.companyInfo.legalName }}</div>
}
</div>
</div>
</td>
<td>
<div>{{ merchant.email }}</div>
<small class="text-muted">{{ merchant.companyInfo?.address }}</small>
</td>
<td>
<span class="badge bg-light text-dark">{{ getCountryName(merchant.country) }}</span>
</td>
<td>
<span [class]="'badge ' + getStatusBadgeClass(merchant.status)">
{{ getStatusText(merchant.status) }}
</span>
</td>
<td>
<small class="text-muted">
{{ merchant.createdAt | date:'dd/MM/yyyy' }}
</small>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" title="Voir détails">
<ng-icon name="lucideEye"></ng-icon>
</button>
@if (merchant.status === 'ACTIVE') {
<button class="btn btn-outline-warning"
(click)="suspendMerchant(merchant)"
title="Suspendre">
<ng-icon name="lucidePause"></ng-icon>
</button>
} @else if (merchant.status === 'SUSPENDED') {
<button class="btn btn-outline-success"
(click)="activateMerchant(merchant)"
title="Activer">
<ng-icon name="lucidePlay"></ng-icon>
</button>
}
<button class="btn btn-outline-info" title="API Keys">
<ng-icon name="lucideKey"></ng-icon>
</button>
</div>
</td>
</tr>
}
@empty {
<tr>
<td colspan="6" class="text-center py-4">
<ng-icon name="lucideStore" class="text-muted fs-1 mb-2"></ng-icon>
<p class="text-muted">Aucun merchant trouvé</p>
<button class="btn btn-primary" (click)="activeTab = 'config'">
Créer le premier merchant
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
@if (filteredMerchants.length > 0) {
<div class="card-footer">
<div class="d-flex justify-content-between align-items-center">
<div class="text-muted">
Affichage de {{ (currentPage - 1) * itemsPerPage + 1 }} à
{{ mathMin(currentPage * itemsPerPage, filteredMerchants.length) }} sur
{{ filteredMerchants.length }} merchants
</div>
<ngb-pagination
[collectionSize]="filteredMerchants.length"
[page]="currentPage"
[pageSize]="itemsPerPage"
[maxSize]="5"
(pageChange)="onPageChange($event)"
/>
</div>
</div>
}
</div>
}
</ng-template>
</li>
<li [ngbNavItem]="'config'">
<a ngbNavLink (click)="onTabChange('config')">
<ng-icon name="lucideSettings" class="me-1"></ng-icon>
Configuration
</a>
<ng-template ngbNavContent>
<!-- CONTENU CONFIGURATION -->
<app-ui-card title="Création d'un Nouveau Merchant">
<span helper-text class="badge badge-soft-success badge-label fs-xxs py-1">
Payment Hub DCB
</span>
<div class="ins-wizard" card-body>
<!-- Progress Bar -->
<ngb-progressbar
class="mb-4"
[value]="progressValue"
type="primary"
height="6px"
/>
<!-- Navigation Steps -->
<ul class="nav nav-tabs wizard-tabs" role="tablist">
@for (step of wizardSteps; track $index; let i = $index) {
<li class="nav-item">
<a
href="javascript:void(0);"
[class.active]="i === currentStep"
class="nav-link"
[class]="i < currentStep ? 'wizard-item-done' : ''"
(click)="goToStep(i)"
>
<span class="d-flex align-items-center">
<ng-icon [name]="step.icon" class="fs-32" />
<span class="flex-grow-1 ms-2 text-truncate">
<span class="mb-0 lh-base d-block fw-semibold text-body fs-base">
{{ step.title }}
</span>
<span class="mb-0 fw-normal">{{ step.subtitle }}</span>
</span>
</span>
</a>
</li>
}
</ul>
<!-- Messages -->
@if (configError) {
<div class="alert alert-danger mt-3">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
{{ configError }}
</div>
}
@if (configSuccess) {
<div class="alert alert-success mt-3">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
{{ configSuccess }}
</div>
}
<!-- Contenu des Steps -->
<div class="tab-content pt-3">
@for (step of wizardSteps; track $index; let i = $index) {
<div
class="tab-pane fade"
[class.show]="currentStep === i"
[class.active]="currentStep === i"
>
<form [formGroup]="merchantForm">
<!-- Step 1: Informations Société -->
@if (i === 0) {
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Nom de l'entreprise *</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="name"
placeholder="Nom commercial" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom légal *</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="legalName"
placeholder="Raison sociale" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Numéro fiscal</label>
<div formGroupName="companyInfo">
<input type="text" class="form-control" formControlName="taxId"
placeholder="NIF/RC" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Pays *</label>
<div formGroupName="companyInfo">
<select class="form-select" formControlName="country">
@for (country of countries; track country.code) {
@if (country.code !== 'all') {
<option [value]="country.code">{{ country.name }}</option>
}
}
</select>
</div>
</div>
<div class="col-12 mb-3">
<label class="form-label">Adresse *</label>
<div formGroupName="companyInfo">
<textarea class="form-control" formControlName="address"
placeholder="Adresse complète" rows="2"></textarea>
</div>
</div>
</div>
}
<!-- Step 2: Contact Principal -->
@if (i === 1) {
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Email *</label>
<!-- CORRECTION : Ajouter formGroupName="contactInfo" -->
<div formGroupName="contactInfo">
<input type="email" class="form-control" formControlName="email"
placeholder="email@entreprise.com" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Téléphone *</label>
<div formGroupName="contactInfo">
<input type="tel" class="form-control" formControlName="phone"
placeholder="+225 XX XX XX XX" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Prénom *</label>
<div formGroupName="contactInfo">
<input type="text" class="form-control" formControlName="firstName"
placeholder="Prénom du contact" />
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Nom *</label>
<div formGroupName="contactInfo">
<input type="text" class="form-control" formControlName="lastName"
placeholder="Nom du contact" />
</div>
</div>
</div>
}
<!-- Dans la section Configuration - Step 3: Configuration Paiements -->
@if (i === 2) {
<div class="row">
<div class="col-12 mb-3">
<label class="form-label">Opérateurs supportés</label>
<!-- CORRECTION : Ajouter formGroupName="paymentConfig" -->
<div formGroupName="paymentConfig">
<div class="row">
@for (operator of operators; track operator; let idx = $index) {
<div class="col-md-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox"
[formControl]="supportedOperatorsArray[idx]"
[id]="'operator-' + idx">
<label class="form-check-label" [for]="'operator-' + idx">
{{ operator }}
</label>
</div>
</div>
}
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Devise par défaut</label>
<div formGroupName="paymentConfig">
<select class="form-select" formControlName="defaultCurrency">
<option value="XOF">XOF (Franc CFA)</option>
<option value="XAF">XAF (Franc CFA)</option>
<option value="USD">USD (Dollar US)</option>
</select>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Montant max par transaction (XOF)</label>
<div formGroupName="paymentConfig">
<input type="number" class="form-control" formControlName="maxTransactionAmount"
min="1000" max="1000000" />
</div>
</div>
</div>
}
<!-- Dans la section Configuration - Step 4: Webhooks -->
@if (i === 3) {
<div class="row">
<div class="col-12 mb-3">
<h6>Webhooks Abonnements</h6>
<!-- CORRECTION : Ajouter formGroupName="webhookConfig" -->
<div formGroupName="webhookConfig">
<div formGroupName="subscription">
<div class="row">
<div class="col-md-6 mb-2">
<label class="form-label">Création d'abonnement</label>
<input type="url" class="form-control"
formControlName="onCreate"
placeholder="https://votre-domaine.com/webhooks/subscription-created" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Renouvellement</label>
<input type="url" class="form-control"
formControlName="onRenew"
placeholder="https://votre-domaine.com/webhooks/subscription-renewed" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Annulation</label>
<input type="url" class="form-control"
formControlName="onCancel"
placeholder="https://votre-domaine.com/webhooks/subscription-cancelled" />
</div>
<div class="col-md-6 mb-2">
<label class="form-label">Expiration</label>
<input type="url" class="form-control"
formControlName="onExpire"
placeholder="https://votre-domaine.com/webhooks/subscription-expired" />
</div>
</div>
</div>
</div>
</div>
<div class="col-12 mb-3">
<h6>Webhooks Paiements</h6>
<div formGroupName="webhookConfig">
<div formGroupName="payment">
<div class="row">
<div class="col-md-4 mb-2">
<label class="form-label">Paiement réussi</label>
<input type="url" class="form-control"
formControlName="onSuccess"
placeholder="https://votre-domaine.com/webhooks/payment-success" />
</div>
<div class="col-md-4 mb-2">
<label class="form-label">Paiement échoué</label>
<input type="url" class="form-control"
formControlName="onFailure"
placeholder="https://votre-domaine.com/webhooks/payment-failed" />
</div>
<div class="col-md-4 mb-2">
<label class="form-label">Remboursement</label>
<input type="url" class="form-control"
formControlName="onRefund"
placeholder="https://votre-domaine.com/webhooks/payment-refunded" />
</div>
</div>
</div>
</div>
</div>
</div>
}
</form>
<!-- Navigation Buttons -->
<div class="d-flex justify-content-between mt-4">
@if (i > 0) {
<button type="button" class="btn btn-secondary" (click)="previousStep()">
← Précédent
</button>
} @else {
<div></div>
}
@if (i < wizardSteps.length - 1) {
<button type="button" class="btn btn-primary" (click)="nextStep()">
Suivant →
</button>
} @else {
<button type="button" class="btn btn-success"
(click)="submitForm()" [disabled]="configLoading">
@if (configLoading) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Créer le Merchant
</button>
}
</div>
</div>
}
</div>
</div>
</app-ui-card>
</ng-template>
</li>
<li [ngbNavItem]="'stats'">
<a ngbNavLink (click)="onTabChange('stats')">
<ng-icon name="lucideBarChart3" class="me-1"></ng-icon>
Statistiques
</a>
<ng-template ngbNavContent>
<!-- CONTENU STATISTIQUES -->
<!-- Loading State -->
@if (statsLoading) {
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
<p class="mt-2 text-muted">Chargement des statistiques...</p>
</div>
}
<!-- Statistiques Globales -->
@if (stats && !statsLoading) {
<div class="row g-4">
<!-- KPI Cards -->
<div class="col-xl-3 col-md-6">
<div class="card border-primary">
<div class="card-body text-center">
<ng-icon name="lucideCreditCard" class="text-primary fs-24 mb-2"></ng-icon>
<h3 class="text-primary">{{ formatNumber(stats.totalTransactions) }}</h3>
<p class="text-muted mb-0">Transactions totales</p>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card border-success">
<div class="card-body text-center">
<ng-icon name="lucideTrendingUp" class="text-success fs-24 mb-2"></ng-icon>
<h3 class="text-success">{{ stats.successRate }}%</h3>
<p class="text-muted mb-0">Taux de succès</p>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card border-info">
<div class="card-body text-center">
<ng-icon name="lucideUsers" class="text-info fs-24 mb-2"></ng-icon>
<h3 class="text-info">{{ formatNumber(stats.activeSubscriptions) }}</h3>
<p class="text-muted mb-0">Abonnements actifs</p>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6">
<div class="card border-warning">
<div class="card-body text-center">
<ng-icon name="lucideDollarSign" class="text-warning fs-24 mb-2"></ng-icon>
<h3 class="text-warning">{{ formatCurrency(stats.totalRevenue) }}</h3>
<p class="text-muted mb-0">Revenue total</p>
</div>
</div>
</div>
<!-- Détails des performances -->
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Détails des Performances</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-4">
<div class="border-end">
<h4 class="text-success">{{ formatNumber(stats.successfulTransactions) }}</h4>
<p class="text-muted mb-0">Transactions réussies</p>
</div>
</div>
<div class="col-md-4">
<div class="border-end">
<h4 class="text-danger">{{ formatNumber(stats.failedTransactions) }}</h4>
<p class="text-muted mb-0">Transactions échouées</p>
</div>
</div>
<div class="col-md-4">
<div>
<h4 class="text-info">+{{ stats.monthlyGrowth }}%</h4>
<p class="text-muted mb-0">Croissance mensuelle</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
</div>
</div>
</div>

View File

@ -1,2 +0,0 @@
import { Merchants } from './merchants';
describe('Merchants', () => {});

View File

@ -1,339 +0,0 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { NgIconComponent } from '@ng-icons/core';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormControl } from '@angular/forms';
import { NgbNavModule, NgbProgressbarModule, NgbPaginationModule, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { firstValueFrom } from 'rxjs'; // ← AJOUT IMPORT
import { PageTitle } from '@app/components/page-title/page-title';
import { UiCard } from '@app/components/ui-card';
import { MerchantsService } from './services/merchants.service';
import { MerchantResponse, MerchantStats, MerchantFormData } from './models/merchant.models';
@Component({
selector: 'app-merchant',
standalone: true,
imports: [
CommonModule,
RouterModule,
NgIconComponent,
FormsModule,
ReactiveFormsModule,
NgbNavModule,
NgbProgressbarModule,
NgbPaginationModule,
NgbDropdownModule,
PageTitle,
UiCard
],
templateUrl: './merchants.html',
})
export class Merchants implements OnInit {
private fb = inject(FormBuilder);
private merchantsService = inject(MerchantsService);
// Navigation par onglets
activeTab: 'list' | 'config' | 'stats' = 'list';
// === DONNÉES LISTE ===
merchants: MerchantResponse[] = [];
loading = false;
error = '';
// Pagination et filtres
currentPage = 1;
itemsPerPage = 10;
searchTerm = '';
statusFilter: 'all' | 'ACTIVE' | 'PENDING' | 'SUSPENDED' = 'all';
countryFilter = 'all';
// === DONNÉES CONFIG (WIZARD) ===
currentStep = 0;
wizardSteps = [
{ id: 'company-info', icon: 'lucideBuilding', title: 'Informations Société', subtitle: 'Détails entreprise' },
{ id: 'contact-info', icon: 'lucideUser', title: 'Contact Principal', subtitle: 'Personne de contact' },
{ id: 'payment-config', icon: 'lucideCreditCard', title: 'Configuration Paiements', subtitle: 'Paramètres DCB' },
{ id: 'webhooks', icon: 'lucideWebhook', title: 'Webhooks', subtitle: 'Notifications et retours' },
{ id: 'review', icon: 'lucideCheckCircle', title: 'Validation', subtitle: 'Vérification finale' }
];
configLoading = false;
configError = '';
configSuccess = '';
// === DONNÉES STATS ===
stats: MerchantStats | null = null;
statsLoading = false;
supportedOperatorsArray: FormControl<boolean | null>[] = [
this.fb.control(false), // Orange
this.fb.control(false), // MTN
this.fb.control(false), // Airtel
this.fb.control(false) // Moov
];
// === FORMULAIRE ===
merchantForm = this.fb.group({
companyInfo: this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
legalName: ['', [Validators.required]],
taxId: [''],
address: ['', [Validators.required]],
country: ['CIV', [Validators.required]]
}),
contactInfo: this.fb.group({
email: ['', [Validators.required, Validators.email]],
phone: ['', [Validators.required]],
firstName: ['', [Validators.required]],
lastName: ['', [Validators.required]]
}),
paymentConfig: this.fb.group({
// CORRECTION : Utiliser l'array déclaré séparément
supportedOperators: this.fb.array(this.supportedOperatorsArray),
defaultCurrency: ['XOF', [Validators.required]],
maxTransactionAmount: [50000, [Validators.min(1000), Validators.max(1000000)]]
}),
webhookConfig: this.fb.group({
subscription: this.fb.group({
onCreate: ['', [Validators.pattern('https?://.+')]],
onRenew: ['', [Validators.pattern('https?://.+')]],
onCancel: ['', [Validators.pattern('https?://.+')]],
onExpire: ['', [Validators.pattern('https?://.+')]]
}),
payment: this.fb.group({
onSuccess: ['', [Validators.pattern('https?://.+')]],
onFailure: ['', [Validators.pattern('https?://.+')]],
onRefund: ['', [Validators.pattern('https?://.+')]]
})
})
});
// Données partagées
countries = [
{ code: 'all', name: 'Tous les pays' },
{ code: 'CIV', name: 'Côte d\'Ivoire' },
{ code: 'SEN', name: 'Sénégal' },
{ code: 'CMR', name: 'Cameroun' },
{ code: 'COD', name: 'RDC' },
{ code: 'TUN', name: 'Tunisie' },
{ code: 'BFA', name: 'Burkina Faso' },
{ code: 'MLI', name: 'Mali' },
{ code: 'GIN', name: 'Guinée' }
];
operators = ['Orange', 'MTN', 'Airtel', 'Moov'];
ngOnInit() {
this.loadMerchants();
this.loadStats();
}
// === MÉTHODES LISTE ===
loadMerchants() {
this.loading = true;
this.merchantsService.getAllMerchants().subscribe({
next: (merchants) => {
this.merchants = merchants;
this.loading = false;
},
error: (error) => {
this.error = 'Erreur lors du chargement des merchants';
this.loading = false;
console.error('Error loading merchants:', error);
}
});
}
get filteredMerchants(): MerchantResponse[] {
return this.merchants.filter(merchant => {
const matchesSearch = !this.searchTerm ||
merchant.name.toLowerCase().includes(this.searchTerm.toLowerCase()) ||
merchant.email.toLowerCase().includes(this.searchTerm.toLowerCase());
const matchesStatus = this.statusFilter === 'all' || merchant.status === this.statusFilter;
const matchesCountry = this.countryFilter === 'all' || merchant.country === this.countryFilter;
return matchesSearch && matchesStatus && matchesCountry;
});
}
get displayedMerchants(): MerchantResponse[] {
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
return this.filteredMerchants.slice(startIndex, startIndex + this.itemsPerPage);
}
getStatusBadgeClass(status: string): string {
switch (status) {
case 'ACTIVE': return 'bg-success';
case 'PENDING': return 'bg-warning';
case 'SUSPENDED': return 'bg-danger';
default: return 'bg-secondary';
}
}
getStatusText(status: string): string {
switch (status) {
case 'ACTIVE': return 'Actif';
case 'PENDING': return 'En attente';
case 'SUSPENDED': return 'Suspendu';
default: return 'Inconnu';
}
}
getCountryName(code: string): string {
const country = this.countries.find(c => c.code === code);
return country ? country.name : code;
}
suspendMerchant(merchant: MerchantResponse) {
if (confirm(`Êtes-vous sûr de vouloir suspendre ${merchant.name} ?`)) {
this.merchantsService.updateMerchantStatus(merchant.partnerId, 'SUSPENDED').subscribe({
next: () => {
merchant.status = 'SUSPENDED';
},
error: (error) => {
console.error('Error suspending merchant:', error);
alert('Erreur lors de la suspension du merchant');
}
});
}
}
activateMerchant(merchant: MerchantResponse) {
this.merchantsService.updateMerchantStatus(merchant.partnerId, 'ACTIVE').subscribe({
next: () => {
merchant.status = 'ACTIVE';
},
error: (error) => {
console.error('Error activating merchant:', error);
alert('Erreur lors de l\'activation du merchant');
}
});
}
onPageChange(page: number) {
this.currentPage = page;
}
clearFilters() {
this.searchTerm = '';
this.statusFilter = 'all';
this.countryFilter = 'all';
this.currentPage = 1;
}
// === MÉTHODES CONFIG (WIZARD) ===
get progressValue(): number {
return ((this.currentStep + 1) / this.wizardSteps.length) * 100;
}
nextStep() {
if (this.currentStep < this.wizardSteps.length - 1) {
this.currentStep++;
}
}
previousStep() {
if (this.currentStep > 0) {
this.currentStep--;
}
}
goToStep(index: number) {
this.currentStep = index;
}
getSelectedOperators(): string[] {
return this.supportedOperatorsArray
.map((control, index) => control.value ? this.operators[index] : null)
.filter(op => op !== null) as string[];
}
async submitForm() {
if (this.merchantForm.valid) {
this.configLoading = true;
this.configError = '';
try {
const formData = this.merchantForm.value as unknown as MerchantFormData;
const registrationData = {
name: formData.companyInfo.name,
email: formData.contactInfo.email,
country: formData.companyInfo.country,
companyInfo: {
legalName: formData.companyInfo.legalName,
taxId: formData.companyInfo.taxId,
address: formData.companyInfo.address
}
};
// CORRECTION : Utilisation de firstValueFrom au lieu de toPromise()
const partnerResponse = await firstValueFrom(
this.merchantsService.registerMerchant(registrationData)
);
if (partnerResponse) {
// CORRECTION : Utilisation de firstValueFrom au lieu de toPromise()
await firstValueFrom(
this.merchantsService.updateCallbacks(partnerResponse.partnerId, formData.webhookConfig)
);
this.configSuccess = `Merchant créé avec succès! API Key: ${partnerResponse.apiKey}`;
this.merchantForm.reset();
this.currentStep = 0;
this.loadMerchants(); // Recharger la liste
this.activeTab = 'list'; // Retourner à la liste
}
} catch (error) {
this.configError = 'Erreur lors de la création du merchant';
console.error('Error creating merchant:', error);
} finally {
this.configLoading = false;
}
}
}
// === MÉTHODES STATS ===
loadStats() {
this.statsLoading = true;
// Données mockées pour l'exemple
this.stats = {
totalTransactions: 12543,
successfulTransactions: 12089,
failedTransactions: 454,
totalRevenue: 45875000,
activeSubscriptions: 8450,
successRate: 96.4,
monthlyGrowth: 12.3
};
this.statsLoading = false;
}
formatNumber(num: number): string {
return new Intl.NumberFormat('fr-FR').format(num);
}
formatCurrency(amount: number): string {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'XOF'
}).format(amount);
}
// Méthode utilitaire pour Math.min dans le template
mathMin(a: number, b: number): number {
return Math.min(a, b);
}
// === MÉTHODES COMMUNES ===
onTabChange(tab: 'list' | 'config' | 'stats') {
this.activeTab = tab;
if (tab === 'list') {
this.loadMerchants();
} else if (tab === 'stats') {
this.loadStats();
}
}
}

View File

@ -1,81 +0,0 @@
export interface MerchantRegistration {
name: string;
email: string;
country: string;
companyInfo?: {
legalName?: string;
taxId?: string;
address?: string;
};
}
export interface MerchantResponse {
partnerId: string;
name: string;
email: string;
country: string;
apiKey: string;
secretKey: string;
status: 'PENDING' | 'ACTIVE' | 'SUSPENDED';
createdAt: string;
companyInfo?: {
legalName?: string;
taxId?: string;
address?: string;
};
}
export interface CallbackConfiguration {
headerEnrichment?: {
url?: string;
method?: 'GET' | 'POST';
headers?: { [key: string]: string };
};
subscription?: {
onCreate?: string;
onRenew?: string;
onCancel?: string;
onExpire?: string;
};
payment?: {
onSuccess?: string;
onFailure?: string;
onRefund?: string;
};
authentication?: {
onSuccess?: string;
onFailure?: string;
};
}
export interface MerchantStats {
totalTransactions: number;
successfulTransactions: number;
failedTransactions: number;
totalRevenue: number;
activeSubscriptions: number;
successRate: number;
monthlyGrowth: number;
}
export interface MerchantFormData {
companyInfo: {
name: string;
legalName: string;
taxId: string;
address: string;
country: string;
};
contactInfo: {
email: string;
phone: string;
firstName: string;
lastName: string;
};
paymentConfig: {
supportedOperators: string[];
defaultCurrency: string;
maxTransactionAmount: number;
};
webhookConfig: CallbackConfiguration;
}

View File

@ -1,54 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
import {
MerchantRegistration,
MerchantResponse,
CallbackConfiguration,
MerchantStats
} from '../models/merchant.models';
@Injectable({ providedIn: 'root' })
export class MerchantsService {
private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/partners`;
// Enregistrement d'un nouveau merchant
registerMerchant(registration: MerchantRegistration): Observable<MerchantResponse> {
return this.http.post<MerchantResponse>(`${this.apiUrl}/register`, registration);
}
// Configuration des webhooks
updateCallbacks(partnerId: string, callbacks: CallbackConfiguration): Observable<CallbackConfiguration> {
return this.http.put<CallbackConfiguration>(`${this.apiUrl}/${partnerId}/callbacks`, callbacks);
}
// Récupération de tous les merchants
getAllMerchants(): Observable<MerchantResponse[]> {
return this.http.get<MerchantResponse[]>(`${this.apiUrl}`);
}
// Récupération d'un merchant par ID
getMerchantById(partnerId: string): Observable<MerchantResponse> {
return this.http.get<MerchantResponse>(`${this.apiUrl}/${partnerId}`);
}
// Statistiques d'un merchant
getMerchantStats(partnerId: string): Observable<MerchantStats> {
return this.http.get<MerchantStats>(`${this.apiUrl}/${partnerId}/stats`);
}
// Test des webhooks
testWebhook(url: string, event: string): Observable<{ success: boolean; response: any; responseTime: number }> {
return this.http.post<{ success: boolean; response: any; responseTime: number }>(
`${environment.iamApiUrl}/webhooks/test`,
{ url, event }
);
}
// Suspension/Activation d'un merchant
updateMerchantStatus(partnerId: string, status: 'ACTIVE' | 'SUSPENDED'): Observable<MerchantResponse> {
return this.http.patch<MerchantResponse>(`${this.apiUrl}/${partnerId}`, { status });
}
}

View File

@ -1,312 +0,0 @@
import { Component } from '@angular/core'
import { NgIcon } from '@ng-icons/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { UiCard } from '@app/components/ui-card'
import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap'
import { wizardSteps } from '@/app/modules/merchants/data'
@Component({
selector: 'app-wizard-with-progress',
imports: [
NgIcon,
ReactiveFormsModule,
FormsModule,
UiCard,
NgbProgressbarModule,
],
template: `
<app-ui-card title="Progressbar Support">
<span helper-text class="badge badge-soft-success badge-label fs-xxs py-1"
>Exclusive</span
>
<div class="ins-wizard" card-body>
<ngb-progressbar
class="mb-4"
[value]="progressValue"
type="primary"
height="6px"
>
</ngb-progressbar>
<ul class="nav nav-tabs wizard-tabs" role="tablist">
@for (step of wizardSteps; track $index; let i = $index) {
<li class="nav-item">
<a
href="javascript:void(0);"
[class.active]="i === currentStep"
class="nav-link"
[class]="i < currentStep ? 'wizard-item-done' : ''"
(click)="goToStep(i)"
>
<span class="d-flex align-items-center">
<ng-icon [name]="step.icon" class="fs-32" />
<span class="flex-grow-1 ms-2 text-truncate">
<span
class="mb-0 lh-base d-block fw-semibold text-body fs-base"
>{{ step.title }}</span
>
<span class="mb-0 fw-normal">{{ step.subtitle }}</span>
</span>
</span>
</a>
</li>
}
</ul>
<div class="tab-content pt-3">
@for (step of wizardSteps; track $index; let i = $index) {
<div
class="tab-pane fade"
[class.show]="currentStep === i"
[class.active]="currentStep === i"
>
@switch (i) {
@case (0) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Full Name</label>
<input
type="text"
class="form-control"
placeholder="Enter your full name"
name="fullname"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Email</label>
<input
type="email"
class="form-control"
placeholder="Enter your email"
name="email"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Phone Number</label>
<input
type="tel"
class="form-control"
name="phone"
placeholder="Enter your phone number"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Date of Birth</label>
<input
type="text"
data-provider="flatpickr"
data-date-format="d M, Y"
placeholder="Select your DOB"
class="form-control"
name="dob"
required
/>
</div>
</div>
}
@case (1) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Street Address</label>
<input
type="text"
class="form-control"
name="street"
placeholder="123 Main St"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">City</label>
<input
type="text"
class="form-control"
name="city"
placeholder="e.g., New York"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">State</label>
<input
type="text"
class="form-control"
name="state"
placeholder="e.g., California"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Zip Code</label>
<input
type="text"
class="form-control"
name="zip"
placeholder="e.g., 10001"
required
/>
</div>
</div>
}
@case (2) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Choose Course</label>
<select class="form-select" name="course" required>
<option value="">Select</option>
<option value="Engineering">Engineering</option>
<option value="Medical">Medical</option>
<option value="Business">Business</option>
</select>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Enrollment Type</label>
<select class="form-select" name="enrollment" required>
<option value="">Select</option>
<option value="Full Time">Full Time</option>
<option value="Part Time">Part Time</option>
</select>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Preferred Batch Time</label>
<select class="form-select" name="batch_time" required>
<option value="">Select Time</option>
<option value="Morning">Morning (8am 12pm)</option>
<option value="Afternoon">Afternoon (1pm 5pm)</option>
<option value="Evening">Evening (6pm 9pm)</option>
</select>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Mode of Study</label>
<select class="form-select" name="mode" required>
<option value="">Select Mode</option>
<option value="Offline">Offline</option>
<option value="Online">Online</option>
<option value="Hybrid">Hybrid</option>
</select>
</div>
</div>
}
@case (3) {
<div class="row">
<div class="col-xl-6 mb-3">
<label class="form-label">Parent/Guardian Name</label>
<input
type="text"
class="form-control"
name="parent_name"
placeholder="e.g., John Doe"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Relation</label>
<input
type="text"
class="form-control"
name="relation"
placeholder="e.g., Father, Mother"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Parent Phone</label>
<input
type="tel"
class="form-control"
name="parent_phone"
placeholder="e.g., +1 555 123 4567"
required
/>
</div>
<div class="col-xl-6 mb-3">
<label class="form-label">Parent Email</label>
<input
type="email"
class="form-control"
name="parent_email"
placeholder="e.g., parent@example.com"
required
/>
</div>
</div>
}
@case (4) {
<div class="mb-3">
<label class="form-label">Upload ID Proof</label>
<input
type="file"
class="form-control"
name="id_proof"
required
/>
</div>
<div class="mb-3">
<label class="form-label">Upload Previous Marksheet</label>
<input
type="file"
class="form-control"
name="marksheet"
required
/>
</div>
}
}
<div class="d-flex justify-content-between mt-3">
@if (i > 0) {
<button
type="button"
class="btn btn-secondary"
(click)="previousStep()"
>
Back:
{{ step.title }}
</button>
}
@if (i < wizardSteps.length - 1) {
<button
type="button"
class="btn btn-primary ms-auto"
(click)="nextStep()"
>
Next: {{ step.title }}
</button>
}
@if (i === wizardSteps.length - 1) {
<button type="submit" class="btn btn-success">
Submit Application
</button>
}
</div>
</div>
}
</div>
</div>
</app-ui-card>
`,
styles: ``,
})
export class WizardWithProgress {
currentStep = 0
nextStep() {
if (this.currentStep < wizardSteps.length - 1) this.currentStep++
}
previousStep() {
if (this.currentStep > 0) this.currentStep--
}
goToStep(index: number) {
this.currentStep = index
}
get progressValue(): number {
const totalSteps = this.wizardSteps.length
return ((this.currentStep + 1) / totalSteps) * 100
}
protected readonly wizardSteps = wizardSteps
}

View File

@ -8,7 +8,7 @@ import { Users } from '@modules/users/users';
import { DcbDashboard } from './dcb-dashboard/dcb-dashboard'; import { DcbDashboard } from './dcb-dashboard/dcb-dashboard';
import { Team } from './team/team'; import { Team } from './team/team';
import { Transactions } from './transactions/transactions'; import { Transactions } from './transactions/transactions';
import { Merchants } from './merchants/merchants'; import { MerchantPartners } from './merchant-partners/merchant-partners';
import { OperatorsConfig } from './operators/config/config'; import { OperatorsConfig } from './operators/config/config';
import { OperatorsStats } from './operators/stats/stats'; import { OperatorsStats } from './operators/stats/stats';
import { WebhooksHistory } from './webhooks/history/history'; import { WebhooksHistory } from './webhooks/history/history';
@ -85,16 +85,23 @@ const routes: Routes = [
}, },
// --------------------------- // ---------------------------
// Merchants // Partners
// --------------------------- // ---------------------------
{ {
path: 'merchants', path: 'merchant-partners',
component: Merchants, component: MerchantPartners,
canActivate: [authGuard, roleGuard], canActivate: [authGuard, roleGuard],
data: { data: {
title: 'Gestion des Merchants', title: 'Gestion Partners/Marchants',
module: 'merchants', module: 'merchant-partners',
requiredRoles: ['admin', 'support'] requiredRoles: [
'dcb-admin',
'dcb-support',
'dcb-partner',
'dcb-partner-admin',
'dcb-partner-manager',
'dcb-partner-suport',
]
} }
}, },
@ -190,12 +197,12 @@ const routes: Routes = [
// Support & Profile (Tous les utilisateurs authentifiés) // Support & Profile (Tous les utilisateurs authentifiés)
// --------------------------- // ---------------------------
{ {
path: 'support', path: 'dcb-support',
component: Support, component: Support,
canActivate: [authGuard, roleGuard], canActivate: [authGuard, roleGuard],
data: { data: {
title: 'Support', title: 'Support',
module: 'support' module: 'dcb-support'
} }
}, },
{ {

View File

@ -1,4 +1,3 @@
<!-- my-profile.html -->
<div class="container-fluid"> <div class="container-fluid">
<!-- En-tête avec navigation --> <!-- En-tête avec navigation -->
<div class="row mb-4"> <div class="row mb-4">
@ -7,20 +6,60 @@
<div> <div>
<h4 class="mb-1"> <h4 class="mb-1">
@if (user) { @if (user) {
Mon Profil - {{ getUserDisplayName() }} {{ getUserDisplayName() }}
} @else { } @else {
Mon Profil Profil Utilisateur
} }
</h4> </h4>
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0"> <ol class="breadcrumb mb-0">
<li class="breadcrumb-item active">Mon Profil</li> <li class="breadcrumb-item">
<a href="javascript:void(0)" (click)="back.emit()" class="text-decoration-none cursor-pointer">
Utilisateurs
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
@if (user) {
{{ getUserDisplayName() }}
} @else {
Profil
}
</li>
</ol> </ol>
</nav> </nav>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
@if (user && !isEditing) { <!-- Bouton de réinitialisation de mot de passe -->
@if (user && canEditUsers && !isEditing) {
<button
class="btn btn-warning"
(click)="resetPassword()"
>
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser MDP
</button>
<!-- Bouton activation/désactivation -->
@if (user.enabled) {
<button
class="btn btn-outline-warning"
(click)="disableUser()"
>
<ng-icon name="lucidePause" class="me-1"></ng-icon>
Désactiver
</button>
} @else {
<button
class="btn btn-outline-success"
(click)="enableUser()"
>
<ng-icon name="lucidePlay" class="me-1"></ng-icon>
Activer
</button>
}
<!-- Bouton modification -->
<button <button
class="btn btn-primary" class="btn btn-primary"
(click)="startEditing()" (click)="startEditing()"
@ -34,18 +73,38 @@
</div> </div>
</div> </div>
<!-- Indicateur de permissions -->
@if (currentUserRole && !canEditUsers) {
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-warning">
<div class="d-flex align-items-center">
<ng-icon name="lucideShield" class="me-2"></ng-icon>
<div>
<strong>Permissions limitées :</strong> Vous ne pouvez que consulter ce profil
</div>
</div>
</div>
</div>
</div>
}
<!-- Messages d'alerte --> <!-- Messages d'alerte -->
@if (error) { @if (error) {
<div class="alert alert-danger"> <div class="alert alert-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon> <div class="d-flex align-items-center">
{{ error }} <ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
</div>
</div> </div>
} }
@if (success) { @if (success) {
<div class="alert alert-success"> <div class="alert alert-success">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon> <div class="d-flex align-items-center">
{{ success }} <ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ success }}</div>
</div>
</div> </div>
} }
@ -56,7 +115,7 @@
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Chargement...</span> <span class="visually-hidden">Chargement...</span>
</div> </div>
<p class="mt-2 text-muted">Chargement de votre profil...</p> <p class="mt-2 text-muted">Chargement du profil...</p>
</div> </div>
} }
@ -67,12 +126,12 @@
<!-- Carte profil --> <!-- Carte profil -->
<div class="card"> <div class="card">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="card-title mb-0">Mon Profil</h5> <h5 class="card-title mb-0">Profil Utilisateur Hub</h5>
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
<!-- Avatar --> <!-- Avatar -->
<div class="avatar-lg mx-auto mb-3"> <div class="avatar-lg mx-auto mb-3">
<div class="avatar-title bg-primary rounded-circle text-white fs-24"> <div class="avatar-title bg-primary bg-opacity-10 rounded-circle text-primary fs-24">
{{ getUserInitials() }} {{ getUserInitials() }}
</div> </div>
</div> </div>
@ -90,43 +149,112 @@
<div class="d-flex align-items-center mb-2"> <div class="d-flex align-items-center mb-2">
<ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon> <ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon>
<small>{{ user.email }}</small> <small>{{ user.email }}</small>
@if (!user.emailVerified) {
<ng-icon name="lucideAlertTriangle" class="ms-1 text-warning" size="14" title="Email non vérifié"></ng-icon>
}
</div> </div>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon> <ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<small>Membre depuis {{ formatTimestamp(user.createdTimestamp) }}</small> <small>Créé le {{ formatTimestamp(user.createdTimestamp) }}</small>
</div> </div>
<div class="d-flex align-items-center mt-2"> @if (user.lastLogin) {
<ng-icon name="lucideShield" class="me-2 text-muted"></ng-icon> <div class="d-flex align-items-center mt-2">
<small class="text-info">Vous pouvez modifier votre mot de passe ici</small> <ng-icon name="lucideLogIn" class="me-2 text-muted"></ng-icon>
</div> <small>Dernière connexion : {{ formatTimestamp(user.lastLogin) }}</small>
</div> </div>
}
<!-- Bouton de sécurité -->
<div class="mt-4">
<button
class="btn btn-outline-warning btn-sm"
(click)="openResetPasswordModal()"
[disabled]="resettingPassword"
>
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Changer mon mot de passe
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Carte rôles --> <!-- Carte rôle utilisateur -->
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header bg-light"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Mes Rôles</h5> <h5 class="card-title mb-0">Rôle Utilisateur</h5>
@if (canManageRoles && !isEditing) {
<span class="badge bg-info">Modifiable</span>
}
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-flex flex-wrap gap-1"> <!-- Rôle actuel -->
@for (role of user.clientRoles; track role) { <div class="text-center mb-3">
<span class="badge" [ngClass]="getRoleBadgeClass(role)"> <span class="badge d-flex align-items-center justify-content-center" [ngClass]="getRoleBadgeClass(user.role)">
{{ role }} <ng-icon [name]="getRoleIcon(user.role)" class="me-2"></ng-icon>
</span> {{ getRoleLabel(user.role) }}
} </span>
<small class="text-muted d-block mt-1">
{{ getRoleDescription(user.role) }}
</small>
</div>
<!-- Changement de rôle -->
@if (canManageRoles && !isEditing) {
<div class="mt-3">
<label class="form-label fw-semibold">Changer le rôle</label>
<select
class="form-select"
[value]="user.role"
(change)="updateUserRole($any($event.target).value)"
[disabled]="updatingRoles"
>
<option value="" disabled>Sélectionnez un nouveau rôle</option>
@for (role of availableRoles; track role.value) {
<option
[value]="role.value"
[disabled]="!canAssignRole(role.value) || role.value === user.role"
>
{{ role.label }}
@if (!canAssignRole(role.value)) {
(Non autorisé)
} @else if (role.value === user.role) {
(Actuel)
}
</option>
}
</select>
<div class="form-text">
@if (updatingRoles) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Mise à jour...</span>
</div>
Mise à jour en cours...
} @else {
Sélectionnez un nouveau rôle pour cet utilisateur
}
</div>
</div>
} @else if (!canManageRoles) {
<div class="alert alert-info mt-3">
<small>
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Vous n'avez pas la permission de modifier les rôles
</small>
</div>
}
</div>
</div>
<!-- Informations de création -->
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Informations de Création</h6>
</div>
<div class="card-body">
<div class="row g-2 small">
<div class="col-12">
<strong>Créé par :</strong>
<div class="text-muted">{{ user.createdByUsername || 'Système' }}</div>
</div>
<div class="col-12">
<strong>Date de création :</strong>
<div class="text-muted">{{ formatTimestamp(user.createdTimestamp) }}</div>
</div>
<div class="col-12">
<strong>Type d'utilisateur :</strong>
<div class="text-muted">
<span class="badge bg-secondary">{{ user.userType }}</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -135,14 +263,44 @@
<!-- Colonne de droite - Détails et édition --> <!-- Colonne de droite - Détails et édition -->
<div class="col-xl-8 col-lg-7"> <div class="col-xl-8 col-lg-7">
<div class="card"> <div class="card">
<div class="card-header bg-light"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
@if (isEditing) { @if (isEditing) {
<ng-icon name="lucideEdit" class="me-2"></ng-icon>
Modification du Profil Modification du Profil
} @else { } @else {
Mes Informations <ng-icon name="lucideUser" class="me-2"></ng-icon>
Détails du Compte
} }
</h5> </h5>
@if (isEditing) {
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-outline-secondary btn-sm"
(click)="cancelEditing()"
[disabled]="saving"
>
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler
</button>
<button
type="button"
class="btn btn-success btn-sm"
(click)="saveProfile()"
[disabled]="saving"
>
@if (saving) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Enregistrer
</button>
</div>
}
</div> </div>
<div class="card-body"> <div class="card-body">
@ -155,7 +313,8 @@
type="text" type="text"
class="form-control" class="form-control"
[(ngModel)]="editedUser.firstName" [(ngModel)]="editedUser.firstName"
placeholder="Votre prénom" placeholder="Entrez le prénom"
[disabled]="saving"
> >
} @else { } @else {
<div class="form-control-plaintext"> <div class="form-control-plaintext">
@ -172,7 +331,8 @@
type="text" type="text"
class="form-control" class="form-control"
[(ngModel)]="editedUser.lastName" [(ngModel)]="editedUser.lastName"
placeholder="Votre nom" placeholder="Entrez le nom"
[disabled]="saving"
> >
} @else { } @else {
<div class="form-control-plaintext"> <div class="form-control-plaintext">
@ -184,10 +344,12 @@
<!-- Nom d'utilisateur --> <!-- Nom d'utilisateur -->
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Nom d'utilisateur</label> <label class="form-label">Nom d'utilisateur</label>
<div class="form-control-plaintext"> <div class="form-control-plaintext font-monospace">
{{ user.username }} {{ user.username }}
</div> </div>
<small class="text-muted">Le nom d'utilisateur ne peut pas être modifié</small> <div class="form-text">
Le nom d'utilisateur ne peut pas être modifié
</div>
</div> </div>
<!-- Email --> <!-- Email -->
@ -198,56 +360,45 @@
type="email" type="email"
class="form-control" class="form-control"
[(ngModel)]="editedUser.email" [(ngModel)]="editedUser.email"
placeholder="votre@email.com" placeholder="email@exemple.com"
[disabled]="saving"
> >
} @else { } @else {
<div class="form-control-plaintext"> <div class="form-control-plaintext">
{{ user.email }} {{ user.email }}
@if (!user.emailVerified) {
<span class="badge bg-warning ms-2">Non vérifié</span>
}
</div> </div>
} }
</div> </div>
<!-- Section Sécurité --> <!-- Statut activé -->
<div class="col-12"> @if (isEditing) {
<hr> <div class="col-md-6">
<h6> <div class="form-check form-switch">
<ng-icon name="lucideShield" class="me-2 text-warning"></ng-icon> <input
Sécurité du Compte class="form-check-input"
</h6> type="checkbox"
<div class="alert alert-info"> id="enabledSwitch"
<div class="d-flex justify-content-between align-items-center"> [(ngModel)]="editedUser.enabled"
<div> [disabled]="saving"
<strong>Mot de passe</strong> >
<br> <label class="form-check-label" for="enabledSwitch">
<small class="text-muted">Vous pouvez changer votre mot de passe à tout moment</small> Compte activé
</div> </label>
</div>
<div class="form-text">
L'utilisateur peut se connecter si activé
</div> </div>
</div> </div>
</div> } @else {
<div class="col-md-6">
<!-- Actions d'édition --> <label class="form-label">Statut du compte</label>
@if (isEditing) { <div class="form-control-plaintext">
<div class="col-12"> <span [class]="getStatusBadgeClass()">
<div class="d-flex gap-2 justify-content-end mt-4"> {{ getStatusText() }}
<button </span>
type="button"
class="btn btn-light"
(click)="cancelEditing()"
[disabled]="saving">
Annuler
</button>
<button
type="button"
class="btn btn-success"
(click)="saveProfile()"
[disabled]="saving">
@if (saving) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Enregistrer
</button>
</div> </div>
</div> </div>
} }
@ -256,11 +407,14 @@
@if (!isEditing) { @if (!isEditing) {
<div class="col-12"> <div class="col-12">
<hr> <hr>
<h6>Informations Système</h6> <h6 class="mb-3">
<ng-icon name="lucideSettings" class="me-2"></ng-icon>
Informations Système
</h6>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">ID Utilisateur</label> <label class="form-label">ID Utilisateur</label>
<div class="form-control-plaintext font-monospace small"> <div class="form-control-plaintext font-monospace small text-truncate">
{{ user.id }} {{ user.id }}
</div> </div>
</div> </div>
@ -270,136 +424,75 @@
{{ formatTimestamp(user.createdTimestamp) }} {{ formatTimestamp(user.createdTimestamp) }}
</div> </div>
</div> </div>
<div class="col-md-6">
<label class="form-label">Créé par</label>
<div class="form-control-plaintext">
{{ user.createdByUsername || 'Système' }}
</div>
</div>
<div class="col-md-6">
<label class="form-label">Type d'utilisateur</label>
<div class="form-control-plaintext">
<span class="badge bg-secondary">{{ user.userType }}</span>
</div>
</div>
</div> </div>
</div> </div>
} }
</div> </div>
</div> </div>
</div> </div>
</div>
}
</div>
</div>
<!-- Modal de réinitialisation de mot de passe --> <!-- Actions supplémentaires -->
<!-- Modal de réinitialisation de mot de passe --> @if (!isEditing && canEditUsers) {
<ng-template #resetPasswordModal let-modal> <div class="card mt-3">
<div class="modal-header"> <div class="card-header bg-light">
<h4 class="modal-title"> <h6 class="card-title mb-0">Actions de Gestion</h6>
<ng-icon name="lucideKey" class="me-2"></ng-icon> </div>
Réinitialiser votre mot de passe <div class="card-body">
</h4> <div class="row g-2">
<button <div class="col-md-4">
type="button" <button
class="btn-close" class="btn btn-outline-warning w-100"
(click)="modal.dismiss()" (click)="resetPassword()"
[disabled]="resettingPassword" >
></button> <ng-icon name="lucideKey" class="me-1"></ng-icon>
</div> Réinitialiser MDP
</button>
<div class="modal-body"> </div>
<!-- Message de succès --> <div class="col-md-4">
@if (resetPasswordSuccess) { @if (user.enabled) {
<div class="alert alert-success d-flex align-items-center"> <button
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon> class="btn btn-outline-secondary w-100"
<div>{{ resetPasswordSuccess }}</div> (click)="disableUser()"
</div> >
} <ng-icon name="lucideUserX" class="me-1"></ng-icon>
Désactiver
<!-- Message d'erreur --> </button>
@if (resetPasswordError) { } @else {
<div class="alert alert-danger d-flex align-items-center"> <button
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon> class="btn btn-outline-success w-100"
<div>{{ resetPasswordError }}</div> (click)="enableUser()"
</div> >
} <ng-icon name="lucideUserCheck" class="me-1"></ng-icon>
Activer
@if (!resetPasswordSuccess && user) { </button>
<div class="alert alert-info"> }
<strong>Utilisateur :</strong> {{ user.username }} </div>
@if (user.firstName || user.lastName) { <div class="col-md-4">
<br> <button
<strong>Nom :</strong> {{ user.firstName }} {{ user.lastName }} class="btn btn-outline-primary w-100"
(click)="startEditing()"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier
</button>
</div>
</div>
</div>
</div>
} }
</div> </div>
<form (ngSubmit)="confirmResetPassword()" #resetForm="ngForm">
<div class="mb-3">
<label class="form-label">
Nouveau mot de passe <span class="text-danger">*</span>
</label>
<input
type="password"
class="form-control"
placeholder="Entrez votre nouveau mot de passe"
[(ngModel)]="newPassword"
name="newPassword"
required
minlength="8"
[disabled]="resettingPassword"
>
<div class="form-text">
Le mot de passe doit contenir au moins 8 caractères.
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="temporaryPassword"
[(ngModel)]="temporaryPassword"
name="temporaryPassword"
[disabled]="resettingPassword"
>
<label class="form-check-label" for="temporaryPassword">
Mot de passe temporaire
</label>
</div>
<div class="form-text">
Vous devrez changer votre mot de passe à la prochaine connexion.
</div>
</div>
</form>
} }
</div> </div>
</div>
<div class="modal-footer">
@if (resetPasswordSuccess) {
<button
type="button"
class="btn btn-success"
(click)="modal.close()"
>
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Fermer
</button>
} @else {
<button
type="button"
class="btn btn-light"
(click)="modal.dismiss()"
[disabled]="resettingPassword"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
(click)="confirmResetPassword()"
[disabled]="!newPassword || newPassword.length < 8 || resettingPassword"
>
@if (resettingPassword) {
<div class="spinner-border spinner-border-sm me-2" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
Réinitialisation...
} @else {
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser le mot de passe
}
</button>
}
</div>
</ng-template>

View File

@ -1,2 +1,2 @@
import { Profile } from './profile'; import { MyProfile } from './profile';
describe('Profile', () => {}); describe('MyProfile', () => {});

View File

@ -1,16 +1,18 @@
import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef } from '@angular/core'; // src/app/modules/users/profile/personal-profile.ts
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
import { UsersService } from '@modules/users/services/users.service'; import { Subject, takeUntil } from 'rxjs';
import { HubUsersService, UserRole, UpdateHubUserDto } from '../users/services/users.service';
import { RoleManagementService } from '@core/services/role-management.service';
import { AuthService } from '@core/services/auth.service'; import { AuthService } from '@core/services/auth.service';
import { UserResponse, UpdateUserDto } from '@modules/users/models/user';
@Component({ @Component({
selector: 'app-my-profile', selector: 'app-my-profile',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule, NgbModalModule], imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule],
templateUrl: './profile.html', templateUrl: './profile.html',
styles: [` styles: [`
.avatar-lg { .avatar-lg {
@ -22,129 +24,120 @@ import { UserResponse, UpdateUserDto } from '@modules/users/models/user';
} }
`] `]
}) })
export class MyProfile implements OnInit { export class MyProfile implements OnInit, OnDestroy {
private usersService = inject(UsersService); private usersService = inject(HubUsersService);
private roleService = inject(RoleManagementService);
private authService = inject(AuthService); private authService = inject(AuthService);
private modalService = inject(NgbModal);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
@ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef<any>; @Output() back = new EventEmitter<void>();
@Output() openResetPasswordModal = new EventEmitter<string>();
user: UserResponse | null = null; user: any | null = null;
loading = false; loading = false;
saving = false; saving = false;
error = ''; error = '';
success = ''; success = '';
// Gestion des permissions (toujours true pour le profil personnel)
currentUserRole: UserRole | null = null;
canEditUsers = true; // Toujours vrai pour son propre profil
canManageRoles = false; // Jamais vrai pour le profil personnel
canDeleteUsers = false; // Jamais vrai pour le profil personnel
// Édition // Édition
isEditing = false; isEditing = false;
editedUser: UpdateUserDto = {}; editedUser: UpdateHubUserDto = {};
// Réinitialisation mot de passe // Gestion des rôles (simplifiée pour profil personnel)
newPassword = ''; availableRoles: { value: UserRole; label: string; description: string }[] = [];
temporaryPassword = false; updatingRoles = false;
resettingPassword = false;
resetPasswordError = '';
resetPasswordSuccess = '';
ngOnInit() { ngOnInit() {
this.loadMyProfile(); this.initializeUserPermissions();
this.loadAvailableRoles();
this.loadUserProfile();
} }
loadMyProfile() { ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Initialise les permissions de l'utilisateur courant
*/
private initializeUserPermissions(): void {
this.authService.loadUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (profile) => {
this.currentUserRole = profile?.roles?.[0] as UserRole || null;
// Pour le profil personnel, on peut toujours éditer son propre profil
this.canEditUsers = true;
this.canManageRoles = false; // On ne peut pas gérer les rôles de son propre profil
this.canDeleteUsers = false; // On ne peut pas se supprimer soi-même
},
error: (error) => {
console.error('Error loading user permissions:', error);
}
});
}
/**
* Charge les rôles disponibles (lecture seule pour profil personnel)
*/
private loadAvailableRoles(): void {
this.roleService.getAvailableRolesSimple()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (roles) => {
this.availableRoles = roles;
},
error: (error) => {
console.error('Error loading available roles:', error);
// Fallback
this.availableRoles = [
{ value: UserRole.DCB_ADMIN, label: 'DCB Admin', description: 'Administrateur système' },
{ value: UserRole.DCB_SUPPORT, label: 'DCB Support', description: 'Support technique' },
{ value: UserRole.DCB_PARTNER, label: 'DCB Partner', description: 'Partenaire commercial' }
];
}
});
}
loadUserProfile() {
this.loading = true; this.loading = true;
this.error = ''; this.error = '';
this.usersService.getCurrentUserProfile().subscribe({ this.usersService.getUserById(this.user.id)
next: (user) => { .pipe(takeUntil(this.destroy$))
this.user = user; .subscribe({
this.loading = false; next: (user) => {
this.cdRef.detectChanges(); this.user = user;
}, this.loading = false;
error: (error) => { this.cdRef.detectChanges();
this.error = 'Erreur lors du chargement de votre profil'; },
this.loading = false; error: (error) => {
this.cdRef.detectChanges(); this.error = 'Erreur lors du chargement de votre profil';
console.error('Error loading my profile:', error); this.loading = false;
} this.cdRef.detectChanges();
}); console.error('Error loading user profile:', error);
}
// Ouvrir le modal de réinitialisation de mot de passe
openResetPasswordModal() {
if (!this.user) return;
this.newPassword = '';
this.temporaryPassword = false;
this.resetPasswordError = '';
this.resetPasswordSuccess = '';
this.modalService.open(this.resetPasswordModal, {
centered: true,
size: 'md'
});
}
// Réinitialiser le mot de passe
confirmResetPassword() {
if (!this.user || !this.newPassword || this.newPassword.length < 8) {
this.resetPasswordError = 'Veuillez saisir un mot de passe valide (au moins 8 caractères).';
return;
}
this.resettingPassword = true;
this.resetPasswordError = '';
this.resetPasswordSuccess = '';
const resetDto = {
userId: this.user.id,
newPassword: this.newPassword,
temporary: this.temporaryPassword
};
this.usersService.resetPassword(resetDto).subscribe({
next: () => {
this.resettingPassword = false;
this.resetPasswordSuccess = 'Votre mot de passe a été réinitialisé avec succès !';
this.cdRef.detectChanges();
// Déconnexion automatique si mot de passe temporaire
if (this.temporaryPassword) {
setTimeout(() => {
this.authService.logout();
}, 2000);
} }
}, });
error: (error) => {
this.resettingPassword = false;
this.resetPasswordError = this.getResetPasswordErrorMessage(error);
this.cdRef.detectChanges();
console.error('Error resetting password:', error);
}
});
}
// Gestion des erreurs pour la réinitialisation
private getResetPasswordErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 404) {
return 'Utilisateur non trouvé.';
}
if (error.status === 400) {
return 'Le mot de passe ne respecte pas les critères de sécurité.';
}
return 'Erreur lors de la réinitialisation du mot de passe. Veuillez réessayer.';
} }
startEditing() { startEditing() {
// Pas de vérification de permission pour le profil personnel
this.isEditing = true; this.isEditing = true;
this.editedUser = { this.editedUser = {
firstName: this.user?.firstName, firstName: this.user?.firstName,
lastName: this.user?.lastName, lastName: this.user?.lastName,
email: this.user?.email email: this.user?.email
// On ne permet pas de modifier 'enabled' sur son propre profil
}; };
this.cdRef.detectChanges();
} }
cancelEditing() { cancelEditing() {
@ -152,6 +145,7 @@ export class MyProfile implements OnInit {
this.editedUser = {}; this.editedUser = {};
this.error = ''; this.error = '';
this.success = ''; this.success = '';
this.cdRef.detectChanges();
} }
saveProfile() { saveProfile() {
@ -161,23 +155,70 @@ export class MyProfile implements OnInit {
this.error = ''; this.error = '';
this.success = ''; this.success = '';
this.usersService.updateCurrentUserProfile(this.editedUser).subscribe({ this.usersService.updateUser(this.user.id, this.editedUser)
next: (updatedUser) => { .pipe(takeUntil(this.destroy$))
this.user = updatedUser; .subscribe({
this.isEditing = false; next: (updatedUser) => {
this.saving = false; this.user = updatedUser;
this.success = 'Profil mis à jour avec succès'; this.isEditing = false;
this.editedUser = {}; this.saving = false;
}, this.success = 'Profil mis à jour avec succès';
error: (error) => { this.editedUser = {};
this.error = 'Erreur lors de la mise à jour du profil'; this.cdRef.detectChanges();
this.saving = false; },
console.error('Error updating profile:', error); error: (error) => {
} this.error = this.getErrorMessage(error);
}); this.saving = false;
this.cdRef.detectChanges();
}
});
} }
// Utilitaires d'affichage // Gestion des rôles - désactivée pour profil personnel
updateUserRole(newRole: UserRole) {
// Non autorisé pour le profil personnel
this.error = 'Vous ne pouvez pas modifier votre propre rôle';
this.cdRef.detectChanges();
}
// Gestion du statut - désactivée pour profil personnel
enableUser() {
// Non autorisé pour le profil personnel
this.error = 'Vous ne pouvez pas vous activer/désactiver vous-même';
this.cdRef.detectChanges();
}
disableUser() {
// Non autorisé pour le profil personnel
this.error = 'Vous ne pouvez pas vous activer/désactiver vous-même';
this.cdRef.detectChanges();
}
// Réinitialisation du mot de passe
resetPassword() {
if (this.user) {
this.openResetPasswordModal.emit(this.user.id);
}
}
// Gestion des erreurs - même méthode que le premier composant
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions nécessaires pour cette action';
}
if (error.status === 404) {
return 'Utilisateur non trouvé';
}
if (error.status === 400) {
return 'Données invalides';
}
return 'Une erreur est survenue. Veuillez réessayer.';
}
// Utilitaires d'affichage - mêmes méthodes que le premier composant
getStatusBadgeClass(): string { getStatusBadgeClass(): string {
if (!this.user) return 'badge bg-secondary'; if (!this.user) return 'badge bg-secondary';
if (!this.user.enabled) return 'badge bg-danger'; if (!this.user.enabled) return 'badge bg-danger';
@ -193,6 +234,7 @@ export class MyProfile implements OnInit {
} }
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number): string {
if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', { return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@ -215,13 +257,30 @@ export class MyProfile implements OnInit {
return this.user.username; return this.user.username;
} }
getRoleBadgeClass(role: string): string { getRoleBadgeClass(role: UserRole): string {
switch (role) { return this.roleService.getRoleBadgeClass(role);
case 'admin': return 'bg-danger'; }
case 'merchant': return 'bg-success';
case 'support': return 'bg-info'; getRoleLabel(role: UserRole): string {
case 'user': return 'bg-secondary'; return this.roleService.getRoleLabel(role);
default: return 'bg-secondary'; }
}
getRoleIcon(role: UserRole): string {
return this.roleService.getRoleIcon(role);
}
getRoleDescription(role: UserRole): string {
const roleInfo = this.availableRoles.find(r => r.value === role);
return roleInfo?.description || 'Description non disponible';
}
// Vérification des permissions pour les actions - toujours false pour les actions sensibles
canAssignRole(targetRole: UserRole): boolean {
return false; // Jamais autorisé pour le profil personnel
}
// Vérifie si c'est le profil de l'utilisateur courant - toujours true
isCurrentUserProfile(): boolean {
return true; // Toujours vrai pour le profil personnel
} }
} }

View File

@ -1,2 +1,2 @@
import { TransactionsDetails } from './details'; import { TransactionDetails } from './details';
describe('TransactionsDetails', () => {}); describe('TransactionDetails', () => {});

View File

@ -1,30 +1,71 @@
<app-ui-card title="Liste des Utilisateurs"> <app-ui-card title="Liste des Utilisateurs Hub">
<a <a
helper-text helper-text
href="javascript:void(0);" href="javascript:void(0);"
class="icon-link icon-link-hover link-primary fw-semibold" class="icon-link icon-link-hover link-primary fw-semibold"
>Gérez les accès utilisateurs de votre plateforme >Gérez les accès utilisateurs de votre plateforme DCB
</a> </a>
<div card-body> <div card-body>
<!-- Barre d'actions supérieure --> <!-- Barre d'actions supérieure -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="d-flex justify-right-end gap-2"> <div class="d-flex align-items-center gap-2">
<button <!-- Statistiques rapides -->
class="btn btn-primary" <div class="btn-group btn-group-sm">
(click)="openCreateModal.emit()" <button
> type="button"
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon> class="btn btn-outline-primary"
Nouvel Utilisateur [class.active]="roleFilter === 'all'"
</button> (click)="filterByRole('all')"
>
Tous ({{ allUsers.length }})
</button>
<button
type="button"
class="btn btn-outline-danger"
[class.active]="roleFilter === UserRole.DCB_ADMIN"
(click)="filterByRole(UserRole.DCB_ADMIN)"
>
Admins ({{ getUsersCountByRole(UserRole.DCB_ADMIN) }})
</button>
<button
type="button"
class="btn btn-outline-info"
[class.active]="roleFilter === UserRole.DCB_SUPPORT"
(click)="filterByRole(UserRole.DCB_SUPPORT)"
>
Support ({{ getUsersCountByRole(UserRole.DCB_SUPPORT) }})
</button>
<button
type="button"
class="btn btn-outline-success"
[class.active]="roleFilter === UserRole.DCB_PARTNER"
(click)="filterByRole(UserRole.DCB_PARTNER)"
>
Partenaires ({{ getUsersCountByRole(UserRole.DCB_PARTNER) }})
</button>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end gap-2">
@if (canCreateUsers) {
<button
class="btn btn-primary"
(click)="openCreateModal.emit()"
>
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Nouvel Utilisateur
</button>
}
</div> </div>
</div> </div>
</div> </div>
<!-- Barre de recherche et filtres --> <!-- Barre de recherche et filtres -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-4"> <div class="col-md-3">
<div class="input-group"> <div class="input-group">
<span class="input-group-text"> <span class="input-group-text">
<ng-icon name="lucideSearch"></ng-icon> <ng-icon name="lucideSearch"></ng-icon>
@ -32,20 +73,20 @@
<input <input
type="text" type="text"
class="form-control" class="form-control"
placeholder="Rechercher par nom, email, username..." placeholder="Nom, email, username..."
[(ngModel)]="searchTerm" [(ngModel)]="searchTerm"
(keyup.enter)="onSearch()" (keyup.enter)="onSearch()"
> >
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<select class="form-select" [(ngModel)]="statusFilter" (change)="onSearch()"> <select class="form-select" [(ngModel)]="statusFilter" (change)="onSearch()">
<option value="all">Tous les statuts</option> <option value="all">Tous les statuts</option>
<option value="enabled">Activés</option> <option value="enabled">Activés ({{ getEnabledUsersCount() }})</option>
<option value="disabled">Désactivés</option> <option value="disabled">Désactivés ({{ getDisabledUsersCount() }})</option>
</select> </select>
</div> </div>
<div class="col-md-3"> <div class="col-md-2">
<select class="form-select" [(ngModel)]="emailVerifiedFilter" (change)="onSearch()"> <select class="form-select" [(ngModel)]="emailVerifiedFilter" (change)="onSearch()">
<option value="all">Tous les emails</option> <option value="all">Tous les emails</option>
<option value="verified">Email vérifié</option> <option value="verified">Email vérifié</option>
@ -53,12 +94,21 @@
</select> </select>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<select class="form-select" [(ngModel)]="roleFilter" (change)="onSearch()">
@for (role of availableRoles; track role.value) {
<option [value]="role.value">{{ role.label }}</option>
}
</select>
</div>
<div class="col-md-3">
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-outline-primary" (click)="onSearch()"> <button class="btn btn-outline-primary" (click)="onSearch()">
<ng-icon name="lucideFilter"></ng-icon> <ng-icon name="lucideFilter" class="me-1"></ng-icon>
Appliquer
</button> </button>
<button class="btn btn-outline-secondary" (click)="onClearFilters()"> <button class="btn btn-outline-secondary" (click)="onClearFilters()">
<ng-icon name="lucideX"></ng-icon> <ng-icon name="lucideX" class="me-1"></ng-icon>
Réinitialiser
</button> </button>
</div> </div>
</div> </div>
@ -77,8 +127,10 @@
<!-- Error State --> <!-- Error State -->
@if (error && !loading) { @if (error && !loading) {
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon> <div class="d-flex align-items-center">
{{ error }} <ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
</div>
</div> </div>
} }
@ -100,7 +152,12 @@
<ng-icon [name]="getSortIcon('email')" class="ms-1 fs-12"></ng-icon> <ng-icon [name]="getSortIcon('email')" class="ms-1 fs-12"></ng-icon>
</div> </div>
</th> </th>
<th>Rôles</th> <th (click)="sort('role')" class="cursor-pointer">
<div class="d-flex align-items-center">
<span>Rôle</span>
<ng-icon [name]="getSortIcon('role')" class="ms-1 fs-12"></ng-icon>
</div>
</th>
<th (click)="sort('enabled')" class="cursor-pointer"> <th (click)="sort('enabled')" class="cursor-pointer">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span>Statut</span> <span>Statut</span>
@ -113,7 +170,7 @@
<ng-icon [name]="getSortIcon('createdTimestamp')" class="ms-1 fs-12"></ng-icon> <ng-icon [name]="getSortIcon('createdTimestamp')" class="ms-1 fs-12"></ng-icon>
</div> </div>
</th> </th>
<th width="150">Actions</th> <th width="180">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -121,35 +178,35 @@
<tr> <tr>
<td> <td>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="avatar-sm bg-primary rounded-circle d-flex align-items-center justify-content-center me-2"> <div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<span class="text-white fw-bold small"> <span class="text-primary fw-semibold small">
{{ getUserInitials(user) }} {{ getUserInitials(user) }}
</span> </span>
</div> </div>
<div> <div>
<strong>{{ getUserDisplayName(user) }}</strong> <strong class="d-block">{{ getUserDisplayName(user) }}</strong>
<div class="text-muted small">@{{ user.username }}</div> <small class="text-muted">@{{ user.username }}</small>
</div> </div>
</div> </div>
</td> </td>
<td> <td>
<div>{{ user.email }}</div> <div class="d-flex align-items-center">
@if (!user.emailVerified) { {{ user.email }}
<small class="text-warning"> @if (!user.emailVerified) {
<ng-icon name="lucideAlertCircle" class="me-1"></ng-icon> <ng-icon
Non vérifié name="lucideAlertTriangle"
</small> class="ms-1 text-warning"
} size="16"
title="Email non vérifié"
></ng-icon>
}
</div>
</td> </td>
<td> <td>
@for (role of user.clientRoles; track role) { <span class="badge d-flex align-items-center" [ngClass]="getRoleBadgeClass(user.role)">
<span class="badge me-1 mb-1" [ngClass]="getRoleBadgeClass(role)"> <ng-icon [name]="getRoleIcon(user.role)" class="me-1" size="14"></ng-icon>
{{ role }} {{ getRoleLabel(user.role) }}
</span> </span>
}
@if (user.clientRoles.length === 0) {
<span class="text-muted small">Aucun rôle</span>
}
</td> </td>
<td> <td>
<span [class]="getStatusBadgeClass(user)"> <span [class]="getStatusBadgeClass(user)">
@ -171,7 +228,7 @@
<ng-icon name="lucideEye"></ng-icon> <ng-icon name="lucideEye"></ng-icon>
</button> </button>
<button <button
class="btn btn-outline-info btn-sm" class="btn btn-outline-warning btn-sm"
(click)="resetPassword(user)" (click)="resetPassword(user)"
title="Réinitialiser le mot de passe" title="Réinitialiser le mot de passe"
> >
@ -179,11 +236,11 @@
</button> </button>
@if (user.enabled) { @if (user.enabled) {
<button <button
class="btn btn-outline-warning btn-sm" class="btn btn-outline-secondary btn-sm"
(click)="disableUser(user)" (click)="disableUser(user)"
title="Désactiver l'utilisateur" title="Désactiver l'utilisateur"
> >
<ng-icon name="lucidePause"></ng-icon> <ng-icon name="lucideUserX"></ng-icon>
</button> </button>
} @else { } @else {
<button <button
@ -191,16 +248,18 @@
(click)="enableUser(user)" (click)="enableUser(user)"
title="Activer l'utilisateur" title="Activer l'utilisateur"
> >
<ng-icon name="lucidePlay"></ng-icon> <ng-icon name="lucideUserCheck"></ng-icon>
</button>
}
@if (canDeleteUsers) {
<button
class="btn btn-outline-danger btn-sm"
(click)="deleteUser(user)"
title="Supprimer l'utilisateur"
>
<ng-icon name="lucideTrash2"></ng-icon>
</button> </button>
} }
<button
class="btn btn-outline-danger btn-sm"
(click)="deleteUser(user)"
title="Supprimer l'utilisateur"
>
<ng-icon name="lucideTrash2"></ng-icon>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -208,11 +267,17 @@
@empty { @empty {
<tr> <tr>
<td colspan="6" class="text-center py-4"> <td colspan="6" class="text-center py-4">
<ng-icon name="lucideUsers" class="text-muted fs-1 mb-2"></ng-icon> <div class="text-muted">
<p class="text-muted">Aucun utilisateur trouvé</p> <ng-icon name="lucideUsers" class="fs-1 mb-3 opacity-50"></ng-icon>
<button class="btn btn-primary" (click)="openCreateModal.emit()"> <h5 class="mb-2">Aucun utilisateur trouvé</h5>
Créer le premier utilisateur <p class="mb-3">Aucun utilisateur ne correspond à vos critères de recherche.</p>
</button> @if (canCreateUsers) {
<button class="btn btn-primary" (click)="openCreateModal.emit()">
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Créer le premier utilisateur
</button>
}
</div>
</td> </td>
</tr> </tr>
} }
@ -221,6 +286,7 @@
</div> </div>
<!-- Pagination --> <!-- Pagination -->
@if (totalPages > 1) {
<div class="d-flex justify-content-between align-items-center mt-3"> <div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted"> <div class="text-muted">
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} utilisateurs Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} utilisateurs
@ -237,7 +303,40 @@
/> />
</nav> </nav>
</div> </div>
}
<!-- Résumé des résultats -->
@if (displayedUsers.length > 0) {
<div class="mt-3 pt-3 border-top">
<div class="row text-center">
<div class="col">
<small class="text-muted">
<strong>Total :</strong> {{ allUsers.length }} utilisateurs
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Actifs :</strong> {{ getEnabledUsersCount() }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Admins :</strong> {{ getUsersCountByRole(UserRole.DCB_ADMIN) }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Support :</strong> {{ getUsersCountByRole(UserRole.DCB_SUPPORT) }}
</small>
</div>
<div class="col">
<small class="text-muted">
<strong>Partenaires :</strong> {{ getUsersCountByRole(UserRole.DCB_PARTNER) }}
</small>
</div>
</div>
</div>
}
} }
</div> </div>
</app-ui-card> </app-ui-card>

View File

@ -1,10 +1,12 @@
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; // src/app/modules/users/list/list.ts
import { Component, inject, OnInit, Output, EventEmitter, ChangeDetectorRef, Input, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { UsersService } from '../services/users.service'; import { Subject, takeUntil } from 'rxjs';
import { UserResponse } from '../models/user'; import { HubUsersService, HubUserResponse, UserRole } from '../services/users.service';
import { RoleManagementService } from '@core/services/role-management.service';
import { UiCard } from '@app/components/ui-card'; import { UiCard } from '@app/components/ui-card';
@Component({ @Component({
@ -19,21 +21,26 @@ import { UiCard } from '@app/components/ui-card';
], ],
templateUrl: './list.html', templateUrl: './list.html',
}) })
export class UsersList implements OnInit { export class UsersList implements OnInit, OnDestroy {
private usersService = inject(UsersService); private usersService = inject(HubUsersService);
private roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
readonly UserRole = UserRole;
@Input() canCreateUsers: boolean = false;
@Input() canDeleteUsers: boolean = false;
@Output() userSelected = new EventEmitter<string>(); @Output() userSelected = new EventEmitter<string>();
@Output() openCreateModal = new EventEmitter<void>(); @Output() openCreateModal = new EventEmitter<void>();
@Output() openResetPasswordModal = new EventEmitter<string>(); @Output() openResetPasswordModal = new EventEmitter<string>();
@Output() openDeleteUserModal = new EventEmitter<string>(); @Output() openDeleteUserModal = new EventEmitter<string>();
// Données // Données
allUsers: UserResponse[] = []; allUsers: HubUserResponse[] = [];
filteredUsers: UserResponse[] = []; filteredUsers: HubUserResponse[] = [];
displayedUsers: UserResponse[] = []; displayedUsers: HubUserResponse[] = [];
// États // États
loading = false; loading = false;
@ -43,6 +50,7 @@ export class UsersList implements OnInit {
searchTerm = ''; searchTerm = '';
statusFilter: 'all' | 'enabled' | 'disabled' = 'all'; statusFilter: 'all' | 'enabled' | 'disabled' = 'all';
emailVerifiedFilter: 'all' | 'verified' | 'not-verified' = 'all'; emailVerifiedFilter: 'all' | 'verified' | 'not-verified' = 'all';
roleFilter: UserRole | 'all' = 'all';
// Pagination // Pagination
currentPage = 1; currentPage = 1;
@ -51,31 +59,46 @@ export class UsersList implements OnInit {
totalPages = 0; totalPages = 0;
// Tri // Tri
sortField: keyof UserResponse = 'username'; sortField: keyof HubUserResponse = 'username';
sortDirection: 'asc' | 'desc' = 'asc'; sortDirection: 'asc' | 'desc' = 'asc';
// Rôles disponibles pour le filtre
availableRoles = [
{ value: 'all' as const, label: 'Tous les rôles' },
{ value: UserRole.DCB_ADMIN, label: 'Administrateurs' },
{ value: UserRole.DCB_SUPPORT, label: 'Support' },
{ value: UserRole.DCB_PARTNER, label: 'Partenaires' }
];
ngOnInit() { ngOnInit() {
this.loadUsers(); this.loadUsers();
} }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
loadUsers() { loadUsers() {
this.loading = true; this.loading = true;
this.error = ''; this.error = '';
this.usersService.findAllUsers().subscribe({ this.usersService.findAllUsers()
next: (response) => { .pipe(takeUntil(this.destroy$))
this.allUsers = response.data; .subscribe({
this.applyFiltersAndPagination(); next: (response) => {
this.loading = false; this.allUsers = response.users;
this.cdRef.detectChanges(); this.applyFiltersAndPagination();
}, this.loading = false;
error: (error) => { this.cdRef.detectChanges();
this.error = 'Erreur lors du chargement des utilisateurs'; },
this.loading = false; error: (error) => {
this.cdRef.detectChanges(); this.error = 'Erreur lors du chargement des utilisateurs';
console.error('Error loading users:', error); this.loading = false;
} this.cdRef.detectChanges();
}); console.error('Error loading users:', error);
}
});
} }
// Recherche et filtres // Recherche et filtres
@ -88,6 +111,7 @@ export class UsersList implements OnInit {
this.searchTerm = ''; this.searchTerm = '';
this.statusFilter = 'all'; this.statusFilter = 'all';
this.emailVerifiedFilter = 'all'; this.emailVerifiedFilter = 'all';
this.roleFilter = 'all';
this.currentPage = 1; this.currentPage = 1;
this.applyFiltersAndPagination(); this.applyFiltersAndPagination();
} }
@ -112,7 +136,10 @@ export class UsersList implements OnInit {
(this.emailVerifiedFilter === 'verified' && user.emailVerified) || (this.emailVerifiedFilter === 'verified' && user.emailVerified) ||
(this.emailVerifiedFilter === 'not-verified' && !user.emailVerified); (this.emailVerifiedFilter === 'not-verified' && !user.emailVerified);
return matchesSearch && matchesStatus && matchesEmailVerified; // Filtre par rôle
const matchesRole = this.roleFilter === 'all' || user.role === this.roleFilter;
return matchesSearch && matchesStatus && matchesEmailVerified && matchesRole;
}); });
// Appliquer le tri // Appliquer le tri
@ -145,7 +172,7 @@ export class UsersList implements OnInit {
} }
// Tri // Tri
sort(field: keyof UserResponse) { sort(field: keyof HubUserResponse) {
if (this.sortField === field) { if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else { } else {
@ -155,7 +182,7 @@ export class UsersList implements OnInit {
this.applyFiltersAndPagination(); this.applyFiltersAndPagination();
} }
getSortIcon(field: keyof UserResponse): string { getSortIcon(field: keyof HubUserResponse): string {
if (this.sortField !== field) return 'lucideArrowUpDown'; if (this.sortField !== field) return 'lucideArrowUpDown';
return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown'; return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown';
} }
@ -180,79 +207,125 @@ export class UsersList implements OnInit {
} }
// Méthode pour réinitialiser le mot de passe // Méthode pour réinitialiser le mot de passe
resetPassword(user: UserResponse) { resetPassword(user: HubUserResponse) {
this.openResetPasswordModal.emit(user.id); this.openResetPasswordModal.emit(user.id);
} }
// Méthode pour ouvrir le modal de suppression // Méthode pour ouvrir le modal de suppression
deleteUser(user: UserResponse) { deleteUser(user: HubUserResponse) {
this.openDeleteUserModal.emit(user.id); if (this.canDeleteUsers) {
this.openDeleteUserModal.emit(user.id);
}
} }
enableUser(user: HubUserResponse) {
enableUser(user: UserResponse) { this.usersService.enableUser(user.id)
this.usersService.enableUser(user.id).subscribe({ .pipe(takeUntil(this.destroy$))
next: () => { .subscribe({
user.enabled = true; next: () => {
this.applyFiltersAndPagination(); user.enabled = true;
this.cdRef.detectChanges(); this.applyFiltersAndPagination();
}, this.cdRef.detectChanges();
error: (error) => { },
console.error('Error enabling user:', error); error: (error) => {
alert('Erreur lors de l\'activation de l\'utilisateur'); console.error('Error enabling user:', error);
} this.error = 'Erreur lors de l\'activation de l\'utilisateur';
}); this.cdRef.detectChanges();
}
});
} }
disableUser(user: UserResponse) { disableUser(user: HubUserResponse) {
this.usersService.disableUser(user.id).subscribe({ this.usersService.disableUser(user.id)
next: () => { .pipe(takeUntil(this.destroy$))
user.enabled = false; .subscribe({
this.applyFiltersAndPagination(); next: () => {
this.cdRef.detectChanges(); user.enabled = false;
}, this.applyFiltersAndPagination();
error: (error) => { this.cdRef.detectChanges();
console.error('Error disabling user:', error); },
alert('Erreur lors de la désactivation de l\'utilisateur'); error: (error) =>{
} console.error('Error disabling user:', error);
}); this.error = 'Erreur lors de la désactivation de l\'utilisateur';
this.cdRef.detectChanges();
}
});
} }
// Utilitaires d'affichage // Utilitaires d'affichage
getStatusBadgeClass(user: UserResponse): string { getStatusBadgeClass(user: HubUserResponse): string {
if (!user.enabled) return 'badge bg-danger'; if (!user.enabled) return 'badge bg-danger';
if (!user.emailVerified) return 'badge bg-warning'; if (!user.emailVerified) return 'badge bg-warning';
return 'badge bg-success'; return 'badge bg-success';
} }
getStatusText(user: UserResponse): string { getStatusText(user: HubUserResponse): string {
if (!user.enabled) return 'Désactivé'; if (!user.enabled) return 'Désactivé';
if (!user.emailVerified) return 'Email non vérifié'; if (!user.emailVerified) return 'Email non vérifié';
return 'Actif'; return 'Actif';
} }
getRoleBadgeClass(role: string): string { getRoleBadgeClass(role: UserRole): string {
switch (role) { return this.roleService.getRoleBadgeClass(role);
case 'admin': return 'bg-danger'; }
case 'merchant': return 'bg-success';
case 'support': return 'bg-info'; getRoleLabel(role: UserRole): string {
case 'user': return 'bg-secondary'; return this.roleService.getRoleLabel(role);
default: return 'bg-secondary'; }
}
getRoleIcon(role: UserRole): string {
return this.roleService.getRoleIcon(role);
} }
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('fr-FR'); if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} }
getUserInitials(user: UserResponse): string { getUserInitials(user: HubUserResponse): string {
return (user.firstName?.charAt(0) || '') + (user.lastName?.charAt(0) || '') || 'U'; return (user.firstName?.charAt(0) || '') + (user.lastName?.charAt(0) || '') || 'U';
} }
getUserDisplayName(user: UserResponse): string { getUserDisplayName(user: HubUserResponse): string {
if (user.firstName && user.lastName) { if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`; return `${user.firstName} ${user.lastName}`;
} }
return user.username; return user.username;
} }
// Statistiques
/**
* Récupère le nombre d'utilisateurs par rôle (méthode publique pour le template)
*/
getUsersCountByRole(role: UserRole): number {
return this.allUsers.filter(user => user.role === role).length;
}
getEnabledUsersCount(): number {
return this.allUsers.filter(user => user.enabled).length;
}
getDisabledUsersCount(): number {
return this.allUsers.filter(user => !user.enabled).length;
}
// Vérification des permissions pour les actions
canManageUser(user: HubUserResponse): boolean {
// Implémentez votre logique de permission ici
// Par exemple, empêcher un utilisateur de se modifier lui-même
return true;
}
// Recherche rapide par rôle
filterByRole(role: UserRole | 'all') {
this.roleFilter = role;
this.currentPage = 1;
this.applyFiltersAndPagination();
}
} }

View File

@ -0,0 +1,54 @@
// src/app/modules/users/models/user.model.ts
export enum UserRole {
DCB_ADMIN = 'DCB_ADMIN',
DCB_SUPPORT = 'DCB_SUPPORT',
DCB_PARTNER = 'DCB_PARTNER'
}
export interface HubUserResponse {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
enabled: boolean;
emailVerified: boolean;
createdBy: string;
createdByUsername: string;
createdTimestamp: number;
lastLogin?: number;
userType: 'HUB';
}
export interface CreateHubUserDto {
username: string;
email: string;
firstName: string;
lastName: string;
password: string;
role: UserRole;
enabled?: boolean;
emailVerified?: boolean;
}
export interface UpdateHubUserDto {
firstName?: string;
lastName?: string;
email?: string;
enabled?: boolean;
}
export interface ResetPasswordDto {
userId: string;
newPassword: string;
temporary?: boolean;
}
export interface PaginatedUserResponse {
users: HubUserResponse[];
total: number;
page: number;
limit: number;
totalPages: number;
}

View File

@ -0,0 +1,311 @@
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[] = [];
createdTimestamp?: number;
constructor(partial?: Partial<User>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UserCredentials {
type: string = 'password';
value: string = '';
temporary: boolean = false;
constructor(type?: string, value?: string, temporary?: boolean) {
if (type) this.type = type;
if (value) this.value = value;
if (temporary !== undefined) this.temporary = temporary;
}
}
export class CreateUserDto {
@IsString()
@MinLength(3)
username: string = '';
@IsEmail()
email: string = '';
@IsOptional()
@IsString()
firstName: string = '';
@IsOptional()
@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[] = [];
constructor(partial?: Partial<CreateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
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[];
constructor(partial?: Partial<UpdateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UserQueryDto {
@IsOptional()
page: number = 1;
@IsOptional()
limit: number = 10;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsBoolean()
emailVerified?: boolean;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
constructor(partial?: Partial<UserQueryDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class ResetPasswordDto {
@IsString()
userId: string = '';
@IsString()
@MinLength(8)
newPassword: string = '';
@IsOptional()
@IsBoolean()
temporary: boolean = false;
constructor(partial?: Partial<ResetPasswordDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UserResponse {
id: string = '';
username: string = '';
email: string = '';
firstName: string = '';
lastName: string = '';
enabled: boolean = true;
emailVerified: boolean = false;
attributes: Record<string, any> = {};
clientRoles: string[] = [];
createdTimestamp: number = Date.now();
constructor(user?: any) {
if (user) {
this.id = user.id || '';
this.username = user.username || '';
this.email = user.email || '';
this.firstName = user.firstName || '';
this.lastName = user.lastName || '';
this.enabled = user.enabled ?? true;
this.emailVerified = user.emailVerified ?? false;
this.attributes = user.attributes || {};
this.clientRoles = user.clientRoles || [];
this.createdTimestamp = user.createdTimestamp || Date.now();
}
}
}
export class PaginatedUserResponse {
users: UserResponse[] = [];
total: number = 0;
page: number = 1;
limit: number = 10;
totalPages: number = 0;
constructor(users: UserResponse[] = [], total: number = 0, page: number = 1, limit: number = 10) {
this.users = users;
this.total = total;
this.page = page;
this.limit = limit;
this.totalPages = Math.ceil(total / limit) || 0;
}
}
export class AssignRolesDto {
@IsArray()
@IsString({ each: true })
roles: string[] = [];
constructor(partial?: Partial<AssignRolesDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class LoginDto {
@IsString()
username: string = '';
@IsString()
password: string = '';
constructor(partial?: Partial<LoginDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class TokenResponse {
access_token: string = '';
refresh_token?: string = '';
expires_in: number = 0;
token_type: string = '';
scope?: string = '';
constructor(partial?: Partial<TokenResponse>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class ApiResponse<T> {
data: T | null = null;
message: string = '';
status: string = '';
constructor(partial?: Partial<ApiResponse<T>>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UserRoleMapping {
id: string = '';
name: string = '';
description?: string = '';
composite?: boolean = false;
clientRole?: boolean = false;
containerId?: string = '';
constructor(partial?: Partial<UserRoleMapping>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UserSession {
id: string = '';
username: string = '';
userId: string = '';
ipAddress: string = '';
start: number = 0;
lastAccess: number = 0;
clients: Record<string, string> = {};
constructor(partial?: Partial<UserSession>) {
if (partial) {
Object.assign(this, partial);
}
}
}
// Types pour les rôles client
export type ClientRole =
| 'dcb-admin'
| 'dcb-partner'
| 'dcb-support'
| 'dcb-partner-admin'
| 'dcb-partner-manager'
| 'dcb-partner-support'
| 'dcb-partner-user';

View File

@ -1,180 +0,0 @@
export class User {
id?: string;
username: string;
email: string;
firstName: string;
lastName: string;
enabled?: boolean;
emailVerified?: boolean;
attributes?: { [key: string]: any };
createdTimestamp?: number;
constructor(partial?: Partial<User>) {
// Initialisation des propriétés obligatoires
this.username = '';
this.email = '';
this.firstName = '';
this.lastName = '';
if (partial) {
Object.assign(this, partial);
}
// Valeurs par défaut
this.enabled = this.enabled ?? true;
this.emailVerified = this.emailVerified ?? false;
}
}
export class UserResponse {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
enabled: boolean;
emailVerified: boolean;
attributes?: { [key: string]: any };
clientRoles: string[];
createdTimestamp: number;
constructor(user: any) {
// Initialisation avec valeurs par défaut
this.id = user?.id || '';
this.username = user?.username || '';
this.email = user?.email || '';
this.firstName = user?.firstName || '';
this.lastName = user?.lastName || '';
this.enabled = user?.enabled ?? true;
this.emailVerified = user?.emailVerified ?? false;
this.attributes = user?.attributes || {};
this.clientRoles = user?.clientRoles || [];
this.createdTimestamp = user?.createdTimestamp || Date.now();
}
}
export class CreateUserDto {
username: string = '';
email: string = '';
firstName: string = '';
lastName: string = '';
password: string = '';
enabled: boolean = true;
emailVerified: boolean = false;
clientRoles: string[] = [];
constructor(partial?: Partial<CreateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class UpdateUserDto {
username?: string;
email?: string;
firstName?: string;
lastName?: string;
enabled?: boolean;
emailVerified?: boolean;
attributes?: { [key: string]: any };
constructor(partial?: Partial<UpdateUserDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class ResetPasswordDto {
userId: string;
newPassword: string;
temporary?: boolean;
constructor(partial?: Partial<ResetPasswordDto>) {
// Initialisation des propriétés obligatoires
this.userId = '';
this.newPassword = '';
if (partial) {
Object.assign(this, partial);
}
// Valeur par défaut
this.temporary = this.temporary ?? false;
}
}
export class UserQueryDto {
page?: number;
limit?: number;
search?: string;
enabled?: boolean;
emailVerified?: boolean; // 🔥 Ajouter ce champ
email?: string;
username?: string;
firstName?: string;
lastName?: string;
constructor(partial?: Partial<UserQueryDto>) {
if (partial) {
Object.assign(this, partial);
}
}
}
export class PaginatedUserResponse {
data: UserResponse[];
total: number;
page: number;
limit: number;
totalPages: number;
constructor(data: UserResponse[], total: number, page: number, limit: number) {
this.data = data || [];
this.total = total || 0;
this.page = page || 1;
this.limit = limit || 10;
this.totalPages = Math.ceil(this.total / this.limit);
}
}
export class UserCredentials {
type: string;
value: string;
temporary: boolean;
constructor(type: string, value: string, temporary: boolean = false) {
this.type = type;
this.value = value;
this.temporary = temporary;
}
}
// Types (restent des interfaces/type alias)
export type ClientRole = 'admin' | 'merchant' | 'support' | 'user';
export interface ApiResponse<T> {
data: T;
message?: string;
status: string;
}
export interface UserRoleMapping {
id: string;
name: string;
description?: string;
composite?: boolean;
clientRole?: boolean;
containerId?: string;
}
export interface UserSession {
id: string;
username: string;
userId: string;
ipAddress: string;
start: number;
lastAccess: number;
clients: { [key: string]: string };
}

View File

@ -14,26 +14,36 @@
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0"> <ol class="breadcrumb mb-0">
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<a href="javascript:void(0)" class="text-decoration-none">Profile Utilisateurs</a> <a href="javascript:void(0)" (click)="back.emit()" class="text-decoration-none cursor-pointer">
Utilisateurs
</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
@if (user) {
{{ getUserDisplayName() }}
} @else {
Profil
}
</li> </li>
</ol> </ol>
</nav> </nav>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<!-- Bouton de réinitialisation de mot de passe -->
@if (user && !isEditing) { @if (user && canEditUsers && !isEditing) {
<button <button
class="btn btn-warning" class="btn btn-warning"
(click)="openResetPasswordModal.emit()" (click)="resetPassword()"
> >
<ng-icon name="lucideKey" class="me-1"></ng-icon> <ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser MDP Réinitialiser MDP
</button> </button>
<!-- Bouton activation/désactivation -->
@if (user.enabled) { @if (user.enabled) {
<button <button
class="btn btn-warning" class="btn btn-outline-warning"
(click)="disableUser()" (click)="disableUser()"
> >
<ng-icon name="lucidePause" class="me-1"></ng-icon> <ng-icon name="lucidePause" class="me-1"></ng-icon>
@ -41,7 +51,7 @@
</button> </button>
} @else { } @else {
<button <button
class="btn btn-success" class="btn btn-outline-success"
(click)="enableUser()" (click)="enableUser()"
> >
<ng-icon name="lucidePlay" class="me-1"></ng-icon> <ng-icon name="lucidePlay" class="me-1"></ng-icon>
@ -49,6 +59,7 @@
</button> </button>
} }
<!-- Bouton modification -->
<button <button
class="btn btn-primary" class="btn btn-primary"
(click)="startEditing()" (click)="startEditing()"
@ -62,18 +73,38 @@
</div> </div>
</div> </div>
<!-- Indicateur de permissions -->
@if (currentUserRole && !canEditUsers) {
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-warning">
<div class="d-flex align-items-center">
<ng-icon name="lucideShield" class="me-2"></ng-icon>
<div>
<strong>Permissions limitées :</strong> Vous ne pouvez que consulter ce profil
</div>
</div>
</div>
</div>
</div>
}
<!-- Messages d'alerte --> <!-- Messages d'alerte -->
@if (error) { @if (error) {
<div class="alert alert-danger"> <div class="alert alert-danger">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon> <div class="d-flex align-items-center">
{{ error }} <ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
<div>{{ error }}</div>
</div>
</div> </div>
} }
@if (success) { @if (success) {
<div class="alert alert-success"> <div class="alert alert-success">
<ng-icon name="lucideCheckCircle" class="me-2"></ng-icon> <div class="d-flex align-items-center">
{{ success }} <ng-icon name="lucideCheckCircle" class="me-2"></ng-icon>
<div>{{ success }}</div>
</div>
</div> </div>
} }
@ -95,12 +126,12 @@
<!-- Carte profil --> <!-- Carte profil -->
<div class="card"> <div class="card">
<div class="card-header bg-light"> <div class="card-header bg-light">
<h5 class="card-title mb-0">Profil Keycloak</h5> <h5 class="card-title mb-0">Profil Utilisateur Hub</h5>
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
<!-- Avatar --> <!-- Avatar -->
<div class="avatar-lg mx-auto mb-3"> <div class="avatar-lg mx-auto mb-3">
<div class="avatar-title bg-primary rounded-circle text-white fs-24"> <div class="avatar-title bg-primary bg-opacity-10 rounded-circle text-primary fs-24">
{{ getUserInitials() }} {{ getUserInitials() }}
</div> </div>
</div> </div>
@ -118,70 +149,115 @@
<div class="d-flex align-items-center mb-2"> <div class="d-flex align-items-center mb-2">
<ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon> <ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon>
<small>{{ user.email }}</small> <small>{{ user.email }}</small>
@if (!user.emailVerified) {
<ng-icon name="lucideAlertTriangle" class="ms-1 text-warning" size="14" title="Email non vérifié"></ng-icon>
}
</div> </div>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon> <ng-icon name="lucideCalendar" class="me-2 text-muted"></ng-icon>
<small>Créé le {{ formatTimestamp(user.createdTimestamp) }}</small> <small>Créé le {{ formatTimestamp(user.createdTimestamp) }}</small>
</div> </div>
@if (user.lastLogin) {
<div class="d-flex align-items-center mt-2">
<ng-icon name="lucideLogIn" class="me-2 text-muted"></ng-icon>
<small>Dernière connexion : {{ formatTimestamp(user.lastLogin) }}</small>
</div>
}
</div> </div>
</div> </div>
</div> </div>
<!-- Carte rôles client --> <!-- Carte rôle utilisateur -->
<div class="card mt-3"> <div class="card mt-3">
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Rôles Client</h5> <h5 class="card-title mb-0">Rôle Utilisateur</h5>
@if (!isEditing) { @if (canManageRoles && !isEditing) {
<button <span class="badge bg-info">Modifiable</span>
class="btn btn-outline-primary btn-sm"
(click)="updateUserRoles()"
[disabled]="updatingRoles"
>
@if (updatingRoles) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
}
Mettre à jour
</button>
} }
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-2"> <!-- Rôle actuel -->
@for (role of availableRoles; track role) { <div class="text-center mb-3">
<div class="col-6"> <span class="badge d-flex align-items-center justify-content-center" [ngClass]="getRoleBadgeClass(user.role)">
<div class="form-check"> <ng-icon [name]="getRoleIcon(user.role)" class="me-2"></ng-icon>
<input {{ getRoleLabel(user.role) }}
class="form-check-input" </span>
type="checkbox" <small class="text-muted d-block mt-1">
[id]="'role-' + role" {{ getRoleDescription(user.role) }}
[checked]="isRoleSelected(role)" </small>
(change)="toggleRole(role)"
[disabled]="isEditing"
>
<label class="form-check-label" [for]="'role-' + role">
<span class="badge" [ngClass]="getRoleBadgeClass(role)">
{{ role }}
</span>
</label>
</div>
</div>
}
</div> </div>
<!-- Rôles actuels --> <!-- Changement de rôle -->
@if (user.clientRoles.length > 0) { @if (canManageRoles && !isEditing) {
<div class="mt-3"> <div class="mt-3">
<h6>Rôles assignés :</h6> <label class="form-label fw-semibold">Changer le rôle</label>
@for (role of user.clientRoles; track role) { <select
<span class="badge me-1 mb-1" [ngClass]="getRoleBadgeClass(role)"> class="form-select"
{{ role }} [value]="user.role"
</span> (change)="updateUserRole($any($event.target).value)"
} [disabled]="updatingRoles"
>
<option value="" disabled>Sélectionnez un nouveau rôle</option>
@for (role of availableRoles; track role.value) {
<option
[value]="role.value"
[disabled]="!canAssignRole(role.value) || role.value === user.role"
>
{{ role.label }}
@if (!canAssignRole(role.value)) {
(Non autorisé)
} @else if (role.value === user.role) {
(Actuel)
}
</option>
}
</select>
<div class="form-text">
@if (updatingRoles) {
<div class="spinner-border spinner-border-sm me-1" role="status">
<span class="visually-hidden">Mise à jour...</span>
</div>
Mise à jour en cours...
} @else {
Sélectionnez un nouveau rôle pour cet utilisateur
}
</div>
</div>
} @else if (!canManageRoles) {
<div class="alert alert-info mt-3">
<small>
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Vous n'avez pas la permission de modifier les rôles
</small>
</div> </div>
} }
</div> </div>
</div> </div>
<!-- Informations de création -->
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Informations de Création</h6>
</div>
<div class="card-body">
<div class="row g-2 small">
<div class="col-12">
<strong>Créé par :</strong>
<div class="text-muted">{{ user.createdByUsername || 'Système' }}</div>
</div>
<div class="col-12">
<strong>Date de création :</strong>
<div class="text-muted">{{ formatTimestamp(user.createdTimestamp) }}</div>
</div>
<div class="col-12">
<strong>Type d'utilisateur :</strong>
<div class="text-muted">
<span class="badge bg-secondary">{{ user.userType }}</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Colonne de droite - Détails et édition --> <!-- Colonne de droite - Détails et édition -->
@ -190,8 +266,10 @@
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"> <h5 class="card-title mb-0">
@if (isEditing) { @if (isEditing) {
<ng-icon name="lucideEdit" class="me-2"></ng-icon>
Modification du Profil Modification du Profil
} @else { } @else {
<ng-icon name="lucideUser" class="me-2"></ng-icon>
Détails du Compte Détails du Compte
} }
</h5> </h5>
@ -200,10 +278,11 @@
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button <button
type="button" type="button"
class="btn btn-secondary btn-sm" class="btn btn-outline-secondary btn-sm"
(click)="cancelEditing()" (click)="cancelEditing()"
[disabled]="saving" [disabled]="saving"
> >
<ng-icon name="lucideX" class="me-1"></ng-icon>
Annuler Annuler
</button> </button>
<button <button
@ -217,6 +296,7 @@
<span class="visually-hidden">Chargement...</span> <span class="visually-hidden">Chargement...</span>
</div> </div>
} }
<ng-icon name="lucideCheck" class="me-1"></ng-icon>
Enregistrer Enregistrer
</button> </button>
</div> </div>
@ -234,10 +314,11 @@
class="form-control" class="form-control"
[(ngModel)]="editedUser.firstName" [(ngModel)]="editedUser.firstName"
placeholder="Entrez le prénom" placeholder="Entrez le prénom"
[disabled]="saving"
> >
} @else { } @else {
<div class="form-control-plaintext"> <div class="form-control-plaintext">
{{ user.firstName || 'Non défini' }} {{ user.firstName || 'Non renseigné' }}
</div> </div>
} }
</div> </div>
@ -251,10 +332,11 @@
class="form-control" class="form-control"
[(ngModel)]="editedUser.lastName" [(ngModel)]="editedUser.lastName"
placeholder="Entrez le nom" placeholder="Entrez le nom"
[disabled]="saving"
> >
} @else { } @else {
<div class="form-control-plaintext"> <div class="form-control-plaintext">
{{ user.lastName || 'Non défini' }} {{ user.lastName || 'Non renseigné' }}
</div> </div>
} }
</div> </div>
@ -262,18 +344,12 @@
<!-- Nom d'utilisateur --> <!-- Nom d'utilisateur -->
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Nom d'utilisateur</label> <label class="form-label">Nom d'utilisateur</label>
@if (isEditing) { <div class="form-control-plaintext font-monospace">
<input {{ user.username }}
type="text" </div>
class="form-control" <div class="form-text">
[(ngModel)]="editedUser.username" Le nom d'utilisateur ne peut pas être modifié
placeholder="Nom d'utilisateur" </div>
>
} @else {
<div class="form-control-plaintext">
{{ user.username }}
</div>
}
</div> </div>
<!-- Email --> <!-- Email -->
@ -285,10 +361,14 @@
class="form-control" class="form-control"
[(ngModel)]="editedUser.email" [(ngModel)]="editedUser.email"
placeholder="email@exemple.com" placeholder="email@exemple.com"
[disabled]="saving"
> >
} @else { } @else {
<div class="form-control-plaintext"> <div class="form-control-plaintext">
{{ user.email }} {{ user.email }}
@if (!user.emailVerified) {
<span class="badge bg-warning ms-2">Non vérifié</span>
}
</div> </div>
} }
</div> </div>
@ -302,25 +382,23 @@
type="checkbox" type="checkbox"
id="enabledSwitch" id="enabledSwitch"
[(ngModel)]="editedUser.enabled" [(ngModel)]="editedUser.enabled"
[disabled]="saving"
> >
<label class="form-check-label" for="enabledSwitch"> <label class="form-check-label" for="enabledSwitch">
Compte activé Compte activé
</label> </label>
</div> </div>
<div class="form-text">
L'utilisateur peut se connecter si activé
</div>
</div> </div>
} @else {
<!-- Email vérifié -->
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <label class="form-label">Statut du compte</label>
<input <div class="form-control-plaintext">
class="form-check-input" <span [class]="getStatusBadgeClass()">
type="checkbox" {{ getStatusText() }}
id="emailVerifiedSwitch" </span>
[(ngModel)]="editedUser.emailVerified"
>
<label class="form-check-label" for="emailVerifiedSwitch">
Email vérifié
</label>
</div> </div>
</div> </div>
} }
@ -329,11 +407,14 @@
@if (!isEditing) { @if (!isEditing) {
<div class="col-12"> <div class="col-12">
<hr> <hr>
<h6>Informations Système</h6> <h6 class="mb-3">
<ng-icon name="lucideSettings" class="me-2"></ng-icon>
Informations Système
</h6>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">ID Utilisateur</label> <label class="form-label">ID Utilisateur</label>
<div class="form-control-plaintext font-monospace small"> <div class="form-control-plaintext font-monospace small text-truncate">
{{ user.id }} {{ user.id }}
</div> </div>
</div> </div>
@ -343,12 +424,74 @@
{{ formatTimestamp(user.createdTimestamp) }} {{ formatTimestamp(user.createdTimestamp) }}
</div> </div>
</div> </div>
<div class="col-md-6">
<label class="form-label">Créé par</label>
<div class="form-control-plaintext">
{{ user.createdByUsername || 'Système' }}
</div>
</div>
<div class="col-md-6">
<label class="form-label">Type d'utilisateur</label>
<div class="form-control-plaintext">
<span class="badge bg-secondary">{{ user.userType }}</span>
</div>
</div>
</div> </div>
</div> </div>
} }
</div> </div>
</div> </div>
</div> </div>
<!-- Actions supplémentaires -->
@if (!isEditing && canEditUsers) {
<div class="card mt-3">
<div class="card-header bg-light">
<h6 class="card-title mb-0">Actions de Gestion</h6>
</div>
<div class="card-body">
<div class="row g-2">
<div class="col-md-4">
<button
class="btn btn-outline-warning w-100"
(click)="resetPassword()"
>
<ng-icon name="lucideKey" class="me-1"></ng-icon>
Réinitialiser MDP
</button>
</div>
<div class="col-md-4">
@if (user.enabled) {
<button
class="btn btn-outline-secondary w-100"
(click)="disableUser()"
>
<ng-icon name="lucideUserX" class="me-1"></ng-icon>
Désactiver
</button>
} @else {
<button
class="btn btn-outline-success w-100"
(click)="enableUser()"
>
<ng-icon name="lucideUserCheck" class="me-1"></ng-icon>
Activer
</button>
}
</div>
<div class="col-md-4">
<button
class="btn btn-outline-primary w-100"
(click)="startEditing()"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier
</button>
</div>
</div>
</div>
</div>
}
</div> </div>
} }
</div> </div>

View File

@ -1,2 +1,2 @@
import { UsersProfile } from './profile'; import { UserProfile } from './profile';
describe('UsersProfile', () => {}); describe('UserProfile', () => {});

View File

@ -1,10 +1,13 @@
import { Component, inject, OnInit, Input, Output, EventEmitter, ChangeDetectorRef } from '@angular/core'; // src/app/modules/users/profile/profile.ts
import { Component, inject, OnInit, Input, Output, EventEmitter, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap';
import { UsersService } from '../services/users.service'; import { Subject, takeUntil } from 'rxjs';
import { UserResponse, UpdateUserDto, ClientRole } from '../models/user'; import { HubUsersService, HubUserResponse, UserRole, UpdateHubUserDto } from '../services/users.service';
import { RoleManagementService } from '@core/services/role-management.service';
import { AuthService } from '@core/services/auth.service';
@Component({ @Component({
selector: 'app-user-profile', selector: 'app-user-profile',
@ -21,66 +24,126 @@ import { UserResponse, UpdateUserDto, ClientRole } from '../models/user';
} }
`] `]
}) })
export class UserProfile implements OnInit { export class UserProfile implements OnInit, OnDestroy {
private usersService = inject(UsersService); private usersService = inject(HubUsersService);
private roleService = inject(RoleManagementService);
private authService = inject(AuthService);
private cdRef = inject(ChangeDetectorRef); private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
@Input() userId!: string; @Input() userId!: string;
@Output() openResetPasswordModal = new EventEmitter<void>(); @Output() back = new EventEmitter<void>();
@Output() openResetPasswordModal = new EventEmitter<string>();
user: UserResponse | null = null; user: HubUserResponse | null = null;
loading = false; loading = false;
saving = false; saving = false;
error = ''; error = '';
success = ''; success = '';
// Gestion des permissions
currentUserRole: UserRole | null = null;
canEditUsers = false;
canManageRoles = false;
canDeleteUsers = false;
// Édition // Édition
isEditing = false; isEditing = false;
editedUser: UpdateUserDto = {}; editedUser: UpdateHubUserDto = {};
// Gestion des rôles // Gestion des rôles
availableRoles: ClientRole[] = ['admin', 'merchant', 'support', 'user']; availableRoles: { value: UserRole; label: string; description: string }[] = [];
selectedRoles: ClientRole[] = [];
updatingRoles = false; updatingRoles = false;
ngOnInit() { ngOnInit() {
if (this.userId) { if (this.userId) {
this.initializeUserPermissions();
this.loadAvailableRoles();
this.loadUserProfile(); this.loadUserProfile();
} }
} }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
/**
* Initialise les permissions de l'utilisateur courant
*/
private initializeUserPermissions(): void {
this.authService.loadUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (profile) => {
this.currentUserRole = profile?.roles?.[0] as UserRole || null;
if (this.currentUserRole) {
this.canEditUsers = this.roleService.canEditUsers(this.currentUserRole);
this.canManageRoles = this.roleService.canManageRoles(this.currentUserRole);
this.canDeleteUsers = this.roleService.canDeleteUsers(this.currentUserRole);
}
},
error: (error) => {
console.error('Error loading user permissions:', error);
}
});
}
/**
* Charge les rôles disponibles
*/
private loadAvailableRoles(): void {
this.roleService.getAvailableRolesSimple()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (roles) => {
this.availableRoles = roles;
},
error: (error) => {
console.error('Error loading available roles:', error);
// Fallback
this.availableRoles = [
{ value: UserRole.DCB_ADMIN, label: 'DCB Admin', description: 'Administrateur système' },
{ value: UserRole.DCB_SUPPORT, label: 'DCB Support', description: 'Support technique' },
{ value: UserRole.DCB_PARTNER, label: 'DCB Partner', description: 'Partenaire commercial' }
];
}
});
}
loadUserProfile() { loadUserProfile() {
this.loading = true; this.loading = true;
this.error = ''; this.error = '';
this.usersService.getUserById(this.userId).subscribe({ this.usersService.getUserById(this.userId)
next: (user) => { .pipe(takeUntil(this.destroy$))
this.user = user; .subscribe({
this.selectedRoles = user.clientRoles next: (user) => {
.filter((role): role is ClientRole => this.user = user;
this.availableRoles.includes(role as ClientRole) this.loading = false;
); this.cdRef.detectChanges();
this.loading = false; },
this.cdRef.detectChanges(); error: (error) => {
}, this.error = 'Erreur lors du chargement du profil utilisateur';
error: (error) => { this.loading = false;
this.error = 'Erreur lors du chargement du profil utilisateur'; this.cdRef.detectChanges();
this.loading = false; console.error('Error loading user profile:', error);
this.cdRef.detectChanges(); }
console.error('Error loading user profile:', error); });
}
});
} }
startEditing() { startEditing() {
if (!this.canEditUsers) {
this.error = 'Vous n\'avez pas la permission de modifier les utilisateurs';
return;
}
this.isEditing = true; this.isEditing = true;
this.editedUser = { this.editedUser = {
firstName: this.user?.firstName, firstName: this.user?.firstName,
lastName: this.user?.lastName, lastName: this.user?.lastName,
email: this.user?.email, email: this.user?.email,
username: this.user?.username, enabled: this.user?.enabled
enabled: this.user?.enabled,
emailVerified: this.user?.emailVerified
}; };
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
@ -94,100 +157,121 @@ export class UserProfile implements OnInit {
} }
saveProfile() { saveProfile() {
if (!this.user) return; if (!this.user || !this.canEditUsers) return;
this.saving = true; this.saving = true;
this.error = ''; this.error = '';
this.success = ''; this.success = '';
this.usersService.updateUser(this.user.id, this.editedUser).subscribe({ this.usersService.updateUser(this.user.id, this.editedUser)
next: (updatedUser) => { .pipe(takeUntil(this.destroy$))
this.user = updatedUser; .subscribe({
this.isEditing = false; next: (updatedUser) => {
this.saving = false; this.user = updatedUser;
this.success = 'Profil mis à jour avec succès'; this.isEditing = false;
this.editedUser = {}; this.saving = false;
this.cdRef.detectChanges(); this.success = 'Profil mis à jour avec succès';
}, this.editedUser = {};
error: (error) => { this.cdRef.detectChanges();
this.error = 'Erreur lors de la mise à jour du profil'; },
this.saving = false; error: (error) => {
this.cdRef.detectChanges(); this.error = this.getErrorMessage(error);
console.error('Error updating user:', error); this.saving = false;
} this.cdRef.detectChanges();
}); }
});
} }
// Gestion des rôles // Gestion des rôles
toggleRole(role: ClientRole) { updateUserRole(newRole: UserRole) {
const index = this.selectedRoles.indexOf(role); if (!this.user || !this.canManageRoles) return;
if (index > -1) {
this.selectedRoles.splice(index, 1); // Vérifier que l'utilisateur peut attribuer ce rôle
} else { if (!this.roleService.canAssignRole(this.currentUserRole, newRole)) {
this.selectedRoles.push(role); this.error = 'Vous n\'avez pas la permission d\'attribuer ce rôle';
return;
} }
this.cdRef.detectChanges();
}
isRoleSelected(role: ClientRole): boolean {
return this.selectedRoles.includes(role);
}
updateUserRoles() {
if (!this.user) return;
this.updatingRoles = true; this.updatingRoles = true;
this.usersService.assignClientRoles(this.user.id, this.selectedRoles).subscribe({ this.error = '';
next: () => { this.success = '';
this.updatingRoles = false;
this.success = 'Rôles mis à jour avec succès'; this.usersService.updateUserRole(this.user.id, newRole)
if (this.user) { .pipe(takeUntil(this.destroy$))
this.user.clientRoles = [...this.selectedRoles]; .subscribe({
next: (updatedUser) => {
this.user = updatedUser;
this.updatingRoles = false;
this.success = 'Rôle mis à jour avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
this.updatingRoles = false;
this.error = this.getErrorMessage(error);
this.cdRef.detectChanges();
} }
this.cdRef.detectChanges(); });
},
error: (error) => {
this.updatingRoles = false;
this.error = 'Erreur lors de la mise à jour des rôles';
this.cdRef.detectChanges();
console.error('Error updating roles:', error);
}
});
} }
// Gestion du statut // Gestion du statut
enableUser() { enableUser() {
if (!this.user) return; if (!this.user || !this.canEditUsers) return;
this.usersService.enableUser(this.user.id).subscribe({ this.usersService.enableUser(this.user.id)
next: () => { .pipe(takeUntil(this.destroy$))
this.user!.enabled = true; .subscribe({
this.success = 'Utilisateur activé avec succès'; next: (updatedUser) => {
this.cdRef.detectChanges(); this.user = updatedUser;
}, this.success = 'Utilisateur activé avec succès';
error: (error) => { this.cdRef.detectChanges();
this.error = 'Erreur lors de l\'activation de l\'utilisateur'; },
this.cdRef.detectChanges(); error: (error) => {
console.error('Error enabling user:', error); this.error = this.getErrorMessage(error);
} this.cdRef.detectChanges();
}); }
});
} }
disableUser() { disableUser() {
if (!this.user) return; if (!this.user || !this.canEditUsers) return;
this.usersService.disableUser(this.user.id).subscribe({ this.usersService.disableUser(this.user.id)
next: () => { .pipe(takeUntil(this.destroy$))
this.user!.enabled = false; .subscribe({
this.success = 'Utilisateur désactivé avec succès'; next: (updatedUser) => {
this.cdRef.detectChanges(); this.user = updatedUser;
}, this.success = 'Utilisateur désactivé avec succès';
error: (error) => { this.cdRef.detectChanges();
this.error = 'Erreur lors de la désactivation de l\'utilisateur'; },
this.cdRef.detectChanges(); error: (error) => {
console.error('Error disabling user:', error); this.error = this.getErrorMessage(error);
} this.cdRef.detectChanges();
}); }
});
}
// Réinitialisation du mot de passe
resetPassword() {
if (this.user) {
this.openResetPasswordModal.emit(this.user.id);
}
}
// Gestion des erreurs
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions nécessaires pour cette action';
}
if (error.status === 404) {
return 'Utilisateur non trouvé';
}
if (error.status === 400) {
return 'Données invalides';
}
return 'Une erreur est survenue. Veuillez réessayer.';
} }
// Utilitaires d'affichage // Utilitaires d'affichage
@ -206,6 +290,7 @@ export class UserProfile implements OnInit {
} }
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number): string {
if (!timestamp) return 'Non disponible';
return new Date(timestamp).toLocaleDateString('fr-FR', { return new Date(timestamp).toLocaleDateString('fr-FR', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@ -228,13 +313,31 @@ export class UserProfile implements OnInit {
return this.user.username; return this.user.username;
} }
getRoleBadgeClass(role: string): string { getRoleBadgeClass(role: UserRole): string {
switch (role) { return this.roleService.getRoleBadgeClass(role);
case 'admin': return 'bg-danger'; }
case 'merchant': return 'bg-success';
case 'support': return 'bg-info'; getRoleLabel(role: UserRole): string {
case 'user': return 'bg-secondary'; return this.roleService.getRoleLabel(role);
default: return 'bg-secondary'; }
}
getRoleIcon(role: UserRole): string {
return this.roleService.getRoleIcon(role);
}
getRoleDescription(role: UserRole): string {
const roleInfo = this.availableRoles.find(r => r.value === role);
return roleInfo?.description || 'Description non disponible';
}
// Vérification des permissions pour les actions
canAssignRole(targetRole: UserRole): boolean {
return this.roleService.canAssignRole(this.currentUserRole, targetRole);
}
// Vérifie si c'est le profil de l'utilisateur courant
isCurrentUserProfile(): boolean {
// Implémentez cette logique selon votre système d'authentification
return false;
} }
} }

View File

@ -0,0 +1,78 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { environment } from '@environments/environment';
export interface ApiResponse<T> {
data?: T;
message?: string;
success: boolean;
status?: string;
}
@Injectable({
providedIn: 'root'
})
export class ApiService {
private http = inject(HttpClient);
private baseUrl = environment.iamApiUrl;
private getHeaders(): HttpHeaders {
const token = localStorage.getItem('access_token');
return new HttpHeaders({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
});
}
get<T>(endpoint: string, params?: any): Observable<T> {
return this.http.get<T>(`${this.baseUrl}/${endpoint}`, {
headers: this.getHeaders(),
params: this.createParams(params)
}).pipe(
catchError(this.handleError)
);
}
post<T>(endpoint: string, data: any): Observable<T> {
return this.http.post<T>(`${this.baseUrl}/${endpoint}`, data, {
headers: this.getHeaders()
}).pipe(
catchError(this.handleError)
);
}
put<T>(endpoint: string, data: any): Observable<T> {
return this.http.put<T>(`${this.baseUrl}/${endpoint}`, data, {
headers: this.getHeaders()
}).pipe(
catchError(this.handleError)
);
}
delete<T>(endpoint: string): Observable<T> {
return this.http.delete<T>(`${this.baseUrl}/${endpoint}`, {
headers: this.getHeaders()
}).pipe(
catchError(this.handleError)
);
}
private createParams(params: any): HttpParams {
let httpParams = new HttpParams();
if (params) {
Object.keys(params).forEach(key => {
if (params[key] !== null && params[key] !== undefined) {
httpParams = httpParams.set(key, params[key].toString());
}
});
}
return httpParams;
}
private handleError(error: any) {
console.error('API Error:', error);
return throwError(() => error);
}
}

View File

@ -1,31 +1,80 @@
// src/app/modules/users/services/users.service.ts
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '@environments/environment'; import { environment } from '@environments/environment';
import { Observable, map, catchError, throwError } from 'rxjs'; import { Observable, map, catchError, throwError, of } from 'rxjs';
import { of } from 'rxjs'; import { AvailableRolesResponse } from '@core/services/role-management.service';
// Interfaces alignées avec le contrôleur NestJS
export interface HubUserResponse {
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
enabled: boolean;
emailVerified: boolean;
createdBy: string;
createdByUsername: string;
createdTimestamp: number;
lastLogin?: number;
userType: 'HUB';
}
import { export interface CreateHubUserDto {
UserResponse, username: string;
CreateUserDto, email: string;
UpdateUserDto, firstName: string;
ResetPasswordDto, lastName: string;
PaginatedUserResponse, password: string;
ClientRole role: UserRole;
} from '../models/user'; enabled?: boolean;
emailVerified?: boolean;
}
export interface UpdateHubUserDto {
firstName?: string;
lastName?: string;
email?: string;
enabled?: boolean;
}
export interface ResetPasswordDto {
newPassword: string;
temporary?: boolean;
}
export interface PaginatedUserResponse {
users: HubUserResponse[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export enum UserRole {
DCB_ADMIN = 'dcb-admin',
DCB_SUPPORT = 'dcb-support',
DCB_PARTNER = 'dcb-partner'
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class UsersService { export class HubUsersService {
private http = inject(HttpClient); private http = inject(HttpClient);
private apiUrl = `${environment.iamApiUrl}/users`; private apiUrl = `${environment.iamApiUrl}/hub-users`;
// === CRUD COMPLET === // === CRUD COMPLET ===
createUser(createUserDto: CreateUserDto): Observable<UserResponse> {
/**
* Crée un nouvel utilisateur Hub
*/
createUser(createUserDto: CreateHubUserDto): Observable<HubUserResponse> {
// Validation // Validation
if (!createUserDto.username || createUserDto.username.trim() === '') { if (!createUserDto.username?.trim()) {
return throwError(() => 'Username is required and cannot be empty'); return throwError(() => 'Username is required and cannot be empty');
} }
if (!createUserDto.email || createUserDto.email.trim() === '') { if (!createUserDto.email?.trim()) {
return throwError(() => 'Email is required and cannot be empty'); return throwError(() => 'Email is required and cannot be empty');
} }
@ -33,6 +82,10 @@ export class UsersService {
return throwError(() => 'Password must be at least 8 characters'); return throwError(() => 'Password must be at least 8 characters');
} }
if (!createUserDto.role) {
return throwError(() => 'Role is required');
}
// Nettoyage des données // Nettoyage des données
const payload = { const payload = {
username: createUserDto.username.trim(), username: createUserDto.username.trim(),
@ -40,129 +93,230 @@ export class UsersService {
firstName: (createUserDto.firstName || '').trim(), firstName: (createUserDto.firstName || '').trim(),
lastName: (createUserDto.lastName || '').trim(), lastName: (createUserDto.lastName || '').trim(),
password: createUserDto.password, password: createUserDto.password,
role: createUserDto.role,
enabled: createUserDto.enabled !== undefined ? createUserDto.enabled : true, enabled: createUserDto.enabled !== undefined ? createUserDto.enabled : true,
emailVerified: createUserDto.emailVerified !== undefined ? createUserDto.emailVerified : false, emailVerified: createUserDto.emailVerified !== undefined ? createUserDto.emailVerified : false,
clientRoles: createUserDto.clientRoles || []
}; };
return this.http.post<UserResponse>(`${this.apiUrl}`, payload).pipe( return this.http.post<HubUserResponse>(this.apiUrl, payload).pipe(
catchError(error => throwError(() => error)) catchError(error => throwError(() => error))
); );
} }
// READ - Obtenir tous les utilisateurs /**
findAllUsers(): Observable<PaginatedUserResponse> { * Récupère tous les utilisateurs Hub avec pagination
return this.http.get<{ */
users: any[]; findAllUsers(page: number = 1, limit: number = 10, filters?: any): Observable<PaginatedUserResponse> {
total: number; let params = new HttpParams()
page: number; .set('page', page.toString())
limit: number; .set('limit', limit.toString());
totalPages: number;
}>(`${this.apiUrl}`).pipe( if (filters) {
Object.keys(filters).forEach(key => {
if (filters[key] !== undefined && filters[key] !== null) {
params = params.set(key, filters[key].toString());
}
});
}
return this.http.get<HubUserResponse[]>(this.apiUrl, { params, observe: 'response' }).pipe(
map(response => { map(response => {
const users = response.users.map(user => new UserResponse(user)); const users = response.body || [];
return new PaginatedUserResponse(users, response.total, response.page, response.limit); const total = parseInt(response.headers.get('X-Total-Count') || '0');
return {
users,
total,
page,
limit,
totalPages: Math.ceil(total / limit)
};
}), }),
catchError(error => { catchError(error => {
console.error('Error loading users:', error); console.error('Error loading users:', error);
return of(new PaginatedUserResponse([], 0, 1, 10)); return throwError(() => error);
}) })
); );
} }
// READ - Obtenir un utilisateur par ID /**
getUserById(id: string): Observable<UserResponse> { * Récupère tous les utilisateurs Hub avec pagination
return this.http.get<any>(`${this.apiUrl}/${id}`).pipe( */
map(response => new UserResponse(response)) findAllMerchantUsers(page: number = 1, limit: number = 10, filters?: any): Observable<PaginatedUserResponse> {
let params = new HttpParams()
.set('page', page.toString())
.set('limit', limit.toString());
if (filters) {
Object.keys(filters).forEach(key => {
if (filters[key] !== undefined && filters[key] !== null) {
params = params.set(key, filters[key].toString());
}
});
}
return this.http.get<HubUserResponse[]>(`${this.apiUrl}//merchants/all`, { params, observe: 'response' }).pipe(
map(response => {
const users = response.body || [];
const total = parseInt(response.headers.get('X-Total-Count') || '0');
return {
users,
total,
page,
limit,
totalPages: Math.ceil(total / limit)
};
}),
catchError(error => {
console.error('Error loading users:', error);
return throwError(() => error);
})
); );
} }
// READ - Obtenir le profil de l'utilisateur connecté /**
getCurrentUserProfile(): Observable<UserResponse> { * Récupère un utilisateur Hub par ID
return this.http.get<any>(`${this.apiUrl}/profile/me`).pipe( */
map(response => new UserResponse(response)) getUserById(id: string): Observable<HubUserResponse> {
); return this.http.get<HubUserResponse>(`${this.apiUrl}/${id}`);
} }
// UPDATE - Mettre à jour un utilisateur /**
updateUser(id: string, updateUserDto: UpdateUserDto): Observable<UserResponse> { * Met à jour un utilisateur Hub
return this.http.put<any>(`${this.apiUrl}/${id}`, updateUserDto).pipe( */
map(response => new UserResponse(response)) updateUser(id: string, updateUserDto: UpdateHubUserDto): Observable<HubUserResponse> {
); return this.http.put<HubUserResponse>(`${this.apiUrl}/${id}`, updateUserDto);
} }
// UPDATE - Mettre à jour le profil de l'utilisateur connecté /**
updateCurrentUserProfile(updateUserDto: UpdateUserDto): Observable<UserResponse> { * Supprime un utilisateur Hub
return this.http.put<any>(`${this.apiUrl}/profile/me`, updateUserDto).pipe( */
map(response => new UserResponse(response))
);
}
// DELETE - Supprimer un utilisateur
deleteUser(id: string): Observable<{ message: string }> { deleteUser(id: string): Observable<{ message: string }> {
return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`); return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`);
} }
// === GESTION DES MOTS DE PASSE === // === GESTION DES MOTS DE PASSE ===
resetPassword(resetPasswordDto: ResetPasswordDto): Observable<{ message: string }> {
return this.http.put<{ message: string }>( /**
`${this.apiUrl}/${resetPasswordDto.userId}/password`, * Réinitialise le mot de passe d'un utilisateur
resetPasswordDto */
resetPassword(userId: string, newPassword: string, temporary: boolean = true): Observable<{ message: string }> {
return this.http.post<{ message: string }>(
`${this.apiUrl}/${userId}/reset-password`,
{ newPassword, temporary }
);
}
/**
* Envoie un email de réinitialisation de mot de passe
*/
sendPasswordResetEmail(userId: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>(
`${this.apiUrl}/${userId}/send-reset-email`,
{}
); );
} }
// === GESTION DU STATUT === // === GESTION DU STATUT ===
enableUser(id: string): Observable<{ message: string }> {
return this.http.put<{ message: string }>(`${this.apiUrl}/${id}/enable`, {}); /**
* Active un utilisateur
*/
enableUser(id: string): Observable<HubUserResponse> {
return this.http.put<HubUserResponse>(`${this.apiUrl}/${id}`, { enabled: true });
} }
disableUser(id: string): Observable<{ message: string }> { /**
return this.http.put<{ message: string }>(`${this.apiUrl}/${id}/disable`, {}); * Désactive un utilisateur
} */
disableUser(id: string): Observable<HubUserResponse> {
// === RECHERCHE ET VÉRIFICATION === return this.http.put<HubUserResponse>(`${this.apiUrl}/${id}`, { enabled: false });
userExists(username: string): Observable<{ exists: boolean }> {
return this.http.get<{ exists: boolean }>(`${this.apiUrl}/check/${username}`);
}
findUserByUsername(username: string): Observable<UserResponse[]> {
return this.http.get<any[]>(`${this.apiUrl}/search/username/${username}`).pipe(
map(users => users.map(user => new UserResponse(user)))
);
}
findUserByEmail(email: string): Observable<UserResponse[]> {
return this.http.get<any[]>(`${this.apiUrl}/search/email/${email}`).pipe(
map(users => users.map(user => new UserResponse(user)))
);
} }
// === GESTION DES RÔLES === // === GESTION DES RÔLES ===
getUserClientRoles(id: string): Observable<{ roles: string[] }> {
return this.http.get<{ roles: string[] }>(`${this.apiUrl}/${id}/roles`); /**
* Met à jour le rôle d'un utilisateur
*/
updateUserRole(id: string, role: UserRole): Observable<HubUserResponse> {
return this.http.put<HubUserResponse>(`${this.apiUrl}/${id}/role`, { role });
} }
assignClientRoles(userId: string, roles: ClientRole[]): Observable<{ message: string }> { /**
return this.http.put<{ message: string }>(`${this.apiUrl}/${userId}/roles`, { roles }); * Récupère les rôles Hub disponibles
*/
getAvailableHubRoles(): Observable<AvailableRolesResponse> {
return this.http.get<AvailableRolesResponse>(
`${this.apiUrl}/roles/available`
).pipe(
catchError(error => {
console.error('Error loading available roles:', error);
// Fallback en cas d'erreur
return of({
roles: [
{
value: UserRole.DCB_ADMIN,
label: 'DCB Admin',
description: 'Full administrative access to the entire system'
},
{
value: UserRole.DCB_SUPPORT,
label: 'DCB Support',
description: 'Support access with limited administrative capabilities'
},
{
value: UserRole.DCB_PARTNER,
label: 'DCB Partner',
description: 'Merchant partner with access to their own merchant ecosystem'
}
]
});
})
);
} }
// === SESSIONS ET TOKENS === /**
getUserSessions(userId: string): Observable<any[]> { * Récupère les utilisateurs par rôle
return this.http.get<any[]>(`${this.apiUrl}/${userId}/sessions`); */
getUsersByRole(role: UserRole): Observable<HubUserResponse[]> {
return this.http.get<HubUserResponse[]>(`${this.apiUrl}/role/${role}`);
} }
logoutUser(userId: string): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.apiUrl}/${userId}/logout`, {});
}
// === STATISTIQUES === // === STATISTIQUES ===
getUserStats(): Observable<{
total: number; /**
enabled: number; * Récupère les statistiques des utilisateurs
disabled: number; */
emailVerified: number; getUsersStats(): Observable<any> {
emailNotVerified: number; return this.http.get<any>(`${this.apiUrl}/stats/overview`);
}> { }
return this.http.get<any>(`${this.apiUrl}/stats`);
// === UTILITAIRES ===
/**
* Vérifie si un nom d'utilisateur existe
*/
userExists(username: string): Observable<{ exists: boolean }> {
// Implémentation temporaire - à adapter selon votre API
return this.findAllUsers().pipe(
map(response => ({
exists: response.users.some(user => user.username === username)
}))
);
}
/**
* Recherche des utilisateurs
*/
searchUsers(query: string): Observable<HubUserResponse[]> {
return this.findAllUsers().pipe(
map(response => response.users.filter(user =>
user.username.toLowerCase().includes(query.toLowerCase()) ||
user.email.toLowerCase().includes(query.toLowerCase()) ||
user.firstName?.toLowerCase().includes(query.toLowerCase()) ||
user.lastName?.toLowerCase().includes(query.toLowerCase())
))
);
} }
} }

View File

@ -5,6 +5,42 @@
[badge]="{icon:'lucideUsers', text:'Keycloak Users'}" [badge]="{icon:'lucideUsers', text:'Keycloak Users'}"
/> />
<!-- Indicateur de permissions -->
@if (currentUserRole) {
<div class="row mb-3">
<div class="col-12">
<div class="alert alert-info py-2">
<div class="d-flex align-items-center">
<ng-icon name="lucideInfo" class="me-2"></ng-icon>
<div class="flex-grow-1">
<small>
<strong>Rôle actuel :</strong>
<span class="badge" [ngClass]="getRoleBadgeClass(currentUserRole)">
{{ roleService.getRoleLabel(currentUserRole) }}
</span>
@if (!canCreateUsers) {
<span class="text-warning ms-2">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Permissions limitées
</span>
}
</small>
</div>
@if (canCreateUsers) {
<button
class="btn btn-primary btn-sm"
(click)="openCreateUserModal()"
>
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Nouvel Utilisateur
</button>
}
</div>
</div>
</div>
</div>
}
<!-- Navigation par onglets avec style bordered --> <!-- Navigation par onglets avec style bordered -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
@ -22,6 +58,8 @@
</a> </a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<app-users-list <app-users-list
[canCreateUsers]="canCreateUsers"
[canDeleteUsers]="canDeleteUsers"
(userSelected)="showTab('profile', $event)" (userSelected)="showTab('profile', $event)"
(openCreateModal)="openCreateUserModal()" (openCreateModal)="openCreateUserModal()"
(openResetPasswordModal)="openResetPasswordModal($event)" (openResetPasswordModal)="openResetPasswordModal($event)"
@ -75,6 +113,19 @@
</div> </div>
} }
<!-- Avertissement permissions -->
@if (!canManageRoles && assignableRoles.length === 1) {
<div class="alert alert-warning">
<small>
<ng-icon name="lucideShield" class="me-1"></ng-icon>
<strong>Permissions limitées :</strong> Vous ne pouvez créer que des utilisateurs avec le rôle
<span class="badge" [ngClass]="getRoleBadgeClass(assignableRoles[0])">
{{ roleService.getRoleLabel(assignableRoles[0]) }}
</span>
</small>
</div>
}
<form (ngSubmit)="createUser()" #userForm="ngForm"> <form (ngSubmit)="createUser()" #userForm="ngForm">
<div class="row g-3"> <div class="row g-3">
<!-- Informations de base --> <!-- Informations de base -->
@ -158,6 +209,64 @@
</div> </div>
</div> </div>
<!-- Sélection du rôle -->
<div class="col-12">
<label class="form-label">
Rôle <span class="text-danger">*</span>
</label>
<select
class="form-select"
[(ngModel)]="newUser.role"
name="role"
required
[disabled]="creatingUser || !canManageRoles"
>
<option value="" disabled>Sélectionnez un rôle</option>
@for (role of availableRoles; track role.value) {
<option
[value]="role.value"
[disabled]="!canAssignRole(role.value)"
>
{{ role.label }} - {{ role.description }}
@if (!canAssignRole(role.value)) {
(Non autorisé)
}
</option>
}
</select>
<div class="form-text">
@if (canManageRoles) {
Sélectionnez le rôle à assigner à cet utilisateur
} @else {
Vous ne pouvez pas modifier les rôles disponibles
}
</div>
</div>
<!-- Aperçu du rôle sélectionné -->
@if (newUser.role) {
<div class="col-12">
<div class="alert alert-info">
<div class="d-flex align-items-center">
<ng-icon
[name]="roleService.getRoleIcon(newUser.role)"
class="me-2"
></ng-icon>
<div>
<strong>Rôle sélectionné :</strong>
<span class="badge ms-2" [ngClass]="getRoleBadgeClass(newUser.role)">
{{ roleService.getRoleLabel(newUser.role) }}
</span>
<br>
<small class="text-muted">
{{ getRoleDescription(newUser.role) }}
</small>
</div>
</div>
</div>
</div>
}
<!-- Configuration du compte --> <!-- Configuration du compte -->
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check form-switch"> <div class="form-check form-switch">
@ -192,51 +301,6 @@
</div> </div>
<div class="form-text">L'utilisateur n'aura pas à vérifier son email</div> <div class="form-text">L'utilisateur n'aura pas à vérifier son email</div>
</div> </div>
<!-- Rôles client -->
<div class="col-12">
<label class="form-label">Rôles Client</label>
<div class="row g-2">
@for (role of availableRoles; track role) {
<div class="col-md-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
[id]="'role-' + role"
[checked]="isRoleSelected(role)"
(change)="toggleRole(role)"
[disabled]="creatingUser"
>
<label class="form-check-label" [for]="'role-' + role">
<span class="badge"
[class]="{
'bg-primary': role === 'admin',
'bg-success': role === 'merchant',
'bg-info': role === 'support'
}"
>
{{ role }}
</span>
</label>
</div>
</div>
}
</div>
<div class="form-text">Sélectionnez les rôles à assigner à cet utilisateur</div>
</div>
<!-- Aperçu des rôles sélectionnés -->
@if (selectedRoles.length > 0) {
<div class="col-12">
<div class="alert alert-info">
<strong>Rôles sélectionnés :</strong>
@for (role of selectedRoles; track role) {
<span class="badge bg-primary ms-1">{{ role }}</span>
}
</div>
</div>
}
</div> </div>
<div class="modal-footer mt-4"> <div class="modal-footer mt-4">
@ -251,7 +315,7 @@
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
[disabled]="!userForm.form.valid || creatingUser" [disabled]="!userForm.form.valid || creatingUser || !canAssignRole(newUser.role)"
> >
@if (creatingUser) { @if (creatingUser) {
<div class="spinner-border spinner-border-sm me-2" role="status"> <div class="spinner-border spinner-border-sm me-2" role="status">
@ -302,11 +366,24 @@
@if (!resetPasswordSuccess && selectedUserForReset) { @if (!resetPasswordSuccess && selectedUserForReset) {
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Utilisateur :</strong> {{ selectedUserForReset.username }} <div class="d-flex align-items-center">
@if (selectedUserForReset.firstName || selectedUserForReset.lastName) { <ng-icon
<br> [name]="roleService.getRoleIcon(selectedUserForReset.role)"
<strong>Nom :</strong> {{ selectedUserForReset.firstName }} {{ selectedUserForReset.lastName }} class="me-2"
} ></ng-icon>
<div>
<strong>Utilisateur :</strong> {{ selectedUserForReset.username }}
@if (selectedUserForReset.firstName || selectedUserForReset.lastName) {
<br>
<strong>Nom :</strong> {{ selectedUserForReset.firstName }} {{ selectedUserForReset.lastName }}
}
<br>
<strong>Rôle :</strong>
<span class="badge ms-1" [ngClass]="getRoleBadgeClass(selectedUserForReset.role)">
{{ roleService.getRoleLabel(selectedUserForReset.role) }}
</span>
</div>
</div>
</div> </div>
<form (ngSubmit)="confirmResetPassword()" #resetForm="ngForm"> <form (ngSubmit)="confirmResetPassword()" #resetForm="ngForm">
@ -418,8 +495,8 @@
@if (selectedUserForDelete) { @if (selectedUserForDelete) {
<div class="alert alert-warning"> <div class="alert alert-warning">
<div class="d-flex align-items-center"> <div class="d-flex align-items-start">
<ng-icon name="lucideAlertTriangle" class="me-2"></ng-icon> <ng-icon name="lucideAlertTriangle" class="me-2 mt-1"></ng-icon>
<div> <div>
<strong>Utilisateur :</strong> {{ selectedUserForDelete.username }} <strong>Utilisateur :</strong> {{ selectedUserForDelete.username }}
@if (selectedUserForDelete.firstName || selectedUserForDelete.lastName) { @if (selectedUserForDelete.firstName || selectedUserForDelete.lastName) {
@ -428,6 +505,11 @@
} }
<br> <br>
<strong>Email :</strong> {{ selectedUserForDelete.email }} <strong>Email :</strong> {{ selectedUserForDelete.email }}
<br>
<strong>Rôle :</strong>
<span class="badge ms-1" [ngClass]="getRoleBadgeClass(selectedUserForDelete.role)">
{{ roleService.getRoleLabel(selectedUserForDelete.role) }}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -456,7 +538,7 @@
type="button" type="button"
class="btn btn-danger" class="btn btn-danger"
(click)="confirmDeleteUser()" (click)="confirmDeleteUser()"
[disabled]="deletingUser" [disabled]="deletingUser || !canDeleteUsers"
> >
@if (deletingUser) { @if (deletingUser) {
<div class="spinner-border spinner-border-sm me-2" role="status"> <div class="spinner-border spinner-border-sm me-2" role="status">

View File

@ -1,13 +1,16 @@
import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef } from '@angular/core'; // src/app/modules/users/users.ts
import { Component, inject, OnInit, TemplateRef, ViewChild, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core'; import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { Subject, takeUntil } from 'rxjs';
import { PageTitle } from '@app/components/page-title/page-title'; import { PageTitle } from '@app/components/page-title/page-title';
import { UsersList } from './list/list'; import { UsersList } from './list/list';
import { UserProfile } from './profile/profile'; import { UserProfile } from './profile/profile';
import { UsersService } from './services/users.service'; import { HubUsersService, CreateHubUserDto, UserRole, HubUserResponse } from './services/users.service';
import { CreateUserDto, ClientRole, UserResponse } from './models/user'; import { RoleManagementService } from '@core/services/role-management.service';
import { AuthService } from '@core/services/auth.service';
@Component({ @Component({
selector: 'app-users', selector: 'app-users',
@ -24,54 +27,143 @@ import { CreateUserDto, ClientRole, UserResponse } from './models/user';
], ],
templateUrl: './users.html', templateUrl: './users.html',
}) })
export class Users implements OnInit { export class Users implements OnInit, OnDestroy {
private modalService = inject(NgbModal); private modalService = inject(NgbModal);
private usersService = inject(UsersService); private usersService = inject(HubUsersService);
private cdRef = inject(ChangeDetectorRef); private authService = inject(AuthService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Rendre le service accessible au template via des méthodes proxy
protected roleService = inject(RoleManagementService);
activeTab: 'list' | 'profile' = 'list'; activeTab: 'list' | 'profile' = 'list';
selectedUserId: string | null = null; selectedUserId: string | null = null;
// Gestion des permissions
currentUserRole: UserRole | null = null;
userPermissions: any = null;
canCreateUsers = false;
canDeleteUsers = false;
canManageRoles = false;
// Données pour la création d'utilisateur // Données pour la création d'utilisateur
newUser: CreateUserDto = { newUser: CreateHubUserDto = {
username: '', username: '',
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
password: '', password: '',
role: UserRole.DCB_SUPPORT,
enabled: true, enabled: true,
emailVerified: false, emailVerified: false
clientRoles: ['user']
}; };
availableRoles: ClientRole[] = ['admin', 'merchant', 'support', 'user']; availableRoles: { value: UserRole; label: string; description: string }[] = [];
selectedRoles: ClientRole[] = ['user']; assignableRoles: UserRole[] = [];
creatingUser = false; creatingUser = false;
createUserError = ''; createUserError = '';
// Données pour la réinitialisation de mot de passe // Données pour la réinitialisation de mot de passe
selectedUserForReset: UserResponse | null = null; selectedUserForReset: HubUserResponse | null = null;
newPassword = ''; newPassword = '';
temporaryPassword = false; temporaryPassword = false;
resettingPassword = false; resettingPassword = false;
resetPasswordError = ''; resetPasswordError = '';
resetPasswordSuccess = ''; resetPasswordSuccess = '';
selectedUserForDelete: UserResponse | null = null; selectedUserForDelete: HubUserResponse | null = null;
deletingUser = false; deletingUser = false;
deleteUserError = ''; deleteUserError = '';
ngOnInit() { ngOnInit() {
this.activeTab = 'list'; this.activeTab = 'list';
this.synchronizeRoles(); this.initializeUserPermissions();
this.loadAvailableRoles();
} }
private synchronizeRoles(): void { ngOnDestroy(): void {
// S'assurer que les rôles sont synchronisés this.destroy$.next();
this.selectedRoles = [...this.newUser.clientRoles as ClientRole[]]; this.destroy$.complete();
} }
/**
* Initialise les permissions de l'utilisateur courant
*/
private initializeUserPermissions(): void {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (profile) => {
// Supposons que le rôle principal est le premier rôle
this.currentUserRole = profile?.roles?.[0] as UserRole || null;
if (this.currentUserRole) {
this.roleService.setCurrentUserRole(this.currentUserRole);
this.userPermissions = this.roleService.getPermissionsForRole(this.currentUserRole);
this.canCreateUsers = this.roleService.canCreateUsers(this.currentUserRole);
this.canDeleteUsers = this.roleService.canDeleteUsers(this.currentUserRole);
this.canManageRoles = this.roleService.canManageRoles(this.currentUserRole);
// Rôles que l'utilisateur peut attribuer
this.assignableRoles = this.roleService.getAssignableRoles(this.currentUserRole);
}
},
error: (error) => {
console.error('Error loading user profile:', error);
}
});
}
/**
* Charge les rôles disponibles
*/
private loadAvailableRoles(): void {
this.roleService.getAvailableRolesSimple()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (roles) => {
this.availableRoles = roles;
},
error: (error) => {
console.error('Error loading available roles:', error);
// Fallback en cas d'erreur
this.availableRoles = [
{ value: UserRole.DCB_ADMIN, label: 'DCB Admin', description: 'Administrateur système' },
{ value: UserRole.DCB_SUPPORT, label: 'DCB Support', description: 'Support technique' },
{ value: UserRole.DCB_PARTNER, label: 'DCB Partner', description: 'Partenaire commercial' }
];
}
});
}
/**
* Vérifie si l'utilisateur peut attribuer un rôle spécifique
*/
canAssignRole(targetRole: UserRole): boolean {
return this.roleService.canAssignRole(this.currentUserRole, targetRole);
}
// Méthodes proxy pour le template
getRoleBadgeClass(role: UserRole): string {
return this.roleService.getRoleBadgeClass(role);
}
getRoleLabel(role: UserRole): string {
return this.roleService.getRoleLabel(role);
}
getRoleIcon(role: UserRole): string {
return this.roleService.getRoleIcon(role);
}
getRoleDescription(role: UserRole): string {
const roleInfo = this.availableRoles.find(r => r.value === role);
return roleInfo?.description || 'Description non disponible';
}
showTab(tab: 'list' | 'profile', userId?: string) { showTab(tab: 'list' | 'profile', userId?: string) {
this.activeTab = tab; this.activeTab = tab;
@ -96,93 +188,88 @@ export class Users implements OnInit {
// Méthode pour ouvrir le modal de création d'utilisateur // Méthode pour ouvrir le modal de création d'utilisateur
openCreateUserModal() { openCreateUserModal() {
if (!this.canCreateUsers) {
console.warn('User does not have permission to create users');
return;
}
this.newUser = { this.newUser = {
username: '', username: '',
email: '', email: '',
firstName: '', firstName: '',
lastName: '', lastName: '',
password: '', password: '',
role: this.assignableRoles[0] || UserRole.DCB_SUPPORT,
enabled: true, enabled: true,
emailVerified: false, emailVerified: false
clientRoles: ['user']
}; };
this.selectedRoles = ['user'];
this.createUserError = ''; this.createUserError = '';
this.openModal(this.createUserModal); this.openModal(this.createUserModal);
} }
// Méthode pour ouvrir le modal de réinitialisation de mot de passe // Méthode pour ouvrir le modal de réinitialisation de mot de passe
openResetPasswordModal(userId: string) { openResetPasswordModal(userId: string) {
// Charger les données de l'utilisateur this.usersService.getUserById(userId)
this.usersService.getUserById(userId).subscribe({ .pipe(takeUntil(this.destroy$))
next: (user) => { .subscribe({
this.selectedUserForReset = user; next: (user) => {
this.newPassword = ''; this.selectedUserForReset = user;
this.temporaryPassword = false; this.newPassword = '';
this.resetPasswordError = ''; this.temporaryPassword = false;
this.openModal(this.resetPasswordModal); this.resetPasswordError = '';
}, this.resetPasswordSuccess = '';
error: (error) => { this.openModal(this.resetPasswordModal);
console.error('Error loading user for password reset:', error); },
} error: (error) => {
}); console.error('Error loading user for password reset:', error);
} this.resetPasswordError = 'Erreur lors du chargement de l\'utilisateur';
}
// Gestion des rôles sélectionnés });
toggleRole(role: ClientRole) {
const index = this.selectedRoles.indexOf(role);
if (index > -1) {
this.selectedRoles.splice(index, 1);
} else {
this.selectedRoles.push(role);
}
// Mettre à jour les deux propriétés
this.newUser.clientRoles = [...this.selectedRoles];
}
isRoleSelected(role: ClientRole): boolean {
return this.selectedRoles.includes(role);
} }
// Création d'utilisateur
createUser() { createUser() {
if (!this.canCreateUsers) {
this.createUserError = 'Vous n\'avez pas la permission de créer des utilisateurs';
return;
}
const validation = this.validateUserForm(); const validation = this.validateUserForm();
if (!validation.isValid) { if (!validation.isValid) {
this.createUserError = validation.error!; this.createUserError = validation.error!;
return; return;
} }
// Vérifier que l'utilisateur peut attribuer ce rôle
if (!this.canAssignRole(this.newUser.role)) {
this.createUserError = 'Vous n\'avez pas la permission d\'attribuer ce rôle';
return;
}
this.creatingUser = true; this.creatingUser = true;
this.createUserError = ''; this.createUserError = '';
const payload = { this.usersService.createUser(this.newUser)
username: this.newUser.username.trim(), .pipe(takeUntil(this.destroy$))
email: this.newUser.email.trim(), .subscribe({
firstName: this.newUser.firstName.trim(), next: (createdUser) => {
lastName: this.newUser.lastName.trim(), this.creatingUser = false;
password: this.newUser.password, this.modalService.dismissAll();
enabled: this.newUser.enabled,
emailVerified: this.newUser.emailVerified, // Rafraîchir la liste
clientRoles: this.selectedRoles if (this.usersListComponent) {
}; this.usersListComponent.loadUsers();
}
this.usersService.createUser(payload).subscribe({ this.showTab('list');
next: (createdUser) => { this.cdRef.detectChanges();
this.creatingUser = false; },
this.modalService.dismissAll(); error: (error) => {
this.creatingUser = false;
if(this.usersListComponent){ this.createUserError = this.getErrorMessage(error);
this.usersListComponent.loadUsers(); this.cdRef.detectChanges();
this.usersListComponent.onClearFilters();
} }
});
this.showTab('list');
},
error: (error) => {
this.creatingUser = false;
this.createUserError = this.getErrorMessage(error);
}
});
} }
// Réinitialiser le mot de passe // Réinitialiser le mot de passe
@ -196,27 +283,77 @@ export class Users implements OnInit {
this.resetPasswordError = ''; this.resetPasswordError = '';
this.resetPasswordSuccess = ''; this.resetPasswordSuccess = '';
const resetDto = { this.usersService.resetPassword(
userId: this.selectedUserForReset.id, this.selectedUserForReset.id,
newPassword: this.newPassword, this.newPassword,
temporary: this.temporaryPassword this.temporaryPassword
}; )
.pipe(takeUntil(this.destroy$))
this.usersService.resetPassword(resetDto).subscribe({ .subscribe({
next: () => { next: () => {
this.resettingPassword = false; this.resettingPassword = false;
this.resetPasswordSuccess = 'Mot de passe réinitialisé avec succès !'; this.resetPasswordSuccess = 'Mot de passe réinitialisé avec succès !';
this.cdRef.detectChanges(); // Forcer la détection des changements this.cdRef.detectChanges();
}, },
error: (error) => { error: (error) => {
this.resettingPassword = false; this.resettingPassword = false;
this.resetPasswordError = this.getResetPasswordErrorMessage(error); this.resetPasswordError = this.getResetPasswordErrorMessage(error);
this.cdRef.detectChanges(); // Forcer la détection des changements this.cdRef.detectChanges();
} }
}); });
} }
// Gestion des erreurs améliorée // Méthode pour ouvrir le modal de suppression
openDeleteUserModal(userId: string) {
if (!this.canDeleteUsers) {
console.warn('User does not have permission to delete users');
return;
}
this.usersService.getUserById(userId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (user) => {
this.selectedUserForDelete = user;
this.deleteUserError = '';
this.openModal(this.deleteUserModal);
},
error: (error) => {
console.error('Error loading user for deletion:', error);
this.deleteUserError = 'Erreur lors du chargement de l\'utilisateur';
}
});
}
confirmDeleteUser() {
if (!this.selectedUserForDelete || !this.canDeleteUsers) return;
this.deletingUser = true;
this.deleteUserError = '';
this.usersService.deleteUser(this.selectedUserForDelete.id)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: () => {
this.deletingUser = false;
this.modalService.dismissAll();
// Rafraîchir la liste
if (this.usersListComponent) {
this.usersListComponent.loadUsers();
}
this.cdRef.detectChanges();
},
error: (error) => {
this.deletingUser = false;
this.deleteUserError = this.getDeleteErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
// Gestion des erreurs
private getErrorMessage(error: any): string { private getErrorMessage(error: any): string {
if (error.error?.message) { if (error.error?.message) {
return error.error.message; return error.error.message;
@ -227,6 +364,9 @@ export class Users implements OnInit {
if (error.status === 409) { if (error.status === 409) {
return 'Un utilisateur avec ce nom ou email existe déjà.'; return 'Un utilisateur avec ce nom ou email existe déjà.';
} }
if (error.status === 403) {
return 'Vous n\'avez pas les permissions nécessaires pour cette action.';
}
return 'Erreur lors de la création de l\'utilisateur. Veuillez réessayer.'; return 'Erreur lors de la création de l\'utilisateur. Veuillez réessayer.';
} }
@ -240,59 +380,12 @@ export class Users implements OnInit {
if (error.status === 400) { if (error.status === 400) {
return 'Le mot de passe ne respecte pas les critères de sécurité.'; return 'Le mot de passe ne respecte pas les critères de sécurité.';
} }
if (error.status === 403) {
return 'Vous n\'avez pas les permissions pour réinitialiser ce mot de passe.';
}
return 'Erreur lors de la réinitialisation du mot de passe. Veuillez réessayer.'; return 'Erreur lors de la réinitialisation du mot de passe. Veuillez réessayer.';
} }
// Méthode pour ouvrir le modal de suppression
openDeleteUserModal(userId: string) {
this.usersService.getUserById(userId).subscribe({
next: (user) => {
this.selectedUserForDelete = user;
this.deleteUserError = '';
this.openModal(this.deleteUserModal);
},
error: (error) => {
console.error('Error loading user for password reset:', error);
}
});
}
@ViewChild(UsersList) usersListComponent!: UsersList;
private refreshUsersList(): void {
if (this.usersListComponent && typeof this.usersListComponent.loadUsers === 'function') {
this.usersListComponent.loadUsers();
} else {
console.warn('UsersList component not available for refresh');
// Alternative: reload the current tab
this.showTab('list');
}
}
confirmDeleteUser() {
if (!this.selectedUserForDelete) return;
this.deletingUser = true;
this.deleteUserError = '';
this.usersService.deleteUser(this.selectedUserForDelete.id).subscribe({
next: () => {
this.deletingUser = false;
this.modalService.dismissAll();
this.refreshUsersList();
this.cdRef.detectChanges();
},
error: (error) => {
this.deletingUser = false;
this.deleteUserError = this.getDeleteErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
// Gestion des erreurs pour la suppression
private getDeleteErrorMessage(error: any): string { private getDeleteErrorMessage(error: any): string {
if (error.error?.message) { if (error.error?.message) {
return error.error.message; return error.error.message;
@ -306,11 +399,6 @@ export class Users implements OnInit {
return 'Erreur lors de la suppression de l\'utilisateur. Veuillez réessayer.'; return 'Erreur lors de la suppression de l\'utilisateur. Veuillez réessayer.';
} }
// Méthode pour afficher un message de succès
private showSuccessMessage(message: string) {
console.log('Success:', message);
}
// Validation du formulaire // Validation du formulaire
private validateUserForm(): { isValid: boolean; error?: string } { private validateUserForm(): { isValid: boolean; error?: string } {
const requiredFields = [ const requiredFields = [
@ -326,13 +414,25 @@ export class Users implements OnInit {
} }
} }
// Validation email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.newUser.email)) {
return { isValid: false, error: 'Format d\'email invalide' };
}
if (!this.newUser.password || this.newUser.password.length < 8) { if (!this.newUser.password || this.newUser.password.length < 8) {
return { isValid: false, error: 'Le mot de passe doit contenir au moins 8 caractères' }; return { isValid: false, error: 'Le mot de passe doit contenir au moins 8 caractères' };
} }
if (!this.newUser.role) {
return { isValid: false, error: 'Le rôle est requis' };
}
return { isValid: true }; return { isValid: true };
} }
@ViewChild(UsersList) usersListComponent!: UsersList;
// Références aux templates de modals // Références aux templates de modals
@ViewChild('createUserModal') createUserModal!: TemplateRef<any>; @ViewChild('createUserModal') createUserModal!: TemplateRef<any>;
@ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef<any>; @ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef<any>;

View File

@ -0,0 +1,26 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate',
standalone: true
})
export class TruncatePipe implements PipeTransform {
transform(
value: string,
limit: number = 25,
completeWords: boolean = false,
ellipsis: string = '...'
): string {
if (!value) return '';
if (value.length <= limit) return value;
if (completeWords) {
// substring(0, limit) instead of substr(0, limit)
limit = value.substring(0, limit).lastIndexOf(' ');
}
return value.length > limit
? value.substring(0, limit) + ellipsis
: value;
}
}

View File

@ -57,7 +57,7 @@ export const environment = {
} }
}, },
// Configuration Merchants // Configuration Partners
merchants: { merchants: {
onboarding: { onboarding: {
maxFileSize: 10 * 1024 * 1024, maxFileSize: 10 * 1024 * 1024,

View File

@ -57,7 +57,7 @@ export const environment = {
} }
}, },
// Configuration Merchants // Configuration Partners
merchants: { merchants: {
onboarding: { onboarding: {
maxFileSize: 10 * 1024 * 1024, maxFileSize: 10 * 1024 * 1024,

View File

@ -57,7 +57,7 @@ export const environment = {
} }
}, },
// Configuration Merchants // Configuration Partners
merchants: { merchants: {
onboarding: { onboarding: {
maxFileSize: 10 * 1024 * 1024, maxFileSize: 10 * 1024 * 1024,