247 lines
7.9 KiB
TypeScript
247 lines
7.9 KiB
TypeScript
import {
|
|
ChangeDetectionStrategy,
|
|
Component,
|
|
computed,
|
|
HostListener,
|
|
inject,
|
|
input,
|
|
output,
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
import {
|
|
passwordMatchValidator,
|
|
PasswordPolicy,
|
|
} from './change-password-modal.validators';
|
|
|
|
export type ChangePasswordMode = 'self' | 'admin-reset';
|
|
|
|
export interface ChangePasswordSubmitPayload {
|
|
oldPassword: string;
|
|
newPassword: string;
|
|
confirmNewPassword: string;
|
|
}
|
|
|
|
const DEFAULT_POLICY: PasswordPolicy = {
|
|
minLength: 12,
|
|
requireMixed: true,
|
|
description: 'At least 12 characters with upper, lower, digit and symbol',
|
|
};
|
|
|
|
@Component({
|
|
selector: 'app-change-password-modal',
|
|
standalone: true,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [CommonModule, ReactiveFormsModule],
|
|
styles: [`
|
|
.modal-overlay {
|
|
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.9);
|
|
backdrop-filter: blur(4px);
|
|
display: flex; align-items: center; justify-content: center; z-index: 1200;
|
|
padding: 16px;
|
|
}
|
|
.modal-card {
|
|
background: #000000; color: #ffffff;
|
|
border: 3px solid var(--neon-red);
|
|
padding: 28px 24px;
|
|
width: min(440px, 94vw); position: relative;
|
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.9), 0 0 25px rgba(255, 0, 51, 0.35);
|
|
}
|
|
h2 {
|
|
font-family: var(--font-heading, 'Chakra Petch', sans-serif);
|
|
font-size: 2rem; font-weight: 900; font-style: italic;
|
|
text-transform: uppercase; color: #ffffff; margin-bottom: 20px;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.field {
|
|
display: flex; flex-direction: column; gap: 6px;
|
|
margin-bottom: 16px; text-align: left;
|
|
}
|
|
.field span {
|
|
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 800;
|
|
color: #888899; text-transform: uppercase; letter-spacing: 1px;
|
|
}
|
|
.field input {
|
|
font-family: var(--font-mono); background-color: #000000; color: #ffffff;
|
|
border: 2px solid var(--neon-purple); padding: 12px 14px;
|
|
font-size: 0.95rem; outline: none; width: 100%;
|
|
}
|
|
.field input:focus {
|
|
border-color: var(--neon-cyan); box-shadow: 0 0 10px var(--neon-cyan-glow);
|
|
}
|
|
.error {
|
|
color: var(--neon-red); font-family: var(--font-mono); font-size: 0.85rem; font-weight: bold;
|
|
}
|
|
.hint {
|
|
color: #777788; font-family: var(--font-mono); font-size: 0.75rem; margin-bottom: 20px;
|
|
}
|
|
.actions {
|
|
display: flex; justify-content: flex-end; gap: 12px; margin-top: 10px;
|
|
}
|
|
.actions button {
|
|
font-family: var(--font-mono); font-weight: 800; text-transform: uppercase;
|
|
padding: 10px 20px; font-size: 0.9rem; cursor: pointer;
|
|
}
|
|
.btn-cancel {
|
|
background: #000000; color: var(--neon-purple); border: 2px solid var(--neon-purple);
|
|
}
|
|
.btn-cancel:hover:not([disabled]) {
|
|
background: var(--neon-purple); color: #000000;
|
|
}
|
|
.btn-ok {
|
|
background: var(--neon-red); color: #000000; border: 2px solid var(--neon-red);
|
|
}
|
|
.btn-ok:hover:not([disabled]) {
|
|
background: #000000; color: var(--neon-red); box-shadow: 0 0 15px rgba(255, 0, 51, 0.4);
|
|
}
|
|
`],
|
|
template: `
|
|
@if (open()) {
|
|
<div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()">
|
|
<div
|
|
class="modal-card"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="cp-title"
|
|
data-testid="change-password-modal"
|
|
(click)="$event.stopPropagation()"
|
|
>
|
|
<h2 id="cp-title">CHANGE PASSWORD</h2>
|
|
|
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
|
@if (mode() === 'self') {
|
|
<label class="field">
|
|
<span>OLD PASSWORD</span>
|
|
<input
|
|
type="password"
|
|
autocomplete="current-password"
|
|
formControlName="oldPassword"
|
|
placeholder="CURRENT PASSWORD"
|
|
data-testid="cp-old"
|
|
/>
|
|
</label>
|
|
}
|
|
|
|
<label class="field">
|
|
<span>NEW PASSWORD</span>
|
|
<input
|
|
type="password"
|
|
autocomplete="new-password"
|
|
formControlName="newPassword"
|
|
placeholder="NEW PASSWORD"
|
|
data-testid="cp-new"
|
|
/>
|
|
@if (fieldErrorFor('newPassword'); as msg) {
|
|
<span class="error" data-testid="cp-new-error">{{ msg }}</span>
|
|
}
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span>CONFIRM NEW PASSWORD</span>
|
|
<input
|
|
type="password"
|
|
autocomplete="new-password"
|
|
formControlName="confirmNewPassword"
|
|
placeholder="CONFIRM NEW PASSWORD"
|
|
data-testid="cp-confirm"
|
|
/>
|
|
@if (fieldErrorFor('confirmPassword'); as msg) {
|
|
<span class="error" data-testid="cp-confirm-error">{{ msg }}</span>
|
|
}
|
|
@if (fieldErrorFor('confirmNewPassword'); as msg) {
|
|
<span class="error" data-testid="cp-confirm-error">{{ msg }}</span>
|
|
}
|
|
</label>
|
|
|
|
@if (errorMessage()) {
|
|
<p class="error" data-testid="cp-error">{{ errorMessage() }}</p>
|
|
}
|
|
@if (formError()) {
|
|
<p class="error" data-testid="cp-form-error">{{ formError() }}</p>
|
|
}
|
|
<p class="hint" data-testid="cp-policy">{{ policy().description }}</p>
|
|
|
|
<div class="actions">
|
|
<button
|
|
type="button"
|
|
class="btn-cancel"
|
|
data-testid="cp-cancel"
|
|
(click)="onCancel()"
|
|
[disabled]="submitting()"
|
|
>
|
|
CANCEL
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="btn-ok"
|
|
data-testid="cp-ok"
|
|
[disabled]="submitting()"
|
|
>
|
|
{{ submitting() ? 'SAVING...' : 'OK' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
}
|
|
`,
|
|
})
|
|
export class ChangePasswordModalComponent {
|
|
private readonly fb = inject(FormBuilder);
|
|
|
|
readonly open = input<boolean>(false);
|
|
readonly mode = input<ChangePasswordMode>('self');
|
|
readonly policy = input<PasswordPolicy>(DEFAULT_POLICY);
|
|
readonly errorMessage = input<string | null>(null);
|
|
readonly submitting = input<boolean>(false);
|
|
readonly fieldErrors = input<Record<string, string> | null>(null);
|
|
|
|
readonly cancel = output<void>();
|
|
readonly submit = output<ChangePasswordSubmitPayload>();
|
|
|
|
readonly form = this.fb.nonNullable.group(
|
|
{
|
|
oldPassword: this.fb.nonNullable.control(''),
|
|
newPassword: this.fb.nonNullable.control('', [Validators.required]),
|
|
confirmNewPassword: this.fb.nonNullable.control('', [Validators.required]),
|
|
},
|
|
{ validators: passwordMatchValidator },
|
|
);
|
|
|
|
readonly formError = computed<string | null>(() => {
|
|
const errs = this.form.errors;
|
|
if (errs?.['passwordMismatch']) return 'Passwords do not match';
|
|
return null;
|
|
});
|
|
|
|
fieldErrorFor(path: string): string | null {
|
|
const map = this.fieldErrors();
|
|
if (!map) return null;
|
|
return map[path] ?? null;
|
|
}
|
|
|
|
@HostListener('document:keydown.escape')
|
|
onEscape(): void {
|
|
if (this.open() && !this.submitting()) this.onCancel();
|
|
}
|
|
|
|
onCancel(): void {
|
|
if (this.submitting()) return;
|
|
this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' });
|
|
this.cancel.emit();
|
|
}
|
|
|
|
onSubmit(): void {
|
|
if (this.submitting()) return;
|
|
const v = this.form.getRawValue();
|
|
if (this.mode() === 'self' && !v.oldPassword) return;
|
|
if (!v.newPassword || !v.confirmNewPassword) return;
|
|
if (this.form.invalid) return;
|
|
this.submit.emit({
|
|
oldPassword: v.oldPassword,
|
|
newPassword: v.newPassword,
|
|
confirmNewPassword: v.confirmNewPassword,
|
|
});
|
|
}
|
|
}
|