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();