administration page added
This commit is contained in:
parent
167e301a21
commit
a34b72eb2b
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
}
|
||||
|
|
@ -56,6 +56,10 @@
|
|||
padding-top: 112px;
|
||||
}
|
||||
|
||||
.app-shell--admin {
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.app-main-content {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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.'
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue