diff --git a/.changeset/thin-cows-develop.md b/.changeset/thin-cows-develop.md new file mode 100644 index 0000000000..1388c83461 --- /dev/null +++ b/.changeset/thin-cows-develop.md @@ -0,0 +1,5 @@ +--- +'@commercetools-frontend/application-shell': minor +--- + +Enforce that the Custom Application route is protected by the application "View" permission. diff --git a/packages/application-shell/src/components/application-entry-point/application-entry-point.tsx b/packages/application-shell/src/components/application-entry-point/application-entry-point.tsx index 28aa28bf57..d76f3eadd8 100644 --- a/packages/application-shell/src/components/application-entry-point/application-entry-point.tsx +++ b/packages/application-shell/src/components/application-entry-point/application-entry-point.tsx @@ -3,14 +3,51 @@ import type { TProviderProps } from '@commercetools-frontend/application-shell-c import { Children, ReactNode } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import invariant from 'tiny-invariant'; +import { useIsAuthorized } from '@commercetools-frontend/permissions'; +import { PageUnauthorized } from '@commercetools-frontend/application-components'; +import { entryPointUriPathToPermissionKeys } from '../../utils/formatters'; import RouteCatchAll from '../route-catch-all'; type Props = { environment: TProviderProps['environment']; + disableRoutePermissionCheck?: boolean; render?: () => JSX.Element; children?: ReactNode; }; +const ApplicationRouteWithPermissionCheck = < + AdditionalEnvironmentProperties extends {} +>( + props: Props +) => { + const permissionKeys = entryPointUriPathToPermissionKeys( + props.environment.entryPointUriPath + ); + // Require View permission to render the application. + const canView = useIsAuthorized({ + demandedPermissions: [permissionKeys.View], + }); + + if (canView) { + return <>{Children.only(props.children)}; + } + return ; +}; + +const ApplicationRoute = ( + props: Props +) => { + if (props.disableRoutePermissionCheck) { + return <>{Children.only(props.children)}; + } + + return ( + + {...props} + /> + ); +}; + const ApplicationEntryPoint = ( props: Props ) => { @@ -33,7 +70,7 @@ const ApplicationEntryPoint = ( ) } - {Children.only(props.children)} + {...props} /> {/* Catch-all route */} diff --git a/packages/application-shell/src/components/application-shell/application-shell.spec.js b/packages/application-shell/src/components/application-shell/application-shell.spec.js index e29106f5ab..e592dc24ce 100644 --- a/packages/application-shell/src/components/application-shell/application-shell.spec.js +++ b/packages/application-shell/src/components/application-shell/application-shell.spec.js @@ -244,16 +244,65 @@ describe.each` renderApp(, { renderNodeAsChildren, route, + disableRoutePermissionCheck: true, }); await screen.findByText('Application name: my-app'); }); + if (renderNodeAsChildren) { + describe('when route permission check is enabled (default)', () => { + it('should render page if application has view permission', async () => { + mockServer.use( + ...getDefaultMockResolvers({ + projects: [ + ProjectMock.build({ + ...createTestAppliedPermissions({ + allAppliedPermissions: [ + { + name: 'canViewAvengers', + value: true, + }, + ], + }), + }), + ], + }) + ); + const TestComponent = () => { + const applicationName = useApplicationContext( + (context) => context.environment.applicationName + ); + return

{`Application name: ${applicationName}`}

