From 13a317aab0ed28a16f30a3105ddddd0f4d7708ef Mon Sep 17 00:00:00 2001 From: diallolatoile Date: Mon, 3 Nov 2025 17:36:59 +0000 Subject: [PATCH] feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature --- package-lock.json | 33 + package.json | 1 + src/app/app.routes.ts | 39 +- src/app/core/guards/auth.guard.ts | 122 +++- src/app/core/guards/public.guard.ts | 40 ++ src/app/core/guards/role.guard.ts | 33 +- src/app/core/interceptors/api.service.ts | 48 ++ src/app/core/interceptors/auth.interceptor.ts | 41 +- src/app/core/services/auth.service.ts | 485 +++++++++------ src/app/core/services/menu.service.ts | 4 +- src/app/core/services/permissions.service.ts | 102 ++- .../core/services/role-management.service.ts | 378 +++++++++++ src/app/core/services/role.service.ts | 117 ++++ src/app/layouts/components/data.ts | 4 +- src/app/modules.zip | Bin 0 -> 155941 bytes src/app/modules/auth/auth.route.ts | 10 - src/app/modules/auth/auth.routes.ts | 41 ++ .../error/{error.route.ts => error.routes.ts} | 14 +- src/app/modules/auth/new-password.ts | 162 +++++ src/app/modules/auth/reset-password.ts | 88 +++ src/app/modules/auth/sign-in.ts | 206 +++++- src/app/modules/auth/two-factor.ts | 92 +++ src/app/modules/components/basic-wizard.ts | 2 +- src/app/modules/components/data.ts | 263 +------- src/app/modules/components/typeaheds.ts | 254 -------- .../{merchants => components}/types.ts | 0 src/app/modules/components/vertical-wizard.ts | 2 +- .../components/wizard-with-progress.ts | 2 +- .../{merchants => components}/wizard.html | 4 + .../{merchants => components}/wizard.spec.ts | 0 .../{merchants => components}/wizard.ts | 6 +- .../components/recent-transactions.ts | 2 +- .../merchant-partners/config/config.html | 436 +++++++++++++ .../merchant-partners/config/config.spec.ts | 2 + .../merchant-partners/config/config.ts | 317 ++++++++++ .../modules/merchant-partners/list/list.html | 399 ++++++++++++ .../merchant-partners/list/list.spec.ts | 2 + .../modules/merchant-partners/list/list.ts | 559 +++++++++++++++++ .../merchant-partners/merchant-partners.html | 519 ++++++++++++++++ .../merchant-partners.spec.ts | 2 + .../merchant-partners/merchant-partners.ts | 486 +++++++++++++++ .../models/merchant-user.model.ts | 80 +++ .../models/partners-config.model.ts | 531 ++++++++++++++++ .../merchant-partners/profile/profile.html | 511 +++++++++++++++ .../merchant-partners/profile/profile.spec.ts | 2 + .../merchant-partners/profile/profile.ts | 440 +++++++++++++ .../services/merchant-partners.service.ts | 347 +++++++++++ .../services/partner-config.service.ts | 233 +++++++ .../merchant-partners/stats/stats.html | 249 ++++++++ .../merchant-partners/stats/stats.spec.ts | 2 + .../modules/merchant-partners/stats/stats.ts | 15 + src/app/modules/merchant-partners/types.ts | 6 + src/app/modules/merchants/data.ts | 34 - src/app/modules/merchants/merchants.html | 588 ------------------ src/app/modules/merchants/merchants.spec.ts | 2 - src/app/modules/merchants/merchants.ts | 339 ---------- .../merchants/models/merchant.models.ts | 81 --- .../merchants/services/merchants.service.ts | 54 -- .../modules/merchants/wizard-with-progress.ts | 312 ---------- src/app/modules/modules.routes.ts | 25 +- src/app/modules/profile/profile.html | 509 ++++++++------- src/app/modules/profile/profile.spec.ts | 4 +- src/app/modules/profile/profile.ts | 305 +++++---- .../transactions/details/details.spec.ts | 4 +- src/app/modules/users/list/list.html | 215 +++++-- src/app/modules/users/list/list.ts | 211 +++++-- .../modules/users/models/hub-user.model.ts | 54 ++ src/app/modules/users/models/user.model.ts | 311 +++++++++ src/app/modules/users/models/user.ts | 180 ------ src/app/modules/users/profile/profile.html | 313 +++++++--- src/app/modules/users/profile/profile.spec.ts | 4 +- src/app/modules/users/profile/profile.ts | 325 ++++++---- src/app/modules/users/services/api.service.ts | 78 +++ .../modules/users/services/users.service.ts | 348 ++++++++--- src/app/modules/users/users.html | 190 ++++-- src/app/modules/users/users.ts | 402 +++++++----- src/app/utils/truncate.pipe.ts | 26 + src/environments/environment.preprod.ts | 2 +- src/environments/environment.prod.ts | 2 +- src/environments/environment.ts | 2 +- 80 files changed, 9261 insertions(+), 3392 deletions(-) create mode 100644 src/app/core/guards/public.guard.ts create mode 100644 src/app/core/interceptors/api.service.ts create mode 100644 src/app/core/services/role-management.service.ts create mode 100644 src/app/core/services/role.service.ts create mode 100644 src/app/modules.zip delete mode 100644 src/app/modules/auth/auth.route.ts create mode 100644 src/app/modules/auth/auth.routes.ts rename src/app/modules/auth/error/{error.route.ts => error.routes.ts} (60%) create mode 100644 src/app/modules/auth/new-password.ts create mode 100644 src/app/modules/auth/reset-password.ts create mode 100644 src/app/modules/auth/two-factor.ts delete mode 100644 src/app/modules/components/typeaheds.ts rename src/app/modules/{merchants => components}/types.ts (100%) rename src/app/modules/{merchants => components}/wizard.html (86%) rename src/app/modules/{merchants => components}/wizard.spec.ts (100%) rename src/app/modules/{merchants => components}/wizard.ts (53%) create mode 100644 src/app/modules/merchant-partners/config/config.html create mode 100644 src/app/modules/merchant-partners/config/config.spec.ts create mode 100644 src/app/modules/merchant-partners/config/config.ts create mode 100644 src/app/modules/merchant-partners/list/list.html create mode 100644 src/app/modules/merchant-partners/list/list.spec.ts create mode 100644 src/app/modules/merchant-partners/list/list.ts create mode 100644 src/app/modules/merchant-partners/merchant-partners.html create mode 100644 src/app/modules/merchant-partners/merchant-partners.spec.ts create mode 100644 src/app/modules/merchant-partners/merchant-partners.ts create mode 100644 src/app/modules/merchant-partners/models/merchant-user.model.ts create mode 100644 src/app/modules/merchant-partners/models/partners-config.model.ts create mode 100644 src/app/modules/merchant-partners/profile/profile.html create mode 100644 src/app/modules/merchant-partners/profile/profile.spec.ts create mode 100644 src/app/modules/merchant-partners/profile/profile.ts create mode 100644 src/app/modules/merchant-partners/services/merchant-partners.service.ts create mode 100644 src/app/modules/merchant-partners/services/partner-config.service.ts create mode 100644 src/app/modules/merchant-partners/stats/stats.html create mode 100644 src/app/modules/merchant-partners/stats/stats.spec.ts create mode 100644 src/app/modules/merchant-partners/stats/stats.ts create mode 100644 src/app/modules/merchant-partners/types.ts delete mode 100644 src/app/modules/merchants/data.ts delete mode 100644 src/app/modules/merchants/merchants.html delete mode 100644 src/app/modules/merchants/merchants.spec.ts delete mode 100644 src/app/modules/merchants/merchants.ts delete mode 100644 src/app/modules/merchants/models/merchant.models.ts delete mode 100644 src/app/modules/merchants/services/merchants.service.ts delete mode 100644 src/app/modules/merchants/wizard-with-progress.ts create mode 100644 src/app/modules/users/models/hub-user.model.ts create mode 100644 src/app/modules/users/models/user.model.ts delete mode 100644 src/app/modules/users/models/user.ts create mode 100644 src/app/modules/users/services/api.service.ts create mode 100644 src/app/utils/truncate.pipe.ts diff --git a/package-lock.json b/package-lock.json index c316ae2..2e7b434 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "bootstrap": "^5.3.8", "chart.js": "^4.5.1", "choices.js": "^11.1.0", + "class-validator": "^0.14.2", "datatables": "^1.10.18", "datatables.net": "^2.3.4", "datatables.net-bs5": "^2.3.4", @@ -4376,6 +4377,12 @@ "dev": true, "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": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -5017,6 +5024,17 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -7589,6 +7607,12 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "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": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -10599,6 +10623,15 @@ "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 3b46308..5d13b45 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "bootstrap": "^5.3.8", "chart.js": "^4.5.1", "choices.js": "^11.1.0", + "class-validator": "^0.14.2", "datatables": "^1.10.18", "datatables.net": "^2.3.4", "datatables.net-bs5": "^2.3.4", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 61e73fd..9433e32 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,37 +1,42 @@ -import { Routes } from '@angular/router' -import { VerticalLayout } from '@layouts/vertical-layout/vertical-layout' +import { Routes } from '@angular/router'; +import { VerticalLayout } from '@layouts/vertical-layout/vertical-layout'; +import { authGuard } from './core/guards/auth.guard'; export const routes: Routes = [ + // Redirection racine { path: '', redirectTo: '/dcb-dashboard', pathMatch: 'full' }, - // Routes publiques (auth) + // ===== ROUTES D'ERREUR (publiques) ===== { - path: '', - loadChildren: () => - import('./modules/auth/auth.route').then(mod => mod.Auth_ROUTES), + path: 'error', + loadChildren: () => import('./modules/auth/error/error.routes').then(mod => mod.ERROR_PAGES_ROUTES), }, - // Routes d'erreur (publiques) + // ===== ROUTES PUBLIQUES ===== { - path: '', - loadChildren: () => - import('./modules/auth/error/error.route').then(mod => mod.ERROR_PAGES_ROUTES), + path: 'auth', + loadChildren: () => import('./modules/auth/auth.routes').then(mod => mod.AUTH_ROUTES), }, - // Routes protégées - SANS guards au niveau parent + // ===== ROUTES PROTÉGÉES - Layout Principal ===== { path: '', component: VerticalLayout, - loadChildren: () => - import('./modules/modules.routes').then( - m => m.ModulesRoutes - ), + canActivate: [authGuard], + children: [ + { + 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: '403', redirectTo: '/error/403' }, + { path: '401', redirectTo: '/auth/sign-in' }, + { path: 'unauthorized', redirectTo: '/error/403' }, - // Catch-all + // ===== CATCH-ALL ===== { path: '**', redirectTo: '/error/404' }, ]; \ No newline at end of file diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts index 84007ff..ca2c97c 100644 --- a/src/app/core/guards/auth.guard.ts +++ b/src/app/core/guards/auth.guard.ts @@ -1,20 +1,126 @@ +// src/app/core/guards/auth.guard.ts 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 { 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 roleService = inject(RoleService); 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; } - // Rediriger vers login avec l'URL de retour - router.navigate(['/auth/sign-in'], { - queryParams: { returnUrl: state.url } + const hasRequiredRole = roleService.hasAnyRole(requiredRoles); + const currentUserRoles = roleService.getCurrentUserRoles(); + + 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; -}; \ No newline at end of file +} + +/** + * 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; +} \ No newline at end of file diff --git a/src/app/core/guards/public.guard.ts b/src/app/core/guards/public.guard.ts new file mode 100644 index 0000000..3095a54 --- /dev/null +++ b/src/app/core/guards/public.guard.ts @@ -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; +}; \ No newline at end of file diff --git a/src/app/core/guards/role.guard.ts b/src/app/core/guards/role.guard.ts index 010d2db..0b7c523 100644 --- a/src/app/core/guards/role.guard.ts +++ b/src/app/core/guards/role.guard.ts @@ -11,8 +11,11 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) = // Vérifier d'abord l'authentification if (!authService.isAuthenticated()) { console.log('RoleGuard: User not authenticated, redirecting to login'); - router.navigate(['/auth/sign-in'], { - queryParams: { returnUrl: state.url } + router.navigate(['/auth/login'], { + queryParams: { + returnUrl: state.url, + reason: 'not_authenticated' + } }); return false; } @@ -22,13 +25,15 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) = if (!userRoles || userRoles.length === 0) { console.warn('RoleGuard: User has no roles'); - router.navigate(['/unauthorized']); + router.navigate(['/unauthorized'], { + queryParams: { reason: 'no_roles' } + }); return false; } const modulePath = getModulePath(route); - console.log('RoleGuard check:', { + console.log('🔐 RoleGuard check:', { module: modulePath, userRoles: userRoles, url: state.url @@ -38,12 +43,18 @@ export const roleGuard: CanActivateFn = (route: ActivatedRouteSnapshot, state) = const hasAccess = permissionsService.canAccessModule(modulePath, userRoles); if (!hasAccess) { - console.warn('RoleGuard: Access denied for', modulePath, 'User roles:', userRoles); - router.navigate(['/unauthorized']); + console.warn('❌ RoleGuard: Access denied for', modulePath, 'User roles:', userRoles); + router.navigate(['/unauthorized'], { + queryParams: { + module: modulePath, + userRoles: userRoles.join(','), + requiredRoles: getRequiredRolesForModule(permissionsService, modulePath).join(',') + } + }); return false; } - console.log('RoleGuard: Access granted for', modulePath); + console.log('✅ RoleGuard: Access granted for', modulePath); return true; }; @@ -78,4 +89,12 @@ function buildPathFromUrl(route: ActivatedRouteSnapshot): string { } 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 || []; } \ No newline at end of file diff --git a/src/app/core/interceptors/api.service.ts b/src/app/core/interceptors/api.service.ts new file mode 100644 index 0000000..8246c13 --- /dev/null +++ b/src/app/core/interceptors/api.service.ts @@ -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(endpoint: string, params?: any): Observable { + return this.http.get(`${environment.iamApiUrl}/${endpoint}`, { + params: this.createParams(params) + }); + } + + protected post(endpoint: string, data: any): Observable { + return this.http.post(`${environment.iamApiUrl}/${endpoint}`, data); + } + + protected put(endpoint: string, data: any): Observable { + return this.http.put(`${environment.iamApiUrl}/${endpoint}`, data); + } + + protected patch(endpoint: string, data: any): Observable { + return this.http.patch(`${environment.iamApiUrl}/${endpoint}`, data); + } + + protected delete(endpoint: string): Observable { + return this.http.delete(`${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; + } +} \ No newline at end of file diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 9ffc62d..043b233 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -1,6 +1,7 @@ +// src/app/core/interceptors/auth.interceptor.ts import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpErrorResponse } from '@angular/common/http'; import { inject } from '@angular/core'; -import { AuthService } from '../services/auth.service'; +import { AuthService, LoginResponseDto } from '../services/auth.service'; import { Router } from '@angular/router'; import { catchError, switchMap, throwError } from 'rxjs'; @@ -8,21 +9,19 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { const authService = inject(AuthService); const router = inject(Router); - // On ignore les requêtes d'authentification + // Exclusion des endpoints d'authentification if (isAuthRequest(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)) { const cloned = addToken(req, token); return next(cloned).pipe( catchError((error: HttpErrorResponse) => { - if (error.status === 401) { - // Token expiré, on tente de le rafraîchir + if (error.status === 401 && !req.url.includes('/auth/refresh')) { return handle401Error(authService, router, req, next); } return throwError(() => error); @@ -33,7 +32,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { return next(req); }; -// Ajoute le token à la requête +// === FONCTIONS UTILITAIRES === + function addToken(req: HttpRequest, token: string): HttpRequest { return req.clone({ setHeaders: { @@ -42,7 +42,6 @@ function addToken(req: HttpRequest, token: string): HttpRequest { }); } -// Gère les erreurs 401 (token expiré) function handle401Error( authService: AuthService, router: Router, @@ -50,32 +49,28 @@ function handle401Error( next: HttpHandlerFn ) { return authService.refreshToken().pipe( - switchMap((response: any) => { - // Nouveau token obtenu, on relance la requête originale - const newToken = response.access_token; - const newRequest = addToken(req, newToken); + switchMap((response: LoginResponseDto) => { + const newRequest = addToken(req, response.access_token); return next(newRequest); }), catchError((refreshError) => { - // Échec du rafraîchissement, on déconnecte - authService.logout(); + authService.logout().subscribe(); router.navigate(['/auth/sign-in']); return throwError(() => refreshError); }) ); } -// Détermine si c'est une requête vers le backend API function isApiRequest(req: HttpRequest): boolean { return req.url.includes('/api/') || req.url.includes('/auth/'); } -// Liste des endpoints où le token ne doit pas être ajouté function isAuthRequest(req: HttpRequest): boolean { - const url = req.url; - return ( - url.includes('/auth/login') || - url.endsWith('/auth/refresh') || - url.endsWith('/auth/logout') - ); -} + const authEndpoints = [ + '/auth/login', + '/auth/refresh', + '/auth/logout' + ]; + + return authEndpoints.some(endpoint => req.url.includes(endpoint)); +} \ No newline at end of file diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index 1215f12..e5b370d 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -1,282 +1,371 @@ +// src/app/core/services/auth.service.ts import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Router } from '@angular/router'; import { environment } from '@environments/environment'; -import { BehaviorSubject, tap, catchError, Observable, throwError, map } from 'rxjs'; -import { jwtDecode } from "jwt-decode"; +import { BehaviorSubject, Observable, throwError, tap, catchError, map, of } from 'rxjs'; -interface DecodedToken { - exp: number; - iat?: number; - sub?: string; - preferred_username?: string; - email?: string; - given_name?: string; - family_name?: string; - resource_access?: { - [key: string]: { - roles: string[]; - }; - }; +// Interfaces pour les DTOs de l'API +export interface LoginDto { + username: string; + password: string; } -export interface AuthResponse { +export interface RefreshTokenDto { + refresh_token: string; +} + +export interface LoginResponseDto { access_token: string; - expires_in?: number; - refresh_token?: string; - token_type?: string; + refresh_token: string; + expires_in: number; + 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 { - private readonly tokenKey = 'access_token'; - private readonly refreshTokenKey = 'refresh_token'; private readonly http = inject(HttpClient); private readonly router = inject(Router); + + private readonly tokenKey = 'access_token'; + private readonly refreshTokenKey = 'refresh_token'; + private authState$ = new BehaviorSubject(this.isAuthenticated()); + private userProfile$ = new BehaviorSubject(null); + private initialized$ = new BehaviorSubject(false); - private userRoles$ = new BehaviorSubject(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[] { - const token = this.getToken(); - if (!token) return []; + async initialize(): Promise { + console.log('🔄 Initialisation du service d\'authentification...'); try { - const decoded: DecodedToken = jwtDecode(token); + const token = this.getAccessToken(); - // Priorité 2: Rôles du client (resource_access) - // Prendre tous les rôles de tous les clients - if (decoded.resource_access) { - const allClientRoles: string[] = []; - - Object.values(decoded.resource_access).forEach(client => { - if (client?.roles) { - allClientRoles.push(...client.roles); - } - }); - - // Retourner les rôles uniques - return [...new Set(allClientRoles)]; + if (!token) { + console.log('🔍 Aucun token trouvé, utilisateur non authentifié'); + this.initialized$.next(true); + return false; } + + 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 []; - } catch { - return []; + this.authState$.next(true); + this.initialized$.next(true); + + 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[] { - return this.userRoles$.value; + private async tryRefreshToken(): Promise { + 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 { - return this.userRoles$.asObservable(); + getInitializedState(): Observable { + return this.initialized$.asObservable(); } + // === MÉTHODES EXISTANTES AVEC AMÉLIORATIONS === + /** - * Vérifications rapides par rôle + * Connexion utilisateur */ - isAdmin(): boolean { - return this.getCurrentUserRoles().includes('admin'); - } - - 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 { - 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 { - return this.http.post( - `${environment.iamApiUrl}/auth/login`, - { username, password } + login(credentials: LoginDto): Observable { + return this.http.post( + `${environment.iamApiUrl}/auth/login`, + credentials ).pipe( tap(response => { - this.handleLoginResponse(response); + this.handleLoginSuccess(response); + this.loadUserProfile().subscribe(); // Charger le profil après connexion }), - catchError((error: HttpErrorResponse) => { - console.error('Login failed:', error); - return throwError(() => this.getErrorMessage(error)); - }) + catchError(error => this.handleLoginError(error)) ); } /** - * Rafraîchit le token d'accès + * Rafraîchissement du token */ - refreshToken(): Observable { - const refreshToken = localStorage.getItem(this.refreshTokenKey); - + refreshToken(): Observable { + const refreshToken = this.getRefreshToken(); + if (!refreshToken) { - return throwError(() => 'No refresh token available'); + return throwError(() => new Error('No refresh token available')); } - return this.http.post( + return this.http.post( `${environment.iamApiUrl}/auth/refresh`, { refresh_token: refreshToken } ).pipe( tap(response => { - this.handleLoginResponse(response); + this.handleLoginSuccess(response); + console.log('🔄 Token rafraîchi avec succès'); }), - catchError((error: HttpErrorResponse) => { - console.error('Token refresh failed:', error); - this.clearSession(); + catchError(error => { + console.error('❌ Échec du rafraîchissement du token:', error); + this.clearAuthData(); return throwError(() => error); }) ); } /** - * Déconnecte l'utilisateur + * Déconnexion utilisateur */ - logout(redirect = true): void { - this.clearSession(redirect); - - // Appel API optionnel (ne pas bloquer dessus) - this.http.post(`${environment.iamApiUrl}/auth/logout`, {}).subscribe({ - error: () => {} // Ignorer silencieusement les erreurs de logout - }); + logout(): Observable { + return this.http.post( + `${environment.iamApiUrl}/auth/logout`, + {} + ).pipe( + 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); - localStorage.removeItem(this.refreshTokenKey); - this.authState$.next(false); - this.userRoles$.next([]); - - if (redirect) { - this.router.navigate(['/auth/sign-in']); - } + /** + * Chargement du profil utilisateur + */ + loadUserProfile(): Observable { + return this.http.get( + `${environment.iamApiUrl}/auth/profile` + ).pipe( + 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 { - if (response?.access_token) { + /** + * Gestion de la connexion réussie + */ + private handleLoginSuccess(response: LoginResponseDto): void { + if (response.access_token) { localStorage.setItem(this.tokenKey, response.access_token); if (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); + 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 { + return this.http.get( + `${environment.iamApiUrl}/auth/validate` + ); + } + + // === OBSERVABLES POUR COMPOSANTS === + + getAuthState(): Observable { + return this.authState$.asObservable(); + } + + getUserProfile(): Observable { + 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 { + return this.authState$.asObservable(); +} + +/** + * Récupère le profil utilisateur + */ +getProfile(): Observable { + 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); } - /** - * Vérifie si le token est expiré - */ - isTokenExpired(token: string): boolean { + getRefreshToken(): string | null { + return localStorage.getItem(this.refreshTokenKey); + } + + // === METHODES PRIVEES === + + private handleLoginError(error: HttpErrorResponse): Observable { + 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 { - const decoded: DecodedToken = jwtDecode(token); - const now = Math.floor(Date.now() / 1000); - return decoded.exp < (now + 60); // Marge de sécurité de 60 secondes + const payload = JSON.parse(atob(token.split('.')[1])); + const expiry = payload.exp; + return (Math.floor((new Date).getTime() / 1000)) >= expiry; } catch { 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 { - return this.http.get(`${environment.iamApiUrl}/users/profile/me`).pipe( - catchError(error => { - return throwError(() => error); - }) - ); - } } \ No newline at end of file diff --git a/src/app/core/services/menu.service.ts b/src/app/core/services/menu.service.ts index 7858706..46b6cbd 100644 --- a/src/app/core/services/menu.service.ts +++ b/src/app/core/services/menu.service.ts @@ -77,9 +77,9 @@ export class MenuService { url: '/transactions', }, { - label: 'Gestions Marchant', + label: 'Gestions Merchants/Partenaires', icon: 'lucideStore', - url: '/merchants' + url: '/merchant-partners' }, { label: 'Opérateurs', diff --git a/src/app/core/services/permissions.service.ts b/src/app/core/services/permissions.service.ts index b5eca1b..6540041 100644 --- a/src/app/core/services/permissions.service.ts +++ b/src/app/core/services/permissions.service.ts @@ -12,88 +12,150 @@ export class PermissionsService { // 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 { 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', - roles: ['admin', 'merchant', 'support'], + module: 'merchant-partners', + roles: [ + 'dcb-admin', + 'dcb-partner', + 'dcb-support', + 'dcb-partner-admin', + 'dcb-partner-manager', + 'dcb-partner-support'], }, // Operators (Admin only) { module: 'operators', - roles: ['admin'], + roles: ['dcb-admin'], children: { - 'config': ['admin'], - 'stats': ['admin'] + 'config': ['dcb-admin'], + 'stats': ['dcb-admin'] } }, // Webhooks { module: 'webhooks', - roles: ['admin', 'merchant'], + roles: ['dcb-admin', 'dcb-partner'], children: { - 'history': ['admin', 'merchant'], - 'status': ['admin', 'merchant'], - 'retry': ['admin'] + 'history': ['dcb-admin', 'dcb-partner'], + 'status': ['dcb-admin', 'dcb-partner'], + 'retry': ['dcb-admin'] } }, // Users (Admin only) { module: 'users', - roles: ['admin'] + roles: ['dcb-admin', 'dcb-support'] }, // Support (All authenticated users) { 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) { module: 'integrations', - roles: ['admin'] + roles: ['dcb-admin'] }, // Support (All authenticated users) { 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) { 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) { 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) { 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) { module: 'about', - roles: ['admin', 'merchant', 'support'] + roles: [ + 'dcb-admin', + 'dcb-partner', + 'dcb-support', + 'dcb-partner-admin', + 'dcb-partner-manager', + 'dcb-partner-support' + ] } ]; diff --git a/src/app/core/services/role-management.service.ts b/src/app/core/services/role-management.service.ts new file mode 100644 index 0000000..d743216 --- /dev/null +++ b/src/app/core/services/role-management.service.ts @@ -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(null); + private currentUserRole$ = new BehaviorSubject(null); + + /** + * Charge les rôles disponibles depuis l'API et les enrichit avec les permissions + */ + loadAvailableRoles(): Observable { + 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 { + 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 { + 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 { + 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); + } +} \ No newline at end of file diff --git a/src/app/core/services/role.service.ts b/src/app/core/services/role.service.ts new file mode 100644 index 0000000..8ddc090 --- /dev/null +++ b/src/app/core/services/role.service.ts @@ -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(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 { + 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 []; + } + } +} \ No newline at end of file diff --git a/src/app/layouts/components/data.ts b/src/app/layouts/components/data.ts index 047149f..2944a9e 100644 --- a/src/app/layouts/components/data.ts +++ b/src/app/layouts/components/data.ts @@ -66,9 +66,9 @@ export const menuItems: MenuItemType[] = [ url: '/transactions', }, { - label: 'Gestions Marchant', + label: 'Gestions Merchants/Partners', icon: 'lucideStore', - url: '/merchants' + url: '/merchant-partners' }, { label: 'Opérateurs', diff --git a/src/app/modules.zip b/src/app/modules.zip new file mode 100644 index 0000000000000000000000000000000000000000..770058d75b3263e4445471d679962b938b0c17c9 GIT binary patch literal 155941 zcmaI719)xAwk;exS+TWZ+gh=0+cs8g+qP}nPF8H&cJi{%x&J$R-*@jnzFA|AZ_Zv- zqpEuKUaeOvSqWeeD1g5%=0Gite?9!y4dVOT%Er*i(#U~U_Ww%hTdMy<3hr-Gdipj_ zj{lNH{{9sLz(+z=k`Jnn4nAQ0^vR4q?7R! zS#WeKBmFB|!3iKR#V=I5*I)|GW6av?SQnWwe^#J%jSYvP3boaNlk*jOvPTt)>Q}&O z*Z)2aX#dY!IvCkIn;HDq;QgmFOCjjxZN7#5zZ2>Ip6TBs^Iv6rcdXC^JzO6hjPMS3 z@pE$n5+{|>`2?i=Qcez26?SnM^1&qp>f=*q%SB#L5PNqj6;T@Y%o(S7JM47ZT5bC|eC?OO!9sm+ z>!K*ou&C;s=sX777T%}poMjmb{?3tTjj5zCt5l@awij$ZSX)DuOGh^?Lq%$d!E$8j zEfIZW=^|b(J;{N9{{^Hwo)~+b1V1_V zrvsp8Z`5)-ykqr;3f>nrS_ApHq+aGIJ0Zr)*T;+$ayQD}BV7y4)_=MS;=2#NoBw|= zr0=KyZx;X6d|D%WdmH<|b=snzh6dVeVmK`b001>K007?qoZ(;DsF~=PzB{f``7CUY z1^F|(m`zG8u7@l+)zNv>u&>D|H?WXu&MKD*m{20DvWmJjE`tr$*E58N_v_F*JI5`a zM=Tnf6wBn?ISF+)XWWGcCo_FY7oZQ_YQ&Z8k}e=F#z)5YG9NL)y-WwM$t@GL8K7zutj5o^*7`%lf}`OFjp$X zqzY1?qXhcVyOfCDXhi9%a9^pB@UV$gWaf9vH+lft36*GBCCxB!&e)|gw*hZq2#;}R zZ!*Auc>-zlc%KLhvnj5+oG47h;Fx`ojr^Ywqj8wTO2@$*g_3P37_fi_;|@j-)q zZ{QyaN3CFU{!Znp>rKgV?WRDqL1tdSu`zo4V`~0gN|DfvCeElwh0rvPS;G$9Z~WJXNcGzD1?gne!hQE3-JJO*~K&@jgeuS|tibZil{dybZtrUP^6Jc9O)miBzK zf5(}0a*rB&7kT$ZAAzpg`dY>GZ5|iZ33IszwMEg)z3(YYTMD*nOY4TLo587kP6q&9 zz|rQsKZuZu@T@npbS8~>Dm1eG>bR}WqinYg`crXHe=c| zZZZJbz3_kq{HZOrPofhPF>u{Hm( z)0Hu`VST*tL2sU6CrzUmeC0j8Y9O4j<;wOWh$67ZRg%U+H2aKUrqer++VxnGS{*5T#h)79w zxl(=})WQA!v>L@xOTpj(Y4%5Bgdr8%Y1E*41Zf=Te-0o;&I#$t@~r2ebECKOj*6<{ ziz6e81b%lrTf$I_XQA)E=YN2(jjo^=V&5DQTDZrJVa2E@Qnz%FP$nHI^7dtVh*pRR z2RgY(qh@SINOLFi;LUFkNy;{YJ|QG@1hFQvSlUTYG1OJOIFmc4+S@zyk0RBS6Oih( z1k#Lby$W&~hK{-HyrGvkd9_Eg7~%#_`4SMz1kJ?uwAF_SrNHhbz)A?%z>w3)*td{) zh<(1Ik-@xpq%ikLm=nNCm~Sg9>-vL1riSbUHfC-p%9@pd2$QR_r>B4s_F|@mh7I`> zOpg7$Fe&*w+35usSOjzp5NFT>w&y{t(v8riN;*eH>teQ}tc z9851Q9Yu|(9FN-vfEgY$`gG*(otzY=V7Kk+priq)Q|#`% zZcZn!x0hf3U!NI7Z7P={utQfec{IV+h!2ln$ztbfY{QQV( z5}q|80pZ5&28^z6sClN2zZszaY^3hJ#q|685H#f%LAj4)04a%6kd-Ws#swwV4Sfkx zO{3n$5by#1N}#5Uy_u(_%Y2CPw=8M>|=Yo%;^wN|JFwJ#q03*Ofj~1<+VA#AC>@K z%r*g9$1YX;GX*j$A4bxO{NA+Sy;%_j9HKQh*{;I}RLwk*zEJx_YTSpybM*5h;_2|= z+|zWU~HpgvEj7^=R3{P1Igge|!Bt(4e81fNAE>a_NBfb$3K0%!zUCdCX8z`FCt zc1Z1vCePk`s@FmLMJ+2*i;90~Ldh>yDZf;dIXeZEL+c3FbzjH9t8ooNM@@`&b!O+a z$joBga{5c6p+BXJgYq2KDDWwRB6LG*ra*UdmHH2mn;$8BDwd&DxH^LMToScP^W};p z6w%r?PU~|fXAs2?4QlM2*Rt<8T#h0(@An!qdu^3YR&NsJbXmPM$|65d^44;ujUWGv z)3vz)fzBaseqn7cpJ+;7EGh}Pw6yU?JD{!bj6Jv_?s{hFN!sRs`4ja6701#?%Oa?k z;&iR%cE!bMjtT6k2@E)qOgO_ZW3Q6R=Mgo|#nRa@ z%B{yLJA7Mx?uqS%-x!BQ1Fo;5r&sIT~}fxix-vQjc&margqSVDI$U(+=ptesD-2$H6$3t2#^~aapSQicau|EmCaAGy0nqV zS+Z=SRz_iRZ90nIDB>aW5Q69kP0mIJU(AcsnD&j?s3X2+A~0!3U}eLUlcA1~x=33k)S9+%O&vWX7epn%xlm+)xg@ImeAzRo|6neM^;EK?oK3vtta8(01kTk4b? z{gJ|$p`+q`R@(5O!OatCc)S~X{g&@ADSUjYM#bN$X^7u*SV69jSH-vIhk7q;oUss~ zV-wbhqNRAPC7mk;pVw?E@IoHpCjyKUek*Xl6-5S;`p%*~LNFTy7K@{?Z=M8nP!`Qs zK#Kc>oWGp;RggEzrNcRbM}dGUxkMo1lm-=1$N@-|$&TQG?4iISBBEwEN#7pI$Tb1} z3)%AKWPw}QaQgBQ?zx>mx>$v7jI4k(-O8L&*Oh{axR2anr?Qh-CkMqX)U`we3VEXf z9nyZ0^buVWftR=Ag5>=Z$3d_Na~vlX1yAzs&&rrJKw^P_-T@NQ$+PjOl7;pIZ4>tX zx@z1k{}BMe{Txb%9WXo6+-u1y3C70|47Z8jvU9e_G}I2)fxv@8bF8J;fe)$%y^y*RSwnF0|)2d)8eb5I*92Wpufy%d_2B0|3p zC%Y&4gS|s+CLw5%kh=+9%XKhB=+e>Z&X$aDh<_nhX+ap&MF{$h1z)M-po%OXwCEMk z7gVhY?%a)Qz*oTn<#b1)8_cP(KcYrl$oT_CTRXaV?Je%Llmd&a`0gB(FUEP??lU zoM|Uo-Bp05V`jPq=Dn!9Z`^rm+a`N97rO32#K>4VZdJ+w`;Gi*BpGpHT|v_4IN zBgMc$7}A`ZwL$^joPyP71G2KDf;~RB0Tf#^vA#Y?m7Lnq0iIBo&DBPR9+_Z(3mnzE z1tRQ%t9X%2L7DP369IJjR$9JFyZ;ibUZX&uw&9-&p=JhtSVQ*pOF;CHD$NjXab%X!ct|Lf5eU~lLyqC|I!hw^7qs5UO#1&{L z`RY8!NLA%ck?6%h44e z-m;JnkT7WSapi1cwb{h)xZo8w7FA0Kb@FPU8h1}vYd>oQpHV{^lB2JUyvq#zl8_h* zlJ)f5vj)~*yco(#5^kRDe23+$uv1~o@##)^vA5P2hxIjm+ilx~jYF4kp*KIW41IfU zP!nh)q7?GsV{Jq8s7hQOc(rrUPzk1l@XO(+{j8mWg&c5CD2@F9=U4JxtC(xDl(#4? z(pkwk-ZEgv3xP&I5T0~^I41Z4JG9g`Xc%Bg?n^CoZrZkrGWywvOxkzMDG*XbQIf*S)Sj6ttCFUAQV=JF^RU zOQq)N1@h+iJ;OG9&fVE#A`RjBBbobVr6iyj7zR|pubQ%K!r2lA-!Wav9f-Qw=5V1B zr&=MEZ@qT3TH~)bWRWx4*uFKpkI^d_U*B7#bhM|UeZ`usy;TQDenbl8SUEj;pYb5q;F;Hl_CY9(D7Og@jF)Rk?9GITnN z`$)Hr!luoG9o@!w!@BTeje%Yg3LP;^UqllUo{a87SlRn1syJ^Nq%bVBYl+&U0@hJj z0Cm|UuA_#f$F9uIwzBiGp#dsyW2EA#8SJC9XM3;K)4`2Z8ut>&G3beXqoRH4~CpLsDVfGpDO>_d%SvA&Z=vbmgc#KUb zhHRsMc<9HQrcPKUtDifawau=C9V+$ZfeW>>Hq*}aHT?PAY;rJd_wUVn%l)sa34{iq zmVlg!Jp`OswjD>-n42N(oNmp|(TzSE=JsF2nsM-5?!Y#Q3A2?TTjDmt)&s6a5f#0b zLzK|Jh?L2zoqvd9TBv^9oQJT+|BNpUV$7p?@R6^VLyas}=WZ1vZCvXP2b*><_41l%v3BDfGaug*b(2lTAdQk%)}VQ?^=_%vncnHRmP-@V-8eRu2?Z;!>VQ z42;Y0PNr(mBGJ5r@cwx6c1?z*(I~78cVj4IGOSmd9GI}b&{rO%DbBM2+ZMQEOktFR z(Xz9=ixV~9hsuZIvZ%C}aX6n-XTV;4i5{lUnlx_>ez8|LX+UG#qfR51F{n`WpwT3C z%_K5Kb5Zt~Q4|MqDK(d;w4(56zzVdK)_9mL@-SySRBYZR^pa|_a5S6~s9*7uc$i?| z&MZ8%57eD565oK&TKluQ4wJG;4W;sSm3JJD#>|QK&WxAcoZIeXRJ-_8%=;aSVL2;H zGy#!BNS@)?-nX8Q0izva8URfWkiWjsWVy<)SRiTq0{#zPd89s3Zy-bW&;SMiNd4YQ zCHWg(`MVpow{iL2vZFRMGS+jlbo^_RYf#zBW{wX2b5qBT0TI}&RIUA@-;_`n2_lr$ zcN-Xjp43^vLK@rBM1%RF+hIy0o(2mGb2YeZYQy2^?(X@(FwJ6b8wid$iXoQ0B_ME? z+_F4))uGOjrWrheq)$kl^9*Kucm(i( zqsh^Rs}R~sUP2}{E`z<4@zvA;r(p6VrB~45mQtRIQ*F&)up$#_S}DiS8R&vc^;ChX z3tQb+d1|8Um5PA|CQ9(E1*6abRiYV+#Jz@1K$U{9^0_VKIvv@>HsH)$s$QLUL^Vv2 z1C?A)8&h-3B$W!s3@Vz4$}$HLsKF??&UQY6TVz`Zl3-Fr3c1LaD5c2{KW2Px2+IPc zfNS}p1tCY32wAs_T}?q-;?1&{dPD|2K^3tpX{gN}lu6P#n#F8yc*EF-x*csy zR%bdUyPa@35AW?x-fw&mkQtuA3nl;eCQV zBt#yshVC~HVb5#fuJS)xw42oX0)Q`4hE_hQu`t#ASXda4xRqe#TOk-TEycG=nO_ud zpEyy&j@Pc(y9Y1$qz1pc!F7LwlQ@H?KJQ*n;^gK1o^k(~7^+#{p!J8w#9;1peeV>N z&vyII>ryZ)8t`fD{A$>ZD_1^>E1iHVtgdkR{8xA@HxTfIXYz}`Wp1bYIzMMT4=j$0 z(xUtT={~BRyWPjkJeC_ysdr{DSra=L*0oOw7|B3!iN z%auyPlqrhaK zUQarF$HZ8&y`gca0y9uJae&Y>c|w2me)Ri{p;{ru3i-yRxA1*E-2@Yjqmqj^5sh7H zXM}Lwa&C&?I^EOrjYGtPbogLNdPD1__~7XZUn6FG`k-#5NG_~{IPYAn=x5Mc zGEABJ#T(babMnexSdp7Yo}af2K=?s`86+v1{*0>wSv1@Cc7EA@ zLTfL_SSeuI*xmIIUUdLxJz2WmnPQgXl6Jb_4sVf-r8G`Jhm!kYpSDc{1>9N}A+otW z={-4QEd7RNck<(%qZjwi6XDMAL&*HluNoK)_yap^c)IN5k-if7bn3)~`r_jpc|p@H z4Md2+lmOQ1;0zYUO*EmGyTyliDR#Zq zzBoBAd8>zb`-tFb@BUB=2h2FC8b$$x=FrN9rLjvY%k&Sv^YZkCpt>nd;yZFT4h2~lh;XdU;1 z#1TWCNbrqYTeRC?v^JAHk^tTVQiSd?U z5G=0vbZH4mvne>e(N%-u7_oyK+$FLoIem1bYr4-SJQADN7819YaU~;dB$wJl;D#jW zA$krXg8#Abv5X~F)h3UY1$Co*iukjTtJqbnj6fNuO-rElt zf16Tqsp@IjilS=WHGXOI^!T(0B;vnjV&?5J=RViYSuMDE-dt%b3N0bUEO_F)oB0Vg zSmaqX^n)g7bWxxv z8=dbu~Dv*&&Ht({;Tv&CthvrQ&!wRr*Qfbf~ z>3%;JKJd=h{=Ru;3PK9glC|>6j{ZfPE!Z$eal`%~f=&tcBJ5P#jUZZpD1-|$FF4iT zvuXdzE6!9jAfy-p8bt;f?5I0HI|wLWeOs6?pKyChHAN9=(=@{o<~_F-N%A^wyknKJmAB$e{MWsKtkUdl;qIG_fpb^_XB z_^vaq2ns@U>lx7QWxs7T)U`YA;&DzW)^CZ`xeWX-?FFn`g%a&JX?db2g#>);L&!g2 ztP2i z%DG+dvw0?c4M?bt-thmNK@2zdNLKP)bELDXyi@onddHQg9IUBlOeQ6{ef6l0NrKo= zEQYkAjX9kgbyQcOg3hJ(PCTcV-UxCkY#|>##meJ#5>e!U1RKSDf})mPvWIBfXw1ms zf*F+@8mn$(4XMC;fgBBieQ$!|jDQV!QNJ738cmv+Q&X2zVfsPtpQRC$T66lLrrlsW!N z34BelZ3+LyFjY~VLCVnA%17pmKuUzGXhmL~0=Cd&p_e83hFMfth#)aZma$ZqIcL() z4>m^lR@Hyt6KQ~@=WA=d6c5Fi{vM4h8$9tSE_g67kzBle7`gQ%P8Qzkrc?;;WiGJr zD=KXaXc4;_(Rn)?H1b4q<(?=N_Y&A?6>yzJOiY?yj);VenL5H+y5@Ww!4Eh~nYi*E z;uggFsv@I1fD*buN}Zv7E^Xz&`|U_URqxz6$aZBFejfu)C(Cw8pvBw@Lgn@+6@(?n ziOk~~W>cZ#A!hqMH2e5OUC;GsAhl&ACRG?#6Y!sx)@=Dj{|0bL=Tm2Sz9cqf28 zi|a(5k7p;4?3HdMcwp-H$nq_L*hX~!WW4;Z|K^6jazB6fg8=~CGXImx^tV;AfvL@3 zyg2iJxn7M8hcy-$?6|5QXjk0OADz8Aur-M}wNu%RW=)M+8NiLE{SA;LZrZ={c$kGIgo5^hxm_po)W_uV#&>K*pjbl;(S3D-+ zCQeobw)3ysdT;TTK&Q^Q*ngc~>F5hJNCT>c36);=Z(^d1>z zh#qNdvu(FFT$B|WSG*h zmag)Up6%+0kXhtPp8`RN|M-ZRWs z0a6mcNqr{JNRSg3xdKIswaeu!usRfZ6r)bm1M6H07tE3xh|tqKZ+93{(s+ZpFfW-= zc(^yuQ-z6b;1jX3k@*Pe9r&1S)wOU2b+k7Gb`&l*3sjIqprJpl(H&7507Z)%gGkQ# zTKn@B6*c*|Xn+V&yTaUsatAUBO$?Bz9uT>Arsf_&TqbbPnL5Tr$6~VvS0N-n2?P0f zFJ-LQ@I5o=7wqzf9a4ygJZz#kO!LgEs`u`ECLT^vUPly!oFHE3H(uiG9t?vYKpsbt z#A>edk3!Y_ayEt~<8btX&j+H~WA?c?Rmu4pJCXKa6CqLU)j%RFiHMbZ-xGjdzSl27@M=qd?E5<$>PYQ+h0BtWF;#~V@!uh{a0u4n1z zNfAs|u{o)}9_Z!VbSu7b0?oPFx-s=w&9?b@1Crtv3 zhk%TZfOjpO z6O6U5w6Iz4;OA;isP;SV?y4ks#iA+^)b=Xxe#%bW=)>g_ftO9i zas!q28n&ZF`gt2BjPtWD;1%Kayc6si1A*gAFsoA~-o4QiPf2ZmiKF{HJsfF2u@ck6 zdxkUgQF|~R5=F;(Q7%&%9kzCnZ)G(GHEJ_Q{16QS6MuU1tRf>SC*THz&}O~D=n*Cu z-R-gKXY5n9GU5t|y`PgKxd!ty&tY4eEVi3aKG=O{R3H~8v!30$rpWbL>d#J=!RnR7 zW@b)vD8xI3TmIN*wW5aj=EM<}6h`6>Q3-3+&G`WKL7&+PlDpCX&c_85g|foC&LJq$ zk6?(4^+Wa6CU(jD%}Uxtz+nNTUqK67w%CyTG09sL{$2;EQA~&GIC=Hd`>7U2BM96A ztGUxjfYvrOyZk4)7a__B)2g~`4ANw*T9d>EX3gwOlgBwQ(pc0FNnlvzS*x=}=8uie zi1wYxhMj)bX$RX6VZBaJtxgJau!$fRim^-OKZ|=8#(7{hi;vrtcWSIVTfD}+BN^;2 z{#*`S#Z@yDnZ8a=lXbMXR&Q)pEl)OC98s)vdHHF|+lMK{DY-A6zF2sWReBxEW`xD* zO32_P@!?4g+}E9nI2`^^6uM@qe&|8{tutMH)K-0s;bY`>Lqh31zBC*ceyhYj*wN_u zvqYS#G*(ZA_AWU9TcYRWz;wQ(rRNvF?TenlSTj3XXQ>%K@el1?%WxT#rKh^~y&<4; zuj;)aC#b=*q_E2kyFG$9m*L3F??1jj>9N1Do2zBswUhYI; z`J&0yVUWlC(CZV{urp0OuuOemoM>{f9Xp5z1v&Q_JA0^euC}>gU0?R6GMXQzSW5O7 zL&wv;;jW4s?MCp(**fF0;$QJ_=ET{~8A)N$eH7xxh#)_VX5}c6_L7=%l7(WGDZy47 zUI)_@c0Y{U0gOf*jZd1^SP?@4%Q$~P{~=)JzR1)U7>)D;$7NSUxah3@g*Z-;P}uO> zsuzc6W9?78=Z%&%zE@83_Pq%0dH*v?M5H8haRLeeu>DQ8C;VSvsFQ=EjTNep1lZ6SnG4Y;dg9+B!7W_r+!GUj(~zdrfG!yY(bOVZ z@ED>tq<^jevKet9c-N7I+v#w34U#47BeITMm1q{S{h8llKom(~E$MbhDolIC9VFt9 zvzxuLRL!=iOmuS&@L*)Lk&jo67G<_sD|v&?tsCl1Uc>_48y;5aLP)|JwEM+ z#4b0b;{j1Aee9DIO>*V0(x=kAs8pf~qN}gEcYyv-*IdoBuNowl{(~;Y6%c(2sasIF zukBC7Fl1n29^uEV9uQ1-Dj5-iI)(-yfnA*9j_4hW61n%XtR|ismU~581A`vVZ=V3C zSn?hc*!K$qdu91GxzOuN@1jMzRFE)tLMF5UqZBJx5QrmHfwwOnjfc(DDsvFSSU2C zSdcH`#bIjYsM)YKL3+oPSC^*4b;n01Xi?-qezZ0YQhQh9;s z)Gh~bsJds_%>=SQkP^}QujRP52dSv9Nr+*&$dU`G%GMK0RRSYz{;1LR+_cSypgrWdkyNhV&->5GvDHRc zr!4f%I8L0~aJ-$m0v(30Q}oU}rOpWS9=5LYhJ>@sfY#U6KJ;sSs(rP5;LANGxp@Oe zK@jusYjxb3b8YQ^_^ER>Q2fcGhlKAWLy-1`R0A?V%HWi(+-yP`oXxudLomtG10P9- zM0FvOBTDf=27SRJIe?ej;j+&+)uYul&J;HkyaZLOc-?_3UO)F8o03LSDUKAPOhpAp znjDv=V09NszF!e3f|bvsvkqsw9dL7v^|ca0lD(ZMHYi6A;No7_W&-CTs&!9!B2&z% z+7dYPBW@*(ro9umCCUaMc?NDBky-e0Q`f=2>B=u%_)NbvO7jyXlrSV@1`iJ*C46dG zj=PVks+X5^Wt9zE_98fkc9@pm! z3m%G&*l&q*%29WL&&IaPu($SR;e$;t)jK%2Oe@XNGCBZLL_ zy5T~T{ zlm2H+cD6(3v>v zio`Juh2n~!GAePaO4j3({G2FvBa66Ov2bxvUOfYtH{tmagw;PMJ*zaY-IG$ry(-7l zR%6`W`BRfY$!lw(bU!YHPm45y+G(=X_a))XYrJ1RiZ_Uk68PxUhJq2M(YxN!K9xW=OpsSVgF(YeNO|4g2=IW>krfA##PYe~6uwc2rn<S$9JVQCJ2vJ3Sn*p6{O6ri;unNq;t>mexL zajFeo~l7e!;)N{u#}B2_KQcJHp*}`bnmCc`Mv;l zsK@09C(pE4o?u=IH0ZDGm@~qlR6mOp2wdL}xLPy**y|xp-F9Y?PieSV2=N-W$DzQ+ zd&J!+C-2eirIVYKdMscTr%>yd@TlmPkbTG>h+${(DJfG}w$R%l1Ss)0E;m^aT*~`E zoNdgg?|vZ9MMyHT{E3e0f#d6Yg`DJc&gBo*xL=fe8v12g_B$4?*vT=4KLhS}99L|s zou^@#(yIWC)f{dO*$#9wgJk6`0`pa$bn&JhFnWAbC9-s61?9ME4vSDDTy331V-tWW zojDij8fTROTLBDqF?naz8%&4`;x~Z5d(okgsrRnfSyH* za?|wg&UhzGR3#Ciwq`)%4EXdxeWS!5p}coviJ(;A!COImaz(3i`3b`Y^+IStOtPgG zD+o$N+q9rN#t2+X8bY$P2ldXPgmCO~Urs5k<>e}_`~D=sfM)m3U#PsIX*!(#DSa~r z%;VeHyfz^keeQvNGS8Gao-1tyeD+hSywIHh<_b{2rOZ1UUeP_u4k-5&AR#0z@K}C1 zd06`Zkj8$dB*EVn6rYlTjR~0?{@F$^?KAlrip`YS!H20j1m`c<*>wuhj1w1%D zr((dWI>QyqA=YKM!VH&GbyCC)D=n%b$wACm111u{R_$Ml{37I&V$yMXaAS*FOw&_ZWKs3H9_{i^B}X(~>_UX3xob17 zV;7`Fq8?EhY-`4q46+0lCkKirY|#$?N*_P_A@)seS-< zgRUFaR>7M&-znTBDQaj}3xVG>saAFHqO5~7Ak_zezEacCEDEm?LS)6`9n4gG&AC1E z(Dx+^a)xDO7(* z4l8Xfu6Sd~*&5qDmmL}a9?$EUZH#+h{MPly`TfK3n?z7j=OZbk5z9m+Z;)b!;cy_e zqAn@pMcbcTWG4>162Rj#FG41^h9HXaLJ3goX?}RRsS}hl)$5`$nzg{LX@YtPTZUyb zZCn+#)6uU7pIwj&J|>F(rkqqUu}v$f%)6qWdt$;G)rp(pT8mW+ZJ3U%ck}x*?AJ)% zZriqt6(Jou6`sDK*R7x2CE*+DXk@sF9PxcRuL$zP=`)UhuKr6Fs3hX{nLzg)UTy-2 zC=EAaQhu?CFq`$8H?BCg^T%X2%Cx#pQU_*xtzb$$5q8%xhkWM2_@4CyQ8+6r+w+ae=e_ zb|TMDW~n1#AbBl0Do?g0lcjcQ4>KP1NkoS`GxOKUi+2M$bf1m09$H8Td{4eY&O0=f z@iyoMYUb(gFM3bqR-`u1U;gu55p=4Nb2WX_PG>s*gjF*r+3jK9j?(FS^PTvAc9zDL zHhPX`)+W@Jdiq9|e*volWi5*}I%LnM>Xv6>ta*p_M0$RGphAB>5$MU~essZF8-4l} zhPqp!WACj-Ba_hXIBPx1^U)-aOYSxik=p|~g2W)|bgrzAy-+_+po((^>! z(Lm_2&G5P~;;vQ-hR6^k4e&^qfY1$RL7`2nK;bT;g&11K<5#q!h%9@Ox1p1Y1T;ny zVbx+76MKsUvGqO3vFTkl*SdL@;(ECJHRtv&04wO{oIv?=IyOC*CCf%iblsZ~_JCC3 z$t34|)2Ee1HTBuY-g2kf!N)MhVM~5Oj_(=fTFf<|r3cIpS;ji|AAQ_+MKs1m(&55f z#C<3XIj$weuxt*?h5;Y7T?Es^KrS~!2>!|O%+^%=Kt)= zg30AE{9(PRYZcOd{ds^<;kC4yo6d+&$HPAF~-eW$!?ilk^N6)UT8DI-uY&} z3x3l`{(c1SZ)+7ZYg;ErYGX4aOT&L@vig?H9vi&Zl@9$Fw9B8M5Uoc?ex_Xz{$R)9 zD!5B+Y0@1MYl}()kx*!jpPx@SoN)WW=>@Va*2&=N!&?!AFOjKe zWUfADBuLAgKlPioiJ%EmXFF#JZ*82#>so*$=I|*B?{s_LA>Jr~lIz1o!$<+??=W~q zDtBi`_m+QcL<885nH3m1gSeNhnhl{x;Z*TR*xj6fLITq)MjfLik z*!olZ7*zJ9frODu4{ru8LM+{vGpO(UPL-!+vPBMAp%%>8e_0;i&jeThlRe_9MsS)U zYM(v*uG3L`nS_{PHuBS}qq;XE4Gs$)#Eu~syNChHoqZ}xVGpUAA|x}^##UPZ6TgFj zU-!@x8(k52zJ`Lg{x|GLqg6>QH1ey%Ir)7xIF<97<@sPvsigf6zpR@Je-zxT5vrop zYD1_;3&~sd$e(JjcpWtwt_C}H;Q1k|2@-iJDX9=_K#taLTlj(~G4%CJm4Q_KE5BhD z062eZAvpJ_uAiX+XpSRfN<{JDxcd<`z(QdMVSNQAhctIt>T6!U;fk3oJ4vvG)3(X; zKSu}6FE@^&{8A6br>aup!sNx%ZM6|hJJwa<7wtyOB>a%15tQ89DgCJC--bmTz9ZPk zSkAH4sL5uyOmUzloj>e`zCSJqeq9-( zo~|GJi3FT!F$j>~CZLHQ5W)yX9ms&(Y|(814KcwrkLC886c(_-KAf!Epxncl=GRVC zDf_X-hw*edN=y~ZeuClby3-SR89X!1vIz4d#-_e{*fn+@_HWsENtF1$BxY0Wtf|5Y z@(i`yy&M3?(baora=gNhYk3}#L3Yxzyn;ndfk~4zAw@EVd=w)%dI0c0z8c*3cCu_! zvj%6Pa89p9Y>&?b`t*%p-|A0IfbO|)5XJxSOB3698BLQMYx1kLszD#;GxwMF-q zmbFXPW!tuGqpQp8vTfV8Z5v&-ZQHipWqPXc4Si0(I;9q(#1NV>&l!q8PT_wrBPtAn&>a7&x~W9Nlu(R7dbhBO$;Yh zrLd45(;t(0)-Dm6V6alHXf z*U7VnMwYk}Gj2g)-fe2&mH82zQ!cobE>9^ZH<2=<{?rsuG#Q=ql@-YixzYY3C5e0D zwo~?R5J*&LxTt>alzTnU{E%E2(b1h<2?4tpjpuG6DrYV!T%E}Z71^)#$5mn~%^G&I*I#%e;qJ0~Evp9J~t1>xQZ@!18rOzP)2;Ha*}PDR;Y zh08Rz3+{+pdYAgAiC>kv9hErhTU;|U*z0ya_3mN+dxE@Dm^V%SPLKxQ+429IAWgo- zBy9gNNBkF_LkQ*+D5sG2*SEDQPZ?blgTpEXw>%kOb3qhE= zh6J)NtpQH{D8#vo=thI(ReUsX4BL*(Z0uG*S?E(tq9BS$To%`nJL~C=^m4-qfW;mr zN|E7f*8)@dy9BG94BSGYO(W~~LFQb9-7^JPx#+-+3nhK;RngXAW|4?aiiva?6e;*2}rym zP`3;G_rWpH-7LH@25C_BaR)fwPfi@FMap(OH3lT9jQ&6JWwCSP*JC@@{rP@b+uQhT zcTUohZv}03@=DdEExb)kU`%+1Nt$v1^T02oS+?~okt!bwIE=N~lRb6Nthu6jH3>`f z2{pi>^|B6Y=!{?se;O4}CPe!vRIMKFslQqgEa5^g$HwGvCECyuqbprgKQpq5gjb{D zEf1Id!l(<(W6**%lMHT5up`}<^Dc8B!T@}nZqxa=O!A;c*s^v09CXzXH`P*lK1UzA zr8x{H#d+Fxm0Vec5o5+2K{<)9VqIabU7j3O&ZO4#uu56UG+1y0g4NWVS|sy#ruhxa zUayw5t%8l`%*J)WGwX=g0If*gybrzKfcb(ZKu^SH69|kD9w@5(bD6fh^&YQqLF0~(ax&msb-G*DFec+(DHu2%YR#OGlS_cG(^&d z4w96LY?vnxjy*Tap#Cse*86tz^=bIXu-!!AR$hlz=kMwov7;*`SHPXAhS(k}w?MH4 zkh=v<5LaFLzs-D-41C!Lrlcw(Pi7Me}AT? zB92I>VvNw{$(yDSvjB*xe&4q<_S6Bh0FxOJP`%q%3$&^6x*o7Lnw=teDoM}cJ${e-TU3fC?hIl}|8X0RU*p6 z!{Z}2)m_T`r>i-prIHUyi@$Ht{9u%(Ku_I6A6c-d<$ZuYv<|{WX8%|&z9L`j2%VOZ zj+`m;;cpLHBhjaJ+>XiahmlR75(jeAmiFuSennmzUt#bb$aBt9-_vykv>EAFx| zns%Aa(F}rD4Xe`)D=M2+KK)SMY^67q&4#(6(+}+|7AVsV}VNrTtpk{~SfyE{2DLf6CjcLmseeq3a`c5wf zt1+huR!L1R-Va@#bIczl>H8BO`k}hkp&KK9+H6lMK+0sd}KD7R}ez zddE)(u31Dw?*(O9w=kzIE?LisY-v5SuF|fcU7)DeOAa?z7c)9=NiVrIv~Whk-qd-d z`dr97%$C$GGV!Px{F7}8Urf>JWVTP9D*vfrBF}oLLRAlDb?I#5rer~^l-NbJZ3{ET=4cBh@m2v!M zrHLWFiah0~_p#L6Ndq6*VQb0v)g1nZIuYC-UaI4eEVg?a>S=UU)}&NXlc=^G=6ngF zPOBbrhYeRT$v&OJ3Wi%rpi9(0F%XCv23#i+Q8^`aXDBU(duUa=+c6~BDu*Pg0uHU- zz6Zi>9iQ|8X#L{NvyksY$g`hRxzi1(a#Ke}swVRI?Q%fGx1r zAccAkH`C-I-EvRF(cVI*H$b3DlP6_3S3Iw)<}aJG8-UAfnF9bJDh)ofo{aGUEAYn# z8~S~acuGAm7a_$^SMBmWp3pGfSLlui{-%pNMwZOCa8)GrbzZUAqY3fNvgV`h2kBzizA%<;V(@?$fZsqe&GL5L90GE_jbAW2S^>er-V z!IwG2!7d&#oGb6X=J{4$7iw%NrD?{m$q4|eNcxEA--=8?V%P(EPMpx57~m@7r)3E*CU>|cpoOu( zWg5$|Vf%cSUdOD8%rnSUR~;(`G~i?E=0g}QR2$JxO+Y?V9CK@?Me10ciNDj4<~9(g zQe)8(*YXbj6F+Q{abm%#K>20?E6hZE z2N9jej72%HO9bN_9WVXOaC=Lj*%-H0w)*mK-&`hF{k! zly1NzcB3w57r7V?%M;9BQI`=NiR*^4jU%X8ik&&Ff=k-3nf;mc>H&U6P-b4hsGKWO zkD}G9&sr?*Ofx# zTDTP&u5s{oT=Ec*%lTlRdYU&IYmT=k&PN(Pki(QQ4xIj>4KhY{@V-W%#~kpm>&s8_~_D{FcnpNbdJsq0)v#W57P5F7d?i$1@Q8m3b<1XXWJI_=J+E>$=|^|Nr$p_w6JWQcf$BiA`=K% z)eXsQP5>BVZ`W^+e=ir=eXAY*e`^CrH`{;I2AisKzrJe&@9S!gx)6UM2yBSF zveQ_q!dnIwD=Cj3Vl81A>Kt~7Es0PM-PbteGi1)t1qNF9Wka7`+&Ldv(_w0v;Zl9X z_n>^FEO870dSv>hIdLRd4)Jb&Bc|;S1Mj1+B+$pEW@n&DPFGQB*aUngwwPKVL~d-~ z`x|Q$hRS!V?+j;DN zWtz0?f)ELF4t4)RfZr$pt(x7mi}ELt0ZZ(cHS7|^hmeVGV;PQ$E6khcej3yCMM zS>%vFY%?g4h>J!GP9(Tog+iGaLpUnR66ji{sAc_*jzlPY1_E4r_`|@@onJ(M&r6Rm z$fn3Djga*$9mzPl(8VS(%Ql^i3cmSMLwWrxn}!q7EXzrG{E;F@ErImSOs#@ zbdy1i@$N=ErmbMKz^tlqL#jhqA*R$I2XK3oN$QO*O_7)-qu$Xd$jz}mT9n~!A(Y9y zOCxq8KXMngE!xKAOEikyfLUXByGO7kW2(z|x|MHP7R{*d{Dr3kF7atw>Kq`N5}kj0 zdMdRP5)aOdzDW|K^&rz!^HkonuuMPtl;QD%g4?)MTXu45bZXQ2m)1#Hzd&88I(!E; z$o75*b7I)xu{NU9c!%7iYFZ%J#JYr5f*nt8n$qex(3Y500uf6_8=K;}g$TRIu2WdX z?s0(EvbV~Khh({+I#oqbzEh*Gx-v9I{eTM`?8iZ`3m+cdrykxyI`&iCpI4U!7dK|D zN6I^tKXV|p_1cO;SCIJhm=@gUo{{B@b;Z-_1S#3mWE8CN=P@7^#P8(=uw7E-5K*^+U{fL~9G9h0--_xr_ z3eG3B9LlORoY2$9DNEeGgO7FHViw%VrVKoI53ZpFC}?lY>B^xYAPz!C9EcAFa=J9U z)7xUk&!e}m53Lt*93Jh;*mK3qk;rK&z0v#M3i;>0`~-;a_5>2*|N5~0CEWSF22CICxI10LV^TZ48!BDF#Yjv4WcVV0#tsFGsVEd_)`a3 zI`Uxe0<;$d!GH6xv~D6ghVeo1w5jB@`+n)p*> z5x15$yOTH8x_v^YM{wO5#v3=0{s?t0)u4>;IUiZit0ht{hbSa;L(`HR1y49%$>r-e z^EPx}S2DddA#2>BCA>7;ppxT*RDXl~J@!5daXH4RcrhifoMfc-ocw1d+@3@1w^DLnFy`FErZ{|-)L1-V zwP(V;J%s(~lgzVRgdzzJ_UpMxb#XQN0_W4-CGsuk#JuivlZob> z(m0CYs&N>Ct7y=5ngz^SeS|w{&O-yL=o6*tLh>WU*+7=&QcsP({Nd9uvXFLo@6lIH z6~NK2!8Lw&3tAlgCUi7Br=Ax0%U$P0VPg6QPFsi6D+Ay&PH`MVr%YQsf(V^Q_0LyT z!>vDBHaMB~0sZyjZ%;cm8*lP9dwv*<_K3i=@8@UJhZ(8)N;HrNp2Gdl+{5DL$I0K6 z82_TIOx_M8{`Jle{GMr2{LlRW2Pa!w8~g8RGOdxNk=0+?Am7Z@v-xu-LGuIhuZi~?e$W``x*e&yQ6^X zs0QNlHC1?aTl14bIggg0B34wK4%9Zw#2OxUGBn9T`%#9RFQR}x zDLzLcHo%UfB$%2hu8+>YKh7s%dh+@I9ujixhxc)*V>}^rsqDex1 zZQ;`A7RJXs#BC$&CB}2pcM#pp$#W!WKs{U?7$0;qXZkt*(M}?$Oixp!PO{BJdVjc- zO})E81R2Rl`alXp-o&J0N{ELW--zHm&&SsGLy{Z^=S{8{t@R$;`5r9U!e5Drxom|k z&O5m!c>B~iz-((2LS0d)1U>EN+mw3!C&hTuuh$`FNXcQvGywePLwydgc!HBYCI!pa z+(z39Vz>wabbs_eWy6Y?+<2`cr9#0_6#GQ0b>1>_kP-T(6pDt3>4lvRQw4hQlcM=Gd{Lml`GLh9iTwzg3PTbwzbUsO4%t7*tShPKj^TbV<5bu4hI}fD zZ?k?cMDH#&izMP>ULNhQ00)V zU^gV*m^bp+h7~>b+d>w8C6vo94nYlK@fdVX#t>Cdd^-(gO}%r(rSXndcX<_?mR6&( zJPg0Qm@nIZ8Z`b4;^h0xvGpv8xeoy4&tIJ(AL5gFG@u{SX%GbHnpwBgsdvUq)9hj; z1-2omK%vEqD0F&+9n>taEQW&tWL=FV6MxMU*{qu#oRyiGaD!gQeoOaf<%4t=@^P@Z zBcagx%xucYay#z0ydCaV+r;(g@tHf!b1|bty>d%({xFC+gx(Eso%WE-78c%c*RM6H zVWdo9-NNRjQPT$gP_;4=KG&M7-A%8EXDJJE)Z?Wt-pdiTxiHFi$awX;-sGujJgr#Y zZ}MU-|I~&-;6!V z|M{79w6U>tG_!S}b=I>q`)2T&+5Dv$x~ckQGtYtY)#)=xOdhAa%+iBFUe*NOU|Tj| zhuluMS$1lR%>FQ0>(InZVfyv9`jX8;OKnM@Rb)2$cJ;(JU5gJ%Oo!qbOu76EhItXZ z@3S}i^9wwKIe!OB**`>=Uyvr05~*q!w23?D2s4dnS7ji7!;R6P-@7IMIVqDepm;flLa^j;dn zIyLHO-Z_{mEvJe+Bnqsfmu8N~94{VD5wy$rLks_5L<5ut6r`1|s8+IL3=!pU za%qkrWP9M=sM;Mh4S`#ts)82@+o{EbW7It|9mZRcYamZP+f;#ps@IG@p_=_!ih$G> z;ya+RJ1?kNq8}6nl+ziCx;#xbbT5AgX|KAP+m0j<_32CxQ1eJx1XOnDsdH60( zYquGlPDW4JaFLOG9oi~{+6)-Zydcw{&+gD_aOa%rv-g}Oe}+SW=u)gz-qb!hBa)Rb`?_x_|f zNTqlmXg^*nWT|mf4TUl)knibat#piWNX= z6=QqEtHOOZ^_S<8`m;jN#L!;9=aTYk_&ErujIJ}eYQdTaM=7ZR3Gsk>L;Q8hH*wC> znaDOUt$LpWyH%uG&m7ymz1Ic!xe-A24CI*%*rA(JBcfq+BdC=3cUcRSZ(|WG>+i;z z5Lj^V^=V#&rg?Bn!45e4yLnM8`07^9_^8VH`jD7tJbeC_BFe?JSnGlb)>PKlgFt-} z#uK6wU~vCHXw(%rtVk-0V5;M-1iIS22xRzI2M)YT(5e~OS}#oP83 z0co*zffn?=cEGOf6Ik01C+udn6{u0M8|uA%phU8c-rgNzIV{@+buNUg zJ04^lh}ot4@+wo!dxM98#(=z2jCsiAEYP#3f9Z~W*zm~NG;Wb!Ue!zs7g0`UjA8a< z^YOXcdZK%k6?8s;ZCc!g_<%^Q@@hQ-*FN3EHI6%|ud+VUWq@CTMaFL@$0vv>EvxR) zBC7t0k+uelAX_DX1O+mf*HkE2Pi&brqdyHv{xLtqn6zcjZ1$XXj>GIF2~W6jQjj;q z!`kPo=*Gfh(B=`uf`6FqEGg)nw*Y(WbBocZT1FI1x&+5qsH^18ZN>Fo3~Y{!HLgmj ztyz;C?)T|HB=D1jO<8%BB{_XBx^*>eU@vf}Ze`%-1y4h|uw;nRO`VvN6{ndPw=#=_ zaqQ{(NM$4+>}h9Oj6`FeD%@_Tcl(vojVQB~bibb`UAnZ(q)U7tqI3=69c2^dp~!7K z61?(ZF2>MmLwh@~&{bNk<$B=dmaRpLC6QEewVPyR0OGmgQwNmVyjm>S;w9{E_&M&b zL!Esinf=Te;t*v$(hET=gA2b2Ce4tXpehPyQ^KhVQ?`sxggpS?$Xkt0S~xsW;r*(w z(1C^`R@Vep}mcXQCs3`@nDmA3w>J!lEXHsaoeEu8a zwAO(t=Kp@(=zd>+ci{LNW9UB{Lz0yge>real+uVPK}=Uj&`3biiPJ&=+F7BNwW6Rf zDO8|@ONKMu%?V^4yz`J)iyMo}P$6}yae+fwv^OlKf_1@Q;os zywF0JjpCGZ(xGZo$q@S-pbLYt*Oya#8eVI|V_qIU=L~X@4|-JxV=M`|Atvq8^GdhK zyskB%Kcd-i^tmZ=O+?m8(F@3Sy3GEF*4oCxIu+e*b7GKkpK}uh)*sQXbY{eS`4oix zp&;W$yvTWxm%Uyx#*kyn&f4h`N@V8pio;i&$3cU;tH`v=>+{H(dN`4gk0QlLc@4&f<{PnxU`Ll-0QIi8M+E;CW2D$xjnR zdpyu$;lz%dHa4%tlnq$mr^84Hs6o$S&>=4W`@H_s9^pd{6t%}$NmN`Ac5-jOTVJmz4B*vK;wx4R;dkz5XkE&c|$kNxuyAV^R)1eiv132!2&?K~o4EBj?Q{9l(D_|T}#ME5k0bjRT z##kaj0V0^zpc3j3_ z^i^MqURCroy#IDaF z9Z3491ceqdsh-N!%J?or;}(U{+u7YDc?ZGk-EH~JOJ!|q*!ZbvC230ODZ`_9+u#of z5oc=lFFi!fG2Q05JMIW&gNFV6{UPTmck*@abFIfhA#{493P)kjLinSS$Rg>f)LSG2 zQo1}sOiVJ+d&n3$XiAdWxr&lY>N1@$I^;wuvbN|HZngOhR@e&zpW$)1C6+s~C8-r` zLK{yOHbtZZcdR*D;&^Jm!*Rhk-P3-CH5BcWfiv_WsXtN&z|f`p(s0Wx57`t6Y3VrL zhl(j6%+>OMD4;bIv%oi7)N6TTT~O|V7X=M0Tw%^(_N5ADHxgQ^zFqQJC|OqLx8(g} z_}qXLuNo7!slRX*H`{Ky|x(@At{6Y`VKbd#gbB$RRJYIrL$s* zLrW(!y)yp;$f3%x0P`naB@A|<@+pwY^Zr{dHN~p5XCG%SoD!kGehxbc6Zf9D@h6`p zH5!NZYr7VM=h)euqlHQiXvs^!JFYP<+j;8(ai$E z=a36XS87S6Vc{+X_6mM^8Oz(t1=#YjOl{({>(Em~D!-}siu)qQ zCJ9RPMWJI8uv&6~kwh}_UYH!a=*Y6`?A_Fy4&}o$4pUz!_u@fr`ZZju3ojIXr9npu zZBH4rjm^Q3P_{ahuR|;piANKhIw+Z!8iOqLJDK|1h8a4J7-9RkKw{YM2xnCa0E)Rpq z-QXcAZfRIV0IdnkPuj!&+Xt>b;|Jr_mrqsf`iHv(I4^W%^g1E)lqEp@a3*koJF<{CaCTFC_Il2b;nM09O!+4_8X?FCDMn`ss~6t!QWHcg98!@+ni z3G1{K#{}`XS_TmgOX{&BylsTA4^f+PlHvrQ_%dG4OhIdFzaPo}g@gYp3@CR_n6|$Q z18_P30IL5P2me!z_PZ=_G^Mq*w=uCda`>xz^_Mp7KHGm(2ch#(`5l%`E^6y3o0gAN z;3Tl=m#Ce>wUXkAlfgtzBuOi`mZxlzn^3_9xCgB6ZgXOWT zP)7aF1VMM>Km21&f{kC>40OR|aMP=(5Re6z)GGr@FV|FP0i-#jA{F0if!S*kr3P6t4S4J$>gYu&Xo;D)aU7@)>4>sh2xDtzJgGG1HARG1}mBvTTRXl+_Vn zm@}O_C2Tmxnai`69p)&inTd3NxQQg$oXq?f2wT#kC$Y=%e|v0{imbk)_!(Olo)Bv< z*1(TrJ|gnjqyhwVA03Q1&Dv!`UUOp9?>LaEB?YGUGbQ-G$2@XluwVFd#KSOjpTn!da>o5@iC2|Gs+@=wvt$^S5;j=k199~md?h{H4TK1lO1+w zd=cydx=lKvR(VB?@>~6s@SmAscVtrFOQk=1b@VD3f;^D-FvId*M5 zy#5Jj$Xd7b(k^3v;<)TmQZA*VrjEoUu(&zqo4d!HmA}9q@T2+2{t6iL0Cb*Fv)TFWG^H}NV~SsP(${Ju#Jsf}YJ^D=hwY>tA2L@%o- zQiwMqdEaOLyfR{_eSQScRn*_X3C~jHJPWX9sqM9lrS{9hX0dS+3nj~IDEL7>?%*@_ ztr`H=xLIIp%H5hU9R&GRv6jWH^NI7=*8g@DQd6232wBY<3yb_b2o|kcu9&Os0VVT1 zBgn2b`UHSDwyes<@E8MQR8v6zq47@KL1lgIp<-v!KKzs0Bzy=KNFd-A_#XtJ?|MigNZ0;97-3{gYQdMu3I$th#Cir172a(p6^QS_;Lxz zee2(f{-^$JIS&o2qm%*KszrQV4NK3u=KhYlN2mBHTd0hz>h%}ay(myXOv&TaVLydO z3-k=-R*ECA3;3f9l`X zYJKb9rhuA$r4`=1`FIChX3=QrU|mc5A1e_S-MG&aWy9A2Ib8F_ilR5j*3$fVQ-XX- z;qIvnT*et+$?}+%!i&MXpev2UKcE=4-mfKr-X?<_E?qiL3OWQ7TGiPof7=$#)305o z_Ra}?FnU63FRyFv5l2b!+p*!4VLD|_`UpVn}yEe>GMz(Gb&1+^lahUCn00mMbd*WX9 zIgQv1hAj+YNd}FZvAthuN@EtA0r9$!<^0*Ww}$q;hE=<%za4mU7b+5k=fjkLS$_jO zyk_-DH2MTX@2N(;QPX(j-H-)Mc`5?d#z34JkTn?hnM6#4qam?b^Cl}oS(^y?#ucuQ{8;gx0dSTyb~(v632RDk?c0f zOsI8wkDLx!JJQK{S{uf<0@d=!tw(r8N6kap$eVwIk#pVTls<+ z*yiNbz{~;SpTC(`q^1xoS6}whpXLge?u~oyjg?wfqmG4jI!u$8XDw5=0iqU}LPZqLGKf7COy#8C?BJkOU3gmmo zz2W=9`~Q^*|L`Eyl4Z2+XMoxGfa;4#DHGrZ3mbMmO9a9UhijPN5Hb|cHRLv(?N(8S zA-*Kplj+#stU5$&=tX!ea%LmaKyqRNZZ^^axR~)ctR+U}#FBvck^OiWk$@Nd9yFJX z2`_l(U7$VB)r%koG*I7?$1TX8IzlD%$JTxhJe4Cmfi=%}FpfL&f+p9hH{v|-=Njq) zdedeH;0C#8A=^2S7B6JN;=QZGmub!AiGz-}15{n*MD9G|aG}c$1-f9Kd#Gd7`WdZ` zeJXt#bKseY^2)+s=2I2Cox;@)eOZ>=uW9I7lm%Vg%HoYdq6T|*#YwY8tc&e$&P|<& z19@Dl^qoVEBDW-q8jSQ*IVFqE2enh=@2hgp)*ALtQ9LPBA~&>~ z8KA5I35Vq&@`ZyuxXAvMSgIYu@Y3ySBhVDqaTD11s{y^vsBg2;Qx7<_T$bg!uV}W> z{M{3C4_|80WyBwapkmMd8S44k(UJ$54@41{juA&Pzm}aeQ2(KZ!c?iwS3=~WjvVEV zM8`a5Ep7%$r?unfPx1C8e0=bE!pWVA(7`HJFBJiXcLxQ44y|__jU7nx`oX!!g)Ngv zpYDGh=U+|=;fn-JXWyp@_I;dxx19M~8|}Y56ry6sW%?Nqgr7dcS3yZiR!x+8;T-`T z{bJ2G2x|?8mt5C?PU0{H@5?|-8lvQ!62`4=U z2C0r_ncwu*3`WA_&8Y}f{3MdVi@Qj}yi#Owx8Xy@?Z#cCu={ka!*EzVhe$~Yo5Hz! z-m+~c-bNUDZZ=OQL2ouvEoM4IM#8SV@S*3Xe|OJZsiNJw2O`K9p3F9q>X?@5sxIPq7Cj4_-~onudAQWAHhF8!-4~+4A$_wR#3K5Ix}+sPr)my`QOY-|8Bi&XrNDPsOMm+Z}aUj@elvff8OJ$@ZazGdzk+Bd;WIH z|N2|*+O05j%LnAv<^=C4hHsSwttP-{(=@+iqbY~ zKM*>JZ}CN_l^T$!?-0upfg}_glW6%S-%bz$n^UFPLwLlV1dwlSDX57VHh-=C@~M5Z6w(TZ8uy439|g z2H`1$*Nqrk6b=QE&85|)9j~9G`;o?`_0`Slb|-mv%beWG_Ul)1)N=%t^ns)fu3`~Q zMW!W$o??XKx8?Wu`qGTWjuX)Tc(-q>0v}-6Z3!|lu}toG-U@oaF$;ALpzyt=L1noU zf?-WI^UyA-P;_uzHkV&)YH3`c4C2xUBZNOqrLh*I1UbSR=&4kpXROy{ZKYtU3V*Hu zB*CUCoWdlJ`hjnbf?N|R@ql-1$dRrY1vxOmbcR`CO1-VmarXA5CR@mY7_iuKD&Ht# zZ^B5IjuAJgV}=Q?+^FvbUq&Z2qgX6m)(i{YYoCdHl}|GS9Mfq#o4OjQm#A!<>Q6DM=Dq*nG(ZbMr zov&ox9W_G$t$%bm0@PfkJ2;4l44?EKTUa3M&U^`f*XiSOzP;njx!>G-FwAF2wm(eO zEs}?H;kZ*ugkXz=M@LTgN{tKmNtwGb#v-dvN>RV50Y9Or)!b*lhfx!x7i2u zUr}lG9n`IU@9TBnQTgy~#Le)3ib~7xR`@sGH8e4D{A=Q?JZZlE1ECB19WEe`!;j8B zlMH+r5|R^i4QFizPlAxzjKr#K4Yi$mnCw8yy&^}Deo2k9iFBRPO3Z^o)wPlPZgQHv zKrIe^jiV(5TADg&tQVL>8ZMRn+(*YwEgaVmxI%lXFksVZXkrPoGBh%3c9Q5oaA=M5^Q7S=1vaqAC zag_AQsvPtNBst=rSXlRxV?L^eo4oYpaAzDtQE4p=+r|YrPSESDaLV1Sp!uxx*GS48Iu)i!%^!Cgq6cw zfP)Ss*hVD!6%Ni;Afy8NHjrnw5{JD}NK`>Ms|fbPaPB4hJBS9@9lP-^BOX4-N^GFK zLBo=xJoiM)9hpBj$d{NRbJHa=6&*Oxg^Ed_VqEl4q#=HPOiG3*hFl|PLdOEr&u2pU z|1by)C{WqYT?sOCpgTlrhIFIU6JZDe9tpw%Z`?rp(XO4nw!Zb!%vw_pMyHg&z5u=U z^48IP+}F&ff(YcLl_(~}I?Qj<5hloA9&{@NAA+g4t;aBuJ^IA(_SMpr8~yA|DAllf z*%V3C@R(B34RNm0mPtULw=zR{iC8S|~dL zMQm?vY*ozn5^dLZ->)#&j~>u~3A~Nj>+U7E92oLp4PNqzS8EG&w)>~G%c$q0|JT3b zAO1W3J#nzHRKMfD^*hi1pR%%zt&zQ+qm4c7_ouOqy_KG|!9U24%A4ljDhyrl2e@#- zaj?aOlkrtf^E0*OS`C;LE(Wrze95eqqhj_62!Ht29qtFo)?Z3}ntCbBoWIw3k2A05 zUr63=NRq~Sp!g)XzZWwr2o;(9wpdYVB1*BI<@K)X)>mJ)>V9>h&?5RHTC3dC6|T2# zwRUPdaRXo(+(#*X6Xw&A((?0{NFH$|l4N%cuC1W=N|#ike<=aH1PC469}J`=(@B%>jq0}QRB?fjMA`vvGh4Z~kR%7x-V6vbi=zB6 zm;%{N4kcTY)kqs4zRo72o>wBfP-NKx!qCKsFLFNTTWo>+>YxXim>bS%;z_0C#T42% zKNH|}!CnzTL}2NqCZF=e+d&`C7?KGZ%%tiC4UkRPg}4VMcDI&1?%9dS)oAbZ!8gV3}{$1zrqbI5N zc?QacFL3Jq-OI;O2+GE7kqMn9M|d}-rAM9*M_q^Rr&;xNjgPy~9$KNf8Fct`fn~c^ z{aKbAg^~6_r}kkDf;Qi-{Q!!?kU;mf!qu|j;O{=8|y8N^ni zz=EX=s3gif`R2(wTp7;r6TsyRtONZhx|+95?F+Vzi9cpSvzGe?_W9a#36c4f8a+oG zb9ut!$fm-i+Y|znS7Z0BOSQ}yTYjM?7ipMpjZTUpXNsO^j618{{H`#d+Q~|(?0GtF zJjT8E<2RwDro`@Iy-M7ShGrBE(`((<5DqHN-j&~x4@DI*Cv@sl zjSrrX!a&X?#tP;>VzTABy1seRsm9Ojk*GzelHrLl)8e}V7Vc6~HK6lvzST-27FlcR zxYRd-&Y9PqcT*-?i@SB&bKczSO4(i|x2qeJaeBdr@ zNm)6lSpnG~Cl3-}Q7j@atmtZ)1i8d-Q5jfsEYIaNd%@FBh6^KI{Afx#KJ7luWulid z(+C_S89W_%XmWu?RE0_N6%?})_o&Ag;+qyBv^&Sz%%d+lT%*b}6~5lp#{V_*TN!P5 z5h`QtBgL}HHtTYFg%%$66l}`)!mDR)#blocTb41viyeZ^PtsgA&+GtyuAfT%gT3@h z&Z}_bJj0!s9SEbOwUm3{7?6EcCiX)}lO(uOoT+#3xrn#7f%fnGiPrCTSv5y_%;h(_ z_-nm}{r{33?2QcmN)C?pde#nqp^bk6j6qck>vax z%Qo6{OF04vS#>N8OVV$d0q)fW@D+`V=}At{THG_ebE9E1^$Ne0Ia`jq$$-qX8Uhsg z1dTsGcaXuWMl&OeAVMD^|A-$btU3mQly|q+A9!)QT|#Kr2mG=OrY>tcguy|R{o;kr zlMz6G(3WT9N+5C^jD!!uezDaRyFLxHD@zj#1Dzx$Y?zA=o_vpp3%73t<1B`Yg%)qk zkB$?tc5#8wvyLGk-%sdri$WOz5(p$HMH4216&i4gxuL-Dixihoz%9kARXpIxO%(1z zqz1X#%YJr~N`tRLWo}m#l-b$P^=xuH(T0)ogO{mT$=5TLw6z^wqJAIaV(<)`mc1)- zBR)LAAz9D_fJ2gq;#Z+2J;r`jdaope_`vu$4s_e8qqOrZTM+Nho3qL!yFUxR1auOJ zr;nSn^fVxru#&>^%LcUl>BY9oCI~ym)Z68v7`H%9DXIXta1LOB_CQ99)O^{zm!9~yA?Z= znpumI#)A9e8&<%8jFRya(zQc#hxFFv%+t1BB{BtK4n2w2ma%_7smoO#ipj}GJ7iWr zUt1{9?$I%Awq^{97A-dBB)7DaRny1(h&iYga}-wdx^FP6BgQPWRJ?Vo$Id$X4GfHD zgPrfFKeBc0JkFZBaa19XL-Q6`f1Pku&?!hIvySuwP z1c%`69$Z5pK=AjIIWt4%%*;KHwJKq=cH6G%s;{cMs{;^<)W78iyBI#zR!14oIWr(K zklwiESg)m3epo`;JtezdXJw#wXiSKpqRey0&~BP@LNpyoudb<7-1?rlyd0y&n2C^L z^u9f(zzNS!hr1RP%!1gxjZUIvboJ4`KC*}lm@$QzLR!zcNx`$6f3RfeX)i4AW3;s? z+*|zZ=~iVv&=JG1-NQTvU>rQG%WlMTj634ujJt4OA}sS>WZwpAf6c7eBZF+=%Vn>s z@ZIj6(h%_~ZcfAv)DR`eaJItmcb%^9Sdp zA8=mS-~d6H6yks zI8E}gTbQu-}5BLl@#RzfJW@5?~2#>=Sd!N8fjMJv9z7SF3@1e*2p;YB8I z$+abGF4zb(Oo#eVKN1)s9XaC|w<-|hRh4^uXI@VFs_g8{YAgYl|AjPFL@kPcS@R;_ zH>ChJb_qUl)C=R1gtm+04(4o(*GfT2E;L!nMt{h!L7_=rh>E1ZexyCTm!-*eKxHtV zm_-6P6>C*CbG~nHoV{b@+SwW3ss+V+`z^QP4pEeY@Xb|mV&udQD#svcx#5$6k=W6f zSdSUA`f|Upl(3jN`^?Ri8$7t1@j(X$80IrIt3 zdQ5qY*%fXSi&m9zW-LTHULCUH@|5vE;82w^-Vkl4oFVup&4G1XK4<~fb7ynSGNdU! z2-tS!BS=g;(M)~M#JL7q?Jty};@36a`r0F^KA-nVuhrsxcz7Q*Ob-n0~9 zR&{RDyXarq7mQ0L3`~zivfg7;gnBhr-IB0U6*RZc@Aj=nyc&b>)3&cm5H(cadwP*y zv=U%J#0nl;i~#tO3g8X>Px#W#(8MeP)&=|;l6o0GJou|cjd6Tsr zV=T=y9M9dPe75fP0~U%Vju!%In0(Am0C*@qt46Z$qSpDX4fB{$kMTe`9+mZ+zcuTQWv66_sQ16;#pK{7!{|KU=UP#Fz#yt9NFW#4D@HT$)h z41&CoDq`xx;WY@b#w12BrY>WK6c=)LN-=>B#$O2TIRM{)4@Zsj` zA@vm<0hD)Vs-MwnWzX<(Tq_?RjJz^-bGHOHn1pI4g0I8_9O43Pt!FyiswLRcVHC9P zJaD>cP^T5;=F*x|-R|^?D%xK)Qc=sK?a~eCBXpw;Oeac|vM3JbTAtci`I&M7&Z{DK z?F>Stqy_irDjPJ{8iKFrj5P^0CHbQkd|lAfsxX}D@5(YkHM3pDG;%8{>$Gn0>k z9y)B?d_4m-0y93FEs9LCj!E5pzGM@$q)9zGZ+K=8c43O{kQM0U4Cq{#+R{@!J@zB`#B3Z>xVC*5TJwk07*flZ;R(3zuGDErTP> zUKwzlR|R{xL=^7UPXgtHRZF^L=m})k1$)5!IYP-UAv*^0R<38AAA5B1ARWlcr;$qx zf56?#?6ApWRhc6=%b2b?PPJhncb)S68N!eROjwyx%K@%xOl(rr z)AW7Bef6L?%DnU&iz)_){(XmW9_L8uw5E}I+p3RjOO>xFUr~vLB}}yDP||?05KKe3 zRkoyqUa_|8s-*|X0-|L42n4HRv%pvGizh%TuRo`;675fNlQq=AlMF^dCti+G_0opp zbR{ZfRH`=y?)h?JcX=2B0(iWjq($|Kz`3LWhoX$%B}eEPLR4f0`oe~cwlDXOntF0V|lYXns9uQ-VDh=D4@$g zB7V7yfL7;lT>k>!7TXA2l(>f{IP&lH%694TWS=qov?S``Rl5Gp%{%!1b!Xwf)V8V?M-F!7Sria zmfW!ZL6i!k3zSm=GS~G94F0|8QoBb>@D$Q`{QH2sGNHMmbt~2jcxD0hBAJ|san>Xa zuNEKY$AU3a4tT1G*v{O}pneL$xH*seA~0naLc7X4>!S~vnhOhFYQ>|NCtd_*x4O5b z?`{h(-TTR>Z|^cKrKhb_PsCGAPQdv!x2-;u9noO+u^~X{?iM1*urEtzA=vo2KbJh?V@J)f7!U`(dWIZmxgY4HrN~QI1f{=~fC8IRKhRZ5IfnjTEmj-x_5k#f z<5n+zE@HaL3UC?q?4FnBQ}SdptGR4~e2Oxq3&9r0m-LhH3N#{b`$Nv-BHhK|PjHAicFXZIObp&clf7=PoTKF_OZt$5U+5A|L*CVwx~} zORQOC!L|)%GFtf_B)U8r6n9weY;7BPsFY*s?NGQcO>F#?LW{DcRS?)Vi^krSzoOOF zRN_GF`msP{@{hALqVp7w@gj+!_t?Yu$k;12m>J!CJ$@)@kAa&H1b&q1WYO+~ms2gc zl&GCBV;Ao(D14C_N=C2Tcm2Le<_0;DPF`|jw z1lK^Yof^f1@~F4%2kQL;DP_)8Lv8wyR%}KxSEjTdcu^2sW$6a_Q%;XFuYO~gjQFKD zLzuBDqyBZ8tR*WvxTO@@Y|RG(rvNj}UE3>NZYZK}aw$~AaSr=El^fe!;3*?4)ic*l z*srNGyw=cx#tW+ka$NccPph4XE)x|=(_XBAM*~zi&rW)hB;ebs0I(MzT`G{JwSgnx z+LT|v=C4lxz3mb6&bpf(HsI?6YO`Z3m#_ubpz1jAszvuWi$ghczW_J(#e^t&9s4`$3YzZx zA?>=9GFfV>VVnC_w{QT~ixVLp;%lEEYA6g*MBz_%{I=4YF7oS2FU~)*1HdFe2IilO z#J}BlU@WeouKUz3Squ#-e%N;9L%tE3GZ<9?{|2^? zA63{1;KzE!%hJO{aSQW(AGuqvW=az8H$Gv_Y zC;Cl#dj29kFE45Mh1+z`xig+G2z((wK}4GBr6TT5>#dI$vP6_t(AOpiiFd-)|X+quRd|c$Wt?-a!lO8v__^iq;j>(1vzERQuvkbM0 z?P8KS3*tzyX+! z7xvDWOHQ=w49%3DQV+OEtQn&_v+q@0>FKx~)tfIDJ~T-qZ_+3vg>p5asDnPsX3gUr ze|2nkv!?eL47?-T!bSN^u>Xsg&oJZ3>ZlVu=oCwK&vGfkIbnT(<0qv+0u68~4Z)W`NV( z_~alr`E~)%VKp<-*zgARD&BJ1uhYS6BXtcy3gPxT$@A^^h8@2NyN&N!Iyd59afd}i zEr%ZvBeXW`y*AW6Ih*Ev^SZunfqv;-A z5RF8s3atRX)#EyYdlP8Eo^lvXNmW5Ycg2;metXV>nNB;hMYUEJ*Gb5mezH%}zOpFF zXW{E_7GxQmdM*oY5)Y4_sO<&GVEk=CfMZ6}dR=!oO|8%L_mOdR#ADzEY9u?Ohf_|q ziDwOK!6Tz}e?Vn4dC{h-Te5wzrP*Yxb>U7f9(t3FUKHRxj@|a25cVRXJ2{KF(W;Jw zovWtzx~KY(GcJ(Y8y_VgkJVFoMH4@)RX|0mKFb-{B%WglGyNS3W4M{~2Wzsi!K3-8 z#?>v!-KvH>!zmK9JYO;19hbi1z2QU_<^GBe;-b82)lHnf^5&wbXiqQrBd@IGbT1T! zhnPefyIcB!ySZydq8Y=jqo|Wv{svPR$D(${Mc-&xdqpn(^D+m;Hm%j6} zxMr+imyZW+TFnamA6R#4VK>2QI@RbKts%AjZJyvEuRGwgXBXch!n)yT&7XY0%!R4m zlOMLEWkcV`s9jdxFf!lgiS=At^VF*Gi|B|u?L##Fz)(+6t6`%6Hp%Yl1rii*9_Y`U zDLCD+tL$1HVl`?}K(fHW7VRc1w7+KgD&@xWo2Q8|g^X&gV1a8b3)01;z55R8`A&Mr ztX)P2p`a9^-nQ-g!^R9GsT_tC6Z&%1f?=W%aH%&R*#(r~;0ZZkFj{FvcM(Y?phSqNJ?5l8#0002j9c9Z*(EZWS}^$O zfcd zO7aaGsTGs}sX%No>5&&jzp348{HxED%z}t*HNMfx%7F+(4mUp?{@)CYo`u>!Z(g(3 zcYI#${h5v7gR>8I;fOHpM%C__8?Zj?%#dT?aC zc9O0N^u_tv=ejFuk5L5y^(q4B7ti2jZFIlZ{U6U@c!cKp9B+}5&C5pK7b%KS4UH?x z&QHq-peZ-*N{G`)(+){V%*u*RW>@59Wf$w17s_gun!^31D{Im3lRf~tkqw|2OaQu@9?^f(LzPF7~Y1b3Y8ggxsfPd-t zcybJ8mlB~frh@sQqW6d-Uba?iqp~>s1O+-d#%DOA5! z0}-WdN!)8pTb+TXT~$IK8n91<%&=*JhWS!B@lOjxmt53$!nzcAI+J|XJ4kj^sI=nM zbieJ3@ANwn>)or#7h8pESf*4XyuthDj{pZ4%kQp`iJ^tfUu4=>;3W0=6&L`$+{F3w z)#rbHm*5MrQHrX+5!_E+D3;HgemDVy0ia*n`dPtW#qMWs%(0n&~!o-8GupBX%&?CtMVLa~pde zonVNaGnnM{b$gXzeMkUDyML*dh`_+GgI}t<+V%cPx7imfp=k`baBmCOkHQHYnwwJf zoQFh%dmY>Rl3kj5o*+$ z-Rl}KKEH|rpqGl}mm)78{*b*9D)D!863@Dms^M>Bki(;AR{&6{Jb+&2+Id+Q-OGpH zgDQWh&a)RSD7}zPA!MarG^bMKziCb^UpuO2&6xopVgUUvoG+XHwI2@l@5UIQ0sHX% z0X&@8eZ6F0oycE^oAnA&L>#Wh&;S*>-?{QYwua|N$7Y{`5FF-;VVTroR!>1pXJyhg zTQ03Y-2G_trFFqB5G4aRO1tlXJS_v(NeCK=vKQ_uIjTr*;W_?=9oWS%GQzA(1aHSH zpK2u+Y}mTrc18WY-h6rf;+L-fA&0-J%>eZMg6BCI0w9)u8ulM$U)K1-+Y%0AfcK+^ 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 Q;6Ir)A|jpGD-1>c2W}=b9smFU literal 0 HcmV?d00001 diff --git a/src/app/modules/auth/auth.route.ts b/src/app/modules/auth/auth.route.ts deleted file mode 100644 index 7a97f24..0000000 --- a/src/app/modules/auth/auth.route.ts +++ /dev/null @@ -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' }, - }, -] diff --git a/src/app/modules/auth/auth.routes.ts b/src/app/modules/auth/auth.routes.ts new file mode 100644 index 0000000..d858eda --- /dev/null +++ b/src/app/modules/auth/auth.routes.ts @@ -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' } + } +]; \ No newline at end of file diff --git a/src/app/modules/auth/error/error.route.ts b/src/app/modules/auth/error/error.routes.ts similarity index 60% rename from src/app/modules/auth/error/error.route.ts rename to src/app/modules/auth/error/error.routes.ts index 6a9239b..ccce515 100644 --- a/src/app/modules/auth/error/error.route.ts +++ b/src/app/modules/auth/error/error.routes.ts @@ -4,18 +4,20 @@ import { Unauthorized } from '../unauthorized' export const ERROR_PAGES_ROUTES: Routes = [ { - path: 'error/404', + path: '404', // component: Error404, data: { title: 'Page Non Trouvée' }, }, { - path: 'error/403', + path: '403', // component: Unauthorized, data: { title: 'Accès Refusé' }, }, { - path: 'unauthorized', - component: Unauthorized, - data: { title: 'Accès Refusé' }, - } + path: '500', // + component: Error404, + data: { title: 'Erreur Serveur' }, + }, + // Redirection par défaut + { path: '', redirectTo: '404', pathMatch: 'full' } ] \ No newline at end of file diff --git a/src/app/modules/auth/new-password.ts b/src/app/modules/auth/new-password.ts new file mode 100644 index 0000000..b19588c --- /dev/null +++ b/src/app/modules/auth/new-password.ts @@ -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: ` + + `, + styles: ``, +}) +export class NewPassword { + password: string = '' + protected readonly currentYear = currentYear + protected readonly credits = credits +} diff --git a/src/app/modules/auth/reset-password.ts b/src/app/modules/auth/reset-password.ts new file mode 100644 index 0000000..8b7fa37 --- /dev/null +++ b/src/app/modules/auth/reset-password.ts @@ -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: ` +
+
+
+
+
+
+
+ +

