Split admin services from authorization and add session checks

This commit is contained in:
Lucio Lelii 2026-04-15 12:10:36 +02:00
parent 5f405b69a0
commit 52333b8601
23 changed files with 706 additions and 469 deletions

View File

@ -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<Authorization>;
@ -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<boolean | UrlTree>);
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<boolean | UrlTree>);
expect(result).toBe(homeUrlTree);
expect(routerSpy.parseUrl).toHaveBeenCalledWith('/');
});
});

View File

@ -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('/');
})
);
};

View File

@ -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<Authorization>;
@ -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<boolean | UrlTree>);
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<boolean | UrlTree>);
expect(result).toBe(loginUrlTree);
expect(routerSpy.parseUrl).toHaveBeenCalledWith('/login');
});
});

View File

@ -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');
})
);
};

View File

@ -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) => {

View File

@ -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;
};

View File

@ -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<string | null>(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.');

View File

@ -69,6 +69,14 @@
<div class="admin-stats-metric__value">{{ currentStats.simulationsStarted }}</div>
<div class="admin-stats-metric__meta">Started in simulation mode</div>
</mat-card>
@if (selectedUsername() && selectedUserStats(); as userStats) {
<mat-card class="admin-stats-metric">
<div class="admin-stats-metric__label">Authentications</div>
<div class="admin-stats-metric__value">{{ userStats.loginCount }}</div>
<div class="admin-stats-metric__meta">Average session {{ formatDuration(userStats.avgSessionDurationSeconds) }}</div>
</mat-card>
}
</div>
<mat-card class="admin-stats-card">
@ -83,6 +91,11 @@
@if (!selectedUsername() && operationsStats(); as overviewStats) {
<div><span class="admin-stats-details__label">Users count:</span> {{ overviewStats.usersCount }}</div>
}
@if (selectedUsername() && selectedUserStats(); as userStats) {
<div><span class="admin-stats-details__label">Login count:</span> {{ userStats.loginCount }}</div>
<div><span class="admin-stats-details__label">Average session duration:</span> {{ formatDuration(userStats.avgSessionDurationSeconds) }}</div>
<div><span class="admin-stats-details__label">Last login:</span> {{ userStats.lastLoginAt ? (userStats.lastLoginAt | date:'dd/MM/yyyy HH:mm') : 'N/A' }}</div>
}
<div><span class="admin-stats-details__label">Last flow update:</span> {{ currentStats.lastFlowUpdateAt ? (currentStats.lastFlowUpdateAt | date:'dd/MM/yyyy HH:mm') : 'N/A' }}</div>
<div><span class="admin-stats-details__label">Last execution:</span> {{ currentStats.lastExecutionAt ? (currentStats.lastExecutionAt | date:'dd/MM/yyyy HH:mm') : 'N/A' }}</div>
</div>

View File

@ -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<string[]>([]);
@ -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);

View File

@ -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.`);

View File

@ -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<AdminUser[]>([]);
@ -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.`);

View File

@ -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);

View File

@ -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<AdminUser[]>;
abstract createAdminUser(request: AdminCreateUserRequest): Observable<void>;
abstract changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable<void>;
abstract changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable<void>;
abstract deleteAdminUser(username: string): Observable<void>;
abstract getOperationsStatistics(): Observable<OperationsStatistics>;
abstract listStatisticsUsers(): Observable<string[]>;
abstract getUserStatistics(username: string): Observable<UserStatistics>;
}

View File

