mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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)
|
||||
"
|
||||
|
||||
@@ -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,
|
||||
|
||||
+44
@@ -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();
|
||||
|
||||
|
||||
+22
-7
@@ -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;
|
||||
}
|
||||
|
||||
+4
-8
@@ -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 () => {
|
||||
|
||||
+6
-4
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user