Merge pull request #17358 from strapi/feat/review-workflows-rbac-fe

Feat: Add RBAC permissions for review-workflow settings pages
This commit is contained in:
Gustav Hansen 2023-07-19 12:25:38 +02:00 committed by GitHub
commit a2b088974e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 153 additions and 78 deletions

View File

@ -6,6 +6,9 @@ export const ADMIN_PERMISSIONS_EE = {
}, },
'review-workflows': { 'review-workflows': {
main: [{ action: 'admin::review-workflows.read', subject: null }], main: [{ action: 'admin::review-workflows.read', subject: null }],
create: [{ action: 'admin::review-workflows.create', subject: null }],
delete: [{ action: 'admin::review-workflows.delete', subject: null }],
update: [{ action: 'admin::review-workflows.update', subject: null }],
}, },
sso: { sso: {
main: [{ action: 'admin::provider-login.read', subject: null }], main: [{ action: 'admin::provider-login.read', subject: null }],

View File

@ -1,21 +0,0 @@
import React from 'react';
import { CheckPagePermissions } from '@strapi/helper-plugin';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors';
export function ProtectedPage({ children }) {
const permissions = useSelector(selectAdminPermissions);
return (
<CheckPagePermissions permissions={permissions.settings['review-workflows'].main}>
{children}
</CheckPagePermissions>
);
}
ProtectedPage.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@ -50,6 +50,7 @@ export function Stage({
index, index,
canDelete, canDelete,
canReorder, canReorder,
canUpdate,
isOpen: isOpenDefault = false, isOpen: isOpenDefault = false,
stagesCount, stagesCount,
}) { }) {
@ -216,6 +217,7 @@ export function Stage({
<IconButton <IconButton
background="transparent" background="transparent"
disabled={!canUpdate}
forwardedAs="div" forwardedAs="div"
role="button" role="button"
noBorder noBorder
@ -240,6 +242,7 @@ export function Stage({
<TextInput <TextInput
{...nameField} {...nameField}
id={nameField.name} id={nameField.name}
disabled={!canUpdate}
label={formatMessage({ label={formatMessage({
id: 'Settings.review-workflows.stage.name.label', id: 'Settings.review-workflows.stage.name.label',
defaultMessage: 'Stage name', defaultMessage: 'Stage name',
@ -255,6 +258,7 @@ export function Stage({
<GridItem col={6}> <GridItem col={6}>
<SingleSelect <SingleSelect
disabled={!canUpdate}
error={colorMeta?.error ?? false} error={colorMeta?.error ?? false}
id={colorField.name} id={colorField.name}
required required
@ -319,5 +323,6 @@ Stage.propTypes = PropTypes.shape({
color: PropTypes.string.isRequired, color: PropTypes.string.isRequired,
canDelete: PropTypes.bool.isRequired, canDelete: PropTypes.bool.isRequired,
canReorder: PropTypes.bool.isRequired, canReorder: PropTypes.bool.isRequired,
canUpdate: PropTypes.bool.isRequired,
stagesCount: PropTypes.number.isRequired, stagesCount: PropTypes.number.isRequired,
}).isRequired; }).isRequired;

View File

@ -115,4 +115,13 @@ describe('Admin | Settings | Review Workflow | Stage', () => {
expect(getByRole('textbox').value).toBe('something'); expect(getByRole('textbox').value).toBe('something');
}); });
it('disables all input fields, if canUpdate = false', async () => {
const { container, getByRole } = setup({ canUpdate: false });
await user.click(container.querySelector('button[aria-expanded]'));
expect(getByRole('textbox')).toHaveAttribute('disabled');
expect(getByRole('combobox')).toHaveAttribute('data-disabled');
});
}); });

View File

@ -12,26 +12,27 @@ import { AddStage } from '../AddStage';
import { Stage } from './Stage'; import { Stage } from './Stage';
const StagesContainer = styled(Box)`
position: relative;
`;
const Background = styled(Box)` const Background = styled(Box)`
left: 50%;
position: absolute;
top: 0;
transform: translateX(-50%); transform: translateX(-50%);
`; `;
function Stages({ stages }) { export function Stages({ canDelete, canUpdate, stages }) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { trackUsage } = useTracking(); const { trackUsage } = useTracking();
return ( return (
<Flex direction="column" gap={6} width="100%"> <Flex direction="column" gap={6} width="100%">
<StagesContainer spacing={4} width="100%"> <Box position="relative" spacing={4} width="100%">
<Background background="neutral200" height="100%" width={2} zIndex={1} /> <Background
background="neutral200"
height="100%"
left="50%"
position="absolute"
top="0"
width={2}
zIndex={1}
/>
<Flex <Flex
direction="column" direction="column"
@ -49,18 +50,19 @@ function Stages({ stages }) {
<Stage <Stage
id={id} id={id}
index={index} index={index}
canDelete={stages.length > 1}
isOpen={!stage.id} isOpen={!stage.id}
canDelete={stages.length > 1 && canDelete}
canReorder={stages.length > 1} canReorder={stages.length > 1}
canUpdate={canUpdate}
stagesCount={stages.length} stagesCount={stages.length}
/> />
</Box> </Box>
); );
})} })}
</Flex> </Flex>
</StagesContainer> </Box>
<Flex direction="column" gap={6}> {canUpdate && (
<AddStage <AddStage
type="button" type="button"
onClick={() => { onClick={() => {
@ -73,18 +75,20 @@ function Stages({ stages }) {
defaultMessage: 'Add new stage', defaultMessage: 'Add new stage',
})} })}
</AddStage> </AddStage>
</Flex> )}
</Flex> </Flex>
); );
} }
export { Stages };
Stages.defaultProps = { Stages.defaultProps = {
canDelete: true,
canUpdate: true,
stages: [], stages: [],
}; };
Stages.propTypes = { Stages.propTypes = {
canDelete: PropTypes.bool,
canUpdate: PropTypes.bool,
stages: PropTypes.arrayOf( stages: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,

View File

@ -122,4 +122,10 @@ describe('Admin | Settings | Review Workflow | Stages', () => {
name: 'New name', name: 'New name',
}); });
}); });
it('should not render the "add stage" button if canUpdate = false', () => {
const { queryByText } = setup({ canUpdate: false });
expect(queryByText('Add new stage')).not.toBeInTheDocument();
});
}); });