+ Enter your email address and we'll send you a link to reset + your password. +

+
+ +
+
+ +
+ +
+
+ +
+
+ + +
+
+ +
+ +
+
+ +

+ Return to + Sign in +

+
+
+ +

+ © {{ currentYear }} Simple — by + {{ credits.name }} +

+
+
+
+
+ `, + styles: ``, +}) +export class ResetPassword { + protected readonly currentYear = currentYear + protected readonly credits = credits +} diff --git a/src/app/modules/auth/sign-in.ts b/src/app/modules/auth/sign-in.ts index bf23b05..163a3c1 100644 --- a/src/app/modules/auth/sign-in.ts +++ b/src/app/modules/auth/sign-in.ts @@ -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 { FormsModule, NgForm } from '@angular/forms'; -import { Router } from '@angular/router'; -import { AuthService } from '@core/services/auth.service'; +import { Router, RouterLink } from '@angular/router'; +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 { PasswordStrengthBar } from '@app/components/password-strength-bar'; import { appName, credits, currentYear } from '@/app/constants'; @@ -10,7 +13,7 @@ import { appName, credits, currentYear } from '@/app/constants'; @Component({ selector: 'app-sign-in', standalone: true, - imports: [FormsModule, CommonModule, AppLogo, PasswordStrengthBar], + imports: [FormsModule, CommonModule, RouterLink, AppLogo, PasswordStrengthBar], template: `
@@ -25,7 +28,7 @@ import { appName, credits, currentYear } from '@/app/constants';

