fix(parental-lock): per-flow PIN dialog labels and visible mismatch error (#1777)

This commit is contained in:
4gray authored and GitHub committed 2026-10-01 11:21:27 +02:00
1 parent 8b6fcf3560
commit d1e79bdc3e
32 files changed
+998 -103

No files matched your search

@@ -0,0 +1,72 @@
import {
ParentalLockPinThrottle,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { ParentalLockPromptRequest } from './parental-lock-prompt.token';
type PromptLabels = Pick<
ParentalLockPromptRequest,
'titleKey' | 'descriptionKey' | 'submitKey'
>;
/**
* The submit verb of each PIN flow, so the button says what it will do
* instead of a generic "Unlock".
*/
export const PARENTAL_LOCK_SUBMIT_KEYS = {
unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK',
save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE',
confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM',
turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF',
} as const;
/** Setting up or replacing the PIN: typed twice, saved on submit. */
export const NEW_PIN_REQUEST: ParentalLockPromptRequest = {
mode: 'set',
submitKey: PARENTAL_LOCK_SUBMIT_KEYS.save,
};
/** Unlocks the session with the current PIN. */
export function unlockPinRequest(
hash: string,
throttle: ParentalLockPinThrottle,
labels: Pick<PromptLabels, 'titleKey' | 'descriptionKey'> = {}
): ParentalLockPromptRequest {
return currentPinRequest(hash, throttle, {
submitKey: PARENTAL_LOCK_SUBMIT_KEYS.unlock,
...labels,
});
}
/**
* Confirms the current PIN before a protected settings change; `submitKey`
* names that change (Confirm before a new PIN, Turn off).
*/
export function confirmPinRequest(
hash: string,
throttle: ParentalLockPinThrottle,
submitKey: string
): ParentalLockPromptRequest {
return currentPinRequest(hash, throttle, {
titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE',
descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION',
submitKey,
});
}
/**
* Asks for the current PIN, checked against `hash`. Every such prompt shares
* the service's `throttle`, so the cooldown survives a dismissed dialog.
*/
function currentPinRequest(
hash: string,
throttle: ParentalLockPinThrottle,
labels: PromptLabels
): ParentalLockPromptRequest {
return {
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle,
...labels,
};
}
@@ -20,6 +20,11 @@ export interface ParentalLockPromptRequest {
titleKey?: string;
/** Optional translation key overriding the mode's default description. */
descriptionKey?: string;
/**
* Translation key of the flow's verb on the submit button ("Unlock",
* "Save PIN", "Turn off", …); the mode's default when absent.
*/
submitKey?: string;
}
/**
@@ -559,6 +559,46 @@ describe('ParentalLockService', () => {
expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock');
});
it('labels each prompt with the verb of its flow', async () => {
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
request.mode === 'set'
? '1234'
: (await request.verify?.('1234'))
? '1234'
: null
);
const service = await createService();
const submitKeys = () =>
prompt.requestPin.mock.calls.map(
([request]: [ParentalLockPromptRequest]) =>
`${request.mode}:${request.submitKey}`
);
await expect(service.setupPin()).resolves.toBe(true);
expect(submitKeys()).toEqual(['set:PARENTAL_LOCK.PIN_DIALOG.SAVE']);
prompt.requestPin.mockClear();
await expect(service.changePin()).resolves.toBe(true);
expect(submitKeys()).toEqual([
'unlock:PARENTAL_LOCK.PIN_DIALOG.CONFIRM',
'set:PARENTAL_LOCK.PIN_DIALOG.SAVE',
]);
prompt.requestPin.mockClear();
service.lock();
await expect(service.requestUnlock()).resolves.toBe(true);
expect(submitKeys()).toEqual([
'unlock:PARENTAL_LOCK.PIN_DIALOG.UNLOCK',
]);
prompt.requestPin.mockClear();
await expect(service.disable()).resolves.toBe(true);
expect(submitKeys()).toEqual([
'unlock:PARENTAL_LOCK.PIN_DIALOG.TURN_OFF',
]);
});
it('persists locks per portal, stamps the Xtream column and bumps the version', async () => {
const service = await createService();
const versionBefore = service.version();
@@ -13,7 +13,6 @@ import {
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockXtreamCategoryType,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
@@ -35,6 +34,12 @@ import {
persistParentalLockEnabled,
persistParentalLockRelockMinutes,
} from './parental-lock-settings-writer';
import {
confirmPinRequest,
NEW_PIN_REQUEST,
PARENTAL_LOCK_SUBMIT_KEYS,
unlockPinRequest,
} from './parental-lock-prompt-requests';
import { ParentalLockStorageService } from './parental-lock-storage';
/**
@@ -242,12 +247,9 @@ export class ParentalLockService {
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle: this.pinThrottle,
...options,
});
const pin = await this.prompt.requestPin(
unlockPinRequest(hash, this.pinThrottle, options)
);
if (pin === null) {
return false;
}
@@ -276,7 +278,7 @@ export class ParentalLockService {
// depends on.
const needsPersist =
this.settingsStore.parentalLockEnabled?.() !== true;
const pin = await this.prompt.requestPin({ mode: 'set' });
const pin = await this.prompt.requestPin(NEW_PIN_REQUEST);
if (pin === null) {
return false;
}
@@ -306,10 +308,10 @@ export class ParentalLockService {
if (!this.prompt || !this.hasPin()) {
return false;
}
if (!(await this.verifyCurrentPin())) {
if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.confirm))) {
return false;
}
const pin = await this.prompt.requestPin({ mode: 'set' });
const pin = await this.prompt.requestPin(NEW_PIN_REQUEST);
if (pin === null) {
return false;
}
@@ -321,7 +323,7 @@ export class ParentalLockService {
if (!this.enabled()) {
return true;
}
if (!(await this.verifyCurrentPin())) {
if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.turnOff))) {
return false;
}
if (!(await this.persistEnabled(false))) {
@@ -335,22 +337,18 @@ export class ParentalLockService {
* Always asks for the PIN, unlocked session or not: changing the PIN or
* switching the feature off must not be possible just because a parent
* left the app unlocked. Unlike `requestUnlock()` this never short-cuts
* on `active`.
* on `active`. `submitKey` names the step the PIN confirms.
*/
private async verifyCurrentPin(): Promise<boolean> {
private async verifyCurrentPin(submitKey: string): Promise<boolean> {
await this.initialize();
await this.ensurePin();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle: this.pinThrottle,
titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE',
descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION',
});
const pin = await this.prompt.requestPin(
confirmPinRequest(hash, this.pinThrottle, submitKey)
);
return pin !== null;
}
@@ -11,7 +11,7 @@
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="shake()"
[class.pin-dialog__field--shake]="shake() === 'pin'"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.PIN_LABEL' | translate }}</mat-label>
<input
@@ -19,12 +19,14 @@
matInput
type="password"
inputmode="numeric"
autocomplete="off"
data-test-id="parental-lock-pin"
[attr.autocomplete]="isSetMode() ? 'new-password' : 'off'"
[attr.maxlength]="maxLength"
[disabled]="busy() || inCooldown()"
[errorStateMatcher]="pinErrorState"
[ngModel]="pin()"
(ngModelChange)="onPinInput($event)"
(keydown.enter)="onPinEnter($event)"
name="pin"
/>
<mat-hint>
@@ -33,6 +35,11 @@
| translate: { min: minLength, max: maxLength }
}}
</mat-hint>
@if (pinErrorKey(); as errorKey) {
<mat-error data-test-id="parental-lock-pin-error">
{{ errorKey | translate: { min: minLength, max: maxLength } }}
</mat-error>
}
</mat-form-field>
@if (isSetMode()) {
@@ -40,30 +47,30 @@
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="shake() === 'confirmation'"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_LABEL' | translate }}</mat-label>
<input
#confirmationInput
matInput
type="password"
inputmode="numeric"
autocomplete="off"
autocomplete="new-password"
data-test-id="parental-lock-pin-confirm"
[attr.maxlength]="maxLength"
[disabled]="busy()"
[errorStateMatcher]="confirmationErrorState"
[ngModel]="confirmation()"
(ngModelChange)="onConfirmationInput($event)"
name="confirmation"
/>
@if (mismatch()) {
<mat-error data-test-id="parental-lock-pin-mismatch">
{{ 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH' | translate }}
</mat-error>
}
</mat-form-field>
}
@if (error(); as errorKey) {
<p class="pin-dialog__error" role="alert" data-test-id="parental-lock-pin-error">
{{ errorKey | translate }}
</p>
}
@if (isSetMode()) {
<p class="pin-dialog__note">
{{ 'PARENTAL_LOCK.PIN_DIALOG.NO_RECOVERY' | translate }}
</p>
@@ -72,21 +79,16 @@
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="busy()">
{{ 'CLOSE' | translate }}
{{ 'CANCEL' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="submit"
data-test-id="parental-lock-pin-submit"
[disabled]="!canSubmit()"
[disabled]="busy() || inCooldown()"
>
{{
(isSetMode()
? 'PARENTAL_LOCK.PIN_DIALOG.SAVE'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK'
) | translate
}}
{{ submitKey() | translate }}
</button>
</mat-dialog-actions>
</form>
@@ -1,7 +1,28 @@
// The theme declares no Material system colours, so the error colour is a
// local token with a value per theme (>= 6:1 on the dialog surface in both).
// The fields' error state (message, outline, label, caret) takes it too.
:host {
--pin-dialog-error-color: #b3261e;
--mat-form-field-error-text-color: var(--pin-dialog-error-color);
--mat-form-field-outlined-error-outline-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-hover-outline-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-focus-outline-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-label-text-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-hover-label-text-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-focus-label-text-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-caret-color: var(--pin-dialog-error-color);
}
:host-context(.dark-theme) {
@@ -37,12 +58,10 @@
.pin-dialog__field--shake {
animation: pin-dialog-shake 0.4s ease;
}
.pin-dialog__error {
margin: 0;
color: var(--pin-dialog-error-color);
font-size: 0.8125rem;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.pin-dialog__note {
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
@@ -6,48 +6,361 @@ import {
createParentalLockPinThrottle,
PARENTAL_LOCK_PIN_MAX_ATTEMPTS,
} from '@iptvnator/shared/interfaces';
import { ParentalLockPinDialogComponent } from './parental-lock-pin-dialog.component';
import {
ParentalLockPinDialogComponent,
ParentalLockPinDialogData,
} from './parental-lock-pin-dialog.component';
describe('ParentalLockPinDialogComponent cooldown', () => {
const throttle = createParentalLockPinThrottle();
const verify = jest.fn(async () => false);
// Without a loader the translate pipe renders the key itself.
const KEYS = {
save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE',
unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK',
confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM',
turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF',
mismatch: 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH',
hint: 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT',
wrongPin: 'PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN',
};
function openDialog(): ParentalLockPinDialogComponent {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [
ParentalLockPinDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: { mode: 'unlock', verify, throttle },
},
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
],
interface DialogHarness {
fixture: ComponentFixture<ParentalLockPinDialogComponent>;
component: ParentalLockPinDialogComponent;
close: jest.Mock;
query<T extends Element = HTMLElement>(testId: string): T | null;
type(testId: string, value: string): Promise<void>;
/** Submits the form the way Enter in a field does. */
pressEnter(): Promise<void>;
}
async function openDialog(
data: ParentalLockPinDialogData
): Promise<DialogHarness> {
TestBed.resetTestingModule();
const close = jest.fn();
TestBed.configureTestingModule({
imports: [
ParentalLockPinDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: MatDialogRef, useValue: { close } },
],
});
const fixture = TestBed.createComponent(ParentalLockPinDialogComponent);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const query = <T extends Element = HTMLElement>(testId: string) =>
root.querySelector<T>(`[data-test-id="${testId}"]`);
return {
fixture,
component: fixture.componentInstance,
close,
query,
async type(testId, value) {
const input = query<HTMLInputElement>(testId);
if (!input) throw new Error(`No input ${testId}`);
input.value = value;
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
},
async pressEnter() {
root.querySelector('form')?.dispatchEvent(
new Event('submit', { cancelable: true })
);
await fixture.whenStable();
},
};
}
function text(element: Element | null): string {
return element?.textContent?.trim() ?? '';
}
describe('ParentalLockPinDialogComponent', () => {
describe('submit labels', () => {
it.each([
['unlock', undefined, KEYS.unlock],
['set', undefined, KEYS.save],
['unlock', KEYS.confirm, KEYS.confirm],
['unlock', KEYS.turnOff, KEYS.turnOff],
['set', KEYS.save, KEYS.save],
] as const)(
'%s mode with submitKey %s reads %s',
async (mode, submitKey, expected) => {
const dialog = await openDialog({ mode, submitKey });
expect(text(dialog.query('parental-lock-pin-submit'))).toBe(
expected
);
const dismiss = dialog.fixture.nativeElement.querySelector(
'mat-dialog-actions button[type="button"]'
);
expect(text(dismiss)).toBe('CANCEL');
}
);
});
describe('set mode', () => {
it('asks password managers for a new password in both fields', async () => {
const dialog = await openDialog({ mode: 'set' });
expect(
dialog.query('parental-lock-pin')?.getAttribute('autocomplete')
).toBe('new-password');
expect(
dialog
.query('parental-lock-pin-confirm')
?.getAttribute('autocomplete')
).toBe('new-password');
});
const fixture = TestBed.createComponent(ParentalLockPinDialogComponent);
fixture.detectChanges();
return fixture.componentInstance;
}
it('keeps the cooldown when the prompt is dismissed and opened again', async () => {
const first = openDialog();
for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
first.onPinInput('0000');
await first.submit();
}
expect(first.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS);
it('shows the mismatch once the repeat is as long as the PIN, then saves the fixed PIN', async () => {
const dialog = await openDialog({ mode: 'set' });
const confirm = () =>
dialog.query<HTMLInputElement>('parental-lock-pin-confirm');
const reopened = openDialog();
expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
reopened.onPinInput('0000');
await reopened.submit();
await dialog.type('parental-lock-pin', '2468');
await dialog.type('parental-lock-pin-confirm', '246');
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
expect(confirm()?.getAttribute('aria-invalid')).toBe('false');
expect(reopened.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS);
await dialog.type('parental-lock-pin-confirm', '2469');
const error = dialog.query('parental-lock-pin-mismatch');
expect(text(error)).toBe(KEYS.mismatch);
expect(confirm()?.getAttribute('aria-invalid')).toBe('true');
// Announced: the error is the input's description.
expect(confirm()?.getAttribute('aria-describedby')).toContain(
error?.id
);
// Enter is refused visibly rather than swallowed.
expect(
dialog.query<HTMLButtonElement>('parental-lock-pin-submit')
?.disabled
).toBe(false);
await dialog.pressEnter();
expect(dialog.close).not.toHaveBeenCalled();
expect(dialog.component.shake()).toBe('confirmation');
expect(document.activeElement).toBe(confirm());
await dialog.type('parental-lock-pin-confirm', '2468');
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
expect(confirm()?.getAttribute('aria-invalid')).toBe('false');
await dialog.pressEnter();
expect(dialog.close).toHaveBeenCalledWith('2468');
});
it('shows the mismatch for a short repeat when Enter is pressed', async () => {
const dialog = await openDialog({ mode: 'set' });
await dialog.type('parental-lock-pin', '2468');
await dialog.type('parental-lock-pin-confirm', '24');
await dialog.pressEnter();
expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe(
KEYS.mismatch
);
expect(dialog.close).not.toHaveBeenCalled();
// Editing the repeat hands the error back to the length rule.
await dialog.type('parental-lock-pin-confirm', '246');
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
});
it('moves on to an empty repeat on Enter in the PIN field', async () => {
const dialog = await openDialog({ mode: 'set' });
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
cancelable: true,
});
await dialog.type('parental-lock-pin', '2468');
dialog.query('parental-lock-pin')?.dispatchEvent(enter);
await dialog.fixture.whenStable();
// Handled before the form sees it: no implicit submission.
expect(enter.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(
dialog.query('parental-lock-pin-confirm')
);
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
expect(dialog.component.shake()).toBeNull();
});
it('refuses an empty repeat on Save while the PIN field keeps focus', async () => {
// WebKit does not focus a clicked button, so focus says nothing
// about how the form was submitted.
const dialog = await openDialog({ mode: 'set' });
await dialog.type('parental-lock-pin', '2468');
dialog.query('parental-lock-pin')?.focus();
await dialog.pressEnter();
expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe(
KEYS.mismatch
);
expect(dialog.component.shake()).toBe('confirmation');
expect(dialog.close).not.toHaveBeenCalled();
});
it('refuses an empty repeat when Enter is pressed in it', async () => {
const dialog = await openDialog({ mode: 'set' });
await dialog.type('parental-lock-pin', '2468');
dialog.query('parental-lock-pin-confirm')?.focus();
await dialog.pressEnter();
expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe(
KEYS.mismatch
);
expect(dialog.component.shake()).toBe('confirmation');
expect(dialog.close).not.toHaveBeenCalled();
});
it('lets a second refusal shake for its full time', async () => {
const { component } = await openDialog({ mode: 'set' });
jest.useFakeTimers();
try {
component.onPinInput('24');
await component.submit();
expect(component.shake()).toBe('pin');
jest.advanceTimersByTime(300);
component.onPinInput('2468');
component.onConfirmationInput('2469');
await component.submit();
expect(component.shake()).toBe('confirmation');
// The first refusal's timer must not end the second shake.
jest.advanceTimersByTime(150);
expect(component.shake()).toBe('confirmation');
jest.advanceTimersByTime(250);
expect(component.shake()).toBeNull();
} finally {
jest.useRealTimers();
}
});
it('replays the shake when the same field is refused again', async () => {
const dialog = await openDialog({ mode: 'set' });
const field = dialog
.query('parental-lock-pin')
?.closest('mat-form-field');
// jsdom has no Web Animations: stand in for the running shake.
const shake: Partial<CSSAnimation> = {
animationName: '_ngcontent-x_pin-dialog-shake',
currentTime: 250,
};
Object.defineProperty(field, 'getAnimations', {
value: () => [shake],
});
await dialog.type('parental-lock-pin', '24');
await dialog.pressEnter();
expect(shake.currentTime).toBe(250);
await dialog.pressEnter();
expect(dialog.component.shake()).toBe('pin');
expect(shake.currentTime).toBe(0);
});
it('refuses a too-short PIN on Enter with an error on the PIN field', async () => {
const dialog = await openDialog({ mode: 'set' });
const pin = () =>
dialog.query<HTMLInputElement>('parental-lock-pin');
await dialog.type('parental-lock-pin', '24');
await dialog.type('parental-lock-pin-confirm', '24');
await dialog.pressEnter();
expect(dialog.close).not.toHaveBeenCalled();
expect(text(dialog.query('parental-lock-pin-error'))).toBe(
KEYS.hint
);
expect(pin()?.getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(pin());
await dialog.type('parental-lock-pin', '246');
expect(dialog.query('parental-lock-pin-error')).toBeNull();
});
});
describe('unlock mode', () => {
it('has no repeat field and leaves autocomplete off', async () => {
const dialog = await openDialog({ mode: 'unlock' });
expect(dialog.query('parental-lock-pin-confirm')).toBeNull();
expect(
dialog.query('parental-lock-pin')?.getAttribute('autocomplete')
).toBe('off');
});
it('marks the PIN field invalid after a wrong PIN and unlocks with the right one', async () => {
const verify = jest.fn(async (pin: string) => pin === '2468');
const dialog = await openDialog({ mode: 'unlock', verify });
const pin = () =>
dialog.query<HTMLInputElement>('parental-lock-pin');
await dialog.type('parental-lock-pin', '1357');
await dialog.pressEnter();
await dialog.fixture.whenStable();
expect(text(dialog.query('parental-lock-pin-error'))).toBe(
KEYS.wrongPin
);
expect(pin()?.getAttribute('aria-invalid')).toBe('true');
expect(dialog.close).not.toHaveBeenCalled();
await dialog.type('parental-lock-pin', '2468');
expect(dialog.query('parental-lock-pin-error')).toBeNull();
await dialog.pressEnter();
expect(dialog.close).toHaveBeenCalledWith('2468');
});
it('does not verify an incomplete PIN', async () => {
const verify = jest.fn(async () => true);
const dialog = await openDialog({ mode: 'unlock', verify });
await dialog.type('parental-lock-pin', '12');
await dialog.pressEnter();
expect(verify).not.toHaveBeenCalled();
expect(text(dialog.query('parental-lock-pin-error'))).toBe(
KEYS.hint
);
});
});
describe('cooldown', () => {
const throttle = createParentalLockPinThrottle();
const verify = jest.fn(async () => false);
it('keeps the cooldown when the prompt is dismissed and opened again', async () => {
const first = (
await openDialog({ mode: 'unlock', verify, throttle })
).component;
for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
first.onPinInput('0000');
await first.submit();
}
expect(first.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(
PARENTAL_LOCK_PIN_MAX_ATTEMPTS
);
const reopened = (
await openDialog({ mode: 'unlock', verify, throttle })
).component;
expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
reopened.onPinInput('0000');
await reopened.submit();
expect(reopened.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(
PARENTAL_LOCK_PIN_MAX_ATTEMPTS
);
});
});
});
@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { ErrorStateMatcher } from '@angular/material/core';
import {
MAT_DIALOG_DATA,
MatDialog,
@@ -29,6 +30,8 @@ import {
export type ParentalLockPinDialogMode = 'unlock' | 'set';
type PinDialogField = 'pin' | 'confirmation';
export interface ParentalLockPinDialogData {
mode: ParentalLockPinDialogMode;
/** Unlock only: whether the typed PIN is the right one. */
@@ -41,12 +44,18 @@ export interface ParentalLockPinDialogData {
throttle?: ParentalLockPinThrottle;
titleKey?: string;
descriptionKey?: string;
/** The flow's verb on the submit button; defaults to the mode's. */
submitKey?: string;
}
/**
* PIN prompt for the parental lock. Pure UI: the caller supplies `verify`
* for the unlock mode and receives the accepted PIN (or `undefined` when
* dismissed) through the dialog result. `set` mode asks for the PIN twice.
*
* Submit stays enabled while the input is incomplete: implicit submission
* (Enter) does nothing on a disabled default button, so an invalid entry
* is refused by `submit()` with an error on the field instead.
*/
@Component({
selector: 'app-parental-lock-pin-dialog',
@@ -70,18 +79,24 @@ export class ParentalLockPinDialogComponent {
);
private readonly pinInput =
viewChild<ElementRef<HTMLInputElement>>('pinInput');
private readonly confirmationInput =
viewChild<ElementRef<HTMLInputElement>>('confirmationInput');
readonly minLength = PARENTAL_LOCK_PIN_MIN_LENGTH;
readonly maxLength = PARENTAL_LOCK_PIN_MAX_LENGTH;
readonly pin = signal('');
readonly confirmation = signal('');
readonly busy = signal(false);
/** Wrong PIN or cooldown, shown on the PIN field. */
readonly error = signal<string | null>(null);
readonly shake = signal(false);
readonly shake = signal<PinDialogField | null>(null);
readonly cooldownUntil = signal(0);
/** A submit was refused for this field; cleared when it is edited. */
private readonly refused = signal<PinDialogField | null>(null);
private readonly throttle =
this.data.throttle ?? createParentalLockPinThrottle();
private cooldownTimer: number | null = null;
private shakeTimer: number | null = null;
readonly isSetMode = computed(() => this.data.mode === 'set');
readonly titleKey = computed(
@@ -98,14 +113,43 @@ export class ParentalLockPinDialogComponent {
? 'PARENTAL_LOCK.PIN_DIALOG.SET_DESCRIPTION'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK_DESCRIPTION')
);
readonly inCooldown = computed(() => this.cooldownUntil() > Date.now());
readonly canSubmit = computed(
readonly submitKey = computed(
() =>
!this.busy() &&
!this.inCooldown() &&
isValidParentalLockPin(this.pin()) &&
(!this.isSetMode() || this.confirmation() === this.pin())
this.data.submitKey ??
(this.isSetMode()
? 'PARENTAL_LOCK.PIN_DIALOG.SAVE'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK')
);
readonly inCooldown = computed(() => this.cooldownUntil() > Date.now());
/** The message under the PIN field, if any. */
readonly pinErrorKey = computed(
() =>
this.error() ??
(this.refused() === 'pin' && !isValidParentalLockPin(this.pin())
? 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT'
: null)
);
/**
* Set mode: the repeat differs from the PIN. Shown while typing once the
* repeat is as long as the PIN, and at any length after a refused submit.
*/
readonly mismatch = computed(() => {
const confirmation = this.confirmation();
const pin = this.pin();
if (!this.isSetMode() || confirmation === pin) {
return false;
}
return (
this.refused() === 'confirmation' ||
(confirmation.length > 0 && confirmation.length >= pin.length)
);
});
readonly pinErrorState: ErrorStateMatcher = {
isErrorState: () => this.pinErrorKey() !== null,
};
readonly confirmationErrorState: ErrorStateMatcher = {
isErrorState: () => this.mismatch(),
};
constructor() {
// Reopened during a cooldown: the pause carries on where it was.
@@ -132,24 +176,48 @@ export class ParentalLockPinDialogComponent {
onPinInput(value: string): void {
this.pin.set(value.replace(/\D/g, '').slice(0, this.maxLength));
this.error.set(null);
this.clearRefusal('pin');
}
onConfirmationInput(value: string): void {
this.confirmation.set(
value.replace(/\D/g, '').slice(0, this.maxLength)
);
this.error.set(null);
this.clearRefusal('confirmation');
}
/**
* Set mode: Enter after a complete first PIN moves on to an empty repeat
* instead of submitting. Handled on the key, not in `submit()`: focus
* cannot tell Enter from a click on Save, as WebKit does not focus a
* clicked button.
*/
onPinEnter(event: Event): void {
if (
this.isSetMode() &&
!this.confirmation() &&
isValidParentalLockPin(this.pin())
) {
event.preventDefault();
this.confirmationInput()?.nativeElement.focus();
}
}
async submit(): Promise<void> {
if (!this.canSubmit()) {
if (this.isSetMode() && this.confirmation() !== this.pin()) {
this.fail('PARENTAL_LOCK.PIN_DIALOG.MISMATCH');
}
if (this.busy() || this.inCooldown()) {
return;
}
const pin = this.pin();
if (!isValidParentalLockPin(pin)) {
this.error.set(null);
this.refuse('pin');
return;
}
if (this.isSetMode()) {
if (this.confirmation() !== pin) {
this.refuse('confirmation');
return;
}
this.dialogRef.close(pin);
return;
}
@@ -165,10 +233,11 @@ export class ParentalLockPinDialogComponent {
this.pin.set('');
if (this.throttle.recordFailure()) {
this.startCooldown(this.throttle.cooldownUntil());
this.fail('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
this.error.set('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
} else {
this.fail('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN');
this.error.set('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN');
}
this.shakeField('pin');
} finally {
this.busy.set(false);
queueMicrotask(() => this.pinInput()?.nativeElement.focus());
@@ -179,10 +248,48 @@ export class ParentalLockPinDialogComponent {
this.dialogRef.close(undefined);
}
private fail(messageKey: string): void {
this.error.set(messageKey);
this.shake.set(true);
window.setTimeout(() => this.shake.set(false), 400);
/** Shows the field's error, shakes it and moves focus to it. */
private refuse(field: PinDialogField): void {
this.refused.set(field);
this.shakeField(field);
this.fieldInput(field)?.focus();
}
private fieldInput(field: PinDialogField): HTMLInputElement | undefined {
return (field === 'pin' ? this.pinInput() : this.confirmationInput())
?.nativeElement;
}
private clearRefusal(field: PinDialogField): void {
if (this.refused() === field) {
this.refused.set(null);
}
}
private shakeField(field: PinDialogField): void {
// A refusal inside the previous one's 400ms shakes for its own
// full time; the earlier timer would otherwise end it early.
if (this.shakeTimer !== null) {
window.clearTimeout(this.shakeTimer);
}
if (this.shake() === field) {
// Same field again: its class stays on, so the running shake
// would only finish. Rewind it (none under reduced motion).
this.fieldInput(field)
?.closest('mat-form-field')
?.getAnimations?.()
.filter((animation) =>
(animation as CSSAnimation).animationName?.includes(
'pin-dialog-shake'
)
)
.forEach((animation) => (animation.currentTime = 0));
}
this.shake.set(field);
this.shakeTimer = window.setTimeout(() => {
this.shakeTimer = null;
this.shake.set(null);
}, 400);
}
private startCooldown(until: number): void {