@ -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<UserRegistration & { role: UserRole }> = [
{ username: 'testuser', email: 'testuser@example.com', password: 'Password123!', role: 'USER' },
{ username: 'adminuser', email: 'admin@example.com', password: 'Adminpass1!', role: 'ADMIN' },
];
override listAdminUsers(): Observable<AdminUser[]> {
return new Observable<AdminUser[]>((observer) => {
observer.next(this.users.map((user) => ({
username: user.username,
email: user.email,
role: user.role
})));
observer.complete();
});
}
override createAdminUser(request: AdminCreateUserRequest): Observable<void> {
return new Observable<void>((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<void> {
return new Observable<void>((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<void> {
return new Observable<void>((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<void> {
return new Observable<void>((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<OperationsStatistics> {
return new Observable<OperationsStatistics>((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<string[]> {
return new Observable<string[]>((observer) => {
observer.next(this.users.map((user) => user.username));
observer.complete();
});
}
override getUserStatistics(username: string): Observable<UserStatistics> {
return new Observable<UserStatistics>((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
};
}
}

View File

@ -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<AdminUser[]> {
return this.http
.get<unknown[]>(`${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<void> {
return this.http
.post<void>(`${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<void> {
return this.http
.put<void>(`${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<void> {
return this.http
.put<void>(`${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<void> {
return this.http
.delete<void>(`${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<OperationsStatistics> {
return this.http
.get<unknown>(`${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<string[]> {
return this.http
.get<unknown[]>(`${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<UserStatistics> {
return this.http
.get<unknown>(`${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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<number, string>): Observable<never> {
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.'));
}
}

View File

@ -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)
);
}
}

View File

@ -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<User>;
abstract currentUser(): Observable<User>;
abstract register(userRegistration: UserRegistration): Observable<void>;
abstract changePassword(request: ChangePasswordRequest): Observable<void>;
abstract listAdminUsers(): Observable<AdminUser[]>;
abstract createAdminUser(request: AdminCreateUserRequest): Observable<void>;
abstract changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable<void>;
abstract changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable<void>;
abstract deleteAdminUser(username: string): Observable<void>;
abstract getOperationsStatistics(): Observable<OperationsStatistics>;
abstract listStatisticsUsers(): Observable<string[]>;
abstract getUserStatistics(username: string): Observable<UserStatistics>;
abstract logout(): Observable<void>;
}

View File

@ -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<User> {
return new Observable<User>((observer) => {
@ -37,10 +32,25 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
email: user.email,
role: user.role
});
this.currentUsername = user.username;
observer.complete();
});
}
currentUser(): Observable<User> {
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<void> {
return new Observable<void>((observer) => {
setTimeout(() => {
@ -77,150 +87,8 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
});
}
listAdminUsers(): Observable<AdminUser[]> {
return new Observable<AdminUser[]>((observer) => {
observer.next(this.users.map((user) => ({
username: user.username,
email: user.email,
role: user.role
})));
observer.complete();
});
}
createAdminUser(request: AdminCreateUserRequest): Observable<void> {
return new Observable<void>((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<void> {
return new Observable<void>((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<void> {
return new Observable<void>((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<void> {
return new Observable<void>((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<OperationsStatistics> {
return new Observable<OperationsStatistics>((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<string[]> {
return new Observable<string[]>((observer) => {
observer.next(this.users.map((user) => user.username));
observer.complete();
});
}
getUserStatistics(username: string): Observable<UserStatistics> {
return new Observable<UserStatistics>((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<void> {
this.currentUsername = null;
return of(undefined);
}
}

View File

@ -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<unknown>(`${environment.apiUrl}/auth/login`, { username, password })
.pipe(
map((raw) => {
const payload = (raw ?? {}) as Record<string, unknown>;
const userSource =
(payload["user"] as Record<string, unknown> | undefined)
?? (payload["profile"] as Record<string, unknown> | 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<User> {
return this.http
.get<unknown>(`${environment.apiUrl}/auth/me`)
.pipe(
map((raw) => this.userFromApi(raw)),
catchError((error: unknown) => this.toHttpError(error, {
401: 'Unauthenticated'
}))
);
}
override register(userRegistration: UserRegistration): Observable<void> {
return this.http.post<void>(`${environment.apiUrl}/auth/register`, userRegistration)
.pipe(
@ -75,104 +69,6 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
);
}
override listAdminUsers(): Observable<AdminUser[]> {
return this.http
.get<unknown[]>(`${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<void> {
return this.http
.post<void>(`${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<void> {
return this.http
.put<void>(`${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<void> {
return this.http
.put<void>(`${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<void> {
return this.http
.delete<void>(`${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<OperationsStatistics> {
return this.http
.get<unknown>(`${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<string[]> {
return this.http
.get<unknown[]>(`${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<UserStatistics> {
return this.http
.get<unknown>(`${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<string, unknown>;
const userSource =
(payload["user"] as Record<string, unknown> | undefined)
?? (payload["profile"] as Record<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/logout`, {})

View File

@ -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<User | null>(null);
private sessionValidation$: Observable<User | null> | 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<void> {
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<User | null> {
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);
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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,