Skip to content

Commit

Permalink
feat: multiple project roles (#4512)
Browse files Browse the repository at this point in the history
https://linear.app/unleash/issue/2-1128/change-the-api-to-support-adding-multiple-roles-to-a-usergroup-on-a

https://linear.app/unleash/issue/2-1125/be-able-to-fetch-all-roles-for-a-user-in-a-project

https://linear.app/unleash/issue/2-1127/adapt-the-ui-to-be-able-to-do-a-multi-select-on-role-permissions-for

- Allows assigning project roles to groups with root roles
- Implements new methods that support assigning, editing, removing and
retrieving multiple project roles in project access, along with other
auxiliary methods
- Adds new events for updating and removing assigned roles
- Adapts `useProjectApi` to new methods that use new endpoints that
support multiple roles
- Adds the `multipleRoles` feature flag that controls the possibility of
selecting multiple roles on the UI
- Adapts `ProjectAccessAssign` to support multiple role, using the new
methods
- Adds a new `MultipleRoleSelect` component that allows you to select
multiple roles based on the `RoleSelect` component
- Adapts the `RoleCell` component to support either a single role or
multiple roles
- Updates the `access.spec.ts` Cypress e2e test to reflect our new logic
- Updates `access-service.e2e.test.ts` with tests covering the multiple
roles logic and covering some corner cases
- Updates `project-service.e2e.test.ts` to adapt to the new logic,
adding a test that covers adding access with `[roles], [groups],
[users]`
- Misc refactors and boy scouting


![image](https://github.com/Unleash/unleash/assets/14320932/d1cc7626-9387-4ab8-9860-cd293a0d4f62)

---------

Co-authored-by: David Leek <david@getunleash.io>
Co-authored-by: Mateusz Kwasniewski <kwasniewski.mateusz@gmail.com>
Co-authored-by: Nuno Góis <github@nunogois.com>
  • Loading branch information
4 people committed Aug 25, 2023
1 parent 1f96c16 commit 21b4ada
Show file tree
Hide file tree
Showing 26 changed files with 1,927 additions and 430 deletions.
41 changes: 37 additions & 4 deletions frontend/cypress/integration/projects/access.spec.ts
Expand Up @@ -47,6 +47,19 @@ describe('project-access', () => {
id: groupAndProjectName,
name: groupAndProjectName,
});

cy.intercept('GET', `${baseUrl}/api/admin/ui-config`, req => {
req.headers['cache-control'] =
'no-cache, no-store, must-revalidate';
req.on('response', res => {
if (res.body) {
res.body.flags = {
...res.body.flags,
multipleRoles: true,
};
}
});
});
});

