administration page added

This commit is contained in:
Lucio Lelii 2026-03-26 09:59:11 +01:00
parent 167e301a21
commit a34b72eb2b
27 changed files with 2152 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
<div class="admin-shell">
<div class="admin-shell__header">
<div>
<div class="admin-shell__eyebrow">Administration</div>
<h1 class="admin-shell__title">Admin Console</h1>
<p class="admin-shell__subtitle">Manage users and admin-only operations from a dedicated workspace.</p>
</div>
</div>
<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>
</aside>
<section class="admin-shell__content">
<router-outlet></router-outlet>
</section>
</div>
</div>

View File

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

View File

@ -56,6 +56,10 @@
padding-top: 112px;
}
.app-shell--admin {
padding-top: 56px;
}
.app-main-content {
width: 100%;
flex: 1 1 auto;

View File

@ -25,6 +25,12 @@
<mat-icon fontIcon="password"></mat-icon>
<span>Change password</span>
</button>
@if (isAdmin()) {
<button mat-menu-item type="button" routerLink="/admin/users">
<mat-icon fontIcon="admin_panel_settings"></mat-icon>
<span>Admin users</span>
</button>
}
</mat-menu>
@if (changePasswordSuccess()) {
@ -44,7 +50,8 @@
</app-change-password-dialog>
}
<div class="app-shell">
<div class="app-shell" [class.app-shell--admin]="isAdminRoute()">
@if (!isAdminRoute()) {
<div class="app-tabbar-wrap">
<nav mat-tab-nav-bar [tabPanel]="tabPanel" class="app-tabbar">
<a
@ -72,6 +79,7 @@
</nav>
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
</div>
}
<main class="main-content app-main-content">
<router-outlet></router-outlet>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,80 @@
<div class="admin-create-user-page">
<div class="admin-create-user-page__header">
<div>
<div class="admin-create-user-page__eyebrow">Admin</div>
<h2 class="admin-create-user-page__title">Create User</h2>
<p class="admin-create-user-page__subtitle">Provision a new account with email, role and password policy enforcement.</p>
</div>
</div>
@if (successMessage()) {
<div class="admin-create-user-page__success">{{ successMessage() }}</div>
}
<mat-card class="admin-create-user-card">
<div class="admin-create-user-card__title">New user</div>
<form class="admin-create-user-form" (ngSubmit)="onCreateUser()">
<div class="admin-create-user-form__grid">
<mat-form-field appearance="outline">
<mat-label>Username</mat-label>
<input matInput [field]="createForm.username" placeholder="manageduser" />
@if (isInvalid(createForm.username())) {
<mat-error>{{ createForm.username().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput [field]="createForm.email" placeholder="manageduser@example.com" />
@if (createEmailError()) {
<mat-error>{{ createEmailError() }}</mat-error>
} @else if (isInvalid(createForm.email())) {
<mat-error>{{ createForm.email().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Role</mat-label>
<mat-select [ngModel]="createModel().role" (ngModelChange)="setCreateRole($event)" [ngModelOptions]="{ standalone: true }">
<mat-option value="USER">USER</mat-option>
<mat-option value="ADMIN">ADMIN</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="admin-create-user-form__password">
<mat-label>Password</mat-label>
<input matInput type="password" [field]="createForm.password" placeholder="Managedpass1!" />
@if (createPasswordError()) {
<mat-error>{{ createPasswordError() }}</mat-error>
} @else if (isInvalid(createForm.password())) {
<mat-error>{{ createForm.password().errors()[0].message }}</mat-error>
}
</mat-form-field>
<div class="admin-create-user-checklist">
@for (check of createPasswordChecks(); track check.label) {
<div class="admin-create-user-check" [class.admin-create-user-check--ok]="check.satisfied">
<mat-icon [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<span>{{ check.label }}</span>
</div>
}
</div>
@if (createError()) {
<div class="admin-create-user-card__error">{{ createError() }}</div>
}
<div class="admin-create-user-form__actions">
<button type="submit" mat-flat-button [disabled]="createForm().invalid() || createSaving()">
@if (createSaving()) {
Creating...
} @else {
Create user
}
</button>
</div>
</form>
</mat-card>
</div>

View File

@ -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<string | null>(null);
readonly createEmailError = signal<string | null>(null);
readonly createPasswordError = signal<string | null>(null);
readonly createSaving = signal(false);
readonly successMessage = signal<string | null>(null);
readonly createModel = signal<AdminCreateUserRequest>({
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;
}
}

View File

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

View File

@ -0,0 +1,74 @@
<div class="admin-users-list-page">
<div class="admin-users-list-page__header">
<div>
<div class="admin-users-list-page__eyebrow">Admin</div>
<h2 class="admin-users-list-page__title">Users</h2>
<p class="admin-users-list-page__subtitle">Review existing users, change roles, reset passwords and remove accounts.</p>
</div>
<button type="button" mat-stroked-button (click)="loadUsers()">
<mat-icon fontIcon="refresh"></mat-icon>
Refresh
</button>
</div>
@if (successMessage()) {
<div class="admin-users-list-page__success">{{ successMessage() }}</div>
}
<mat-card class="admin-users-list-card">
<div class="admin-users-list-card__title">Users</div>
@if (loading()) {
<div class="admin-users-list-state">Loading users...</div>
} @else if (pageError()) {
<div class="admin-users-list-card__error">{{ pageError() }}</div>
} @else if (!users().length) {
<div class="admin-users-list-state">No users found.</div>
} @else {
<div class="admin-users-list">
@for (user of users(); track user.username) {
<div class="admin-users-list-row">
<div class="admin-users-list-row__identity">
<div class="admin-users-list-row__username">{{ user.username }}</div>
<div class="admin-users-list-row__email">{{ user.email || 'No email' }}</div>
</div>
<div class="admin-users-list-row__role">
<mat-form-field appearance="outline" class="admin-users-list-row__role-field">
<mat-label>Role</mat-label>
<mat-select [ngModel]="roleDraft(user.username)" (ngModelChange)="setRoleDraft(user.username, $event)" [ngModelOptions]="{ standalone: true }">
<mat-option value="USER">USER</mat-option>
<mat-option value="ADMIN">ADMIN</mat-option>
</mat-select>
</mat-form-field>
<button type="button" mat-stroked-button [disabled]="roleSavingByUser()[user.username] || roleDraft(user.username) === user.role" (click)="saveRole(user)">
Save role
</button>
</div>
<div class="admin-users-list-row__actions">
<button type="button" mat-stroked-button (click)="openResetPasswordDialog(user)">Reset password</button>
<button type="button" mat-stroked-button color="warn" [disabled]="deleteBusyByUser()[user.username]" (click)="deleteUser(user)">
Delete
</button>
</div>
@if (roleErrorByUser()[user.username]) {
<div class="admin-users-list-row__error">{{ roleErrorByUser()[user.username] }}</div>
}
</div>
}
</div>
}
</mat-card>
@if (resetPasswordDialogUser(); as user) {
<app-admin-reset-password-dialog
[username]="user.username"
[saving]="resetPasswordSaving()"
[submitError]="resetPasswordError()"
(closed)="closeResetPasswordDialog()"
(submitted)="submitResetPassword($event)">
</app-admin-reset-password-dialog>
}
</div>

View File

@ -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<AdminUser[]>([]);
readonly loading = signal(true);
readonly pageError = signal<string | null>(null);
readonly roleSavingByUser = signal<Record<string, boolean>>({});
readonly roleErrorByUser = signal<Record<string, string | null>>({});
readonly deleteBusyByUser = signal<Record<string, boolean>>({});
readonly roleDraftByUser = signal<Record<string, UserRole>>({});
readonly resetPasswordDialogUser = signal<AdminUser | null>(null);
readonly resetPasswordSaving = signal(false);
readonly resetPasswordError = signal<string | null>(null);
readonly successMessage = signal<string | null>(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<Record<string, UserRole>>((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;
}
}

View File

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

View File

@ -0,0 +1,140 @@
<div class="admin-users-page">
<div class="admin-users-page__header">
<div>
<div class="admin-users-page__eyebrow">Admin</div>
<h2 class="admin-users-page__title">User Management</h2>
<p class="admin-users-page__subtitle">Create users, change roles, reset passwords and remove accounts.</p>
</div>
<button type="button" mat-stroked-button (click)="loadUsers()">
<mat-icon fontIcon="refresh"></mat-icon>
Refresh
</button>
</div>
@if (successMessage()) {
<div class="admin-users-page__success">{{ successMessage() }}</div>
}
<mat-card class="admin-users-card">
<div class="admin-users-card__title">Create User</div>
<form class="admin-users-create" (ngSubmit)="onCreateUser()">
<div class="admin-users-create__grid">
<mat-form-field appearance="outline">
<mat-label>Username</mat-label>
<input matInput [field]="createForm.username" placeholder="manageduser" />
@if (isInvalid(createForm.username())) {
<mat-error>{{ createForm.username().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Email</mat-label>
<input matInput [field]="createForm.email" placeholder="manageduser@example.com" />
@if (createEmailError()) {
<mat-error>{{ createEmailError() }}</mat-error>
} @else if (isInvalid(createForm.email())) {
<mat-error>{{ createForm.email().errors()[0].message }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Role</mat-label>
<mat-select [ngModel]="createModel().role" (ngModelChange)="setCreateRole($event)" [ngModelOptions]="{ standalone: true }">
<mat-option value="USER">USER</mat-option>
<mat-option value="ADMIN">ADMIN</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-form-field appearance="outline" class="admin-users-create__password">
<mat-label>Password</mat-label>
<input matInput type="password" [field]="createForm.password" placeholder="Managedpass1!" />
@if (createPasswordError()) {
<mat-error>{{ createPasswordError() }}</mat-error>
} @else if (isInvalid(createForm.password())) {
<mat-error>{{ createForm.password().errors()[0].message }}</mat-error>
}
</mat-form-field>
<div class="admin-users-checklist">
@for (check of createPasswordChecks(); track check.label) {
<div class="admin-users-check" [class.admin-users-check--ok]="check.satisfied">
<mat-icon [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<span>{{ check.label }}</span>
</div>
}
</div>
@if (createError()) {
<div class="admin-users-card__error">{{ createError() }}</div>
}
<div class="admin-users-create__actions">
<button type="submit" mat-flat-button [disabled]="createForm().invalid() || createSaving()">
@if (createSaving()) {
Creating...
} @else {
Create user
}
</button>
</div>
</form>
</mat-card>
<mat-card class="admin-users-card">
<div class="admin-users-card__title">Users</div>
@if (loading()) {
<div class="admin-users-state">Loading users...</div>
} @else if (pageError()) {
<div class="admin-users-card__error">{{ pageError() }}</div>
} @else if (!users().length) {
<div class="admin-users-state">No users found.</div>
} @else {
<div class="admin-users-list">
@for (user of users(); track user.username) {
<div class="admin-users-row">
<div class="admin-users-row__identity">
<div class="admin-users-row__username">{{ user.username }}</div>
<div class="admin-users-row__email">{{ user.email || 'No email' }}</div>
</div>
<div class="admin-users-row__role">
<mat-form-field appearance="outline" class="admin-users-row__role-field">
<mat-label>Role</mat-label>
<mat-select [ngModel]="roleDraft(user.username)" (ngModelChange)="setRoleDraft(user.username, $event)" [ngModelOptions]="{ standalone: true }">
<mat-option value="USER">USER</mat-option>
<mat-option value="ADMIN">ADMIN</mat-option>
</mat-select>
</mat-form-field>
<button type="button" mat-stroked-button [disabled]="roleSavingByUser()[user.username] || roleDraft(user.username) === user.role" (click)="saveRole(user)">
Save role
</button>
</div>
<div class="admin-users-row__actions">
<button type="button" mat-stroked-button (click)="openResetPasswordDialog(user)">Reset password</button>
<button type="button" mat-stroked-button color="warn" [disabled]="deleteBusyByUser()[user.username]" (click)="deleteUser(user)">
Delete
</button>
</div>
@if (roleErrorByUser()[user.username]) {
<div class="admin-users-row__error">{{ roleErrorByUser()[user.username] }}</div>
}
</div>
}
</div>
}
</mat-card>
@if (resetPasswordDialogUser(); as user) {
<app-admin-reset-password-dialog
[username]="user.username"
[saving]="resetPasswordSaving()"
[submitError]="resetPasswordError()"
(closed)="closeResetPasswordDialog()"
(submitted)="submitResetPassword($event)">
</app-admin-reset-password-dialog>
}
</div>

View File

@ -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<AdminUser[]>([]);
readonly loading = signal(true);
readonly pageError = signal<string | null>(null);
readonly createError = signal<string | null>(null);
readonly createEmailError = signal<string | null>(null);
readonly createPasswordError = signal<string | null>(null);
readonly createSaving = signal(false);
readonly roleSavingByUser = signal<Record<string, boolean>>({});
readonly roleErrorByUser = signal<Record<string, string | null>>({});
readonly deleteBusyByUser = signal<Record<string, boolean>>({});
readonly resetPasswordDialogUser = signal<AdminUser | null>(null);
readonly resetPasswordSaving = signal(false);
readonly resetPasswordError = signal<string | null>(null);
readonly successMessage = signal<string | null>(null);
readonly roleDraftByUser = signal<Record<string, UserRole>>({});
readonly createModel = signal<AdminCreateUserRequest>({
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<Record<string, UserRole>>((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.'
}));
}
});
}
}

View File

@ -6,19 +6,13 @@
<div class="signup-card-body">
<form (ngSubmit)="onSubmit()" class="signup-form">
<mat-form-field appearance="outline" class="signup-field">
<mat-label>Full Name</mat-label>
<input matInput id="fullname" type="text" [field]="signupForm.fullname" placeholder="Paolo Rossi" />
@if (isInvalid(signupForm.fullname())) {
<mat-error>{{ signupForm.fullname().errors()[0].message }}</mat-error>
}
</mat-form-field>
<div class="signup-row">
<mat-form-field appearance="outline" class="signup-field">
<mat-label>Email</mat-label>
<input matInput id="email" type="email" [field]="signupForm.email" placeholder="email@example.com" />
@if (isInvalid(signupForm.email())) {
@if (emailError()) {
<mat-error>{{ emailError() }}</mat-error>
} @else if (isInvalid(signupForm.email())) {
<mat-error>{{ signupForm.email().errors()[0].message }}</mat-error>
}
</mat-form-field>
@ -41,7 +35,9 @@
[disabled]="signupModel().password.length === 0">
{{ password.type === 'password' ? 'Show' : 'Hide' }}
</button>
@if (isInvalid(signupForm.password())) {
@if (passwordError()) {
<mat-error>{{ passwordError() }}</mat-error>
} @else if (isInvalid(signupForm.password())) {
<mat-error>{{ signupForm.password().errors()[0].message }}</mat-error>
}
</mat-form-field>
@ -63,7 +59,7 @@
<div class="signup-links">
<span>
Already have an account?
<a routerLink="/sign-in" class="signup-link">Sign In</a>
<a routerLink="/login" class="signup-link">Sign In</a>
</span>
</div>

View File

@ -30,6 +30,8 @@ export class Signup extends FormUtility {
private router = inject(Router);
error = signal<string | null>(null);
emailError = signal<string | null>(null);
passwordError = signal<string | null>(null);
isRegistering = signal(false);
constructor() {
@ -48,8 +50,7 @@ export class Signup extends FormUtility {
username: '',
email: '',
password: '',
confirmPassword: '',
fullname: ''
confirmPassword: ''
});
signupForm = form(this.signupModel, (model) => {
@ -70,7 +71,7 @@ export class Signup extends FormUtility {
message: 'Password must include uppercase, lowercase, number and special character, with no spaces.'
};
}),
required(model.fullname, { message: 'Full name is required'}),
required(model.password, { message: 'Password is required' }),
validate(model.confirmPassword, ({value, valueOf}) => {
const confirmPassword = value()
const password = valueOf(model.password)
@ -87,12 +88,25 @@ export class Signup extends FormUtility {
onSubmit() {
this.isRegistering.set(true)
console.log(this.signupModel());
this.error.set(null);
this.emailError.set(null);
this.passwordError.set(null);
this.authService.signup(this.signupModel()).subscribe({
const { username, email, password } = this.signupModel();
this.authService.signup({ username, email, password }).subscribe({
error: (err) => {
this.isRegistering.set(false);
this.error.set(err.message);
const message = err instanceof Error ? err.message : 'Unable to register user.';
if (message === 'INVALID_EMAIL') {
this.emailError.set('Invalid email address');
return;
}
if (message === 'INVALID_PASSWORD') {
this.passwordError.set('Password does not satisfy the required policy.');
return;
}
this.error.set(message);
},
complete: () => {
this.isRegistering.set(false);

View File

@ -1,4 +1,12 @@
import { ChangePasswordRequest, User, UserRegistration } from "@models/user";
import {
AdminChangeRoleRequest,
AdminCreateUserRequest,
AdminResetPasswordRequest,
AdminUser,
ChangePasswordRequest,
User,
UserRegistration
} from "@models/user";
import { Observable } from "rxjs";
export abstract class AuthorizationCallServiceBase {
@ -9,4 +17,14 @@ export abstract class AuthorizationCallServiceBase {
abstract changePassword(request: ChangePasswordRequest): Observable<void>;
abstract listAdminUsers(): Observable<AdminUser[]>;
abstract createAdminUser(request: AdminCreateUserRequest): Observable<void>;
abstract changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable<void>;
abstract changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable<void>;
abstract deleteAdminUser(username: string): Observable<void>;
}

View File

@ -1,12 +1,22 @@
import { Observable } from "rxjs";
import { AuthorizationCallServiceBase } from "./authorization-call.base";
import { ChangePasswordRequest, User, UserRegistration } from "@models/user";
import {
AdminChangeRoleRequest,
AdminCreateUserRequest,
AdminResetPasswordRequest,
AdminUser,
ChangePasswordRequest,
User,
UserRegistration,
UserRole
} from "@models/user";
export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
private users: UserRegistration[] = [
{ username: 'testuser', email: 'testuser@example.com', password: 'password123', fullname: 'Test User' },
private users: Array<UserRegistration & { role: UserRole }> = [
{ username: 'testuser', email: 'testuser@example.com', password: 'Password123!', role: 'USER' },
{ username: 'adminuser', email: 'admin@example.com', password: 'Adminpass1!', role: 'ADMIN' },
];
login(username: string, password: string): Observable<User> {
@ -20,7 +30,12 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
observer.error(new Error(`Invalid password for ${username}`));
return;
}
observer.next(user);
observer.next({
username: user.username,
email: user.email,
role: user.role,
token: 'fake-jwt-token'
});
observer.complete();
});
}
@ -29,11 +44,15 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
return new Observable<void>((observer) => {
setTimeout(() => {
// Simulate a successful registration response
if (this.users.find(user => user.email === userRegistration.email)) {
observer.error(new Error('Email already exists'));
if (!userRegistration.username || !userRegistration.password || !userRegistration.email) {
observer.error(new Error('Username, password and email are required'));
return;
}
this.users.push(userRegistration);
if (this.users.find(user => user.username === userRegistration.username)) {
observer.error(new Error('the user already exists'));
return;
}
this.users.push({ ...userRegistration, role: 'USER' });
observer.next();
observer.complete();
}, 3000);
@ -56,4 +75,92 @@ export class AuthorizationCallFakeService extends AuthorizationCallServiceBase {
observer.complete();
});
}
listAdminUsers(): Observable<AdminUser[]> {
return new Observable<AdminUser[]>((observer) => {
observer.next(this.users.map((user) => ({
username: user.username,
email: user.email,
role: user.role
})));
observer.complete();
});
}
createAdminUser(request: AdminCreateUserRequest): Observable<void> {
return new Observable<void>((observer) => {
if (!request.username || !request.password || !request.email) {
observer.error(new Error('username, password and email are required'));
return;
}
if (this.users.find((user) => user.username === request.username)) {
observer.error(new Error('the user already exists'));
return;
}
this.users.push({
username: request.username,
password: request.password,
email: request.email,
role: request.role === 'ADMIN' ? 'ADMIN' : 'USER'
});
observer.next();
observer.complete();
});
}
changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable<void> {
return new Observable<void>((observer) => {
const user = this.users.find((candidate) => candidate.username === username);
if (!user) {
observer.error(new Error(`User ${username} not found`));
return;
}
if (!request.newPassword) {
observer.error(new Error('username and newPassword are required'));
return;
}
user.password = request.newPassword;
observer.next();
observer.complete();
});
}
changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable<void> {
return new Observable<void>((observer) => {
const user = this.users.find((candidate) => candidate.username === username);
if (!user) {
observer.error(new Error(`User ${username} not found`));
return;
}
const nextRole: UserRole = request.role === 'ADMIN' ? 'ADMIN' : request.role === 'USER' ? 'USER' : null as never;
if (!nextRole) {
observer.error(new Error('INVALID_ROLE'));
return;
}
if (user.role === 'ADMIN' && nextRole !== 'ADMIN' && this.users.filter((candidate) => candidate.role === 'ADMIN').length === 1) {
observer.error(new Error('LAST_ADMIN'));
return;
}
user.role = nextRole;
observer.next();
observer.complete();
});
}
deleteAdminUser(username: string): Observable<void> {
return new Observable<void>((observer) => {
const userIndex = this.users.findIndex((candidate) => candidate.username === username);
if (userIndex < 0) {
observer.error(new Error(`User ${username} not found`));
return;
}
if (this.users[userIndex].role === 'ADMIN' && this.users.filter((candidate) => candidate.role === 'ADMIN').length === 1) {
observer.error(new Error('LAST_ADMIN'));
return;
}
this.users.splice(userIndex, 1);
observer.next();
observer.complete();
});
}
}

View File

@ -1,5 +1,14 @@
import { AuthorizationCallServiceBase } from "./authorization-call.base";
import { ChangePasswordRequest, User, UserRegistration } from "@models/user";
import {
AdminChangeRoleRequest,
AdminCreateUserRequest,
AdminResetPasswordRequest,
AdminUser,
ChangePasswordRequest,
User,
UserRegistration,
UserRole
} from "@models/user";
import { catchError, map, Observable, throwError } from "rxjs";
import { HttpClient, HttpErrorResponse } from "@angular/common/http";
import { inject } from "@angular/core";
@ -22,7 +31,8 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
return {
username: String(userSource["username"] ?? username),
email: String(userSource["email"] ?? ''),
email: typeof userSource["email"] === "string" ? String(userSource["email"]) : null,
role: this.normalizeRole(userSource["role"]),
token: typeof token === "string" && token.length > 0 ? token : undefined
} satisfies User;
}),
@ -38,7 +48,12 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
);
}
override register(userRegistration: UserRegistration): Observable<void> {
return this.http.post<void>(`${environment.apiUrl}/auth/register`, userRegistration);
return this.http.post<void>(`${environment.apiUrl}/auth/register`, userRegistration)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
400: 'Unable to register user.'
}))
);
}
override changePassword(request: ChangePasswordRequest): Observable<void> {
@ -60,6 +75,65 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
);
}
override listAdminUsers(): Observable<AdminUser[]> {
return this.http
.get<unknown[]>(`${environment.apiUrl}/auth/admin/users`)
.pipe(
map((raw) => Array.isArray(raw) ? raw.map((item) => this.adminUserFromApi(item)) : []),
catchError((error: unknown) => this.toHttpError(error, {
403: 'Admin access required.'
}))
);
}
override createAdminUser(request: AdminCreateUserRequest): Observable<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/admin/users`, request)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
400: 'Unable to create user.',
403: 'Admin access required.'
}))
);
}
override changeAdminUserPassword(username: string, request: AdminResetPasswordRequest): Observable<void> {
return this.http
.put<void>(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/password`, request)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
400: 'Unable to update password.',
403: 'Admin access required.',
404: `User ${username} not found`
}))
);
}
override changeAdminUserRole(username: string, request: AdminChangeRoleRequest): Observable<void> {
return this.http
.put<void>(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}/role`, request)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
400: 'Unable to update role.',
403: 'Admin access required.',
404: `User ${username} not found`,
409: 'LAST_ADMIN'
}))
);
}
override deleteAdminUser(username: string): Observable<void> {
return this.http
.delete<void>(`${environment.apiUrl}/auth/admin/users/${encodeURIComponent(username)}`)
.pipe(
catchError((error: unknown) => this.toHttpError(error, {
403: 'Admin access required.',
404: `User ${username} not found`,
409: 'LAST_ADMIN'
}))
);
}
private extractHttpErrorMessage(error: HttpErrorResponse): string | null {
const payload = error.error;
if (typeof payload === 'string' && payload.trim().length > 0) {
@ -96,4 +170,27 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
}
return undefined;
}
private normalizeRole(value: unknown): UserRole {
return String(value ?? '').toUpperCase() === 'ADMIN' ? 'ADMIN' : 'USER';
}
private adminUserFromApi(raw: unknown): AdminUser {
const value = (raw ?? {}) as Record<string, unknown>;
return {
username: String(value['username'] ?? ''),
email: typeof value['email'] === 'string' ? value['email'] : null,
role: this.normalizeRole(value['role'])
};
}
private toHttpError(error: unknown, fallbackByStatus: Record<number, string>): Observable<never> {
if (error instanceof HttpErrorResponse) {
const message = this.extractHttpErrorMessage(error)
?? fallbackByStatus[error.status]
?? 'Request failed.';
return throwError(() => new Error(message));
}
return throwError(() => error);
}
}

View File

@ -1,5 +1,12 @@
import { Injectable, signal } from '@angular/core';
import { ChangePasswordRequest, User, UserRegistration } from '@models/user';
import {
AdminChangeRoleRequest,
AdminCreateUserRequest,
AdminResetPasswordRequest,
ChangePasswordRequest,
User,
UserRegistration
} from '@models/user';
import { AuthorizationCallServiceBase } from './authorization-call.base';
import { environment } from '@environment';
import { take, tap } from 'rxjs';
@ -21,8 +28,12 @@ export class Authorization {
return this.authCall.login(username, password).pipe(
take(1),
tap(res => {
this.user.set(res);
localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(res));
const normalizedUser = this.normalizeUser(res);
if (normalizedUser) {
console.log('[auth] logged user role:', normalizedUser.role);
}
this.user.set(normalizedUser);
localStorage.setItem(Authorization.USER_STORAGE_KEY, JSON.stringify(normalizedUser));
if (res.token) {
localStorage.setItem(Authorization.TOKEN_STORAGE_KEY, res.token);
} else {
@ -44,6 +55,36 @@ export class Authorization {
);
}
listAdminUsers() {
return this.authCall.listAdminUsers().pipe(
take(1)
);
}
createAdminUser(request: AdminCreateUserRequest) {
return this.authCall.createAdminUser(request).pipe(
take(1)
);
}
changeAdminUserPassword(username: string, request: AdminResetPasswordRequest) {
return this.authCall.changeAdminUserPassword(username, request).pipe(
take(1)
);
}
changeAdminUserRole(username: string, request: AdminChangeRoleRequest) {
return this.authCall.changeAdminUserRole(username, request).pipe(
take(1)
);
}
deleteAdminUser(username: string) {
return this.authCall.deleteAdminUser(username).pipe(
take(1)
);
}
logout() {
localStorage.removeItem(Authorization.USER_STORAGE_KEY);
localStorage.removeItem(Authorization.TOKEN_STORAGE_KEY);
@ -56,8 +97,27 @@ export class Authorization {
isLoggedIn(): boolean {
const storedUser = localStorage.getItem(Authorization.USER_STORAGE_KEY);
this.user.set(storedUser != null ? JSON.parse(storedUser) : null);
const normalizedUser = storedUser != null ? this.normalizeUser(JSON.parse(storedUser) as User) : null;
if (normalizedUser) {
console.log('[auth] restored user role:', normalizedUser.role);
}
this.user.set(normalizedUser);
return storedUser != null;
}
isAdmin(): boolean {
const currentUser = this.loggedInUser() ?? (this.isLoggedIn() ? this.loggedInUser() : null);
return currentUser?.role === 'ADMIN';
}
private normalizeUser(user: User | null): User | null {
if (!user) return null;
return {
username: String(user.username ?? ''),
email: typeof user.email === 'string' ? user.email : null,
role: user.role === 'ADMIN' ? 'ADMIN' : 'USER',
token: user.token
};
}
}

View File

@ -0,0 +1,94 @@
.admin-reset-password-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.5);
}
.admin-reset-password-modal {
width: min(520px, calc(100vw - 32px));
max-height: min(80vh, 720px);
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28);
padding: 24px;
}
.admin-reset-password-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.admin-reset-password-title {
margin: 0;
color: #0f172a;
font-size: 20px;
font-weight: 700;
}
.admin-reset-password-subtitle {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
}
.admin-reset-password-form {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 20px;
}
.admin-reset-password-field {
width: 100%;
}
.admin-reset-password-checklist {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: -6px;
padding: 0 2px;
}
.admin-reset-password-check {
display: flex;
align-items: center;
gap: 8px;
color: #b91c1c;
font-size: 12px;
}
.admin-reset-password-check--ok {
color: #15803d;
}
.admin-reset-password-check-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.admin-reset-password-error {
border: 1px solid #fecaca;
border-radius: 12px;
background: #fff1f2;
color: #b91c1c;
font-size: 13px;
padding: 10px 12px;
}
.admin-reset-password-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}

View File

@ -0,0 +1,62 @@
<div class="admin-reset-password-backdrop" (click)="close($event)">
<div class="admin-reset-password-modal" (click)="$event.stopPropagation()">
<div class="admin-reset-password-header">
<div>
<h3 class="admin-reset-password-title">Reset Password</h3>
<p class="admin-reset-password-subtitle">{{ username() }}</p>
</div>
<button type="button" mat-stroked-button (click)="close($event)">Close</button>
</div>
<form (ngSubmit)="submit($event)" class="admin-reset-password-form">
<mat-form-field appearance="outline" class="admin-reset-password-field">
<mat-label>New password</mat-label>
<input
matInput
[type]="hidePassword() ? 'password' : 'text'"
[field]="passwordForm.newPassword">
<button type="button" mat-icon-button matSuffix (click)="hidePassword.set(!hidePassword())">
<mat-icon [fontIcon]="hidePassword() ? 'visibility' : 'visibility_off'"></mat-icon>
</button>
</mat-form-field>
<div class="admin-reset-password-checklist">
@for (check of newPasswordChecks(); track check.label) {
<div class="admin-reset-password-check" [class.admin-reset-password-check--ok]="check.satisfied">
<mat-icon class="admin-reset-password-check-icon" [fontIcon]="check.satisfied ? 'check_circle' : 'radio_button_unchecked'"></mat-icon>
<span>{{ check.label }}</span>
</div>
}
</div>
<mat-form-field appearance="outline" class="admin-reset-password-field">
<mat-label>Confirm new password</mat-label>
<input
matInput
[type]="hideConfirmPassword() ? 'password' : 'text'"
[field]="passwordForm.confirmNewPassword">
<button type="button" mat-icon-button matSuffix (click)="hideConfirmPassword.set(!hideConfirmPassword())">
<mat-icon [fontIcon]="hideConfirmPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
</button>
@if (isInvalid(passwordForm.confirmNewPassword())) {
<mat-error>{{ passwordForm.confirmNewPassword().errors()[0].message }}</mat-error>
}
</mat-form-field>
@if (submitError()) {
<div class="admin-reset-password-error" role="alert">{{ submitError() }}</div>
}
<div class="admin-reset-password-actions">
<button type="button" mat-stroked-button (click)="close($event)" [disabled]="saving()">Cancel</button>
<button type="submit" mat-flat-button [disabled]="!canSubmit()">
@if (saving()) {
Saving...
} @else {
Save
}
</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,94 @@
import { Component, computed, input, output, 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 { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
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-reset-password-dialog',
imports: [FormsModule, Field, MatButtonModule, MatFormFieldModule, MatIconModule, MatInputModule],
templateUrl: './admin-reset-password-dialog.html',
styleUrl: './admin-reset-password-dialog.css'
})
export class AdminResetPasswordDialogComponent extends FormUtility {
readonly username = input.required<string>();
readonly saving = input(false);
readonly submitError = input<string | null>(null);
readonly closed = output<void>();
readonly submitted = output<{ username: string; newPassword: string }>();
readonly hidePassword = signal(true);
readonly hideConfirmPassword = signal(true);
readonly model = signal({
newPassword: '',
confirmNewPassword: ''
});
readonly passwordForm = form(this.model, (model) => {
required(model.newPassword, { message: 'New password is required' });
minLength(model.newPassword, 8, { message: 'Password must be at least 8 characters long' });
validate(model.newPassword, ({ 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.'
};
});
required(model.confirmNewPassword, { message: 'Confirm your new password' });
validate(model.confirmNewPassword, ({ value, valueOf }) => {
if (value() !== valueOf(model.newPassword)) {
return {
kind: 'passwordMismatch',
message: 'Passwords do not match'
};
}
return null;
});
});
readonly canSubmit = computed(() => !this.passwordForm().invalid() && !this.saving());
readonly newPasswordChecks = computed(() => {
const password = this.model().newPassword;
return [
{ 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) }
];
});
close(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.saving()) return;
this.closed.emit();
}
submit(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.canSubmit()) return;
this.submitted.emit({
username: this.username(),
newPassword: this.model().newPassword
});
}
}