Add admin operations dashboard and flow publication controls

This commit is contained in:
Lucio Lelii 2026-03-26 13:01:16 +01:00
parent a34b72eb2b
commit 3e56f6db41
28 changed files with 1029 additions and 36 deletions

View File

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

View File

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

View File

@ -9,25 +9,46 @@
<div class="admin-shell__body">
<aside class="admin-shell__sidebar">
<div class="admin-shell__sidebar-title">Auth</div>
<nav class="admin-shell__nav">
<a
routerLink="/admin/users"
routerLinkActive="admin-shell__nav-link--active"
[routerLinkActiveOptions]="{ exact: true }"
class="admin-shell__nav-link">
<mat-icon fontIcon="group"></mat-icon>
<span>Users</span>
</a>
<a
routerLink="/admin/create-user"
routerLinkActive="admin-shell__nav-link--active"
[routerLinkActiveOptions]="{ exact: true }"
class="admin-shell__nav-link">
<mat-icon fontIcon="person_add"></mat-icon>
<span>Create user</span>
</a>
</nav>
<a routerLink="/editor" class="admin-shell__back-link">
<mat-icon fontIcon="arrow_back"></mat-icon>
<span>Back to editor</span>
</a>
<div class="admin-shell__menu-group">
<div class="admin-shell__sidebar-title">Auth</div>
<nav class="admin-shell__nav">
<a
routerLink="/admin/users"
routerLinkActive="admin-shell__nav-link--active"
[routerLinkActiveOptions]="{ exact: true }"
class="admin-shell__nav-link">
<mat-icon fontIcon="group"></mat-icon>
<span>Users</span>
</a>
<a
routerLink="/admin/create-user"
routerLinkActive="admin-shell__nav-link--active"
[routerLinkActiveOptions]="{ exact: true }"
class="admin-shell__nav-link">
<mat-icon fontIcon="person_add"></mat-icon>
<span>Create user</span>
</a>
</nav>
</div>
<div class="admin-shell__menu-group">
<div class="admin-shell__sidebar-title">Stats</div>
<nav class="admin-shell__nav">
<a
routerLink="/admin/stats"
routerLinkActive="admin-shell__nav-link--active"
[routerLinkActiveOptions]="{ exact: true }"
class="admin-shell__nav-link">
<mat-icon fontIcon="query_stats"></mat-icon>
<span>Operations</span>
</a>
</nav>
</div>
</aside>
<section class="admin-shell__content">

View File

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

View File

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

View File

