diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index f90942dcd8..76d41399ba 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -35,6 +35,7 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { permissionApiRef } from '@backstage/plugin-permission-react'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { SWRConfig } from 'swr'; const mockAuthorize = jest.fn(); @@ -546,7 +547,7 @@ describe('', () => { ).not.toBeInTheDocument(); }); - it('renders techdocs lin when 3rdparty', async () => { + it('renders techdocs link when 3rdparty', async () => { const entity = { apiVersion: 'v1', kind: 'Component', @@ -774,7 +775,9 @@ describe('', () => { namespace: 'default', }, }; - + mockAuthorize.mockImplementation(async () => ({ + result: AuthorizeResult.ALLOW, + })); await renderInTestApp( ', () => { ), ], [catalogApiRef, catalogApi], - [permissionApiRef, {}], + [permissionApiRef, mockPermissionApi], ]} > @@ -816,6 +819,58 @@ describe('', () => { '/create/templates/default/create-react-app-template', ); }); + it('renders disabled launch template button if user has insufficient permissions', async () => { + const entity = { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { + name: 'create-react-app-template', + namespace: 'default', + }, + }; + mockAuthorize.mockImplementation(async () => ({ + result: AuthorizeResult.DENY, + })); + const rendered = await renderInTestApp( + new Map() }}> + + + + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + '/create/templates/:namespace/:templateName': + createFromTemplateRouteRef, + }, + }, + ); + + expect(screen.getByText('Launch Template')).toBeVisible(); + expect(screen.getByText('Launch Template').closest('a')).toBeNull(); + }); it.each([ { diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index a24a132bc4..b63666f80c 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -19,6 +19,7 @@ import { CompoundEntityRef, DEFAULT_NAMESPACE, stringifyEntityRef, + parseEntityRef, } from '@backstage/catalog-model'; import Card from '@material-ui/core/Card'; import CardContent from '@material-ui/core/CardContent'; @@ -58,10 +59,11 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import DocsIcon from '@material-ui/icons/Description'; import EditIcon from '@material-ui/icons/Edit'; import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; -import { parseEntityRef } from '@backstage/catalog-model'; import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha'; import { useSourceTemplateCompoundEntityRef } from './hooks'; +import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'; +import { usePermission } from '@backstage/plugin-permission-react'; const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref'; @@ -114,6 +116,11 @@ export function AboutCard(props: AboutCardProps) { const { allowed: canRefresh } = useEntityPermission( catalogEntityRefreshPermission, ); + const { kind, name, namespace } = entity.metadata; + const { allowed: canCreateTemplateTask } = usePermission({ + permission: taskCreatePermission, + resourceRef: `${kind}:${namespace}/${name}`, + }); const entitySourceLocation = getEntitySourceLocation( entity, @@ -172,7 +179,7 @@ export function AboutCard(props: AboutCardProps) { const launchTemplate: IconLinkVerticalProps = { label: 'Launch Template', icon: , - disabled: !templateRoute, + disabled: !templateRoute || !canCreateTemplateTask, href: templateRoute && templateRoute({ diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 7b39290cfc..3fd1e14fa1 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -65,6 +65,7 @@ import { validate } from 'jsonschema'; import { Logger } from 'winston'; import { z } from 'zod'; import { + TemplateAction, TaskBroker, TemplateFilter, TemplateGlobal, @@ -78,7 +79,6 @@ import { import { createDryRunner } from '../scaffolder/dryrun'; import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker'; import { findTemplate, getEntityBaseUrl, getWorkingDirectory } from './helpers'; -import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { AuthorizeResult, PermissionRuleParams, @@ -491,7 +491,7 @@ export async function createRouter( if (permissions) { const authorizationResponse = ( - await permissions?.authorizeConditional( + await permissions.authorizeConditional( [{ permission: actionReadPermission }], { credentials: credentials }, ) @@ -520,7 +520,7 @@ export async function createRouter( const credentials = await httpAuth.credentials(req); if (permissions) { const authorizationResponse = ( - await permissions?.authorizeConditional( + await permissions.authorizeConditional( [{ permission: taskCreatePermission }], { credentials: credentials }, ) @@ -611,7 +611,7 @@ export async function createRouter( if (permissions) { const authorizationResponse = ( - await permissions?.authorizeConditional( + await permissions.authorizeConditional( [{ permission: taskReadPermission }], { credentials: credentials }, ) @@ -648,7 +648,7 @@ export async function createRouter( if (permissions) { const authorizationResponse = ( - await permissions?.authorizeConditional( + await permissions.authorizeConditional( [{ permission: taskReadPermission }], { credentials: credentials }, ) @@ -672,7 +672,7 @@ export async function createRouter( const credentials = await httpAuth.credentials(req); if (permissions) { - const authorizationResponses = await permissions?.authorizeConditional( + const authorizationResponses = await permissions.authorizeConditional( [ { permission: taskCancelPermission }, { permission: taskReadPermission }, @@ -696,7 +696,7 @@ export async function createRouter( if (permissions) { const authorizationResponse = ( - await permissions?.authorizeConditional( + await permissions.authorizeConditional( [{ permission: taskReadPermission }], { credentials: credentials }, ) @@ -758,7 +758,7 @@ export async function createRouter( if (permissions) { const authorizationResponse = ( - await permissions?.authorizeConditional( + await permissions.authorizeConditional( [{ permission: taskReadPermission }], { credentials: credentials }, ) @@ -802,7 +802,7 @@ export async function createRouter( if (permissions) { const authorizationResponse = ( - await permissions?.authorizeConditional( + await permissions.authorizeConditional( [{ permission: taskCreatePermission }], { credentials: credentials }, ) diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index ad12268baa..0b803f35e4 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -54,6 +54,7 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", @@ -87,13 +88,15 @@ "@backstage/core-app-api": "workspace:^", "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^15.0.0", "@testing-library/user-event": "^14.0.0", "@types/humanize-duration": "^3.18.1", - "@types/luxon": "^3.0.0" + "@types/luxon": "^3.0.0", + "swr": "^2.0.0" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0", diff --git a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx index ec4e4e1b0a..90ae1e097c 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx @@ -19,6 +19,7 @@ import { starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; import { + MockPermissionApi, MockStorageApi, renderInTestApp, TestApiProvider, @@ -28,6 +29,12 @@ import React from 'react'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { RELATION_OWNED_BY } from '@backstage/catalog-model'; import { fireEvent } from '@testing-library/react'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { SWRConfig } from 'swr'; describe('TemplateCard', () => { it('should render the card title', async () => { @@ -50,6 +57,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -79,6 +87,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -110,6 +119,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -139,6 +149,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -174,6 +185,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -213,6 +225,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -257,6 +270,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -305,6 +319,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -347,6 +362,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -386,6 +402,7 @@ describe('TemplateCard', () => { storageApi: MockStorageApi.create(), }), ], + [permissionApiRef, new MockPermissionApi()], ]} > @@ -403,4 +420,44 @@ describe('TemplateCard', () => { expect(mockOnSelected).toHaveBeenCalledWith(mockTemplate); }); + it('should not render the choose button when user has insufficient permissions', async () => { + const mockTemplate: TemplateEntityV1beta3 = { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { name: 'bob', tags: ['cpp', 'react'] }, + spec: { + steps: [], + type: 'service', + }, + }; + const mockOnSelected = jest.fn(); + const mockAuthorize = jest + .fn() + .mockImplementation(async () => ({ result: AuthorizeResult.DENY })); + // SWR used by the usePermission hook needs cache to be reset for each test + const { queryByText } = await renderInTestApp( + new Map() }}> + + + + , + { + mountedRoutes: { + '/catalog/:kind/:namespace/:name': entityRouteRef, + }, + }, + ); + + expect(queryByText('Choose')).toBeNull(); + }); }); diff --git a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx index 4fb2ca0e4a..23e2f13e78 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx @@ -35,6 +35,8 @@ import LanguageIcon from '@material-ui/icons/Language'; import React from 'react'; import { CardHeader } from './CardHeader'; import { CardLink } from './CardLink'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha'; const useStyles = makeStyles(theme => ({ box: { @@ -103,6 +105,11 @@ export const TemplateCard = (props: TemplateCardProps) => { !!props.additionalLinks?.length || !!template.metadata.links?.length; const displayDefaultDivider = !hasTags && !hasLinks; + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + resourceRef: 'task', + }); + return ( @@ -186,14 +193,16 @@ export const TemplateCard = (props: TemplateCardProps) => { )} - + {canCreateTask ? ( + + ) : null} diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index e4425b04bf..ebc0c3596f 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -23,7 +23,8 @@ import { MockStarredEntitiesApi, } from '@backstage/plugin-catalog-react'; import React from 'react'; -import { scaffolderApiRef, ScaffolderClient } from '../src'; +import { ScaffolderClient } from '../src'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { ScaffolderPage } from '../src/plugin'; import { discoveryApiRef, diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx index d6eabb0e5c..7e859e272d 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx @@ -43,7 +43,8 @@ import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { CodeSnippet, Content, - ErrorPage, + EmptyState, + ErrorPanel, Header, MarkdownContent, Page, @@ -112,19 +113,9 @@ const ExamplesTable = (props: { examples: ActionExample[] }) => { ); }; -export const ActionsPage = () => { +const ActionPageContent = () => { const api = useApi(scaffolderApiRef); - const navigate = useNavigate(); - const editorLink = useRouteRef(editRouteRef); - const tasksLink = useRouteRef(scaffolderListTaskRouteRef); - const createLink = useRouteRef(rootRouteRef); - const scaffolderPageContextMenuProps = { - onEditorClicked: () => navigate(editorLink()), - onActionsClicked: undefined, - onTasksClicked: () => navigate(tasksLink()), - onCreateClicked: () => navigate(createLink()), - }; const classes = useStyles(); const { loading, value, error } = useAsync(async () => { return api.listActions(); @@ -137,11 +128,14 @@ export const ActionsPage = () => { if (error) { return ( - + <> + + + ); } @@ -282,7 +276,7 @@ export const ActionsPage = () => { ); }; - const items = value?.map(action => { + return value?.map(action => { if (action.id.startsWith('legacy:')) { return undefined; } @@ -336,6 +330,19 @@ export const ActionsPage = () => { ); }); +}; +export const ActionsPage = () => { + const navigate = useNavigate(); + const editorLink = useRouteRef(editRouteRef); + const tasksLink = useRouteRef(scaffolderListTaskRouteRef); + const createLink = useRouteRef(rootRouteRef); + + const scaffolderPageContextMenuProps = { + onEditorClicked: () => navigate(editorLink()), + onActionsClicked: undefined, + onTasksClicked: () => navigate(tasksLink()), + onCreateClicked: () => navigate(createLink()), + }; return ( @@ -346,7 +353,9 @@ export const ActionsPage = () => { > - {items} + + + ); }; diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx index 4c2eb04c79..d37716a710 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx @@ -77,7 +77,7 @@ const ListTaskPageContent = (props: MyTaskPageProps) => { ); diff --git a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx index 99d9235791..bac23da67d 100644 --- a/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/ContextMenu.tsx @@ -30,6 +30,12 @@ import MoreVert from '@material-ui/icons/MoreVert'; import React, { useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { + taskCancelPermission, + taskReadPermission, + taskCreatePermission, +} from '@backstage/plugin-scaffolder-common/alpha'; type ContextMenuProps = { cancelEnabled?: boolean; @@ -69,6 +75,28 @@ export const ContextMenu = (props: ContextMenuProps) => { } }); + // Used dummy string value for `resourceRef` since `allowed` field will always return `false` if `resourceRef` is `undefined` + const { allowed: canCancelTask } = usePermission({ + permission: taskCancelPermission, + resourceRef: 'task', + }); + + const { allowed: canReadTask } = usePermission({ + permission: taskReadPermission, + resourceRef: 'task', + }); + + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + resourceRef: 'task', + }); + + // Cancel endpoint requires user to have both read and cancel permissions + const cancelNotAllowed = !(canReadTask && canCancelTask); + + // Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions + const canStartOver = canReadTask && canCreateTask; + return ( <> { primary={buttonBarVisible ? 'Hide Button Bar' : 'Show Button Bar'} /> - + @@ -113,7 +145,11 @@ export const ContextMenu = (props: ContextMenuProps) => { diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.test.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.test.tsx index 9d3f29fe7b..70d2193470 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.test.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.test.tsx @@ -16,10 +16,20 @@ import { OngoingTask } from './OngoingTask'; import React from 'react'; -import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { + renderInTestApp, + TestApiProvider, + MockPermissionApi, +} from '@backstage/test-utils'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { act, fireEvent, waitFor, within } from '@testing-library/react'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; import { rootRouteRef } from '../../routes'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { SWRConfig } from 'swr'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -49,18 +59,29 @@ describe('OngoingTask', () => { getTask: jest.fn().mockImplementation(async () => {}), }; - beforeEach(() => { + beforeEach(async () => { jest.clearAllMocks(); }); - it('should trigger cancel api on "Cancel" click in context menu', async () => { - const cancelOptionLabel = 'Cancel'; - const rendered = await renderInTestApp( - - - , + const render = (permissionApi?: PermissionApi) => { + // SWR used by the usePermission hook needs cache to be reset for each test + return renderInTestApp( + new Map() }}> + + + + , { mountedRoutes: { '/': rootRouteRef } }, ); + }; + it('should trigger cancel api on "Cancel" click in context menu', async () => { + const rendered = await render(); + const cancelOptionLabel = 'Cancel'; const { getByTestId } = rendered; await act(async () => { @@ -84,13 +105,9 @@ describe('OngoingTask', () => { }); it('should trigger cancel api on "Cancel" button click', async () => { + const rendered = await render(); const cancelOptionLabel = 'Cancel'; - const rendered = await renderInTestApp( - - - , - { mountedRoutes: { '/': rootRouteRef } }, - ); + const { getByTestId } = rendered; await act(async () => { @@ -114,22 +131,12 @@ describe('OngoingTask', () => { }); it('should initially do not display logs', async () => { - const rendered = await renderInTestApp( - - - , - { mountedRoutes: { '/': rootRouteRef } }, - ); + const rendered = await render(); await expect(rendered.findByText('Show Logs')).resolves.toBeInTheDocument(); }); it('should toggle logs visibility', async () => { - const rendered = await renderInTestApp( - - - , - { mountedRoutes: { '/': rootRouteRef } }, - ); + const rendered = await render(); await act(async () => { const element = await rendered.findByText('Show Logs'); fireEvent.click(element); @@ -137,4 +144,22 @@ describe('OngoingTask', () => { await expect(rendered.findByText('Hide Logs')).resolves.toBeInTheDocument(); }); + + it('should have cancel and start over buttons be disabled without the proper permissions', async () => { + const mockAuthorize = jest + .fn() + .mockImplementation(async () => ({ result: AuthorizeResult.DENY })); + const permissionApi: PermissionApi = { authorize: mockAuthorize }; + const rendered = await render(permissionApi); + + const { getByTestId } = rendered; + expect(getByTestId('cancel-button')).toHaveClass('Mui-disabled'); + expect(getByTestId('start-over-button')).toHaveClass('Mui-disabled'); + + await act(async () => { + fireEvent.click(getByTestId('menu-button')); + }); + expect(getByTestId('cancel-task')).toHaveClass('Mui-disabled'); + expect(getByTestId('start-over-task')).toHaveClass('Mui-disabled'); + }); }); diff --git a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx index 3e7a3b8f52..d5f6dcfb0d 100644 --- a/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx +++ b/plugins/scaffolder/src/components/OngoingTask/OngoingTask.tsx @@ -35,6 +35,12 @@ import { TaskSteps, } from '@backstage/plugin-scaffolder-react/alpha'; import { useAsync } from '@react-hookz/web'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { + taskCancelPermission, + taskReadPermission, + taskCreatePermission, +} from '@backstage/plugin-scaffolder-common/alpha'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -81,6 +87,28 @@ export const OngoingTask = (props: { const [logsVisible, setLogVisibleState] = useState(false); const [buttonBarVisible, setButtonBarVisibleState] = useState(true); + // Used dummy string value for `resourceRef` since `allowed` field will always return `false` if `resourceRef` is `undefined` + const { allowed: canCancelTask } = usePermission({ + permission: taskCancelPermission, + resourceRef: 'task', + }); + + const { allowed: canReadTask } = usePermission({ + permission: taskReadPermission, + resourceRef: 'task', + }); + + const { allowed: canCreateTask } = usePermission({ + permission: taskCreatePermission, + resourceRef: 'task', + }); + + // Cancel endpoint requires user to have both read and cancel permissions + const cancelNotAllowed = !(canReadTask && canCancelTask); + + // Start Over endpoint requires user to have both read (to grab parameters) and create (to create new task) permissions + const canStartOver = canReadTask && canCreateTask; + useEffect(() => { if (taskStream.error) { setLogVisibleState(true); @@ -192,7 +220,11 @@ export const OngoingTask = (props: {