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 0000000..770058d Binary files /dev/null and b/src/app/modules.zip differ 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: ` +
+
+
+
+
+
+
+ +

+ We've emailed you a 6-digit verification code. Please enter + it below to confirm your email address +

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

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

+

+ Return to + Sign in +

+
+
+ +

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

+
+
+
+
+ `, + 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 }} - } -
-
- }