mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
feat(settings): add PIN-protected parental lock for categories (#285)
Locks are per category (Xtream category ids, Stalker genre ids, M3U group titles) and kept in one renderer lock store persisted to app_state / localStorage; `categories.locked` is the SQLite index re-stamped from it. While the lock is active the DB worker filters every content read, the PWA data source, the Stalker store and the M3U channel list filter in memory, and the enforcement service reloads the stores and steps off withheld selections. Settings → Parental lock sets the PIN (PBKDF2, never in Settings), the relock timeout and Lock now; lock toggles live in the Xtream/M3U management dialogs and a new Stalker lock dialog, all behind the PIN. Backups carry the locks per playlist entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
17b8aa309d
commit
83e70a52c5
130 files changed
+6254
-278
No files matched your search
+37
-3
@@ -78,6 +78,21 @@
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (canLockStalkerCategories()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
data-test-id="context-lock-categories"
|
||||
(click)="openLockStalkerCategories()"
|
||||
[matTooltip]="'PARENTAL_LOCK.MANAGE_LOCKS' | translate"
|
||||
[attr.aria-label]="
|
||||
'PARENTAL_LOCK.MANAGE_LOCKS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>lock</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (canHideCategories()) {
|
||||
<button
|
||||
#hideCategoriesButton
|
||||
@@ -87,9 +102,7 @@
|
||||
data-test-id="context-hide-categories"
|
||||
(click)="hideCategories()"
|
||||
[matTooltip]="'LAYOUT.HIDE_CATEGORIES' | translate"
|
||||
[attr.aria-label]="
|
||||
'LAYOUT.HIDE_CATEGORIES' | translate
|
||||
"
|
||||
[attr.aria-label]="'LAYOUT.HIDE_CATEGORIES' | translate"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
@@ -192,4 +205,25 @@
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@if (withheldCategoryCount() > 0) {
|
||||
<button
|
||||
type="button"
|
||||
class="context-locked-row"
|
||||
data-test-id="context-locked-categories"
|
||||
(click)="requestParentalUnlock()"
|
||||
[attr.aria-label]="'PARENTAL_LOCK.UNLOCK_TO_SHOW' | translate"
|
||||
>
|
||||
<mat-icon>lock</mat-icon>
|
||||
<span class="context-locked-row__label">
|
||||
{{
|
||||
'PARENTAL_LOCK.LOCKED_CATEGORIES_ROW'
|
||||
| translate: { count: withheldCategoryCount() }
|
||||
}}
|
||||
</span>
|
||||
<span class="context-locked-row__hint">
|
||||
{{ 'PARENTAL_LOCK.UNLOCK_TO_SHOW' | translate }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
+45
@@ -263,3 +263,48 @@ app-workspace-context-category-view {
|
||||
transform: translateX(120%);
|
||||
}
|
||||
}
|
||||
|
||||
.context-locked-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px 12px;
|
||||
border: 1px dashed var(--mat-sys-outline-variant);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--mat-sys-surface-container);
|
||||
color: var(--mat-sys-on-surface);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
+85
-8
@@ -29,6 +29,11 @@ import {
|
||||
sortPortalCategoryItems,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
toParentalLockStalkerCategoryType,
|
||||
toParentalLockXtreamCategoryType,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { WorkspaceContextCategoryViewComponent } from './components/workspace-context-category-view.component';
|
||||
import { WorkspaceContextErrorViewComponent } from './components/workspace-context-error-view.component';
|
||||
import { hasActiveLiveCategoryRoute } from './workspace-context-panel-route.utils';
|
||||
@@ -78,10 +83,14 @@ export class WorkspaceContextPanelComponent {
|
||||
// when the panel renders as the phone drawer. Some selections here (e.g.
|
||||
// Stalker ITV/radio) update the store without navigating, so the drawer's
|
||||
// NavigationEnd auto-close never fires for them.
|
||||
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
|
||||
optional: true,
|
||||
});
|
||||
private readonly contextDrawer = inject(
|
||||
WorkspaceShellContextDrawerService,
|
||||
{
|
||||
optional: true,
|
||||
}
|
||||
);
|
||||
private readonly liveSidebarState = inject(LiveLayoutSidebarStateService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
|
||||
readonly context = input.required<WorkspaceContextRoute>();
|
||||
readonly section = input.required<string>();
|
||||
@@ -117,10 +126,9 @@ export class WorkspaceContextPanelComponent {
|
||||
? this.xtreamSelectedCategoryId() !== null
|
||||
: !!this.stalkerSelectedCategoryId())
|
||||
);
|
||||
private readonly hideCategoriesButton = viewChild(
|
||||
'hideCategoriesButton',
|
||||
{ read: ElementRef<HTMLElement> }
|
||||
);
|
||||
private readonly hideCategoriesButton = viewChild('hideCategoriesButton', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
private readonly firstHeaderAction = viewChild('firstHeaderAction', {
|
||||
read: ElementRef<HTMLElement>,
|
||||
});
|
||||
@@ -169,6 +177,39 @@ export class WorkspaceContextPanelComponent {
|
||||
readonly canManageXtreamCategories = computed(
|
||||
() => this.isXtreamCategories() && this.xtreamSelectedTypeCountsReady()
|
||||
);
|
||||
/** Stalker has no hide/show dialog; its lock dialog exists only with the lock on. */
|
||||
readonly canLockStalkerCategories = computed(
|
||||
() =>
|
||||
this.isStalkerCategories() &&
|
||||
this.parentalLock.enabled() &&
|
||||
!this.isStalkerCategoryLoading() &&
|
||||
!this.isStalkerCategoryFailed()
|
||||
);
|
||||
/**
|
||||
* Locked categories of the current section that are currently withheld.
|
||||
* The rail shows one row for them so the parent can unlock from here;
|
||||
* their names never reach the rail while locked.
|
||||
*/
|
||||
readonly withheldCategoryCount = computed(() => {
|
||||
this.parentalLock.version();
|
||||
if (!this.parentalLock.active()) {
|
||||
return 0;
|
||||
}
|
||||
const playlistId = this.context().playlistId;
|
||||
if (this.isXtreamCategories()) {
|
||||
const type = toParentalLockXtreamCategoryType(this.section());
|
||||
return type
|
||||
? this.parentalLock.lockedXtreamIds(playlistId, type).length
|
||||
: 0;
|
||||
}
|
||||
if (this.isStalkerCategories()) {
|
||||
const type = toParentalLockStalkerCategoryType(this.section());
|
||||
return type
|
||||
? this.parentalLock.lockedStalkerIds(playlistId, type).length
|
||||
: 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
readonly xtreamStatusText = computed(() => {
|
||||
if (
|
||||
!this.isXtreamCategories() ||
|
||||
@@ -412,10 +453,46 @@ export class WorkspaceContextPanelComponent {
|
||||
this.categorySort.setMode(mode);
|
||||
}
|
||||
|
||||
openManageCategories(): void {
|
||||
async requestParentalUnlock(): Promise<void> {
|
||||
await this.parentalLock.requestUnlock();
|
||||
}
|
||||
|
||||
async openLockStalkerCategories(): Promise<void> {
|
||||
if (!this.canLockStalkerCategories()) {
|
||||
return;
|
||||
}
|
||||
if (!(await this.parentalLock.requestUnlock())) {
|
||||
return;
|
||||
}
|
||||
const context = this.context();
|
||||
const contentType = toParentalLockStalkerCategoryType(this.section());
|
||||
if (!contentType) {
|
||||
return;
|
||||
}
|
||||
const { StalkerCategoryLockDialogComponent } =
|
||||
await import('@iptvnator/portal/stalker/feature');
|
||||
this.dialog.open(StalkerCategoryLockDialogComponent, {
|
||||
data: {
|
||||
playlistId: context.playlistId,
|
||||
contentType,
|
||||
categories: this.stalkerStore
|
||||
.getAllCategoriesForSelectedType()
|
||||
.filter((category) => String(category.category_id) !== '*'),
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '90vh',
|
||||
});
|
||||
}
|
||||
|
||||
async openManageCategories(): Promise<void> {
|
||||
if (!this.canManageXtreamCategories()) {
|
||||
return;
|
||||
}
|
||||
// Hidden AND locked categories are listed there by name, so the
|
||||
// dialog itself sits behind the PIN while the lock is on.
|
||||
if (!(await this.parentalLock.requestUnlock())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = this.context();
|
||||
const section = this.section();
|
||||
|
||||
+27
@@ -114,6 +114,33 @@
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
@if (parentalLockState() !== 'off') {
|
||||
<button
|
||||
type="button"
|
||||
class="header-shortcut header-parental-lock"
|
||||
mat-icon-button
|
||||
data-test-id="header-parental-lock"
|
||||
[class.is-active]="parentalLockState() === 'locked'"
|
||||
(click)="parentalLockToggleRequested.emit()"
|
||||
[matTooltip]="
|
||||
(parentalLockState() === 'locked'
|
||||
? 'PARENTAL_LOCK.HEADER_UNLOCK'
|
||||
: 'PARENTAL_LOCK.HEADER_LOCK'
|
||||
) | translate
|
||||
"
|
||||
[attr.aria-label]="
|
||||
(parentalLockState() === 'locked'
|
||||
? 'PARENTAL_LOCK.HEADER_UNLOCK'
|
||||
: 'PARENTAL_LOCK.HEADER_LOCK'
|
||||
) | translate
|
||||
"
|
||||
[attr.aria-pressed]="parentalLockState() === 'locked'"
|
||||
>
|
||||
<mat-icon>{{
|
||||
parentalLockState() === 'locked' ? 'lock' : 'lock_open'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (!isSettingsRoute()) {
|
||||
@if (headerSidebarToggle(); as sidebarToggle) {
|
||||
<button
|
||||
|
||||
+6
@@ -78,6 +78,11 @@ export class WorkspaceShellHeaderComponent {
|
||||
* isSettingsRoute computed.
|
||||
*/
|
||||
readonly isSettingsRoute = input(false);
|
||||
/**
|
||||
* Parental lock indicator: `off` hides the button, `locked` offers the
|
||||
* PIN prompt, `unlocked` offers "Lock now".
|
||||
*/
|
||||
readonly parentalLockState = input<'off' | 'locked' | 'unlocked'>('off');
|
||||
/**
|
||||
* Phone-width (≤640px) drawer toggle for the context panel. The button
|
||||
* itself is hidden by CSS above the phone breakpoint, so these inputs
|
||||
@@ -108,6 +113,7 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly headerShortcutRequested = output<void>();
|
||||
readonly headerBulkActionRequested = output<void>();
|
||||
readonly headerSidebarToggleRequested = output<void>();
|
||||
readonly parentalLockToggleRequested = output<void>();
|
||||
readonly refreshPlaylistRequested = output<void>();
|
||||
readonly downloadsRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
|
||||
+30
-4
@@ -20,6 +20,8 @@ export interface CommandBuilderActions {
|
||||
openGlobalRecent: () => void;
|
||||
openDownloadsShortcut: () => void;
|
||||
openAddPlaylistDialog: (kind?: 'url' | 'xtream' | 'stalker') => void;
|
||||
lockParentalLock: () => void;
|
||||
unlockParentalLock: () => void;
|
||||
}
|
||||
|
||||
export interface CommandBuilderContext {
|
||||
@@ -31,6 +33,7 @@ export interface CommandBuilderContext {
|
||||
canRefreshPlaylist: boolean;
|
||||
supportsDownloads: boolean;
|
||||
showDashboard: boolean;
|
||||
parentalLockState: 'off' | 'locked' | 'unlocked';
|
||||
translate: TranslateFn;
|
||||
router: Router;
|
||||
actions: CommandBuilderActions;
|
||||
@@ -148,10 +151,35 @@ export function getGlobalCommandDefinitions(
|
||||
hasSearchablePlaylists,
|
||||
supportsDownloads,
|
||||
showDashboard,
|
||||
parentalLockState,
|
||||
actions,
|
||||
} = ctx;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'parental-lock-now',
|
||||
group: 'global',
|
||||
icon: 'lock',
|
||||
labelKey: 'WORKSPACE.SHELL.COMMANDS.PARENTAL_LOCK_NOW_LABEL',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.PARENTAL_LOCK_NOW_DESCRIPTION',
|
||||
priority: 5,
|
||||
visible: parentalLockState === 'unlocked',
|
||||
keywords: ['parental', 'pin', 'lock'],
|
||||
run: () => actions.lockParentalLock(),
|
||||
},
|
||||
{
|
||||
id: 'parental-unlock',
|
||||
group: 'global',
|
||||
icon: 'lock_open',
|
||||
labelKey: 'WORKSPACE.SHELL.COMMANDS.PARENTAL_UNLOCK_LABEL',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.PARENTAL_UNLOCK_DESCRIPTION',
|
||||
priority: 5,
|
||||
visible: parentalLockState === 'locked',
|
||||
keywords: ['parental', 'pin', 'unlock'],
|
||||
run: () => actions.unlockParentalLock(),
|
||||
},
|
||||
{
|
||||
id: 'global-search',
|
||||
group: 'global',
|
||||
@@ -215,8 +243,7 @@ export function getGlobalCommandDefinitions(
|
||||
group: 'global',
|
||||
icon: 'library_books',
|
||||
labelKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_LABEL',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_DESCRIPTION',
|
||||
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.OPEN_SOURCES_DESCRIPTION',
|
||||
priority: 60,
|
||||
visible: route.kind !== 'sources',
|
||||
run: () => {
|
||||
@@ -241,8 +268,7 @@ export function getGlobalCommandDefinitions(
|
||||
group: 'global',
|
||||
icon: 'add_circle_outline',
|
||||
labelKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_LABEL',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_DESCRIPTION',
|
||||
descriptionKey: 'WORKSPACE.SHELL.COMMANDS.ADD_PLAYLIST_DESCRIPTION',
|
||||
priority: 80,
|
||||
run: () => actions.openAddPlaylistDialog(),
|
||||
},
|
||||
|
||||
+21
@@ -10,6 +10,7 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DownloadsService,
|
||||
ParentalLockService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
@@ -40,6 +41,7 @@ export class WorkspaceShellFacade {
|
||||
);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly downloadsService = inject(DownloadsService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly routeState = inject(WorkspaceShellRouteStateService);
|
||||
private readonly search = inject(WorkspaceShellSearchService);
|
||||
private readonly header = inject(WorkspaceShellHeaderService);
|
||||
@@ -118,6 +120,12 @@ export class WorkspaceShellFacade {
|
||||
readonly searchScopeLabel = this.search.searchScopeLabel;
|
||||
readonly searchStatusLabel = this.search.searchStatusLabel;
|
||||
readonly railProviderClass = this.routeState.railProviderClass;
|
||||
readonly parentalLockState = computed<'off' | 'locked' | 'unlocked'>(() => {
|
||||
if (!this.parentalLock.enabled()) {
|
||||
return 'off';
|
||||
}
|
||||
return this.parentalLock.unlocked() ? 'unlocked' : 'locked';
|
||||
});
|
||||
readonly primaryContextLinks = this.routeState.primaryContextLinks;
|
||||
readonly secondaryContextLinks = this.routeState.secondaryContextLinks;
|
||||
readonly isDownloadsView = this.routeState.isDownloadsView;
|
||||
@@ -246,6 +254,14 @@ export class WorkspaceShellFacade {
|
||||
this.header.refreshCurrentPlaylist();
|
||||
}
|
||||
|
||||
toggleParentalLock(): void {
|
||||
if (this.parentalLock.unlocked()) {
|
||||
this.parentalLock.lock();
|
||||
return;
|
||||
}
|
||||
void this.parentalLock.requestUnlock();
|
||||
}
|
||||
|
||||
private makeCommandBuilderContext(): CommandBuilderContext {
|
||||
return {
|
||||
route: this.currentRoute(),
|
||||
@@ -258,6 +274,7 @@ export class WorkspaceShellFacade {
|
||||
canRefreshPlaylist: this.canRefreshPlaylist(),
|
||||
supportsDownloads: this.supportsDownloads,
|
||||
showDashboard: this.showDashboard(),
|
||||
parentalLockState: this.parentalLockState(),
|
||||
translate: (key, params) => this.translateText(key, params),
|
||||
router: this.router,
|
||||
actions: this.commandBuilderActions,
|
||||
@@ -276,6 +293,10 @@ export class WorkspaceShellFacade {
|
||||
openDownloadsShortcut: () => this.openDownloadsShortcut(),
|
||||
openAddPlaylistDialog: (kind) =>
|
||||
this.header.openAddPlaylistDialog(kind),
|
||||
lockParentalLock: () => this.parentalLock.lock(),
|
||||
unlockParentalLock: () => {
|
||||
void this.parentalLock.requestUnlock();
|
||||
},
|
||||
};
|
||||
|
||||
private translateText(
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
[isDownloadsView]="facade.isDownloadsView()"
|
||||
[activeDownloadsCount]="facade.activeDownloadsCount()"
|
||||
[isSettingsRoute]="facade.isSettingsRoute()"
|
||||
[parentalLockState]="facade.parentalLockState()"
|
||||
[showContextDrawerToggle]="facade.hasContextPanelContent()"
|
||||
[isContextDrawerOpen]="contextDrawer.isOpen()"
|
||||
[contextDrawerToggleAriaKey]="facade.contextDrawerLabelKeys().aria"
|
||||
@@ -61,6 +62,7 @@
|
||||
(headerShortcutRequested)="facade.runHeaderShortcut()"
|
||||
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
|
||||
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
|
||||
(parentalLockToggleRequested)="facade.toggleParentalLock()"
|
||||
(downloadsRequested)="facade.openDownloadsShortcut()"
|
||||
(playlistInfoRequested)="facade.openPlaylistInfo()"
|
||||
(accountInfoRequested)="facade.openAccountInfo()"
|
||||
|
||||
@@ -65,6 +65,7 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly contextDrawerTooltipKey = input('');
|
||||
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly headerSidebarToggle = input<unknown>(null);
|
||||
readonly parentalLockState = input<'off' | 'locked' | 'unlocked'>('off');
|
||||
readonly searchChanged = output<string>();
|
||||
readonly searchSubmitted = output<string>();
|
||||
readonly commandPaletteRequested = output<void>();
|
||||
@@ -75,6 +76,7 @@ class MockWorkspaceShellHeaderComponent {
|
||||
readonly downloadsRequested = output<void>();
|
||||
readonly headerBulkActionRequested = output<void>();
|
||||
readonly headerSidebarToggleRequested = output<void>();
|
||||
readonly parentalLockToggleRequested = output<void>();
|
||||
readonly playlistInfoRequested = output<void>();
|
||||
readonly accountInfoRequested = output<void>();
|
||||
readonly contextDrawerToggleRequested = output<void>();
|
||||
@@ -166,6 +168,8 @@ class MockWorkspaceShellFacade {
|
||||
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
|
||||
readonly headerSidebarToggle = signal(null);
|
||||
toggleLiveSidebar = jest.fn();
|
||||
readonly parentalLockState = signal<'off' | 'locked' | 'unlocked'>('off');
|
||||
toggleParentalLock = jest.fn();
|
||||
readonly showContextPanel = signal(true);
|
||||
readonly hasContextPanelContent = signal(true);
|
||||
readonly contextDrawerLabelKeys = signal({
|
||||
|
||||
Reference in new issue
Block a user