diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 0888ed3..859f32a 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -3,8 +3,12 @@ import { Login } from '@pages/auth/login/login';
import { Signup } from '@pages/auth/signup/signup';
import { AppLayout } from '@layouts/app-layout/app-layout';
import { authGuard } from '@guards/auth-guard';
+import { adminGuard } from '@guards/admin-guard';
+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 { AdminUsersListPage } from '@pages/admin/admin-users-list/admin-users-list';
export const routes: Routes = [
{
@@ -23,6 +27,26 @@ export const routes: Routes = [
{
path: 'tasks',
component: TasksExecutor
+ },
+ {
+ path: 'admin',
+ component: AdminLayout,
+ canActivate: [adminGuard],
+ children: [
+ {
+ path: '',
+ redirectTo: 'users',
+ pathMatch: 'full'
+ },
+ {
+ path: 'users',
+ component: AdminUsersListPage
+ },
+ {
+ path: 'create-user',
+ component: AdminCreateUserPage
+ }
+ ]
}
],
canActivate: [authGuard]
diff --git a/src/app/guards/admin-guard.ts b/src/app/guards/admin-guard.ts
new file mode 100644
index 0000000..a3fcead
--- /dev/null
+++ b/src/app/guards/admin-guard.ts
@@ -0,0 +1,15 @@
+import { inject } from '@angular/core';
+import { CanActivateFn, Router } from '@angular/router';
+import { Authorization } from '@services/authorization/authorization';
+
+export const adminGuard: CanActivateFn = () => {
+ const authorization = inject(Authorization);
+ const router = inject(Router);
+
+ if (authorization.isAdmin()) {
+ return true;
+ }
+
+ router.navigate(['/']);
+ return false;
+};
diff --git a/src/app/layouts/admin-layout/admin-layout.css b/src/app/layouts/admin-layout/admin-layout.css
new file mode 100644
index 0000000..3fb2c7d
--- /dev/null
+++ b/src/app/layouts/admin-layout/admin-layout.css
@@ -0,0 +1,112 @@
+.admin-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.admin-shell__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 6px 4px 10px;
+}
+
+.admin-shell__eyebrow {
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.admin-shell__title {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 26px;
+ font-weight: 800;
+}
+
+.admin-shell__subtitle {
+ margin: 6px 0 0;
+ color: #64748b;
+ font-size: 14px;
+}
+
+.admin-shell__body {
+ display: grid;
+ grid-template-columns: 240px minmax(0, 1fr);
+ gap: 18px;
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.admin-shell__sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ padding: 18px 14px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ border-radius: 22px;
+ background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
+}
+
+.admin-shell__sidebar-title {
+ color: #0f172a;
+ font-size: 12px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.admin-shell__nav {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.admin-shell__nav-link {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ border-radius: 16px;
+ color: #334155;
+ font-size: 14px;
+ font-weight: 700;
+ text-decoration: none;
+ transition: background-color 120ms ease, color 120ms ease, transform 120ms ease;
+}
+
+.admin-shell__nav-link:hover {
+ background: rgba(37, 99, 235, 0.08);
+ color: #1d4ed8;
+}
+
+.admin-shell__nav-link--active {
+ background: #2563eb;
+ color: #fff;
+ box-shadow: 0 12px 24px rgba(37, 99, 235, 0.2);
+}
+
+.admin-shell__content {
+ min-height: 0;
+ overflow: auto;
+}
+
+@media (max-width: 900px) {
+ .admin-shell__body {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-shell__sidebar {
+ padding: 14px;
+ }
+
+ .admin-shell__nav {
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+}
diff --git a/src/app/layouts/admin-layout/admin-layout.html b/src/app/layouts/admin-layout/admin-layout.html
new file mode 100644
index 0000000..9a99fba
--- /dev/null
+++ b/src/app/layouts/admin-layout/admin-layout.html
@@ -0,0 +1,37 @@
+
diff --git a/src/app/layouts/admin-layout/admin-layout.ts b/src/app/layouts/admin-layout/admin-layout.ts
new file mode 100644
index 0000000..250c1b0
--- /dev/null
+++ b/src/app/layouts/admin-layout/admin-layout.ts
@@ -0,0 +1,13 @@
+import { CommonModule } from '@angular/common';
+import { Component } from '@angular/core';
+import { MatIconModule } from '@angular/material/icon';
+import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+
+@Component({
+ selector: 'app-admin-layout',
+ imports: [CommonModule, RouterLink, RouterLinkActive, RouterOutlet, MatIconModule],
+ templateUrl: './admin-layout.html',
+ styleUrl: './admin-layout.css'
+})
+export class AdminLayout {
+}
diff --git a/src/app/layouts/app-layout/app-layout.css b/src/app/layouts/app-layout/app-layout.css
index b3fe525..07cc493 100644
--- a/src/app/layouts/app-layout/app-layout.css
+++ b/src/app/layouts/app-layout/app-layout.css
@@ -56,6 +56,10 @@
padding-top: 112px;
}
+.app-shell--admin {
+ padding-top: 56px;
+}
+
.app-main-content {
width: 100%;
flex: 1 1 auto;
diff --git a/src/app/layouts/app-layout/app-layout.html b/src/app/layouts/app-layout/app-layout.html
index dca7c80..e8e82c7 100644
--- a/src/app/layouts/app-layout/app-layout.html
+++ b/src/app/layouts/app-layout/app-layout.html
@@ -25,6 +25,12 @@
Change password
+ @if (isAdmin()) {
+
+ }
@if (changePasswordSuccess()) {
@@ -44,7 +50,8 @@
}
-
+
+ @if (!isAdminRoute()) {
+ }
diff --git a/src/app/layouts/app-layout/app-layout.ts b/src/app/layouts/app-layout/app-layout.ts
index c15dab2..f096a77 100644
--- a/src/app/layouts/app-layout/app-layout.ts
+++ b/src/app/layouts/app-layout/app-layout.ts
@@ -42,6 +42,14 @@ export class AppLayout {
});
}
+ isAdmin() {
+ return this.loggedUser()?.role === 'ADMIN';
+ }
+
+ isAdminRoute() {
+ return this.router.url.startsWith('/admin');
+ }
+
logout() {
this.authService.logout();
this.router.navigate(['/login']);
diff --git a/src/app/models/user.ts b/src/app/models/user.ts
index 91d4197..1085f10 100644
--- a/src/app/models/user.ts
+++ b/src/app/models/user.ts
@@ -1,12 +1,16 @@
+export type UserRole = 'USER' | 'ADMIN';
+
export type User = {
username: string;
- email: string;
+ email: string | null;
+ role: UserRole;
token?: string;
};
-export type UserRegistration = User & {
+export type UserRegistration = {
+ username: string;
password: string;
- fullname: string;
+ email: string;
};
export type ChangePasswordRequest = {
@@ -14,3 +18,24 @@ export type ChangePasswordRequest = {
oldPassword: string;
newPassword: string;
};
+
+export type AdminUser = {
+ username: string;
+ email: string | null;
+ role: UserRole;
+};
+
+export type AdminCreateUserRequest = {
+ username: string;
+ password: string;
+ email: string;
+ role?: UserRole | null;
+};
+
+export type AdminResetPasswordRequest = {
+ newPassword: string;
+};
+
+export type AdminChangeRoleRequest = {
+ role: UserRole;
+};
diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.css b/src/app/pages/admin/admin-create-user/admin-create-user.css
new file mode 100644
index 0000000..825a871
--- /dev/null
+++ b/src/app/pages/admin/admin-create-user/admin-create-user.css
@@ -0,0 +1,108 @@
+.admin-create-user-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-height: 100%;
+ padding: 8px 4px 16px;
+ background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
+}
+
+.admin-create-user-page__eyebrow {
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.admin-create-user-page__title {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 28px;
+ font-weight: 800;
+}
+
+.admin-create-user-page__subtitle {
+ margin: 6px 0 0;
+ color: #64748b;
+ font-size: 14px;
+}
+
+.admin-create-user-page__success,
+.admin-create-user-card__error {
+ border-radius: 14px;
+ padding: 12px 14px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.admin-create-user-page__success {
+ border: 1px solid #86efac;
+ background: #f0fdf4;
+ color: #166534;
+}
+
+.admin-create-user-card {
+ border-radius: 22px;
+ padding: 20px;
+ max-width: 980px;
+}
+
+.admin-create-user-card__title {
+ margin-bottom: 14px;
+ color: #0f172a;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.admin-create-user-card__error {
+ border: 1px solid #fecaca;
+ background: #fff1f2;
+ color: #b91c1c;
+}
+
+.admin-create-user-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.admin-create-user-form__grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.admin-create-user-form__password {
+ width: 100%;
+}
+
+.admin-create-user-checklist {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px 12px;
+}
+
+.admin-create-user-check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #b91c1c;
+ font-size: 12px;
+}
+
+.admin-create-user-check--ok {
+ color: #15803d;
+}
+
+.admin-create-user-form__actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+@media (max-width: 1100px) {
+ .admin-create-user-form__grid,
+ .admin-create-user-checklist {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.html b/src/app/pages/admin/admin-create-user/admin-create-user.html
new file mode 100644
index 0000000..92542da
--- /dev/null
+++ b/src/app/pages/admin/admin-create-user/admin-create-user.html
@@ -0,0 +1,80 @@
+
+
+
+ @if (successMessage()) {
+
{{ successMessage() }}
+ }
+
+
+ New user
+
+
+
+
diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.ts b/src/app/pages/admin/admin-create-user/admin-create-user.ts
new file mode 100644
index 0000000..5ac420d
--- /dev/null
+++ b/src/app/pages/admin/admin-create-user/admin-create-user.ts
@@ -0,0 +1,152 @@
+import { CommonModule } from '@angular/common';
+import { Component, effect, inject, signal } from '@angular/core';
+import { Field, form, minLength, required, validate } from '@angular/forms/signals';
+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 { MatSelectModule } from '@angular/material/select';
+import { AdminCreateUserRequest, UserRole } from '@models/user';
+import { Router } from '@angular/router';
+import { Authorization } from '@services/authorization/authorization';
+import { FormUtility } from '@utilities/form-utility';
+
+function hasValidPasswordComplexity(value: string): boolean {
+ return /^\S+$/.test(value)
+ && /[a-z]/.test(value)
+ && /[A-Z]/.test(value)
+ && /\d/.test(value)
+ && /[^A-Za-z0-9]/.test(value);
+}
+
+@Component({
+ selector: 'app-admin-create-user-page',
+ imports: [
+ CommonModule,
+ FormsModule,
+ Field,
+ MatButtonModule,
+ MatCardModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ MatSelectModule
+ ],
+ templateUrl: './admin-create-user.html',
+ styleUrl: './admin-create-user.css'
+})
+export class AdminCreateUserPage extends FormUtility {
+ private authorization = inject(Authorization);
+ private router = inject(Router);
+
+ readonly createError = signal(null);
+ readonly createEmailError = signal(null);
+ readonly createPasswordError = signal(null);
+ readonly createSaving = signal(false);
+ readonly successMessage = signal(null);
+
+ readonly createModel = signal({
+ username: '',
+ email: '',
+ password: '',
+ role: 'USER'
+ });
+
+ readonly createForm = form(this.createModel, (model) => {
+ required(model.username, { message: 'Username is required' });
+ minLength(model.username, 3, { message: 'Username must be at least 3 characters' });
+ required(model.email, { message: 'Email is required' });
+ validate(model.email, ({ value }) => {
+ const email = value();
+ if (!email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
+ return { kind: 'invalidEmail', message: 'Invalid email address' };
+ });
+ required(model.password, { message: 'Password is required' });
+ minLength(model.password, 8, { message: 'Password must be at least 8 characters long' });
+ validate(model.password, ({ value }) => {
+ const password = value();
+ if (!password || hasValidPasswordComplexity(password)) return null;
+ return {
+ kind: 'passwordComplexity',
+ message: 'Password must include uppercase, lowercase, number and special character, with no spaces.'
+ };
+ });
+ });
+
+ readonly createPasswordChecks = signal([
+ { label: 'At least 8 characters', satisfied: false },
+ { label: 'At least one lowercase letter', satisfied: false },
+ { label: 'At least one uppercase letter', satisfied: false },
+ { label: 'At least one number', satisfied: false },
+ { label: 'At least one special character', satisfied: false },
+ { label: 'No spaces', satisfied: true }
+ ]);
+
+ constructor() {
+ super();
+
+ effect(() => {
+ const password = this.createModel().password;
+ this.createPasswordChecks.set([
+ { label: 'At least 8 characters', satisfied: password.length >= 8 },
+ { label: 'At least one lowercase letter', satisfied: /[a-z]/.test(password) },
+ { label: 'At least one uppercase letter', satisfied: /[A-Z]/.test(password) },
+ { label: 'At least one number', satisfied: /\d/.test(password) },
+ { label: 'At least one special character', satisfied: /[^A-Za-z0-9]/.test(password) },
+ { label: 'No spaces', satisfied: /^\S*$/.test(password) }
+ ]);
+ });
+ }
+
+ setCreateRole(role: UserRole) {
+ this.createModel.update((current) => ({ ...current, role }));
+ }
+
+ onCreateUser() {
+ if (this.createForm().invalid()) return;
+
+ this.createSaving.set(true);
+ this.createError.set(null);
+ this.createEmailError.set(null);
+ this.createPasswordError.set(null);
+
+ this.authorization.createAdminUser(this.createModel()).subscribe({
+ next: () => {
+ this.createSaving.set(false);
+ this.successMessage.set('User created successfully.');
+ this.createModel.set({
+ username: '',
+ email: '',
+ password: '',
+ role: 'USER'
+ });
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ if (this.redirectOnAdminAccessDenied(error)) return;
+ this.createSaving.set(false);
+ const message = error instanceof Error ? error.message : 'Unable to create user.';
+ if (message === 'INVALID_EMAIL') {
+ this.createEmailError.set('Invalid email address');
+ return;
+ }
+ if (message === 'INVALID_PASSWORD') {
+ this.createPasswordError.set('Password does not satisfy the required policy.');
+ return;
+ }
+ this.createError.set(message);
+ }
+ });
+ }
+
+ private redirectOnAdminAccessDenied(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : '';
+ if (message !== 'Admin access required.') return false;
+
+ this.createSaving.set(false);
+ this.router.navigateByUrl('/editor');
+ return true;
+ }
+}
diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.css b/src/app/pages/admin/admin-users-list/admin-users-list.css
new file mode 100644
index 0000000..2139598
--- /dev/null
+++ b/src/app/pages/admin/admin-users-list/admin-users-list.css
@@ -0,0 +1,142 @@
+.admin-users-list-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-height: 100%;
+ padding: 8px 4px 16px;
+ background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
+}
+
+.admin-users-list-page__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.admin-users-list-page__eyebrow {
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.admin-users-list-page__title {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 28px;
+ font-weight: 800;
+}
+
+.admin-users-list-page__subtitle {
+ margin: 6px 0 0;
+ color: #64748b;
+ font-size: 14px;
+}
+
+.admin-users-list-page__success,
+.admin-users-list-card__error,
+.admin-users-list-row__error {
+ border-radius: 14px;
+ padding: 12px 14px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.admin-users-list-page__success {
+ border: 1px solid #86efac;
+ background: #f0fdf4;
+ color: #166534;
+}
+
+.admin-users-list-card {
+ border-radius: 22px;
+ padding: 20px;
+}
+
+.admin-users-list-card__title {
+ margin-bottom: 14px;
+ color: #0f172a;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.admin-users-list-card__error,
+.admin-users-list-row__error {
+ border: 1px solid #fecaca;
+ background: #fff1f2;
+ color: #b91c1c;
+}
+
+.admin-users-list-state {
+ color: #64748b;
+ font-size: 14px;
+ padding: 12px 4px;
+}
+
+.admin-users-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.admin-users-list-row {
+ display: grid;
+ grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) auto;
+ gap: 16px;
+ align-items: center;
+ border: 1px solid #e2e8f0;
+ border-radius: 18px;
+ background: #f8fbff;
+ padding: 16px;
+}
+
+.admin-users-list-row__identity {
+ min-width: 0;
+}
+
+.admin-users-list-row__username {
+ color: #0f172a;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.admin-users-list-row__email {
+ color: #64748b;
+ font-size: 13px;
+ margin-top: 4px;
+ word-break: break-word;
+}
+
+.admin-users-list-row__role {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.admin-users-list-row__role-field {
+ min-width: 150px;
+}
+
+.admin-users-list-row__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.admin-users-list-row__error {
+ grid-column: 1 / -1;
+}
+
+@media (max-width: 1100px) {
+ .admin-users-list-row {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-users-list-row__actions {
+ justify-content: flex-start;
+ }
+}
diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.html b/src/app/pages/admin/admin-users-list/admin-users-list.html
new file mode 100644
index 0000000..2b881ce
--- /dev/null
+++ b/src/app/pages/admin/admin-users-list/admin-users-list.html
@@ -0,0 +1,74 @@
+
+
+
+ @if (successMessage()) {
+
{{ successMessage() }}
+ }
+
+
+ Users
+
+ @if (loading()) {
+ Loading users...
+ } @else if (pageError()) {
+ {{ pageError() }}
+ } @else if (!users().length) {
+ No users found.
+ } @else {
+
+ @for (user of users(); track user.username) {
+
+
+
{{ user.username }}
+
{{ user.email || 'No email' }}
+
+
+
+
+ Role
+
+ USER
+ ADMIN
+
+
+
+
+
+
+
+
+
+
+ @if (roleErrorByUser()[user.username]) {
+
{{ roleErrorByUser()[user.username] }}
+ }
+
+ }
+
+ }
+
+
+ @if (resetPasswordDialogUser(); as user) {
+
+
+ }
+
diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.ts b/src/app/pages/admin/admin-users-list/admin-users-list.ts
new file mode 100644
index 0000000..0ae8c41
--- /dev/null
+++ b/src/app/pages/admin/admin-users-list/admin-users-list.ts
@@ -0,0 +1,167 @@
+import { CommonModule } from '@angular/common';
+import { Component, 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 { MatSelectModule } from '@angular/material/select';
+import { AdminUser, UserRole } from '@models/user';
+import { Router } from '@angular/router';
+import { Authorization } from '@services/authorization/authorization';
+import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
+import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog';
+
+@Component({
+ selector: 'app-admin-users-list-page',
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatButtonModule,
+ MatCardModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatSelectModule,
+ AdminResetPasswordDialogComponent
+ ],
+ templateUrl: './admin-users-list.html',
+ styleUrl: './admin-users-list.css'
+})
+export class AdminUsersListPage {
+ private authorization = inject(Authorization);
+ private confirmDialog = inject(ConfirmDialogService);
+ private router = inject(Router);
+
+ readonly users = signal([]);
+ readonly loading = signal(true);
+ readonly pageError = signal(null);
+ readonly roleSavingByUser = signal>({});
+ readonly roleErrorByUser = signal>({});
+ readonly deleteBusyByUser = signal>({});
+ readonly roleDraftByUser = signal>({});
+ readonly resetPasswordDialogUser = signal(null);
+ readonly resetPasswordSaving = signal(false);
+ readonly resetPasswordError = signal(null);
+ readonly successMessage = signal(null);
+
+ ngOnInit() {
+ this.loadUsers();
+ }
+
+ loadUsers() {
+ this.loading.set(true);
+ this.pageError.set(null);
+ this.authorization.listAdminUsers().subscribe({
+ next: (users) => {
+ this.users.set(users);
+ this.roleDraftByUser.set(
+ users.reduce>((acc, user) => {
+ acc[user.username] = user.role;
+ return acc;
+ }, {})
+ );
+ this.loading.set(false);
+ },
+ error: (error) => {
+ if (this.redirectOnAdminAccessDenied(error)) return;
+ this.pageError.set(error instanceof Error ? error.message : 'Unable to load users.');
+ this.loading.set(false);
+ }
+ });
+ }
+
+ roleDraft(username: string): UserRole {
+ return this.roleDraftByUser()[username] ?? 'USER';
+ }
+
+ setRoleDraft(username: string, role: UserRole) {
+ this.roleDraftByUser.update((current) => ({ ...current, [username]: role }));
+ this.roleErrorByUser.update((current) => ({ ...current, [username]: null }));
+ }
+
+ saveRole(user: AdminUser) {
+ const nextRole = this.roleDraft(user.username);
+ if (nextRole === user.role) return;
+
+ this.roleSavingByUser.update((current) => ({ ...current, [user.username]: true }));
+ this.roleErrorByUser.update((current) => ({ ...current, [user.username]: null }));
+ this.authorization.changeAdminUserRole(user.username, { role: nextRole }).subscribe({
+ next: () => {
+ this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.successMessage.set(`Role updated for ${user.username}.`);
+ this.loadUsers();
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ if (this.redirectOnAdminAccessDenied(error)) return;
+ this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.roleErrorByUser.update((current) => ({
+ ...current,
+ [user.username]: error instanceof Error ? error.message : 'Unable to update role.'
+ }));
+ }
+ });
+ }
+
+ openResetPasswordDialog(user: AdminUser) {
+ this.resetPasswordError.set(null);
+ this.resetPasswordDialogUser.set(user);
+ }
+
+ closeResetPasswordDialog() {
+ if (this.resetPasswordSaving()) return;
+ this.resetPasswordDialogUser.set(null);
+ this.resetPasswordError.set(null);
+ }
+
+ submitResetPassword(event: { username: string; newPassword: string }) {
+ this.resetPasswordSaving.set(true);
+ this.resetPasswordError.set(null);
+ this.authorization.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({
+ next: () => {
+ this.resetPasswordSaving.set(false);
+ this.resetPasswordDialogUser.set(null);
+ this.successMessage.set(`Password updated for ${event.username}.`);
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ if (this.redirectOnAdminAccessDenied(error)) return;
+ this.resetPasswordSaving.set(false);
+ this.resetPasswordError.set(error instanceof Error ? error.message : 'Unable to update password.');
+ }
+ });
+ }
+
+ async deleteUser(user: AdminUser) {
+ const confirmed = await this.confirmDialog.open(`Delete user ${user.username}?`);
+ if (!confirmed) return;
+
+ this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: true }));
+ this.authorization.deleteAdminUser(user.username).subscribe({
+ next: () => {
+ this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.successMessage.set(`User ${user.username} deleted.`);
+ this.loadUsers();
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ if (this.redirectOnAdminAccessDenied(error)) return;
+ this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.roleErrorByUser.update((current) => ({
+ ...current,
+ [user.username]: error instanceof Error ? error.message : 'Unable to delete user.'
+ }));
+ }
+ });
+ }
+
+ private redirectOnAdminAccessDenied(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : '';
+ if (message !== 'Admin access required.') return false;
+
+ this.loading.set(false);
+ this.resetPasswordSaving.set(false);
+ this.router.navigateByUrl('/editor');
+ return true;
+ }
+}
diff --git a/src/app/pages/admin/admin-users/admin-users.css b/src/app/pages/admin/admin-users/admin-users.css
new file mode 100644
index 0000000..01c0a0f
--- /dev/null
+++ b/src/app/pages/admin/admin-users/admin-users.css
@@ -0,0 +1,187 @@
+.admin-users-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ height: 100%;
+ padding: 8px 4px 16px;
+ overflow: auto;
+ background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%);
+}
+
+.admin-users-page__header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.admin-users-page__eyebrow {
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.admin-users-page__title {
+ margin: 4px 0 0;
+ color: #0f172a;
+ font-size: 28px;
+ font-weight: 800;
+}
+
+.admin-users-page__subtitle {
+ margin: 6px 0 0;
+ color: #64748b;
+ font-size: 14px;
+}
+
+.admin-users-page__success,
+.admin-users-card__error,
+.admin-users-row__error {
+ border-radius: 14px;
+ padding: 12px 14px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.admin-users-page__success {
+ border: 1px solid #86efac;
+ background: #f0fdf4;
+ color: #166534;
+}
+
+.admin-users-card {
+ border-radius: 22px;
+ padding: 20px;
+}
+
+.admin-users-card__title {
+ margin-bottom: 14px;
+ color: #0f172a;
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.admin-users-card__error,
+.admin-users-row__error {
+ border: 1px solid #fecaca;
+ background: #fff1f2;
+ color: #b91c1c;
+}
+
+.admin-users-create {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.admin-users-create__grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.admin-users-create__password {
+ width: 100%;
+}
+
+.admin-users-checklist {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px 12px;
+}
+
+.admin-users-check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #b91c1c;
+ font-size: 12px;
+}
+
+.admin-users-check--ok {
+ color: #15803d;
+}
+
+.admin-users-create__actions {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.admin-users-state {
+ color: #64748b;
+ font-size: 14px;
+ padding: 12px 4px;
+}
+
+.admin-users-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.admin-users-row {
+ display: grid;
+ grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) auto;
+ gap: 16px;
+ align-items: center;
+ border: 1px solid #e2e8f0;
+ border-radius: 18px;
+ background: #f8fbff;
+ padding: 16px;
+}
+
+.admin-users-row__identity {
+ min-width: 0;
+}
+
+.admin-users-row__username {
+ color: #0f172a;
+ font-size: 15px;
+ font-weight: 700;
+}
+
+.admin-users-row__email {
+ color: #64748b;
+ font-size: 13px;
+ margin-top: 4px;
+ word-break: break-word;
+}
+
+.admin-users-row__role {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.admin-users-row__role-field {
+ min-width: 150px;
+}
+
+.admin-users-row__actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.admin-users-row__error {
+ grid-column: 1 / -1;
+}
+
+@media (max-width: 1100px) {
+ .admin-users-create__grid,
+ .admin-users-row {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-users-checklist {
+ grid-template-columns: 1fr;
+ }
+
+ .admin-users-row__actions {
+ justify-content: flex-start;
+ }
+}
diff --git a/src/app/pages/admin/admin-users/admin-users.html b/src/app/pages/admin/admin-users/admin-users.html
new file mode 100644
index 0000000..9322098
--- /dev/null
+++ b/src/app/pages/admin/admin-users/admin-users.html
@@ -0,0 +1,140 @@
+
+
+
+ @if (successMessage()) {
+
{{ successMessage() }}
+ }
+
+
+ Create User
+
+
+
+
+ Users
+
+ @if (loading()) {
+ Loading users...
+ } @else if (pageError()) {
+ {{ pageError() }}
+ } @else if (!users().length) {
+ No users found.
+ } @else {
+
+ @for (user of users(); track user.username) {
+
+
+
{{ user.username }}
+
{{ user.email || 'No email' }}
+
+
+
+
+ Role
+
+ USER
+ ADMIN
+
+
+
+
+
+
+
+
+
+
+ @if (roleErrorByUser()[user.username]) {
+
{{ roleErrorByUser()[user.username] }}
+ }
+
+ }
+
+ }
+
+
+ @if (resetPasswordDialogUser(); as user) {
+
+
+ }
+
diff --git a/src/app/pages/admin/admin-users/admin-users.ts b/src/app/pages/admin/admin-users/admin-users.ts
new file mode 100644
index 0000000..b67e223
--- /dev/null
+++ b/src/app/pages/admin/admin-users/admin-users.ts
@@ -0,0 +1,278 @@
+import { CommonModule } from '@angular/common';
+import { Component, effect, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Field, form, minLength, required, validate } from '@angular/forms/signals';
+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 { MatSelectModule } from '@angular/material/select';
+import {
+ AdminCreateUserRequest,
+ AdminUser,
+ UserRole
+} from '@models/user';
+import { Authorization } from '@services/authorization/authorization';
+import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
+import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog';
+import { FormUtility } from '@utilities/form-utility';
+
+function hasValidPasswordComplexity(value: string): boolean {
+ return /^\S+$/.test(value)
+ && /[a-z]/.test(value)
+ && /[A-Z]/.test(value)
+ && /\d/.test(value)
+ && /[^A-Za-z0-9]/.test(value);
+}
+
+@Component({
+ selector: 'app-admin-users',
+ imports: [
+ CommonModule,
+ FormsModule,
+ Field,
+ MatButtonModule,
+ MatCardModule,
+ MatFormFieldModule,
+ MatIconModule,
+ MatInputModule,
+ MatSelectModule,
+ AdminResetPasswordDialogComponent
+ ],
+ templateUrl: './admin-users.html',
+ styleUrl: './admin-users.css'
+})
+export class AdminUsersPage extends FormUtility {
+ private authorization = inject(Authorization);
+ private confirmDialog = inject(ConfirmDialogService);
+
+ readonly users = signal([]);
+ readonly loading = signal(true);
+ readonly pageError = signal(null);
+ readonly createError = signal(null);
+ readonly createEmailError = signal(null);
+ readonly createPasswordError = signal(null);
+ readonly createSaving = signal(false);
+ readonly roleSavingByUser = signal>({});
+ readonly roleErrorByUser = signal>({});
+ readonly deleteBusyByUser = signal>({});
+ readonly resetPasswordDialogUser = signal(null);
+ readonly resetPasswordSaving = signal(false);
+ readonly resetPasswordError = signal(null);
+ readonly successMessage = signal(null);
+ readonly roleDraftByUser = signal>({});
+
+ readonly createModel = signal({
+ username: '',
+ email: '',
+ password: '',
+ role: 'USER'
+ });
+
+ readonly createForm = form(this.createModel, (model) => {
+ required(model.username, { message: 'Username is required' });
+ minLength(model.username, 3, { message: 'Username must be at least 3 characters' });
+ required(model.email, { message: 'Email is required' });
+ validate(model.email, ({ value }) => {
+ const email = value();
+ if (!email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
+ return {
+ kind: 'invalidEmail',
+ message: 'Invalid email address'
+ };
+ });
+ required(model.password, { message: 'Password is required' });
+ minLength(model.password, 8, { message: 'Password must be at least 8 characters long' });
+ validate(model.password, ({ value }) => {
+ const password = value();
+ if (!password || hasValidPasswordComplexity(password)) return null;
+ return {
+ kind: 'passwordComplexity',
+ message: 'Password must include uppercase, lowercase, number and special character, with no spaces.'
+ };
+ });
+ });
+
+ readonly createPasswordChecks = signal([
+ { label: 'At least 8 characters', satisfied: false },
+ { label: 'At least one lowercase letter', satisfied: false },
+ { label: 'At least one uppercase letter', satisfied: false },
+ { label: 'At least one number', satisfied: false },
+ { label: 'At least one special character', satisfied: false },
+ { label: 'No spaces', satisfied: true }
+ ]);
+
+ constructor() {
+ super();
+
+ effect(() => {
+ const password = this.createModel().password;
+ this.createPasswordChecks.set([
+ { label: 'At least 8 characters', satisfied: password.length >= 8 },
+ { label: 'At least one lowercase letter', satisfied: /[a-z]/.test(password) },
+ { label: 'At least one uppercase letter', satisfied: /[A-Z]/.test(password) },
+ { label: 'At least one number', satisfied: /\d/.test(password) },
+ { label: 'At least one special character', satisfied: /[^A-Za-z0-9]/.test(password) },
+ { label: 'No spaces', satisfied: /^\S*$/.test(password) }
+ ]);
+ });
+ }
+
+ ngOnInit() {
+ this.loadUsers();
+ }
+
+ loadUsers() {
+ this.loading.set(true);
+ this.pageError.set(null);
+ this.authorization.listAdminUsers().subscribe({
+ next: (users) => {
+ this.users.set(users);
+ this.roleDraftByUser.set(
+ users.reduce>((acc, user) => {
+ acc[user.username] = user.role;
+ return acc;
+ }, {})
+ );
+ this.loading.set(false);
+ },
+ error: (error) => {
+ this.pageError.set(error instanceof Error ? error.message : 'Unable to load users.');
+ this.loading.set(false);
+ }
+ });
+ }
+
+ onCreateUser() {
+ if (this.createForm().invalid()) return;
+
+ this.createSaving.set(true);
+ this.createError.set(null);
+ this.createEmailError.set(null);
+ this.createPasswordError.set(null);
+
+ this.authorization.createAdminUser(this.createModel()).subscribe({
+ next: () => {
+ this.createSaving.set(false);
+ this.successMessage.set('User created successfully.');
+ this.createModel.set({
+ username: '',
+ email: '',
+ password: '',
+ role: 'USER'
+ });
+ this.loadUsers();
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ this.createSaving.set(false);
+ const message = error instanceof Error ? error.message : 'Unable to create user.';
+ if (message === 'INVALID_EMAIL') {
+ this.createEmailError.set('Invalid email address');
+ return;
+ }
+ if (message === 'INVALID_PASSWORD') {
+ this.createPasswordError.set('Password does not satisfy the required policy.');
+ return;
+ }
+ this.createError.set(message);
+ }
+ });
+ }
+
+ roleDraft(username: string): UserRole {
+ return this.roleDraftByUser()[username] ?? 'USER';
+ }
+
+ setCreateRole(role: UserRole) {
+ this.createModel.update((current) => ({
+ ...current,
+ role
+ }));
+ }
+
+ setRoleDraft(username: string, role: UserRole) {
+ this.roleDraftByUser.update((current) => ({
+ ...current,
+ [username]: role
+ }));
+ this.roleErrorByUser.update((current) => ({
+ ...current,
+ [username]: null
+ }));
+ }
+
+ saveRole(user: AdminUser) {
+ const nextRole = this.roleDraft(user.username);
+ if (nextRole === user.role) return;
+
+ this.roleSavingByUser.update((current) => ({ ...current, [user.username]: true }));
+ this.roleErrorByUser.update((current) => ({ ...current, [user.username]: null }));
+ this.authorization.changeAdminUserRole(user.username, { role: nextRole }).subscribe({
+ next: () => {
+ this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.successMessage.set(`Role updated for ${user.username}.`);
+ this.loadUsers();
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.roleErrorByUser.update((current) => ({
+ ...current,
+ [user.username]: error instanceof Error ? error.message : 'Unable to update role.'
+ }));
+ }
+ });
+ }
+
+ openResetPasswordDialog(user: AdminUser) {
+ this.resetPasswordError.set(null);
+ this.resetPasswordDialogUser.set(user);
+ }
+
+ closeResetPasswordDialog() {
+ if (this.resetPasswordSaving()) return;
+ this.resetPasswordDialogUser.set(null);
+ this.resetPasswordError.set(null);
+ }
+
+ submitResetPassword(event: { username: string; newPassword: string }) {
+ this.resetPasswordSaving.set(true);
+ this.resetPasswordError.set(null);
+ this.authorization.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({
+ next: () => {
+ this.resetPasswordSaving.set(false);
+ this.resetPasswordDialogUser.set(null);
+ this.successMessage.set(`Password updated for ${event.username}.`);
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ this.resetPasswordSaving.set(false);
+ this.resetPasswordError.set(error instanceof Error ? error.message : 'Unable to update password.');
+ }
+ });
+ }
+
+ async deleteUser(user: AdminUser) {
+ const confirmed = await this.confirmDialog.open(`Delete user ${user.username}?`);
+ if (!confirmed) return;
+
+ this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: true }));
+ this.authorization.deleteAdminUser(user.username).subscribe({
+ next: () => {
+ this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.successMessage.set(`User ${user.username} deleted.`);
+ this.loadUsers();
+ setTimeout(() => this.successMessage.set(null), 3000);
+ },
+ error: (error) => {
+ this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false }));
+ this.roleErrorByUser.update((current) => ({
+ ...current,
+ [user.username]: error instanceof Error ? error.message : 'Unable to delete user.'
+ }));
+ }
+ });
+ }
+}
diff --git a/src/app/pages/auth/signup/signup.html b/src/app/pages/auth/signup/signup.html
index de55713..349650f 100644
--- a/src/app/pages/auth/signup/signup.html
+++ b/src/app/pages/auth/signup/signup.html
@@ -6,19 +6,13 @@