fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts

A backup merge now asks for the PIN again right before it replaces a
playlist's locks when the app relocked during the import, instead of
relying on the answer given at the start. The wrong-PIN count and the
30-second pause move from the dialog into the lock service, so
dismissing and reopening the prompt no longer resets them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 12:56:37 +02:00
1 parent 451b373b1d
commit 8ad8907ef9
11 files changed
+237 -19

No files matched your search

@@ -0,0 +1,53 @@
import { 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';
import {
createParentalLockPinThrottle,
PARENTAL_LOCK_PIN_MAX_ATTEMPTS,
} from '@iptvnator/shared/interfaces';
import { ParentalLockPinDialogComponent } from './parental-lock-pin-dialog.component';
describe('ParentalLockPinDialogComponent cooldown', () => {
const throttle = createParentalLockPinThrottle();
const verify = jest.fn(async () => false);
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() } },
],
});
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);
const reopened = openDialog();
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);
});
});
@@ -20,7 +20,9 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import {
createParentalLockPinThrottle,
isValidParentalLockPin,
ParentalLockPinThrottle,
PARENTAL_LOCK_PIN_MAX_LENGTH,
PARENTAL_LOCK_PIN_MIN_LENGTH,
} from '@iptvnator/shared/interfaces';
@@ -31,14 +33,16 @@ export interface ParentalLockPinDialogData {
mode: ParentalLockPinDialogMode;
/** Unlock only: whether the typed PIN is the right one. */
verify?: (pin: string) => Promise<boolean>;
/**
* Unlock only: wrong-PIN count and cooldown owned by the caller, so a
* dismissed and reopened prompt keeps them. A local one is used when
* absent.
*/
throttle?: ParentalLockPinThrottle;
titleKey?: string;
descriptionKey?: string;
}
/** Failed attempts before the prompt pauses for {@link COOLDOWN_MS}. */
const MAX_ATTEMPTS_BEFORE_COOLDOWN = 5;
const COOLDOWN_MS = 30_000;
/**
* PIN prompt for the parental lock. Pure UI: the caller supplies `verify`
* for the unlock mode and receives the accepted PIN (or `undefined` when
@@ -75,7 +79,8 @@ export class ParentalLockPinDialogComponent {
readonly error = signal<string | null>(null);
readonly shake = signal(false);
readonly cooldownUntil = signal(0);
private failedAttempts = 0;
private readonly throttle =
this.data.throttle ?? createParentalLockPinThrottle();
private cooldownTimer: number | null = null;
readonly isSetMode = computed(() => this.data.mode === 'set');
@@ -102,6 +107,15 @@ export class ParentalLockPinDialogComponent {
(!this.isSetMode() || this.confirmation() === this.pin())
);
constructor() {
// Reopened during a cooldown: the pause carries on where it was.
const until = this.throttle.cooldownUntil();
if (until > 0) {
this.startCooldown(until);
this.error.set('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
}
}
static open(
dialog: MatDialog,
data: ParentalLockPinDialogData
@@ -144,14 +158,13 @@ export class ParentalLockPinDialogComponent {
try {
const accepted = (await this.data.verify?.(pin)) === true;
if (accepted) {
this.throttle.recordSuccess();
this.dialogRef.close(pin);
return;
}
this.failedAttempts += 1;
this.pin.set('');
if (this.failedAttempts >= MAX_ATTEMPTS_BEFORE_COOLDOWN) {
this.failedAttempts = 0;
this.startCooldown();
if (this.throttle.recordFailure()) {
this.startCooldown(this.throttle.cooldownUntil());
this.fail('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
} else {
this.fail('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN');
@@ -172,17 +185,20 @@ export class ParentalLockPinDialogComponent {
window.setTimeout(() => this.shake.set(false), 400);
}
private startCooldown(): void {
this.cooldownUntil.set(Date.now() + COOLDOWN_MS);
private startCooldown(until: number): void {
this.cooldownUntil.set(until);
if (this.cooldownTimer !== null) {
window.clearTimeout(this.cooldownTimer);
}
this.cooldownTimer = window.setTimeout(() => {
this.cooldownTimer = null;
// `inCooldown` compares against the clock only when a signal it
// reads changes; resetting the deadline is that change.
this.cooldownUntil.set(0);
this.error.set(null);
}, COOLDOWN_MS);
this.cooldownTimer = window.setTimeout(
() => {
this.cooldownTimer = null;
// `inCooldown` compares against the clock only when a signal it
// reads changes; resetting the deadline is that change.
this.cooldownUntil.set(0);
this.error.set(null);
},
Math.max(0, until - Date.now())
);
}
}