@ -0,0 +1,91 @@
<div class="admin-stats-page">
<div class="admin-stats-page__header">
<div>
<div class="admin-stats-page__eyebrow">Admin</div>
<h2 class="admin-stats-page__title">User Statistics</h2>
<p class="admin-stats-page__subtitle">Inspect aggregated usage metrics for any user account.</p>
</div>
</div>
<mat-card class="admin-stats-card">
<div class="admin-stats-card__title">Users</div>
@if (pageError()) {
<div class="admin-stats-card__error">{{ pageError() }}</div>
} @else {
<div class="admin-stats-lookup">
<mat-form-field appearance="outline" class="admin-stats-lookup__field">
<mat-label>Search user</mat-label>
<input matInput [ngModel]="userSearch()" (ngModelChange)="userSearch.set($event)" [disabled]="loadingUsers()" placeholder="Filter by username or email" />
</mat-form-field>
@if (selectedUsername()) {
<button type="button" mat-stroked-button (click)="clearSelectedUser()">Show all</button>
}
<button type="button" mat-flat-button [disabled]="loadingOverview()" (click)="loadOperationsStats()">
@if (loadingOverview()) {
Loading...
} @else {
Refresh overview
}
</button>
</div>
<div class="admin-stats-users">
@for (user of filteredUsers(); track user) {
<button
type="button"
class="admin-stats-user-chip"
[class.admin-stats-user-chip--active]="selectedUsername() === user"
(click)="selectUser(user)">
<span class="admin-stats-user-chip__username">{{ user }}</span>
</button>
}
</div>
}
</mat-card>
@if (statsError()) {
<div class="admin-stats-card__error">{{ statsError() }}</div>
}
@if (currentStats(); as currentStats) {
<div class="admin-stats-grid">
<mat-card class="admin-stats-metric">
<div class="admin-stats-metric__label">{{ selectedUsername() ? 'User flows' : 'Flows' }}</div>
<div class="admin-stats-metric__value">{{ currentStats.flowsCreated }}</div>
<div class="admin-stats-metric__meta">Published {{ currentStats.flowsPublished }} · Finalized {{ currentStats.flowsFinalized }}</div>
</mat-card>
<mat-card class="admin-stats-metric">
<div class="admin-stats-metric__label">{{ selectedUsername() ? 'User executions' : 'Executions' }}</div>
<div class="admin-stats-metric__value">{{ currentStats.executionsCreated }}</div>
<div class="admin-stats-metric__meta">Running {{ currentStats.executionsRunning }} · Succeeded {{ currentStats.executionsSucceeded }} · Failed {{ currentStats.executionsFailed }}</div>
</mat-card>
<mat-card class="admin-stats-metric">
<div class="admin-stats-metric__label">Simulations</div>
<div class="admin-stats-metric__value">{{ currentStats.simulationsStarted }}</div>
<div class="admin-stats-metric__meta">Started in simulation mode</div>
</mat-card>
</div>
<mat-card class="admin-stats-card">
<div class="admin-stats-card__title">
@if (selectedUsername()) {
Details for {{ selectedUsername() }}
} @else {
Operations overview
}
</div>
<div class="admin-stats-details">
@if (!selectedUsername() && operationsStats(); as overviewStats) {
<div><span class="admin-stats-details__label">Users count:</span> {{ overviewStats.usersCount }}</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>
</mat-card>
}
</div>

View File

@ -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<string[]>([]);
readonly operationsStats = signal<OperationsStatistics | null>(null);
readonly selectedUserStats = signal<UserStatistics | null>(null);
readonly selectedUsername = signal('');
readonly userSearch = signal('');
readonly loadingUsers = signal(true);
readonly loadingOverview = signal(true);
readonly loadingUserStats = signal(false);
readonly pageError = signal<string | null>(null);
readonly statsError = signal<string | null>(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);
}
});
}
}

View File

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

View File

