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

@@ -41,6 +41,7 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
{
mode: request.mode,
verify: request.verify,
throttle: request.throttle,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
}
+10 -1
View File
@@ -89,6 +89,10 @@ refresh deletes and re-inserts the rows.
reached through the `PARENTAL_LOCK_PROMPT` token, provided by the app
(`AppParentalLockPromptService` → `ParentalLockPinDialogComponent` in
`libs/ui/components`), so the data-access lib stays free of UI.
Five wrong PINs pause the prompt for 30 seconds. The count and the
pause live in the service (`createParentalLockPinThrottle`, handed to
every unlock prompt), so dismissing the dialog and opening it again
does not reset them.
The renderer reports `active` to the main process over
`PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`),
@@ -431,7 +435,12 @@ never runs against the empty fail-closed snapshot.
A backup carrying lock lists replaces the matching playlists' locks,
possibly with an emptier set, so the import asks for the PIN first (after
the file was chosen) and aborts when it is refused. Locks are restored LAST
the file was chosen) and aborts when it is refused. That answer can go
stale during a long import (idle relock, "Lock now"), so each merge asks
again right before it replaces locks if the app has relocked; a refusal
fails that entry and keeps its previous locks. A newly created playlist
starts without locks, so its restore can only add some and is not asked
again. Locks are restored LAST
for each entry, after the Xtream data restore, so a
failed merge leaves the playlist's previous locks in place. M3U group titles
travel verbatim (`normalizeParentalLockGroupTitles`, exact
@@ -1,4 +1,5 @@
import { InjectionToken } from '@angular/core';
import type { ParentalLockPinThrottle } from '@iptvnator/shared/interfaces';
export type ParentalLockPromptMode = 'unlock' | 'set';
@@ -10,6 +11,11 @@ export interface ParentalLockPromptRequest {
* asking on a mismatch and applies its own attempt cooldown.
*/
verify?: (pin: string) => Promise<boolean>;
/**
* Unlock only: the wrong-PIN count and cooldown, owned by the lock
* service so they survive the prompt being dismissed and reopened.
*/
throttle?: ParentalLockPinThrottle;
/** Optional translation key overriding the mode's default title. */
titleKey?: string;
/** Optional translation key overriding the mode's default description. */
@@ -7,6 +7,7 @@ import {
untracked,
} from '@angular/core';
import {
createParentalLockPinThrottle,
hashParentalLockPin,
normalizeParentalLockRelockMinutes,
ParentalLockPlaylistLocks,
@@ -55,6 +56,8 @@ export class ParentalLockService {
private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true });
private readonly unlockedState = signal(false);
/** Shared by every unlock prompt: a reopened dialog keeps the cooldown. */
private readonly pinThrottle = createParentalLockPinThrottle();
private readonly pinHash = signal<string | null>(null);
/** The PIN hash could not be read; retried before PIN-protected steps. */
private readonly pinUnreadable = signal(false);
@@ -219,6 +222,7 @@ export class ParentalLockService {
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle: this.pinThrottle,
...options,
});
if (pin === null) {
@@ -320,6 +324,7 @@ export class ParentalLockService {
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',
});
@@ -1186,6 +1186,20 @@ export class PlaylistBackupService {
};
}
// A merge REPLACES the playlist's locks, possibly with fewer. The PIN
// asked at the start may be stale by now (idle relock or "Lock now"
// during a long import): ask again if the session relocked. A new
// playlist starts without locks, so its restore can only add some.
if (
next &&
isMerge &&
!staleOnNewId &&
!(await this.parentalLock.requestUnlock())
) {
throw new PlaylistBackupError(
`Restoring the parental locks for "${playlistId}" needs the parental PIN: the app locked again during the restore.`
);
}
if (
next &&
!(await this.parentalLock.replacePlaylistLocks(playlistId, next))
@@ -195,6 +195,45 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
).not.toHaveBeenCalled();
});
it('asks again before a merge replaces locks once the app relocked mid-import', async () => {
const collaborators = createRestoreCollaborators();
// Accepted at the start, refused after an idle relock or "Lock now"
// landed while the import was running.
const requestUnlock = jest
.fn()
.mockResolvedValueOnce(true)
.mockResolvedValue(false);
const replacePlaylistLocks = jest.fn().mockResolvedValue(true);
const service = createPlaylistBackupService({
...collaborators,
parentalLock: {
initialize: jest.fn().mockResolvedValue(undefined),
locksReadable: jest.fn(() => true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
requestUnlock,
locksFor: jest.fn(() => ({
xtream: [{ categoryType: 'live', xtreamId: 1 }],
stalker: [],
m3u: [],
})),
replacePlaylistLocks,
},
});
const manifest = createXtreamManifest([]);
(
manifest.playlists[0].userState as { lockedCategories?: unknown }
).lockedCategories = [];
const summary = await service.importBackup(JSON.stringify(manifest));
expect(requestUnlock).toHaveBeenCalledTimes(2);
expect(replacePlaylistLocks).not.toHaveBeenCalled();
expect(summary).toEqual(
expect.objectContaining({ merged: 0, failed: 1 })
);
expect(summary.errors[0]).toMatch(/locked again/);
});
it('rejects a damaged parental lock list instead of erasing the persisted locks', async () => {
const collaborators = createRestoreCollaborators();
const service = createPlaylistBackupService(collaborators);
+1
View File
@@ -29,6 +29,7 @@ export * from './lib/language.enum';
export * from './lib/m3u-favorite-channel.interface';
export * from './lib/parental-lock.util';
export * from './lib/parental-lock-pin.util';
export * from './lib/parental-lock-pin-throttle.util';
export * from './lib/parsed-playlist.interface';
export * from './lib/performance-phase.interface';
export * from './lib/playback-position.interface';
@@ -0,0 +1,34 @@
import {
createParentalLockPinThrottle,
PARENTAL_LOCK_PIN_COOLDOWN_MS,
PARENTAL_LOCK_PIN_MAX_ATTEMPTS,
} from './parental-lock-pin-throttle.util';
describe('createParentalLockPinThrottle', () => {
it('starts a cooldown after the maximum failures and ends it on time', () => {
let clock = 1_000;
const throttle = createParentalLockPinThrottle(() => clock);
for (let i = 1; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
expect(throttle.recordFailure()).toBe(false);
}
expect(throttle.cooldownUntil()).toBe(0);
expect(throttle.recordFailure()).toBe(true);
expect(throttle.cooldownUntil()).toBe(
1_000 + PARENTAL_LOCK_PIN_COOLDOWN_MS
);
clock += PARENTAL_LOCK_PIN_COOLDOWN_MS;
expect(throttle.cooldownUntil()).toBe(0);
});
it('clears the count and the cooldown after the right PIN', () => {
const throttle = createParentalLockPinThrottle(() => 0);
for (let i = 1; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
throttle.recordFailure();
}
throttle.recordSuccess();
expect(throttle.recordFailure()).toBe(false);
expect(throttle.cooldownUntil()).toBe(0);
});
});
@@ -0,0 +1,40 @@
/** Failed PIN attempts before the prompt pauses for the cooldown. */
export const PARENTAL_LOCK_PIN_MAX_ATTEMPTS = 5;
export const PARENTAL_LOCK_PIN_COOLDOWN_MS = 30_000;
/**
* Wrong-PIN bookkeeping that outlives a single PIN dialog. Kept by the lock
* service and handed to every unlock prompt, so dismissing the dialog during
* a cooldown and opening it again neither resets the count nor the pause.
*/
export interface ParentalLockPinThrottle {
/** Epoch milliseconds until which no PIN may be tried; 0 when none. */
cooldownUntil(): number;
/** Records a wrong PIN; true when this failure starts a cooldown. */
recordFailure(): boolean;
/** Clears the count after the right PIN. */
recordSuccess(): void;
}
export function createParentalLockPinThrottle(
now: () => number = Date.now
): ParentalLockPinThrottle {
let failedAttempts = 0;
let cooldownUntil = 0;
return {
cooldownUntil: () => (cooldownUntil > now() ? cooldownUntil : 0),
recordFailure: () => {
failedAttempts += 1;
if (failedAttempts < PARENTAL_LOCK_PIN_MAX_ATTEMPTS) {
return false;
}
failedAttempts = 0;
cooldownUntil = now() + PARENTAL_LOCK_PIN_COOLDOWN_MS;
return true;
},
recordSuccess: () => {
failedAttempts = 0;
cooldownUntil = 0;
},
};
}
@@ -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())
);
}
}