mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
491 lines
20 KiB
TypeScript
491 lines
20 KiB
TypeScript
import type React from 'react';
|
|
import { type FormEvent, useState } from 'react';
|
|
import {
|
|
Button,
|
|
capitalize,
|
|
Checkbox,
|
|
styled,
|
|
TextField,
|
|
Tooltip,
|
|
} from '@mui/material';
|
|
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
|
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
|
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
|
import useToast from 'hooks/useToast';
|
|
import useProjectAccess, {
|
|
ENTITY_TYPE,
|
|
type IProjectAccess,
|
|
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
|
import type { IRole } from 'interfaces/role';
|
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
|
import type { IUser } from 'interfaces/user';
|
|
import type { IGroup } from 'interfaces/group';
|
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { GO_BACK } from 'constants/navigate';
|
|
import {
|
|
PA_ASSIGN_CREATE_ID,
|
|
PA_ROLE_ID,
|
|
PA_USERS_GROUPS_ID,
|
|
PA_USERS_GROUPS_TITLE_ID,
|
|
} from 'utils/testIds';
|
|
import { caseInsensitiveSearch } from 'utils/search';
|
|
import type { IServiceAccount } from 'interfaces/service-account';
|
|
import { MultipleRoleSelect } from 'component/common/MultipleRoleSelect/MultipleRoleSelect';
|
|
import type { IUserProjectRole } from '../../../../interfaces/userProjectRoles';
|
|
import { useCheckProjectPermissions } from 'hooks/useHasAccess';
|
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
|
import AutocompleteVirtual from 'component/common/AutocompleteVirtual/AutcompleteVirtual';
|
|
|
|
const StyledForm = styled('form')(() => ({
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
height: '100%',
|
|
}));
|
|
|
|
const StyledInputDescription = styled('p')(({ theme }) => ({
|
|
color: theme.palette.text.secondary,
|
|
marginBottom: theme.spacing(1),
|
|
}));
|
|
|
|
const StyledAutocompleteWrapper = styled('div')(({ theme }) => ({
|
|
'& > div:first-of-type': {
|
|
width: '100%',
|
|
maxWidth: theme.spacing(50),
|
|
marginBottom: theme.spacing(2),
|
|
},
|
|
}));
|
|
|
|
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
|
marginTop: theme.spacing(6),
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
}));
|
|
|
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
|
marginLeft: theme.spacing(3),
|
|
}));
|
|
|
|
const StyledGroupOption = styled('div')(({ theme }) => ({
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
'& > span:last-of-type': {
|
|
color: theme.palette.text.secondary,
|
|
},
|
|
}));
|
|
|
|
const StyledUserOption = styled('div')(({ theme }) => ({
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
'& > span:first-of-type': {
|
|
color: theme.palette.text.secondary,
|
|
},
|
|
}));
|
|
|
|
interface IAccessOption {
|
|
id: number;
|
|
entity: IUser | IGroup;
|
|
type: ENTITY_TYPE;
|
|
}
|
|
|
|
interface IProjectAccessAssignProps {
|
|
selected?: IProjectAccess;
|
|
accesses: IProjectAccess[];
|
|
users: IUser[];
|
|
serviceAccounts: IServiceAccount[];
|
|
groups: IGroup[];
|
|
roles: IRole[];
|
|
userRoles: IUserProjectRole[];
|
|
}
|
|
|
|
export const ProjectAccessAssign = ({
|
|
selected,
|
|
accesses,
|
|
users,
|
|
serviceAccounts,
|
|
groups,
|
|
roles,
|
|
userRoles,
|
|
}: IProjectAccessAssignProps) => {
|
|
const { uiConfig } = useUiConfig();
|
|
const { flags } = uiConfig;
|
|
const entityType = flags.UG ? 'user / group' : 'user';
|
|
|
|
const projectId = useRequiredPathParam('projectId');
|
|
const { refetchProjectAccess } = useProjectAccess(projectId);
|
|
const { addAccessToProject, setUserRoles, setGroupRoles, loading } =
|
|
useProjectApi();
|
|
const edit = Boolean(selected);
|
|
|
|
const checkPermissions = useCheckProjectPermissions(projectId);
|
|
|
|
const { setToastData, setToastApiError } = useToast();
|
|
const navigate = useNavigate();
|
|
|
|
const options = [
|
|
...groups
|
|
.filter(
|
|
(group: IGroup) =>
|
|
edit ||
|
|
!accesses.some(
|
|
({ entity: { id }, type }) =>
|
|
group.id === id && type === ENTITY_TYPE.GROUP,
|
|
),
|
|
)
|
|
.map((group: IGroup) => ({
|
|
id: group.id,
|
|
entity: group,
|
|
type: ENTITY_TYPE.GROUP,
|
|
})),
|
|
...users
|
|
.filter(
|
|
(user: IUser) =>
|
|
edit ||
|
|
!accesses.some(
|
|
({ entity: { id }, type }) =>
|
|
user.id === id && type === ENTITY_TYPE.USER,
|
|
),
|
|
)
|
|
.sort((a: IUser, b: IUser) => {
|
|
const aName = a.name || a.username || '';
|
|
const bName = b.name || b.username || '';
|
|
return aName.localeCompare(bName);
|
|
})
|
|
.map((user: IUser) => ({
|
|
id: user.id,
|
|
entity: user,
|
|
type: ENTITY_TYPE.USER,
|
|
})),
|
|
...serviceAccounts
|
|
.filter(
|
|
(serviceAccount: IServiceAccount) =>
|
|
edit ||
|
|
!accesses.some(
|
|
({ entity: { id }, type }) =>
|
|
serviceAccount.id === id &&
|
|
type === ENTITY_TYPE.SERVICE_ACCOUNT,
|
|
),
|
|
)
|
|
.sort((a: IServiceAccount, b: IServiceAccount) => {
|
|
const aName = a.name || a.username || '';
|
|
const bName = b.name || b.username || '';
|
|
return aName.localeCompare(bName);
|
|
})
|
|
.map((serviceAccount: IServiceAccount) => ({
|
|
id: serviceAccount.id,
|
|
entity: serviceAccount,
|
|
type: ENTITY_TYPE.SERVICE_ACCOUNT,
|
|
})),
|
|
];
|
|
|
|
const [selectedOptions, setSelectedOptions] = useState<IAccessOption[]>(
|
|
() =>
|
|
options.filter(
|
|
({ id, type }) =>
|
|
id === selected?.entity.id && type === selected?.type,
|
|
),
|
|
);
|
|
const [selectedRoles, setRoles] = useState<IRole[]>(
|
|
roles.filter(({ id }) => selected?.entity?.roles?.includes(id)),
|
|
);
|
|
|
|
const payload = {
|
|
roles: selectedRoles.map(({ id }) => id),
|
|
groups: selectedOptions
|
|
?.filter(({ type }) => type === ENTITY_TYPE.GROUP)
|
|
.map(({ id }) => id),
|
|
users: selectedOptions
|
|
?.filter(
|
|
({ type }) =>
|
|
type === ENTITY_TYPE.USER ||
|
|
type === ENTITY_TYPE.SERVICE_ACCOUNT,
|
|
)
|
|
.map(({ id }) => id),
|
|
};
|
|
|
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
|
|
if (!isValid) return;
|
|
try {
|
|
if (!edit) {
|
|
await addAccessToProject(projectId, payload);
|
|
} else if (
|
|
selected?.type === ENTITY_TYPE.USER ||
|
|
selected?.type === ENTITY_TYPE.SERVICE_ACCOUNT
|
|
) {
|
|
await setUserRoles(
|
|
projectId,
|
|
selectedRoles.map(({ id }) => id),
|
|
selected.entity.id,
|
|
);
|
|
} else if (selected?.type === ENTITY_TYPE.GROUP) {
|
|
await setGroupRoles(
|
|
projectId,
|
|
selectedRoles.map(({ id }) => id),
|
|
selected.entity.id,
|
|
);
|
|
}
|
|
refetchProjectAccess();
|
|
navigate(GO_BACK);
|
|
setToastData({
|
|
text: `${selectedOptions.length} ${
|
|
selectedOptions.length === 1 ? 'access' : 'accesses'
|
|
} ${!edit ? 'assigned' : 'edited'} successfully`,
|
|
type: 'success',
|
|
});
|
|
} catch (error: unknown) {
|
|
setToastApiError(formatUnknownError(error));
|
|
}
|
|
};
|
|
|
|
const formatApiCode = () => {
|
|
if (edit) {
|
|
return `curl --location --request PUT '${
|
|
uiConfig.unleashUrl
|
|
}/api/admin/projects/${projectId}/${
|
|
selected?.type === ENTITY_TYPE.USER ||
|
|
selected?.type === ENTITY_TYPE.SERVICE_ACCOUNT
|
|
? 'users'
|
|
: 'groups'
|
|
}/${selected?.entity.id}/roles' \\
|
|
--header 'Authorization: INSERT_API_KEY' \\
|
|
--header 'Content-Type: application/json' \\
|
|
--data-raw '${JSON.stringify({ roles: payload.roles }, undefined, 2)}'`;
|
|
}
|
|
return `curl --location --request POST '${
|
|
uiConfig.unleashUrl
|
|
}/api/admin/projects/${projectId}/access' \\
|
|
--header 'Authorization: INSERT_API_KEY' \\
|
|
--header 'Content-Type: application/json' \\
|
|
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
|
|
};
|
|
|
|
const createRootGroupWarning = (group?: IGroup): string | undefined => {
|
|
if (group && Boolean(group.rootRole)) {
|
|
return 'This group has a root role associated with it. Groups with a root role association cannot be assigned to projects, and users in this group already have the role applied globally.';
|
|
}
|
|
};
|
|
|
|
const renderOption = (
|
|
props: React.HTMLAttributes<HTMLLIElement>,
|
|
option: IAccessOption,
|
|
selected: boolean,
|
|
) => {
|
|
let optionGroup: IGroup | undefined, optionUser: IUser | undefined;
|
|
if (option.type === ENTITY_TYPE.GROUP) {
|
|
optionGroup = option.entity as IGroup;
|
|
} else {
|
|
optionUser = option.entity as IUser;
|
|
}
|
|
return (
|
|
<Tooltip title={createRootGroupWarning(optionGroup)}>
|
|
<span>
|
|
<li {...props}>
|
|
<Checkbox
|
|
icon={<CheckBoxOutlineBlankIcon fontSize='small' />}
|
|
checkedIcon={<CheckBoxIcon fontSize='small' />}
|
|
style={{ marginRight: 8 }}
|
|
checked={selected}
|
|
/>
|
|
<ConditionallyRender
|
|
condition={option.type === ENTITY_TYPE.GROUP}
|
|
show={
|
|
<span>
|
|
<StyledGroupOption>
|
|
<span>{optionGroup?.name}</span>
|
|
<span>
|
|
{optionGroup?.userCount} users
|
|
</span>
|
|
</StyledGroupOption>
|
|
</span>
|
|
}
|
|
elseShow={
|
|
<StyledUserOption>
|
|
<span>
|
|
{optionUser?.name ||
|
|
optionUser?.username}
|
|
</span>
|
|
<span>
|
|
{optionUser?.name &&
|
|
optionUser?.username
|
|
? optionUser?.username
|
|
: optionUser?.email}
|
|
</span>
|
|
</StyledUserOption>
|
|
}
|
|
/>
|
|
</li>
|
|
</span>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const isValid = selectedOptions.length > 0 && selectedRoles.length > 0;
|
|
const displayAllRoles =
|
|
checkPermissions(ADMIN) ||
|
|
userRoles.length === 0 ||
|
|
userRoles.some((userRole) => userRole.name === 'Owner');
|
|
|
|
let filteredRoles: IRole[];
|
|
if (displayAllRoles) {
|
|
filteredRoles = roles;
|
|
} else {
|
|
filteredRoles = roles.filter((role) =>
|
|
userRoles.some((userrole) => role.id === userrole.id),
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SidebarModal
|
|
open
|
|
onClose={() => navigate(GO_BACK)}
|
|
label={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`}
|
|
>
|
|
<FormTemplate
|
|
loading={loading}
|
|
modal
|
|
title={`${!edit ? 'Assign' : 'Edit'} ${entityType} access`}
|
|
description='Custom project roles allow you to fine-tune access rights and permissions within your projects.'
|
|
documentationLink='https://docs.getunleash.io/how-to/how-to-create-and-assign-custom-project-roles'
|
|
documentationLinkLabel='Project access documentation'
|
|
formatApiCode={formatApiCode}
|
|
>
|
|
<StyledForm onSubmit={handleSubmit}>
|
|
<div>
|
|
<StyledInputDescription
|
|
data-testid={PA_USERS_GROUPS_TITLE_ID}
|
|
>
|
|
Select the {entityType}
|
|
</StyledInputDescription>
|
|
<StyledAutocompleteWrapper>
|
|
<AutocompleteVirtual
|
|
data-testid={PA_USERS_GROUPS_ID}
|
|
size='small'
|
|
multiple
|
|
openOnFocus
|
|
limitTags={10}
|
|
disableCloseOnSelect
|
|
disabled={edit}
|
|
value={selectedOptions}
|
|
getOptionDisabled={(option) => {
|
|
if (option.type === ENTITY_TYPE.GROUP) {
|
|
const optionGroup =
|
|
option.entity as IGroup;
|
|
return Boolean(optionGroup.rootRole);
|
|
}
|
|
return false;
|
|
}}
|
|
onChange={(event, newValue, reason) => {
|
|
if (
|
|
event.type === 'keydown' &&
|
|
(event as React.KeyboardEvent).key ===
|
|
'Backspace' &&
|
|
reason === 'removeOption'
|
|
) {
|
|
return;
|
|
}
|
|
setSelectedOptions(newValue);
|
|
}}
|
|
options={options}
|
|
groupBy={(option) => option.type}
|
|
renderOption={(props, option, { selected }) =>
|
|
renderOption(props, option, selected)
|
|
}
|
|
getOptionLabel={(option: IAccessOption) => {
|
|
if (
|
|
option.type === ENTITY_TYPE.USER ||
|
|
option.type ===
|
|
ENTITY_TYPE.SERVICE_ACCOUNT
|
|
) {
|
|
const optionUser =
|
|
option.entity as IUser;
|
|
return (
|
|
optionUser.email ||
|
|
optionUser.name ||
|
|
optionUser.username ||
|
|
''
|
|
);
|
|
} else {
|
|
return option.entity.name;
|
|
}
|
|
}}
|
|
filterOptions={(options, { inputValue }) =>
|
|
options.filter((option: IAccessOption) => {
|
|
if (
|
|
option.type === ENTITY_TYPE.USER ||
|
|
option.type ===
|
|
ENTITY_TYPE.SERVICE_ACCOUNT
|
|
) {
|
|
const optionUser =
|
|
option.entity as IUser;
|
|
return (
|
|
caseInsensitiveSearch(
|
|
inputValue,
|
|
optionUser.email,
|
|
) ||
|
|
caseInsensitiveSearch(
|
|
inputValue,
|
|
optionUser.name,
|
|
) ||
|
|
caseInsensitiveSearch(
|
|
inputValue,
|
|
optionUser.username,
|
|
)
|
|
);
|
|
}
|
|
return caseInsensitiveSearch(
|
|
inputValue,
|
|
option.entity.name,
|
|
);
|
|
})
|
|
}
|
|
isOptionEqualToValue={(option, value) =>
|
|
option.type === value.type &&
|
|
option.entity.id === value.entity.id
|
|
}
|
|
renderInput={(params) => (
|
|
<TextField
|
|
{...params}
|
|
label={capitalize(entityType)}
|
|
/>
|
|
)}
|
|
/>
|
|
</StyledAutocompleteWrapper>
|
|
<StyledInputDescription>
|
|
Select the role to assign for this project
|
|
</StyledInputDescription>
|
|
<StyledAutocompleteWrapper>
|
|
<MultipleRoleSelect
|
|
data-testid={PA_ROLE_ID}
|
|
roles={filteredRoles}
|
|
value={selectedRoles}
|
|
setValue={setRoles}
|
|
/>
|
|
</StyledAutocompleteWrapper>
|
|
</div>
|
|
|
|
<StyledButtonContainer>
|
|
<Button
|
|
data-testid={PA_ASSIGN_CREATE_ID}
|
|
type='submit'
|
|
variant='contained'
|
|
color='primary'
|
|
disabled={!isValid}
|
|
>
|
|
{edit ? 'Save' : `Assign ${entityType}`}
|
|
</Button>
|
|
<StyledCancelButton onClick={() => navigate(GO_BACK)}>
|
|
Cancel
|
|
</StyledCancelButton>
|
|
</StyledButtonContainer>
|
|
</StyledForm>
|
|
</FormTemplate>
|
|
</SidebarModal>
|
|
);
|
|
};
|