after(() => {
Expand Down Expand Up @@ -76,7 +89,7 @@ describe('project-access', () => {

cy.intercept(
'POST',
`/api/admin/projects/${groupAndProjectName}/role/4/access`
`/api/admin/projects/${groupAndProjectName}/access`
).as('assignAccess');

cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click();
Expand All @@ -95,7 +108,7 @@ describe('project-access', () => {

cy.intercept(
'POST',
`/api/admin/projects/${groupAndProjectName}/role/4/access`
`/api/admin/projects/${groupAndProjectName}/access`
).as('assignAccess');

cy.get(`[data-testid='${PA_USERS_GROUPS_ID}']`).click();
Expand All @@ -114,9 +127,10 @@ describe('project-access', () => {

cy.intercept(
'PUT',
`/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5`
`/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles`
).as('editAccess');

cy.get(`[data-testid='CancelIcon']`).last().click();
cy.get(`[data-testid='${PA_ROLE_ID}']`).click();
cy.contains('update feature toggles within a project').click({
force: true,
Expand All @@ -128,12 +142,31 @@ describe('project-access', () => {
cy.get("td span:contains('Member')").should('have.length', 1);
});

it('can edit role to multiple roles', () => {
cy.get(`[data-testid='${PA_EDIT_BUTTON_ID}']`).first().click();

cy.intercept(
'PUT',
`/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles`
).as('editAccess');

cy.get(`[data-testid='${PA_ROLE_ID}']`).click();
cy.contains('full control over the project').click({
force: true,
});

cy.get(`[data-testid='${PA_ASSIGN_CREATE_ID}']`).click();
cy.wait('@editAccess');
cy.get("td span:contains('Owner')").should('have.length', 2);
cy.get("td span:contains('2 roles')").should('have.length', 1);
});

it('can remove access', () => {
cy.get(`[data-testid='${PA_REMOVE_BUTTON_ID}']`).first().click();

cy.intercept(
'DELETE',
`/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles/5`
`/api/admin/projects/${groupAndProjectName}/groups/${groupIds[0]}/roles`
).as('removeAccess');

cy.contains("Yes, I'm sure").click();
Expand Down
Expand Up @@ -93,9 +93,13 @@ export const ServiceAccountsTable = () => {
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
Cell: ({ row: { original: serviceAccount }, value }: any) => (
<RoleCell value={value} roleId={serviceAccount.rootRole} />
),
Cell: ({
row: { original: serviceAccount },
value,
}: {
row: { original: IServiceAccount };
value: string;
}) => <RoleCell value={value} role={serviceAccount.rootRole} />,
maxWidth: 120,
},
{
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/component/admin/users/UsersList/UsersList.tsx
Expand Up @@ -125,9 +125,13 @@ const UsersList = () => {
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
Cell: ({ row: { original: user }, value }: any) => (
<RoleCell value={value} roleId={user.rootRole} />
),
Cell: ({
row: { original: user },
value,
}: {
row: { original: IUser };
value: string;
}) => <RoleCell value={value} role={user.rootRole} />,
maxWidth: 120,
},
{
Expand Down
@@ -0,0 +1,89 @@
import {
Autocomplete,
AutocompleteProps,
AutocompleteRenderOptionState,
Checkbox,
TextField,
styled,
} from '@mui/material';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import { IRole } from 'interfaces/role';
import { RoleDescription } from '../RoleDescription/RoleDescription';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';

const StyledRoleOption = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
'& > span:last-of-type': {
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
},
}));

interface IMultipleRoleSelectProps
extends Partial<AutocompleteProps<IRole, true, false, false>> {
roles: IRole[];
value: IRole[];
setValue: (role: IRole[]) => void;
required?: boolean;
}

export const MultipleRoleSelect = ({
roles,
value,
setValue,
required,
...rest
}: IMultipleRoleSelectProps) => {
const renderRoleOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: IRole,
state: AutocompleteRenderOptionState
) => (
<li {...props}>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
style={{ marginRight: 8 }}
checked={state.selected}
/>
<StyledRoleOption>
<span>{option.name}</span>
<span>{option.description}</span>
</StyledRoleOption>
</li>
);

return (
<>
<Autocomplete
multiple
disableCloseOnSelect
openOnFocus
size="small"
value={value}
onChange={(_, roles) => setValue(roles)}
options={roles}
renderOption={renderRoleOption}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField {...params} label="Role" required={required} />
)}
{...rest}
/>
<ConditionallyRender
condition={value.length > 0}
show={() =>
value.map(({ id }) => (
<RoleDescription
key={id}
sx={{ marginTop: 1 }}
roleId={id}
/>
))
}
/>
</>
);
};
Expand Up @@ -21,7 +21,7 @@ const StyledDescription = styled('div', {
: theme.palette.neutral.light,
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
borderRadius: theme.shape.borderRadiusMedium,
borderRadius: tooltip ? 0 : theme.shape.borderRadiusMedium,
}));

const StyledDescriptionBlock = styled('div')(({ theme }) => ({
Expand Down
42 changes: 37 additions & 5 deletions frontend/src/component/common/Table/cells/RoleCell/RoleCell.tsx
Expand Up @@ -3,20 +3,52 @@ import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { RoleDescription } from 'component/common/RoleDescription/RoleDescription';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { styled } from '@mui/material';

interface IRoleCellProps {
roleId: number;
const StyledRoleDescriptions = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
'& > *:not(:last-child)': {
borderBottom: `1px solid ${theme.palette.divider}`,
paddingBottom: theme.spacing(1),
},
}));

type TSingleRoleProps = {
value: string;
}
role: number;
roles?: never;
};

export const RoleCell: VFC<IRoleCellProps> = ({ roleId, value }) => {
type TMultipleRolesProps = {
value: string;
roles: number[];
role?: never;
};

type TRoleCellProps = TSingleRoleProps | TMultipleRolesProps;

export const RoleCell: VFC<TRoleCellProps> = ({ role, roles, value }) => {
const { isEnterprise } = useUiConfig();

if (isEnterprise()) {
const rolesArray = roles ? roles : [role];

return (
<TextCell>
<TooltipLink
tooltip={<RoleDescription roleId={roleId} tooltip />}
tooltip={
<StyledRoleDescriptions>
{rolesArray.map(roleId => (
<RoleDescription
key={roleId}
roleId={roleId}
tooltip
/>
))}
</StyledRoleDescriptions>
}
>
{value}
</TooltipLink>
Expand Down

0 comments on commit 21b4ada

Please sign in to comment.