Add admin operations dashboard and flow publication controls
This commit is contained in:
parent
a34b72eb2b
commit
3e56f6db41
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)`,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const environment = {
|
|||
apiUrl: 'http://localhost:8080',
|
||||
assistantEnabled: true,
|
||||
tourModeAlwaysOn: false,
|
||||
turnstileSiteKey: '',
|
||||
assistantCallService: AssistantCallService,
|
||||
authorizationCallService: AuthorizationCallService,
|
||||
flowsCallService: FlowsCallService,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ export const environment = {
|
|||
production: true,
|
||||
assistantEnabled: false,
|
||||
tourModeAlwaysOn: false,
|
||||
turnstileSiteKey: '',
|
||||
authorizationCallService: AuthorizationCallService,
|
||||
assistantCallService: AssistantCallService,
|
||||
flowsCallService: FlowsCallService,
|
||||
|
|
|
|||
Loading…
Reference in New Issue