; + }; + renderApp(, { + renderNodeAsChildren, + route, + }); + await screen.findByText('Application name: my-app'); + }); + it('should render unauthorized page if application does not have view permission', async () => { + const TestComponent = () => { + const applicationName = useApplicationContext( + (context) => context.environment.applicationName + ); + return

{`Application name: ${applicationName}`}

; + }; + renderApp(, { + renderNodeAsChildren, + route, + }); + await screen.findByText(/you are not authorized to view it/); + }); + }); + } + describe('when user navigates to "/account" route', () => { if (renderNodeAsChildren) { it('should trigger a page reload (when served by proxy)', async () => { const { history } = renderApp(null, { renderNodeAsChildren, environment: { servedByProxy: true }, + disableRoutePermissionCheck: true, }); await screen.findByText('OK'); history.push('/account'); @@ -265,6 +314,7 @@ describe.each` it('should render using the "render" prop', async () => { const { history } = renderApp(null, { renderNodeAsChildren, + disableRoutePermissionCheck: true, }); await screen.findByText('OK'); history.push('/account'); @@ -278,7 +328,9 @@ describe.each` describe('when route does not contain a project key (e.g. /account)', () => { it('should not render NavBar', async () => { - const { history, queryByLeftNavigation } = renderApp(); + const { history, queryByLeftNavigation } = renderApp(null, { + disableRoutePermissionCheck: true, + }); await screen.findByText('OK'); history.push('/account'); await waitFor(() => { diff --git a/packages/application-shell/src/components/application-shell/application-shell.tsx b/packages/application-shell/src/components/application-shell/application-shell.tsx index 5b012d4ded..f44ecab34b 100644 --- a/packages/application-shell/src/components/application-shell/application-shell.tsx +++ b/packages/application-shell/src/components/application-shell/application-shell.tsx @@ -77,6 +77,7 @@ type Props = { event: SyntheticEvent, track: TrackFn ) => void; + disableRoutePermissionCheck?: boolean; render?: () => JSX.Element; children?: ReactNode; }; @@ -460,6 +461,9 @@ export const RestrictedApplication = < match={routerProps.match} location={routerProps.location} environment={applicationEnvironment} + disableRoutePermissionCheck={ + props.disableRoutePermissionCheck + } // This effectively renders the // children, which is the application // specific part @@ -530,6 +534,9 @@ const ApplicationShell = ( render={props.render} applicationMessages={props.applicationMessages} onMenuItemClick={props.onMenuItemClick} + disableRoutePermissionCheck={ + props.disableRoutePermissionCheck + } > {props.children} diff --git a/packages/application-shell/src/components/project-container/project-container.tsx b/packages/application-shell/src/components/project-container/project-container.tsx index 62a26e9309..dee370487d 100644 --- a/packages/application-shell/src/components/project-container/project-container.tsx +++ b/packages/application-shell/src/components/project-container/project-container.tsx @@ -33,6 +33,7 @@ type Props = Pick< > & { user: TFetchLoggedInUserQuery['user']; environment: TProviderProps['environment']; + disableRoutePermissionCheck?: boolean; render?: () => JSX.Element; children?: ReactNode; }; @@ -176,6 +177,9 @@ const ProjectContainer = ( */} environment={props.environment} + disableRoutePermissionCheck={ + props.disableRoutePermissionCheck + } render={props.render} > {props.children} diff --git a/packages/application-shell/src/test-utils/test-utils.tsx b/packages/application-shell/src/test-utils/test-utils.tsx index 916fc62df8..af6c2e756d 100644 --- a/packages/application-shell/src/test-utils/test-utils.tsx +++ b/packages/application-shell/src/test-utils/test-utils.tsx @@ -266,6 +266,7 @@ export type TRenderAppOptions = { apolloClient?: ApolloClient; enableApolloMocks: boolean; route: string; + disableRoutePermissionCheck: boolean; disableAutomaticEntryPointRoutes: boolean; history: ReturnType; flags: TFlags; @@ -286,6 +287,9 @@ type TApplicationProvidersProps = { }; function createApplicationProviders({ + // application + disableAutomaticEntryPointRoutes = false, + disableRoutePermissionCheck = false, // react-intl locale = 'en', // Apollo @@ -294,7 +298,6 @@ function createApplicationProviders({ enableApolloMocks = false, // react-router route, - disableAutomaticEntryPointRoutes = false, history, // flopflip flags = {}, @@ -340,6 +343,7 @@ function createApplicationProviders({ }>