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

@@ -82,7 +82,7 @@
<mat-select
data-test-id="parental-lock-relock"
[value]="relockMinutes()"
[disabled]="busy()"
[disabled]="busy() || !unlocked()"
(selectionChange)="
relockMinutesChanged.emit($event.value)
"
+7 -4
View File
@@ -365,9 +365,10 @@ on either side.
with one "Lock / Unlock category (group)" item, only while the feature
is on. The portal rail persists through
`WorkspaceCategoryLockActionService` (PIN gate, then one-id edit of the
lock store, failure snackbar); the M3U rail emits the edited list to
`ChannelListContainerComponent`, which persists it like the dialog's
result. Bulk actions stay in the dialog, and while the session is locked
lock store, failure snackbar); the M3U rail emits the toggled group to
`ChannelListContainerComponent`, which captures the playlist, asks for
the PIN, and persists the edit only if that playlist is still open (two
playlists can share a group name). Bulk actions stay in the dialog, and while the session is locked
a locked category is not in the rail, so unlocking always goes through
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
hands its lock list back to `ChannelListContainerComponent`, which
@@ -378,7 +379,9 @@ on either side.
leaving every category unlocked. The relock timeout persists through the
same undo-on-failure pattern as the switch (`setRelockMinutes` reverts
the in-memory value and the facade shows the settings save-failure
snackbar).
snackbar). A locked session cannot change it: the selector is disabled
until the PIN is entered, and `setRelockMinutes` refuses while `active`,
so a child cannot switch the idle relock off for the next unlock.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
@@ -355,6 +355,20 @@ describe('ParentalLockService', () => {
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);
@@ -360,6 +360,11 @@ export class ParentalLockService {
* timer on screen never differs from the one the next launch uses.
*/
async setRelockMinutes(minutes: number): Promise<boolean> {
// 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,
@@ -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 });
}