mirror of
https://github.com/adityachandelgit/BookLore.git
synced 2026-02-17 18:57:40 +01:00
feat(ui): add password confirmations (#2291)
* feat(ui): add password confirmation to setup screen * feat(ui): add password confirmation to create user dialog * fix(tests): force frontend tests to use jsdom, and run tests in isolated environment. Also add debug statements to further diagnose failing test if needed --------- Co-authored-by: ACX <8075870+acx10@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
dfd757fe36
commit
4253322d2c
@@ -45,23 +45,6 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
pInputText
|
||||
formControlName="email"
|
||||
placeholder="Enter email..."
|
||||
class="field-input"
|
||||
/>
|
||||
@if (userForm.get('email')?.invalid && (userForm.get('email')?.dirty || userForm.get('email')?.touched)) {
|
||||
<small class="field-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
Enter a valid email address.
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Username</label>
|
||||
<input
|
||||
@@ -79,22 +62,66 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Password</label>
|
||||
<div class="form-field field-full">
|
||||
<label class="field-label">Email</label>
|
||||
<input
|
||||
type="password"
|
||||
type="email"
|
||||
pInputText
|
||||
formControlName="password"
|
||||
placeholder="Enter password..."
|
||||
formControlName="email"
|
||||
placeholder="Enter email..."
|
||||
class="field-input"
|
||||
/>
|
||||
@if (userForm.get('password')?.invalid && (userForm.get('password')?.dirty || userForm.get('password')?.touched)) {
|
||||
@if (userForm.get('email')?.invalid && (userForm.get('email')?.dirty || userForm.get('email')?.touched)) {
|
||||
<small class="field-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
Password must be at least 6 characters long.
|
||||
Enter a valid email address.
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="password-row field-full">
|
||||
<div class="password-grid">
|
||||
<div class="form-field">
|
||||
<label class="field-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
pInputText
|
||||
formControlName="password"
|
||||
placeholder="Enter password..."
|
||||
class="field-input"
|
||||
/>
|
||||
@if (userForm.get('password')?.invalid && (userForm.get('password')?.dirty || userForm.get('password')?.touched)) {
|
||||
<small class="field-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
Password must be at least 6 characters long.
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="field-label">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
pInputText
|
||||
formControlName="confirmPassword"
|
||||
placeholder="Confirm password..."
|
||||
class="field-input"
|
||||
/>
|
||||
@if (userForm.get('confirmPassword')?.invalid && (userForm.get('confirmPassword')?.dirty || userForm.get('confirmPassword')?.touched)) {
|
||||
<small class="field-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
Password confirmation is required
|
||||
</small>
|
||||
}
|
||||
@if (userForm.errors?.['passwordMismatch'] && (userForm.get('confirmPassword')?.dirty || userForm.get('confirmPassword')?.touched)) {
|
||||
<small class="field-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
Passwords do not match.
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,8 +77,22 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
|
||||
.field-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.password-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.password-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {of} from 'rxjs';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {DynamicDialogRef} from 'primeng/dynamicdialog';
|
||||
|
||||
import {CreateUserDialogComponent} from './create-user-dialog.component';
|
||||
import {LibraryService} from '../../../book/service/library.service';
|
||||
import {UserService} from '../user.service';
|
||||
import {Library} from '../../../book/model/library.model';
|
||||
|
||||
describe('CreateUserDialogComponent confirm password', () => {
|
||||
let fixture: ComponentFixture<CreateUserDialogComponent>;
|
||||
let component: CreateUserDialogComponent;
|
||||
let libraryServiceMock: { getLibrariesFromState: ReturnType<typeof vi.fn> };
|
||||
let userServiceMock: { createUser: ReturnType<typeof vi.fn> };
|
||||
let messageServiceMock: { add: ReturnType<typeof vi.fn> };
|
||||
let dialogRefMock: { close: ReturnType<typeof vi.fn> };
|
||||
let libraries: Library[];
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!globalThis.navigator) {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: {userAgent: 'node'},
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
libraries = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Main Library',
|
||||
icon: 'pi pi-book',
|
||||
watch: false,
|
||||
paths: []
|
||||
}
|
||||
];
|
||||
|
||||
libraryServiceMock = {
|
||||
getLibrariesFromState: vi.fn().mockReturnValue(libraries)
|
||||
};
|
||||
userServiceMock = {
|
||||
createUser: vi.fn().mockReturnValue(of(void 0))
|
||||
};
|
||||
messageServiceMock = {
|
||||
add: vi.fn()
|
||||
};
|
||||
dialogRefMock = {
|
||||
close: vi.fn()
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CreateUserDialogComponent],
|
||||
providers: [
|
||||
{provide: LibraryService, useValue: libraryServiceMock},
|
||||
{provide: UserService, useValue: userServiceMock},
|
||||
{provide: MessageService, useValue: messageServiceMock},
|
||||
{provide: DynamicDialogRef, useValue: dialogRefMock},
|
||||
]
|
||||
}).compileComponents();
|
||||
console.log('doc === window.document', document === window.document);
|
||||
console.log('body ownerDocument === document',document.body?.ownerDocument === document);
|
||||
fixture = TestBed.createComponent(CreateUserDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
const getErrorTexts = () =>
|
||||
Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll('.field-error')
|
||||
)
|
||||
.map(el => el.textContent?.trim())
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
const setValidForm = () => {
|
||||
component.userForm.patchValue({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
password: '123456',
|
||||
confirmPassword: '123456',
|
||||
selectedLibraries: [libraries[0]]
|
||||
});
|
||||
};
|
||||
|
||||
it('shows required error when confirm password is empty', () => {
|
||||
const confirmControl = component.userForm.get('confirmPassword');
|
||||
confirmControl?.setValue('');
|
||||
confirmControl?.markAsTouched();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getErrorTexts()).toContain('Password confirmation is required');
|
||||
});
|
||||
|
||||
it('shows mismatch error when confirm password does not match', () => {
|
||||
component.userForm.get('password')?.setValue('abcdef');
|
||||
const confirmControl = component.userForm.get('confirmPassword');
|
||||
confirmControl?.setValue('abcdeg');
|
||||
confirmControl?.markAsTouched();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getErrorTexts()).toContain('Passwords do not match.');
|
||||
});
|
||||
|
||||
it('hides mismatch error when confirm password matches', () => {
|
||||
component.userForm.get('password')?.setValue('abcdef');
|
||||
const confirmControl = component.userForm.get('confirmPassword');
|
||||
confirmControl?.setValue('abcdef');
|
||||
confirmControl?.markAsTouched();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getErrorTexts()).not.toContain('Passwords do not match.');
|
||||
});
|
||||
|
||||
it('does not submit when confirm password does not match', () => {
|
||||
component.userForm.patchValue({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
password: '123456',
|
||||
confirmPassword: '123457',
|
||||
selectedLibraries: [libraries[0]]
|
||||
});
|
||||
|
||||
component.createUser();
|
||||
|
||||
expect(component.userForm.invalid).toBe(true);
|
||||
expect(userServiceMock.createUser).not.toHaveBeenCalled();
|
||||
expect(messageServiceMock.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({severity: 'warn'})
|
||||
);
|
||||
});
|
||||
|
||||
it('submits when form is valid and excludes confirmPassword from payload', () => {
|
||||
setValidForm();
|
||||
|
||||
component.createUser();
|
||||
|
||||
expect(component.userForm.valid).toBe(true);
|
||||
expect(userServiceMock.createUser).toHaveBeenCalled();
|
||||
|
||||
const callArg = userServiceMock.createUser.mock.calls[0]?.[0];
|
||||
expect(callArg.confirmPassword).toBeUndefined();
|
||||
expect(callArg.selectedLibraries).toEqual([1]);
|
||||
expect(dialogRefMock.close).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {LibraryService} from '../../../book/service/library.service';
|
||||
import {UserService} from '../user.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {DynamicDialogRef} from 'primeng/dynamicdialog';
|
||||
import {passwordMatchValidator} from '../../../../shared/validators/password-match.validator';
|
||||
|
||||
|
||||
@Component({
|
||||
@@ -43,6 +44,7 @@ export class CreateUserDialogComponent implements OnInit {
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
username: ['', Validators.required],
|
||||
password: ['', [Validators.required, Validators.minLength(6)]],
|
||||
confirmPassword: ['', Validators.required],
|
||||
selectedLibraries: [[], Validators.required],
|
||||
permissionUpload: [false],
|
||||
permissionDownload: [false],
|
||||
@@ -72,7 +74,7 @@ export class CreateUserDialogComponent implements OnInit {
|
||||
permissionBulkResetBookloreReadProgress: [false],
|
||||
permissionBulkResetKoReaderReadProgress: [false],
|
||||
permissionBulkResetBookReadStatus: [false],
|
||||
});
|
||||
}, {validators: [passwordMatchValidator('password', 'confirmPassword')]});
|
||||
|
||||
this.userForm.get('permissionAdmin')?.valueChanges.subscribe((isAdmin: boolean) => {
|
||||
const controls = this.userForm.controls;
|
||||
@@ -93,10 +95,13 @@ export class CreateUserDialogComponent implements OnInit {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Detele confirmPassword from form, it's not necessary to keep once validation has passed
|
||||
const {confirmPassword, ...formValue} = this.userForm.value;
|
||||
void confirmPassword;
|
||||
|
||||
const userData = {
|
||||
...this.userForm.value,
|
||||
selectedLibraries: this.userForm.value.selectedLibraries.map((lib: Library) => lib.id)
|
||||
...formValue,
|
||||
selectedLibraries: formValue.selectedLibraries.map((lib: Library) => lib.id)
|
||||
};
|
||||
|
||||
this.userService.createUser(userData).subscribe({
|
||||
|
||||
@@ -111,6 +111,36 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="confirmPassword" class="form-label">
|
||||
<i class="pi pi-lock"></i>
|
||||
<span>Confirm Password</span>
|
||||
</label>
|
||||
<input
|
||||
pInputText
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
formControlName="confirmPassword"
|
||||
placeholder="Re-enter your password"
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
@if (setupForm.get('confirmPassword')?.invalid && setupForm.get('confirmPassword')?.touched) {
|
||||
<small class="field-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
Password confirmation is required
|
||||
</small>
|
||||
}
|
||||
@if (setupForm.get('confirmPassword')?.touched && setupForm.hasError('passwordMismatch')) {
|
||||
<small class="field-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
Passwords do not match
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p-button
|
||||
fluid
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {Router} from '@angular/router';
|
||||
import {of} from 'rxjs';
|
||||
|
||||
import {SetupComponent} from './setup.component';
|
||||
import {SetupService} from './setup.service';
|
||||
|
||||
describe('SetupComponent confirm password', () => {
|
||||
let fixture: ComponentFixture<SetupComponent>;
|
||||
let component: SetupComponent;
|
||||
let setupServiceMock: { createAdmin: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
setupServiceMock = {
|
||||
createAdmin: vi.fn().mockReturnValue(of(void 0))
|
||||
};
|
||||
const routerMock = {
|
||||
navigate: vi.fn()
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SetupComponent],
|
||||
providers: [
|
||||
{provide: SetupService, useValue: setupServiceMock},
|
||||
{provide: Router, useValue: routerMock}
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SetupComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
const getErrorTexts = () =>
|
||||
Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll('.field-error')
|
||||
)
|
||||
.map(el => el.textContent?.trim())
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
|
||||
it('shows required error when confirm password is empty', () => {
|
||||
const confirmControl = component.setupForm.get('confirmPassword');
|
||||
confirmControl?.setValue('');
|
||||
confirmControl?.markAsTouched();
|
||||
|
||||
fixture.detectChanges();
|
||||
expect(getErrorTexts()).toContain('Password confirmation is required');
|
||||
});
|
||||
|
||||
it('shows mismatch error when confirm password does not match', () => {
|
||||
component.setupForm.get('password')?.setValue('abcdef');
|
||||
const confirmControl = component.setupForm.get('confirmPassword');
|
||||
confirmControl?.setValue('abcdeg');
|
||||
confirmControl?.markAsTouched();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getErrorTexts()).toContain('Passwords do not match');
|
||||
});
|
||||
|
||||
it('hides mismatch error when confirm password matches', () => {
|
||||
component.setupForm.get('password')?.setValue('abcdef');
|
||||
const confirmControl = component.setupForm.get('confirmPassword');
|
||||
confirmControl?.setValue('abcdef');
|
||||
confirmControl?.markAsTouched();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getErrorTexts()).not.toContain('Passwords do not match');
|
||||
});
|
||||
|
||||
it('does not submit when confirm password is missing', () => {
|
||||
component.setupForm.patchValue({
|
||||
username: 'admin',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
password: '123456',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.setupForm.invalid).toBe(true);
|
||||
expect(setupServiceMock.createAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit when confirm password does not match', () => {
|
||||
component.setupForm.patchValue({
|
||||
username: 'admin',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
password: '123456',
|
||||
confirmPassword: '123457'
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.setupForm.invalid).toBe(true);
|
||||
expect(setupServiceMock.createAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits when form is valid and excludes confirmPassword from payload', () => {
|
||||
component.setupForm.patchValue({
|
||||
username: 'admin',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
password: '123456',
|
||||
confirmPassword: '123456'
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.setupForm.valid).toBe(true);
|
||||
expect(setupServiceMock.createAdmin).toHaveBeenCalledWith({
|
||||
username: 'admin',
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
password: '123456'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {SetupService} from './setup.service';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {Button} from 'primeng/button';
|
||||
import {Message} from 'primeng/message';
|
||||
import {passwordMatchValidator} from '../../validators/password-match.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup',
|
||||
@@ -34,7 +35,8 @@ export class SetupComponent {
|
||||
username: ['', [Validators.required]],
|
||||
email: ['', [Validators.required, Validators.email]],
|
||||
password: ['', [Validators.required, Validators.minLength(6)]],
|
||||
});
|
||||
confirmPassword: ['', [Validators.required]],
|
||||
}, {validators: [passwordMatchValidator('password', 'confirmPassword')]});
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
@@ -42,8 +44,10 @@ export class SetupComponent {
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
this.setupService.createAdmin(this.setupForm.value).subscribe({
|
||||
// Remove confirm password from the payload, as it does not need to be sent to backend
|
||||
const { confirmPassword, ...payload } = this.setupForm.value;
|
||||
void confirmPassword;
|
||||
this.setupService.createAdmin(payload).subscribe({
|
||||
next: () => {
|
||||
this.success = true;
|
||||
setTimeout(() => this.router.navigate(['/login']), 1500);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import {AbstractControl, ValidationErrors, ValidatorFn} from '@angular/forms';
|
||||
|
||||
export const passwordMatchValidator = (
|
||||
passwordControlName: string,
|
||||
confirmPasswordControlName: string
|
||||
): ValidatorFn => (control: AbstractControl): ValidationErrors | null => {
|
||||
const password = control.get(passwordControlName)?.value;
|
||||
const confirmPassword = control.get(confirmPasswordControlName)?.value;
|
||||
|
||||
if (!password || !confirmPassword) return null;
|
||||
|
||||
return password === confirmPassword ? null : {passwordMismatch: true};
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import {defineConfig} from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
isolate: true,
|
||||
reporters: [
|
||||
['default', {summary: false}],
|
||||
['junit', {outputFile: 'test-results/vitest-results.xml'}]
|
||||
|
||||
Reference in New Issue
Block a user