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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 15:20:34 +02:00
1 parent 067f920833
commit 10cd201ebf
8 files changed
+103 -24

No files matched your search

@@ -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();
@@ -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<void> {
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<void> {
const playlistId = this.lockPlaylistId();
if (!playlistId) {
return;
}
@@ -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 () => {
@@ -513,15 +513,17 @@ export class GroupsViewComponent {
this.groupLockMenu()?.open(event, locked.includes(groupKey));
}
async onGroupLockToggle(lock: boolean): Promise<void> {
/**
* 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 });
}