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.
-
- }
-
-
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'}]