diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 859f32a..7996abf 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -8,6 +8,7 @@ import { AdminLayout } from '@layouts/admin-layout/admin-layout';
import { FlowEditor } from '@layouts/flow-editor/flow-editor';
import { TasksExecutor } from '@layouts/tasks-executor/tasks-executor';
import { AdminCreateUserPage } from '@pages/admin/admin-create-user/admin-create-user';
+import { AdminStatsPage } from '@pages/admin/admin-stats/admin-stats';
import { AdminUsersListPage } from '@pages/admin/admin-users-list/admin-users-list';
export const routes: Routes = [
@@ -45,6 +46,10 @@ export const routes: Routes = [
{
path: 'create-user',
component: AdminCreateUserPage
+ },
+ {
+ path: 'stats',
+ component: AdminStatsPage
}
]
}
diff --git a/src/app/layouts/admin-layout/admin-layout.css b/src/app/layouts/admin-layout/admin-layout.css
index 3fb2c7d..a781ba6 100644
--- a/src/app/layouts/admin-layout/admin-layout.css
+++ b/src/app/layouts/admin-layout/admin-layout.css
@@ -53,6 +53,12 @@
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
}
+.admin-shell__menu-group {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
.admin-shell__sidebar-title {
color: #0f172a;
font-size: 12px;
@@ -67,6 +73,28 @@
gap: 8px;
}
+.admin-shell__back-link {
+ margin-top: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border-radius: 16px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ color: #0f172a;
+ font-size: 14px;
+ font-weight: 700;
+ text-decoration: none;
+ background: rgba(255, 255, 255, 0.78);
+ transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
+}
+
+.admin-shell__back-link:hover {
+ border-color: rgba(37, 99, 235, 0.28);
+ background: #fff;
+ color: #1d4ed8;
+}
+
.admin-shell__nav-link {
display: flex;
align-items: center;
diff --git a/src/app/layouts/admin-layout/admin-layout.html b/src/app/layouts/admin-layout/admin-layout.html
index 9a99fba..f479be1 100644
--- a/src/app/layouts/admin-layout/admin-layout.html
+++ b/src/app/layouts/admin-layout/admin-layout.html
@@ -9,25 +9,46 @@
diff --git a/src/app/models/user.ts b/src/app/models/user.ts
index 1085f10..26e8728 100644
--- a/src/app/models/user.ts
+++ b/src/app/models/user.ts
@@ -11,6 +11,7 @@ export type UserRegistration = {
username: string;
password: string;
email: string;
+ captchaToken?: string;
};
export type ChangePasswordRequest = {
@@ -39,3 +40,31 @@ export type AdminResetPasswordRequest = {
export type AdminChangeRoleRequest = {
role: UserRole;
};
+
+export type UserStatistics = {
+ username: string;
+ flowsCreated: number;
+ flowsPublished: number;
+ flowsFinalized: number;
+ executionsCreated: number;
+ executionsRunning: number;
+ executionsSucceeded: number;
+ executionsFailed: number;
+ simulationsStarted: number;
+ lastFlowUpdateAt: string | null;
+ lastExecutionAt: number | null;
+};
+
+export type OperationsStatistics = {
+ usersCount: number;
+ flowsCreated: number;
+ flowsPublished: number;
+ flowsFinalized: number;
+ executionsCreated: number;
+ executionsRunning: number;
+ executionsSucceeded: number;
+ executionsFailed: number;
+ simulationsStarted: number;
+ lastFlowUpdateAt: string | null;
+ lastExecutionAt: number | null;
+};
diff --git a/src/app/pages/admin/admin-stats/admin-stats.css b/src/app/pages/admin/admin-stats/admin-stats.css
new file mode 100644
index 0000000..0d39d00
--- /dev/null
+++ b/src/app/pages/admin/admin-stats/admin-stats.css
@@ -0,0 +1,166 @@
+.admin-stats-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-height: 100%;
+ padding: 8px 4px 16px;
+ background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
+}
+
+.admin-stats-page__eyebrow {
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.admin-stats-page__title {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 28px;
+ font-weight: 800;
+}
+
+.admin-stats-page__subtitle {
+ margin: 6px 0 0;
+ color: #64748b;
+ font-size: 14px;
+}
+
+.admin-stats-card {
+ border-radius: 22px;
+ padding: 20px;
+}
+
+.admin-stats-card__title {
+ margin-bottom: 14px;
+ color: #0f172a;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.admin-stats-card__error {
+ border: 1px solid #fecaca;
+ background: #fff1f2;
+ color: #b91c1c;
+ border-radius: 14px;
+ padding: 12px 14px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.admin-stats-lookup {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.admin-stats-lookup__field {
+ min-width: 320px;
+}
+
+.admin-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+}
+
+.admin-stats-users {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 14px;
+}
+
+.admin-stats-user-chip {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 180px;
+ padding: 12px 14px;
+ border: 1px solid #dbe7f5;
+ border-radius: 16px;
+ background: #f8fbff;
+ color: #334155;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
+}
+
+.admin-stats-user-chip:hover {
+ border-color: #93c5fd;
+ background: #eff6ff;
+ transform: translateY(-1px);
+}
+
+.admin-stats-user-chip--active {
+ border-color: #2563eb;
+ background: #dbeafe;
+ color: #1e3a8a;
+}
+
+.admin-stats-user-chip__username {
+ font-size: 14px;
+ font-weight: 800;
+}
+
+.admin-stats-user-chip__email {
+ font-size: 12px;
+ color: #64748b;
+}
+
+.admin-stats-metric {
+ border-radius: 20px;
+ padding: 18px;
+}
+
+.admin-stats-metric__label {
+ color: #64748b;
+ font-size: 13px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.admin-stats-metric__value {
+ margin-top: 10px;
+ color: #0f172a;
+ font-size: 34px;
+ font-weight: 800;
+}
+
+.admin-stats-metric__meta {
+ margin-top: 8px;
+ color: #475569;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.admin-stats-details {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ color: #334155;
+ font-size: 14px;
+}
+
+.admin-stats-details__label {
+ color: #64748b;
+ font-weight: 700;
+}
+
+@media (max-width: 1100px) {
+ .admin-stats-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-stats-lookup {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .admin-stats-lookup__field {
+ min-width: 0;
+ }
+}
diff --git a/src/app/pages/admin/admin-stats/admin-stats.html b/src/app/pages/admin/admin-stats/admin-stats.html
new file mode 100644
index 0000000..2753700
--- /dev/null
+++ b/src/app/pages/admin/admin-stats/admin-stats.html
@@ -0,0 +1,91 @@
+
+
+
+
+ Users
+
+ @if (pageError()) {
+ {{ pageError() }}
+ } @else {
+
+
+ Search user
+
+
+
+ @if (selectedUsername()) {
+
+ }
+
+
+
+
+
+ @for (user of filteredUsers(); track user) {
+
+ }
+
+ }
+
+
+ @if (statsError()) {
+
{{ statsError() }}
+ }
+
+ @if (currentStats(); as currentStats) {
+
+
+ {{ selectedUsername() ? 'User flows' : 'Flows' }}
+ {{ currentStats.flowsCreated }}
+ Published {{ currentStats.flowsPublished }} · Finalized {{ currentStats.flowsFinalized }}
+
+
+
+ {{ selectedUsername() ? 'User executions' : 'Executions' }}
+ {{ currentStats.executionsCreated }}
+ Running {{ currentStats.executionsRunning }} · Succeeded {{ currentStats.executionsSucceeded }} · Failed {{ currentStats.executionsFailed }}
+
+
+
+ Simulations
+ {{ currentStats.simulationsStarted }}
+ Started in simulation mode
+
+
+
+
+
+ @if (selectedUsername()) {
+ Details for {{ selectedUsername() }}
+ } @else {
+ Operations overview
+ }
+
+
+ @if (!selectedUsername() && operationsStats(); as overviewStats) {
+
Users count: {{ overviewStats.usersCount }}
+ }
+
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
new file mode 100644
index 0000000..70ac3d2
--- /dev/null
+++ b/src/app/pages/admin/admin-stats/admin-stats.ts
@@ -0,0 +1,118 @@
+import { CommonModule } from '@angular/common';
+import { Component, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCardModule } from '@angular/material/card';
+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 { Authorization } from '@services/authorization/authorization';
+
+@Component({
+ selector: 'app-admin-stats-page',
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatButtonModule,
+ MatCardModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule
+ ],
+ templateUrl: './admin-stats.html',
+ styleUrl: './admin-stats.css'
+})
+export class AdminStatsPage {
+ private authorization = inject(Authorization);
+
+ readonly users = signal([]);
+ readonly operationsStats = signal(null);
+ readonly selectedUserStats = signal(null);
+ readonly selectedUsername = signal('');
+ readonly userSearch = signal('');
+ readonly loadingUsers = signal(true);
+ readonly loadingOverview = signal(true);
+ readonly loadingUserStats = signal(false);
+ readonly pageError = signal(null);
+ readonly statsError = signal(null);
+ readonly filteredUsers = computed(() => {
+ const term = this.userSearch().trim().toLowerCase();
+ const users = [...this.users()].sort((left, right) => left.localeCompare(right));
+ if (!term) return users;
+ return users.filter((user) => user.toLowerCase().includes(term));
+ });
+ readonly currentStats = computed(() =>
+ this.selectedUsername() ? this.selectedUserStats() : this.operationsStats()
+ );
+
+ ngOnInit() {
+ this.loadUsers();
+ this.loadOperationsStats();
+ }
+
+ loadUsers() {
+ if (!this.authorization.isAdmin()) return;
+ this.loadingUsers.set(true);
+ this.pageError.set(null);
+ this.authorization.listStatisticsUsers().subscribe({
+ next: (users) => {
+ this.users.set(users);
+ this.loadingUsers.set(false);
+ },
+ error: (error) => {
+ const message = error instanceof Error ? error.message : 'Unable to load users.';
+ this.pageError.set(message);
+ this.loadingUsers.set(false);
+ }
+ });
+ }
+
+ loadOperationsStats() {
+ if (!this.authorization.isAdmin()) return;
+ this.loadingOverview.set(true);
+ this.statsError.set(null);
+ this.authorization.getOperationsStatistics().subscribe({
+ next: (stats) => {
+ this.operationsStats.set(stats);
+ this.loadingOverview.set(false);
+ },
+ error: (error) => {
+ const message = error instanceof Error ? error.message : 'Unable to load statistics.';
+ this.statsError.set(message);
+ this.loadingOverview.set(false);
+ }
+ });
+ }
+
+ selectUser(username: string) {
+ this.selectedUsername.set(username);
+ this.selectedUserStats.set(null);
+ this.loadSelectedUserStats();
+ }
+
+ clearSelectedUser() {
+ this.selectedUsername.set('');
+ this.selectedUserStats.set(null);
+ this.statsError.set(null);
+ }
+
+ loadSelectedUserStats() {
+ const username = this.selectedUsername().trim();
+ if (!username || !this.authorization.isAdmin()) return;
+
+ this.loadingUserStats.set(true);
+ this.statsError.set(null);
+ this.authorization.getUserStatistics(username).subscribe({
+ next: (stats) => {
+ this.selectedUserStats.set(stats);
+ this.loadingUserStats.set(false);
+ },
+ error: (error) => {
+ const message = error instanceof Error ? error.message : 'Unable to load statistics.';
+ this.statsError.set(message);
+ this.loadingUserStats.set(false);
+ }
+ });
+ }
+}
diff --git a/src/app/pages/auth/signup/signup.css b/src/app/pages/auth/signup/signup.css
index 28c6ebd..0c61fc7 100644
--- a/src/app/pages/auth/signup/signup.css
+++ b/src/app/pages/auth/signup/signup.css
@@ -71,6 +71,35 @@
font-size: 14px;
}
+.signup-captcha {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: center;
+}
+
+.signup-captcha__widget {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ min-height: 66px;
+}
+
+.signup-captcha__state {
+ color: #64748b;
+ font-size: 13px;
+}
+
+.signup-captcha__error {
+ width: 100%;
+ padding: 10px 12px;
+ border: 1px solid #fecaca;
+ border-radius: 10px;
+ background: #fef2f2;
+ color: #991b1b;
+ font-size: 13px;
+}
+
@media (min-width: 1024px) {
.signup-row {
flex-direction: row;
diff --git a/src/app/pages/auth/signup/signup.html b/src/app/pages/auth/signup/signup.html
index 349650f..fddbd6b 100644
--- a/src/app/pages/auth/signup/signup.html
+++ b/src/app/pages/auth/signup/signup.html
@@ -63,6 +63,18 @@
+ @if (captchaEnabled) {
+
+ @if (captchaLoading()) {
+
Loading captcha...
+ }
+
+ @if (captchaError()) {
+
{{ captchaError() }}
+ }
+
+ }
+
@if(error()) {
Error {{error()}}
diff --git a/src/app/pages/auth/signup/signup.ts b/src/app/pages/auth/signup/signup.ts
index 1fc65f2..aaba72d 100644
--- a/src/app/pages/auth/signup/signup.ts
+++ b/src/app/pages/auth/signup/signup.ts
@@ -1,4 +1,4 @@
-import { Component, effect, inject, signal } from '@angular/core';
+import { AfterViewInit, Component, effect, ElementRef, inject, OnDestroy, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
@@ -6,6 +6,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Field, form, minLength, email, required, validate, maxLength, disabled } from '@angular/forms/signals'
import { Router, RouterLink } from '@angular/router';
+import { environment } from '@environment';
import { UserRegistration } from '@models/user';
import { Authorization } from '@services/authorization/authorization';
import { FormUtility } from '@utilities/form-utility';
@@ -18,21 +19,39 @@ function hasValidPasswordComplexity(value: string): boolean {
&& /[^A-Za-z0-9]/.test(value);
}
+declare global {
+ interface Window {
+ turnstile?: {
+ render: (container: string | HTMLElement, options: Record
) => string;
+ remove: (widgetId: string) => void;
+ reset: (widgetId: string) => void;
+ };
+ }
+}
+
+let turnstileScriptPromise: Promise | null = null;
+
@Component({
selector: 'app-signup',
imports: [FormsModule, RouterLink, Field, MatButtonModule, MatCardModule, MatFormFieldModule, MatInputModule],
templateUrl: './signup.html',
styleUrl: './signup.css',
})
-export class Signup extends FormUtility {
+export class Signup extends FormUtility implements AfterViewInit, OnDestroy {
private authService = inject(Authorization);
private router = inject(Router);
+ private captchaContainer = viewChild>('captchaContainer');
error = signal(null);
emailError = signal(null);
passwordError = signal(null);
+ captchaError = signal(null);
isRegistering = signal(false);
+ captchaToken = signal(null);
+ captchaLoading = signal(false);
+ captchaEnabled = !!String(environment.turnstileSiteKey ?? '').trim();
+ private captchaWidgetId: string | null = null;
constructor() {
super();
@@ -45,6 +64,18 @@ export class Signup extends FormUtility {
});
}
+ async ngAfterViewInit() {
+ if (!this.captchaEnabled) return;
+ await this.mountCaptcha();
+ }
+
+ ngOnDestroy() {
+ if (this.captchaWidgetId && window.turnstile) {
+ window.turnstile.remove(this.captchaWidgetId);
+ this.captchaWidgetId = null;
+ }
+ }
+
signupModel = signal({
username: '',
@@ -86,15 +117,23 @@ export class Signup extends FormUtility {
disabled(model, this.isRegistering)
});
- onSubmit() {
+ async onSubmit() {
this.isRegistering.set(true)
this.error.set(null);
this.emailError.set(null);
this.passwordError.set(null);
+ this.captchaError.set(null);
- const { username, email, password } = this.signupModel();
+ if (this.captchaEnabled && !this.captchaToken()) {
+ this.isRegistering.set(false);
+ this.captchaError.set('Please complete the captcha verification.');
+ return;
+ }
- this.authService.signup({ username, email, password }).subscribe({
+ const { username, email, password } = this.signupModel();
+ const captchaToken = this.captchaToken();
+
+ this.authService.signup({ username, email, password, ...(captchaToken ? { captchaToken } : {}) }).subscribe({
error: (err) => {
this.isRegistering.set(false);
const message = err instanceof Error ? err.message : 'Unable to register user.';
@@ -106,6 +145,9 @@ export class Signup extends FormUtility {
this.passwordError.set('Password does not satisfy the required policy.');
return;
}
+ if (this.captchaEnabled) {
+ this.resetCaptcha();
+ }
this.error.set(message);
},
complete: () => {
@@ -114,4 +156,75 @@ export class Signup extends FormUtility {
}
});
}
+
+ private async mountCaptcha() {
+ const siteKey = String(environment.turnstileSiteKey ?? '').trim();
+ const container = this.captchaContainer()?.nativeElement;
+ if (!siteKey || !container) return;
+
+ this.captchaLoading.set(true);
+ this.captchaError.set(null);
+
+ try {
+ await this.ensureTurnstileScript();
+ if (!window.turnstile) {
+ throw new Error('Turnstile is unavailable.');
+ }
+ this.captchaWidgetId = window.turnstile.render(container, {
+ sitekey: siteKey,
+ theme: 'light',
+ callback: (token: string) => {
+ this.captchaToken.set(token);
+ this.captchaError.set(null);
+ },
+ 'expired-callback': () => {
+ this.captchaToken.set(null);
+ },
+ 'error-callback': () => {
+ this.captchaToken.set(null);
+ this.captchaError.set('Captcha could not be verified. Please try again.');
+ }
+ });
+ } catch (error) {
+ this.captchaError.set(error instanceof Error ? error.message : 'Unable to load captcha.');
+ } finally {
+ this.captchaLoading.set(false);
+ }
+ }
+
+ private ensureTurnstileScript(): Promise {
+ if (window.turnstile) {
+ return Promise.resolve();
+ }
+ if (turnstileScriptPromise) {
+ return turnstileScriptPromise;
+ }
+
+ turnstileScriptPromise = new Promise((resolve, reject) => {
+ const existing = document.querySelector('script[data-turnstile-script="true"]');
+ if (existing) {
+ existing.addEventListener('load', () => resolve(), { once: true });
+ existing.addEventListener('error', () => reject(new Error('Unable to load Turnstile script.')), { once: true });
+ return;
+ }
+
+ const script = document.createElement('script');
+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
+ script.async = true;
+ script.defer = true;
+ script.dataset['turnstileScript'] = 'true';
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error('Unable to load Turnstile script.'));
+ document.head.appendChild(script);
+ });
+
+ return turnstileScriptPromise;
+ }
+
+ private resetCaptcha() {
+ this.captchaToken.set(null);
+ if (this.captchaWidgetId && window.turnstile) {
+ window.turnstile.reset(this.captchaWidgetId);
+ }
+ }
}
diff --git a/src/app/services/authorization/authorization-call.base.ts b/src/app/services/authorization/authorization-call.base.ts
index b666e2e..ad70e71 100644
--- a/src/app/services/authorization/authorization-call.base.ts
+++ b/src/app/services/authorization/authorization-call.base.ts
@@ -4,8 +4,10 @@ import {
AdminResetPasswordRequest,
AdminUser,
ChangePasswordRequest,
+ OperationsStatistics,
User,
- UserRegistration
+ UserRegistration,
+ UserStatistics
} from "@models/user";
import { Observable } from "rxjs";
@@ -27,4 +29,10 @@ export abstract class AuthorizationCallServiceBase {
abstract deleteAdminUser(username: string): Observable;
+ abstract getOperationsStatistics(): Observable;
+
+ abstract listStatisticsUsers(): Observable;
+
+ abstract getUserStatistics(username: string): Observable;
+
}
diff --git a/src/app/services/authorization/authorization-call.fake.ts b/src/app/services/authorization/authorization-call.fake.ts
index 12be5cc..4b0ab80 100644
--- a/src/app/services/authorization/authorization-call.fake.ts
+++ b/src/app/services/authorization/authorization-call.fake.ts
@@ -6,8 +6,10 @@ import {
AdminResetPasswordRequest,
AdminUser,
ChangePasswordRequest,
+ OperationsStatistics,
User,
UserRegistration,
+ UserStatistics,
UserRole
} from "@models/user";
@@ -163,4 +165,59 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
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
+ };
+ }
}
diff --git a/src/app/services/authorization/authorization-call.ts b/src/app/services/authorization/authorization-call.ts
index 1dd95e4..cb87f12 100644
--- a/src/app/services/authorization/authorization-call.ts
+++ b/src/app/services/authorization/authorization-call.ts
@@ -5,8 +5,10 @@ import {
AdminResetPasswordRequest,
AdminUser,
ChangePasswordRequest,
+ OperationsStatistics,
User,
UserRegistration,
+ UserStatistics,
UserRole
} from "@models/user";
import { catchError, map, Observable, throwError } from "rxjs";
@@ -134,6 +136,45 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
);
}
+ 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) {
@@ -184,6 +225,40 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
};
}
+ 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
+ };
+ }
+
private toHttpError(error: unknown, fallbackByStatus: Record): Observable {
if (error instanceof HttpErrorResponse) {
const message = this.extractHttpErrorMessage(error)
diff --git a/src/app/services/authorization/authorization.ts b/src/app/services/authorization/authorization.ts
index b01a2a6..7787f07 100644
--- a/src/app/services/authorization/authorization.ts
+++ b/src/app/services/authorization/authorization.ts
@@ -4,8 +4,10 @@ import {
AdminCreateUserRequest,
AdminResetPasswordRequest,
ChangePasswordRequest,
+ OperationsStatistics,
User,
- UserRegistration
+ UserRegistration,
+ UserStatistics
} from '@models/user';
import { AuthorizationCallServiceBase } from './authorization-call.base';
import { environment } from '@environment';
@@ -85,6 +87,24 @@ export class Authorization {
);
}
+ 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() {
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
diff --git a/src/app/services/flows/flows-call.base.ts b/src/app/services/flows/flows-call.base.ts
index 4233796..a6c698c 100644
--- a/src/app/services/flows/flows-call.base.ts
+++ b/src/app/services/flows/flows-call.base.ts
@@ -15,4 +15,8 @@ export abstract class FlowsCallServiceBase {
abstract deleteFlow(flowId: string) : Observable;
+ abstract updatePublished(flowId: string, value: boolean) : Observable;
+
+ abstract finalizeFlow(flowId: string) : Observable;
+
}
diff --git a/src/app/services/flows/flows-call.fake.ts b/src/app/services/flows/flows-call.fake.ts
index 2e03ee2..b71cef5 100644
--- a/src/app/services/flows/flows-call.fake.ts
+++ b/src/app/services/flows/flows-call.fake.ts
@@ -6,12 +6,26 @@ import { inject } from "@angular/core";
import { flowFromApi } from "./flow-mapper";
export class FlowsCallServiceFake extends FlowsCallServiceBase {
- override getFlowById(flowId: string): Observable {
+ private ownerUsername(): string {
+ return this.authorizationService.loggedInUser()?.username ?? '';
+ }
+
+ private requireFlow(flowId: string): Flow {
const flow = this.data[flowId];
if (!flow) {
throw new Error(`Flow with id ${flowId} not found`);
}
- return of(flow);
+ return flow;
+ }
+
+ private requireOwner(flow: Flow) {
+ if (flow.author !== this.ownerUsername()) {
+ throw new Error('Only the owner can change flow flags.');
+ }
+ }
+
+ override getFlowById(flowId: string): Observable {
+ return of(this.requireFlow(flowId));
}
authorizationService = inject(Authorization);
@@ -27,6 +41,9 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
}
override updateFlow(flow: Flow) {
+ if (flow.finalized) {
+ throw new Error('Flow is finalized');
+ }
this.data[flow.id] = flow;
return of(flow);
}
@@ -58,9 +75,41 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
}
override deleteFlow(flowId: string): Observable {
+ const flow = this.requireFlow(flowId);
+ if (flow.finalized) {
+ throw new Error('Flow is finalized');
+ }
delete this.data[flowId];
return of(void 0);
}
+
+ override updatePublished(flowId: string, value: boolean): Observable {
+ const flow = this.requireFlow(flowId);
+ this.requireOwner(flow);
+ const updated = {
+ ...flow,
+ published: value,
+ visibility: value ? 'PUBLIC' : 'PRIVATE',
+ updatedAt: new Date()
+ } satisfies Flow;
+ this.data[flowId] = updated;
+ return of(updated);
+ }
+
+ override finalizeFlow(flowId: string): Observable {
+ const flow = this.requireFlow(flowId);
+ this.requireOwner(flow);
+ if (flow.finalized) {
+ return of(flow);
+ }
+ const updated = {
+ ...flow,
+ finalized: true,
+ updatedAt: new Date()
+ } satisfies Flow;
+ this.data[flowId] = updated;
+ return of(updated);
+ }
}
const testDataFlow ={
"id": "testFlow",
diff --git a/src/app/services/flows/flows-call.ts b/src/app/services/flows/flows-call.ts
index d8f9a2e..c4269b5 100644
--- a/src/app/services/flows/flows-call.ts
+++ b/src/app/services/flows/flows-call.ts
@@ -58,4 +58,18 @@ export class FlowsCallService extends FlowsCallServiceBase {
)
.pipe(map((raw) => flowFromApi(raw)));
}
+
+ override updatePublished(flowId: string, value: boolean): Observable {
+ const encodedId = encodeURIComponent(flowId);
+ return this.http
+ .put(`${environment.apiUrl}/flows/${encodedId}/published`, { value })
+ .pipe(map((raw) => flowFromApi(raw)));
+ }
+
+ override finalizeFlow(flowId: string): Observable {
+ const encodedId = encodeURIComponent(flowId);
+ return this.http
+ .put(`${environment.apiUrl}/flows/${encodedId}/finalized`, { value: true })
+ .pipe(map((raw) => flowFromApi(raw)));
+ }
}
diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts
index 035e02e..9462884 100644
--- a/src/app/services/flows/flows.ts
+++ b/src/app/services/flows/flows.ts
@@ -93,6 +93,42 @@ export class FlowsService {
);
}
+ updatePublished(flowId: string, value: boolean) {
+ return this.flowsCallService.updatePublished(flowId, value).pipe(
+ tap((updatedFlow) => {
+ this._flows.update((flows) => {
+ const index = flows.findIndex((current) => current.id === updatedFlow.id);
+ if (index < 0) return [updatedFlow, ...flows];
+ const next = [...flows];
+ next[index] = updatedFlow;
+ return next;
+ });
+ }),
+ catchError(err => {
+ console.error('Update published failed', err);
+ return throwError(() => err);
+ })
+ );
+ }
+
+ finalizeFlow(flowId: string) {
+ return this.flowsCallService.finalizeFlow(flowId).pipe(
+ tap((updatedFlow) => {
+ this._flows.update((flows) => {
+ const index = flows.findIndex((current) => current.id === updatedFlow.id);
+ if (index < 0) return [updatedFlow, ...flows];
+ const next = [...flows];
+ next[index] = updatedFlow;
+ return next;
+ });
+ }),
+ catchError(err => {
+ console.error('Finalize flow failed', err);
+ return throwError(() => err);
+ })
+ );
+ }
+
cloneFlow(flow: Pick): Observable {
return this.createFlow({
name: `${flow.name} (cloned)`,
diff --git a/src/app/shared/flows-list/flow-item/flow-item.ts b/src/app/shared/flows-list/flow-item/flow-item.ts
index a34feb8..8aa5208 100644
--- a/src/app/shared/flows-list/flow-item/flow-item.ts
+++ b/src/app/shared/flows-list/flow-item/flow-item.ts
@@ -31,12 +31,14 @@ export class FlowItem {
openedFlowId = computed(() => this.editorState.currentFlow()?.id);
canDelete = computed(() => {
const username = this.authorization.loggedInUser()?.username ?? null;
- return !!username && this.flow().author === username;
+ return !!username && this.flow().author === username && !this.flow().finalized;
});
deleteTooltip = computed(() =>
- this.canDelete()
- ? 'Delete flow'
- : 'Only the owner can delete this flow'
+ this.flow().finalized
+ ? 'Finalized flows cannot be deleted'
+ : this.canDelete()
+ ? 'Delete flow'
+ : 'Only the owner can delete this flow'
);
async open() {
diff --git a/src/app/shared/flows-list/flows-list.html b/src/app/shared/flows-list/flows-list.html
index 16294dc..e1f753f 100644
--- a/src/app/shared/flows-list/flows-list.html
+++ b/src/app/shared/flows-list/flows-list.html
@@ -16,6 +16,7 @@
All
Public
Private
+ Finalized
diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts
index 8a590e8..3304e5e 100644
--- a/src/app/shared/flows-list/flows-list.ts
+++ b/src/app/shared/flows-list/flows-list.ts
@@ -13,7 +13,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { OrderEvent, OrderField, Ordering } from "@shared/ordering/ordering";
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
-type FlowFilter = FlowVisibility | 'all';
+type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
@Component({
selector: 'app-flows-list',
@@ -85,6 +85,7 @@ export class FlowsList extends ListStateViewHolder {
if (!flows) return [];
const filteredFlows = flows.filter(f => f.name.toLowerCase().includes(this.searchTerm().toLowerCase()));
if (this.filter() === 'all') return filteredFlows;
+ if (this.filter() === 'FINALIZED') return filteredFlows.filter(f => !!f.finalized);
return filteredFlows.filter(f => f.visibility === this.filter());
});
diff --git a/src/app/shared/title-toolbar/title-toolbar.css b/src/app/shared/title-toolbar/title-toolbar.css
index 0e5b45f..040b356 100644
--- a/src/app/shared/title-toolbar/title-toolbar.css
+++ b/src/app/shared/title-toolbar/title-toolbar.css
@@ -107,6 +107,20 @@
flex-shrink: 0;
}
+.title-toolbar-flags {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 6px 12px;
+ border: 1px solid #e2e8f0;
+ border-radius: 16px;
+ background: #f8fbff;
+}
+
+.title-toolbar-flags .mat-mdc-slide-toggle {
+ margin-right: 2px;
+}
+
.title-toolbar-spinner {
width: 12px;
height: 12px;
@@ -167,3 +181,24 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+
+@media (max-width: 1180px) {
+ .title-toolbar-shell {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .title-toolbar-main,
+ .title-toolbar-actions {
+ width: 100%;
+ }
+
+ .title-toolbar-actions {
+ justify-content: space-between;
+ flex-wrap: wrap;
+ }
+
+ .title-toolbar-flags {
+ flex-wrap: wrap;
+ }
+}
diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html
index f8101dd..785caae 100644
--- a/src/app/shared/title-toolbar/title-toolbar.html
+++ b/src/app/shared/title-toolbar/title-toolbar.html
@@ -61,6 +61,25 @@