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:
arjunsrinivasan1997
2026-01-19 07:41:42 -08:00
committed by GitHub
parent dfd757fe36
commit 4253322d2c
9 changed files with 398 additions and 30 deletions

View File

@@ -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>

View File

@@ -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;
}
}
}
}

View File

@@ -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);
});
});

View File

@@ -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({

View File

@@ -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"

View File

@@ -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'
});
});
});

View File

@@ -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);

View File

@@ -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};
};

View File

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