-
+
@@ -124,50 +147,155 @@ import { appName, credits, currentYear } from '@/app/constants';
`, + styles: [] }) -export class SignIn { +export class SignIn implements OnInit, OnDestroy { protected readonly appName = appName; protected readonly currentYear = currentYear; protected readonly credits = credits; private authService = inject(AuthService); + private roleService = inject(RoleService); private router = inject(Router); private cdRef = inject(ChangeDetectorRef); + private destroy$ = new Subject(); - username: string = ''; - password: string = ''; + credentials: LoginDto = { + username: '', + password: '' + }; + rememberMe: boolean = false; loading: boolean = false; 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) { - form.control.markAllAsTouched(); + this.markFormGroupTouched(form); + this.focusFirstInvalidField(); return; } this.loading = true; this.errorMessage = null; - this.authService.login(this.username, this.password).subscribe({ - next: (res) => { - this.router.navigate(['/dcb-dashboard']); - this.loading = false; - }, - error: (err) => { - this.errorMessage = err.error?.message || 'Login failed'; - this.loading = false; - - // Forcer la mise à jour de la vue - this.cdRef.detectChanges(); - - // Scroll et focus sur l'erreur - this.scrollToError(); - }, + this.authService.login(this.credentials) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.handleLoginSuccess(); + }, + error: (error) => { + this.handleLoginError(error); + } + }); + } + + /** + * 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(() => { const errorElement = document.getElementById('error-message'); if (errorElement) { @@ -179,4 +307,14 @@ export class SignIn { } }, 100); } + + /** + * Réinitialise le formulaire + */ + resetForm(form: NgForm): void { + form.resetForm(); + this.credentials = { username: '', password: '' }; + this.errorMessage = null; + this.rememberMe = false; + } } \ No newline at end of file diff --git a/src/app/modules/auth/two-factor.ts b/src/app/modules/auth/two-factor.ts new file mode 100644 index 0000000..10e9b25 --- /dev/null +++ b/src/app/modules/auth/two-factor.ts @@ -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: ` +
+
+
+
+
+
+
+ +

