diff --git a/src/app/layouts/app-layout/app-layout.ts b/src/app/layouts/app-layout/app-layout.ts index c15dab2..f096a77 100644 --- a/src/app/layouts/app-layout/app-layout.ts +++ b/src/app/layouts/app-layout/app-layout.ts @@ -42,6 +42,14 @@ export class AppLayout { }); } + isAdmin() { + return this.loggedUser()?.role === 'ADMIN'; + } + + isAdminRoute() { + return this.router.url.startsWith('/admin'); + } + logout() { this.authService.logout(); this.router.navigate(['/login']); diff --git a/src/app/models/user.ts b/src/app/models/user.ts index 91d4197..1085f10 100644 --- a/src/app/models/user.ts +++ b/src/app/models/user.ts @@ -1,12 +1,16 @@ +export type UserRole = 'USER' | 'ADMIN'; + export type User = { username: string; - email: string; + email: string | null; + role: UserRole; token?: string; }; -export type UserRegistration = User & { +export type UserRegistration = { + username: string; password: string; - fullname: string; + email: string; }; export type ChangePasswordRequest = { @@ -14,3 +18,24 @@ export type ChangePasswordRequest = { oldPassword: string; newPassword: string; }; + +export type AdminUser = { + username: string; + email: string | null; + role: UserRole; +}; + +export type AdminCreateUserRequest = { + username: string; + password: string; + email: string; + role?: UserRole | null; +}; + +export type AdminResetPasswordRequest = { + newPassword: string; +}; + +export type AdminChangeRoleRequest = { + role: UserRole; +}; diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.css b/src/app/pages/admin/admin-create-user/admin-create-user.css new file mode 100644 index 0000000..825a871 --- /dev/null +++ b/src/app/pages/admin/admin-create-user/admin-create-user.css @@ -0,0 +1,108 @@ +.admin-create-user-page { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; + padding: 8px 4px 16px; + background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%); +} + +.admin-create-user-page__eyebrow { + color: #2563eb; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.admin-create-user-page__title { + margin: 4px 0 0; + color: #0f172a; + font-size: 28px; + font-weight: 800; +} + +.admin-create-user-page__subtitle { + margin: 6px 0 0; + color: #64748b; + font-size: 14px; +} + +.admin-create-user-page__success, +.admin-create-user-card__error { + border-radius: 14px; + padding: 12px 14px; + font-size: 13px; + font-weight: 600; +} + +.admin-create-user-page__success { + border: 1px solid #86efac; + background: #f0fdf4; + color: #166534; +} + +.admin-create-user-card { + border-radius: 22px; + padding: 20px; + max-width: 980px; +} + +.admin-create-user-card__title { + margin-bottom: 14px; + color: #0f172a; + font-size: 18px; + font-weight: 700; +} + +.admin-create-user-card__error { + border: 1px solid #fecaca; + background: #fff1f2; + color: #b91c1c; +} + +.admin-create-user-form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.admin-create-user-form__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.admin-create-user-form__password { + width: 100%; +} + +.admin-create-user-checklist { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; +} + +.admin-create-user-check { + display: flex; + align-items: center; + gap: 8px; + color: #b91c1c; + font-size: 12px; +} + +.admin-create-user-check--ok { + color: #15803d; +} + +.admin-create-user-form__actions { + display: flex; + justify-content: flex-end; +} + +@media (max-width: 1100px) { + .admin-create-user-form__grid, + .admin-create-user-checklist { + grid-template-columns: 1fr; + } +} diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.html b/src/app/pages/admin/admin-create-user/admin-create-user.html new file mode 100644 index 0000000..92542da --- /dev/null +++ b/src/app/pages/admin/admin-create-user/admin-create-user.html @@ -0,0 +1,80 @@ +
+
+
+
Admin
+

Create User

+

Provision a new account with email, role and password policy enforcement.

+
+
+ + @if (successMessage()) { +
{{ successMessage() }}
+ } + + +
New user
+ +
+
+ + Username + + @if (isInvalid(createForm.username())) { + {{ createForm.username().errors()[0].message }} + } + + + + Email + + @if (createEmailError()) { + {{ createEmailError() }} + } @else if (isInvalid(createForm.email())) { + {{ createForm.email().errors()[0].message }} + } + + + + Role + + USER + ADMIN + + +
+ + + Password + + @if (createPasswordError()) { + {{ createPasswordError() }} + } @else if (isInvalid(createForm.password())) { + {{ createForm.password().errors()[0].message }} + } + + +
+ @for (check of createPasswordChecks(); track check.label) { +
+ + {{ check.label }} +
+ } +
+ + @if (createError()) { +
{{ createError() }}
+ } + +
+ +
+
+
+
diff --git a/src/app/pages/admin/admin-create-user/admin-create-user.ts b/src/app/pages/admin/admin-create-user/admin-create-user.ts new file mode 100644 index 0000000..5ac420d --- /dev/null +++ b/src/app/pages/admin/admin-create-user/admin-create-user.ts @@ -0,0 +1,152 @@ +import { CommonModule } from '@angular/common'; +import { Component, effect, inject, signal } from '@angular/core'; +import { Field, form, minLength, required, validate } from '@angular/forms/signals'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { AdminCreateUserRequest, UserRole } from '@models/user'; +import { Router } from '@angular/router'; +import { Authorization } from '@services/authorization/authorization'; +import { FormUtility } from '@utilities/form-utility'; + +function hasValidPasswordComplexity(value: string): boolean { + return /^\S+$/.test(value) + && /[a-z]/.test(value) + && /[A-Z]/.test(value) + && /\d/.test(value) + && /[^A-Za-z0-9]/.test(value); +} + +@Component({ + selector: 'app-admin-create-user-page', + imports: [ + CommonModule, + FormsModule, + Field, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule + ], + templateUrl: './admin-create-user.html', + styleUrl: './admin-create-user.css' +}) +export class AdminCreateUserPage extends FormUtility { + private authorization = inject(Authorization); + private router = inject(Router); + + readonly createError = signal(null); + readonly createEmailError = signal(null); + readonly createPasswordError = signal(null); + readonly createSaving = signal(false); + readonly successMessage = signal(null); + + readonly createModel = signal({ + username: '', + email: '', + password: '', + role: 'USER' + }); + + readonly createForm = form(this.createModel, (model) => { + required(model.username, { message: 'Username is required' }); + minLength(model.username, 3, { message: 'Username must be at least 3 characters' }); + required(model.email, { message: 'Email is required' }); + validate(model.email, ({ value }) => { + const email = value(); + if (!email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null; + return { kind: 'invalidEmail', message: 'Invalid email address' }; + }); + required(model.password, { message: 'Password is required' }); + minLength(model.password, 8, { message: 'Password must be at least 8 characters long' }); + validate(model.password, ({ value }) => { + const password = value(); + if (!password || hasValidPasswordComplexity(password)) return null; + return { + kind: 'passwordComplexity', + message: 'Password must include uppercase, lowercase, number and special character, with no spaces.' + }; + }); + }); + + readonly createPasswordChecks = signal([ + { label: 'At least 8 characters', satisfied: false }, + { label: 'At least one lowercase letter', satisfied: false }, + { label: 'At least one uppercase letter', satisfied: false }, + { label: 'At least one number', satisfied: false }, + { label: 'At least one special character', satisfied: false }, + { label: 'No spaces', satisfied: true } + ]); + + constructor() { + super(); + + effect(() => { + const password = this.createModel().password; + this.createPasswordChecks.set([ + { label: 'At least 8 characters', satisfied: password.length >= 8 }, + { label: 'At least one lowercase letter', satisfied: /[a-z]/.test(password) }, + { label: 'At least one uppercase letter', satisfied: /[A-Z]/.test(password) }, + { label: 'At least one number', satisfied: /\d/.test(password) }, + { label: 'At least one special character', satisfied: /[^A-Za-z0-9]/.test(password) }, + { label: 'No spaces', satisfied: /^\S*$/.test(password) } + ]); + }); + } + + setCreateRole(role: UserRole) { + this.createModel.update((current) => ({ ...current, role })); + } + + onCreateUser() { + if (this.createForm().invalid()) return; + + this.createSaving.set(true); + this.createError.set(null); + this.createEmailError.set(null); + this.createPasswordError.set(null); + + this.authorization.createAdminUser(this.createModel()).subscribe({ + next: () => { + this.createSaving.set(false); + this.successMessage.set('User created successfully.'); + this.createModel.set({ + username: '', + email: '', + password: '', + role: 'USER' + }); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + if (this.redirectOnAdminAccessDenied(error)) return; + this.createSaving.set(false); + const message = error instanceof Error ? error.message : 'Unable to create user.'; + if (message === 'INVALID_EMAIL') { + this.createEmailError.set('Invalid email address'); + return; + } + if (message === 'INVALID_PASSWORD') { + this.createPasswordError.set('Password does not satisfy the required policy.'); + return; + } + this.createError.set(message); + } + }); + } + + private redirectOnAdminAccessDenied(error: unknown): boolean { + const message = error instanceof Error ? error.message : ''; + if (message !== 'Admin access required.') return false; + + this.createSaving.set(false); + this.router.navigateByUrl('/editor'); + return true; + } +} diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.css b/src/app/pages/admin/admin-users-list/admin-users-list.css new file mode 100644 index 0000000..2139598 --- /dev/null +++ b/src/app/pages/admin/admin-users-list/admin-users-list.css @@ -0,0 +1,142 @@ +.admin-users-list-page { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 100%; + padding: 8px 4px 16px; + background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%); +} + +.admin-users-list-page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.admin-users-list-page__eyebrow { + color: #2563eb; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.admin-users-list-page__title { + margin: 4px 0 0; + color: #0f172a; + font-size: 28px; + font-weight: 800; +} + +.admin-users-list-page__subtitle { + margin: 6px 0 0; + color: #64748b; + font-size: 14px; +} + +.admin-users-list-page__success, +.admin-users-list-card__error, +.admin-users-list-row__error { + border-radius: 14px; + padding: 12px 14px; + font-size: 13px; + font-weight: 600; +} + +.admin-users-list-page__success { + border: 1px solid #86efac; + background: #f0fdf4; + color: #166534; +} + +.admin-users-list-card { + border-radius: 22px; + padding: 20px; +} + +.admin-users-list-card__title { + margin-bottom: 14px; + color: #0f172a; + font-size: 18px; + font-weight: 700; +} + +.admin-users-list-card__error, +.admin-users-list-row__error { + border: 1px solid #fecaca; + background: #fff1f2; + color: #b91c1c; +} + +.admin-users-list-state { + color: #64748b; + font-size: 14px; + padding: 12px 4px; +} + +.admin-users-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.admin-users-list-row { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) auto; + gap: 16px; + align-items: center; + border: 1px solid #e2e8f0; + border-radius: 18px; + background: #f8fbff; + padding: 16px; +} + +.admin-users-list-row__identity { + min-width: 0; +} + +.admin-users-list-row__username { + color: #0f172a; + font-size: 15px; + font-weight: 700; +} + +.admin-users-list-row__email { + color: #64748b; + font-size: 13px; + margin-top: 4px; + word-break: break-word; +} + +.admin-users-list-row__role { + display: flex; + align-items: center; + gap: 10px; +} + +.admin-users-list-row__role-field { + min-width: 150px; +} + +.admin-users-list-row__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.admin-users-list-row__error { + grid-column: 1 / -1; +} + +@media (max-width: 1100px) { + .admin-users-list-row { + grid-template-columns: 1fr; + } + + .admin-users-list-row__actions { + justify-content: flex-start; + } +} diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.html b/src/app/pages/admin/admin-users-list/admin-users-list.html new file mode 100644 index 0000000..2b881ce --- /dev/null +++ b/src/app/pages/admin/admin-users-list/admin-users-list.html @@ -0,0 +1,74 @@ +
+
+
+
Admin
+

Users

+

Review existing users, change roles, reset passwords and remove accounts.

+
+ +
+ + @if (successMessage()) { +
{{ successMessage() }}
+ } + + +
Users
+ + @if (loading()) { +
Loading users...
+ } @else if (pageError()) { +
{{ pageError() }}
+ } @else if (!users().length) { +
No users found.
+ } @else { +
+ @for (user of users(); track user.username) { +
+
+
{{ user.username }}
+ +
+ +
+ + Role + + USER + ADMIN + + + +
+ +
+ + +
+ + @if (roleErrorByUser()[user.username]) { +
{{ roleErrorByUser()[user.username] }}
+ } +
+ } +
+ } +
+ + @if (resetPasswordDialogUser(); as user) { + + + } +
diff --git a/src/app/pages/admin/admin-users-list/admin-users-list.ts b/src/app/pages/admin/admin-users-list/admin-users-list.ts new file mode 100644 index 0000000..0ae8c41 --- /dev/null +++ b/src/app/pages/admin/admin-users-list/admin-users-list.ts @@ -0,0 +1,167 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSelectModule } from '@angular/material/select'; +import { AdminUser, UserRole } from '@models/user'; +import { Router } from '@angular/router'; +import { Authorization } from '@services/authorization/authorization'; +import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; +import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog'; + +@Component({ + selector: 'app-admin-users-list-page', + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + MatSelectModule, + AdminResetPasswordDialogComponent + ], + templateUrl: './admin-users-list.html', + styleUrl: './admin-users-list.css' +}) +export class AdminUsersListPage { + private authorization = inject(Authorization); + private confirmDialog = inject(ConfirmDialogService); + private router = inject(Router); + + readonly users = signal([]); + readonly loading = signal(true); + readonly pageError = signal(null); + readonly roleSavingByUser = signal>({}); + readonly roleErrorByUser = signal>({}); + readonly deleteBusyByUser = signal>({}); + readonly roleDraftByUser = signal>({}); + readonly resetPasswordDialogUser = signal(null); + readonly resetPasswordSaving = signal(false); + readonly resetPasswordError = signal(null); + readonly successMessage = signal(null); + + ngOnInit() { + this.loadUsers(); + } + + loadUsers() { + this.loading.set(true); + this.pageError.set(null); + this.authorization.listAdminUsers().subscribe({ + next: (users) => { + this.users.set(users); + this.roleDraftByUser.set( + users.reduce>((acc, user) => { + acc[user.username] = user.role; + return acc; + }, {}) + ); + this.loading.set(false); + }, + error: (error) => { + if (this.redirectOnAdminAccessDenied(error)) return; + this.pageError.set(error instanceof Error ? error.message : 'Unable to load users.'); + this.loading.set(false); + } + }); + } + + roleDraft(username: string): UserRole { + return this.roleDraftByUser()[username] ?? 'USER'; + } + + setRoleDraft(username: string, role: UserRole) { + this.roleDraftByUser.update((current) => ({ ...current, [username]: role })); + this.roleErrorByUser.update((current) => ({ ...current, [username]: null })); + } + + saveRole(user: AdminUser) { + const nextRole = this.roleDraft(user.username); + if (nextRole === user.role) return; + + this.roleSavingByUser.update((current) => ({ ...current, [user.username]: true })); + this.roleErrorByUser.update((current) => ({ ...current, [user.username]: null })); + this.authorization.changeAdminUserRole(user.username, { role: nextRole }).subscribe({ + next: () => { + this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false })); + this.successMessage.set(`Role updated for ${user.username}.`); + this.loadUsers(); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + if (this.redirectOnAdminAccessDenied(error)) return; + this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false })); + this.roleErrorByUser.update((current) => ({ + ...current, + [user.username]: error instanceof Error ? error.message : 'Unable to update role.' + })); + } + }); + } + + openResetPasswordDialog(user: AdminUser) { + this.resetPasswordError.set(null); + this.resetPasswordDialogUser.set(user); + } + + closeResetPasswordDialog() { + if (this.resetPasswordSaving()) return; + this.resetPasswordDialogUser.set(null); + this.resetPasswordError.set(null); + } + + submitResetPassword(event: { username: string; newPassword: string }) { + this.resetPasswordSaving.set(true); + this.resetPasswordError.set(null); + this.authorization.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({ + next: () => { + this.resetPasswordSaving.set(false); + this.resetPasswordDialogUser.set(null); + this.successMessage.set(`Password updated for ${event.username}.`); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + if (this.redirectOnAdminAccessDenied(error)) return; + this.resetPasswordSaving.set(false); + this.resetPasswordError.set(error instanceof Error ? error.message : 'Unable to update password.'); + } + }); + } + + async deleteUser(user: AdminUser) { + const confirmed = await this.confirmDialog.open(`Delete user ${user.username}?`); + if (!confirmed) return; + + this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: true })); + this.authorization.deleteAdminUser(user.username).subscribe({ + next: () => { + this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false })); + this.successMessage.set(`User ${user.username} deleted.`); + this.loadUsers(); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + if (this.redirectOnAdminAccessDenied(error)) return; + this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false })); + this.roleErrorByUser.update((current) => ({ + ...current, + [user.username]: error instanceof Error ? error.message : 'Unable to delete user.' + })); + } + }); + } + + private redirectOnAdminAccessDenied(error: unknown): boolean { + const message = error instanceof Error ? error.message : ''; + if (message !== 'Admin access required.') return false; + + this.loading.set(false); + this.resetPasswordSaving.set(false); + this.router.navigateByUrl('/editor'); + return true; + } +} diff --git a/src/app/pages/admin/admin-users/admin-users.css b/src/app/pages/admin/admin-users/admin-users.css new file mode 100644 index 0000000..01c0a0f --- /dev/null +++ b/src/app/pages/admin/admin-users/admin-users.css @@ -0,0 +1,187 @@ +.admin-users-page { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + padding: 8px 4px 16px; + overflow: auto; + background: linear-gradient(180deg, #f8fafc 0%, #eef6ff 100%); +} + +.admin-users-page__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.admin-users-page__eyebrow { + color: #2563eb; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.admin-users-page__title { + margin: 4px 0 0; + color: #0f172a; + font-size: 28px; + font-weight: 800; +} + +.admin-users-page__subtitle { + margin: 6px 0 0; + color: #64748b; + font-size: 14px; +} + +.admin-users-page__success, +.admin-users-card__error, +.admin-users-row__error { + border-radius: 14px; + padding: 12px 14px; + font-size: 13px; + font-weight: 600; +} + +.admin-users-page__success { + border: 1px solid #86efac; + background: #f0fdf4; + color: #166534; +} + +.admin-users-card { + border-radius: 22px; + padding: 20px; +} + +.admin-users-card__title { + margin-bottom: 14px; + color: #0f172a; + font-size: 18px; + font-weight: 700; +} + +.admin-users-card__error, +.admin-users-row__error { + border: 1px solid #fecaca; + background: #fff1f2; + color: #b91c1c; +} + +.admin-users-create { + display: flex; + flex-direction: column; + gap: 14px; +} + +.admin-users-create__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.admin-users-create__password { + width: 100%; +} + +.admin-users-checklist { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; +} + +.admin-users-check { + display: flex; + align-items: center; + gap: 8px; + color: #b91c1c; + font-size: 12px; +} + +.admin-users-check--ok { + color: #15803d; +} + +.admin-users-create__actions { + display: flex; + justify-content: flex-end; +} + +.admin-users-state { + color: #64748b; + font-size: 14px; + padding: 12px 4px; +} + +.admin-users-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.admin-users-row { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(220px, 1fr) auto; + gap: 16px; + align-items: center; + border: 1px solid #e2e8f0; + border-radius: 18px; + background: #f8fbff; + padding: 16px; +} + +.admin-users-row__identity { + min-width: 0; +} + +.admin-users-row__username { + color: #0f172a; + font-size: 15px; + font-weight: 700; +} + +.admin-users-row__email { + color: #64748b; + font-size: 13px; + margin-top: 4px; + word-break: break-word; +} + +.admin-users-row__role { + display: flex; + align-items: center; + gap: 10px; +} + +.admin-users-row__role-field { + min-width: 150px; +} + +.admin-users-row__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.admin-users-row__error { + grid-column: 1 / -1; +} + +@media (max-width: 1100px) { + .admin-users-create__grid, + .admin-users-row { + grid-template-columns: 1fr; + } + + .admin-users-checklist { + grid-template-columns: 1fr; + } + + .admin-users-row__actions { + justify-content: flex-start; + } +} diff --git a/src/app/pages/admin/admin-users/admin-users.html b/src/app/pages/admin/admin-users/admin-users.html new file mode 100644 index 0000000..9322098 --- /dev/null +++ b/src/app/pages/admin/admin-users/admin-users.html @@ -0,0 +1,140 @@ +
+
+
+
Admin
+

User Management

+

Create users, change roles, reset passwords and remove accounts.

+
+ +
+ + @if (successMessage()) { +
{{ successMessage() }}
+ } + + +
Create User
+
+
+ + Username + + @if (isInvalid(createForm.username())) { + {{ createForm.username().errors()[0].message }} + } + + + + Email + + @if (createEmailError()) { + {{ createEmailError() }} + } @else if (isInvalid(createForm.email())) { + {{ createForm.email().errors()[0].message }} + } + + + + Role + + USER + ADMIN + + +
+ + + Password + + @if (createPasswordError()) { + {{ createPasswordError() }} + } @else if (isInvalid(createForm.password())) { + {{ createForm.password().errors()[0].message }} + } + + +
+ @for (check of createPasswordChecks(); track check.label) { +
+ + {{ check.label }} +
+ } +
+ + @if (createError()) { +
{{ createError() }}
+ } + +
+ +
+
+
+ + +
Users
+ + @if (loading()) { +
Loading users...
+ } @else if (pageError()) { +
{{ pageError() }}
+ } @else if (!users().length) { +
No users found.
+ } @else { +
+ @for (user of users(); track user.username) { +
+
+
{{ user.username }}
+ +
+ +
+ + Role + + USER + ADMIN + + + +
+ +
+ + +
+ + @if (roleErrorByUser()[user.username]) { +
{{ roleErrorByUser()[user.username] }}
+ } +
+ } +
+ } +
+ + @if (resetPasswordDialogUser(); as user) { + + + } +
diff --git a/src/app/pages/admin/admin-users/admin-users.ts b/src/app/pages/admin/admin-users/admin-users.ts new file mode 100644 index 0000000..b67e223 --- /dev/null +++ b/src/app/pages/admin/admin-users/admin-users.ts @@ -0,0 +1,278 @@ +import { CommonModule } from '@angular/common'; +import { Component, effect, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Field, form, minLength, required, validate } from '@angular/forms/signals'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { + AdminCreateUserRequest, + AdminUser, + UserRole +} from '@models/user'; +import { Authorization } from '@services/authorization/authorization'; +import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; +import { AdminResetPasswordDialogComponent } from '@shared/admin-reset-password-dialog/admin-reset-password-dialog'; +import { FormUtility } from '@utilities/form-utility'; + +function hasValidPasswordComplexity(value: string): boolean { + return /^\S+$/.test(value) + && /[a-z]/.test(value) + && /[A-Z]/.test(value) + && /\d/.test(value) + && /[^A-Za-z0-9]/.test(value); +} + +@Component({ + selector: 'app-admin-users', + imports: [ + CommonModule, + FormsModule, + Field, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSelectModule, + AdminResetPasswordDialogComponent + ], + templateUrl: './admin-users.html', + styleUrl: './admin-users.css' +}) +export class AdminUsersPage extends FormUtility { + private authorization = inject(Authorization); + private confirmDialog = inject(ConfirmDialogService); + + readonly users = signal([]); + readonly loading = signal(true); + readonly pageError = signal(null); + readonly createError = signal(null); + readonly createEmailError = signal(null); + readonly createPasswordError = signal(null); + readonly createSaving = signal(false); + readonly roleSavingByUser = signal>({}); + readonly roleErrorByUser = signal>({}); + readonly deleteBusyByUser = signal>({}); + readonly resetPasswordDialogUser = signal(null); + readonly resetPasswordSaving = signal(false); + readonly resetPasswordError = signal(null); + readonly successMessage = signal(null); + readonly roleDraftByUser = signal>({}); + + readonly createModel = signal({ + username: '', + email: '', + password: '', + role: 'USER' + }); + + readonly createForm = form(this.createModel, (model) => { + required(model.username, { message: 'Username is required' }); + minLength(model.username, 3, { message: 'Username must be at least 3 characters' }); + required(model.email, { message: 'Email is required' }); + validate(model.email, ({ value }) => { + const email = value(); + if (!email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null; + return { + kind: 'invalidEmail', + message: 'Invalid email address' + }; + }); + required(model.password, { message: 'Password is required' }); + minLength(model.password, 8, { message: 'Password must be at least 8 characters long' }); + validate(model.password, ({ value }) => { + const password = value(); + if (!password || hasValidPasswordComplexity(password)) return null; + return { + kind: 'passwordComplexity', + message: 'Password must include uppercase, lowercase, number and special character, with no spaces.' + }; + }); + }); + + readonly createPasswordChecks = signal([ + { label: 'At least 8 characters', satisfied: false }, + { label: 'At least one lowercase letter', satisfied: false }, + { label: 'At least one uppercase letter', satisfied: false }, + { label: 'At least one number', satisfied: false }, + { label: 'At least one special character', satisfied: false }, + { label: 'No spaces', satisfied: true } + ]); + + constructor() { + super(); + + effect(() => { + const password = this.createModel().password; + this.createPasswordChecks.set([ + { label: 'At least 8 characters', satisfied: password.length >= 8 }, + { label: 'At least one lowercase letter', satisfied: /[a-z]/.test(password) }, + { label: 'At least one uppercase letter', satisfied: /[A-Z]/.test(password) }, + { label: 'At least one number', satisfied: /\d/.test(password) }, + { label: 'At least one special character', satisfied: /[^A-Za-z0-9]/.test(password) }, + { label: 'No spaces', satisfied: /^\S*$/.test(password) } + ]); + }); + } + + ngOnInit() { + this.loadUsers(); + } + + loadUsers() { + this.loading.set(true); + this.pageError.set(null); + this.authorization.listAdminUsers().subscribe({ + next: (users) => { + this.users.set(users); + this.roleDraftByUser.set( + users.reduce>((acc, user) => { + acc[user.username] = user.role; + return acc; + }, {}) + ); + this.loading.set(false); + }, + error: (error) => { + this.pageError.set(error instanceof Error ? error.message : 'Unable to load users.'); + this.loading.set(false); + } + }); + } + + onCreateUser() { + if (this.createForm().invalid()) return; + + this.createSaving.set(true); + this.createError.set(null); + this.createEmailError.set(null); + this.createPasswordError.set(null); + + this.authorization.createAdminUser(this.createModel()).subscribe({ + next: () => { + this.createSaving.set(false); + this.successMessage.set('User created successfully.'); + this.createModel.set({ + username: '', + email: '', + password: '', + role: 'USER' + }); + this.loadUsers(); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + this.createSaving.set(false); + const message = error instanceof Error ? error.message : 'Unable to create user.'; + if (message === 'INVALID_EMAIL') { + this.createEmailError.set('Invalid email address'); + return; + } + if (message === 'INVALID_PASSWORD') { + this.createPasswordError.set('Password does not satisfy the required policy.'); + return; + } + this.createError.set(message); + } + }); + } + + roleDraft(username: string): UserRole { + return this.roleDraftByUser()[username] ?? 'USER'; + } + + setCreateRole(role: UserRole) { + this.createModel.update((current) => ({ + ...current, + role + })); + } + + setRoleDraft(username: string, role: UserRole) { + this.roleDraftByUser.update((current) => ({ + ...current, + [username]: role + })); + this.roleErrorByUser.update((current) => ({ + ...current, + [username]: null + })); + } + + saveRole(user: AdminUser) { + const nextRole = this.roleDraft(user.username); + if (nextRole === user.role) return; + + this.roleSavingByUser.update((current) => ({ ...current, [user.username]: true })); + this.roleErrorByUser.update((current) => ({ ...current, [user.username]: null })); + this.authorization.changeAdminUserRole(user.username, { role: nextRole }).subscribe({ + next: () => { + this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false })); + this.successMessage.set(`Role updated for ${user.username}.`); + this.loadUsers(); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + this.roleSavingByUser.update((current) => ({ ...current, [user.username]: false })); + this.roleErrorByUser.update((current) => ({ + ...current, + [user.username]: error instanceof Error ? error.message : 'Unable to update role.' + })); + } + }); + } + + openResetPasswordDialog(user: AdminUser) { + this.resetPasswordError.set(null); + this.resetPasswordDialogUser.set(user); + } + + closeResetPasswordDialog() { + if (this.resetPasswordSaving()) return; + this.resetPasswordDialogUser.set(null); + this.resetPasswordError.set(null); + } + + submitResetPassword(event: { username: string; newPassword: string }) { + this.resetPasswordSaving.set(true); + this.resetPasswordError.set(null); + this.authorization.changeAdminUserPassword(event.username, { newPassword: event.newPassword }).subscribe({ + next: () => { + this.resetPasswordSaving.set(false); + this.resetPasswordDialogUser.set(null); + this.successMessage.set(`Password updated for ${event.username}.`); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + this.resetPasswordSaving.set(false); + this.resetPasswordError.set(error instanceof Error ? error.message : 'Unable to update password.'); + } + }); + } + + async deleteUser(user: AdminUser) { + const confirmed = await this.confirmDialog.open(`Delete user ${user.username}?`); + if (!confirmed) return; + + this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: true })); + this.authorization.deleteAdminUser(user.username).subscribe({ + next: () => { + this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false })); + this.successMessage.set(`User ${user.username} deleted.`); + this.loadUsers(); + setTimeout(() => this.successMessage.set(null), 3000); + }, + error: (error) => { + this.deleteBusyByUser.update((current) => ({ ...current, [user.username]: false })); + this.roleErrorByUser.update((current) => ({ + ...current, + [user.username]: error instanceof Error ? error.message : 'Unable to delete user.' + })); + } + }); + } +} diff --git a/src/app/pages/auth/signup/signup.html b/src/app/pages/auth/signup/signup.html index de55713..349650f 100644 --- a/src/app/pages/auth/signup/signup.html +++ b/src/app/pages/auth/signup/signup.html @@ -6,19 +6,13 @@