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 @@ +
+
+
+
Admin
+

User Statistics

+

Inspect aggregated usage metrics for any user account.

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