+ We've emailed you a 6-digit verification code we sent to +

+
+ +
+
+ (12) ******6789
+
+ + + + + + +
+ +
+ + +

+ Don’t have a code? + Resend + or + Call Us +

+

+ Return to + Sign in +

+
+
+ +

+ © + {{ currentYear }} + Simple — by {{ credits.name }} +

+
+
+
+
+ `, + styles: ``, +}) +export class TwoFactor { + protected readonly currentYear = currentYear + protected readonly credits = credits +} diff --git a/src/app/modules/components/basic-wizard.ts b/src/app/modules/components/basic-wizard.ts index 06dc2cd..cb1ebfd 100644 --- a/src/app/modules/components/basic-wizard.ts +++ b/src/app/modules/components/basic-wizard.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core' 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' @Component({ diff --git a/src/app/modules/components/data.ts b/src/app/modules/components/data.ts index 3a65516..992b1c6 100644 --- a/src/app/modules/components/data.ts +++ b/src/app/modules/components/data.ts @@ -1,253 +1,34 @@ -export const paginationIcons = { - first: ``, - previous: ``, - next: ``, - last: ``, -} +import { WizardStepType } from '@/app/modules/components/types' -export const states = [ - '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 }[] = [ +export const wizardSteps: WizardStepType[] = [ { - name: 'Alabama', - flag: '5/5c/Flag_of_Alabama.svg/45px-Flag_of_Alabama.svg.png', + id: 'stuInfo', + icon: 'tablerUserCircle', + title: 'Student Info', + subtitle: 'Personal details', }, { - name: 'Alaska', - flag: 'e/e6/Flag_of_Alaska.svg/43px-Flag_of_Alaska.svg.png', + id: 'addrInfo', + icon: 'tablerMapPin', + title: 'Address Info', + subtitle: 'Where you live', }, { - name: 'Arizona', - flag: '9/9d/Flag_of_Arizona.svg/45px-Flag_of_Arizona.svg.png', + id: 'courseInfo', + icon: 'tablerBook', + title: 'Course Info', + subtitle: 'Select your course', }, { - name: 'Arkansas', - flag: '9/9d/Flag_of_Arkansas.svg/45px-Flag_of_Arkansas.svg.png', + id: 'parentInfo', + icon: 'tablerUsers', + title: 'Parent Info', + subtitle: 'Guardian details', }, { - name: 'California', - flag: '0/01/Flag_of_California.svg/45px-Flag_of_California.svg.png', - }, - { - 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', + id: 'documents', + icon: 'tablerFolder', + title: 'Documents', + subtitle: 'Upload certificates', }, ] diff --git a/src/app/modules/components/typeaheds.ts b/src/app/modules/components/typeaheds.ts deleted file mode 100644 index 87db9a4..0000000 --- a/src/app/modules/components/typeaheds.ts +++ /dev/null @@ -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: ` - -
-
-

