refactor: extract cuesheet permissions

This commit is contained in:
Carlos Valente
2026-02-20 20:49:08 +01:00
committed by Carlos Valente
parent 5be504ce8f
commit df588b8845
9 changed files with 290 additions and 75 deletions
@@ -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 (
<CuesheetDnd columns={columns}>
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
{isLoading ? (
<EmptyPage text='Loading...' />
) : (
<CuesheetTable
columns={columns}
cuesheetMode={cuesheetMode}
tableRoot='cuesheet'
setCuesheetMode={setCuesheetMode}
/>
)}
</CuesheetDnd>
);
}
@@ -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);
});
});
@@ -34,13 +34,24 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
import style from './CuesheetTable.module.scss';
interface CuesheetTableProps {
type CuesheetTableBaseProps = {
columns: ColumnDef<ExtendedEntry>[];
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 <EmptyPage text='Loading...' />;
}
// control components need different implementations for handling permissions
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
return (
<>
<TableRootSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
{tableRoot === 'editor' ? (
<EditorTableSettings
columns={allLeafColumns}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
) : (
<CuesheetTableSettings
columns={allLeafColumns}
cuesheetMode={cuesheetMode}
setCuesheetMode={setCuesheetMode}
handleResetResizing={resetColumnResizing}
handleResetReordering={resetColumnOrder}
handleClearToggles={setAllVisible}
/>
)}
<TableVirtuoso
ref={virtuosoRef}
data={data}
@@ -7,6 +7,7 @@ import DelayIndicator from '../../../../common/components/delay-indicator/DelayI
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import DurationInput from './DurationInput';
import EditableImage from './EditableImage';
@@ -257,15 +258,7 @@ export function makeCuesheetColumns(
preset: URLPreset | undefined,
): ColumnDef<ExtendedEntry>[] {
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
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<string>();
const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set<string>();
// 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({
@@ -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<ExtendedEntry, unknown>[];
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);
@@ -0,0 +1,65 @@
import { URLPreset } from 'ontime-types';
import { AppMode } from '../../ontimeConfig';
import type { CuesheetPermissions } from './useTablePermissions';
function getPermissionKeys(permission: string | undefined): Set<string> {
return permission ? new Set(permission.split(',')) : new Set<string>();
}
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)),
};
}
@@ -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 };
}
@@ -6,9 +6,11 @@ interface CuesheetPermissionsStore {
canEditEntries: boolean;
canFlag: boolean;
canShare: boolean;
setPermissions: (permissions: Omit<CuesheetPermissionsStore, 'setPermissions'>) => void;
setPermissions: (permissions: CuesheetPermissions) => void;
}
export type CuesheetPermissions = Omit<CuesheetPermissionsStore, 'setPermissions'>;
export const useCuesheetPermissions = create<CuesheetPermissionsStore>((set) => ({
canChangeMode: false,
canCreateEntries: false,
+20 -8
View File
@@ -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();