View File

@ -9,7 +9,7 @@ import { useDispatch } from 'react-redux';
import { updateWorkflow } from '../../actions'; import { updateWorkflow } from '../../actions';
export function WorkflowAttributes({ contentTypes: { collectionTypes, singleTypes } }) { export function WorkflowAttributes({ canUpdate, contentTypes: { collectionTypes, singleTypes } }) {
const { formatMessage, locale } = useIntl(); const { formatMessage, locale } = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [nameField, nameMeta, nameHelper] = useField('name'); const [nameField, nameMeta, nameHelper] = useField('name');
@ -24,6 +24,7 @@ export function WorkflowAttributes({ contentTypes: { collectionTypes, singleType
<TextInput <TextInput
{...nameField} {...nameField}
id={nameField.name} id={nameField.name}
disabled={!canUpdate}
label={formatMessage({ label={formatMessage({
id: 'Settings.review-workflows.workflow.name.label', id: 'Settings.review-workflows.workflow.name.label',
defaultMessage: 'Workflow Name', defaultMessage: 'Workflow Name',
@ -50,6 +51,7 @@ export function WorkflowAttributes({ contentTypes: { collectionTypes, singleType
{ count: value.length } { count: value.length }
) )
} }
disabled={!canUpdate}
error={contentTypesMeta.error ?? false} error={contentTypesMeta.error ?? false}
id={contentTypesField.name} id={contentTypesField.name}
label={formatMessage({ label={formatMessage({
@ -102,7 +104,12 @@ const ContentTypeType = PropTypes.shape({
}).isRequired, }).isRequired,
}); });
WorkflowAttributes.defaultProps = {
canUpdate: true,
};
WorkflowAttributes.propTypes = { WorkflowAttributes.propTypes = {
canUpdate: PropTypes.bool,
contentTypes: PropTypes.shape({ contentTypes: PropTypes.shape({
collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired, collectionTypes: PropTypes.arrayOf(ContentTypeType).isRequired,
singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired, singleTypes: PropTypes.arrayOf(ContentTypeType).isRequired,

View File

@ -74,6 +74,9 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => {
expect(getByRole('textbox')).toHaveValue('workflow name'); expect(getByRole('textbox')).toHaveValue('workflow name');
expect(getByText(/2 content types selected/i)).toBeInTheDocument(); expect(getByText(/2 content types selected/i)).toBeInTheDocument();
expect(getByRole('textbox')).not.toHaveAttribute('disabled');
expect(getByRole('combobox', { name: /associated to/i })).not.toHaveAttribute('data-disabled');
await user.click(contentTypesSelect); await user.click(contentTypesSelect);
await waitFor(() => { await waitFor(() => {
@ -81,4 +84,13 @@ describe('Admin | Settings | Review Workflow | WorkflowAttributes', () => {
expect(getByRole('option', { name: /content type 2/i })).toBeInTheDocument(); expect(getByRole('option', { name: /content type 2/i })).toBeInTheDocument();
}); });
}); });
it('should disabled fields if canUpdate = false', async () => {
const { getByRole } = setup({ canUpdate: false });
await waitFor(() => {
expect(getByRole('textbox')).toHaveAttribute('disabled');
expect(getByRole('combobox', { name: /associated to/i })).toHaveAttribute('data-disabled');
});
});
}); });

View File

@ -1,7 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { Button, Flex, Loader } from '@strapi/design-system'; import { Button, Flex, Loader } from '@strapi/design-system';
import { useAPIErrorHandler, useFetchClient, useNotification } from '@strapi/helper-plugin'; import {
useAPIErrorHandler,
useFetchClient,
useNotification,
useRBAC,
} from '@strapi/helper-plugin';
import { Check } from '@strapi/icons'; import { Check } from '@strapi/icons';
import { useFormik, Form, FormikProvider } from 'formik'; import { useFormik, Form, FormikProvider } from 'formik';
import set from 'lodash/set'; import set from 'lodash/set';
@ -12,6 +17,7 @@ import { useHistory } from 'react-router-dom';
import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes';
import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer';
import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors';
import { useLicenseLimits } from '../../../../../../hooks'; import { useLicenseLimits } from '../../../../../../hooks';
import { resetWorkflow } from '../../actions'; import { resetWorkflow } from '../../actions';
import * as Layout from '../../components/Layout'; import * as Layout from '../../components/Layout';
@ -29,6 +35,7 @@ export function ReviewWorkflowsCreateView() {
const { push } = useHistory(); const { push } = useHistory();
const { formatAPIError } = useAPIErrorHandler(); const { formatAPIError } = useAPIErrorHandler();
const dispatch = useDispatch(); const dispatch = useDispatch();
const permissions = useSelector(selectAdminPermissions);
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes(); const { collectionTypes, singleTypes, isLoading: isLoadingModels } = useContentTypes();
const { const {
@ -36,6 +43,9 @@ export function ReviewWorkflowsCreateView() {
currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty }, currentWorkflow: { data: currentWorkflow, isDirty: currentWorkflowIsDirty },
}, },
} = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState);
const {
allowedActions: { canCreate },
} = useRBAC(permissions.settings['review-workflows']);
const [showLimitModal, setShowLimitModal] = React.useState(false); const [showLimitModal, setShowLimitModal] = React.useState(false);
const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits(); const { isLoading: isLicenseLoading, getFeature } = useLicenseLimits();
const { meta, isLoading: isWorkflowLoading } = useReviewWorkflows(); const { meta, isLoading: isWorkflowLoading } = useReviewWorkflows();
@ -191,7 +201,7 @@ export function ReviewWorkflowsCreateView() {
startIcon={<Check />} startIcon={<Check />}
type="submit" type="submit"
size="M" size="M"
disabled={!currentWorkflowIsDirty} disabled={!currentWorkflowIsDirty || !canCreate}
isLoading={isLoading} isLoading={isLoading}
> >
{formatMessage({ {formatMessage({

View File

@ -1,13 +1,18 @@
import React from 'react'; import React from 'react';
import { ProtectedPage } from '../../components/ProtectedPage'; import { CheckPagePermissions } from '@strapi/helper-plugin';
import { useSelector } from 'react-redux';
import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors';
import { ReviewWorkflowsCreateView } from './CreateView'; import { ReviewWorkflowsCreateView } from './CreateView';
export default function () { export default function () {
const permissions = useSelector(selectAdminPermissions);
return ( return (
<ProtectedPage> <CheckPagePermissions permissions={permissions.settings['review-workflows'].create}>
<ReviewWorkflowsCreateView /> <ReviewWorkflowsCreateView />
</ProtectedPage> </CheckPagePermissions>
); );
} }

View File

@ -6,6 +6,7 @@ import {
useAPIErrorHandler, useAPIErrorHandler,
useFetchClient, useFetchClient,
useNotification, useNotification,
useRBAC,
} from '@strapi/helper-plugin'; } from '@strapi/helper-plugin';
import { Check } from '@strapi/icons'; import { Check } from '@strapi/icons';
import { useFormik, Form, FormikProvider } from 'formik'; import { useFormik, Form, FormikProvider } from 'formik';
@ -17,6 +18,7 @@ import { useParams } from 'react-router-dom';
import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes';
import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer'; import { useInjectReducer } from '../../../../../../../../admin/src/hooks/useInjectReducer';
import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors';
import { useLicenseLimits } from '../../../../../../hooks'; import { useLicenseLimits } from '../../../../../../hooks';
import { setWorkflow } from '../../actions'; import { setWorkflow } from '../../actions';
import * as Layout from '../../components/Layout'; import * as Layout from '../../components/Layout';
@ -30,6 +32,7 @@ import { validateWorkflow } from '../../utils/validateWorkflow';
export function ReviewWorkflowsEditView() { export function ReviewWorkflowsEditView() {
const { workflowId } = useParams(); const { workflowId } = useParams();
const permissions = useSelector(selectAdminPermissions);
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { put } = useFetchClient(); const { put } = useFetchClient();
@ -53,6 +56,9 @@ export function ReviewWorkflowsEditView() {
}, },
}, },
} = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState); } = useSelector((state) => state?.[REDUX_NAMESPACE] ?? initialState);
const {
allowedActions: { canDelete, canUpdate },
} = useRBAC(permissions.settings['review-workflows']);
const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = React.useState(false); const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = React.useState(false);
const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits(); const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits();
const [showLimitModal, setShowLimitModal] = React.useState(false); const [showLimitModal, setShowLimitModal] = React.useState(false);
@ -225,7 +231,7 @@ export function ReviewWorkflowsEditView() {
startIcon={<Check />} startIcon={<Check />}
type="submit" type="submit"
size="M" size="M"
disabled={!currentWorkflowIsDirty} disabled={!currentWorkflowIsDirty || !canUpdate}
// if the confirm dialog is open the loading state is on // if the confirm dialog is open the loading state is on
// the confirm button already // the confirm button already
loading={!isConfirmDeleteDialogOpen && isLoading} loading={!isConfirmDeleteDialogOpen && isLoading}
@ -256,8 +262,15 @@ export function ReviewWorkflowsEditView() {
</Loader> </Loader>
) : ( ) : (
<Flex alignItems="stretch" direction="column" gap={7}> <Flex alignItems="stretch" direction="column" gap={7}>
<WorkflowAttributes contentTypes={{ collectionTypes, singleTypes }} /> <WorkflowAttributes
<Stages stages={formik.values?.stages} /> canUpdate={canUpdate}
contentTypes={{ collectionTypes, singleTypes }}
/>
<Stages
canDelete={canDelete}
canUpdate={canUpdate}
stages={formik.values?.stages}
/>
</Flex> </Flex>
)} )}
</Layout.Root> </Layout.Root>

View File

@ -1,13 +1,18 @@
import React from 'react'; import React from 'react';
import { ProtectedPage } from '../../components/ProtectedPage'; import { CheckPagePermissions } from '@strapi/helper-plugin';
import { useSelector } from 'react-redux';
import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors';
import { ReviewWorkflowsEditView } from './EditView'; import { ReviewWorkflowsEditView } from './EditView';
export default function () { export default function () {
const permissions = useSelector(selectAdminPermissions);
return ( return (
<ProtectedPage> <CheckPagePermissions permissions={permissions.settings['review-workflows'].main}>
<ReviewWorkflowsEditView /> <ReviewWorkflowsEditView />
</ProtectedPage> </CheckPagePermissions>
); );
} }

View File

@ -23,15 +23,18 @@ import {
useAPIErrorHandler, useAPIErrorHandler,
useFetchClient, useFetchClient,
useNotification, useNotification,
useRBAC,
useTracking, useTracking,
} from '@strapi/helper-plugin'; } from '@strapi/helper-plugin';
import { Pencil, Plus, Trash } from '@strapi/icons'; import { Pencil, Plus, Trash } from '@strapi/icons';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useMutation } from 'react-query'; import { useMutation } from 'react-query';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes'; import { useContentTypes } from '../../../../../../../../admin/src/hooks/useContentTypes';
import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors';
import { useLicenseLimits } from '../../../../../../hooks'; import { useLicenseLimits } from '../../../../../../hooks';
import * as Layout from '../../components/Layout'; import * as Layout from '../../components/Layout';
import * as LimitsModal from '../../components/LimitsModal'; import * as LimitsModal from '../../components/LimitsModal';
@ -76,6 +79,10 @@ export function ReviewWorkflowsListView() {
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits(); const { getFeature, isLoading: isLicenseLoading } = useLicenseLimits();
const { trackUsage } = useTracking(); const { trackUsage } = useTracking();
const permissions = useSelector(selectAdminPermissions);
const {
allowedActions: { canCreate, canDelete },
} = useRBAC(permissions.settings['review-workflows']);
const limits = getFeature('review-workflows'); const limits = getFeature('review-workflows');
@ -166,6 +173,7 @@ export function ReviewWorkflowsListView() {
<Layout.Header <Layout.Header
primaryAction={ primaryAction={
<LinkButton <LinkButton
disabled={!canCreate}
startIcon={<Plus />} startIcon={<Plus />}
size="S" size="S"
to="/settings/review-workflows/create" to="/settings/review-workflows/create"
@ -218,31 +226,36 @@ export function ReviewWorkflowsListView() {
colCount={3} colCount={3}
footer={ footer={
// TODO: we should be able to use a link here instead of an (inaccessible onClick) handler // TODO: we should be able to use a link here instead of an (inaccessible onClick) handler
<TFooter canCreate && (
icon={<Plus />} <TFooter
onClick={() => { icon={<Plus />}
/** onClick={() => {
* If the current license has a workflow limit: /**
* check if the total count of workflows exceeds that limit * If the current license has a workflow limit:
* * check if the total count of workflows exceeds that limit
* If the current license does not have a limit (e.g. offline license): *
* allow the user to navigate to the create-view. In case they exceed the * If the current license does not have a limit (e.g. offline license):
* current hard-limit of 200 they will see an error thrown by the API. * allow the user to navigate to the create-view. In case they exceed the
*/ * current hard-limit of 200 they will see an error thrown by the API.
*/
if (limits?.workflows && meta?.workflowCount >= parseInt(limits.workflows, 10)) { if (
setShowLimitModal(true); limits?.workflows &&
} else { meta?.workflowCount >= parseInt(limits.workflows, 10)
push('/settings/review-workflows/create'); ) {
trackUsage('willCreateWorkflow'); setShowLimitModal(true);
} } else {
}} push('/settings/review-workflows/create');
> trackUsage('willCreateWorkflow');
{formatMessage({ }
id: 'Settings.review-workflows.list.page.create', }}
defaultMessage: 'Create new workflow', >
})} {formatMessage({
</TFooter> id: 'Settings.review-workflows.list.page.create',
defaultMessage: 'Create new workflow',
})}
</TFooter>
)
} }
rowCount={1} rowCount={1}
> >
@ -334,7 +347,7 @@ export function ReviewWorkflowsListView() {
}, },
{ name: 'Default workflow' } { name: 'Default workflow' }
)} )}
disabled={workflows.length === 1} disabled={workflows.length === 1 || !canDelete}
icon={<Trash />} icon={<Trash />}
noBorder noBorder
onClick={() => { onClick={() => {

View File

@ -1,13 +1,18 @@
import React from 'react'; import React from 'react';
import { ProtectedPage } from '../../components/ProtectedPage'; import { CheckPagePermissions } from '@strapi/helper-plugin';
import { useSelector } from 'react-redux';
import { selectAdminPermissions } from '../../../../../../../../admin/src/pages/App/selectors';
import { ReviewWorkflowsListView } from './ListView'; import { ReviewWorkflowsListView } from './ListView';
export default function () { export default function () {
const permissions = useSelector(selectAdminPermissions);
return ( return (
<ProtectedPage> <CheckPagePermissions permissions={permissions.settings['review-workflows'].main}>
<ReviewWorkflowsListView /> <ReviewWorkflowsListView />
</ProtectedPage> </CheckPagePermissions>
); );
} }