diff --git a/src/app/guards/admin-guard.spec.ts b/src/app/guards/admin-guard.spec.ts index 508ca94..76ca519 100644 --- a/src/app/guards/admin-guard.spec.ts +++ b/src/app/guards/admin-guard.spec.ts @@ -1,7 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Authorization } from '@services/authorization/authorization'; import { adminGuard } from './admin-guard'; +import { firstValueFrom, Observable, of } from 'rxjs'; describe('adminGuard', () => { let authorizationSpy: jasmine.SpyObj; @@ -14,8 +15,9 @@ describe('adminGuard', () => { const dummyState = {} as RouterStateSnapshot; beforeEach(() => { - authorizationSpy = jasmine.createSpyObj('Authorization', ['isAdmin']); - routerSpy = jasmine.createSpyObj('Router', ['navigate']); + authorizationSpy = jasmine.createSpyObj('Authorization', ['validateSession']); + routerSpy = jasmine.createSpyObj('Router', ['parseUrl']); + routerSpy.parseUrl.and.returnValue({} as UrlTree); TestBed.configureTestingModule({ providers: [ @@ -25,17 +27,27 @@ describe('adminGuard', () => { }); }); - it('should allow access when user is admin', () => { - authorizationSpy.isAdmin.and.returnValue(true); - const result = executeGuard(dummyRoute, dummyState); + it('should allow access when user is admin', async () => { + authorizationSpy.validateSession.and.returnValue(of({ + username: 'adminuser', + email: 'admin@example.com', + role: 'ADMIN' + })); + const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable); expect(result).toBeTrue(); - expect(routerSpy.navigate).not.toHaveBeenCalled(); + expect(routerSpy.parseUrl).not.toHaveBeenCalled(); }); - it('should deny access and redirect to / when user is not admin', () => { - authorizationSpy.isAdmin.and.returnValue(false); - const result = executeGuard(dummyRoute, dummyState); - expect(result).toBeFalse(); - expect(routerSpy.navigate).toHaveBeenCalledWith(['/']); + it('should deny access and redirect to / when user is not admin', async () => { + const homeUrlTree = {} as UrlTree; + authorizationSpy.validateSession.and.returnValue(of({ + username: 'testuser', + email: 'test@example.com', + role: 'USER' + })); + routerSpy.parseUrl.and.returnValue(homeUrlTree); + const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable); + expect(result).toBe(homeUrlTree); + expect(routerSpy.parseUrl).toHaveBeenCalledWith('/'); }); }); diff --git a/src/app/guards/admin-guard.ts b/src/app/guards/admin-guard.ts index 9939006..6cf1199 100644 --- a/src/app/guards/admin-guard.ts +++ b/src/app/guards/admin-guard.ts @@ -1,16 +1,20 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { Authorization } from '@services/authorization/authorization'; +import { map } from 'rxjs'; export const adminGuard: CanActivateFn = (_route, state) => { const authorization = inject(Authorization); const router = inject(Router); - if (authorization.isAdmin()) { - return true; - } + return authorization.validateSession().pipe( + map((user) => { + if (user?.role === 'ADMIN') { + return true; + } - console.warn(`[adminGuard] Access denied to ${state.url} — user is not admin, redirecting to /`); - router.navigate(['/']); - return false; + console.warn(`[adminGuard] Access denied to ${state.url} — user is not admin, redirecting to /`); + return router.parseUrl('/'); + }) + ); }; diff --git a/src/app/guards/auth-guard.spec.ts b/src/app/guards/auth-guard.spec.ts index 68250cd..be63aba 100644 --- a/src/app/guards/auth-guard.spec.ts +++ b/src/app/guards/auth-guard.spec.ts @@ -1,7 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router'; +import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Authorization } from '@services/authorization/authorization'; import { authGuard } from './auth-guard'; +import { firstValueFrom, Observable, of } from 'rxjs'; describe('authGuard', () => { let authorizationSpy: jasmine.SpyObj; @@ -14,8 +15,9 @@ describe('authGuard', () => { const dummyState = {} as RouterStateSnapshot; beforeEach(() => { - authorizationSpy = jasmine.createSpyObj('Authorization', ['isLoggedIn']); - routerSpy = jasmine.createSpyObj('Router', ['navigate']); + authorizationSpy = jasmine.createSpyObj('Authorization', ['validateSession']); + routerSpy = jasmine.createSpyObj('Router', ['parseUrl']); + routerSpy.parseUrl.and.returnValue({} as UrlTree); TestBed.configureTestingModule({ providers: [ @@ -25,17 +27,23 @@ describe('authGuard', () => { }); }); - it('should allow access when logged in', () => { - authorizationSpy.isLoggedIn.and.returnValue(true); - const result = executeGuard(dummyRoute, dummyState); + it('should allow access when logged in', async () => { + authorizationSpy.validateSession.and.returnValue(of({ + username: 'testuser', + email: 'test@example.com', + role: 'USER' + })); + const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable); expect(result).toBeTrue(); - expect(routerSpy.navigate).not.toHaveBeenCalled(); + expect(routerSpy.parseUrl).not.toHaveBeenCalled(); }); - it('should deny access and redirect to /login when not logged in', () => { - authorizationSpy.isLoggedIn.and.returnValue(false); - const result = executeGuard(dummyRoute, dummyState); - expect(result).toBeFalse(); - expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']); + it('should deny access and redirect to /login when not logged in', async () => { + const loginUrlTree = {} as UrlTree; + authorizationSpy.validateSession.and.returnValue(of(null)); + routerSpy.parseUrl.and.returnValue(loginUrlTree); + const result = await firstValueFrom(executeGuard(dummyRoute, dummyState) as Observable); + expect(result).toBe(loginUrlTree); + expect(routerSpy.parseUrl).toHaveBeenCalledWith('/login'); }); }); diff --git a/src/app/guards/auth-guard.ts b/src/app/guards/auth-guard.ts index b2aae9d..aa25a10 100644 --- a/src/app/guards/auth-guard.ts +++ b/src/app/guards/auth-guard.ts @@ -1,15 +1,20 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { Authorization } from '@services/authorization/authorization'; +import { map } from 'rxjs'; export const authGuard: CanActivateFn = (_route, state) => { const authService = inject(Authorization); const router = inject(Router); - if (authService.isLoggedIn()) { - return true; - } else { + + return authService.validateSession().pipe( + map((user) => { + if (user) { + return true; + } + console.warn(`[authGuard] Access denied to ${state.url} — user not logged in, redirecting to /login`); - router.navigate(['/login']); - return false; - } + return router.parseUrl('/login'); + }) + ); }; diff --git a/src/app/interceptors/auth-token.interceptor.ts b/src/app/interceptors/auth-token.interceptor.ts index e20293d..aea7dae 100644 --- a/src/app/interceptors/auth-token.interceptor.ts +++ b/src/app/interceptors/auth-token.interceptor.ts @@ -14,7 +14,8 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => { const requestPath = req.url.split('?')[0]; const isAuthEndpoint = requestPath.endsWith('/auth/login') || - requestPath.endsWith('/auth/register'); + requestPath.endsWith('/auth/register') || + requestPath.endsWith('/auth/me'); return next(req).pipe( catchError((error: unknown) => { diff --git a/src/app/models/user.ts b/src/app/models/user.ts index 181fd22..61c87da 100644 --- a/src/app/models/user.ts +++ b/src/app/models/user.ts @@ -50,6 +50,9 @@ export type UserStatistics = { executionsSucceeded: number; executionsFailed: number; simulationsStarted: number; + loginCount: number; + avgSessionDurationSeconds: number; + lastLoginAt: string | null; lastFlowUpdateAt: string | null; lastExecutionAt: number | null; }; diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.ts b/src/app/pages/admin/admin-create-user/admin-create-user.ts index f6da9cd..d266478 100644 --- a/src/app/pages/admin/admin-create-user/admin-create-user.ts +++ b/src/app/pages/admin/admin-create-user/admin-create-user.ts @@ -10,7 +10,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { AdminCreateUserRequest, UserRole } from '@models/user'; import { Router } from '@angular/router'; -import { Authorization } from '@services/authorization/authorization'; +import { AdminService } from '@services/admin/admin'; import { FormUtility } from '@utilities/form-utility'; import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChecks } from '@utilities/password-validation'; @@ -32,7 +32,7 @@ import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChec changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminCreateUserPage extends FormUtility { - private authorization = inject(Authorization); + private adminService = inject(AdminService); private router = inject(Router); readonly createError = signal(null); @@ -92,7 +92,7 @@ export class AdminCreateUserPage extends FormUtility { this.createEmailError.set(null); this.createPasswordError.set(null); - this.authorization.createAdminUser(this.createModel()).subscribe({ + this.adminService.createAdminUser(this.createModel()).subscribe({ next: () => { this.createSaving.set(false); this.successMessage.set('User created successfully.'); diff --git a/src/app/pages/admin/admin-stats/admin-stats.html b/src/app/pages/admin/admin-stats/admin-stats.html index 2753700..b8e90c9 100644 --- a/src/app/pages/admin/admin-stats/admin-stats.html +++ b/src/app/pages/admin/admin-stats/admin-stats.html @@ -69,6 +69,14 @@
{{ currentStats.simulationsStarted }}
Started in simulation mode
+ + @if (selectedUsername() && selectedUserStats(); as userStats) { + +
Authentications
+
{{ userStats.loginCount }}
+
Average session {{ formatDuration(userStats.avgSessionDurationSeconds) }}
+
+ } @@ -83,6 +91,11 @@ @if (!selectedUsername() && operationsStats(); as overviewStats) {
Users count: {{ overviewStats.usersCount }}
} + @if (selectedUsername() && selectedUserStats(); as userStats) { +
Login count: {{ userStats.loginCount }}
+
Average session duration: {{ formatDuration(userStats.avgSessionDurationSeconds) }}
+
Last login: {{ userStats.lastLoginAt ? (userStats.lastLoginAt | date:'dd/MM/yyyy HH:mm') : 'N/A' }}
+ }
Last flow update: {{ currentStats.lastFlowUpdateAt ? (currentStats.lastFlowUpdateAt | date:'dd/MM/yyyy HH:mm') : 'N/A' }}
Last execution: {{ currentStats.lastExecutionAt ? (currentStats.lastExecutionAt | date:'dd/MM/yyyy HH:mm') : 'N/A' }}
diff --git a/src/app/pages/admin/admin-stats/admin-stats.ts b/src/app/pages/admin/admin-stats/admin-stats.ts index e79559c..19c369e 100644 --- a/src/app/pages/admin/admin-stats/admin-stats.ts +++ b/src/app/pages/admin/admin-stats/admin-stats.ts @@ -7,6 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { OperationsStatistics, UserStatistics } from '@models/user'; +import { AdminService } from '@services/admin/admin'; import { Authorization } from '@services/authorization/authorization'; @Component({ @@ -25,6 +26,7 @@ import { Authorization } from '@services/authorization/authorization'; changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminStatsPage { + private adminService = inject(AdminService); private authorization = inject(Authorization); readonly users = signal([]); @@ -47,6 +49,23 @@ export class AdminStatsPage { this.selectedUsername() ? this.selectedUserStats() : this.operationsStats() ); + formatDuration(totalSeconds: number | null | undefined): string { + const seconds = Math.max(0, Math.trunc(Number(totalSeconds ?? 0))); + if (!seconds) return '0m'; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainingSeconds = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } + return `${remainingSeconds}s`; + } + ngOnInit() { this.loadUsers(); this.loadOperationsStats(); @@ -56,7 +75,7 @@ export class AdminStatsPage { if (!this.authorization.isAdmin()) return; this.loadingUsers.set(true); this.pageError.set(null); - this.authorization.listStatisticsUsers().subscribe({ + this.adminService.listStatisticsUsers().subscribe({ next: (users) => { this.users.set(users); this.loadingUsers.set(false); @@ -73,7 +92,7 @@ export class AdminStatsPage { if (!this.authorization.isAdmin()) return; this.loadingOverview.set(true); this.statsError.set(null); - this.authorization.getOperationsStatistics().subscribe({ + this.adminService.getOperationsStatistics().subscribe({ next: (stats) => { this.operationsStats.set(stats); this.loadingOverview.set(false); @@ -104,7 +123,7 @@ export class AdminStatsPage { this.loadingUserStats.set(true); this.statsError.set(null); - this.authorization.getUserStatistics(username).subscribe({ + this.adminService.getUserStatistics(username).subscribe({ next: (stats) => { this.selectedUserStats.set(stats); this.loadingUserStats.set(false); diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.ts b/src/app/pages/admin/admin-users-list/admin-users-list.ts index 20356d6..0db20f1 100644 --- a/src/app/pages/admin/admin-users-list/admin-users-list.ts +++ b/src/app/pages/admin/admin-users-list/admin-users-list.ts @@ -8,7 +8,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { AdminUser, UserRole } from '@models/user'; import { Router } from '@angular/router'; -import { Authorization } from '@services/authorization/authorization'; +import { AdminService } from '@services/admin/admin'; import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog'; @@ -29,7 +29,7 @@ import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password- changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminUsersListPage { - private authorization = inject(Authorization); + private adminService = inject(AdminService); private confirmDialog = inject(ConfirmDialogService); private router = inject(Router); @@ -52,7 +52,7 @@ export class AdminUsersListPage { loadUsers() { this.loading.set(true); this.pageError.set(null); - this.authorization.listAdminUsers().subscribe({ + this.adminService.listAdminUsers().subscribe({ next: (users) => { this.users.set(users); this.roleDraftByUser.set( @@ -86,7 +86,7 @@ export class AdminUsersListPage { this.roleSavingByUser.update((current) => ({ ...current, [user.username]: true })); this.roleErrorByUser.update((current) => ({ ...current, [user.username]: null })); - this.authorization.changeAdminUserRole(user.username, { role: nextRole }).subscribe({ + this.adminService.changeAdminUserRole(user.username, { role: nextRole }).subscribe({ next: () => { this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false })); this.successMessage.set(`Role updated for ${user.username}.`); @@ -118,7 +118,7 @@ export class AdminUsersListPage { submitResetPassword(event: { username: string; newPassword: string }) { this.resetPasswordSaving.set(true); this.resetPasswordError.set(null); - this.authorization.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({ + this.adminService.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({ next: () => { this.resetPasswordSaving.set(false); this.resetPasswordDialogUser.set(null); @@ -138,7 +138,7 @@ export class AdminUsersListPage { if (!confirmed) return; this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: true })); - this.authorization.deleteAdminUser(user.username).subscribe({ + this.adminService.deleteAdminUser(user.username).subscribe({ next: () => { this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false })); this.successMessage.set(`User ${user.username} deleted.`); diff --git a/src/app/pages/admin/admin-users/admin-users.ts b/src/app/pages/admin/admin-users/admin-users.ts index f45c012..ae69b3b 100644 --- a/src/app/pages/admin/admin-users/admin-users.ts +++ b/src/app/pages/admin/admin-users/admin-users.ts @@ -13,7 +13,7 @@ import { AdminUser, UserRole } from '@models/user'; -import { Authorization } from '@services/authorization/authorization'; +import { AdminService } from '@services/admin/admin'; import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog'; import { FormUtility } from '@utilities/form-utility'; @@ -38,7 +38,7 @@ import { hasValidPasswordComplexity, evaluatePasswordChecks, initialPasswordChec changeDetection: ChangeDetectionStrategy.OnPush }) export class AdminUsersPage extends FormUtility { - private authorization = inject(Authorization); + private adminService = inject(AdminService); private confirmDialog = inject(ConfirmDialogService); readonly users = signal([]); @@ -106,7 +106,7 @@ export class AdminUsersPage extends FormUtility { loadUsers() { this.loading.set(true); this.pageError.set(null); - this.authorization.listAdminUsers().subscribe({ + this.adminService.listAdminUsers().subscribe({ next: (users) => { this.users.set(users); this.roleDraftByUser.set( @@ -132,7 +132,7 @@ export class AdminUsersPage extends FormUtility { this.createEmailError.set(null); this.createPasswordError.set(null); - this.authorization.createAdminUser(this.createModel()).subscribe({ + this.adminService.createAdminUser(this.createModel()).subscribe({ next: () => { this.createSaving.set(false); this.successMessage.set('User created successfully.'); @@ -189,7 +189,7 @@ export class AdminUsersPage extends FormUtility { this.roleSavingByUser.update((current) => ({ ...current, [user.username]: true })); this.roleErrorByUser.update((current) => ({ ...current, [user.username]: null })); - this.authorization.changeAdminUserRole(user.username, { role: nextRole }).subscribe({ + this.adminService.changeAdminUserRole(user.username, { role: nextRole }).subscribe({ next: () => { this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false })); this.successMessage.set(`Role updated for ${user.username}.`); @@ -220,7 +220,7 @@ export class AdminUsersPage extends FormUtility { submitResetPassword(event: { username: string; newPassword: string }) { this.resetPasswordSaving.set(true); this.resetPasswordError.set(null); - this.authorization.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({ + this.adminService.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({ next: () => { this.resetPasswordSaving.set(false); this.resetPasswordDialogUser.set(null); @@ -239,7 +239,7 @@ export class AdminUsersPage extends FormUtility { if (!confirmed) return; this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: true })); - this.authorization.deleteAdminUser(user.username).subscribe({ + this.adminService.deleteAdminUser(user.username).subscribe({ next: () => { this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false })); this.successMessage.set(`User ${user.username} deleted.`); diff --git a/src/app/pages/auth/login/login.ts b/src/app/pages/auth/login/login.ts index b532771..1179b87 100644 --- a/src/app/pages/auth/login/login.ts +++ b/src/app/pages/auth/login/login.ts @@ -51,6 +51,13 @@ export class Login extends FormUtility { } ngOnInit(): void { + this.authService.validateSession().subscribe({ + next: (user) => { + if (user) { + void this.router.navigate(['/']); + } + } + }); const registered = history.state?.registered; if (registered) { this.registeredUser.set(registered); diff --git a/src/app/services/admin/admin-call.base.ts b/src/app/services/admin/admin-call.base.ts new file mode 100644 index 0000000..b12e23b --- /dev/null +++ b/src/app/services/admin/admin-call.base.ts @@ -0,0 +1,27 @@ +import { + AdminChangeRoleRequest, + AdminCreateUserRequest, + AdminResetPasswordRequest, + AdminUser, + OperationsStatistics, + UserStatistics +} from "@models/user"; +import { Observable } from "rxjs"; + +export abstract class AdminCallServiceBase { + abstract listAdminUsers(): Observable; + + abstract createAdminUser(request: AdminCreateUserRequest): Observable; + + abstract changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable; + + abstract changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable; + + abstract deleteAdminUser(username: string): Observable; + + abstract getOperationsStatistics(): Observable; + + abstract listStatisticsUsers(): Observable; + + abstract getUserStatistics(username: string): Observable; +} diff --git a/src/app/services/admin/admin-call.fake.ts b/src/app/services/admin/admin-call.fake.ts new file mode 100644 index 0000000..675839b --- /dev/null +++ b/src/app/services/admin/admin-call.fake.ts @@ -0,0 +1,165 @@ +import { Observable } from "rxjs"; +import { AdminCallServiceBase } from "./admin-call.base"; +import { + AdminChangeRoleRequest, + AdminCreateUserRequest, + AdminResetPasswordRequest, + AdminUser, + OperationsStatistics, + UserRegistration, + UserRole, + UserStatistics +} from "@models/user"; + +export class AdminCallFakeService extends AdminCallServiceBase { + private users: Array = [ + { username: 'testuser', email: 'testuser@example.com', password: 'Password123!', role: 'USER' }, + { username: 'adminuser', email: 'admin@example.com', password: 'Adminpass1!', role: 'ADMIN' }, + ]; + + override listAdminUsers(): Observable { + return new Observable((observer) => { + observer.next(this.users.map((user) => ({ + username: user.username, + email: user.email, + role: user.role + }))); + observer.complete(); + }); + } + + override createAdminUser(request: AdminCreateUserRequest): Observable { + return new Observable((observer) => { + if (!request.username || !request.password || !request.email) { + observer.error(new Error('username, password and email are required')); + return; + } + if (this.users.find((user) => user.username === request.username)) { + observer.error(new Error('the user already exists')); + return; + } + this.users.push({ + username: request.username, + password: request.password, + email: request.email, + role: request.role === 'ADMIN' ? 'ADMIN' : 'USER' + }); + observer.next(); + observer.complete(); + }); + } + + override changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable { + return new Observable((observer) => { + const user = this.users.find((candidate) => candidate.username === username); + if (!user) { + observer.error(new Error(`User ${username} not found`)); + return; + } + if (!request.newPassword) { + observer.error(new Error('username and newPassword are required')); + return; + } + user.password = request.newPassword; + observer.next(); + observer.complete(); + }); + } + + override changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable { + return new Observable((observer) => { + const user = this.users.find((candidate) => candidate.username === username); + if (!user) { + observer.error(new Error(`User ${username} not found`)); + return; + } + const nextRole: UserRole = request.role === 'ADMIN' ? 'ADMIN' : request.role === 'USER' ? 'USER' : null as never; + if (!nextRole) { + observer.error(new Error('INVALID_ROLE')); + return; + } + if (user.role === 'ADMIN' && nextRole !== 'ADMIN' && this.users.filter((candidate) => candidate.role === 'ADMIN').length === 1) { + observer.error(new Error('LAST_ADMIN')); + return; + } + user.role = nextRole; + observer.next(); + observer.complete(); + }); + } + + override deleteAdminUser(username: string): Observable { + return new Observable((observer) => { + const userIndex = this.users.findIndex((candidate) => candidate.username === username); + if (userIndex < 0) { + observer.error(new Error(`User ${username} not found`)); + return; + } + if (this.users[userIndex].role === 'ADMIN' && this.users.filter((candidate) => candidate.role === 'ADMIN').length === 1) { + observer.error(new Error('LAST_ADMIN')); + return; + } + this.users.splice(userIndex, 1); + observer.next(); + observer.complete(); + }); + } + + override getOperationsStatistics(): Observable { + return new Observable((observer) => { + observer.next({ + usersCount: this.users.length, + flowsCreated: 7, + flowsPublished: 3, + flowsFinalized: 0, + executionsCreated: 0, + executionsRunning: 0, + executionsSucceeded: 0, + executionsFailed: 0, + simulationsStarted: 0, + lastFlowUpdateAt: '2026-03-26T12:18:06.100822', + lastExecutionAt: null + }); + observer.complete(); + }); + } + + override listStatisticsUsers(): Observable { + return new Observable((observer) => { + observer.next(this.users.map((user) => user.username)); + observer.complete(); + }); + } + + override getUserStatistics(username: string): Observable { + return new Observable((observer) => { + const user = this.users.find((candidate) => candidate.username === username); + if (!user) { + observer.error(new Error('User not found')); + return; + } + observer.next(this.buildStatistics(username)); + observer.complete(); + }); + } + + private buildStatistics(username: string): UserStatistics { + const base = username.length; + return { + username, + flowsCreated: base + 2, + flowsPublished: Math.max(0, base - 2), + flowsFinalized: Math.max(0, base - 4), + executionsCreated: base * 3, + executionsRunning: base % 3, + executionsSucceeded: base * 2, + executionsFailed: base % 5, + simulationsStarted: Math.max(0, base - 5), + loginCount: base + 1, + avgSessionDurationSeconds: base * 180, + lastLoginAt: '2026-04-15T08:10:00Z', + lastFlowUpdateAt: '2026-03-26T10:15:30', + lastExecutionAt: 1774520966139 + }; + } +} diff --git a/src/app/services/admin/admin-call.ts b/src/app/services/admin/admin-call.ts new file mode 100644 index 0000000..e96291a --- /dev/null +++ b/src/app/services/admin/admin-call.ts @@ -0,0 +1,202 @@ +import { + AdminChangeRoleRequest, + AdminCreateUserRequest, + AdminResetPasswordRequest, + AdminUser, + OperationsStatistics, + UserStatistics, + UserRole +} from "@models/user"; +import { HttpClient, HttpErrorResponse } from "@angular/common/http"; +import { inject } from "@angular/core"; +import { environment } from "@environment"; +import { catchError, map, Observable, throwError } from "rxjs"; +import { AdminCallServiceBase } from "./admin-call.base"; + +export class AdminCallService extends AdminCallServiceBase { + private readonly http = inject(HttpClient); + + override listAdminUsers(): Observable { + return this.http + .get(`${environment.apiUrl}/auth/admin/users`) + .pipe( + map((raw) => Array.isArray(raw) ? raw.map((item) => this.adminUserFromApi(item)) : []), + catchError((error: unknown) => this.toHttpError(error, { + 403: 'Admin access required.' + })) + ); + } + + override createAdminUser(request: AdminCreateUserRequest): Observable { + return this.http + .post(`${environment.apiUrl}/auth/admin/users`, request) + .pipe( + catchError((error: unknown) => this.toHttpError(error, { + 400: 'Unable to create user.', + 403: 'Admin access required.' + })) + ); + } + + override changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable { + return this.http + .put(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/password`, request) + .pipe( + catchError((error: unknown) => this.toHttpError(error, { + 400: 'Unable to update password.', + 403: 'Admin access required.', + 404: `User ${username} not found` + })) + ); + } + + override changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable { + return this.http + .put(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/role`, request) + .pipe( + catchError((error: unknown) => this.toHttpError(error, { + 400: 'Unable to update role.', + 403: 'Admin access required.', + 404: `User ${username} not found`, + 409: 'LAST_ADMIN' + })) + ); + } + + override deleteAdminUser(username: string): Observable { + return this.http + .delete(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}`) + .pipe( + catchError((error: unknown) => this.toHttpError(error, { + 403: 'Admin access required.', + 404: `User ${username} not found`, + 409: 'LAST_ADMIN' + })) + ); + } + + override getOperationsStatistics(): Observable { + return this.http + .get(`${environment.apiUrl}/stats`) + .pipe( + map((raw) => this.operationsStatisticsFromApi(raw)), + catchError((error: unknown) => this.toHttpError(error, { + 401: 'Unauthenticated', + 403: 'You are not allowed to view user statistics' + })) + ); + } + + override listStatisticsUsers(): Observable { + return this.http + .get(`${environment.apiUrl}/stats/users`) + .pipe( + map((raw) => Array.isArray(raw) + ? raw.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter((item) => item.length > 0) + : []), + catchError((error: unknown) => this.toHttpError(error, { + 401: 'Unauthenticated', + 403: 'You are not allowed to view user statistics' + })) + ); + } + + override getUserStatistics(username: string): Observable { + return this.http + .get(`${environment.apiUrl}/stats/users/${encodeURIComponent(username)}`) + .pipe( + map((raw) => this.userStatisticsFromApi(raw, username)), + catchError((error: unknown) => this.toHttpError(error, { + 401: 'Unauthenticated', + 403: 'You are not allowed to view user statistics', + 404: 'User not found' + })) + ); + } + + private extractHttpErrorMessage(error: HttpErrorResponse): string | null { + const payload = error.error; + if (typeof payload === 'string' && payload.trim().length > 0) { + return payload.trim(); + } + if (payload && typeof payload === 'object') { + const record = payload as Record; + const directMessage = record['message']; + if (typeof directMessage === 'string' && directMessage.trim().length > 0) { + return directMessage.trim(); + } + const errorMessage = record['error']; + if (typeof errorMessage === 'string' && errorMessage.trim().length > 0) { + return errorMessage.trim(); + } + const details = record['details']; + if (typeof details === 'string' && details.trim().length > 0) { + return details.trim(); + } + } + return null; + } + + private normalizeRole(value: unknown): UserRole { + return String(value ?? '').toUpperCase() === 'ADMIN' ? 'ADMIN' : 'USER'; + } + + private adminUserFromApi(raw: unknown): AdminUser { + const value = (raw ?? {}) as Record; + return { + username: String(value['username'] ?? ''), + email: typeof value['email'] === 'string' ? value['email'] : null, + role: this.normalizeRole(value['role']) + }; + } + + private userStatisticsFromApi(raw: unknown, username: string): UserStatistics { + const value = (raw ?? {}) as Record; + return { + username: String(value['username'] ?? username), + flowsCreated: Number(value['flowsCreated'] ?? 0), + flowsPublished: Number(value['flowsPublished'] ?? 0), + flowsFinalized: Number(value['flowsFinalized'] ?? 0), + executionsCreated: Number(value['executionsCreated'] ?? 0), + executionsRunning: Number(value['executionsRunning'] ?? 0), + executionsSucceeded: Number(value['executionsSucceeded'] ?? 0), + executionsFailed: Number(value['executionsFailed'] ?? 0), + simulationsStarted: Number(value['simulationsStarted'] ?? 0), + loginCount: Number(value['loginCount'] ?? 0), + avgSessionDurationSeconds: Number(value['avgSessionDurationSeconds'] ?? 0), + lastLoginAt: typeof value['lastLoginAt'] === 'string' ? value['lastLoginAt'] : null, + lastFlowUpdateAt: typeof value['lastFlowUpdateAt'] === 'string' ? value['lastFlowUpdateAt'] : null, + lastExecutionAt: typeof value['lastExecutionAt'] === 'number' ? value['lastExecutionAt'] : null + }; + } + + private operationsStatisticsFromApi(raw: unknown): OperationsStatistics { + const value = (raw ?? {}) as Record; + return { + usersCount: Number(value['usersCount'] ?? 0), + flowsCreated: Number(value['flowsCreated'] ?? 0), + flowsPublished: Number(value['flowsPublished'] ?? 0), + flowsFinalized: Number(value['flowsFinalized'] ?? 0), + executionsCreated: Number(value['executionsCreated'] ?? 0), + executionsRunning: Number(value['executionsRunning'] ?? 0), + executionsSucceeded: Number(value['executionsSucceeded'] ?? 0), + executionsFailed: Number(value['executionsFailed'] ?? 0), + simulationsStarted: Number(value['simulationsStarted'] ?? 0), + lastFlowUpdateAt: typeof value['lastFlowUpdateAt'] === 'string' ? value['lastFlowUpdateAt'] : null, + lastExecutionAt: typeof value['lastExecutionAt'] === 'number' ? value['lastExecutionAt'] : null + }; + } + + private toHttpError(error: unknown, fallbackByStatus: Record): Observable { + if (error instanceof HttpErrorResponse) { + const message = this.extractHttpErrorMessage(error) + ?? fallbackByStatus[error.status] + ?? 'Request failed.'; + return throwError(() => new Error(message)); + } + if (error instanceof Error) { + return throwError(() => error); + } + return throwError(() => new Error('Request failed.')); + } +} diff --git a/src/app/services/admin/admin.ts b/src/app/services/admin/admin.ts new file mode 100644 index 0000000..3d4f002 --- /dev/null +++ b/src/app/services/admin/admin.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { + AdminChangeRoleRequest, + AdminCreateUserRequest, + AdminResetPasswordRequest, + OperationsStatistics, + UserStatistics +} from '@models/user'; +import { environment } from '@environment'; +import { take } from 'rxjs'; +import { AdminCallServiceBase } from './admin-call.base'; + +@Injectable({ + providedIn: 'root', +}) +export class AdminService { + adminCall: AdminCallServiceBase = new environment.adminCallService(); + + listAdminUsers() { + return this.adminCall.listAdminUsers().pipe( + take(1) + ); + } + + createAdminUser(request: AdminCreateUserRequest) { + return this.adminCall.createAdminUser(request).pipe( + take(1) + ); + } + + changeAdminUserPassword(username: string, request: AdminResetPasswordRequest) { + return this.adminCall.changeAdminUserPassword(username, request).pipe( + take(1) + ); + } + + changeAdminUserRole(username: string, request: AdminChangeRoleRequest) { + return this.adminCall.changeAdminUserRole(username, request).pipe( + take(1) + ); + } + + deleteAdminUser(username: string) { + return this.adminCall.deleteAdminUser(username).pipe( + take(1) + ); + } + + getOperationsStatistics() { + return this.adminCall.getOperationsStatistics().pipe( + take(1) + ); + } + + listStatisticsUsers() { + return this.adminCall.listStatisticsUsers().pipe( + take(1) + ); + } + + getUserStatistics(username: string) { + return this.adminCall.getUserStatistics(username).pipe( + take(1) + ); + } +} diff --git a/src/app/services/authorization/authorization-call.base.ts b/src/app/services/authorization/authorization-call.base.ts index f5971dd..ea42f76 100644 --- a/src/app/services/authorization/authorization-call.base.ts +++ b/src/app/services/authorization/authorization-call.base.ts @@ -1,13 +1,7 @@ import { - AdminChangeRoleRequest, - AdminCreateUserRequest, - AdminResetPasswordRequest, - AdminUser, ChangePasswordRequest, - OperationsStatistics, User, UserRegistration, - UserStatistics } from "@models/user"; import { Observable } from "rxjs"; @@ -15,26 +9,12 @@ export abstract class AuthorizationCallServiceBase { abstract login(username: string, password: string): Observable; + abstract currentUser(): Observable; + abstract register(userRegistration: UserRegistration): Observable; abstract changePassword(request: ChangePasswordRequest): Observable; - abstract listAdminUsers(): Observable; - - abstract createAdminUser(request: AdminCreateUserRequest): Observable; - - abstract changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable; - - abstract changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable; - - abstract deleteAdminUser(username: string): Observable; - - abstract getOperationsStatistics(): Observable; - - abstract listStatisticsUsers(): Observable; - - abstract getUserStatistics(username: string): Observable; - abstract logout(): Observable; } diff --git a/src/app/services/authorization/authorization-call.fake.ts b/src/app/services/authorization/authorization-call.fake.ts index 5e0f5df..43bb00b 100644 --- a/src/app/services/authorization/authorization-call.fake.ts +++ b/src/app/services/authorization/authorization-call.fake.ts @@ -1,15 +1,9 @@ -import { Observable, of } from "rxjs"; +import { Observable, of, throwError } from "rxjs"; import { AuthorizationCallServiceBase } from "./authorization-call.base"; import { - AdminChangeRoleRequest, - AdminCreateUserRequest, - AdminResetPasswordRequest, - AdminUser, ChangePasswordRequest, - OperationsStatistics, User, UserRegistration, - UserStatistics, UserRole } from "@models/user"; @@ -20,6 +14,7 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase { { username: 'testuser', email: 'testuser@example.com', password: 'Password123!', role: 'USER' }, { username: 'adminuser', email: 'admin@example.com', password: 'Adminpass1!', role: 'ADMIN' }, ]; + private currentUsername: string | null = null; login(username: string, password: string): Observable { return new Observable((observer) => { @@ -37,10 +32,25 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase { email: user.email, role: user.role }); + this.currentUsername = user.username; observer.complete(); }); } + currentUser(): Observable { + const user = this.currentUsername + ? this.users.find((candidate) => candidate.username === this.currentUsername) + : null; + if (!user) { + return throwError(() => new Error('Unauthenticated')); + } + return of({ + username: user.username, + email: user.email, + role: user.role + }); + } + register(userRegistration: UserRegistration): Observable { return new Observable((observer) => { setTimeout(() => { @@ -77,150 +87,8 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase { }); } - listAdminUsers(): Observable { - return new Observable((observer) => { - observer.next(this.users.map((user) => ({ - username: user.username, - email: user.email, - role: user.role - }))); - observer.complete(); - }); - } - - createAdminUser(request: AdminCreateUserRequest): Observable { - return new Observable((observer) => { - if (!request.username || !request.password || !request.email) { - observer.error(new Error('username, password and email are required')); - return; - } - if (this.users.find((user) => user.username === request.username)) { - observer.error(new Error('the user already exists')); - return; - } - this.users.push({ - username: request.username, - password: request.password, - email: request.email, - role: request.role === 'ADMIN' ? 'ADMIN' : 'USER' - }); - observer.next(); - observer.complete(); - }); - } - - changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable { - return new Observable((observer) => { - const user = this.users.find((candidate) => candidate.username === username); - if (!user) { - observer.error(new Error(`User ${username} not found`)); - return; - } - if (!request.newPassword) { - observer.error(new Error('username and newPassword are required')); - return; - } - user.password = request.newPassword; - observer.next(); - observer.complete(); - }); - } - - changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable { - return new Observable((observer) => { - const user = this.users.find((candidate) => candidate.username === username); - if (!user) { - observer.error(new Error(`User ${username} not found`)); - return; - } - const nextRole: UserRole = request.role === 'ADMIN' ? 'ADMIN' : request.role === 'USER' ? 'USER' : null as never; - if (!nextRole) { - observer.error(new Error('INVALID_ROLE')); - return; - } - if (user.role === 'ADMIN' && nextRole !== 'ADMIN' && this.users.filter((candidate) => candidate.role === 'ADMIN').length === 1) { - observer.error(new Error('LAST_ADMIN')); - return; - } - user.role = nextRole; - observer.next(); - observer.complete(); - }); - } - - deleteAdminUser(username: string): Observable { - return new Observable((observer) => { - const userIndex = this.users.findIndex((candidate) => candidate.username === username); - if (userIndex < 0) { - observer.error(new Error(`User ${username} not found`)); - return; - } - if (this.users[userIndex].role === 'ADMIN' && this.users.filter((candidate) => candidate.role === 'ADMIN').length === 1) { - observer.error(new Error('LAST_ADMIN')); - return; - } - this.users.splice(userIndex, 1); - observer.next(); - observer.complete(); - }); - } - - getOperationsStatistics(): Observable { - return new Observable((observer) => { - observer.next({ - usersCount: this.users.length, - flowsCreated: 7, - flowsPublished: 3, - flowsFinalized: 0, - executionsCreated: 0, - executionsRunning: 0, - executionsSucceeded: 0, - executionsFailed: 0, - simulationsStarted: 0, - lastFlowUpdateAt: '2026-03-26T12:18:06.100822', - lastExecutionAt: null - }); - observer.complete(); - }); - } - - listStatisticsUsers(): Observable { - return new Observable((observer) => { - observer.next(this.users.map((user) => user.username)); - observer.complete(); - }); - } - - getUserStatistics(username: string): Observable { - return new Observable((observer) => { - const user = this.users.find((candidate) => candidate.username === username); - if (!user) { - observer.error(new Error('User not found')); - return; - } - observer.next(this.buildStatistics(username)); - observer.complete(); - }); - } - - private buildStatistics(username: string): UserStatistics { - const base = username.length; - return { - username, - flowsCreated: base + 2, - flowsPublished: Math.max(0, base - 2), - flowsFinalized: Math.max(0, base - 4), - executionsCreated: base * 3, - executionsRunning: base % 3, - executionsSucceeded: base * 2, - executionsFailed: base % 5, - simulationsStarted: Math.max(0, base - 5), - lastFlowUpdateAt: '2026-03-26T10:15:30', - lastExecutionAt: 1774520966139 - }; - } - logout(): Observable { + this.currentUsername = null; return of(undefined); } } diff --git a/src/app/services/authorization/authorization-call.ts b/src/app/services/authorization/authorization-call.ts index 8f4d0a6..e542cac 100644 --- a/src/app/services/authorization/authorization-call.ts +++ b/src/app/services/authorization/authorization-call.ts @@ -1,14 +1,8 @@ import { AuthorizationCallServiceBase } from "./authorization-call.base"; import { - AdminChangeRoleRequest, - AdminCreateUserRequest, - AdminResetPasswordRequest, - AdminUser, ChangePasswordRequest, - OperationsStatistics, User, UserRegistration, - UserStatistics, UserRole } from "@models/user"; import { catchError, map, Observable, of, throwError } from "rxjs"; @@ -23,19 +17,7 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase { return this.http .post(`${environment.apiUrl}/auth/login`, { username, password }) .pipe( - map((raw) => { - const payload = (raw ?? {}) as Record; - const userSource = - (payload["user"] as Record | undefined) - ?? (payload["profile"] as Record | undefined) - ?? payload; - - return { - username: String(userSource["username"] ?? username), - email: typeof userSource["email"] === "string" ? String(userSource["email"]) : null, - role: this.normalizeRole(userSource["role"]) - } satisfies User; - }), + map((raw) => this.userFromApi(raw, username)), catchError((error: unknown) => { if (error instanceof HttpErrorResponse && error.status === 404) { return throwError(() => new Error('User not found')); @@ -47,6 +29,18 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase { }) ); } + + override currentUser(): Observable { + return this.http + .get(`${environment.apiUrl}/auth/me`) + .pipe( + map((raw) => this.userFromApi(raw)), + catchError((error: unknown) => this.toHttpError(error, { + 401: 'Unauthenticated' + })) + ); + } + override register(userRegistration: UserRegistration): Observable { return this.http.post(`${environment.apiUrl}/auth/register`, userRegistration) .pipe( @@ -75,104 +69,6 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase { ); } - override listAdminUsers(): Observable { - return this.http - .get(`${environment.apiUrl}/auth/admin/users`) - .pipe( - map((raw) => Array.isArray(raw) ? raw.map((item) => this.adminUserFromApi(item)) : []), - catchError((error: unknown) => this.toHttpError(error, { - 403: 'Admin access required.' - })) - ); - } - - override createAdminUser(request: AdminCreateUserRequest): Observable { - return this.http - .post(`${environment.apiUrl}/auth/admin/users`, request) - .pipe( - catchError((error: unknown) => this.toHttpError(error, { - 400: 'Unable to create user.', - 403: 'Admin access required.' - })) - ); - } - - override changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable { - return this.http - .put(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/password`, request) - .pipe( - catchError((error: unknown) => this.toHttpError(error, { - 400: 'Unable to update password.', - 403: 'Admin access required.', - 404: `User ${username} not found` - })) - ); - } - - override changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable { - return this.http - .put(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/role`, request) - .pipe( - catchError((error: unknown) => this.toHttpError(error, { - 400: 'Unable to update role.', - 403: 'Admin access required.', - 404: `User ${username} not found`, - 409: 'LAST_ADMIN' - })) - ); - } - - override deleteAdminUser(username: string): Observable { - return this.http - .delete(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}`) - .pipe( - catchError((error: unknown) => this.toHttpError(error, { - 403: 'Admin access required.', - 404: `User ${username} not found`, - 409: 'LAST_ADMIN' - })) - ); - } - - override getOperationsStatistics(): Observable { - return this.http - .get(`${environment.apiUrl}/stats`) - .pipe( - map((raw) => this.operationsStatisticsFromApi(raw)), - catchError((error: unknown) => this.toHttpError(error, { - 401: 'Unauthenticated', - 403: 'You are not allowed to view user statistics' - })) - ); - } - - override listStatisticsUsers(): Observable { - return this.http - .get(`${environment.apiUrl}/stats/users`) - .pipe( - map((raw) => Array.isArray(raw) - ? raw.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter((item) => item.length > 0) - : []), - catchError((error: unknown) => this.toHttpError(error, { - 401: 'Unauthenticated', - 403: 'You are not allowed to view user statistics' - })) - ); - } - - override getUserStatistics(username: string): Observable { - return this.http - .get(`${environment.apiUrl}/stats/users/${encodeURIComponent(username)}`) - .pipe( - map((raw) => this.userStatisticsFromApi(raw, username)), - catchError((error: unknown) => this.toHttpError(error, { - 401: 'Unauthenticated', - 403: 'You are not allowed to view user statistics', - 404: 'User not found' - })) - ); - } - private extractHttpErrorMessage(error: HttpErrorResponse): string | null { const payload = error.error; if (typeof payload === 'string' && payload.trim().length > 0) { @@ -196,53 +92,24 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase { return null; } + private userFromApi(raw: unknown, fallbackUsername?: string): User { + const payload = (raw ?? {}) as Record; + const userSource = + (payload["user"] as Record | undefined) + ?? (payload["profile"] as Record | undefined) + ?? payload; + + return { + username: String(userSource["username"] ?? fallbackUsername ?? ''), + email: typeof userSource["email"] === "string" ? String(userSource["email"]) : null, + role: this.normalizeRole(userSource["role"]) + } satisfies User; + } + private normalizeRole(value: unknown): UserRole { return String(value ?? '').toUpperCase() === 'ADMIN' ? 'ADMIN' : 'USER'; } - private adminUserFromApi(raw: unknown): AdminUser { - const value = (raw ?? {}) as Record; - return { - username: String(value['username'] ?? ''), - email: typeof value['email'] === 'string' ? value['email'] : null, - role: this.normalizeRole(value['role']) - }; - } - - private userStatisticsFromApi(raw: unknown, username: string): UserStatistics { - const value = (raw ?? {}) as Record; - return { - username: String(value['username'] ?? username), - flowsCreated: Number(value['flowsCreated'] ?? 0), - flowsPublished: Number(value['flowsPublished'] ?? 0), - flowsFinalized: Number(value['flowsFinalized'] ?? 0), - executionsCreated: Number(value['executionsCreated'] ?? 0), - executionsRunning: Number(value['executionsRunning'] ?? 0), - executionsSucceeded: Number(value['executionsSucceeded'] ?? 0), - executionsFailed: Number(value['executionsFailed'] ?? 0), - simulationsStarted: Number(value['simulationsStarted'] ?? 0), - lastFlowUpdateAt: typeof value['lastFlowUpdateAt'] === 'string' ? value['lastFlowUpdateAt'] : null, - lastExecutionAt: typeof value['lastExecutionAt'] === 'number' ? value['lastExecutionAt'] : null - }; - } - - private operationsStatisticsFromApi(raw: unknown): OperationsStatistics { - const value = (raw ?? {}) as Record; - return { - usersCount: Number(value['usersCount'] ?? 0), - flowsCreated: Number(value['flowsCreated'] ?? 0), - flowsPublished: Number(value['flowsPublished'] ?? 0), - flowsFinalized: Number(value['flowsFinalized'] ?? 0), - executionsCreated: Number(value['executionsCreated'] ?? 0), - executionsRunning: Number(value['executionsRunning'] ?? 0), - executionsSucceeded: Number(value['executionsSucceeded'] ?? 0), - executionsFailed: Number(value['executionsFailed'] ?? 0), - simulationsStarted: Number(value['simulationsStarted'] ?? 0), - lastFlowUpdateAt: typeof value['lastFlowUpdateAt'] === 'string' ? value['lastFlowUpdateAt'] : null, - lastExecutionAt: typeof value['lastExecutionAt'] === 'number' ? value['lastExecutionAt'] : null - }; - } - override logout(): Observable { return this.http .post(`${environment.apiUrl}/auth/logout`, {}) diff --git a/src/app/services/authorization/authorization.ts b/src/app/services/authorization/authorization.ts index a76e250..2a8675f 100644 --- a/src/app/services/authorization/authorization.ts +++ b/src/app/services/authorization/authorization.ts @@ -1,17 +1,12 @@ import { Injectable, signal } from '@angular/core'; import { - AdminChangeRoleRequest, - AdminCreateUserRequest, - AdminResetPasswordRequest, ChangePasswordRequest, - OperationsStatistics, User, - UserRegistration, - UserStatistics + UserRegistration } from '@models/user'; import { AuthorizationCallServiceBase } from './authorization-call.base'; import { environment } from '@environment'; -import { Observable, catchError, take, tap, throwError } from 'rxjs'; +import { Observable, catchError, finalize, map, of, shareReplay, take, tap, throwError } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -20,24 +15,21 @@ export class Authorization { static readonly USER_STORAGE_KEY = 'loggedInUser'; private user = signal(null); + private sessionValidation$: Observable | null = null; authCall: AuthorizationCallServiceBase = new environment.authorizationCallService(); loggedInUser = this.user.asReadonly(); + constructor() { + this.restoreUserFromStorage(); + } + login(username: string, password: string) { return this.authCall.login(username, password).pipe( take(1), tap(res => { - const normalizedUser = this.normalizeUser(res); - this.user.set(normalizedUser); - try { - if (typeof localStorage !== 'undefined') { - localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(normalizedUser)); - } - } catch { - // Ignore storage errors (e.g. quota exceeded, private browsing). - } + this.persistUserState(res); }), catchError((err) => { console.error('Login failed', err); @@ -58,81 +50,41 @@ export class Authorization { ); } - listAdminUsers() { - return this.authCall.listAdminUsers().pipe( - take(1) - ); - } - - createAdminUser(request: AdminCreateUserRequest) { - return this.authCall.createAdminUser(request).pipe( - take(1) - ); - } - - changeAdminUserPassword(username: string, request: AdminResetPasswordRequest) { - return this.authCall.changeAdminUserPassword(username, request).pipe( - take(1) - ); - } - - changeAdminUserRole(username: string, request: AdminChangeRoleRequest) { - return this.authCall.changeAdminUserRole(username, request).pipe( - take(1) - ); - } - - deleteAdminUser(username: string) { - return this.authCall.deleteAdminUser(username).pipe( - take(1) - ); - } - - getOperationsStatistics() { - return this.authCall.getOperationsStatistics().pipe( - take(1) - ); - } - - listStatisticsUsers() { - return this.authCall.listStatisticsUsers().pipe( - take(1) - ); - } - - getUserStatistics(username: string) { - return this.authCall.getUserStatistics(username).pipe( - take(1) - ); - } - logout(): Observable { return this.authCall.logout().pipe( take(1), - tap(() => { - try { - if (typeof localStorage !== 'undefined') { - localStorage.removeItem(Authorization.USER_STORAGE_KEY); - } - } catch { - // Ignore storage errors. - } - this.user.set(null); - }) + tap(() => this.clearUserState()) ); } isLoggedIn(): boolean { - if (typeof localStorage === 'undefined') return false; - const storedUser = localStorage.getItem(Authorization.USER_STORAGE_KEY); - const normalizedUser = storedUser != null ? this.normalizeUser(JSON.parse(storedUser) as User) : null; - this.user.set(normalizedUser); - return storedUser != null; + return this.loggedInUser() != null; } isAdmin(): boolean { - const currentUser = this.loggedInUser() ?? (this.isLoggedIn() ? this.loggedInUser() : null); - return currentUser?.role === 'ADMIN'; + return this.loggedInUser()?.role === 'ADMIN'; + } + + validateSession(): Observable { + if (this.sessionValidation$) { + return this.sessionValidation$; + } + + this.sessionValidation$ = this.authCall.currentUser().pipe( + take(1), + map((user) => this.normalizeUser(user)), + tap((user) => this.persistUserState(user)), + catchError(() => { + this.clearUserState(); + return of(null); + }), + finalize(() => { + this.sessionValidation$ = null; + }), + shareReplay(1) + ); + + return this.sessionValidation$; } private normalizeUser(user: User | null): User | null { @@ -144,4 +96,36 @@ export class Authorization { }; } + private restoreUserFromStorage() { + if (typeof localStorage === 'undefined') return; + + try { + const storedUser = localStorage.getItem(Authorization.USER_STORAGE_KEY); + if (!storedUser) return; + this.user.set(this.normalizeUser(JSON.parse(storedUser) as User)); + } catch { + this.clearUserState(); + } + } + + private persistUserState(user: User | null) { + const normalizedUser = this.normalizeUser(user); + this.user.set(normalizedUser); + + try { + if (typeof localStorage === 'undefined') return; + if (normalizedUser) { + localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(normalizedUser)); + } else { + localStorage.removeItem(Authorization.USER_STORAGE_KEY); + } + } catch { + // Ignore storage errors (e.g. quota exceeded, private browsing). + } + } + + private clearUserState() { + this.persistUserState(null); + } + } diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index e7e42e7..551bbc5 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -1,3 +1,4 @@ +import { AdminCallFakeService } from "@services/admin/admin-call.fake"; import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fake"; import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake"; import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake"; @@ -13,6 +14,7 @@ export const environment = { tourModeAlwaysOn: true, turnstileEnabled: false, authorizationCallService: AuthorizationCallFakeService, // Assign the appropriate service here + adminCallService: AdminCallFakeService, assistantCallService: AssistantCallServiceFake, flowsCallService: FlowsCallServiceFake, blocksCallService: BlocksCallServiceFake, diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index b07f5fa..ebefc40 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -1,3 +1,4 @@ +import { AdminCallService } from "@services/admin/admin-call"; import { AssistantCallService } from "@services/assistant/assistant-call"; import { AuthorizationCallService } from "@services/authorization/authorization-call"; import { BlocksCallService } from "@services/blocks/blocks-call"; @@ -14,6 +15,7 @@ export const environment = { turnstileEnabled: false, assistantCallService: AssistantCallService, authorizationCallService: AuthorizationCallService, + adminCallService: AdminCallService, flowsCallService: FlowsCallService, blocksCallService: BlocksCallService, containersCallService: ContainersCallService, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 3ea9700..1af1474 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -1,3 +1,4 @@ +import { AdminCallService } from "@services/admin/admin-call"; import { AssistantCallService } from "@services/assistant/assistant-call"; import { AuthorizationCallService } from "@services/authorization/authorization-call"; import { BlocksCallService } from "@services/blocks/blocks-call"; @@ -13,6 +14,7 @@ export const environment = { tourModeAlwaysOn: window.__runtimeConfig?.tourModeAlwaysOn ?? false, turnstileEnabled: window.__runtimeConfig?.turnstileEnabled ?? true, authorizationCallService: AuthorizationCallService, + adminCallService: AdminCallService, assistantCallService: AssistantCallService, flowsCallService: FlowsCallService, blocksCallService: BlocksCallService,