From df588b88459612caf8fcb01a61a145ae10a519f6 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 20 Feb 2026 20:49:08 +0100 Subject: [PATCH] refactor: extract cuesheet permissions --- .../views/cuesheet/CuesheetTableWrapper.tsx | 47 +++------- .../__tests__/cuesheet.policies.test.ts | 94 +++++++++++++++++++ .../cuesheet/cuesheet-table/CuesheetTable.tsx | 45 ++++++--- .../cuesheetColsFactory.tsx | 11 +-- .../CuesheetTableSettings.tsx | 16 ++-- .../src/views/cuesheet/cuesheet.policies.ts | 65 +++++++++++++ .../views/cuesheet/useApplyCuesheetPolicy.ts | 55 +++++++++++ .../views/cuesheet/useTablePermissions.tsx | 4 +- e2e/tests/features/206-url-preset.spec.ts | 28 ++++-- 9 files changed, 290 insertions(+), 75 deletions(-) create mode 100644 apps/client/src/views/cuesheet/__tests__/cuesheet.policies.test.ts create mode 100644 apps/client/src/views/cuesheet/cuesheet.policies.ts create mode 100644 apps/client/src/views/cuesheet/useApplyCuesheetPolicy.ts diff --git a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx index 6c421cf3a..f59f06a74 100644 --- a/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx +++ b/apps/client/src/views/cuesheet/CuesheetTableWrapper.tsx @@ -1,49 +1,19 @@ -import { memo, use, useEffect, useMemo } from 'react'; -import { useSessionStorage } from '@mantine/hooks'; +import { memo, use, useMemo } from 'react'; import EmptyPage from '../../common/components/state/EmptyPage'; import { PresetContext } from '../../common/context/PresetContext'; import useCustomFields from '../../common/hooks-query/useCustomFields'; -import { sessionScope } from '../../externals'; -import { AppMode, sessionKeys } from '../../ontimeConfig'; import CuesheetDnd from './cuesheet-dnd/CuesheetDnd'; import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory'; import CuesheetTable from './cuesheet-table/CuesheetTable'; -import { useCuesheetPermissions } from './useTablePermissions'; +import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy'; export default memo(CuesheetTableWrapper); function CuesheetTableWrapper() { const { data: customFields, status: customFieldStatus } = useCustomFields(); - const setPermissions = useCuesheetPermissions((state) => state.setPermissions); const preset = use(PresetContext); - - // set permissions based on preset - useEffect(() => { - if (preset) { - const fullWrite = preset.options?.write === 'full'; - setPermissions({ - canChangeMode: preset.options?.write !== '-', - canCreateEntries: fullWrite, - canEditEntries: fullWrite, - canFlag: fullWrite || Boolean(preset.options?.write.includes('flag')), - canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes - }); - } else { - setPermissions({ - canChangeMode: true, - canCreateEntries: true, - canEditEntries: true, - canFlag: true, - canShare: sessionScope === 'rw', - }); - } - }, [preset, setPermissions]); - - const [cuesheetMode] = useSessionStorage({ - key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode, - defaultValue: preset ? AppMode.Run : AppMode.Edit, - }); + const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset); const columns = useMemo( () => makeCuesheetColumns(customFields, cuesheetMode, preset), @@ -54,7 +24,16 @@ function CuesheetTableWrapper() { return ( - {isLoading ? : } + {isLoading ? ( + + ) : ( + + )} ); } diff --git a/apps/client/src/views/cuesheet/__tests__/cuesheet.policies.test.ts b/apps/client/src/views/cuesheet/__tests__/cuesheet.policies.test.ts new file mode 100644 index 000000000..2444460f9 --- /dev/null +++ b/apps/client/src/views/cuesheet/__tests__/cuesheet.policies.test.ts @@ -0,0 +1,94 @@ +import { OntimeView, URLPreset } from 'ontime-types'; + +import { AppMode } from '../../../ontimeConfig'; +import { getCuesheetColumnAccessPolicy, getCuesheetPermissionsPolicy } from '../cuesheet.policies'; + +describe('getCuesheetPermissionsPolicy()', () => { + test('returns full permissions when there is no preset', () => { + expect(getCuesheetPermissionsPolicy(undefined, true)).toEqual({ + canChangeMode: true, + canCreateEntries: true, + canEditEntries: true, + canFlag: true, + canShare: true, + }); + }); + + test('returns run-only permissions when write is disabled', () => { + const preset: URLPreset = { + enabled: true, + alias: 'cuesheet-read-only', + target: OntimeView.Cuesheet, + search: '', + options: { + read: 'full', + write: '-', + }, + }; + + expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({ + canChangeMode: false, + canCreateEntries: false, + canEditEntries: false, + canFlag: false, + canShare: false, + }); + }); + + test('allows flag changes when write includes flag only', () => { + const preset: URLPreset = { + enabled: true, + alias: 'cuesheet-flag', + target: OntimeView.Cuesheet, + search: '', + options: { + read: 'full', + write: 'flag', + }, + }; + + expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({ + canChangeMode: true, + canCreateEntries: false, + canEditEntries: false, + canFlag: true, + canShare: false, + }); + }); + + test('defaults to full read and write when cuesheet options are absent', () => { + const preset: URLPreset = { + enabled: true, + alias: 'cuesheet-default', + target: OntimeView.Cuesheet, + search: '', + }; + + const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit); + + expect(policy.canRead('title')).toBe(true); + expect(policy.canWrite('title')).toBe(true); + }); + + test('column access honors granular permissions and mode', () => { + const preset: URLPreset = { + enabled: true, + alias: 'cuesheet-granular', + target: OntimeView.Cuesheet, + search: '', + options: { + read: 'cue,title', + write: 'title', + }, + }; + + const editPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit); + const runPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Run); + + expect(editPolicy.canRead('cue')).toBe(true); + expect(editPolicy.canRead('duration')).toBe(false); + expect(editPolicy.canWrite('title')).toBe(true); + expect(editPolicy.canWrite('cue')).toBe(false); + expect(runPolicy.canWrite('title')).toBe(false); + }); +}); diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index c5af0b587..d89024cc7 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -34,13 +34,24 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn import style from './CuesheetTable.module.scss'; -interface CuesheetTableProps { +type CuesheetTableBaseProps = { columns: ColumnDef[]; cuesheetMode: AppMode; - tableRoot?: 'editor' | 'cuesheet'; -} +}; -export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) { +type EditorCuesheetTableProps = CuesheetTableBaseProps & { + tableRoot: 'editor'; + setCuesheetMode?: undefined; +}; + +type ViewCuesheetTableProps = CuesheetTableBaseProps & { + tableRoot: 'cuesheet'; + setCuesheetMode: (mode: AppMode) => void; +}; + +type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps; + +export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) { const { data, status } = useFlatRundownWithMetadata(); const { updateEntry, updateTimer } = useEntryActionsContext(); @@ -206,17 +217,25 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues return ; } - // control components need different implementations for handling permissions - const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings; - return ( <> - + {tableRoot === 'editor' ? ( + + ) : ( + + )} [] { const columnsDef: ColumnDef[] = []; - const modeAllowsWrite = cuesheetMode === AppMode.Edit; - const fullRead = preset ? preset.options?.read === 'full' : true; - const fullWrite = preset ? preset.options?.write === 'full' : true; - const canWriteKeys = preset?.options?.write ? new Set(preset.options.write.split(',')) : new Set(); - const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set(); - - // helpers to check read/write for a given key - const canRead = (key: string) => fullRead || canReadKeys.has(key); - const canWrite = (key: string) => modeAllowsWrite && (fullWrite || canWriteKeys.has(key)); + const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode); if (canRead('flag')) { columnsDef.push({ diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx index 11dc17fcd..02c6c17c3 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -1,20 +1,18 @@ -import { ReactNode, use } from 'react'; +import { ReactNode } from 'react'; import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5'; import { Popover } from '@base-ui/react/popover'; import { Toggle } from '@base-ui/react/toggle'; import { ToggleGroup } from '@base-ui/react/toggle-group'; import { Toolbar } from '@base-ui/react/toolbar'; -import { useSessionStorage } from '@mantine/hooks'; import type { Column } from '@tanstack/react-table'; import Button from '../../../../common/components/buttons/Button'; import Checkbox from '../../../../common/components/checkbox/Checkbox'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; import PopoverContents from '../../../../common/components/popover/Popover'; -import { PresetContext } from '../../../../common/context/PresetContext'; import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata'; import { cx } from '../../../../common/utils/styleUtils'; -import { AppMode, sessionKeys } from '../../../../ontimeConfig'; +import { AppMode } from '../../../../ontimeConfig'; import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options'; import { useCuesheetPermissions } from '../../useTablePermissions'; @@ -24,6 +22,8 @@ import style from './CuesheetTableSettings.module.scss'; interface CuesheetTableSettingsProps { columns: Column[]; + cuesheetMode: AppMode; + setCuesheetMode: (mode: AppMode) => void; handleResetResizing: () => void; handleResetReordering: () => void; handleClearToggles: () => void; @@ -42,20 +42,16 @@ export interface ColumnSettingsProps { export default function CuesheetTableSettings({ columns, + cuesheetMode, + setCuesheetMode, handleResetResizing, handleResetReordering, handleClearToggles, }: CuesheetTableSettingsProps) { const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode); const canShare = useCuesheetPermissions((state) => state.canShare); - const preset = use(PresetContext); const options = usePersistedCuesheetOptions(); - const [cuesheetMode, setCuesheetMode] = useSessionStorage({ - key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode, - defaultValue: preset ? AppMode.Run : AppMode.Edit, - }); - const toggleCuesheetMode = (mode: AppMode[]) => { // we need to stop user from deselecting a mode const newValue = mode.at(0); diff --git a/apps/client/src/views/cuesheet/cuesheet.policies.ts b/apps/client/src/views/cuesheet/cuesheet.policies.ts new file mode 100644 index 000000000..a36de6a76 --- /dev/null +++ b/apps/client/src/views/cuesheet/cuesheet.policies.ts @@ -0,0 +1,65 @@ +import { URLPreset } from 'ontime-types'; + +import { AppMode } from '../../ontimeConfig'; + +import type { CuesheetPermissions } from './useTablePermissions'; + +function getPermissionKeys(permission: string | undefined): Set { + return permission ? new Set(permission.split(',')) : new Set(); +} + +function getCuesheetPermissions(readPermission: string | undefined, writePermission: string | undefined) { + const readKeys = getPermissionKeys(readPermission); + const writeKeys = getPermissionKeys(writePermission); + const fullRead = readPermission == null || readPermission === 'full'; + const fullWrite = writePermission == null || writePermission === 'full'; + + return { + writePermission, + readKeys, + writeKeys, + fullRead, + fullWrite, + }; +} + +export function getCuesheetPermissionsPolicy( + preset: URLPreset | undefined, + canShareInSession: boolean, +): CuesheetPermissions { + if (!preset) { + return { + canChangeMode: true, + canCreateEntries: true, + canEditEntries: true, + canFlag: true, + canShare: canShareInSession, + }; + } + + const { writePermission, writeKeys, fullWrite } = getCuesheetPermissions( + preset?.options?.read, + preset?.options?.write, + ); + + return { + canChangeMode: writePermission !== '-', + canCreateEntries: fullWrite, + canEditEntries: fullWrite, + canFlag: fullWrite || writeKeys.has('flag'), + canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes + }; +} + +export function getCuesheetColumnAccessPolicy(preset: URLPreset | undefined, cuesheetMode: AppMode) { + const { readKeys, writeKeys, fullRead, fullWrite } = getCuesheetPermissions( + preset?.options?.read, + preset?.options?.write, + ); + const modeAllowsWrite = cuesheetMode === AppMode.Edit; + + return { + canRead: (key: string) => fullRead || readKeys.has(key), + canWrite: (key: string) => modeAllowsWrite && (fullWrite || writeKeys.has(key)), + }; +} diff --git a/apps/client/src/views/cuesheet/useApplyCuesheetPolicy.ts b/apps/client/src/views/cuesheet/useApplyCuesheetPolicy.ts new file mode 100644 index 000000000..71b2b3c91 --- /dev/null +++ b/apps/client/src/views/cuesheet/useApplyCuesheetPolicy.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useSessionStorage } from '@mantine/hooks'; +import { URLPreset } from 'ontime-types'; + +import { sessionScope } from '../../externals'; +import { AppMode, sessionKeys } from '../../ontimeConfig'; + +import { getCuesheetPermissionsPolicy } from './cuesheet.policies'; +import { useCuesheetPermissions } from './useTablePermissions'; + +/** + * Applies cuesheet permissions to shared state and exposes the effective mode for the UI. + */ +export function useApplyCuesheetPolicy(preset: URLPreset | undefined): { + cuesheetMode: AppMode; + setCuesheetMode: (mode: AppMode) => void; +} { + const setPermissions = useCuesheetPermissions((state) => state.setPermissions); + const canShareInSession = sessionScope === 'rw'; + const permissions = useMemo( + () => getCuesheetPermissionsPolicy(preset, canShareInSession), + [preset, canShareInSession], + ); + + const [storedCuesheetMode, setStoredCuesheetMode] = useSessionStorage({ + key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode, + defaultValue: preset ? AppMode.Run : AppMode.Edit, + }); + + const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run; + const setCuesheetMode = useCallback( + (mode: AppMode) => { + if (!permissions.canChangeMode) { + return; + } + + setStoredCuesheetMode(mode); + }, + [permissions.canChangeMode, setStoredCuesheetMode], + ); + + // Keep the shared permissions store aligned with the active preset policy. + useEffect(() => { + setPermissions(permissions); + }, [permissions, setPermissions]); + + // Force Run mode whenever the policy forbids mode switching. + useEffect(() => { + if (!permissions.canChangeMode) { + setStoredCuesheetMode((mode) => (mode === AppMode.Run ? mode : AppMode.Run)); + } + }, [permissions.canChangeMode, setStoredCuesheetMode]); + + return { cuesheetMode, setCuesheetMode }; +} diff --git a/apps/client/src/views/cuesheet/useTablePermissions.tsx b/apps/client/src/views/cuesheet/useTablePermissions.tsx index f4fa1b785..518eedd1d 100644 --- a/apps/client/src/views/cuesheet/useTablePermissions.tsx +++ b/apps/client/src/views/cuesheet/useTablePermissions.tsx @@ -6,9 +6,11 @@ interface CuesheetPermissionsStore { canEditEntries: boolean; canFlag: boolean; canShare: boolean; - setPermissions: (permissions: Omit) => void; + setPermissions: (permissions: CuesheetPermissions) => void; } +export type CuesheetPermissions = Omit; + export const useCuesheetPermissions = create((set) => ({ canChangeMode: false, canCreateEntries: false, diff --git a/e2e/tests/features/206-url-preset.spec.ts b/e2e/tests/features/206-url-preset.spec.ts index 6a72bde69..a7e2d9165 100644 --- a/e2e/tests/features/206-url-preset.spec.ts +++ b/e2e/tests/features/206-url-preset.spec.ts @@ -132,14 +132,16 @@ test.describe('Sharing from cuesheet', () => { await page.close(); }); - test('Sharing a link with readonly permissions', async ({ page }) => { + test('Sharing a link with readonly permissions', async ({ page }, testInfo) => { + const alias = `cuesheet-read-test-${testInfo.retry}-${Date.now()}`; + await page.goto('http://localhost:4001/cuesheet'); await expect(page.getByTestId('cuesheet')).toBeVisible(); await page.getByRole('button', { name: 'Share...' }).click(); // configure share for readonly - await page.getByRole('textbox').fill('cuesheet-read-test'); + await page.locator('input[name="alias"]').fill(alias); await page.getByText('Custom write').click(); await page.getByText('Custom read').click(); await page.getByTestId('lockNav').click(); @@ -151,9 +153,16 @@ test.describe('Sharing from cuesheet', () => { await page.getByTestId('write-duration').click(); await page.getByTestId('write-note').click(); + // disable any custom write permissions if they exist + const customWriteSwitches = page.locator('[data-testid^="write-custom-"]'); + const customWriteSwitchCount = await customWriteSwitches.count(); + for (let i = 0; i < customWriteSwitchCount; i++) { + await customWriteSwitches.nth(i).click(); + } + // create and verify link await page.getByRole('button', { name: 'Create share link' }).click(); - await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-read-test'); + await expect(page.getByTestId('copy-link')).toContainText(`preset/${alias}`); await expect(page.getByTestId('copy-link')).toContainText('n=1'); // verify the preset @@ -177,14 +186,16 @@ test.describe('Sharing from cuesheet', () => { await expect(page.getByRole('cell', { name: 'Duration' })).toBeVisible(); }); - test('Sharing a link with scoped read-write permissions', async ({ page }) => { + test('Sharing a link with scoped read-write permissions', async ({ page }, testInfo) => { + const alias = `cuesheet-scope-test-${testInfo.retry}-${Date.now()}`; + await page.goto('http://localhost:4001/cuesheet'); await expect(page.getByTestId('cuesheet')).toBeVisible(); await page.getByRole('button', { name: 'Share...' }).click(); // configure share for readonly - await page.getByRole('textbox').fill('cuesheet-scope-test'); + await page.locator('input[name="alias"]').fill(alias); await page.getByText('Custom write').click(); await page.getByText('Custom read').click(); await page.getByTestId('lockNav').click(); @@ -203,7 +214,7 @@ test.describe('Sharing from cuesheet', () => { // create and verify link await page.getByRole('button', { name: 'Create share link' }).click(); - await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-scope-test'); + await expect(page.getByTestId('copy-link')).toContainText(`preset/${alias}`); await expect(page.getByTestId('copy-link')).toContainText('n=1'); // verify the preset @@ -220,8 +231,9 @@ test.describe('Sharing from cuesheet', () => { // Verify that the title is visible and editable await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible(); - await page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title').click(); - await expect(page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title').locator('input')).toBeVisible(); + const titleEditor = page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title'); + await titleEditor.click(); + await expect(titleEditor).toBeEditable(); // other elements are not there await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();