diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 1b8e33209..0dcb736d3 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -375,7 +375,12 @@ on either side. playlists can share a group name). The M3U management dialog is bound the same way: `GroupsViewComponent` captures its `playlistId` input before the PIN and drops the dialog's result once another playlist is - open. Bulk actions stay in the dialog, and while the session is locked + open. Every bulk lock editor (Xtream and M3U management dialogs, the + Stalker lock dialog) builds its draft only from a lock store that was + read (`ensureLocksReadable`): a draft taken from the empty fail-closed + snapshot would erase the real locks on Save if storage recovered in + between. The M3U and Xtream dialogs then open without lock toggles; the + Stalker dialog does not open. 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 diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts index 0501c2805..6f50594b7 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.spec.ts @@ -42,6 +42,7 @@ describe('CategoryManagementDialogComponent', () => { active: signal(false), lockedXtreamIds: jest.fn(() => [] as number[]), setXtreamLocks: jest.fn(), + ensureLocksReadable: jest.fn(async () => true), }; const data: CategoryManagementDialogData = { playlistId: 'mock-playlist', @@ -210,6 +211,7 @@ describe('CategoryManagementDialogComponent', () => { it('drops the lock draft when the session relocks during a pending save', async () => { parentalLock.enabled.set(true); + component.showLocks.set(true); fixture.detectChanges(); let finishVisibility: () => void = () => undefined; db.updateCategoryVisibility.mockImplementationOnce( @@ -225,6 +227,18 @@ describe('CategoryManagementDialogComponent', () => { expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled(); }); + it('offers no lock toggles, and saves no locks, while the lock store is unreadable', async () => { + parentalLock.enabled.set(true); + parentalLock.ensureLocksReadable.mockResolvedValueOnce(false); + await component.ngOnInit(); + expect(component.showLocks()).toBe(false); + + await component.save(); + + expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + it('discards pending bulk changes on cancel', () => { component.searchTerm.set('FR'); component.selectAll(); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts index ceddfd01a..396aec19b 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.ts @@ -74,7 +74,12 @@ export class CategoryManagementDialogComponent implements OnInit { private readonly logger = createLogger('CategoryManagementDialog'); /** Lock toggles exist only while the parental lock feature is on. */ - readonly showLocks = this.parentalLock.enabled; + /** + * Lock toggles, only once the lock store has been read: a draft built + * from the empty fail-closed snapshot would, if storage recovered by + * Save, replace the real locks with nothing. + */ + readonly showLocks = signal(false); readonly lockedCount = computed( () => this.categories().filter((c) => c.lockedDraft).length ); @@ -126,6 +131,10 @@ export class CategoryManagementDialogComponent implements OnInit { private async loadCategories(): Promise { try { const type = this.getDbType(); + this.showLocks.set( + this.parentalLock.enabled() && + (await this.parentalLock.ensureLocksReadable()) + ); const allCategories = await this.waitForCategories(type); // The renderer's lock store is authoritative; the row's `locked` // column is only its SQLite mirror. 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 957cab029..d2ed2b48c 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 @@ -47,7 +47,11 @@ describe('GroupsViewComponent', () => { let fixture: ComponentFixture; let component: GroupsViewComponent; let dialog: { open: jest.Mock }; - let parentalLock: { requestUnlock: jest.Mock }; + let parentalLock: { + requestUnlock: jest.Mock; + ensureLocksReadable: jest.Mock; + lockedGroupTitles: jest.Mock; + }; const sportsCenter = createChannel( 'sports-1', @@ -104,7 +108,11 @@ describe('GroupsViewComponent', () => { beforeEach(async () => { localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY); - parentalLock = { requestUnlock: jest.fn().mockResolvedValue(true) }; + parentalLock = { + requestUnlock: jest.fn().mockResolvedValue(true), + ensureLocksReadable: jest.fn().mockResolvedValue(true), + lockedGroupTitles: jest.fn(() => ['News']), + }; dialog = { open: jest.fn(), @@ -537,6 +545,19 @@ describe('GroupsViewComponent', () => { ]); }); + it('opens the manage-groups dialog without lock toggles while the lock store is unreadable', async () => { + fixture.componentRef.setInput('playlistId', 'playlist-a'); + fixture.componentRef.setInput('lockedGroupTitles', []); + fixture.detectChanges(); + parentalLock.ensureLocksReadable.mockResolvedValue(false); + dialog.open.mockReturnValue({ afterClosed: () => of(undefined) }); + + await component.openGroupManagement(); + + const data = dialog.open.mock.calls[0][1].data; + expect(data.lockedGroupTitles).toBeUndefined(); + }); + it('drops the manage-groups result once another playlist is open', async () => { const hiddenGroupTitlesChanged = jest.fn(); const lockedGroupTitlesChanged = jest.fn(); 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 a4d4f6369..0266868ce 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 @@ -556,7 +556,17 @@ export class GroupsViewComponent { count, }) ); - const lockedGroupTitles = this.lockedGroupTitles(); + // Lock toggles only from a lock store that was read: a draft built + // from the empty fail-closed snapshot would, if storage recovered by + // Save, replace the real locks with nothing. Hidden groups stay + // editable either way. + const lockedGroupTitles = + this.lockedGroupTitles() !== null && + playlistId && + (await this.parentalLock.ensureLocksReadable()) && + this.playlistId() === playlistId + ? this.parentalLock.lockedGroupTitles(playlistId) + : null; const dialogRef = this.dialog.open(GroupManagementDialogComponent, { data: { groups, diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index ad75e6ce9..61269041e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -967,6 +967,9 @@ describe('WorkspaceContextPanelComponent', () => { value: signal(true), }); jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true); + jest.spyOn(parentalLock, 'ensureLocksReadable').mockResolvedValue( + true + ); } it('opens with the categories snapshotted before the lazy import', async () => { @@ -1027,6 +1030,28 @@ describe('WorkspaceContextPanelComponent', () => { expect(dialog.open).not.toHaveBeenCalled(); }); + it('opens nothing while the lock store cannot be read', async () => { + enableLock(); + jest.spyOn( + TestBed.inject(ParentalLockService), + 'ensureLocksReadable' + ).mockResolvedValue(false); + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + fixture.detectChanges(); + const component = fixture.componentInstance; + component.loadStalkerLockDialog = jest.fn( + async () => class DialogStub {} + ); + + await component.openManageStalkerCategories(); + + expect(dialog.open).not.toHaveBeenCalled(); + }); + it('opens nothing when the route changed during the lazy import', async () => { enableLock(); fixture.componentRef.setInput('context', { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index ced48da18..ee84057d5 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -548,6 +548,11 @@ export class WorkspaceContextPanelComponent { if (!contentType || !(await this.parentalLock.requestUnlock())) { return; } + // The dialog's draft is the lock store's list: built from the empty + // fail-closed snapshot it would erase the real locks on Save. + if (!unchanged() || !(await this.parentalLock.ensureLocksReadable())) { + return; + } if (!unchanged()) { return; }