mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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:
1 parent
451b373b1d
commit
8ad8907ef9
11 files changed
+237
-19
No files matched your search
+53
@@ -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);
|
||||
});
|
||||
});
|
||||
+34
-18
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user