From 4253322d2cd71f94a85a39d710eeb1bbe6a99814 Mon Sep 17 00:00:00 2001 From: arjunsrinivasan1997 Date: Mon, 19 Jan 2026 07:41:42 -0800 Subject: [PATCH] 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> --- .../create-user-dialog.component.html | 75 ++++++--- .../create-user-dialog.component.scss | 14 ++ .../create-user-dialog.component.spec.ts | 150 ++++++++++++++++++ .../create-user-dialog.component.ts | 11 +- .../components/setup/setup.component.html | 30 ++++ .../components/setup/setup.component.spec.ts | 123 ++++++++++++++ .../components/setup/setup.component.ts | 10 +- .../validators/password-match.validator.ts | 13 ++ booklore-ui/vitest-base.config.ts | 2 + 9 files changed, 398 insertions(+), 30 deletions(-) create mode 100644 booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.spec.ts create mode 100644 booklore-ui/src/app/shared/components/setup/setup.component.spec.ts create mode 100644 booklore-ui/src/app/shared/validators/password-match.validator.ts diff --git a/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.html b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.html index f49ec3cc2..02baec9eb 100644 --- a/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.html +++ b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.html @@ -45,23 +45,6 @@ } -
- - - @if (userForm.get('email')?.invalid && (userForm.get('email')?.dirty || userForm.get('email')?.touched)) { - - - Enter a valid email address. - - } -
-
-
- +
+ - @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)) { - Password must be at least 6 characters long. + Enter a valid email address. }
+ +
+
+
+ + + @if (userForm.get('password')?.invalid && (userForm.get('password')?.dirty || userForm.get('password')?.touched)) { + + + Password must be at least 6 characters long. + + } +
+ +
+ + + @if (userForm.get('confirmPassword')?.invalid && (userForm.get('confirmPassword')?.dirty || userForm.get('confirmPassword')?.touched)) { + + + Password confirmation is required + + } + @if (userForm.errors?.['passwordMismatch'] && (userForm.get('confirmPassword')?.dirty || userForm.get('confirmPassword')?.touched)) { + + + Passwords do not match. + + } +
+
+
diff --git a/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.scss b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.scss index 66dbd36cc..0e04d651c 100644 --- a/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.scss +++ b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.scss @@ -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; + } } } } diff --git a/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.spec.ts b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.spec.ts new file mode 100644 index 000000000..5bd7084ef --- /dev/null +++ b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.spec.ts @@ -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; + let component: CreateUserDialogComponent; + let libraryServiceMock: { getLibrariesFromState: ReturnType }; + let userServiceMock: { createUser: ReturnType }; + let messageServiceMock: { add: ReturnType }; + let dialogRefMock: { close: ReturnType }; + 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); + }); +}); diff --git a/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.ts b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.ts index 18759cbc6..2920676b5 100644 --- a/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.ts +++ b/booklore-ui/src/app/features/settings/user-management/create-user-dialog/create-user-dialog.component.ts @@ -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({ diff --git a/booklore-ui/src/app/shared/components/setup/setup.component.html b/booklore-ui/src/app/shared/components/setup/setup.component.html index 1e249eb2d..a4d3ecb5d 100644 --- a/booklore-ui/src/app/shared/components/setup/setup.component.html +++ b/booklore-ui/src/app/shared/components/setup/setup.component.html @@ -111,6 +111,36 @@ } +
+ + + @if (setupForm.get('confirmPassword')?.invalid && setupForm.get('confirmPassword')?.touched) { + + + Password confirmation is required + + } + @if (setupForm.get('confirmPassword')?.touched && setupForm.hasError('passwordMismatch')) { + + + Passwords do not match + + } +
+ { + let fixture: ComponentFixture; + let component: SetupComponent; + let setupServiceMock: { createAdmin: ReturnType }; + + 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' + }); + }); +}); diff --git a/booklore-ui/src/app/shared/components/setup/setup.component.ts b/booklore-ui/src/app/shared/components/setup/setup.component.ts index c624aabfe..641323902 100644 --- a/booklore-ui/src/app/shared/components/setup/setup.component.ts +++ b/booklore-ui/src/app/shared/components/setup/setup.component.ts @@ -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); diff --git a/booklore-ui/src/app/shared/validators/password-match.validator.ts b/booklore-ui/src/app/shared/validators/password-match.validator.ts new file mode 100644 index 000000000..83bffb573 --- /dev/null +++ b/booklore-ui/src/app/shared/validators/password-match.validator.ts @@ -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}; +}; diff --git a/booklore-ui/vitest-base.config.ts b/booklore-ui/vitest-base.config.ts index f56002ba6..dbad6285e 100644 --- a/booklore-ui/vitest-base.config.ts +++ b/booklore-ui/vitest-base.config.ts @@ -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'}]