From 10cd201ebf30b34fc2ebb4210a8f4dffb1498693 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 15:20:34 +0200 Subject: [PATCH] fix(settings): keep the relock timeout behind the PIN and bind M3U group lock toggles to their playlist A locked session can no longer change the relock timeout: the Settings selector is disabled until the PIN is entered and the service refuses the change while locked. An M3U right-click lock toggle now captures its playlist before the PIN prompt and is saved only if that playlist is still open. Co-Authored-By: Claude Opus 5.5 --- .../settings-parental-section.component.html | 2 +- docs/architecture/parental-lock.md | 11 +++-- .../parental-lock.service.spec.ts | 14 ++++++ .../parental-lock/parental-lock.service.ts | 5 +++ .../channel-list-container.component.spec.ts | 44 +++++++++++++++++++ .../channel-list-container.component.ts | 29 +++++++++--- .../groups-view/groups-view.component.spec.ts | 12 ++--- .../groups-view/groups-view.component.ts | 10 +++-- 8 files changed, 103 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/settings/settings-parental-section.component.html b/apps/web/src/app/settings/settings-parental-section.component.html index 8405cecbf..6f7118038 100644 --- a/apps/web/src/app/settings/settings-parental-section.component.html +++ b/apps/web/src/app/settings/settings-parental-section.component.html @@ -82,7 +82,7 @@ { expect(service.unlocked()).toBe(true); }); + it('refuses a relock timeout change while the session is locked', async () => { + storage.pinHash = await hashParentalLockPin('1234'); + parentalLockEnabled.set(true); + const service = await createService(); + expect(service.active()).toBe(true); + + await expect(service.setRelockMinutes(0)).resolves.toBe(false); + + expect(updateSettings).not.toHaveBeenCalledWith( + expect.objectContaining({ parentalLockRelockMinutes: 0 }) + ); + expect(service.relockMinutes()).toBe(15); + }); + it('rolls the relock timeout back when it cannot be persisted', async () => { updateSettings.mockImplementationOnce(async () => { parentalLockRelockMinutes.set(30); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index 7099c2865..c291b6d8b 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -360,6 +360,11 @@ export class ParentalLockService { * timer on screen never differs from the one the next launch uses. */ async setRelockMinutes(minutes: number): Promise { + // A locked session must not lengthen or switch off its own relock + // timer (Settings disables the selector until the PIN is entered). + if (this.active()) { + return false; + } return persistParentalLockRelockMinutes( this.settingsStore, minutes, diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index 712abc7cc..83f4d2096 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -15,6 +15,7 @@ import { EpgService } from '@iptvnator/epg/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { ChannelActions, PlaylistActions } from '@iptvnator/m3u-state'; import { + ParentalLockService, PlaylistsService, RuntimeCapabilitiesService, SettingsStore, @@ -221,6 +222,49 @@ describe('ChannelListContainerComponent', () => { expect(component.showChannelViews()).toBe(false); }); + it('drops a group lock toggle whose playlist changed while the PIN was asked', async () => { + const parentalLock = TestBed.inject(ParentalLockService); + const setM3uLocks = jest + .spyOn(parentalLock, 'setM3uLocks') + .mockResolvedValue(true); + jest.spyOn(parentalLock, 'requestUnlock').mockImplementation( + async () => { + activePlaylistSignal.set({ + _id: 'playlist-2', + title: 'Playlist Two', + count: 0, + importDate: '2026-04-11T00:00:00.000Z', + } as PlaylistMeta); + return true; + } + ); + + await fixture.componentInstance.onGroupLockToggled({ + groupKey: 'News', + locked: false, + }); + + expect(setM3uLocks).not.toHaveBeenCalled(); + }); + + it('saves a group lock toggle under the playlist it was requested for', async () => { + const parentalLock = TestBed.inject(ParentalLockService); + const setM3uLocks = jest + .spyOn(parentalLock, 'setM3uLocks') + .mockResolvedValue(true); + jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true); + + await fixture.componentInstance.onGroupLockToggled({ + groupKey: 'News', + locked: true, + }); + + expect(setM3uLocks).toHaveBeenCalledWith( + 'playlist-1', + expect.any(Function) + ); + }); + it('does not clear the shared channel list on destroy', () => { fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index 046ba9d3c..4d5addf82 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -623,22 +623,37 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { await this.saveGroupLocks(lockedGroupTitles); } - /** One group from the right-click menu, applied inside the store's queue. */ + /** + * One group from the right-click menu, behind the PIN, applied inside + * the store's queue. The playlist is captured before the prompt and must + * still be the open one afterwards: two playlists can share a group + * name, and the edit must not land on the other one. + */ async onGroupLockToggled(change: { groupKey: string; locked: boolean; }): Promise { - await this.saveGroupLocks((current) => - change.locked - ? [...new Set([...current, change.groupKey])] - : current.filter((title) => title !== change.groupKey) + const playlistId = this.lockPlaylistId(); + if ( + !playlistId || + !(await this.parentalLock.requestUnlock()) || + this.lockPlaylistId() !== playlistId + ) { + return; + } + await this.saveGroupLocks( + (current) => + change.locked + ? [...new Set([...current, change.groupKey])] + : current.filter((title) => title !== change.groupKey), + playlistId ); } private async saveGroupLocks( - groupTitles: string[] | ((current: readonly string[]) => string[]) + groupTitles: string[] | ((current: readonly string[]) => string[]), + playlistId = this.lockPlaylistId() ): Promise { - const playlistId = this.lockPlaylistId(); if (!playlistId) { return; } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts index ddb7d3a4b..ab8dfd8b9 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.spec.ts @@ -537,7 +537,7 @@ describe('GroupsViewComponent', () => { ]); }); - it('emits a single-group toggle from the right-click menu behind the PIN gate', async () => { + it('emits a single-group toggle from the right-click menu (the host asks for the PIN)', () => { const groupLockToggled = jest.fn(); component.groupLockToggled.subscribe(groupLockToggled); fixture.componentRef.setInput('lockedGroupTitles', ['News']); @@ -546,23 +546,19 @@ describe('GroupsViewComponent', () => { component.onGroupContextMenu('Sports', event); expect(event.defaultPrevented).toBe(true); - await component.onGroupLockToggle(true); + component.onGroupLockToggle(true); expect(groupLockToggled).toHaveBeenLastCalledWith({ groupKey: 'Sports', locked: true, }); component.onGroupContextMenu('News', event); - await component.onGroupLockToggle(false); + component.onGroupLockToggle(false); expect(groupLockToggled).toHaveBeenLastCalledWith({ groupKey: 'News', locked: false, }); - - parentalLock.requestUnlock.mockResolvedValueOnce(false); - component.onGroupContextMenu('Movies', event); - await component.onGroupLockToggle(true); - expect(groupLockToggled).toHaveBeenCalledTimes(2); + expect(parentalLock.requestUnlock).not.toHaveBeenCalled(); }); it('shows the locked-groups row while groups are withheld and unlocks from it', async () => { diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 0b425b675..282839055 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -513,15 +513,17 @@ export class GroupsViewComponent { this.groupLockMenu()?.open(event, locked.includes(groupKey)); } - async onGroupLockToggle(lock: boolean): Promise { + /** + * Emitted at once: the host asks for the PIN itself, after capturing the + * playlist the toggle belongs to — this view does not know it, and the + * user may navigate while the (lazily loaded) prompt is pending. + */ + onGroupLockToggle(lock: boolean): void { const groupKey = this.groupLockKey; this.groupLockKey = null; if (groupKey === null || this.lockedGroupTitles() === null) { return; } - if (!(await this.parentalLock.requestUnlock())) { - return; - } this.groupLockToggled.emit({ groupKey, locked: lock }); }