- A flexible JavaScript library that provides a strong foundation for - building robust typeaheads -

- - - View Official Website - - -
- -
-
-
-
Basic
-
-
- -
-
- -
- -
-
-
Open on focus
-
-
- -
-
- -
- -
-
-
Formatted results
-
-
- -
-
- -
- -
-
-
Select on exact
-
-
- -
-
- -
- -
-
-
Custom Template
-
-
- - - - - -
-
-
-
-
- `, - styles: ``, -}) -export class Typeaheds { - basicTypeahead: any - focusTypeahead: any - formattedTypeahead: any - exactSearchTypeahead: any - customTypeahead: any - - search: OperatorFunction = ( - text$: Observable - ) => - 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() - click$ = new Subject() - - searchFocusTypeahead: OperatorFunction = ( - text$: Observable - ) => { - 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 = ( - text$: Observable - ) => - text$.pipe( - debounceTime(200), - distinctUntilChanged(), - map((term) => - term === '' - ? [] - : states - .filter((v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1) - .slice(0, 10) - ) - ) - - searchExact: OperatorFunction = ( - text$: Observable - ) => - 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) => - 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 -} diff --git a/src/app/modules/merchants/types.ts b/src/app/modules/components/types.ts similarity index 100% rename from src/app/modules/merchants/types.ts rename to src/app/modules/components/types.ts diff --git a/src/app/modules/components/vertical-wizard.ts b/src/app/modules/components/vertical-wizard.ts index bb6e862..026cbd9 100644 --- a/src/app/modules/components/vertical-wizard.ts +++ b/src/app/modules/components/vertical-wizard.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core' import { UiCard } from '@app/components/ui-card' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { NgIcon } from '@ng-icons/core' -import { wizardSteps } from '@/app/modules/merchants/data' +import { wizardSteps } from '@/app/modules/components/data' @Component({ selector: 'app-vertical-wizard', diff --git a/src/app/modules/components/wizard-with-progress.ts b/src/app/modules/components/wizard-with-progress.ts index bba8641..c18a9ea 100644 --- a/src/app/modules/components/wizard-with-progress.ts +++ b/src/app/modules/components/wizard-with-progress.ts @@ -3,7 +3,7 @@ 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' +import { wizardSteps } from '@/app/modules/components/data' @Component({ selector: 'app-wizard-with-progress', diff --git a/src/app/modules/merchants/wizard.html b/src/app/modules/components/wizard.html similarity index 86% rename from src/app/modules/merchants/wizard.html rename to src/app/modules/components/wizard.html index e310fc9..125fe93 100644 --- a/src/app/modules/merchants/wizard.html +++ b/src/app/modules/components/wizard.html @@ -7,7 +7,11 @@
+ + + +
diff --git a/src/app/modules/merchants/wizard.spec.ts b/src/app/modules/components/wizard.spec.ts similarity index 100% rename from src/app/modules/merchants/wizard.spec.ts rename to src/app/modules/components/wizard.spec.ts diff --git a/src/app/modules/merchants/wizard.ts b/src/app/modules/components/wizard.ts similarity index 53% rename from src/app/modules/merchants/wizard.ts rename to src/app/modules/components/wizard.ts index 1990d11..2140ede 100644 --- a/src/app/modules/merchants/wizard.ts +++ b/src/app/modules/components/wizard.ts @@ -1,10 +1,12 @@ import { Component } from '@angular/core' 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 { VerticalWizard } from '@/app/modules/components/vertical-wizard' @Component({ - selector: 'app-merchant-wizard', - imports: [PageTitle, WizardWithProgress], + selector: 'app-wizard', + imports: [PageTitle, BasicWizard, WizardWithProgress, VerticalWizard], templateUrl: './wizard.html', styles: ``, }) diff --git a/src/app/modules/dcb-dashboard/components/recent-transactions.ts b/src/app/modules/dcb-dashboard/components/recent-transactions.ts index fbcf67c..89874bd 100644 --- a/src/app/modules/dcb-dashboard/components/recent-transactions.ts +++ b/src/app/modules/dcb-dashboard/components/recent-transactions.ts @@ -25,7 +25,7 @@ interface Transaction {

Transactions Récentes

- + Exporter
diff --git a/src/app/modules/merchant-partners/config/config.html b/src/app/modules/merchant-partners/config/config.html new file mode 100644 index 0000000..88c113b --- /dev/null +++ b/src/app/modules/merchant-partners/config/config.html @@ -0,0 +1,436 @@ + + + Payment Hub DCB + + +
+ + + + + + + + @if (configError) { +
+ + {{ configError }} +
+ } + + @if (configSuccess) { +
+ + {{ configSuccess }} +
+ } + + +
+ @for (step of wizardSteps; track $index; let i = $index) { +
+
+ + @if (i === 0) { +
+
+ +
+ + @if (companyInfo.get('name')?.invalid && companyInfo.get('name')?.touched) { +
Le nom commercial est requis
+ } +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ } + + + @if (i === 1) { +
+
+
Adresse de l'entreprise
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
Contact technique
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ } + + + @if (i === 2) { +
+
+ +
+ +
Pourcentage prélevé sur chaque transaction
+
+
+
+ +
+ +
Plafond total des transactions par jour
+
+
+
+ +
+ +
Montant maximum par transaction
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ } + + + @if (i === 3) { +
+
+
Header Enrichment
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ + @for (header of headerEnrichmentHeaders.controls; track $index; let idx = $index) { +
+
+ + +
+
+ + +
+
+ +
+
+ } +
+
+
+
+ +
+
Webhooks Abonnements
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+
Webhooks Paiements
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ } + + + @if (i === 4) { +
+
+
+ + Vérifiez les informations avant de créer le partenaire +
+ +
+
+
Récapitulatif
+
+
+ Informations Société:
+ {{ companyInfo.value.name || 'Non renseigné' }}
+ {{ companyInfo.value.legalName || 'Non renseigné' }}
+ {{ companyInfo.value.email || 'Non renseigné' }}
+ {{ companyInfo.value.phone || 'Non renseigné' }} +
+
+ Configuration:
+ Commission: {{ paymentConfig.value.commissionRate || 0 }}%
+ Limite quotidienne: {{ (paymentConfig.value.dailyLimit || 0) | number }} XOF
+ Limite transaction: {{ (paymentConfig.value.transactionLimit || 0) | number }} XOF +
+
+
+
+
+
+ } +
+ + +
+ @if (i > 0) { + + } @else { +
+ } + + @if (i < wizardSteps.length - 1) { + + } @else { + + } +
+
+ } +
+
+
\ No newline at end of file diff --git a/src/app/modules/merchant-partners/config/config.spec.ts b/src/app/modules/merchant-partners/config/config.spec.ts new file mode 100644 index 0000000..d27de8c --- /dev/null +++ b/src/app/modules/merchant-partners/config/config.spec.ts @@ -0,0 +1,2 @@ +import { PartnerConfig } from './config'; +describe('PartnerConfig', () => {}); \ No newline at end of file diff --git a/src/app/modules/merchant-partners/config/config.ts b/src/app/modules/merchant-partners/config/config.ts new file mode 100644 index 0000000..1926065 --- /dev/null +++ b/src/app/modules/merchant-partners/config/config.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/list/list.html b/src/app/modules/merchant-partners/list/list.html new file mode 100644 index 0000000..8020403 --- /dev/null +++ b/src/app/modules/merchant-partners/list/list.html @@ -0,0 +1,399 @@ + + + + @if (canViewAllMerchants) { + + Vue administrative - Tous les utilisateurs marchands + } @else if (isDcbPartner) { + + Votre équipe marchande + } @else { + + Utilisateurs de votre partenaire marchand + } + + +
+ + @if (canViewAllMerchants) { +
+
+ +
+ Vue administrative DCB : Vous visualisez tous les utilisateurs marchands de la plateforme +
+
+
+ } @else if (isDcbPartner) { +
+
+ +
+ Vue partenaire marchand : Vous gérez les utilisateurs de votre propre équipe + Merchant Partner ID: {{ currentMerchantPartnerId }} +
+
+
+ } + + +
+
+
+ +
+ + + + +
+
+
+
+
+ @if (!canViewAllMerchants) { + + } +
+
+
+ + +
+
+
+ + + + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+
+
+ + + @if (loading) { +
+
+ Chargement... +
+

Chargement des utilisateurs marchands...

+
+ } + + + @if (error && !loading) { + + } + + + @if (!loading && !error) { +
+ + + + + @if (canViewAllMerchants) { + + } + + + + + + + + + + @for (user of displayedUsers; track user.id) { + + + @if (canViewAllMerchants) { + + } + + + + + + + + } + @empty { + + + + } + +
+
+ Merchant Partner + +
+
+
+ Utilisateur + +
+
+
+ Email + +
+
+
+ Rôle + +
+
+
+ Statut + +
+
+
+ Créé le + +
+
Actions
+
+
+ +
+
+ + {{ (user.merchantPartnerId || 'N/A').substring(0, 8) }}... + +
+
+
+
+
+ + {{ getUserInitials(user) }} + +
+
+ {{ getUserDisplayName(user) }} + @{{ user.username }} +
+
+
+
+ {{ user.email }} + @if (!user.emailVerified) { + + } +
+
+ + + {{ getRoleDisplayName(user.role) }} + + + + {{ getStatusText(user) }} + + + + {{ formatTimestamp(user.createdTimestamp) }} + + +
+ + + @if (user.enabled) { + + } @else { + + } + @if (!canViewAllMerchants) { + + } +
+
+
+ +
Aucun utilisateur marchand trouvé
+

Aucun utilisateur ne correspond à vos critères de recherche.

+ @if (!canViewAllMerchants) { + + } +
+
+
+ + + @if (totalPages > 1) { +
+
+ Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} utilisateurs +
+ +
+ } + + + @if (displayedUsers.length > 0) { +
+
+
+ + Total : {{ allUsers.length }} utilisateurs + +
+
+ + Actifs : {{ getEnabledUsersCount() }} + +
+
+ + Admins : {{ getUsersCountByRole(UserRole.DCB_PARTNER_ADMIN) }} + +
+
+ + Managers : {{ getUsersCountByRole(UserRole.DCB_PARTNER_MANAGER) }} + +
+
+ + Support : {{ getUsersCountByRole(UserRole.DCB_PARTNER_SUPPORT) }} + +
+
+
+ } + } +
+
\ No newline at end of file diff --git a/src/app/modules/merchant-partners/list/list.spec.ts b/src/app/modules/merchant-partners/list/list.spec.ts new file mode 100644 index 0000000..1919cb9 --- /dev/null +++ b/src/app/modules/merchant-partners/list/list.spec.ts @@ -0,0 +1,2 @@ +import { PartnerTeamList } from './list'; +describe('PartnerTeamList', () => {}); \ No newline at end of file diff --git a/src/app/modules/merchant-partners/list/list.ts b/src/app/modules/merchant-partners/list/list.ts new file mode 100644 index 0000000..c389e3b --- /dev/null +++ b/src/app/modules/merchant-partners/list/list.ts @@ -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(); + + readonly UserRole = UserRole; + readonly HubUserRole = HubUserRole; + + @Output() userSelected = new EventEmitter(); + @Output() openCreateModal = new EventEmitter(); + @Output() openResetPasswordModal = new EventEmitter(); + @Output() openDeleteUserModal = new EventEmitter(); + + // 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 + } + } +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/merchant-partners.html b/src/app/modules/merchant-partners/merchant-partners.html new file mode 100644 index 0000000..d8cda15 --- /dev/null +++ b/src/app/modules/merchant-partners/merchant-partners.html @@ -0,0 +1,519 @@ + +
+ + + +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/modules/merchant-partners/merchant-partners.spec.ts b/src/app/modules/merchant-partners/merchant-partners.spec.ts new file mode 100644 index 0000000..23bb215 --- /dev/null +++ b/src/app/modules/merchant-partners/merchant-partners.spec.ts @@ -0,0 +1,2 @@ +import { MerchantPartners } from './merchant-partners'; +describe('Merchant Partners', () => {}); \ No newline at end of file diff --git a/src/app/modules/merchant-partners/merchant-partners.ts b/src/app/modules/merchant-partners/merchant-partners.ts new file mode 100644 index 0000000..4ec3662 --- /dev/null +++ b/src/app/modules/merchant-partners/merchant-partners.ts @@ -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(); + + 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, 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; + @ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef; + @ViewChild('deleteUserModal') deleteUserModal!: TemplateRef; +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/models/merchant-user.model.ts b/src/app/modules/merchant-partners/models/merchant-user.model.ts new file mode 100644 index 0000000..4ee1400 --- /dev/null +++ b/src/app/modules/merchant-partners/models/merchant-user.model.ts @@ -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; +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/models/partners-config.model.ts b/src/app/modules/merchant-partners/models/partners-config.model.ts new file mode 100644 index 0000000..93cb0f8 --- /dev/null +++ b/src/app/modules/merchant-partners/models/partners-config.model.ts @@ -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) { + 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) { + if (partial) { + Object.assign(this, partial); + } + } +} + +export class TechnicalContact { + @IsString() + name: string = ''; + + @IsEmail() + email: string = ''; + + @IsString() + phone: string = ''; + + constructor(partial?: Partial) { + 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) { + 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) { + 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) { + 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) { + if (partial) { + Object.assign(this, partial); + } + } +} + +export class UpdateCallbacksDto { + @IsOptional() + callbacks?: CallbackConfiguration; + + constructor(partial?: Partial) { + 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) { + 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 { + success: boolean = false; + data?: T = undefined; + error?: string = ''; + message?: string = ''; + + constructor(partial?: Partial>) { + if (partial) { + Object.assign(this, partial); + } + } +} + +export class ApiKeyResponse { + apiKey: string = ''; + secretKey: string = ''; + partnerId: string = ''; + createdAt: Date = new Date(); + + constructor(partial?: Partial) { + 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) { + 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) { + if (partial) { + Object.assign(this, partial); + } + } +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/profile/profile.html b/src/app/modules/merchant-partners/profile/profile.html new file mode 100644 index 0000000..0cfe936 --- /dev/null +++ b/src/app/modules/merchant-partners/profile/profile.html @@ -0,0 +1,511 @@ + +
+ +
+
+
+
+

+ @if (user) { + {{ getUserDisplayName() }} + } @else { + Profil Utilisateur Marchand + } +

+ +
+ +
+ + @if (user && !isEditing) { + + + + @if (user.enabled) { + + } @else { + + } + + + + } +
+
+
+
+ + + @if (error) { +
+
+ +
{{ error }}
+
+
+ } + + @if (success) { +
+
+ +
{{ success }}
+
+
+ } + +
+ + @if (loading) { +
+
+ Chargement... +
+

Chargement du profil...

+
+ } + + + @if (user && !loading) { + +
+ +
+
+
Profil Utilisateur Marchand
+
+
+ +
+
+ {{ getUserInitials() }} +
+
+ +
{{ getUserDisplayName() }}
+

@{{ user.username }}

+ + + + + {{ getRoleDisplayName(user.role) }} + + + + + {{ getStatusText() }} + + + +
+
+ + {{ user.email }} + @if (!user.emailVerified) { + + } +
+
+ + + {{ user.merchantPartnerId }} + +
+
+ + Créé le {{ getCreationDate() }} +
+ @if (user.lastLogin) { +
+ + Dernière connexion : {{ getLastLoginDate() }} +
+ } +
+
+
+ + +
+
+
Rôle Utilisateur
+
+
+ +
+ + + {{ getRoleDisplayName(user.role) }} + + + {{ getRoleDescription(user.role) }} + +
+ + +
+ + Information : 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é. + +
+
+
+ + +
+
+
Informations de Création
+
+
+
+
+ Créé par : +
{{ getCreatorName() }}
+
+
+ Date de création : +
{{ getCreationDate() }}
+
+
+ Type d'utilisateur : +
+ {{ user.userType }} +
+
+
+ Merchant Partner : +
+ {{ user.merchantPartnerId }} +
+
+
+
+
+
+ + +
+
+
+
+ @if (isEditing) { + + Modification du Profil + } @else { + + Détails du Compte + } +
+ + @if (isEditing) { +
+ + +
+ } +
+ +
+
+ +
+ + @if (isEditing) { + + } @else { +
+ {{ user.firstName || 'Non renseigné' }} +
+ } +
+ + +
+ + @if (isEditing) { + + } @else { +
+ {{ user.lastName || 'Non renseigné' }} +
+ } +
+ + +
+ +
+ {{ user.username }} +
+
+ Le nom d'utilisateur ne peut pas être modifié +
+
+ + +
+ + @if (isEditing) { + + @if (editedUser.email && !isValidEmail(editedUser.email)) { +
+ Format d'email invalide +
+ } + } @else { +
+ {{ user.email }} + @if (!user.emailVerified) { + Non vérifié + } +
+ } +
+ + +
+ +
+ + {{ getRoleDisplayName(user.role) }} + +
+
+ Rôle assigné à la création +
+
+ + +
+ +
+ {{ user.merchantPartnerId }} +
+
+ Identifiant du partenaire marchand +
+
+ + + @if (isEditing) { +
+
+ + +
+
+ L'utilisateur peut se connecter si activé +
+
+ } @else { +
+ +
+ + {{ getStatusText() }} + +
+
+ } + + + @if (!isEditing) { +
+
+
+ + Informations Système +
+
+
+ +
+ {{ user.id }} +
+
+
+ +
+ {{ getCreationDate() }} +
+
+
+ +
+ {{ getCreatorName() }} +
+
+
+ +
+ {{ user.userType }} +
+
+ @if (user.lastLogin) { +
+ +
+ {{ getLastLoginDate() }} +
+
+ } +
+
+ } +
+
+
+ + + @if (!isEditing) { +
+
+
Actions de Gestion
+
+
+
+
+ +
+
+ @if (user.enabled) { + + } @else { + + } +
+
+ +
+
+ + +
+ + Note : Pour supprimer cet utilisateur, utilisez l'action de suppression + disponible dans la liste des utilisateurs marchands. + +
+
+
+ } +
+ } +
+
\ No newline at end of file diff --git a/src/app/modules/merchant-partners/profile/profile.spec.ts b/src/app/modules/merchant-partners/profile/profile.spec.ts new file mode 100644 index 0000000..334a750 --- /dev/null +++ b/src/app/modules/merchant-partners/profile/profile.spec.ts @@ -0,0 +1,2 @@ +import { PartnerTeamProfile } from './profile'; +describe('PartnerTeamProfile', () => {}); \ No newline at end of file diff --git a/src/app/modules/merchant-partners/profile/profile.ts b/src/app/modules/merchant-partners/profile/profile.ts new file mode 100644 index 0000000..2278a0d --- /dev/null +++ b/src/app/modules/merchant-partners/profile/profile.ts @@ -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(); + + @Input() userId!: string; + @Output() back = new EventEmitter(); + @Output() openResetPasswordModal = new EventEmitter(); + + 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; + } +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/services/merchant-partners.service.ts b/src/app/modules/merchant-partners/services/merchant-partners.service.ts new file mode 100644 index 0000000..e9425f8 --- /dev/null +++ b/src/app/modules/merchant-partners/services/merchant-partners.service.ts @@ -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 { + return this.http.get(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 { + return this.http.get(`${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 { + return this.http.get(`${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 { + // 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(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 { + return this.http.put(`${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 { + return this.http.get(`${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 { + 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(`${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 { + return this.http.get(`${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 { + return this.updateMerchantUser(id, { enabled: true }); + } + + /** + * Désactive un utilisateur marchand + */ + disableMerchantUser(id: string): Observable { + 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 { + return this.searchMerchantUsers({ role }); + } + + /** + * Récupère uniquement les utilisateurs actifs + */ + getActiveMerchantUsers(): Observable { + return this.searchMerchantUsers({ enabled: true }); + } + + /** + * Récupère uniquement les utilisateurs inactifs + */ + getInactiveMerchantUsers(): Observable { + return this.searchMerchantUsers({ enabled: false }); + } +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/services/partner-config.service.ts b/src/app/modules/merchant-partners/services/partner-config.service.ts new file mode 100644 index 0000000..83a579d --- /dev/null +++ b/src/app/modules/merchant-partners/services/partner-config.service.ts @@ -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> { + return this.http.post>(`${this.apiUrl}`, createPartnerDto).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Obtenir toutes les une config marchands avec pagination + */ + findAllPartnersConfig(query: PartnerQuery = new PartnerQuery()): Observable { + const params = this.buildQueryParams(query); + + return this.http.get(`${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> { + return this.http.get>(`${this.apiUrl}/${merchantId}`).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Mettre à jour une config marchand + */ + updatePartnerConfig(merchantId: string, updateData: UpdatePartnerDto): Observable> { + return this.http.put>(`${this.apiUrl}/${merchantId}`, updateData).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Supprimer une config marchand + */ + deletePartnerConfig(merchantId: string): Observable> { + return this.http.delete>(`${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> { + return this.http.put>( + `${this.apiUrl}/${merchantId}/callbacks`, + updateData + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Obtenir la configuration des callbacks d'un marchand + */ + getCallbacksConfig(merchantId: string): Observable> { + return this.http.get>( + `${this.apiUrl}/${merchantId}/callbacks` + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Tester un webhook spécifique + */ + testWebhookConfig(merchantId: string, webhookType: string, payload: any = {}): Observable> { + return this.http.post>( + `${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> { + const params = this.buildQueryParams(query); + return this.http.get>( + `${this.apiUrl}/${merchantId}/callbacks/logs`, + { params } + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + // ==================== GESTION DES STATISTIQUES ==================== + + /** + * Obtenir les statistiques d'un marchand + */ + getPartnerStats(merchantId: string): Observable> { + return this.http.get>( + `${this.apiUrl}/${merchantId}/stats` + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Obtenir mes statistiques (marchand connecté) + */ + getMyStats(): Observable> { + return this.http.get>( + `${this.apiUrl}/me/stats` + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Obtenir les statistiques globales (admin seulement) + */ + getGlobalStats(): Observable> { + return this.http.get>( + `${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> { + return this.http.post>( + `${this.apiUrl}/${merchantId}/api-keys`, + {} + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Révoker les clés API d'un marchand + */ + revokeApiKeys(merchantId: string): Observable> { + return this.http.delete>( + `${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> { + return this.http.put>( + `${this.apiUrl}/${merchantId}/api-keys/regenerate-secret`, + {} + ).pipe( + catchError(error => throwError(() => error)) + ); + } + + /** + * Obtenir les clés API d'un marchand + */ + getApiKeys(merchantId: string): Observable> { + return this.http.get>( + `${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> { + return this.http.get>( + `${this.apiUrl}/${merchantId}/validate` + ).pipe( + catchError(error => throwError(() => error)) + ); + } +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/stats/stats.html b/src/app/modules/merchant-partners/stats/stats.html new file mode 100644 index 0000000..72cd000 --- /dev/null +++ b/src/app/modules/merchant-partners/stats/stats.html @@ -0,0 +1,249 @@ + + + Vue d'ensemble des utilisateurs de votre écosystème marchand + + +
+ @if (!stats) { +
+
+ Chargement... +
+

Chargement des statistiques...

+
+ } + + @if (stats) { +
+ +
+
+
+
+
+

Total Utilisateurs

+

{{ stats.totalUsers }}

+

+ + + Équipe complète + +

+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+

Administrateurs

+

{{ stats.totalAdmins }}

+

+ + + Accès complet + +

+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+

Managers

+

{{ stats.totalManagers }}

+

+ + + Gestion opérationnelle + +

+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+

Support

+

{{ stats.totalSupport }}

+

+ + + Assistance client + +

+
+
+
+ +
+
+
+
+
+
+ + +
+
+
+
Utilisateurs Actifs
+
+

{{ stats.activeUsers }}

+

Comptes activés

+
+
+
+ {{ (stats.activeUsers / stats.totalUsers * 100).toFixed(1) }}% +
+
+ + {{ stats.activeUsers }} sur {{ stats.totalUsers }} utilisateurs + +
+
+
+ +
+
+
+
Utilisateurs Inactifs
+
+

{{ stats.inactiveUsers }}

+

Comptes désactivés

+
+
+
+ {{ (stats.inactiveUsers / stats.totalUsers * 100).toFixed(1) }}% +
+
+ + {{ stats.inactiveUsers }} sur {{ stats.totalUsers }} utilisateurs + +
+
+
+ +
+
+
+
Répartition des Rôles
+
+
+

{{ stats.totalAdmins }}

+ Admins +
+
+

{{ stats.totalManagers }}

+ Managers +
+
+

{{ stats.totalSupport }}

+ Support +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
Synthèse de l'Équipe
+
+
+
+
{{ stats.totalUsers }}
+ Total Membres +
+
+
+
+
{{ stats.activeUsers }}
+ Actifs +
+
+
+
+
{{ stats.inactiveUsers }}
+ Inactifs +
+
+
+
{{ stats.totalAdmins + stats.totalManagers + stats.totalSupport }}
+ Avec Rôle Défini +
+
+ + +
+
+ +
+ + Votre équipe marchande est composée de {{ stats.totalAdmins }} administrateurs, + {{ stats.totalManagers }} managers et {{ stats.totalSupport }} agents de support. + {{ stats.activeUsers }} utilisateurs sont actuellement actifs. + +
+
+
+
+
+
+
+ } +
+
\ No newline at end of file diff --git a/src/app/modules/merchant-partners/stats/stats.spec.ts b/src/app/modules/merchant-partners/stats/stats.spec.ts new file mode 100644 index 0000000..98ccd87 --- /dev/null +++ b/src/app/modules/merchant-partners/stats/stats.spec.ts @@ -0,0 +1,2 @@ +import { MerchantPartnerStats } from './stats'; +describe('Merchant Partner Stats', () => {}); \ No newline at end of file diff --git a/src/app/modules/merchant-partners/stats/stats.ts b/src/app/modules/merchant-partners/stats/stats.ts new file mode 100644 index 0000000..ca3b9d2 --- /dev/null +++ b/src/app/modules/merchant-partners/stats/stats.ts @@ -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; +} \ No newline at end of file diff --git a/src/app/modules/merchant-partners/types.ts b/src/app/modules/merchant-partners/types.ts new file mode 100644 index 0000000..d63fd5b --- /dev/null +++ b/src/app/modules/merchant-partners/types.ts @@ -0,0 +1,6 @@ +export type WizardStepType = { + id: string + icon: string + title: string + subtitle: string +} diff --git a/src/app/modules/merchants/data.ts b/src/app/modules/merchants/data.ts deleted file mode 100644 index 9410624..0000000 --- a/src/app/modules/merchants/data.ts +++ /dev/null @@ -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', - }, -] diff --git a/src/app/modules/merchants/merchants.html b/src/app/modules/merchants/merchants.html deleted file mode 100644 index 8a4b5fa..0000000 --- a/src/app/modules/merchants/merchants.html +++ /dev/null @@ -1,588 +0,0 @@ -
- - - -
-
- - -
-
-
-
\ No newline at end of file diff --git a/src/app/modules/merchants/merchants.spec.ts b/src/app/modules/merchants/merchants.spec.ts deleted file mode 100644 index d64efe2..0000000 --- a/src/app/modules/merchants/merchants.spec.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { Merchants } from './merchants'; -describe('Merchants', () => {}); \ No newline at end of file diff --git a/src/app/modules/merchants/merchants.ts b/src/app/modules/merchants/merchants.ts deleted file mode 100644 index f423005..0000000 --- a/src/app/modules/merchants/merchants.ts +++ /dev/null @@ -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[] = [ - 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(); - } - } -} \ No newline at end of file diff --git a/src/app/modules/merchants/models/merchant.models.ts b/src/app/modules/merchants/models/merchant.models.ts deleted file mode 100644 index 1dea78d..0000000 --- a/src/app/modules/merchants/models/merchant.models.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/app/modules/merchants/services/merchants.service.ts b/src/app/modules/merchants/services/merchants.service.ts deleted file mode 100644 index 41aa8d0..0000000 --- a/src/app/modules/merchants/services/merchants.service.ts +++ /dev/null @@ -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 { - return this.http.post(`${this.apiUrl}/register`, registration); - } - - // Configuration des webhooks - updateCallbacks(partnerId: string, callbacks: CallbackConfiguration): Observable { - return this.http.put(`${this.apiUrl}/${partnerId}/callbacks`, callbacks); - } - - // Récupération de tous les merchants - getAllMerchants(): Observable { - return this.http.get(`${this.apiUrl}`); - } - - // Récupération d'un merchant par ID - getMerchantById(partnerId: string): Observable { - return this.http.get(`${this.apiUrl}/${partnerId}`); - } - - // Statistiques d'un merchant - getMerchantStats(partnerId: string): Observable { - return this.http.get(`${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 { - return this.http.patch(`${this.apiUrl}/${partnerId}`, { status }); - } -} \ No newline at end of file diff --git a/src/app/modules/merchants/wizard-with-progress.ts b/src/app/modules/merchants/wizard-with-progress.ts deleted file mode 100644 index bba8641..0000000 --- a/src/app/modules/merchants/wizard-with-progress.ts +++ /dev/null @@ -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: ` - - Exclusive -
- - - - -
- @for (step of wizardSteps; track $index; let i = $index) { -
- @switch (i) { - @case (0) { -
-
- - -
-
- - -
-
- - -
-
- - -
-
- } - @case (1) { -
-
- - -
-
- - -
-
- - -
-
- - -
-
- } - @case (2) { -
-
- - -
-
- - -
-
- - -
-
- - -
-
- } - @case (3) { -
-
- - -
-
- - -
-
- - -
-
- - -
-
- } - @case (4) { -
- - -
-
- - -
- } - } - -
- @if (i > 0) { - - } - @if (i < wizardSteps.length - 1) { - - } - @if (i === wizardSteps.length - 1) { - - } -
-
- } -
-
-
- `, - 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 -} diff --git a/src/app/modules/modules.routes.ts b/src/app/modules/modules.routes.ts index f76ae14..b439dad 100644 --- a/src/app/modules/modules.routes.ts +++ b/src/app/modules/modules.routes.ts @@ -8,7 +8,7 @@ import { Users } from '@modules/users/users'; import { DcbDashboard } from './dcb-dashboard/dcb-dashboard'; import { Team } from './team/team'; import { Transactions } from './transactions/transactions'; -import { Merchants } from './merchants/merchants'; +import { MerchantPartners } from './merchant-partners/merchant-partners'; import { OperatorsConfig } from './operators/config/config'; import { OperatorsStats } from './operators/stats/stats'; import { WebhooksHistory } from './webhooks/history/history'; @@ -85,16 +85,23 @@ const routes: Routes = [ }, // --------------------------- - // Merchants + // Partners // --------------------------- { - path: 'merchants', - component: Merchants, + path: 'merchant-partners', + component: MerchantPartners, canActivate: [authGuard, roleGuard], data: { - title: 'Gestion des Merchants', - module: 'merchants', - requiredRoles: ['admin', 'support'] + title: 'Gestion Partners/Marchants', + module: 'merchant-partners', + 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) // --------------------------- { - path: 'support', + path: 'dcb-support', component: Support, canActivate: [authGuard, roleGuard], data: { title: 'Support', - module: 'support' + module: 'dcb-support' } }, { diff --git a/src/app/modules/profile/profile.html b/src/app/modules/profile/profile.html index 6a8a697..09c1cd1 100644 --- a/src/app/modules/profile/profile.html +++ b/src/app/modules/profile/profile.html @@ -1,4 +1,3 @@ -
@@ -7,20 +6,60 @@

@if (user) { - Mon Profil - {{ getUserDisplayName() }} + {{ getUserDisplayName() }} } @else { - Mon Profil + Profil Utilisateur }

- @if (user && !isEditing) { + + @if (user && canEditUsers && !isEditing) { + + + + @if (user.enabled) { + + } @else { + + } + +
+ + @if (currentUserRole && !canEditUsers) { +
+
+
+
+ +
+ Permissions limitées : Vous ne pouvez que consulter ce profil +
+
+
+
+
+ } + @if (error) {
- - {{ error }} +
+ +
{{ error }}
+
} @if (success) {
- - {{ success }} +
+ +
{{ success }}
+
} @@ -56,7 +115,7 @@
Chargement...
-

Chargement de votre profil...

+

Chargement du profil...

} @@ -67,12 +126,12 @@
-
Mon Profil
+
Profil Utilisateur Hub
-
+
{{ getUserInitials() }}
@@ -90,43 +149,112 @@
{{ user.email }} + @if (!user.emailVerified) { + + }
- Membre depuis {{ formatTimestamp(user.createdTimestamp) }} + Créé le {{ formatTimestamp(user.createdTimestamp) }}
-
- - Vous pouvez modifier votre mot de passe ici -
-
- - -
- + @if (user.lastLogin) { +
+ + Dernière connexion : {{ formatTimestamp(user.lastLogin) }} +
+ }
- +
-
-
Mes Rôles
+
+
Rôle Utilisateur
+ @if (canManageRoles && !isEditing) { + Modifiable + }
-
- @for (role of user.clientRoles; track role) { - - {{ role }} - - } + +
+ + + {{ getRoleLabel(user.role) }} + + + {{ getRoleDescription(user.role) }} + +
+ + + @if (canManageRoles && !isEditing) { +
+ + +
+ @if (updatingRoles) { +
+ Mise à jour... +
+ Mise à jour en cours... + } @else { + Sélectionnez un nouveau rôle pour cet utilisateur + } +
+
+ } @else if (!canManageRoles) { +
+ + + Vous n'avez pas la permission de modifier les rôles + +
+ } +
+
+ + +
+
+
Informations de Création
+
+
+
+
+ Créé par : +
{{ user.createdByUsername || 'Système' }}
+
+
+ Date de création : +
{{ formatTimestamp(user.createdTimestamp) }}
+
+
+ Type d'utilisateur : +
+ {{ user.userType }} +
+
@@ -135,14 +263,44 @@
-
+
@if (isEditing) { + Modification du Profil } @else { - Mes Informations + + Détails du Compte }
+ + @if (isEditing) { +
+ + +
+ }
@@ -155,7 +313,8 @@ type="text" class="form-control" [(ngModel)]="editedUser.firstName" - placeholder="Votre prénom" + placeholder="Entrez le prénom" + [disabled]="saving" > } @else {
@@ -172,7 +331,8 @@ type="text" class="form-control" [(ngModel)]="editedUser.lastName" - placeholder="Votre nom" + placeholder="Entrez le nom" + [disabled]="saving" > } @else {
@@ -184,10 +344,12 @@
-
+
{{ user.username }}
- Le nom d'utilisateur ne peut pas être modifié +
+ Le nom d'utilisateur ne peut pas être modifié +
@@ -198,56 +360,45 @@ type="email" class="form-control" [(ngModel)]="editedUser.email" - placeholder="votre@email.com" + placeholder="email@exemple.com" + [disabled]="saving" > } @else {
{{ user.email }} + @if (!user.emailVerified) { + Non vérifié + }
}
- -
-
-
- - Sécurité du Compte -
-
-
-
- Mot de passe -
- Vous pouvez changer votre mot de passe à tout moment -
+ + @if (isEditing) { +
+
+ + +
+
+ L'utilisateur peut se connecter si activé
-
- - - @if (isEditing) { -
-
- - + } @else { +
+ +
+ + {{ getStatusText() }} +
} @@ -256,11 +407,14 @@ @if (!isEditing) {

-
Informations Système
+
+ + Informations Système +
-
+
{{ user.id }}
@@ -270,136 +424,75 @@ {{ formatTimestamp(user.createdTimestamp) }}
+
+ +
+ {{ user.createdByUsername || 'Système' }} +
+
+
+ +
+ {{ user.userType }} +
+
}
-
- } -
-
- - - - - - - - - \ No newline at end of file +
\ No newline at end of file diff --git a/src/app/modules/profile/profile.spec.ts b/src/app/modules/profile/profile.spec.ts index c0121b2..f7968a6 100644 --- a/src/app/modules/profile/profile.spec.ts +++ b/src/app/modules/profile/profile.spec.ts @@ -1,2 +1,2 @@ -import { Profile } from './profile'; -describe('Profile', () => {}); \ No newline at end of file +import { MyProfile } from './profile'; +describe('MyProfile', () => {}); \ No newline at end of file diff --git a/src/app/modules/profile/profile.ts b/src/app/modules/profile/profile.ts index d924c91..886ac2e 100644 --- a/src/app/modules/profile/profile.ts +++ b/src/app/modules/profile/profile.ts @@ -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 { FormsModule } from '@angular/forms'; import { NgIcon } from '@ng-icons/core'; -import { NgbAlertModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; -import { UsersService } from '@modules/users/services/users.service'; +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; +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 { UserResponse, UpdateUserDto } from '@modules/users/models/user'; @Component({ selector: 'app-my-profile', standalone: true, - imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule, NgbModalModule], + imports: [CommonModule, FormsModule, NgIcon, NgbAlertModule], templateUrl: './profile.html', styles: [` .avatar-lg { @@ -22,129 +24,120 @@ import { UserResponse, UpdateUserDto } from '@modules/users/models/user'; } `] }) -export class MyProfile implements OnInit { - private usersService = inject(UsersService); +export class MyProfile implements OnInit, OnDestroy { + private usersService = inject(HubUsersService); + private roleService = inject(RoleManagementService); private authService = inject(AuthService); - private modalService = inject(NgbModal); private cdRef = inject(ChangeDetectorRef); + private destroy$ = new Subject(); - @ViewChild('resetPasswordModal') resetPasswordModal!: TemplateRef; + @Output() back = new EventEmitter(); + @Output() openResetPasswordModal = new EventEmitter(); - user: UserResponse | null = null; + user: any | null = null; loading = false; saving = false; error = ''; 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 isEditing = false; - editedUser: UpdateUserDto = {}; - - // Réinitialisation mot de passe - newPassword = ''; - temporaryPassword = false; - resettingPassword = false; - resetPasswordError = ''; - resetPasswordSuccess = ''; + editedUser: UpdateHubUserDto = {}; + + // Gestion des rôles (simplifiée pour profil personnel) + availableRoles: { value: UserRole; label: string; description: string }[] = []; + updatingRoles = false; 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.error = ''; - this.usersService.getCurrentUserProfile().subscribe({ - next: (user) => { - this.user = user; - this.loading = false; - this.cdRef.detectChanges(); - }, - error: (error) => { - this.error = 'Erreur lors du chargement de votre profil'; - this.loading = false; - this.cdRef.detectChanges(); - console.error('Error loading my 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); + this.usersService.getUserById(this.user.id) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (user) => { + this.user = user; + this.loading = false; + this.cdRef.detectChanges(); + }, + error: (error) => { + this.error = 'Erreur lors du chargement de votre profil'; + this.loading = false; + this.cdRef.detectChanges(); + console.error('Error loading user profile:', error); } - }, - 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() { + // Pas de vérification de permission pour le profil personnel this.isEditing = true; this.editedUser = { firstName: this.user?.firstName, lastName: this.user?.lastName, email: this.user?.email + // On ne permet pas de modifier 'enabled' sur son propre profil }; + this.cdRef.detectChanges(); } cancelEditing() { @@ -152,6 +145,7 @@ export class MyProfile implements OnInit { this.editedUser = {}; this.error = ''; this.success = ''; + this.cdRef.detectChanges(); } saveProfile() { @@ -161,23 +155,70 @@ export class MyProfile implements OnInit { this.error = ''; this.success = ''; - this.usersService.updateCurrentUserProfile(this.editedUser).subscribe({ - next: (updatedUser) => { - this.user = updatedUser; - this.isEditing = false; - this.saving = false; - this.success = 'Profil mis à jour avec succès'; - this.editedUser = {}; - }, - error: (error) => { - this.error = 'Erreur lors de la mise à jour du profil'; - this.saving = false; - console.error('Error updating profile:', error); - } - }); + this.usersService.updateUser(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(); + } + }); } - // 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 { if (!this.user) return 'badge bg-secondary'; if (!this.user.enabled) return 'badge bg-danger'; @@ -193,6 +234,7 @@ export class MyProfile implements OnInit { } formatTimestamp(timestamp: number): string { + if (!timestamp) return 'Non disponible'; return new Date(timestamp).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', @@ -215,13 +257,30 @@ export class MyProfile implements OnInit { return this.user.username; } - getRoleBadgeClass(role: string): string { - switch (role) { - case 'admin': return 'bg-danger'; - case 'merchant': return 'bg-success'; - case 'support': return 'bg-info'; - case 'user': return 'bg-secondary'; - default: return 'bg-secondary'; - } + 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'; + } + + // 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 } } \ No newline at end of file diff --git a/src/app/modules/transactions/details/details.spec.ts b/src/app/modules/transactions/details/details.spec.ts index 3425686..861599d 100644 --- a/src/app/modules/transactions/details/details.spec.ts +++ b/src/app/modules/transactions/details/details.spec.ts @@ -1,2 +1,2 @@ -import { TransactionsDetails } from './details'; -describe('TransactionsDetails', () => {}); \ No newline at end of file +import { TransactionDetails } from './details'; +describe('TransactionDetails', () => {}); \ No newline at end of file diff --git a/src/app/modules/users/list/list.html b/src/app/modules/users/list/list.html index 45a44ae..60ee7ef 100644 --- a/src/app/modules/users/list/list.html +++ b/src/app/modules/users/list/list.html @@ -1,30 +1,71 @@ - + Gérez les accès utilisateurs de votre plateforme + >Gérez les accès utilisateurs de votre plateforme DCB
-
- +
+ +
+ + + + +
+
+
+
+
+ @if (canCreateUsers) { + + }
-
+
@@ -32,20 +73,20 @@
-
+
-
+
+ +
+
@@ -77,8 +127,10 @@ @if (error && !loading) { } @@ -100,7 +152,12 @@
- Rôles + +
+ Rôle + +
+
Statut @@ -113,7 +170,7 @@
- Actions + Actions @@ -121,35 +178,35 @@
-
- +
+ {{ getUserInitials(user) }}
- {{ getUserDisplayName(user) }} -
@{{ user.username }}
+ {{ getUserDisplayName(user) }} + @{{ user.username }}
-
{{ user.email }}
- @if (!user.emailVerified) { - - - Non vérifié - - } +
+ {{ user.email }} + @if (!user.emailVerified) { + + } +
- @for (role of user.clientRoles; track role) { - - {{ role }} - - } - @if (user.clientRoles.length === 0) { - Aucun rôle - } + + + {{ getRoleLabel(user.role) }} + @@ -171,7 +228,7 @@ @if (user.enabled) { } @else { + } + @if (canDeleteUsers) { + } -
@@ -208,11 +267,17 @@ @empty { - -

Aucun utilisateur trouvé

- +
+ +
Aucun utilisateur trouvé
+

Aucun utilisateur ne correspond à vos critères de recherche.

+ @if (canCreateUsers) { + + } +
} @@ -221,6 +286,7 @@
+ @if (totalPages > 1) {
Affichage de {{ getStartIndex() }} à {{ getEndIndex() }} sur {{ totalItems }} utilisateurs @@ -237,7 +303,40 @@ />
+ } + + + @if (displayedUsers.length > 0) { +
+
+
+ + Total : {{ allUsers.length }} utilisateurs + +
+
+ + Actifs : {{ getEnabledUsersCount() }} + +
+
+ + Admins : {{ getUsersCountByRole(UserRole.DCB_ADMIN) }} + +
+
+ + Support : {{ getUsersCountByRole(UserRole.DCB_SUPPORT) }} + +
+
+ + Partenaires : {{ getUsersCountByRole(UserRole.DCB_PARTNER) }} + +
+
+
+ } }
- \ No newline at end of file diff --git a/src/app/modules/users/list/list.ts b/src/app/modules/users/list/list.ts index e913f94..3051d8d 100644 --- a/src/app/modules/users/list/list.ts +++ b/src/app/modules/users/list/list.ts @@ -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 { FormsModule } from '@angular/forms'; import { NgIcon } from '@ng-icons/core'; import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'; -import { UsersService } from '../services/users.service'; -import { UserResponse } from '../models/user'; +import { Subject, takeUntil } from 'rxjs'; +import { HubUsersService, HubUserResponse, UserRole } from '../services/users.service'; +import { RoleManagementService } from '@core/services/role-management.service'; import { UiCard } from '@app/components/ui-card'; @Component({ @@ -19,21 +21,26 @@ import { UiCard } from '@app/components/ui-card'; ], templateUrl: './list.html', }) -export class UsersList implements OnInit { - private usersService = inject(UsersService); +export class UsersList implements OnInit, OnDestroy { + private usersService = inject(HubUsersService); + private roleService = inject(RoleManagementService); private cdRef = inject(ChangeDetectorRef); + private destroy$ = new Subject(); + + readonly UserRole = UserRole; + + @Input() canCreateUsers: boolean = false; + @Input() canDeleteUsers: boolean = false; @Output() userSelected = new EventEmitter(); @Output() openCreateModal = new EventEmitter(); @Output() openResetPasswordModal = new EventEmitter(); @Output() openDeleteUserModal = new EventEmitter(); - - // Données - allUsers: UserResponse[] = []; - filteredUsers: UserResponse[] = []; - displayedUsers: UserResponse[] = []; + allUsers: HubUserResponse[] = []; + filteredUsers: HubUserResponse[] = []; + displayedUsers: HubUserResponse[] = []; // États loading = false; @@ -43,6 +50,7 @@ export class UsersList implements OnInit { searchTerm = ''; statusFilter: 'all' | 'enabled' | 'disabled' = 'all'; emailVerifiedFilter: 'all' | 'verified' | 'not-verified' = 'all'; + roleFilter: UserRole | 'all' = 'all'; // Pagination currentPage = 1; @@ -51,31 +59,46 @@ export class UsersList implements OnInit { totalPages = 0; // Tri - sortField: keyof UserResponse = 'username'; + sortField: keyof HubUserResponse = 'username'; 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() { this.loadUsers(); } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + loadUsers() { this.loading = true; this.error = ''; - this.usersService.findAllUsers().subscribe({ - next: (response) => { - this.allUsers = response.data; - this.applyFiltersAndPagination(); - this.loading = false; - this.cdRef.detectChanges(); - }, - error: (error) => { - this.error = 'Erreur lors du chargement des utilisateurs'; - this.loading = false; - this.cdRef.detectChanges(); - console.error('Error loading users:', error); - } - }); + this.usersService.findAllUsers() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response) => { + this.allUsers = response.users; + this.applyFiltersAndPagination(); + this.loading = false; + this.cdRef.detectChanges(); + }, + error: (error) => { + this.error = 'Erreur lors du chargement des utilisateurs'; + this.loading = false; + this.cdRef.detectChanges(); + console.error('Error loading users:', error); + } + }); } // Recherche et filtres @@ -88,6 +111,7 @@ export class UsersList implements OnInit { this.searchTerm = ''; this.statusFilter = 'all'; this.emailVerifiedFilter = 'all'; + this.roleFilter = 'all'; this.currentPage = 1; this.applyFiltersAndPagination(); } @@ -112,7 +136,10 @@ export class UsersList implements OnInit { (this.emailVerifiedFilter === '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 @@ -145,7 +172,7 @@ export class UsersList implements OnInit { } // Tri - sort(field: keyof UserResponse) { + sort(field: keyof HubUserResponse) { if (this.sortField === field) { this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; } else { @@ -155,7 +182,7 @@ export class UsersList implements OnInit { this.applyFiltersAndPagination(); } - getSortIcon(field: keyof UserResponse): string { + getSortIcon(field: keyof HubUserResponse): string { if (this.sortField !== field) return 'lucideArrowUpDown'; return this.sortDirection === 'asc' ? 'lucideArrowUp' : 'lucideArrowDown'; } @@ -180,79 +207,125 @@ export class UsersList implements OnInit { } // Méthode pour réinitialiser le mot de passe - resetPassword(user: UserResponse) { + resetPassword(user: HubUserResponse) { this.openResetPasswordModal.emit(user.id); } // Méthode pour ouvrir le modal de suppression - deleteUser(user: UserResponse) { - this.openDeleteUserModal.emit(user.id); + deleteUser(user: HubUserResponse) { + if (this.canDeleteUsers) { + this.openDeleteUserModal.emit(user.id); + } } - - enableUser(user: UserResponse) { - this.usersService.enableUser(user.id).subscribe({ - next: () => { - user.enabled = true; - this.applyFiltersAndPagination(); - this.cdRef.detectChanges(); - }, - error: (error) => { - console.error('Error enabling user:', error); - alert('Erreur lors de l\'activation de l\'utilisateur'); - } - }); + enableUser(user: HubUserResponse) { + this.usersService.enableUser(user.id) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + user.enabled = true; + this.applyFiltersAndPagination(); + this.cdRef.detectChanges(); + }, + error: (error) => { + console.error('Error enabling user:', error); + this.error = 'Erreur lors de l\'activation de l\'utilisateur'; + this.cdRef.detectChanges(); + } + }); } - disableUser(user: UserResponse) { - this.usersService.disableUser(user.id).subscribe({ - next: () => { - user.enabled = false; - this.applyFiltersAndPagination(); - this.cdRef.detectChanges(); - }, - error: (error) => { - console.error('Error disabling user:', error); - alert('Erreur lors de la désactivation de l\'utilisateur'); - } - }); + disableUser(user: HubUserResponse) { + this.usersService.disableUser(user.id) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + user.enabled = false; + this.applyFiltersAndPagination(); + this.cdRef.detectChanges(); + }, + 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 - getStatusBadgeClass(user: UserResponse): string { + getStatusBadgeClass(user: HubUserResponse): string { if (!user.enabled) return 'badge bg-danger'; if (!user.emailVerified) return 'badge bg-warning'; return 'badge bg-success'; } - getStatusText(user: UserResponse): string { + getStatusText(user: HubUserResponse): string { if (!user.enabled) return 'Désactivé'; if (!user.emailVerified) return 'Email non vérifié'; return 'Actif'; } - getRoleBadgeClass(role: string): string { - switch (role) { - case 'admin': return 'bg-danger'; - case 'merchant': return 'bg-success'; - case 'support': return 'bg-info'; - case 'user': return 'bg-secondary'; - default: return 'bg-secondary'; - } + 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); } 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'; } - getUserDisplayName(user: UserResponse): string { + getUserDisplayName(user: HubUserResponse): string { if (user.firstName && user.lastName) { return `${user.firstName} ${user.lastName}`; } 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(); + } } \ No newline at end of file diff --git a/src/app/modules/users/models/hub-user.model.ts b/src/app/modules/users/models/hub-user.model.ts new file mode 100644 index 0000000..58d6f00 --- /dev/null +++ b/src/app/modules/users/models/hub-user.model.ts @@ -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; +} \ No newline at end of file diff --git a/src/app/modules/users/models/user.model.ts b/src/app/modules/users/models/user.model.ts new file mode 100644 index 0000000..e58c6ec --- /dev/null +++ b/src/app/modules/users/models/user.model.ts @@ -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 = {}; + clientRoles: string[] = []; + createdTimestamp?: number; + + constructor(partial?: Partial) { + 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 = {}; + + @IsOptional() + @IsArray() + clientRoles: string[] = []; + + constructor(partial?: Partial) { + 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; + + @IsOptional() + @IsArray() + clientRoles?: string[]; + + constructor(partial?: Partial) { + 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) { + 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) { + 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 = {}; + 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) { + if (partial) { + Object.assign(this, partial); + } + } +} + +export class LoginDto { + @IsString() + username: string = ''; + + @IsString() + password: string = ''; + + constructor(partial?: Partial) { + 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) { + if (partial) { + Object.assign(this, partial); + } + } +} + +export class ApiResponse { + data: T | null = null; + message: string = ''; + status: string = ''; + + constructor(partial?: Partial>) { + 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) { + 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 = {}; + + constructor(partial?: Partial) { + 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'; \ No newline at end of file diff --git a/src/app/modules/users/models/user.ts b/src/app/modules/users/models/user.ts deleted file mode 100644 index fb4284c..0000000 --- a/src/app/modules/users/models/user.ts +++ /dev/null @@ -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) { - // 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) { - 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) { - if (partial) { - Object.assign(this, partial); - } - } -} - -export class ResetPasswordDto { - userId: string; - newPassword: string; - temporary?: boolean; - - constructor(partial?: Partial) { - // 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) { - 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 { - 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 }; -} \ No newline at end of file diff --git a/src/app/modules/users/profile/profile.html b/src/app/modules/users/profile/profile.html index dc9afa7..09c1cd1 100644 --- a/src/app/modules/users/profile/profile.html +++ b/src/app/modules/users/profile/profile.html @@ -14,26 +14,36 @@
- - @if (user && !isEditing) { + + @if (user && canEditUsers && !isEditing) { + @if (user.enabled) { } @else { } +
+ + @if (currentUserRole && !canEditUsers) { +
+
+
+
+ +
+ Permissions limitées : Vous ne pouvez que consulter ce profil +
+
+
+
+
+ } + @if (error) {
- - {{ error }} +
+ +
{{ error }}
+
} @if (success) {
- - {{ success }} +
+ +
{{ success }}
+
} @@ -95,12 +126,12 @@
-
Profil Keycloak
+
Profil Utilisateur Hub
-
+
{{ getUserInitials() }}
@@ -118,70 +149,115 @@
{{ user.email }} + @if (!user.emailVerified) { + + }
Créé le {{ formatTimestamp(user.createdTimestamp) }}
+ @if (user.lastLogin) { +
+ + Dernière connexion : {{ formatTimestamp(user.lastLogin) }} +
+ }
- +
-
Rôles Client
- @if (!isEditing) { - +
Rôle Utilisateur
+ @if (canManageRoles && !isEditing) { + Modifiable }
-
- @for (role of availableRoles; track role) { -
-
- - -
-
- } + +
+ + + {{ getRoleLabel(user.role) }} + + + {{ getRoleDescription(user.role) }} +
- - - @if (user.clientRoles.length > 0) { + + + @if (canManageRoles && !isEditing) {
-
Rôles assignés :
- @for (role of user.clientRoles; track role) { - - {{ role }} - - } + + +
+ @if (updatingRoles) { +
+ Mise à jour... +
+ Mise à jour en cours... + } @else { + Sélectionnez un nouveau rôle pour cet utilisateur + } +
+
+ } @else if (!canManageRoles) { +
+ + + Vous n'avez pas la permission de modifier les rôles +
}
+ + +
+
+
Informations de Création
+
+
+
+
+ Créé par : +
{{ user.createdByUsername || 'Système' }}
+
+
+ Date de création : +
{{ formatTimestamp(user.createdTimestamp) }}
+
+
+ Type d'utilisateur : +
+ {{ user.userType }} +
+
+
+
+
@@ -190,8 +266,10 @@
@if (isEditing) { + Modification du Profil } @else { + Détails du Compte }
@@ -200,10 +278,11 @@
} + Enregistrer
@@ -234,10 +314,11 @@ class="form-control" [(ngModel)]="editedUser.firstName" placeholder="Entrez le prénom" + [disabled]="saving" > } @else {
- {{ user.firstName || 'Non défini' }} + {{ user.firstName || 'Non renseigné' }}
}
@@ -251,10 +332,11 @@ class="form-control" [(ngModel)]="editedUser.lastName" placeholder="Entrez le nom" + [disabled]="saving" > } @else {
- {{ user.lastName || 'Non défini' }} + {{ user.lastName || 'Non renseigné' }}
}
@@ -262,18 +344,12 @@
- @if (isEditing) { - - } @else { -
- {{ user.username }} -
- } +
+ {{ user.username }} +
+
+ Le nom d'utilisateur ne peut pas être modifié +
@@ -285,10 +361,14 @@ class="form-control" [(ngModel)]="editedUser.email" placeholder="email@exemple.com" + [disabled]="saving" > } @else {
{{ user.email }} + @if (!user.emailVerified) { + Non vérifié + }
}
@@ -302,25 +382,23 @@ type="checkbox" id="enabledSwitch" [(ngModel)]="editedUser.enabled" + [disabled]="saving" >
+
+ L'utilisateur peut se connecter si activé +
- - + } @else {
-
- - + +
+ + {{ getStatusText() }} +
} @@ -329,11 +407,14 @@ @if (!isEditing) {

-
Informations Système
+
+ + Informations Système +
-
+
{{ user.id }}
@@ -343,12 +424,74 @@ {{ formatTimestamp(user.createdTimestamp) }}
+
+ +
+ {{ user.createdByUsername || 'Système' }} +
+
+
+ +
+ {{ user.userType }} +
+
}
+ + + @if (!isEditing && canEditUsers) { +
+
+
Actions de Gestion
+
+
+
+
+ +
+
+ @if (user.enabled) { + + } @else { + + } +
+
+ +
+
+
+
+ } } diff --git a/src/app/modules/users/profile/profile.spec.ts b/src/app/modules/users/profile/profile.spec.ts index 2e2cb81..032e4d3 100644 --- a/src/app/modules/users/profile/profile.spec.ts +++ b/src/app/modules/users/profile/profile.spec.ts @@ -1,2 +1,2 @@ -import { UsersProfile } from './profile'; -describe('UsersProfile', () => {}); \ No newline at end of file +import { UserProfile } from './profile'; +describe('UserProfile', () => {}); \ No newline at end of file diff --git a/src/app/modules/users/profile/profile.ts b/src/app/modules/users/profile/profile.ts index e2188fc..328062a 100644 --- a/src/app/modules/users/profile/profile.ts +++ b/src/app/modules/users/profile/profile.ts @@ -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 { FormsModule } from '@angular/forms'; import { NgIcon } from '@ng-icons/core'; import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; -import { UsersService } from '../services/users.service'; -import { UserResponse, UpdateUserDto, ClientRole } from '../models/user'; +import { Subject, takeUntil } from 'rxjs'; +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({ selector: 'app-user-profile', @@ -21,66 +24,126 @@ import { UserResponse, UpdateUserDto, ClientRole } from '../models/user'; } `] }) -export class UserProfile implements OnInit { - private usersService = inject(UsersService); +export class UserProfile implements OnInit, OnDestroy { + private usersService = inject(HubUsersService); + private roleService = inject(RoleManagementService); + private authService = inject(AuthService); private cdRef = inject(ChangeDetectorRef); + private destroy$ = new Subject(); @Input() userId!: string; - @Output() openResetPasswordModal = new EventEmitter(); + @Output() back = new EventEmitter(); + @Output() openResetPasswordModal = new EventEmitter(); - user: UserResponse | null = null; + user: HubUserResponse | null = null; loading = false; saving = false; error = ''; success = ''; + // Gestion des permissions + currentUserRole: UserRole | null = null; + canEditUsers = false; + canManageRoles = false; + canDeleteUsers = false; + // Édition isEditing = false; - editedUser: UpdateUserDto = {}; + editedUser: UpdateHubUserDto = {}; // Gestion des rôles - availableRoles: ClientRole[] = ['admin', 'merchant', 'support', 'user']; - selectedRoles: ClientRole[] = []; + availableRoles: { value: UserRole; label: string; description: string }[] = []; updatingRoles = false; ngOnInit() { if (this.userId) { + this.initializeUserPermissions(); + this.loadAvailableRoles(); 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() { this.loading = true; this.error = ''; - this.usersService.getUserById(this.userId).subscribe({ - next: (user) => { - this.user = user; - this.selectedRoles = user.clientRoles - .filter((role): role is ClientRole => - this.availableRoles.includes(role as ClientRole) - ); - 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 user profile:', error); - } - }); + this.usersService.getUserById(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 user profile:', error); + } + }); } startEditing() { + if (!this.canEditUsers) { + this.error = 'Vous n\'avez pas la permission de modifier les utilisateurs'; + return; + } + this.isEditing = true; this.editedUser = { firstName: this.user?.firstName, lastName: this.user?.lastName, email: this.user?.email, - username: this.user?.username, - enabled: this.user?.enabled, - emailVerified: this.user?.emailVerified + enabled: this.user?.enabled }; this.cdRef.detectChanges(); } @@ -94,100 +157,121 @@ export class UserProfile implements OnInit { } saveProfile() { - if (!this.user) return; + if (!this.user || !this.canEditUsers) return; this.saving = true; this.error = ''; this.success = ''; - this.usersService.updateUser(this.user.id, this.editedUser).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 = 'Erreur lors de la mise à jour du profil'; - this.saving = false; - this.cdRef.detectChanges(); - console.error('Error updating user:', error); - } - }); + this.usersService.updateUser(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(); + } + }); } // Gestion des rôles - toggleRole(role: ClientRole) { - const index = this.selectedRoles.indexOf(role); - if (index > -1) { - this.selectedRoles.splice(index, 1); - } else { - this.selectedRoles.push(role); + updateUserRole(newRole: UserRole) { + if (!this.user || !this.canManageRoles) return; + + // Vérifier que l'utilisateur peut attribuer ce rôle + if (!this.roleService.canAssignRole(this.currentUserRole, newRole)) { + 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.usersService.assignClientRoles(this.user.id, this.selectedRoles).subscribe({ - next: () => { - this.updatingRoles = false; - this.success = 'Rôles mis à jour avec succès'; - if (this.user) { - this.user.clientRoles = [...this.selectedRoles]; + this.error = ''; + this.success = ''; + + this.usersService.updateUserRole(this.user.id, newRole) + .pipe(takeUntil(this.destroy$)) + .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 enableUser() { - if (!this.user) return; + if (!this.user || !this.canEditUsers) return; - this.usersService.enableUser(this.user.id).subscribe({ - next: () => { - this.user!.enabled = true; - this.success = 'Utilisateur activé avec succès'; - this.cdRef.detectChanges(); - }, - error: (error) => { - this.error = 'Erreur lors de l\'activation de l\'utilisateur'; - this.cdRef.detectChanges(); - console.error('Error enabling user:', error); - } - }); + this.usersService.enableUser(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(); + } + }); } disableUser() { - if (!this.user) return; + if (!this.user || !this.canEditUsers) return; - this.usersService.disableUser(this.user.id).subscribe({ - next: () => { - this.user!.enabled = false; - this.success = 'Utilisateur désactivé avec succès'; - this.cdRef.detectChanges(); - }, - error: (error) => { - this.error = 'Erreur lors de la désactivation de l\'utilisateur'; - this.cdRef.detectChanges(); - console.error('Error disabling user:', error); - } - }); + this.usersService.disableUser(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(); + } + }); + } + + // 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 @@ -206,6 +290,7 @@ export class UserProfile implements OnInit { } formatTimestamp(timestamp: number): string { + if (!timestamp) return 'Non disponible'; return new Date(timestamp).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', @@ -228,13 +313,31 @@ export class UserProfile implements OnInit { return this.user.username; } - getRoleBadgeClass(role: string): string { - switch (role) { - case 'admin': return 'bg-danger'; - case 'merchant': return 'bg-success'; - case 'support': return 'bg-info'; - case 'user': return 'bg-secondary'; - default: return 'bg-secondary'; - } + 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'; + } + + // 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; } } \ No newline at end of file diff --git a/src/app/modules/users/services/api.service.ts b/src/app/modules/users/services/api.service.ts new file mode 100644 index 0000000..9945345 --- /dev/null +++ b/src/app/modules/users/services/api.service.ts @@ -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 { + 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(endpoint: string, params?: any): Observable { + return this.http.get(`${this.baseUrl}/${endpoint}`, { + headers: this.getHeaders(), + params: this.createParams(params) + }).pipe( + catchError(this.handleError) + ); + } + + post(endpoint: string, data: any): Observable { + return this.http.post(`${this.baseUrl}/${endpoint}`, data, { + headers: this.getHeaders() + }).pipe( + catchError(this.handleError) + ); + } + + put(endpoint: string, data: any): Observable { + return this.http.put(`${this.baseUrl}/${endpoint}`, data, { + headers: this.getHeaders() + }).pipe( + catchError(this.handleError) + ); + } + + delete(endpoint: string): Observable { + return this.http.delete(`${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); + } +} \ No newline at end of file diff --git a/src/app/modules/users/services/users.service.ts b/src/app/modules/users/services/users.service.ts index 7f4fa74..eeda969 100644 --- a/src/app/modules/users/services/users.service.ts +++ b/src/app/modules/users/services/users.service.ts @@ -1,31 +1,80 @@ +// src/app/modules/users/services/users.service.ts 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 { Observable, map, catchError, throwError } from 'rxjs'; -import { of } from 'rxjs'; +import { Observable, map, catchError, throwError, 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 { - UserResponse, - CreateUserDto, - UpdateUserDto, - ResetPasswordDto, - PaginatedUserResponse, - ClientRole -} from '../models/user'; +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 { + 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' }) -export class UsersService { +export class HubUsersService { private http = inject(HttpClient); - private apiUrl = `${environment.iamApiUrl}/users`; + private apiUrl = `${environment.iamApiUrl}/hub-users`; // === CRUD COMPLET === - createUser(createUserDto: CreateUserDto): Observable { + + /** + * Crée un nouvel utilisateur Hub + */ + createUser(createUserDto: CreateHubUserDto): Observable { // Validation - if (!createUserDto.username || createUserDto.username.trim() === '') { + if (!createUserDto.username?.trim()) { 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'); } @@ -33,6 +82,10 @@ export class UsersService { return throwError(() => 'Password must be at least 8 characters'); } + if (!createUserDto.role) { + return throwError(() => 'Role is required'); + } + // Nettoyage des données const payload = { username: createUserDto.username.trim(), @@ -40,129 +93,230 @@ export class UsersService { firstName: (createUserDto.firstName || '').trim(), lastName: (createUserDto.lastName || '').trim(), password: createUserDto.password, + role: createUserDto.role, enabled: createUserDto.enabled !== undefined ? createUserDto.enabled : true, emailVerified: createUserDto.emailVerified !== undefined ? createUserDto.emailVerified : false, - clientRoles: createUserDto.clientRoles || [] }; - return this.http.post(`${this.apiUrl}`, payload).pipe( + return this.http.post(this.apiUrl, payload).pipe( catchError(error => throwError(() => error)) ); } - // READ - Obtenir tous les utilisateurs - findAllUsers(): Observable { - return this.http.get<{ - users: any[]; - total: number; - page: number; - limit: number; - totalPages: number; - }>(`${this.apiUrl}`).pipe( + /** + * Récupère tous les utilisateurs Hub avec pagination + */ + findAllUsers(page: number = 1, limit: number = 10, filters?: any): Observable { + 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(this.apiUrl, { params, observe: 'response' }).pipe( map(response => { - const users = response.users.map(user => new UserResponse(user)); - return new PaginatedUserResponse(users, response.total, response.page, response.limit); + 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 of(new PaginatedUserResponse([], 0, 1, 10)); + return throwError(() => error); }) ); } - // READ - Obtenir un utilisateur par ID - getUserById(id: string): Observable { - return this.http.get(`${this.apiUrl}/${id}`).pipe( - map(response => new UserResponse(response)) + /** + * Récupère tous les utilisateurs Hub avec pagination + */ + findAllMerchantUsers(page: number = 1, limit: number = 10, filters?: any): Observable { + 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(`${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 { - return this.http.get(`${this.apiUrl}/profile/me`).pipe( - map(response => new UserResponse(response)) - ); + /** + * Récupère un utilisateur Hub par ID + */ + getUserById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); } - // UPDATE - Mettre à jour un utilisateur - updateUser(id: string, updateUserDto: UpdateUserDto): Observable { - return this.http.put(`${this.apiUrl}/${id}`, updateUserDto).pipe( - map(response => new UserResponse(response)) - ); + /** + * Met à jour un utilisateur Hub + */ + updateUser(id: string, updateUserDto: UpdateHubUserDto): Observable { + return this.http.put(`${this.apiUrl}/${id}`, updateUserDto); } - // UPDATE - Mettre à jour le profil de l'utilisateur connecté - updateCurrentUserProfile(updateUserDto: UpdateUserDto): Observable { - return this.http.put(`${this.apiUrl}/profile/me`, updateUserDto).pipe( - map(response => new UserResponse(response)) - ); - } - - // DELETE - Supprimer un utilisateur + /** + * Supprime un utilisateur Hub + */ deleteUser(id: string): Observable<{ message: string }> { return this.http.delete<{ message: string }>(`${this.apiUrl}/${id}`); } // === GESTION DES MOTS DE PASSE === - resetPassword(resetPasswordDto: ResetPasswordDto): Observable<{ message: string }> { - return this.http.put<{ message: string }>( - `${this.apiUrl}/${resetPasswordDto.userId}/password`, - resetPasswordDto + + /** + * Réinitialise le mot de passe d'un utilisateur + */ + 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 === - enableUser(id: string): Observable<{ message: string }> { - return this.http.put<{ message: string }>(`${this.apiUrl}/${id}/enable`, {}); + + /** + * Active un utilisateur + */ + enableUser(id: string): Observable { + return this.http.put(`${this.apiUrl}/${id}`, { enabled: true }); } - disableUser(id: string): Observable<{ message: string }> { - return this.http.put<{ message: string }>(`${this.apiUrl}/${id}/disable`, {}); - } - - // === RECHERCHE ET VÉRIFICATION === - userExists(username: string): Observable<{ exists: boolean }> { - return this.http.get<{ exists: boolean }>(`${this.apiUrl}/check/${username}`); - } - - findUserByUsername(username: string): Observable { - return this.http.get(`${this.apiUrl}/search/username/${username}`).pipe( - map(users => users.map(user => new UserResponse(user))) - ); - } - - findUserByEmail(email: string): Observable { - return this.http.get(`${this.apiUrl}/search/email/${email}`).pipe( - map(users => users.map(user => new UserResponse(user))) - ); + /** + * Désactive un utilisateur + */ + disableUser(id: string): Observable { + return this.http.put(`${this.apiUrl}/${id}`, { enabled: false }); } // === 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 { + return this.http.put(`${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 { + return this.http.get( + `${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 { - return this.http.get(`${this.apiUrl}/${userId}/sessions`); + /** + * Récupère les utilisateurs par rôle + */ + getUsersByRole(role: UserRole): Observable { + return this.http.get(`${this.apiUrl}/role/${role}`); } - - logoutUser(userId: string): Observable<{ message: string }> { - return this.http.post<{ message: string }>(`${this.apiUrl}/${userId}/logout`, {}); - } - + // === STATISTIQUES === - getUserStats(): Observable<{ - total: number; - enabled: number; - disabled: number; - emailVerified: number; - emailNotVerified: number; - }> { - return this.http.get(`${this.apiUrl}/stats`); + + /** + * Récupère les statistiques des utilisateurs + */ + getUsersStats(): Observable { + return this.http.get(`${this.apiUrl}/stats/overview`); + } + + // === 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 { + 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()) + )) + ); } } \ No newline at end of file diff --git a/src/app/modules/users/users.html b/src/app/modules/users/users.html index 0d9ad50..059b7d6 100644 --- a/src/app/modules/users/users.html +++ b/src/app/modules/users/users.html @@ -5,6 +5,42 @@ [badge]="{icon:'lucideUsers', text:'Keycloak Users'}" /> + + @if (currentUserRole) { +
+
+
+
+ +
+ + Rôle actuel : + + {{ roleService.getRoleLabel(currentUserRole) }} + + @if (!canCreateUsers) { + + + Permissions limitées + + } + +
+ @if (canCreateUsers) { + + } +
+
+
+
+ } +
@@ -22,6 +58,8 @@ } + + @if (!canManageRoles && assignableRoles.length === 1) { +
+ + + Permissions limitées : Vous ne pouvez créer que des utilisateurs avec le rôle + + {{ roleService.getRoleLabel(assignableRoles[0]) }} + + +
+ } +
@@ -158,6 +209,64 @@
+ +
+ + +
+ @if (canManageRoles) { + Sélectionnez le rôle à assigner à cet utilisateur + } @else { + Vous ne pouvez pas modifier les rôles disponibles + } +
+
+ + + @if (newUser.role) { +
+
+
+ +
+ Rôle sélectionné : + + {{ roleService.getRoleLabel(newUser.role) }} + +
+ + {{ getRoleDescription(newUser.role) }} + +
+
+
+
+ } +
@@ -192,51 +301,6 @@
L'utilisateur n'aura pas à vérifier son email
- - -
- -
- @for (role of availableRoles; track role) { -
-
- - -
-
- } -
-
Sélectionnez les rôles à assigner à cet utilisateur
-
- - - @if (selectedRoles.length > 0) { -
-
- Rôles sélectionnés : - @for (role of selectedRoles; track role) { - {{ role }} - } -
-
- }