@ -63,6 +63,18 @@
</span>
</div>
@if (captchaEnabled) {
<div class="signup-captcha">
@if (captchaLoading()) {
<div class="signup-captcha__state">Loading captcha...</div>
}
<div #captchaContainer class="signup-captcha__widget"></div>
@if (captchaError()) {
<div class="signup-captcha__error">{{ captchaError() }}</div>
}
</div>
}
@if(error()) {
<div class="signup-error" role="alert">
<span class="font-bold">Error</span> {{error()}}

View File

@ -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, unknown>) => string;
remove: (widgetId: string) => void;
reset: (widgetId: string) => void;
};
}
}
let turnstileScriptPromise: Promise<void> | 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<ElementRef<HTMLDivElement>>('captchaContainer');
error = signal<string | null>(null);
emailError = signal<string | null>(null);
passwordError = signal<string | null>(null);
captchaError = signal<string | null>(null);
isRegistering = signal(false);
captchaToken = signal<string | null>(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<UserRegistration & { confirmPassword: string }>({
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<void> {
if (window.turnstile) {
return Promise.resolve();
}
if (turnstileScriptPromise) {
return turnstileScriptPromise;
}
turnstileScriptPromise = new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>('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);
}
}
}

View File

@ -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<void>;
abstract getOperationsStatistics(): Observable<OperationsStatistics>;
abstract listStatisticsUsers(): Observable<string[]>;
abstract getUserStatistics(username: string): Observable<UserStatistics>;
}

View File

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

View File

@ -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<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) {
@ -184,6 +225,40 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
};
}
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
};
}
private toHttpError(error: unknown, fallbackByStatus: Record<number, string>): Observable<never> {
if (error instanceof HttpErrorResponse) {
const message = this.extractHttpErrorMessage(error)

View File

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

View File

@ -15,4 +15,8 @@ export abstract class FlowsCallServiceBase {
abstract deleteFlow(flowId: string) : Observable<void>;
abstract updatePublished(flowId: string, value: boolean) : Observable<Flow>;
abstract finalizeFlow(flowId: string) : Observable<Flow>;
}

View File

@ -6,12 +6,26 @@ import { inject } from "@angular/core";
import { flowFromApi } from "./flow-mapper";
export class FlowsCallServiceFake extends FlowsCallServiceBase {
override getFlowById(flowId: string): Observable<Flow> {
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<Flow> {
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<void> {
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<Flow> {
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<Flow> {
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",

View File

@ -58,4 +58,18 @@ export class FlowsCallService extends FlowsCallServiceBase {
)
.pipe(map((raw) => flowFromApi(raw)));
}
override updatePublished(flowId: string, value: boolean): Observable<Flow> {
const encodedId = encodeURIComponent(flowId);
return this.http
.put<unknown>(`${environment.apiUrl}/flows/${encodedId}/published`, { value })
.pipe(map((raw) => flowFromApi(raw)));
}
override finalizeFlow(flowId: string): Observable<Flow> {
const encodedId = encodeURIComponent(flowId);
return this.http
.put<unknown>(`${environment.apiUrl}/flows/${encodedId}/finalized`, { value: true })
.pipe(map((raw) => flowFromApi(raw)));
}
}

View File

@ -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<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
return this.createFlow({
name: `${flow.name} (cloned)`,

View File

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

View File

@ -16,6 +16,7 @@
<mat-button-toggle value="all">All</mat-button-toggle>
<mat-button-toggle value="PUBLIC">Public</mat-button-toggle>
<mat-button-toggle value="PRIVATE">Private</mat-button-toggle>
<mat-button-toggle value="FINALIZED">Finalized</mat-button-toggle>
</mat-button-toggle-group>
<div class="flows-list-ordering">

View File

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

View File

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

View File

@ -61,6 +61,25 @@
</div>
<div class="title-toolbar-actions" >
@if (flow() && isOwner()) {
<div class="title-toolbar-flags">
<mat-slide-toggle
[checked]="!!flow()!.published"
[disabled]="publishSaving()"
[matTooltip]="flow()!.finalized ? 'A finalized flow can still be published or unpublished.' : 'Controls whether the flow is visible to other users.'"
(change)="togglePublished($event.checked)">
Published
</mat-slide-toggle>
<mat-slide-toggle
[checked]="!!flow()!.finalized"
[disabled]="!!flow()!.finalized || finalizeSaving() || notSaved() || blockSyncInProgress()"
[matTooltip]="flow()!.finalized ? 'Finalization is irreversible.' : (notSaved() ? 'Save the flow before finalizing.' : 'Finalized flows cannot be edited anymore.')"
(change)="onFinalizedToggle($event.checked)">
Finalized
</mat-slide-toggle>
</div>
}
<button
type="button"
mat-flat-button
@ -76,7 +95,6 @@
@if (flow()) {
<div class="title-toolbar-meta">
<span><span class="title-toolbar-meta-label">Author:</span> {{ flow()!.author }}</span>
<span><span class="title-toolbar-meta-label">Visibility:</span> {{ flow()!.visibility }}</span>
<span><span class="title-toolbar-meta-label">Created:</span> {{ flow()!.createdAt | date:'dd/MM/yyyy HH:mm' }}</span>
<span><span class="title-toolbar-meta-label">Updated:</span> {{ flow()!.updatedAt | date:'dd/MM/yyyy HH:mm' }}</span>
@if (flow()!.description) {

View File

@ -5,16 +5,19 @@ import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { BlocksService } from '@services/blocks/blocks';
import { Authorization } from '@services/authorization/authorization';
import { FlowsService } from '@services/flows/flows';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { take } from 'rxjs';
import { EditorStateHolder } from '@stores/flow-editor';
@Component({
selector: 'app-title-toolbar',
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule],
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule, MatTooltipModule, MatSlideToggleModule],
templateUrl: './title-toolbar.html',
styleUrl: './title-toolbar.css',
})
@ -26,9 +29,16 @@ export class TitleToolbar {
editorState: EditorStateHolder = inject(EditorStateHolder);
private router = inject(Router);
private blocksService = inject(BlocksService);
private flowsService = inject(FlowsService);
private authorization = inject(Authorization);
private taskExecutionsService = inject(TaskExecutionsService);
flow = computed(() => this.editorState.currentFlow());
readOnly = this.editorState.isCurrentFlowReadOnly;
isOwner = computed(() => {
const flow = this.flow();
const username = this.authorization.loggedInUser()?.username ?? null;
return !!flow && !!username && flow.author === username;
});
title = computed(() => {
const flow = this.flow();
return flow ? flow.name : 'No Flow Opened';
@ -37,11 +47,15 @@ export class TitleToolbar {
notSaved = computed(() => this.editorState.isDirty());
blockSyncInProgress = this.blocksService.hasPendingServerSync;
canSave = computed(() => !this.readOnly() && this.notSaved() && !this.blockSyncInProgress());
canTogglePublished = computed(() => !!this.flow() && this.isOwner());
canFinalize = computed(() => !!this.flow() && this.isOwner() && !this.flow()!.finalized);
canExecute = computed(() => {
const flow = this.flow();
return !!flow && !this.notSaved() && !this.blockSyncInProgress() && flow.status === 'EXECUTABLE';
});
executeLoading = signal(false);
publishSaving = signal(false);
finalizeSaving = signal(false);
snackbarMessage = signal<string | null>(null);
snackbarType = signal<'success' | 'error'>('success');
editingTitle = signal(false);
@ -120,6 +134,49 @@ export class TitleToolbar {
});
}
togglePublished(nextValue: boolean) {
const flow = this.flow();
if (!flow || !this.canTogglePublished() || this.publishSaving()) return;
this.publishSaving.set(true);
this.flowsService.updatePublished(flow.id, nextValue).pipe(take(1)).subscribe({
next: (updatedFlow) => {
this.editorState.openDocument(updatedFlow, { skipDirtyCheck: true });
this.publishSaving.set(false);
this.showSnackbar(nextValue ? 'Flow published' : 'Flow unpublished', 'success');
},
error: (err) => {
this.publishSaving.set(false);
console.error('Update published failed', err);
this.showSnackbar(err instanceof Error ? err.message : 'Unable to update published flag', 'error');
}
});
}
finalizeFlow() {
const flow = this.flow();
if (!flow || !this.canFinalize() || this.finalizeSaving()) return;
this.finalizeSaving.set(true);
this.flowsService.finalizeFlow(flow.id).pipe(take(1)).subscribe({
next: (updatedFlow) => {
this.editorState.openDocument(updatedFlow, { skipDirtyCheck: true });
this.finalizeSaving.set(false);
this.showSnackbar('Flow finalized', 'success');
},
error: (err) => {
this.finalizeSaving.set(false);
console.error('Finalize flow failed', err);
this.showSnackbar(err instanceof Error ? err.message : 'Unable to finalize flow', 'error');
}
});
}
onFinalizedToggle(nextValue: boolean) {
if (!nextValue) return;
this.finalizeFlow();
}
private showSnackbar(message: string, type: 'success' | 'error') {
this.snackbarMessage.set(message);
this.snackbarType.set(type);

View File

@ -21,6 +21,7 @@ export class EditorStateHolder {
const flow = this.currentFlow();
const currentUsername = this.authorization.loggedInUser()?.username ?? null;
if (!flow) return false;
if (flow.finalized) return true;
return flow.visibility === 'PUBLIC' && flow.author !== currentUsername;
});
@ -127,7 +128,7 @@ export class EditorStateHolder {
save() {
if (this.isCurrentFlowReadOnly()) {
return throwError(() => new Error('Read-only public flows cannot be saved by non-owners.'));
return throwError(() => new Error('Read-only flows cannot be saved.'));
}
const flow = this.currentFlow()!;
const save$ = flow.id.startsWith(EditorStateHolder.ASSISTANT_DRAFT_PREFIX)

View File

@ -11,6 +11,7 @@ export const environment = {
apiUrl: 'http://localhost:8080',
assistantEnabled: false,
tourModeAlwaysOn: true,
turnstileSiteKey: '',
authorizationCallService: AuthorizationCallFakeService, // Assign the appropriate service here
assistantCallService: AssistantCallServiceFake,
flowsCallService: FlowsCallServiceFake,

View File

@ -11,6 +11,7 @@ export const environment = {
apiUrl: 'http://localhost:8080',
assistantEnabled: true,
tourModeAlwaysOn: false,
turnstileSiteKey: '',
assistantCallService: AssistantCallService,
authorizationCallService: AuthorizationCallService,
flowsCallService: FlowsCallService,

View File

@ -11,6 +11,7 @@ export const environment = {
production: true,
assistantEnabled: false,
tourModeAlwaysOn: false,
turnstileSiteKey: '',
authorizationCallService: AuthorizationCallService,
assistantCallService: AssistantCallService,
flowsCallService: FlowsCallService,