fix(settings): harden the parental lock after review

- The M3U group dialog opens only after the PIN, like the Xtream and Stalker
  dialogs: it lists locked group names and can rewrite the locks.
- Change PIN and Disable always verify the stored hash, even while the
  session is unlocked, so an app left unlocked cannot lose its lock.
- Stalker paging judges progress on the raw portal page: withheld ids the
  list has not seen count as progress, a page made only of locked rows
  requests the next one itself, and the VOD total is reduced by withheld
  ids so the grid stops asking once every visible row is in.
- Parental lock contract linked from the agent context map after the
  guidance reorganization; bridge helpers split out to stay under the
  file-size cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:17:50 +02:00
1 parent ae5d3f49de
commit 299a162416
32 files changed
+414 -89

No files matched your search

@@ -5,6 +5,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { ParentalLockService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { GroupManagementDialogComponent } from './group-management-dialog/group-management-dialog.component';
@@ -46,6 +47,7 @@ describe('GroupsViewComponent', () => {
let fixture: ComponentFixture<GroupsViewComponent>;
let component: GroupsViewComponent;
let dialog: { open: jest.Mock };
let parentalLock: { requestUnlock: jest.Mock };
const sportsCenter = createChannel(
'sports-1',
@@ -102,6 +104,8 @@ describe('GroupsViewComponent', () => {
beforeEach(async () => {
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
parentalLock = { requestUnlock: jest.fn().mockResolvedValue(true) };
dialog = {
open: jest.fn(),
};
@@ -117,6 +121,10 @@ describe('GroupsViewComponent', () => {
provide: MatDialog,
useValue: dialog,
},
{
provide: ParentalLockService,
useValue: parentalLock,
},
],
}).compileComponents();
@@ -498,14 +506,14 @@ describe('GroupsViewComponent', () => {
).toEqual(['Movie Classic']);
});
it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', () => {
it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', async () => {
const hiddenGroupTitlesChanged = jest.fn();
component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged);
dialog.open.mockReturnValue({
afterClosed: () => of({ hiddenGroupTitles: ['News', 'Sports'] }),
});
component.openGroupManagement();
await component.openGroupManagement();
expect(dialog.open).toHaveBeenCalledWith(
GroupManagementDialogComponent,
@@ -529,6 +537,15 @@ describe('GroupsViewComponent', () => {
]);
});
it('keeps the manage-groups dialog closed when the parental PIN is refused', async () => {
// The dialog names every locked group and can rewrite the locks.
parentalLock.requestUnlock.mockResolvedValueOnce(false);
await component.openGroupManagement();
expect(dialog.open).not.toHaveBeenCalled();
});
it('drops the selected-group header in compact mode but keeps the groups rail header', () => {
fixture.componentRef.setInput('showHeader', false);
fixture.detectChanges();
@@ -22,7 +22,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { SettingsStore } from '@iptvnator/services';
import { ParentalLockService, SettingsStore } from '@iptvnator/services';
import {
foldSearchText,
Channel,
@@ -84,6 +84,7 @@ interface FilteredGroupView {
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly parentalLock = inject(ParentalLockService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
@@ -488,7 +489,13 @@ export class GroupsViewComponent {
this.localGroupSearchTerm.set(value);
}
openGroupManagement(): void {
async openGroupManagement(): Promise<void> {
// The dialog lists every group by name, locked ones included, and can
// rewrite the locks — so it sits behind the PIN like the portal
// dialogs do.
if (!(await this.parentalLock.requestUnlock())) {
return;
}
const groups =
this.managementGroups() ??
this.allGroups().map<GroupManagementDialogGroup>(