mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(parental-lock): per-flow PIN dialog labels and visible mismatch error (#1777)
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
|
||||
+21
-19
@@ -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>
|
||||
+24
-5
@@ -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 {
|
||||
|
||||
+351
-38
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+125
-18
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user