mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-27 13:49:10 +02:00
Merge remote-tracking branch 'origin/task/constraint_card_adjustmnets' into task/constraint_card_adjustmnets
This commit is contained in:
commit
5ea7e8781f
@ -14,6 +14,7 @@ import { GridRow } from 'component/common/GridRow/GridRow';
|
|||||||
import { GridCol } from 'component/common/GridCol/GridCol';
|
import { GridCol } from 'component/common/GridCol/GridCol';
|
||||||
import { GridColLink } from './GridColLink/GridColLink';
|
import { GridColLink } from './GridColLink/GridColLink';
|
||||||
import { STRIPE } from 'component/admin/billing/flags';
|
import { STRIPE } from 'component/admin/billing/flags';
|
||||||
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
|
||||||
const StyledPlanBox = styled('aside')(({ theme }) => ({
|
const StyledPlanBox = styled('aside')(({ theme }) => ({
|
||||||
padding: theme.spacing(2.5),
|
padding: theme.spacing(2.5),
|
||||||
@ -30,15 +31,6 @@ const StyledInfoLabel = styled(Typography)(({ theme }) => ({
|
|||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledPlanBadge = styled('span')(({ theme }) => ({
|
|
||||||
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
|
|
||||||
borderRadius: theme.shape.borderRadiusLarge,
|
|
||||||
fontSize: theme.fontSizes.smallerBody,
|
|
||||||
backgroundColor: theme.palette.statusBadge.success,
|
|
||||||
color: theme.palette.success.dark,
|
|
||||||
fontWeight: theme.fontWeight.bold,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledPlanSpan = styled('span')(({ theme }) => ({
|
const StyledPlanSpan = styled('span')(({ theme }) => ({
|
||||||
fontSize: '3.25rem',
|
fontSize: '3.25rem',
|
||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
@ -116,7 +108,7 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
|
|||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StyledPlanBadge>Current plan</StyledPlanBadge>
|
<Badge color="success">Current plan</Badge>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<GridRow sx={theme => ({ marginBottom: theme.spacing(3) })}>
|
<GridRow sx={theme => ({ marginBottom: theme.spacing(3) })}>
|
||||||
<GridCol>
|
<GridCol>
|
||||||
|
@ -0,0 +1,97 @@
|
|||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { GroupForm } from '../GroupForm/GroupForm';
|
||||||
|
import { useGroupForm } from '../hooks/useGroupForm';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { UG_CREATE_BTN_ID } from 'utils/testIds';
|
||||||
|
import { Button } from '@mui/material';
|
||||||
|
import { CREATE } from 'constants/misc';
|
||||||
|
|
||||||
|
export const CreateGroup = () => {
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
description,
|
||||||
|
setDescription,
|
||||||
|
users,
|
||||||
|
setUsers,
|
||||||
|
getGroupPayload,
|
||||||
|
clearErrors,
|
||||||
|
errors,
|
||||||
|
} = useGroupForm();
|
||||||
|
|
||||||
|
const { createGroup, loading } = useGroupApi();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
const payload = getGroupPayload();
|
||||||
|
try {
|
||||||
|
const group = await createGroup(payload);
|
||||||
|
navigate(`/admin/groups/${group.id}`);
|
||||||
|
setToastData({
|
||||||
|
title: 'Group created successfully',
|
||||||
|
text: 'Now you can start using your group.',
|
||||||
|
confetti: true,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () => {
|
||||||
|
return `curl --location --request POST '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/groups' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
title="Create group"
|
||||||
|
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||||
|
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||||
|
documentationLinkLabel="Groups documentation"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<GroupForm
|
||||||
|
name={name}
|
||||||
|
description={description}
|
||||||
|
users={users}
|
||||||
|
setName={setName}
|
||||||
|
setDescription={setDescription}
|
||||||
|
setUsers={setUsers}
|
||||||
|
errors={errors}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
mode={CREATE}
|
||||||
|
clearErrors={clearErrors}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
data-testid={UG_CREATE_BTN_ID}
|
||||||
|
>
|
||||||
|
Create group
|
||||||
|
</Button>
|
||||||
|
</GroupForm>
|
||||||
|
</FormTemplate>
|
||||||
|
);
|
||||||
|
};
|
94
frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
Normal file
94
frontend/src/component/admin/groups/EditGroup/EditGroup.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { GroupForm } from '../GroupForm/GroupForm';
|
||||||
|
import { useGroupForm } from '../hooks/useGroupForm';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { Button } from '@mui/material';
|
||||||
|
import { EDIT } from 'constants/misc';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
|
||||||
|
|
||||||
|
export const EditGroup = () => {
|
||||||
|
const groupId = Number(useRequiredPathParam('groupId'));
|
||||||
|
const { group, refetchGroup } = useGroup(groupId);
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
description,
|
||||||
|
setDescription,
|
||||||
|
users,
|
||||||
|
setUsers,
|
||||||
|
getGroupPayload,
|
||||||
|
clearErrors,
|
||||||
|
errors,
|
||||||
|
} = useGroupForm(group?.name, group?.description, group?.users);
|
||||||
|
|
||||||
|
const { updateGroup, loading } = useGroupApi();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearErrors();
|
||||||
|
|
||||||
|
const payload = getGroupPayload();
|
||||||
|
try {
|
||||||
|
await updateGroup(groupId, payload);
|
||||||
|
refetchGroup();
|
||||||
|
navigate(-1);
|
||||||
|
setToastData({
|
||||||
|
title: 'Group updated successfully',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () => {
|
||||||
|
return `curl --location --request PUT '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/groups/${groupId}' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(getGroupPayload(), undefined, 2)}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
title="Edit group"
|
||||||
|
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||||
|
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||||
|
documentationLinkLabel="Groups documentation"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<GroupForm
|
||||||
|
name={name}
|
||||||
|
description={description}
|
||||||
|
users={users}
|
||||||
|
setName={setName}
|
||||||
|
setDescription={setDescription}
|
||||||
|
setUsers={setUsers}
|
||||||
|
errors={errors}
|
||||||
|
handleSubmit={handleSubmit}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
mode={EDIT}
|
||||||
|
clearErrors={clearErrors}
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="contained" color="primary">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</GroupForm>
|
||||||
|
</FormTemplate>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,160 @@
|
|||||||
|
import { Button, styled } from '@mui/material';
|
||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
|
import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
|
||||||
|
import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { IGroup, IGroupUser } from 'interfaces/group';
|
||||||
|
import { FC, FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
import { GroupFormUsersSelect } from 'component/admin/groups/GroupForm/GroupFormUsersSelect/GroupFormUsersSelect';
|
||||||
|
import { GroupFormUsersTable } from 'component/admin/groups/GroupForm/GroupFormUsersTable/GroupFormUsersTable';
|
||||||
|
|
||||||
|
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 StyledButtonContainer = styled('div')(() => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IAddGroupUserProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
group: IGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddGroupUser: FC<IAddGroupUserProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
group,
|
||||||
|
}) => {
|
||||||
|
const { refetchGroup } = useGroup(group.id);
|
||||||
|
const { updateGroup, loading } = useGroupApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<IGroupUser[]>(group.users);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsers(group.users);
|
||||||
|
}, [group.users, open]);
|
||||||
|
|
||||||
|
const newUsers = useMemo(() => {
|
||||||
|
return users.filter(
|
||||||
|
user => !group.users.some(({ id }) => id === user.id)
|
||||||
|
);
|
||||||
|
}, [group.users, users]);
|
||||||
|
|
||||||
|
const payload = useMemo(() => {
|
||||||
|
const addUsers = [...group.users, ...newUsers];
|
||||||
|
return {
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
users: addUsers.map(({ id, role }) => ({
|
||||||
|
user: { id },
|
||||||
|
role,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [group, newUsers]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message =
|
||||||
|
newUsers.length === 1
|
||||||
|
? `${
|
||||||
|
newUsers[0].name ||
|
||||||
|
newUsers[0].username ||
|
||||||
|
newUsers[0].email
|
||||||
|
} added to the group`
|
||||||
|
: `${newUsers.length} users added to the group`;
|
||||||
|
await updateGroup(group.id, payload);
|
||||||
|
refetchGroup();
|
||||||
|
setOpen(false);
|
||||||
|
setToastData({
|
||||||
|
title: message,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () => {
|
||||||
|
return `curl --location --request PUT '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/groups/${group.id}' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label="Add user"
|
||||||
|
>
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
modal
|
||||||
|
title="Add user"
|
||||||
|
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||||
|
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||||
|
documentationLinkLabel="Groups documentation"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<StyledForm onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<StyledInputDescription>
|
||||||
|
Add users to this group
|
||||||
|
</StyledInputDescription>
|
||||||
|
<GroupFormUsersSelect
|
||||||
|
users={users}
|
||||||
|
setUsers={setUsers}
|
||||||
|
/>
|
||||||
|
<GroupFormUsersTable
|
||||||
|
users={newUsers}
|
||||||
|
setUsers={setUsers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<StyledCancelButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
</FormTemplate>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,180 @@
|
|||||||
|
import { Button, MenuItem, Select, styled } from '@mui/material';
|
||||||
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
|
import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
|
||||||
|
import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { IGroup, IGroupUser, Role } from 'interfaces/group';
|
||||||
|
import { FC, FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
|
const StyledForm = styled('form')(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledUser = styled('div')(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
padding: theme.spacing(1.5),
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
backgroundColor: theme.palette.secondaryContainer,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > span:first-of-type': {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledSelect = styled(Select)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled('div')(() => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IEditGroupUserProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
user?: IGroupUser;
|
||||||
|
group: IGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditGroupUser: FC<IEditGroupUserProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
user,
|
||||||
|
group,
|
||||||
|
}) => {
|
||||||
|
const { refetchGroup } = useGroup(group.id);
|
||||||
|
const { updateGroup, loading } = useGroupApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
const [role, setRole] = useState<Role>(user?.role || Role.Member);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRole(user?.role || Role.Member);
|
||||||
|
}, [user, open]);
|
||||||
|
|
||||||
|
const payload = useMemo(() => {
|
||||||
|
const editUsers = [...group.users];
|
||||||
|
const editUserIndex = editUsers.findIndex(({ id }) => id === user?.id);
|
||||||
|
editUsers[editUserIndex] = {
|
||||||
|
...user!,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
users: editUsers.map(({ id, role }) => ({
|
||||||
|
user: { id },
|
||||||
|
role,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [group, user, role]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateGroup(group.id, payload);
|
||||||
|
refetchGroup();
|
||||||
|
setOpen(false);
|
||||||
|
setToastData({
|
||||||
|
title: 'User edited successfully',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatApiCode = () => {
|
||||||
|
return `curl --location --request PUT '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/groups/${group.id}' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open && Boolean(user)}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label="Edit user"
|
||||||
|
>
|
||||||
|
<FormTemplate
|
||||||
|
loading={loading}
|
||||||
|
modal
|
||||||
|
title="Edit user"
|
||||||
|
description="Groups is the best and easiest way to organize users and then use them in projects to assign a specific role in one go to all the users in a group."
|
||||||
|
documentationLink="https://docs.getunleash.io/advanced/groups"
|
||||||
|
documentationLinkLabel="Groups documentation"
|
||||||
|
formatApiCode={formatApiCode}
|
||||||
|
>
|
||||||
|
<StyledForm onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<StyledUser>
|
||||||
|
<span>{user?.name || user?.username}</span>
|
||||||
|
<span>{user?.email}</span>
|
||||||
|
</StyledUser>
|
||||||
|
<StyledInputDescription>
|
||||||
|
Assign the role the user should have in this group
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledSelect
|
||||||
|
size="small"
|
||||||
|
value={role}
|
||||||
|
onChange={event =>
|
||||||
|
setRole(event.target.value as Role)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.values(Role).map(role => (
|
||||||
|
<MenuItem key={role} value={role}>
|
||||||
|
{role}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</StyledSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<StyledCancelButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
</FormTemplate>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
402
frontend/src/component/admin/groups/Group/Group.tsx
Normal file
402
frontend/src/component/admin/groups/Group/Group.tsx
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
import { useEffect, useMemo, useState, VFC } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
styled,
|
||||||
|
Tooltip,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||||
|
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||||
|
import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
|
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
|
import { IGroupUser, Role } from 'interfaces/group';
|
||||||
|
import { useSearch } from 'hooks/useSearch';
|
||||||
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
|
||||||
|
import { GroupUserRoleCell } from 'component/admin/groups/GroupUserRoleCell/GroupUserRoleCell';
|
||||||
|
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||||
|
import { Delete, Edit } from '@mui/icons-material';
|
||||||
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { MainHeader } from 'component/common/MainHeader/MainHeader';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
|
import { AddGroupUser } from './AddGroupUser/AddGroupUser';
|
||||||
|
import { EditGroupUser } from './EditGroupUser/EditGroupUser';
|
||||||
|
import { RemoveGroupUser } from './RemoveGroupUser/RemoveGroupUser';
|
||||||
|
|
||||||
|
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||||
|
width: theme.spacing(4),
|
||||||
|
height: theme.spacing(4),
|
||||||
|
margin: 'auto',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledEdit = styled(Edit)(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.mainHeader,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDelete = styled(Delete)(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.mainHeader,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const groupUsersPlaceholder: IGroupUser[] = Array(15).fill({
|
||||||
|
name: 'Name of the user',
|
||||||
|
username: 'Username of the user',
|
||||||
|
role: Role.Member,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PageQueryType = Partial<
|
||||||
|
Record<'sort' | 'order' | 'search', string>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const defaultSort: SortingRule<string> = { id: 'role', desc: true };
|
||||||
|
|
||||||
|
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
||||||
|
'Group:v1',
|
||||||
|
defaultSort
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Group: VFC = () => {
|
||||||
|
const groupId = Number(useRequiredPathParam('groupId'));
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const { group, loading } = useGroup(groupId);
|
||||||
|
const [removeOpen, setRemoveOpen] = useState(false);
|
||||||
|
const [addUserOpen, setAddUserOpen] = useState(false);
|
||||||
|
const [editUserOpen, setEditUserOpen] = useState(false);
|
||||||
|
const [removeUserOpen, setRemoveUserOpen] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<IGroupUser>();
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
Header: 'Avatar',
|
||||||
|
accessor: 'imageUrl',
|
||||||
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
|
<TextCell>
|
||||||
|
<StyledAvatar
|
||||||
|
data-loading
|
||||||
|
alt="Gravatar"
|
||||||
|
src={user.imageUrl}
|
||||||
|
title={`${
|
||||||
|
user.name || user.email || user.username
|
||||||
|
} (id: ${user.id})`}
|
||||||
|
/>
|
||||||
|
</TextCell>
|
||||||
|
),
|
||||||
|
maxWidth: 85,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row: IGroupUser) => row.name || '',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
Header: 'Username',
|
||||||
|
accessor: (row: IGroupUser) => row.username || row.email,
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'User type',
|
||||||
|
accessor: 'role',
|
||||||
|
Cell: GroupUserRoleCell,
|
||||||
|
maxWidth: 150,
|
||||||
|
filterName: 'type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Joined',
|
||||||
|
accessor: 'joinedAt',
|
||||||
|
Cell: DateCell,
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Last login',
|
||||||
|
accessor: (row: IGroupUser) => row.seenAt || '',
|
||||||
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
|
<TimeAgoCell value={user.seenAt} emptyText="Never logged" />
|
||||||
|
),
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Actions',
|
||||||
|
id: 'Actions',
|
||||||
|
align: 'center',
|
||||||
|
Cell: ({ row: { original: rowUser } }: any) => (
|
||||||
|
<ActionCell>
|
||||||
|
<Tooltip
|
||||||
|
title="Edit user"
|
||||||
|
arrow
|
||||||
|
placement="bottom-end"
|
||||||
|
describeChild
|
||||||
|
enterDelay={1000}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(rowUser);
|
||||||
|
setEditUserOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
title="Remove user from group"
|
||||||
|
arrow
|
||||||
|
placement="bottom-end"
|
||||||
|
describeChild
|
||||||
|
enterDelay={1000}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(rowUser);
|
||||||
|
setRemoveUserOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
maxWidth: 100,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[setSelectedUser, setRemoveUserOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [initialState] = useState(() => ({
|
||||||
|
sortBy: [
|
||||||
|
{
|
||||||
|
id: searchParams.get('sort') || storedParams.id,
|
||||||
|
desc: searchParams.has('order')
|
||||||
|
? searchParams.get('order') === 'desc'
|
||||||
|
: storedParams.desc,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hiddenColumns: ['description'],
|
||||||
|
globalFilter: searchParams.get('search') || '',
|
||||||
|
}));
|
||||||
|
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: searchedData,
|
||||||
|
getSearchText,
|
||||||
|
getSearchContext,
|
||||||
|
} = useSearch(columns, searchValue, group?.users ?? []);
|
||||||
|
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
searchedData?.length === 0 && loading
|
||||||
|
? groupUsersPlaceholder
|
||||||
|
: searchedData,
|
||||||
|
[searchedData, loading]
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
headerGroups,
|
||||||
|
rows,
|
||||||
|
prepareRow,
|
||||||
|
state: { sortBy },
|
||||||
|
} = useTable(
|
||||||
|
{
|
||||||
|
columns: columns as any[],
|
||||||
|
data,
|
||||||
|
initialState,
|
||||||
|
sortTypes,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
disableSortRemove: true,
|
||||||
|
disableMultiSort: true,
|
||||||
|
},
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableState: PageQueryType = {};
|
||||||
|
tableState.sort = sortBy[0].id;
|
||||||
|
if (sortBy[0].desc) {
|
||||||
|
tableState.order = 'desc';
|
||||||
|
}
|
||||||
|
if (searchValue) {
|
||||||
|
tableState.search = searchValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(tableState, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
|
||||||
|
}, [sortBy, searchValue, setSearchParams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(group)}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<MainHeader
|
||||||
|
title={group?.name}
|
||||||
|
description={group?.description}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<PermissionIconButton
|
||||||
|
to={`/admin/groups/${groupId}/edit`}
|
||||||
|
component={Link}
|
||||||
|
data-loading
|
||||||
|
permission={ADMIN}
|
||||||
|
tooltipProps={{
|
||||||
|
title: 'Edit group',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledEdit />
|
||||||
|
</PermissionIconButton>
|
||||||
|
<PermissionIconButton
|
||||||
|
data-loading
|
||||||
|
onClick={() => setRemoveOpen(true)}
|
||||||
|
permission={ADMIN}
|
||||||
|
tooltipProps={{
|
||||||
|
title: 'Remove group',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StyledDelete />
|
||||||
|
</PermissionIconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PageContent
|
||||||
|
isLoading={loading}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
secondary
|
||||||
|
title={`Users (${
|
||||||
|
rows.length < data.length
|
||||||
|
? `${rows.length} of ${data.length}`
|
||||||
|
: data.length
|
||||||
|
})`}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!isSmallScreen}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<Search
|
||||||
|
initialValue={
|
||||||
|
searchValue
|
||||||
|
}
|
||||||
|
onChange={
|
||||||
|
setSearchValue
|
||||||
|
}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={
|
||||||
|
getSearchContext
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PageHeader.Divider />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setAddUserOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add user
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isSmallScreen}
|
||||||
|
show={
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchHighlightProvider
|
||||||
|
value={getSearchText(searchValue)}
|
||||||
|
>
|
||||||
|
<VirtualizedTable
|
||||||
|
rows={rows}
|
||||||
|
headerGroups={headerGroups}
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
/>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={searchValue?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No users found matching “
|
||||||
|
{searchValue}
|
||||||
|
” in this group.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
This group is empty. Get started by
|
||||||
|
adding a user to the group.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RemoveGroup
|
||||||
|
open={removeOpen}
|
||||||
|
setOpen={setRemoveOpen}
|
||||||
|
group={group!}
|
||||||
|
/>
|
||||||
|
<AddGroupUser
|
||||||
|
open={addUserOpen}
|
||||||
|
setOpen={setAddUserOpen}
|
||||||
|
group={group!}
|
||||||
|
/>
|
||||||
|
<EditGroupUser
|
||||||
|
open={editUserOpen}
|
||||||
|
setOpen={setEditUserOpen}
|
||||||
|
user={selectedUser!}
|
||||||
|
group={group!}
|
||||||
|
/>
|
||||||
|
<RemoveGroupUser
|
||||||
|
open={removeUserOpen}
|
||||||
|
setOpen={setRemoveUserOpen}
|
||||||
|
user={selectedUser!}
|
||||||
|
group={group!}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,70 @@
|
|||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
|
||||||
|
import { useGroup } from 'hooks/api/getters/useGroup/useGroup';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { IGroup, IGroupUser } from 'interfaces/group';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
|
interface IRemoveGroupUserProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
user?: IGroupUser;
|
||||||
|
group: IGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RemoveGroupUser: FC<IRemoveGroupUserProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
user,
|
||||||
|
group,
|
||||||
|
}) => {
|
||||||
|
const { refetchGroup } = useGroup(group.id);
|
||||||
|
const { updateGroup } = useGroupApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
|
||||||
|
const onRemoveClick = async () => {
|
||||||
|
try {
|
||||||
|
const groupPayload = {
|
||||||
|
...group,
|
||||||
|
users: group.users
|
||||||
|
.filter(({ id }) => id !== user?.id)
|
||||||
|
.map(({ id, role }) => ({
|
||||||
|
user: { id },
|
||||||
|
role,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await updateGroup(group.id, groupPayload);
|
||||||
|
refetchGroup();
|
||||||
|
setOpen(false);
|
||||||
|
setToastData({
|
||||||
|
title: 'User removed from group successfully',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
open={open && Boolean(user)}
|
||||||
|
primaryButtonText="Remove"
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
onClick={onRemoveClick}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
title="Remove user from group"
|
||||||
|
>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you wish to remove{' '}
|
||||||
|
<strong>{user?.name || user?.username || user?.email}</strong>{' '}
|
||||||
|
from <strong>{group.name}</strong>? Removing the user from this
|
||||||
|
group may also remove their access from projects this group is
|
||||||
|
assigned to.
|
||||||
|
</Typography>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
120
frontend/src/component/admin/groups/GroupForm/GroupForm.tsx
Normal file
120
frontend/src/component/admin/groups/GroupForm/GroupForm.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Button, styled } from '@mui/material';
|
||||||
|
import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds';
|
||||||
|
import Input from 'component/common/Input/Input';
|
||||||
|
import { IGroupUser } from 'interfaces/group';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { GroupFormUsersSelect } from './GroupFormUsersSelect/GroupFormUsersSelect';
|
||||||
|
import { GroupFormUsersTable } from './GroupFormUsersTable/GroupFormUsersTable';
|
||||||
|
|
||||||
|
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 StyledInput = styled(Input)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledButtonContainer = styled('div')(() => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(3),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IGroupForm {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
users: IGroupUser[];
|
||||||
|
setName: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setDescription: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
|
||||||
|
handleSubmit: (e: any) => void;
|
||||||
|
handleCancel: () => void;
|
||||||
|
errors: { [key: string]: string };
|
||||||
|
mode: 'Create' | 'Edit';
|
||||||
|
clearErrors: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupForm: FC<IGroupForm> = ({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
users,
|
||||||
|
setName,
|
||||||
|
setDescription,
|
||||||
|
setUsers,
|
||||||
|
handleSubmit,
|
||||||
|
handleCancel,
|
||||||
|
errors,
|
||||||
|
mode,
|
||||||
|
clearErrors,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<StyledForm onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<StyledInputDescription>
|
||||||
|
What would you like to call your group?
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledInput
|
||||||
|
autoFocus
|
||||||
|
label="Name"
|
||||||
|
id="group-name"
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
errorText={errors.name}
|
||||||
|
onFocus={() => clearErrors()}
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
data-testid={UG_NAME_ID}
|
||||||
|
/>
|
||||||
|
<StyledInputDescription>
|
||||||
|
How would you describe your group?
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledInput
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
label="Description"
|
||||||
|
placeholder="A short description of the group"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
data-testid={UG_DESC_ID}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={mode === 'Create'}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<StyledInputDescription>
|
||||||
|
Add users to this group
|
||||||
|
</StyledInputDescription>
|
||||||
|
<GroupFormUsersSelect
|
||||||
|
users={users}
|
||||||
|
setUsers={setUsers}
|
||||||
|
/>
|
||||||
|
<GroupFormUsersTable
|
||||||
|
users={users}
|
||||||
|
setUsers={setUsers}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StyledButtonContainer>
|
||||||
|
{children}
|
||||||
|
<StyledCancelButton onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
);
|
@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
styled,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
|
||||||
|
import CheckBoxIcon from '@mui/icons-material/CheckBox';
|
||||||
|
import { IUser } from 'interfaces/user';
|
||||||
|
import { useMemo, useState, VFC } from 'react';
|
||||||
|
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
|
||||||
|
import { IGroupUser, Role } from 'interfaces/group';
|
||||||
|
|
||||||
|
const StyledOption = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > span:first-of-type': {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledGroupFormUsersSelect = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
'& > div:first-of-type': {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderOption = (
|
||||||
|
props: React.HTMLAttributes<HTMLLIElement>,
|
||||||
|
option: IUser,
|
||||||
|
selected: boolean
|
||||||
|
) => (
|
||||||
|
<li {...props}>
|
||||||
|
<Checkbox
|
||||||
|
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||||||
|
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
checked={selected}
|
||||||
|
/>
|
||||||
|
<StyledOption>
|
||||||
|
<span>{option.name || option.username}</span>
|
||||||
|
<span>{option.email}</span>
|
||||||
|
</StyledOption>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface IGroupFormUsersSelectProps {
|
||||||
|
users: IGroupUser[];
|
||||||
|
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupFormUsersSelect: VFC<IGroupFormUsersSelectProps> = ({
|
||||||
|
users,
|
||||||
|
setUsers,
|
||||||
|
}) => {
|
||||||
|
const { users: usersAll } = useUsers();
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<IUser[]>([]);
|
||||||
|
|
||||||
|
const usersOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
usersAll.filter(
|
||||||
|
(user: IUser) => !users?.map(({ id }) => id).includes(user.id)
|
||||||
|
),
|
||||||
|
[usersAll, users]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAdd = () => {
|
||||||
|
const usersToBeAdded = selectedUsers.map(
|
||||||
|
(user: IUser): IGroupUser => ({
|
||||||
|
...user,
|
||||||
|
role: Role.Member,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setUsers((users: IGroupUser[]) => [...users, ...usersToBeAdded]);
|
||||||
|
setSelectedUsers([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledGroupFormUsersSelect>
|
||||||
|
<Autocomplete
|
||||||
|
size="small"
|
||||||
|
multiple
|
||||||
|
limitTags={10}
|
||||||
|
disableCloseOnSelect
|
||||||
|
value={selectedUsers}
|
||||||
|
onChange={(event, newValue, reason) => {
|
||||||
|
if (
|
||||||
|
event.type === 'keydown' &&
|
||||||
|
(event as React.KeyboardEvent).key === 'Backspace' &&
|
||||||
|
reason === 'removeOption'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedUsers(newValue);
|
||||||
|
}}
|
||||||
|
options={[...usersOptions].sort((a, b) => {
|
||||||
|
const aName = a.name || a.username || '';
|
||||||
|
const bName = b.name || b.username || '';
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
})}
|
||||||
|
renderOption={(props, option, { selected }) =>
|
||||||
|
renderOption(props, option as IUser, selected)
|
||||||
|
}
|
||||||
|
getOptionLabel={(option: IUser) =>
|
||||||
|
option.email || option.name || option.username || ''
|
||||||
|
}
|
||||||
|
renderInput={params => (
|
||||||
|
<TextField {...params} label="Select users" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button variant="outlined" onClick={onAdd}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</StyledGroupFormUsersSelect>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,148 @@
|
|||||||
|
import { useMemo, VFC } from 'react';
|
||||||
|
import { Avatar, IconButton, styled, Tooltip } from '@mui/material';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { IGroupUser } from 'interfaces/group';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { GroupUserRoleCell } from 'component/admin/groups/GroupUserRoleCell/GroupUserRoleCell';
|
||||||
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
|
import { Delete } from '@mui/icons-material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { VirtualizedTable } from 'component/common/Table';
|
||||||
|
import { useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||||
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
|
|
||||||
|
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||||
|
width: theme.spacing(4),
|
||||||
|
height: theme.spacing(4),
|
||||||
|
margin: 'auto',
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IGroupFormUsersTableProps {
|
||||||
|
users: IGroupUser[];
|
||||||
|
setUsers: React.Dispatch<React.SetStateAction<IGroupUser[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupFormUsersTable: VFC<IGroupFormUsersTableProps> = ({
|
||||||
|
users,
|
||||||
|
setUsers,
|
||||||
|
}) => {
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
Header: 'Avatar',
|
||||||
|
accessor: 'imageUrl',
|
||||||
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
|
<TextCell>
|
||||||
|
<StyledAvatar
|
||||||
|
data-loading
|
||||||
|
alt="Gravatar"
|
||||||
|
src={user.imageUrl}
|
||||||
|
title={`${
|
||||||
|
user.name || user.email || user.username
|
||||||
|
} (id: ${user.id})`}
|
||||||
|
/>
|
||||||
|
</TextCell>
|
||||||
|
),
|
||||||
|
maxWidth: 85,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row: IGroupUser) => row.name || '',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
Header: 'Username',
|
||||||
|
accessor: (row: IGroupUser) => row.username || row.email,
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Group role',
|
||||||
|
accessor: 'role',
|
||||||
|
Cell: ({ row: { original: rowUser } }: any) => (
|
||||||
|
<GroupUserRoleCell
|
||||||
|
value={rowUser.role}
|
||||||
|
onChange={role =>
|
||||||
|
setUsers((users: IGroupUser[]) => {
|
||||||
|
const newUsers = [...users];
|
||||||
|
const index = newUsers.findIndex(
|
||||||
|
user => user.id === rowUser.id
|
||||||
|
);
|
||||||
|
newUsers[index] = {
|
||||||
|
...rowUser,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
return newUsers;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
maxWidth: 150,
|
||||||
|
filterName: 'type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Action',
|
||||||
|
id: 'Action',
|
||||||
|
align: 'center',
|
||||||
|
Cell: ({ row: { original: rowUser } }: any) => (
|
||||||
|
<ActionCell>
|
||||||
|
<Tooltip
|
||||||
|
title="Remove user from group"
|
||||||
|
arrow
|
||||||
|
placement="bottom-end"
|
||||||
|
describeChild
|
||||||
|
enterDelay={1000}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
setUsers((users: IGroupUser[]) =>
|
||||||
|
users.filter(
|
||||||
|
user => user.id !== rowUser.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ActionCell>
|
||||||
|
),
|
||||||
|
maxWidth: 100,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[setUsers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { headerGroups, rows, prepareRow } = useTable(
|
||||||
|
{
|
||||||
|
columns: columns as any[],
|
||||||
|
data: users as any[],
|
||||||
|
sortTypes,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
disableSortRemove: true,
|
||||||
|
disableMultiSort: true,
|
||||||
|
},
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length > 0}
|
||||||
|
show={
|
||||||
|
<VirtualizedTable
|
||||||
|
rows={rows}
|
||||||
|
headerGroups={headerGroups}
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,66 @@
|
|||||||
|
import { capitalize, MenuItem, Select, styled } from '@mui/material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { Role } from 'interfaces/group';
|
||||||
|
|
||||||
|
const StyledBadge = styled('div')(({ theme }) => ({
|
||||||
|
padding: theme.spacing(0.5, 1),
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
border: `1px solid ${theme.palette.dividerAlternative}`,
|
||||||
|
background: theme.palette.activityIndicators.unknown,
|
||||||
|
display: 'inline-block',
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
marginLeft: theme.spacing(1.5),
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
lineHeight: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledOwnerBadge = styled(StyledBadge)(({ theme }) => ({
|
||||||
|
color: theme.palette.success.dark,
|
||||||
|
border: `1px solid ${theme.palette.success.border}`,
|
||||||
|
background: theme.palette.success.light,
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IGroupUserRoleCellProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (role: Role) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupUserRoleCell = ({
|
||||||
|
value = Role.Member,
|
||||||
|
onChange,
|
||||||
|
}: IGroupUserRoleCellProps) => {
|
||||||
|
const renderBadge = () => (
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={value === Role.Member}
|
||||||
|
show={<StyledBadge>{capitalize(value)}</StyledBadge>}
|
||||||
|
elseShow={<StyledOwnerBadge>{capitalize(value)}</StyledOwnerBadge>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextCell>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(onChange)}
|
||||||
|
show={
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
value={value}
|
||||||
|
onChange={event =>
|
||||||
|
onChange!(event.target.value as Role)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.values(Role).map(role => (
|
||||||
|
<MenuItem key={role} value={role}>
|
||||||
|
{role}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
elseShow={() => renderBadge()}
|
||||||
|
/>
|
||||||
|
</TextCell>
|
||||||
|
);
|
||||||
|
};
|
11
frontend/src/component/admin/groups/GroupsAdmin.tsx
Normal file
11
frontend/src/component/admin/groups/GroupsAdmin.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { GroupsList } from './GroupsList/GroupsList';
|
||||||
|
import AdminMenu from '../menu/AdminMenu';
|
||||||
|
|
||||||
|
export const GroupsAdmin = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AdminMenu />
|
||||||
|
<GroupsList />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,124 @@
|
|||||||
|
import { styled, Tooltip } from '@mui/material';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars';
|
||||||
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
import { GroupCardActions } from './GroupCardActions/GroupCardActions';
|
||||||
|
import { RemoveGroup } from 'component/admin/groups/RemoveGroup/RemoveGroup';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
const StyledLink = styled(Link)(({ theme }) => ({
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledGroupCard = styled('aside')(({ theme }) => ({
|
||||||
|
padding: theme.spacing(2.5),
|
||||||
|
height: '100%',
|
||||||
|
border: `1px solid ${theme.palette.dividerAlternative}`,
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
boxShadow: theme.boxShadows.card,
|
||||||
|
[theme.breakpoints.up('md')]: {
|
||||||
|
padding: theme.spacing(4),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledRow = styled('div')(() => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledHeaderTitle = styled('h2')(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.mainHeader,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledHeaderActions = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDescription = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
marginTop: theme.spacing(1),
|
||||||
|
marginBottom: theme.spacing(4),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledCounterDescription = styled('span')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ProjectBadgeContainer = styled('div')(() => ({}));
|
||||||
|
|
||||||
|
interface IGroupCardProps {
|
||||||
|
group: IGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupCard = ({ group }: IGroupCardProps) => {
|
||||||
|
const [removeOpen, setRemoveOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledLink key={group.id} to={`/admin/groups/${group.id}`}>
|
||||||
|
<StyledGroupCard>
|
||||||
|
<StyledRow>
|
||||||
|
<StyledHeaderTitle>{group.name}</StyledHeaderTitle>
|
||||||
|
<StyledHeaderActions>
|
||||||
|
<GroupCardActions
|
||||||
|
groupId={group.id}
|
||||||
|
onRemove={() => setRemoveOpen(true)}
|
||||||
|
/>
|
||||||
|
</StyledHeaderActions>
|
||||||
|
</StyledRow>
|
||||||
|
<StyledDescription>{group.description}</StyledDescription>
|
||||||
|
<StyledRow>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={group.users?.length > 0}
|
||||||
|
show={<GroupCardAvatars users={group.users} />}
|
||||||
|
elseShow={
|
||||||
|
<StyledCounterDescription>
|
||||||
|
This group has no users.
|
||||||
|
</StyledCounterDescription>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ProjectBadgeContainer>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={group.projects.length > 0}
|
||||||
|
show={group.projects.map(project => (
|
||||||
|
<Badge
|
||||||
|
color="secondary"
|
||||||
|
sx={{ marginRight: 0.5 }}
|
||||||
|
>
|
||||||
|
{project}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
elseShow={
|
||||||
|
<Tooltip
|
||||||
|
title="This project is not used in any project"
|
||||||
|
arrow
|
||||||
|
placement="bottom-end"
|
||||||
|
describeChild
|
||||||
|
enterDelay={1000}
|
||||||
|
>
|
||||||
|
<Badge>Not used</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ProjectBadgeContainer>
|
||||||
|
</StyledRow>
|
||||||
|
</StyledGroupCard>
|
||||||
|
</StyledLink>
|
||||||
|
<RemoveGroup
|
||||||
|
open={removeOpen}
|
||||||
|
setOpen={setRemoveOpen}
|
||||||
|
group={group!}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,114 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IconButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Popover,
|
||||||
|
styled,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Delete, Edit, MoreVert } from '@mui/icons-material';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const StyledActions = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledPopover = styled(Popover)(({ theme }) => ({
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
padding: theme.spacing(1, 1.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IGroupCardActions {
|
||||||
|
groupId: number;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupCardActions: FC<IGroupCardActions> = ({
|
||||||
|
groupId,
|
||||||
|
onRemove,
|
||||||
|
}) => {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = `feature-${groupId}-actions`;
|
||||||
|
const menuId = `${id}-menu`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledActions
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
title="Group actions"
|
||||||
|
arrow
|
||||||
|
placement="bottom-end"
|
||||||
|
describeChild
|
||||||
|
enterDelay={1000}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
id={id}
|
||||||
|
aria-controls={open ? menuId : undefined}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={open ? 'true' : undefined}
|
||||||
|
onClick={handleClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<MoreVert />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<StyledPopover
|
||||||
|
id={menuId}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||||
|
disableScrollLock={true}
|
||||||
|
>
|
||||||
|
<MenuList aria-labelledby={id}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={handleClose}
|
||||||
|
component={Link}
|
||||||
|
to={`/admin/groups/${groupId}/edit`}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Edit />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
<Typography variant="body2">Edit group</Typography>
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
onRemove();
|
||||||
|
handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Delete />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
<Typography variant="body2">
|
||||||
|
Remove group
|
||||||
|
</Typography>
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</StyledPopover>
|
||||||
|
</StyledActions>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,92 @@
|
|||||||
|
import { Avatar, Badge, styled } from '@mui/material';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { IGroupUser, Role } from 'interfaces/group';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
|
|
||||||
|
const StyledAvatars = styled('div')(({ theme }) => ({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||||
|
width: theme.spacing(4),
|
||||||
|
height: theme.spacing(4),
|
||||||
|
outline: `2px solid ${theme.palette.background.paper}`,
|
||||||
|
marginLeft: theme.spacing(-1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAvatarMore = styled(StyledAvatar)(({ theme }) => ({
|
||||||
|
backgroundColor: theme.palette.secondary.light,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledStar = styled(StarIcon)(({ theme }) => ({
|
||||||
|
color: theme.palette.warning.main,
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
borderRadius: theme.shape.borderRadiusExtraLarge,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
marginLeft: theme.spacing(-1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IGroupCardAvatarsProps {
|
||||||
|
users: IGroupUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupCardAvatars = ({ users }: IGroupCardAvatarsProps) => {
|
||||||
|
const shownUsers = useMemo(
|
||||||
|
() => users.sort((a, b) => (a.role < b.role ? 1 : -1)).slice(0, 9),
|
||||||
|
[users]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<StyledAvatars>
|
||||||
|
{shownUsers.map(user => (
|
||||||
|
<ConditionallyRender
|
||||||
|
key={user.id}
|
||||||
|
condition={user.role === Role.Member}
|
||||||
|
show={
|
||||||
|
<StyledAvatar
|
||||||
|
data-loading
|
||||||
|
alt="Gravatar"
|
||||||
|
src={user.imageUrl}
|
||||||
|
title={`${
|
||||||
|
user.name || user.email || user.username
|
||||||
|
} (id: ${user.id})`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<Badge
|
||||||
|
overlap="circular"
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'left',
|
||||||
|
}}
|
||||||
|
badgeContent={<StyledStar />}
|
||||||
|
>
|
||||||
|
<StyledAvatar
|
||||||
|
data-loading
|
||||||
|
alt="Gravatar"
|
||||||
|
src={user.imageUrl}
|
||||||
|
title={`${
|
||||||
|
user.name || user.email || user.username
|
||||||
|
} (id: ${user.id})`}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={users.length > 9}
|
||||||
|
show={
|
||||||
|
<StyledAvatarMore>
|
||||||
|
+{users.length - shownUsers.length}
|
||||||
|
</StyledAvatarMore>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledAvatars>
|
||||||
|
);
|
||||||
|
};
|
137
frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
Normal file
137
frontend/src/component/admin/groups/GroupsList/GroupsList.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useMemo, useState, VFC } from 'react';
|
||||||
|
import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { Button, Grid, useMediaQuery } from '@mui/material';
|
||||||
|
import theme from 'themes/theme';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { TablePlaceholder } from 'component/common/Table';
|
||||||
|
import { GroupCard } from './GroupCard/GroupCard';
|
||||||
|
|
||||||
|
type PageQueryType = Partial<Record<'search', string>>;
|
||||||
|
|
||||||
|
const groupsSearch = (group: IGroup, searchValue: string) => {
|
||||||
|
const search = searchValue.toLowerCase();
|
||||||
|
const users = {
|
||||||
|
names: group.users?.map(user => user.name?.toLowerCase() || ''),
|
||||||
|
usernames: group.users?.map(user => user.username?.toLowerCase() || ''),
|
||||||
|
emails: group.users?.map(user => user.email?.toLowerCase() || ''),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
group.name.toLowerCase().includes(search) ||
|
||||||
|
group.description.toLowerCase().includes(search) ||
|
||||||
|
users.names?.some(name => name.includes(search)) ||
|
||||||
|
users.usernames?.some(username => username.includes(search)) ||
|
||||||
|
users.emails?.some(email => email.includes(search))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GroupsList: VFC = () => {
|
||||||
|
const { groups = [], loading } = useGroups();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [searchValue, setSearchValue] = useState(
|
||||||
|
searchParams.get('search') || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableState: PageQueryType = {};
|
||||||
|
if (searchValue) {
|
||||||
|
tableState.search = searchValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(tableState, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
}, [searchValue, setSearchParams]);
|
||||||
|
|
||||||
|
const data = useMemo(() => {
|
||||||
|
const sortedGroups = groups.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
return searchValue
|
||||||
|
? sortedGroups.filter(group => groupsSearch(group, searchValue))
|
||||||
|
: sortedGroups;
|
||||||
|
}, [groups, searchValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent
|
||||||
|
isLoading={loading}
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
title={`Groups (${data.length})`}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!isSmallScreen}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
/>
|
||||||
|
<PageHeader.Divider />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
to="/admin/groups/create-group"
|
||||||
|
component={Link}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
New group
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isSmallScreen}
|
||||||
|
show={
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchHighlightProvider value={searchValue}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{data.map(group => (
|
||||||
|
<Grid key={group.id} item xs={12} md={6}>
|
||||||
|
<GroupCard group={group} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={data.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={searchValue?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No groups found matching “
|
||||||
|
{searchValue}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No groups available. Get started by adding a new
|
||||||
|
group.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,60 @@
|
|||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import { useGroupApi } from 'hooks/api/actions/useGroupApi/useGroupApi';
|
||||||
|
import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||||
|
|
||||||
|
interface IRemoveGroupProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
group: IGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RemoveGroup: FC<IRemoveGroupProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
group,
|
||||||
|
}) => {
|
||||||
|
const { refetchGroups } = useGroups();
|
||||||
|
const { removeGroup } = useGroupApi();
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onRemoveClick = async () => {
|
||||||
|
try {
|
||||||
|
await removeGroup(group.id);
|
||||||
|
refetchGroups();
|
||||||
|
setOpen(false);
|
||||||
|
navigate('/admin/groups');
|
||||||
|
setToastData({
|
||||||
|
title: 'Group removed successfully',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
setToastApiError(formatUnknownError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialogue
|
||||||
|
open={open}
|
||||||
|
primaryButtonText="Remove"
|
||||||
|
secondaryButtonText="Cancel"
|
||||||
|
onClick={onRemoveClick}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
title="Remove group"
|
||||||
|
>
|
||||||
|
<Typography>
|
||||||
|
Are you sure you wish to remove <strong>{group.name}</strong>?
|
||||||
|
If this group is currently assigned to one or more projects then
|
||||||
|
users belonging to this group may lose access to those projects.
|
||||||
|
</Typography>
|
||||||
|
</Dialogue>
|
||||||
|
);
|
||||||
|
};
|
60
frontend/src/component/admin/groups/hooks/useGroupForm.ts
Normal file
60
frontend/src/component/admin/groups/hooks/useGroupForm.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import useQueryParams from 'hooks/useQueryParams';
|
||||||
|
import { IGroupUser, Role } from 'interfaces/group';
|
||||||
|
|
||||||
|
export const useGroupForm = (
|
||||||
|
initialName = '',
|
||||||
|
initialDescription = '',
|
||||||
|
initialUsers: IGroupUser[] = []
|
||||||
|
) => {
|
||||||
|
const params = useQueryParams();
|
||||||
|
const groupQueryName = params.get('name');
|
||||||
|
const [name, setName] = useState(groupQueryName || initialName);
|
||||||
|
const [description, setDescription] = useState(initialDescription);
|
||||||
|
const [users, setUsers] = useState<IGroupUser[]>(initialUsers);
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!name) {
|
||||||
|
setName(groupQueryName || initialName);
|
||||||
|
}
|
||||||
|
}, [name, initialName, groupQueryName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDescription(initialDescription);
|
||||||
|
}, [initialDescription]);
|
||||||
|
|
||||||
|
const initialUsersStringified = JSON.stringify(initialUsers);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsers(initialUsers);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [initialUsersStringified]);
|
||||||
|
|
||||||
|
const getGroupPayload = () => {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
users: users.map(({ id, role }) => ({
|
||||||
|
user: { id },
|
||||||
|
role: role || Role.Member,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearErrors = () => {
|
||||||
|
setErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
setName,
|
||||||
|
description,
|
||||||
|
setDescription,
|
||||||
|
users,
|
||||||
|
setUsers,
|
||||||
|
getGroupPayload,
|
||||||
|
clearErrors,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
@ -51,6 +51,19 @@ function AdminMenu() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{flags.UG && (
|
||||||
|
<Tab
|
||||||
|
value="/admin/groups"
|
||||||
|
label={
|
||||||
|
<NavLink
|
||||||
|
to="/admin/groups"
|
||||||
|
style={createNavLinkStyle}
|
||||||
|
>
|
||||||
|
<span>Groups</span>
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{flags.RE && (
|
{flags.RE && (
|
||||||
<Tab
|
<Tab
|
||||||
value="/admin/roles"
|
value="/admin/roles"
|
||||||
|
85
frontend/src/component/common/Badge/Badge.tsx
Normal file
85
frontend/src/component/common/Badge/Badge.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { styled, SxProps, Theme } from '@mui/material';
|
||||||
|
import {
|
||||||
|
cloneElement,
|
||||||
|
FC,
|
||||||
|
ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
|
ReactElement,
|
||||||
|
ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
|
type Color = 'info' | 'success' | 'warning' | 'error' | 'secondary' | 'neutral';
|
||||||
|
|
||||||
|
interface IBadgeProps {
|
||||||
|
color?: Color;
|
||||||
|
icon?: ReactElement;
|
||||||
|
className?: string;
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IBadgeIconProps {
|
||||||
|
color?: Color;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledBadge = styled('div')<IBadgeProps>(
|
||||||
|
({ theme, color = 'neutral' }) => ({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(0.5, 1),
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
backgroundColor: theme.palette[color].light,
|
||||||
|
color: theme.palette[color].dark,
|
||||||
|
border: `1px solid ${theme.palette[color].border}`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const StyledBadgeIcon = styled('div')<IBadgeIconProps>(
|
||||||
|
({ theme, color = 'neutral' }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
color: theme.palette[color].main,
|
||||||
|
marginRight: theme.spacing(0.5),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Badge: FC<IBadgeProps> = forwardRef(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
color = 'neutral',
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
sx,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: IBadgeProps,
|
||||||
|
ref: ForwardedRef<HTMLDivElement>
|
||||||
|
) => (
|
||||||
|
<StyledBadge
|
||||||
|
color={color}
|
||||||
|
className={className}
|
||||||
|
sx={sx}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(icon)}
|
||||||
|
show={
|
||||||
|
<StyledBadgeIcon color={color}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(icon?.props.sx)}
|
||||||
|
show={icon}
|
||||||
|
elseShow={() =>
|
||||||
|
cloneElement(icon!, {
|
||||||
|
sx: { fontSize: '16px' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledBadgeIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</StyledBadge>
|
||||||
|
)
|
||||||
|
);
|
55
frontend/src/component/common/MainHeader/MainHeader.tsx
Normal file
55
frontend/src/component/common/MainHeader/MainHeader.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Paper, styled } from '@mui/material';
|
||||||
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
const StyledMainHeader = styled(Paper)(({ theme }) => ({
|
||||||
|
borderRadius: theme.shape.borderRadiusLarge,
|
||||||
|
padding: theme.spacing(2.5, 4),
|
||||||
|
boxShadow: 'none',
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTitleHeader = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTitle = styled('h1')(({ theme }) => ({
|
||||||
|
fontSize: theme.fontSizes.mainHeader,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledActions = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDescription = styled('span')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IMainHeaderProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainHeader = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
}: IMainHeaderProps) => {
|
||||||
|
usePageTitle(title);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledMainHeader>
|
||||||
|
<StyledTitleHeader>
|
||||||
|
<StyledTitle>{title}</StyledTitle>
|
||||||
|
<StyledActions>{actions}</StyledActions>
|
||||||
|
</StyledTitleHeader>
|
||||||
|
Description:<StyledDescription>{description}</StyledDescription>
|
||||||
|
</StyledMainHeader>
|
||||||
|
);
|
||||||
|
};
|
@ -47,12 +47,13 @@ export const PageContent: FC<IPageContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
|
|
||||||
const headerClasses = classnames(styles.headerContainer, {
|
const headerClasses = classnames('header', styles.headerContainer, {
|
||||||
[styles.paddingDisabled]: disablePadding,
|
[styles.paddingDisabled]: disablePadding,
|
||||||
[styles.borderDisabled]: disableBorder,
|
[styles.borderDisabled]: disableBorder,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bodyClasses = classnames(
|
const bodyClasses = classnames(
|
||||||
|
'body',
|
||||||
bodyClass ? bodyClass : styles.bodyContainer,
|
bodyClass ? bodyClass : styles.bodyContainer,
|
||||||
{
|
{
|
||||||
[styles.paddingDisabled]: disablePadding,
|
[styles.paddingDisabled]: disablePadding,
|
||||||
|
@ -33,6 +33,7 @@ interface IPageHeaderProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
secondary?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageHeaderComponent: FC<IPageHeaderProps> & {
|
const PageHeaderComponent: FC<IPageHeaderProps> & {
|
||||||
@ -45,12 +46,13 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
|
|||||||
variant,
|
variant,
|
||||||
loading,
|
loading,
|
||||||
className = '',
|
className = '',
|
||||||
|
secondary,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { classes: styles } = useStyles();
|
const { classes: styles } = useStyles();
|
||||||
const headerClasses = classnames({ skeleton: loading });
|
const headerClasses = classnames({ skeleton: loading });
|
||||||
|
|
||||||
usePageTitle(title);
|
usePageTitle(secondary ? '' : title);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.headerContainer}>
|
<div className={styles.headerContainer}>
|
||||||
@ -60,7 +62,7 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
|
|||||||
data-loading
|
data-loading
|
||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant={variant || 'h1'}
|
variant={variant || secondary ? 'h2' : 'h1'}
|
||||||
className={classnames(styles.headerTitle, className)}
|
className={classnames(styles.headerTitle, className)}
|
||||||
>
|
>
|
||||||
{titleElement || title}
|
{titleElement || title}
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import { styled, useTheme } from '@mui/material';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
const StyledStatusBadge = styled('div')(({ theme }) => ({
|
|
||||||
padding: theme.spacing(0.5, 1),
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
display: 'inline-block',
|
|
||||||
borderRadius: theme.shape.borderRadius,
|
|
||||||
marginLeft: theme.spacing(1.5),
|
|
||||||
fontSize: theme.fontSizes.smallerBody,
|
|
||||||
lineHeight: 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface IStatusBadgeProps {
|
|
||||||
severity: 'success' | 'warning';
|
|
||||||
className?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBadge = ({
|
|
||||||
severity,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: IStatusBadgeProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const background = theme.palette.statusBadge[severity];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledStatusBadge sx={{ background }} className={className}>
|
|
||||||
{children}
|
|
||||||
</StyledStatusBadge>
|
|
||||||
);
|
|
||||||
};
|
|
@ -109,6 +109,7 @@ export const CellSortable: FC<ICellSortableProps> = ({
|
|||||||
show={
|
show={
|
||||||
<Tooltip title={title} arrow>
|
<Tooltip title={title} arrow>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className={classnames(
|
className={classnames(
|
||||||
isSorted && styles.sortedButton,
|
isSorted && styles.sortedButton,
|
||||||
styles.sortButton,
|
styles.sortButton,
|
||||||
|
@ -10,12 +10,14 @@ import classnames from 'classnames';
|
|||||||
interface ILinkCellProps {
|
interface ILinkCellProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
onClick?: () => void;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkCell: FC<ILinkCellProps> = ({
|
export const LinkCell: FC<ILinkCellProps> = ({
|
||||||
title,
|
title,
|
||||||
to,
|
to,
|
||||||
|
onClick,
|
||||||
subtitle,
|
subtitle,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
@ -63,6 +65,14 @@ export const LinkCell: FC<ILinkCellProps> = ({
|
|||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Link>
|
</Link>
|
||||||
|
) : onClick ? (
|
||||||
|
<Link
|
||||||
|
onClick={onClick}
|
||||||
|
underline="hover"
|
||||||
|
className={classnames(styles.wrapper, styles.link)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className={styles.wrapper}>{content}</span>
|
<span className={styles.wrapper}>{content}</span>
|
||||||
);
|
);
|
||||||
|
@ -4,3 +4,4 @@ export const E = 'E';
|
|||||||
export const EEA = 'EEA';
|
export const EEA = 'EEA';
|
||||||
export const RE = 'RE';
|
export const RE = 'RE';
|
||||||
export const SE = 'SE';
|
export const SE = 'SE';
|
||||||
|
export const UG = 'UG';
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
import { IEnvironment } from 'interfaces/environments';
|
import { IEnvironment } from 'interfaces/environments';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
import { Highlighter } from 'component/common/Highlighter/Highlighter';
|
||||||
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { styled } from '@mui/material';
|
||||||
|
|
||||||
|
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
interface IEnvironmentNameCellProps {
|
interface IEnvironmentNameCellProps {
|
||||||
environment: IEnvironment;
|
environment: IEnvironment;
|
||||||
@ -19,11 +24,11 @@ export const EnvironmentNameCell = ({
|
|||||||
<Highlighter search={searchQuery}>{environment.name}</Highlighter>
|
<Highlighter search={searchQuery}>{environment.name}</Highlighter>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={!environment.enabled}
|
condition={!environment.enabled}
|
||||||
show={<StatusBadge severity="warning">Disabled</StatusBadge>}
|
show={<StyledBadge color="warning">Disabled</StyledBadge>}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={environment.protected}
|
condition={environment.protected}
|
||||||
show={<StatusBadge severity="success">Predefined</StatusBadge>}
|
show={<StyledBadge color="success">Predefined</StyledBadge>}
|
||||||
/>
|
/>
|
||||||
</TextCell>
|
</TextCell>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material';
|
import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material';
|
||||||
import { ExpandMore } from '@mui/icons-material';
|
import { ExpandMore } from '@mui/icons-material';
|
||||||
import React from 'react';
|
|
||||||
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
|
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
|
||||||
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
|
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
|
||||||
import { IFeatureEnvironment } from 'interfaces/featureToggle';
|
import { IFeatureEnvironment } from 'interfaces/featureToggle';
|
||||||
@ -15,8 +14,8 @@ import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetri
|
|||||||
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
|
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
|
||||||
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
|
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
|
|
||||||
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
|
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
|
||||||
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
|
||||||
interface IFeatureOverviewEnvironmentProps {
|
interface IFeatureOverviewEnvironmentProps {
|
||||||
env: IFeatureEnvironment;
|
env: IFeatureEnvironment;
|
||||||
@ -87,12 +86,12 @@ const FeatureOverviewEnvironment = ({
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={!env.enabled}
|
condition={!env.enabled}
|
||||||
show={
|
show={
|
||||||
<StatusBadge
|
<Badge
|
||||||
severity="warning"
|
color="warning"
|
||||||
className={styles.disabledIndicatorPos}
|
className={styles.disabledIndicatorPos}
|
||||||
>
|
>
|
||||||
Disabled
|
Disabled
|
||||||
</StatusBadge>
|
</Badge>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -398,6 +398,44 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
"title": "Users",
|
"title": "Users",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"flag": "UG",
|
||||||
|
"menu": {
|
||||||
|
"adminSettings": true,
|
||||||
|
},
|
||||||
|
"parent": "/admin",
|
||||||
|
"path": "/admin/groups",
|
||||||
|
"title": "Groups",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"flag": "UG",
|
||||||
|
"menu": {},
|
||||||
|
"parent": "/admin",
|
||||||
|
"path": "/admin/groups/:groupId",
|
||||||
|
"title": ":groupId",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"flag": "UG",
|
||||||
|
"menu": {},
|
||||||
|
"parent": "/admin/groups",
|
||||||
|
"path": "/admin/groups/create-group",
|
||||||
|
"title": "Create group",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"flag": "UG",
|
||||||
|
"menu": {},
|
||||||
|
"parent": "/admin/groups",
|
||||||
|
"path": "/admin/groups/:groupId/edit",
|
||||||
|
"title": "Edit group",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
"flag": "RE",
|
"flag": "RE",
|
||||||
|
@ -6,9 +6,10 @@ import { AddonList } from 'component/addons/AddonList/AddonList';
|
|||||||
import Admin from 'component/admin';
|
import Admin from 'component/admin';
|
||||||
import AdminApi from 'component/admin/api';
|
import AdminApi from 'component/admin/api';
|
||||||
import AdminUsers from 'component/admin/users/UsersAdmin';
|
import AdminUsers from 'component/admin/users/UsersAdmin';
|
||||||
|
import { GroupsAdmin } from 'component/admin/groups/GroupsAdmin';
|
||||||
import { AuthSettings } from 'component/admin/auth/AuthSettings';
|
import { AuthSettings } from 'component/admin/auth/AuthSettings';
|
||||||
import Login from 'component/user/Login/Login';
|
import Login from 'component/user/Login/Login';
|
||||||
import { C, EEA, P, RE, SE } from 'component/common/flags';
|
import { C, EEA, P, RE, SE, UG } from 'component/common/flags';
|
||||||
import { NewUser } from 'component/user/NewUser/NewUser';
|
import { NewUser } from 'component/user/NewUser/NewUser';
|
||||||
import ResetPassword from 'component/user/ResetPassword/ResetPassword';
|
import ResetPassword from 'component/user/ResetPassword/ResetPassword';
|
||||||
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
|
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
|
||||||
@ -53,6 +54,9 @@ import FlaggedBillingRedirect from 'component/admin/billing/FlaggedBillingRedire
|
|||||||
import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable';
|
import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable';
|
||||||
import { Billing } from 'component/admin/billing/Billing';
|
import { Billing } from 'component/admin/billing/Billing';
|
||||||
import { Playground } from 'component/playground/Playground/Playground';
|
import { Playground } from 'component/playground/Playground/Playground';
|
||||||
|
import { Group } from 'component/admin/groups/Group/Group';
|
||||||
|
import { CreateGroup } from 'component/admin/groups/CreateGroup/CreateGroup';
|
||||||
|
import { EditGroup } from 'component/admin/groups/EditGroup/EditGroup';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -450,6 +454,42 @@ export const routes: IRoute[] = [
|
|||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: {},
|
menu: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/groups',
|
||||||
|
parent: '/admin',
|
||||||
|
title: 'Groups',
|
||||||
|
component: GroupsAdmin,
|
||||||
|
type: 'protected',
|
||||||
|
menu: { adminSettings: true },
|
||||||
|
flag: UG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/groups/:groupId',
|
||||||
|
parent: '/admin',
|
||||||
|
title: ':groupId',
|
||||||
|
component: Group,
|
||||||
|
type: 'protected',
|
||||||
|
menu: {},
|
||||||
|
flag: UG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/groups/create-group',
|
||||||
|
parent: '/admin/groups',
|
||||||
|
title: 'Create group',
|
||||||
|
component: CreateGroup,
|
||||||
|
type: 'protected',
|
||||||
|
menu: {},
|
||||||
|
flag: UG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/groups/:groupId/edit',
|
||||||
|
parent: '/admin/groups',
|
||||||
|
title: 'Edit group',
|
||||||
|
component: EditGroup,
|
||||||
|
type: 'protected',
|
||||||
|
menu: {},
|
||||||
|
flag: UG,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/roles',
|
path: '/admin/roles',
|
||||||
parent: '/admin',
|
parent: '/admin',
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useContext, VFC } from 'react';
|
import { useContext, VFC } from 'react';
|
||||||
import { ProjectAccessPage } from 'component/project/ProjectAccess/ProjectAccessPage';
|
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { Alert } from '@mui/material';
|
import { Alert } from '@mui/material';
|
||||||
@ -8,6 +7,7 @@ import AccessContext from 'contexts/AccessContext';
|
|||||||
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { usePageTitle } from 'hooks/usePageTitle';
|
import { usePageTitle } from 'hooks/usePageTitle';
|
||||||
|
import { ProjectAccessTable } from 'component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable';
|
||||||
|
|
||||||
interface IProjectAccess {
|
interface IProjectAccess {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
@ -15,6 +15,7 @@ interface IProjectAccess {
|
|||||||
|
|
||||||
export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
|
export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const { isOss } = useUiConfig();
|
const { isOss } = useUiConfig();
|
||||||
usePageTitle(`Project access – ${projectName}`);
|
usePageTitle(`Project access – ${projectName}`);
|
||||||
@ -48,5 +49,5 @@ export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ProjectAccessPage />;
|
return <ProjectAccessTable />;
|
||||||
};
|
};
|
||||||
|
@ -1,227 +0,0 @@
|
|||||||
import React, { ChangeEvent, useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
TextField,
|
|
||||||
CircularProgress,
|
|
||||||
Grid,
|
|
||||||
Button,
|
|
||||||
InputAdornment,
|
|
||||||
SelectChangeEvent,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { Search } from '@mui/icons-material';
|
|
||||||
import Autocomplete from '@mui/material/Autocomplete';
|
|
||||||
import { ProjectRoleSelect } from '../ProjectRoleSelect/ProjectRoleSelect';
|
|
||||||
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
|
||||||
import useToast from 'hooks/useToast';
|
|
||||||
import useProjectAccess, {
|
|
||||||
IProjectAccessUser,
|
|
||||||
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
|
||||||
import { IProjectRole } from 'interfaces/role';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|
||||||
|
|
||||||
interface IProjectAccessAddUserProps {
|
|
||||||
roles: IProjectRole[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectAccessAddUser = ({ roles }: IProjectAccessAddUserProps) => {
|
|
||||||
const projectId = useRequiredPathParam('projectId');
|
|
||||||
const [user, setUser] = useState<IProjectAccessUser | undefined>();
|
|
||||||
const [role, setRole] = useState<IProjectRole | undefined>();
|
|
||||||
const [options, setOptions] = useState<IProjectAccessUser[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const { setToastData } = useToast();
|
|
||||||
const { refetchProjectAccess, access } = useProjectAccess(projectId);
|
|
||||||
|
|
||||||
const { searchProjectUser, addUserToRole } = useProjectApi();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (roles.length > 0) {
|
|
||||||
const regularRole = roles.find(
|
|
||||||
r => r.name.toLowerCase() === 'regular'
|
|
||||||
);
|
|
||||||
setRole(regularRole || roles[0]);
|
|
||||||
}
|
|
||||||
}, [roles]);
|
|
||||||
|
|
||||||
const search = async (query: string) => {
|
|
||||||
if (query.length > 1) {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const result = await searchProjectUser(query);
|
|
||||||
const userSearchResults = await result.json();
|
|
||||||
|
|
||||||
const filteredUsers = userSearchResults.filter(
|
|
||||||
(selectedUser: IProjectAccessUser) => {
|
|
||||||
const selected = access.users.find(
|
|
||||||
(user: IProjectAccessUser) =>
|
|
||||||
user.id === selectedUser.id
|
|
||||||
);
|
|
||||||
return !selected;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setOptions(filteredUsers);
|
|
||||||
} else {
|
|
||||||
setOptions([]);
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQueryUpdate = (evt: { target: { value: string } }) => {
|
|
||||||
const q = evt.target.value;
|
|
||||||
search(q);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
if (options.length > 0) {
|
|
||||||
const user = options[0];
|
|
||||||
setUser(user);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectUser = (
|
|
||||||
evt: ChangeEvent<{}>,
|
|
||||||
selectedUser: string | IProjectAccessUser | null
|
|
||||||
) => {
|
|
||||||
setOptions([]);
|
|
||||||
|
|
||||||
if (typeof selectedUser === 'string' || selectedUser === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedUser?.id) {
|
|
||||||
setUser(selectedUser);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRoleChange = (evt: SelectChangeEvent) => {
|
|
||||||
const roleId = Number(evt.target.value);
|
|
||||||
const role = roles.find(role => role.id === roleId);
|
|
||||||
if (role) {
|
|
||||||
setRole(role);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (evt: React.SyntheticEvent) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
if (!role || !user) {
|
|
||||||
setToastData({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Invalid selection',
|
|
||||||
text: `The selected user or role does not exist`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addUserToRole(projectId, role.id, user.id);
|
|
||||||
refetchProjectAccess();
|
|
||||||
setUser(undefined);
|
|
||||||
setOptions([]);
|
|
||||||
setToastData({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Added user to project',
|
|
||||||
text: `User added to the project with the role of ${role.name}`,
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
|
||||||
let error;
|
|
||||||
|
|
||||||
if (
|
|
||||||
e
|
|
||||||
.toString()
|
|
||||||
.includes(`User already has access to project=${projectId}`)
|
|
||||||
) {
|
|
||||||
error = `User already has access to project ${projectId}`;
|
|
||||||
} else {
|
|
||||||
error = e.toString() || 'Server problems when adding users.';
|
|
||||||
}
|
|
||||||
setToastData({
|
|
||||||
type: 'error',
|
|
||||||
title: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOptionLabel = (option: IProjectAccessUser | string) => {
|
|
||||||
if (option && typeof option !== 'string') {
|
|
||||||
return `${option.name || option.username || '(Empty name)'} <${
|
|
||||||
option.email || option.username
|
|
||||||
}>`;
|
|
||||||
} else return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Grid container spacing={3} alignItems="flex-end">
|
|
||||||
<Grid item>
|
|
||||||
<Autocomplete
|
|
||||||
id="add-user-component"
|
|
||||||
style={{ width: 300 }}
|
|
||||||
noOptionsText="No users found."
|
|
||||||
onChange={handleSelectUser}
|
|
||||||
onBlur={() => handleBlur()}
|
|
||||||
value={user || ''}
|
|
||||||
freeSolo
|
|
||||||
isOptionEqualToValue={() => true}
|
|
||||||
filterOptions={o => o}
|
|
||||||
getOptionLabel={getOptionLabel}
|
|
||||||
options={options}
|
|
||||||
loading={loading}
|
|
||||||
renderInput={params => (
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
label="User"
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
name="search"
|
|
||||||
onChange={handleQueryUpdate}
|
|
||||||
InputProps={{
|
|
||||||
...params.InputProps,
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">
|
|
||||||
<Search />
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
endAdornment: (
|
|
||||||
<>
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={loading}
|
|
||||||
show={
|
|
||||||
<CircularProgress
|
|
||||||
color="inherit"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{params.InputProps.endAdornment}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<ProjectRoleSelect
|
|
||||||
labelId="add-user-select-role-label"
|
|
||||||
id="add-user-select-role"
|
|
||||||
placeholder="Project role"
|
|
||||||
value={role?.id || -1}
|
|
||||||
onChange={handleRoleChange}
|
|
||||||
roles={roles}
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid item>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
disabled={!user}
|
|
||||||
onClick={handleSubmit}
|
|
||||||
>
|
|
||||||
Add user
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,390 @@
|
|||||||
|
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
Button,
|
||||||
|
capitalize,
|
||||||
|
Checkbox,
|
||||||
|
styled,
|
||||||
|
TextField,
|
||||||
|
} 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,
|
||||||
|
IProjectAccess,
|
||||||
|
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
||||||
|
import { IProjectRole } 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 { IUser } from 'interfaces/user';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
|
||||||
|
import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { ProjectRoleDescription } from './ProjectRoleDescription/ProjectRoleDescription';
|
||||||
|
|
||||||
|
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')(() => ({
|
||||||
|
marginTop: 'auto',
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledRoleOption = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > span:last-of-type': {
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IAccessOption {
|
||||||
|
id: number;
|
||||||
|
entity: IUser | IGroup;
|
||||||
|
type: ENTITY_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IProjectAccessAssignProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
selected?: IProjectAccess;
|
||||||
|
accesses: IProjectAccess[];
|
||||||
|
roles: IProjectRole[];
|
||||||
|
entityType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectAccessAssign = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
selected,
|
||||||
|
accesses,
|
||||||
|
roles,
|
||||||
|
entityType,
|
||||||
|
}: IProjectAccessAssignProps) => {
|
||||||
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
const { refetchProjectAccess } = useProjectAccess(projectId);
|
||||||
|
const { addAccessToProject, changeUserRole, changeGroupRole, loading } =
|
||||||
|
useProjectApi();
|
||||||
|
const { users = [] } = useUsers();
|
||||||
|
const { groups = [] } = useGroups();
|
||||||
|
const edit = Boolean(selected);
|
||||||
|
|
||||||
|
const { setToastData, setToastApiError } = useToast();
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
|
||||||
|
const [selectedOptions, setSelectedOptions] = useState<IAccessOption[]>([]);
|
||||||
|
const [role, setRole] = useState<IProjectRole | null>(
|
||||||
|
roles.find(({ id }) => id === selected?.entity.roleId) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRole(roles.find(({ id }) => id === selected?.entity.roleId) ?? null);
|
||||||
|
}, [roles, selected]);
|
||||||
|
|
||||||
|
const payload = useMemo(
|
||||||
|
() => ({
|
||||||
|
users: selectedOptions
|
||||||
|
?.filter(({ type }) => type === ENTITY_TYPE.USER)
|
||||||
|
.map(({ id }) => ({ id })),
|
||||||
|
groups: selectedOptions
|
||||||
|
?.filter(({ type }) => type === ENTITY_TYPE.GROUP)
|
||||||
|
.map(({ id }) => ({ id })),
|
||||||
|
}),
|
||||||
|
[selectedOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() => [
|
||||||
|
...groups
|
||||||
|
.filter(
|
||||||
|
(group: IGroup) =>
|
||||||
|
edit ||
|
||||||
|
!accesses.some(
|
||||||
|
({ entity: { id }, type }) =>
|
||||||
|
group.id === id && type === ENTITY_TYPE.GROUP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(group => ({
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((user: IUser) => ({
|
||||||
|
id: user.id,
|
||||||
|
entity: user,
|
||||||
|
type: ENTITY_TYPE.USER,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[users, accesses, edit, groups]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const selectedOption =
|
||||||
|
options.filter(
|
||||||
|
({ id, type }) =>
|
||||||
|
id === selected?.entity.id && type === selected?.type
|
||||||
|
) || [];
|
||||||
|
setSelectedOptions(selectedOption);
|
||||||
|
setRole(roles.find(({ id }) => id === selected?.entity.roleId) || null);
|
||||||
|
}, [open, selected, options, roles]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!role) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!edit) {
|
||||||
|
await addAccessToProject(projectId, role.id, payload);
|
||||||
|
} else if (selected?.type === ENTITY_TYPE.USER) {
|
||||||
|
await changeUserRole(projectId, role.id, selected.entity.id);
|
||||||
|
} else if (selected?.type === ENTITY_TYPE.GROUP) {
|
||||||
|
await changeGroupRole(projectId, role.id, selected.entity.id);
|
||||||
|
}
|
||||||
|
refetchProjectAccess();
|
||||||
|
setOpen(false);
|
||||||
|
setToastData({
|
||||||
|
title: `${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 ${edit ? 'PUT' : 'POST'} '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/projects/${projectId}/${
|
||||||
|
selected?.type === ENTITY_TYPE.USER ? 'users' : 'groups'
|
||||||
|
}/${selected?.entity.id}/roles/${role?.id}' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY'`;
|
||||||
|
}
|
||||||
|
return `curl --location --request ${edit ? 'PUT' : 'POST'} '${
|
||||||
|
uiConfig.unleashUrl
|
||||||
|
}/api/admin/projects/${projectId}/role/${role?.id}/access' \\
|
||||||
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data-raw '${JSON.stringify(payload, undefined, 2)}'`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOption = (
|
||||||
|
props: React.HTMLAttributes<HTMLLIElement>,
|
||||||
|
option: IAccessOption,
|
||||||
|
selected: boolean
|
||||||
|
) => {
|
||||||
|
let optionGroup;
|
||||||
|
let optionUser;
|
||||||
|
if (option.type === ENTITY_TYPE.GROUP) {
|
||||||
|
optionGroup = option.entity as IGroup;
|
||||||
|
} else {
|
||||||
|
optionUser = option.entity as IUser;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li {...props}>
|
||||||
|
<Checkbox
|
||||||
|
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
|
||||||
|
checkedIcon={<CheckBoxIcon fontSize="small" />}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
checked={selected}
|
||||||
|
/>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={option.type === ENTITY_TYPE.GROUP}
|
||||||
|
show={
|
||||||
|
<StyledGroupOption>
|
||||||
|
<span>{optionGroup?.name}</span>
|
||||||
|
<span>{optionGroup?.users.length} users</span>
|
||||||
|
</StyledGroupOption>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<StyledUserOption>
|
||||||
|
<span>
|
||||||
|
{optionUser?.name || optionUser?.username}
|
||||||
|
</span>
|
||||||
|
<span>{optionUser?.email}</span>
|
||||||
|
</StyledUserOption>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRoleOption = (
|
||||||
|
props: React.HTMLAttributes<HTMLLIElement>,
|
||||||
|
option: IProjectRole
|
||||||
|
) => (
|
||||||
|
<li {...props}>
|
||||||
|
<StyledRoleOption>
|
||||||
|
<span>{option.name}</span>
|
||||||
|
<span>{option.description}</span>
|
||||||
|
</StyledRoleOption>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
Select the {entityType}
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledAutocompleteWrapper>
|
||||||
|
<Autocomplete
|
||||||
|
size="small"
|
||||||
|
multiple
|
||||||
|
limitTags={10}
|
||||||
|
disableCloseOnSelect
|
||||||
|
disabled={edit}
|
||||||
|
value={selectedOptions}
|
||||||
|
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) {
|
||||||
|
const optionUser =
|
||||||
|
option.entity as IUser;
|
||||||
|
return (
|
||||||
|
optionUser.email ||
|
||||||
|
optionUser.name ||
|
||||||
|
optionUser.username ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return option.entity.name;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderInput={params => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={capitalize(entityType)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</StyledAutocompleteWrapper>
|
||||||
|
<StyledInputDescription>
|
||||||
|
Select the role to assign for this project
|
||||||
|
</StyledInputDescription>
|
||||||
|
<StyledAutocompleteWrapper>
|
||||||
|
<Autocomplete
|
||||||
|
size="small"
|
||||||
|
value={role}
|
||||||
|
onChange={(_, newValue) => setRole(newValue)}
|
||||||
|
options={roles}
|
||||||
|
renderOption={renderRoleOption}
|
||||||
|
getOptionLabel={option => option.name}
|
||||||
|
renderInput={params => (
|
||||||
|
<TextField {...params} label="Role" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</StyledAutocompleteWrapper>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(role?.id)}
|
||||||
|
show={<ProjectRoleDescription roleId={role?.id!} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StyledButtonContainer>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Assign {entityType}
|
||||||
|
</Button>
|
||||||
|
<StyledCancelButton
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledCancelButton>
|
||||||
|
</StyledButtonContainer>
|
||||||
|
</StyledForm>
|
||||||
|
</FormTemplate>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,103 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
|
import { useMemo, VFC } from 'react';
|
||||||
|
import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
|
||||||
|
const StyledDescription = styled('div')(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: theme.spacing(50),
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
backgroundColor: theme.palette.neutral.light,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
borderRadius: theme.shape.borderRadiusMedium,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDescriptionBlock = styled('div')(({ theme }) => ({
|
||||||
|
'& p:last-child': {
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDescriptionHeader = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDescriptionSubHeader = styled('p')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface IProjectRoleDescriptionProps {
|
||||||
|
roleId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = ({
|
||||||
|
roleId,
|
||||||
|
}) => {
|
||||||
|
const { role } = useProjectRole(roleId.toString());
|
||||||
|
|
||||||
|
const environments = useMemo(() => {
|
||||||
|
const environments = new Set<string>();
|
||||||
|
role.permissions
|
||||||
|
?.filter((permission: any) => permission.environment !== '')
|
||||||
|
.forEach((permission: any) => {
|
||||||
|
environments.add(permission.environment);
|
||||||
|
});
|
||||||
|
return [...environments].sort();
|
||||||
|
}, [role]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledDescription>
|
||||||
|
<StyledDescriptionHeader>
|
||||||
|
Project permissions
|
||||||
|
</StyledDescriptionHeader>
|
||||||
|
<StyledDescriptionBlock>
|
||||||
|
{role.permissions
|
||||||
|
?.filter((permission: any) => permission.environment === '')
|
||||||
|
.map((permission: any) => permission.displayName)
|
||||||
|
.sort()
|
||||||
|
.map((permission: any) => (
|
||||||
|
<p key={permission}>{permission}</p>
|
||||||
|
))}
|
||||||
|
</StyledDescriptionBlock>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={Boolean(environments.length)}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<StyledDescriptionHeader>
|
||||||
|
Environment permissions
|
||||||
|
</StyledDescriptionHeader>
|
||||||
|
{environments.map((environment: any) => (
|
||||||
|
<div key={environment}>
|
||||||
|
<StyledDescriptionSubHeader>
|
||||||
|
{environment}
|
||||||
|
</StyledDescriptionSubHeader>
|
||||||
|
<StyledDescriptionBlock>
|
||||||
|
{role.permissions
|
||||||
|
.filter(
|
||||||
|
(permission: any) =>
|
||||||
|
permission.environment ===
|
||||||
|
environment
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(permission: any) =>
|
||||||
|
permission.displayName
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.map((permission: any) => (
|
||||||
|
<p key={permission}>{permission}</p>
|
||||||
|
))}
|
||||||
|
</StyledDescriptionBlock>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledDescription>
|
||||||
|
);
|
||||||
|
};
|
@ -1,97 +0,0 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
|
||||||
import { SelectChangeEvent } from '@mui/material';
|
|
||||||
import { ProjectAccessAddUser } from './ProjectAccessAddUser/ProjectAccessAddUser';
|
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
|
||||||
import { useStyles } from './ProjectAccess.styles';
|
|
||||||
import useToast from 'hooks/useToast';
|
|
||||||
import { Dialogue as ConfirmDialogue } from 'component/common/Dialogue/Dialogue';
|
|
||||||
import useProjectAccess, {
|
|
||||||
IProjectAccessUser,
|
|
||||||
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
|
||||||
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
|
||||||
import { ProjectAccessTable } from './ProjectAccessTable/ProjectAccessTable';
|
|
||||||
|
|
||||||
export const ProjectAccessPage = () => {
|
|
||||||
const projectId = useRequiredPathParam('projectId');
|
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
const { access, refetchProjectAccess } = useProjectAccess(projectId);
|
|
||||||
const { setToastData } = useToast();
|
|
||||||
const { removeUserFromRole, changeUserRole } = useProjectApi();
|
|
||||||
const [showDelDialogue, setShowDelDialogue] = useState(false);
|
|
||||||
const [user, setUser] = useState<IProjectAccessUser | undefined>();
|
|
||||||
|
|
||||||
const handleRoleChange = useCallback(
|
|
||||||
(userId: number) => async (evt: SelectChangeEvent) => {
|
|
||||||
const roleId = Number(evt.target.value);
|
|
||||||
try {
|
|
||||||
await changeUserRole(projectId, roleId, userId);
|
|
||||||
refetchProjectAccess();
|
|
||||||
setToastData({
|
|
||||||
type: 'success',
|
|
||||||
title: 'Success',
|
|
||||||
text: 'User role changed successfully',
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
setToastData({
|
|
||||||
type: 'error',
|
|
||||||
title: err.message || 'Server problems when adding users.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[changeUserRole, projectId, refetchProjectAccess, setToastData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveAccess = (user: IProjectAccessUser) => {
|
|
||||||
setUser(user);
|
|
||||||
setShowDelDialogue(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeAccess = (user: IProjectAccessUser | undefined) => async () => {
|
|
||||||
if (!user) return;
|
|
||||||
const { id, roleId } = user;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeUserFromRole(projectId, roleId, id);
|
|
||||||
refetchProjectAccess();
|
|
||||||
setToastData({
|
|
||||||
type: 'success',
|
|
||||||
title: `${
|
|
||||||
user.email || user.username || 'The user'
|
|
||||||
} has been removed from project`,
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
setToastData({
|
|
||||||
type: 'error',
|
|
||||||
title: err.message || 'Server problems when adding users.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setShowDelDialogue(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContent
|
|
||||||
header={<PageHeader titleElement="Project roles" />}
|
|
||||||
className={styles.pageContent}
|
|
||||||
>
|
|
||||||
<ProjectAccessAddUser roles={access?.roles} />
|
|
||||||
<div className={styles.divider} />
|
|
||||||
<ProjectAccessTable
|
|
||||||
access={access}
|
|
||||||
handleRoleChange={handleRoleChange}
|
|
||||||
handleRemoveAccess={handleRemoveAccess}
|
|
||||||
projectId={projectId}
|
|
||||||
/>
|
|
||||||
<ConfirmDialogue
|
|
||||||
open={showDelDialogue}
|
|
||||||
onClick={removeAccess(user)}
|
|
||||||
onClose={() => {
|
|
||||||
setUser(undefined);
|
|
||||||
setShowDelDialogue(false);
|
|
||||||
}}
|
|
||||||
title="Really remove user from this project"
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,109 +1,240 @@
|
|||||||
import { useMemo, VFC } from 'react';
|
import { useEffect, useMemo, useState, VFC } from 'react';
|
||||||
import { useSortBy, useTable } from 'react-table';
|
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||||
import {
|
import { VirtualizedTable, TablePlaceholder } from 'component/common/Table';
|
||||||
Table,
|
import { Avatar, Button, styled, useMediaQuery, useTheme } from '@mui/material';
|
||||||
TableBody,
|
import { Delete, Edit } from '@mui/icons-material';
|
||||||
TableRow,
|
|
||||||
TableCell,
|
|
||||||
SortableTableHeader,
|
|
||||||
} from 'component/common/Table';
|
|
||||||
import { Avatar, SelectChangeEvent } from '@mui/material';
|
|
||||||
import { Delete } from '@mui/icons-material';
|
|
||||||
import { sortTypes } from 'utils/sortTypes';
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
import {
|
import useProjectAccess, {
|
||||||
IProjectAccessOutput,
|
ENTITY_TYPE,
|
||||||
IProjectAccessUser,
|
IProjectAccess,
|
||||||
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
||||||
import { ProjectRoleCell } from './ProjectRoleCell/ProjectRoleCell';
|
|
||||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||||
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { useSearch } from 'hooks/useSearch';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { createLocalStorage } from 'utils/createLocalStorage';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { ProjectAccessAssign } from 'component/project/ProjectAccess/ProjectAccessAssign/ProjectAccessAssign';
|
||||||
|
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
|
||||||
|
import useToast from 'hooks/useToast';
|
||||||
|
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||||
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { ProjectGroupView } from '../ProjectGroupView/ProjectGroupView';
|
||||||
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
|
import { IUser } from 'interfaces/user';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
|
||||||
|
|
||||||
const initialState = {
|
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||||
sortBy: [{ id: 'name' }],
|
width: theme.spacing(4),
|
||||||
};
|
height: theme.spacing(4),
|
||||||
|
margin: 'auto',
|
||||||
|
backgroundColor: theme.palette.secondary.light,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
fontWeight: theme.fontWeight.bold,
|
||||||
|
}));
|
||||||
|
|
||||||
interface IProjectAccessTableProps {
|
export type PageQueryType = Partial<
|
||||||
access: IProjectAccessOutput;
|
Record<'sort' | 'order' | 'search', string>
|
||||||
projectId: string;
|
>;
|
||||||
handleRoleChange: (
|
|
||||||
userId: number
|
|
||||||
) => (event: SelectChangeEvent) => Promise<void>;
|
|
||||||
handleRemoveAccess: (user: IProjectAccessUser) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
|
const defaultSort: SortingRule<string> = { id: 'added' };
|
||||||
access,
|
|
||||||
projectId,
|
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
||||||
handleRoleChange,
|
'ProjectAccess:v1',
|
||||||
handleRemoveAccess,
|
defaultSort
|
||||||
}) => {
|
);
|
||||||
const data = access.users;
|
|
||||||
|
export const ProjectAccessTable: VFC = () => {
|
||||||
|
const projectId = useRequiredPathParam('projectId');
|
||||||
|
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const { flags } = uiConfig;
|
||||||
|
const entityType = flags.UG ? 'user / group' : 'user';
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const { setToastData } = useToast();
|
||||||
|
|
||||||
|
const { access, refetchProjectAccess } = useProjectAccess(projectId);
|
||||||
|
const { removeUserFromRole, removeGroupFromRole } = useProjectApi();
|
||||||
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
|
const [removeOpen, setRemoveOpen] = useState(false);
|
||||||
|
const [groupOpen, setGroupOpen] = useState(false);
|
||||||
|
const [selectedRow, setSelectedRow] = useState<IProjectAccess>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assignOpen && !groupOpen) {
|
||||||
|
setSelectedRow(undefined);
|
||||||
|
}
|
||||||
|
}, [assignOpen, groupOpen]);
|
||||||
|
|
||||||
|
const roles = useMemo(
|
||||||
|
() => access.roles || [],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[JSON.stringify(access.roles)]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedData: IProjectAccess[] = useMemo(() => {
|
||||||
|
const users = access.users || [];
|
||||||
|
const groups = access.groups || [];
|
||||||
|
return [
|
||||||
|
...users.map(user => ({
|
||||||
|
entity: user,
|
||||||
|
type: ENTITY_TYPE.USER,
|
||||||
|
})),
|
||||||
|
...groups.map(group => ({
|
||||||
|
entity: group,
|
||||||
|
type: ENTITY_TYPE.GROUP,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}, [access]);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
Header: 'Avatar',
|
Header: 'Avatar',
|
||||||
accessor: 'imageUrl',
|
accessor: 'imageUrl',
|
||||||
disableSortBy: true,
|
Cell: ({ row: { original: row } }: any) => (
|
||||||
width: 80,
|
<TextCell>
|
||||||
Cell: ({ value }: { value: string }) => (
|
<StyledAvatar
|
||||||
<Avatar
|
data-loading
|
||||||
alt="Gravatar"
|
alt="Gravatar"
|
||||||
src={value}
|
src={row.entity.imageUrl}
|
||||||
sx={{ width: 32, height: 32, mx: 'auto' }}
|
title={`${
|
||||||
/>
|
row.entity.name ||
|
||||||
|
row.entity.email ||
|
||||||
|
row.entity.username
|
||||||
|
} (id: ${row.entity.id})`}
|
||||||
|
>
|
||||||
|
{row.entity.users?.length}
|
||||||
|
</StyledAvatar>
|
||||||
|
</TextCell>
|
||||||
),
|
),
|
||||||
align: 'center',
|
maxWidth: 85,
|
||||||
|
disableSortBy: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
Header: 'Name',
|
Header: 'Name',
|
||||||
accessor: (row: any) => row.name || '',
|
accessor: (row: IProjectAccess) => row.entity.name || '',
|
||||||
|
Cell: ({ value, row: { original: row } }: any) => (
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={row.type === ENTITY_TYPE.GROUP}
|
||||||
|
show={
|
||||||
|
<LinkCell
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRow(row);
|
||||||
|
setGroupOpen(true);
|
||||||
|
}}
|
||||||
|
title={value}
|
||||||
|
subtitle={`${row.entity.users?.length} users`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
elseShow={<HighlightCell value={value} />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'username',
|
id: 'username',
|
||||||
Header: 'Username',
|
Header: 'Username',
|
||||||
accessor: 'email',
|
accessor: (row: IProjectAccess) => {
|
||||||
Cell: ({ row: { original: user } }: any) => (
|
if (row.type === ENTITY_TYPE.USER) {
|
||||||
<TextCell>{user.email || user.username}</TextCell>
|
const userRow = row.entity as IUser;
|
||||||
),
|
return userRow.username || userRow.email;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Header: 'Role',
|
Header: 'Role',
|
||||||
accessor: 'roleId',
|
accessor: (row: IProjectAccess) =>
|
||||||
Cell: ({
|
roles.find(({ id }) => id === row.entity.roleId)?.name,
|
||||||
value,
|
minWidth: 120,
|
||||||
row: { original: user },
|
filterName: 'role',
|
||||||
}: {
|
},
|
||||||
value: number;
|
{
|
||||||
row: { original: IProjectAccessUser };
|
id: 'added',
|
||||||
}) => (
|
Header: 'Added',
|
||||||
<ProjectRoleCell
|
accessor: (row: IProjectAccess) => {
|
||||||
value={value}
|
const userRow = row.entity as IUser | IGroup;
|
||||||
user={user}
|
return userRow.addedAt || '';
|
||||||
roles={access.roles}
|
},
|
||||||
onChange={handleRoleChange(user.id)}
|
Cell: ({ value }: { value: Date }) => (
|
||||||
/>
|
<TimeAgoCell value={value} emptyText="Never logged" />
|
||||||
),
|
),
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Last login',
|
||||||
|
accessor: (row: IProjectAccess) => {
|
||||||
|
if (row.type === ENTITY_TYPE.USER) {
|
||||||
|
const userRow = row.entity as IUser;
|
||||||
|
return userRow.seenAt || '';
|
||||||
|
}
|
||||||
|
const userGroup = row.entity as IGroup;
|
||||||
|
return userGroup.users
|
||||||
|
.map(({ seenAt }) => seenAt)
|
||||||
|
.sort()
|
||||||
|
.reverse()[0];
|
||||||
|
},
|
||||||
|
Cell: ({ value }: { value: Date }) => (
|
||||||
|
<TimeAgoCell value={value} emptyText="Never logged" />
|
||||||
|
),
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
Header: 'Actions',
|
Header: 'Actions',
|
||||||
disableSortBy: true,
|
disableSortBy: true,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 80,
|
maxWidth: 200,
|
||||||
Cell: ({ row: { original: user } }: any) => (
|
Cell: ({ row: { original: row } }: any) => (
|
||||||
<ActionCell>
|
<ActionCell>
|
||||||
<PermissionIconButton
|
<PermissionIconButton
|
||||||
permission={UPDATE_PROJECT}
|
permission={UPDATE_PROJECT}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onClick={() => handleRemoveAccess(user)}
|
onClick={() => {
|
||||||
disabled={access.users.length === 1}
|
setSelectedRow(row);
|
||||||
|
setAssignOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={mappedData.length === 1}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
title:
|
title:
|
||||||
access.users.length === 1
|
mappedData.length === 1
|
||||||
|
? 'Cannot edit access. A project must have at least one owner'
|
||||||
|
: 'Edit access',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</PermissionIconButton>
|
||||||
|
<PermissionIconButton
|
||||||
|
permission={UPDATE_PROJECT}
|
||||||
|
projectId={projectId}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRow(row);
|
||||||
|
setRemoveOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={mappedData.length === 1}
|
||||||
|
tooltipProps={{
|
||||||
|
title:
|
||||||
|
mappedData.length === 1
|
||||||
? 'Cannot remove access. A project must have at least one owner'
|
? 'Cannot remove access. A project must have at least one owner'
|
||||||
: 'Remove access',
|
: 'Remove access',
|
||||||
}}
|
}}
|
||||||
@ -114,50 +245,214 @@ export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[roles, mappedData.length, projectId]
|
||||||
access.roles,
|
|
||||||
access.users.length,
|
|
||||||
handleRemoveAccess,
|
|
||||||
handleRoleChange,
|
|
||||||
projectId,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
useTable(
|
const [initialState] = useState(() => ({
|
||||||
|
sortBy: [
|
||||||
{
|
{
|
||||||
columns: columns as any[], // TODO: fix after `react-table` v8 update
|
id: searchParams.get('sort') || storedParams.id,
|
||||||
data,
|
desc: searchParams.has('order')
|
||||||
initialState,
|
? searchParams.get('order') === 'desc'
|
||||||
sortTypes,
|
: storedParams.desc,
|
||||||
autoResetGlobalFilter: false,
|
|
||||||
autoResetSortBy: false,
|
|
||||||
disableSortRemove: true,
|
|
||||||
defaultColumn: {
|
|
||||||
Cell: TextCell,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
useSortBy
|
],
|
||||||
);
|
globalFilter: searchParams.get('search') || '',
|
||||||
|
}));
|
||||||
|
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
||||||
|
|
||||||
|
const { data, getSearchText, getSearchContext } = useSearch(
|
||||||
|
columns,
|
||||||
|
searchValue,
|
||||||
|
mappedData ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
headerGroups,
|
||||||
|
rows,
|
||||||
|
prepareRow,
|
||||||
|
state: { sortBy },
|
||||||
|
} = useTable(
|
||||||
|
{
|
||||||
|
columns: columns as any[],
|
||||||
|
data,
|
||||||
|
initialState,
|
||||||
|
sortTypes,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
disableSortRemove: true,
|
||||||
|
disableMultiSort: true,
|
||||||
|
defaultColumn: {
|
||||||
|
Cell: TextCell,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tableState: PageQueryType = {};
|
||||||
|
tableState.sort = sortBy[0].id;
|
||||||
|
if (sortBy[0].desc) {
|
||||||
|
tableState.order = 'desc';
|
||||||
|
}
|
||||||
|
if (searchValue) {
|
||||||
|
tableState.search = searchValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchParams(tableState, {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
setStoredParams({ id: sortBy[0].id, desc: sortBy[0].desc || false });
|
||||||
|
}, [sortBy, searchValue, setSearchParams]);
|
||||||
|
|
||||||
|
const removeAccess = async (userOrGroup?: IProjectAccess) => {
|
||||||
|
if (!userOrGroup) return;
|
||||||
|
const { id, roleId } = userOrGroup.entity;
|
||||||
|
let name = userOrGroup.entity.name;
|
||||||
|
if (userOrGroup.type === ENTITY_TYPE.USER) {
|
||||||
|
const user = userOrGroup.entity as IUser;
|
||||||
|
name = name || user.email || user.username || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (userOrGroup.type === ENTITY_TYPE.USER) {
|
||||||
|
await removeUserFromRole(projectId, roleId, id);
|
||||||
|
} else {
|
||||||
|
await removeGroupFromRole(projectId, roleId, id);
|
||||||
|
}
|
||||||
|
refetchProjectAccess();
|
||||||
|
setToastData({
|
||||||
|
type: 'success',
|
||||||
|
title: `${
|
||||||
|
name || `The ${entityType}`
|
||||||
|
} has been removed from project`,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
setToastData({
|
||||||
|
type: 'error',
|
||||||
|
title:
|
||||||
|
err.message ||
|
||||||
|
`Server problems when removing ${entityType}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRemoveOpen(false);
|
||||||
|
setSelectedRow(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table {...getTableProps()} rowHeight="compact">
|
<PageContent
|
||||||
{/* @ts-expect-error -- react-table */}
|
header={
|
||||||
<SortableTableHeader headerGroups={headerGroups} />
|
<PageHeader
|
||||||
<TableBody {...getTableBodyProps()}>
|
secondary
|
||||||
{rows.map(row => {
|
title={`Access (${
|
||||||
prepareRow(row);
|
rows.length < data.length
|
||||||
return (
|
? `${rows.length} of ${data.length}`
|
||||||
<TableRow hover {...row.getRowProps()}>
|
: data.length
|
||||||
{row.cells.map(cell => (
|
})`}
|
||||||
<TableCell {...cell.getCellProps()}>
|
actions={
|
||||||
{cell.render('Cell')}
|
<>
|
||||||
</TableCell>
|
<ConditionallyRender
|
||||||
))}
|
condition={!isSmallScreen}
|
||||||
</TableRow>
|
show={
|
||||||
);
|
<>
|
||||||
})}
|
<Search
|
||||||
</TableBody>
|
initialValue={searchValue}
|
||||||
</Table>
|
onChange={setSearchValue}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
/>
|
||||||
|
<PageHeader.Divider />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setAssignOpen(true)}
|
||||||
|
>
|
||||||
|
Assign {entityType}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isSmallScreen}
|
||||||
|
show={
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||||
|
<VirtualizedTable
|
||||||
|
rows={rows}
|
||||||
|
headerGroups={headerGroups}
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
/>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={searchValue?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No access found matching “
|
||||||
|
{searchValue}
|
||||||
|
”
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No access available. Get started by assigning a{' '}
|
||||||
|
{entityType}.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ProjectAccessAssign
|
||||||
|
open={assignOpen}
|
||||||
|
setOpen={setAssignOpen}
|
||||||
|
selected={selectedRow}
|
||||||
|
accesses={mappedData}
|
||||||
|
roles={roles}
|
||||||
|
entityType={entityType}
|
||||||
|
/>
|
||||||
|
<Dialogue
|
||||||
|
open={removeOpen}
|
||||||
|
onClick={() => removeAccess(selectedRow)}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedRow(undefined);
|
||||||
|
setRemoveOpen(false);
|
||||||
|
}}
|
||||||
|
title={`Really remove ${entityType} from this project?`}
|
||||||
|
/>
|
||||||
|
<ProjectGroupView
|
||||||
|
open={groupOpen}
|
||||||
|
setOpen={setGroupOpen}
|
||||||
|
group={selectedRow?.entity as IGroup}
|
||||||
|
projectId={projectId}
|
||||||
|
subtitle={`Role: ${
|
||||||
|
roles.find(({ id }) => id === selectedRow?.entity.roleId)
|
||||||
|
?.name
|
||||||
|
}`}
|
||||||
|
onEdit={() => {
|
||||||
|
setAssignOpen(true);
|
||||||
|
console.log('Assign Open true');
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setGroupOpen(false);
|
||||||
|
setRemoveOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
import { makeStyles } from 'tss-react/mui';
|
|
||||||
|
|
||||||
export const useStyles = makeStyles()(theme => ({
|
|
||||||
cell: {
|
|
||||||
padding: theme.spacing(0, 1.5),
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
}));
|
|
@ -1,38 +0,0 @@
|
|||||||
import { VFC } from 'react';
|
|
||||||
import { Box, MenuItem, SelectChangeEvent } from '@mui/material';
|
|
||||||
import { IProjectAccessUser } from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
|
||||||
import { IProjectRole } from 'interfaces/role';
|
|
||||||
import { ProjectRoleSelect } from '../../ProjectRoleSelect/ProjectRoleSelect';
|
|
||||||
import { useStyles } from './ProjectRoleCell.styles';
|
|
||||||
|
|
||||||
interface IProjectRoleCellProps {
|
|
||||||
value: number;
|
|
||||||
user: IProjectAccessUser;
|
|
||||||
roles: IProjectRole[];
|
|
||||||
onChange: (event: SelectChangeEvent) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectRoleCell: VFC<IProjectRoleCellProps> = ({
|
|
||||||
value,
|
|
||||||
user,
|
|
||||||
roles,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const { classes } = useStyles();
|
|
||||||
return (
|
|
||||||
<Box className={classes.cell}>
|
|
||||||
<ProjectRoleSelect
|
|
||||||
id={`role-${user.id}-select`}
|
|
||||||
key={user.id}
|
|
||||||
placeholder="Choose role"
|
|
||||||
onChange={onChange}
|
|
||||||
roles={roles}
|
|
||||||
value={value || -1}
|
|
||||||
>
|
|
||||||
<MenuItem value="" disabled>
|
|
||||||
Choose role
|
|
||||||
</MenuItem>
|
|
||||||
</ProjectRoleSelect>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
@ -0,0 +1,270 @@
|
|||||||
|
import { Delete, Edit } from '@mui/icons-material';
|
||||||
|
import { Avatar, styled, useMediaQuery, useTheme } from '@mui/material';
|
||||||
|
import { GroupUserRoleCell } from 'component/admin/groups/GroupUserRoleCell/GroupUserRoleCell';
|
||||||
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
|
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||||
|
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||||
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
|
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||||
|
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||||
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
|
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
|
||||||
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
|
||||||
|
import { useSearch } from 'hooks/useSearch';
|
||||||
|
import { IGroup, IGroupUser } from 'interfaces/group';
|
||||||
|
import { VFC, useState } from 'react';
|
||||||
|
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||||
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
|
|
||||||
|
const StyledAvatar = styled(Avatar)(({ theme }) => ({
|
||||||
|
width: theme.spacing(4),
|
||||||
|
height: theme.spacing(4),
|
||||||
|
margin: 'auto',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledPageContent = styled(PageContent)(({ theme }) => ({
|
||||||
|
height: '100vh',
|
||||||
|
padding: theme.spacing(7.5, 6),
|
||||||
|
'& .header': {
|
||||||
|
padding: theme.spacing(0, 0, 2, 0),
|
||||||
|
},
|
||||||
|
'& .body': {
|
||||||
|
padding: theme.spacing(3, 0, 0, 0),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTitle = styled('div')(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > span': {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultSort: SortingRule<string> = { id: 'role', desc: true };
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
Header: 'Avatar',
|
||||||
|
accessor: 'imageUrl',
|
||||||
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
|
<TextCell>
|
||||||
|
<StyledAvatar
|
||||||
|
data-loading
|
||||||
|
alt="Gravatar"
|
||||||
|
src={user.imageUrl}
|
||||||
|
title={`${user.name || user.email || user.username} (id: ${
|
||||||
|
user.id
|
||||||
|
})`}
|
||||||
|
/>
|
||||||
|
</TextCell>
|
||||||
|
),
|
||||||
|
maxWidth: 85,
|
||||||
|
disableSortBy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
Header: 'Name',
|
||||||
|
accessor: (row: IGroupUser) => row.name || '',
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'username',
|
||||||
|
Header: 'Username',
|
||||||
|
accessor: (row: IGroupUser) => row.username || row.email,
|
||||||
|
Cell: HighlightCell,
|
||||||
|
minWidth: 100,
|
||||||
|
searchable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'User type',
|
||||||
|
accessor: 'role',
|
||||||
|
Cell: GroupUserRoleCell,
|
||||||
|
maxWidth: 150,
|
||||||
|
filterName: 'type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Joined',
|
||||||
|
accessor: 'joinedAt',
|
||||||
|
Cell: DateCell,
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Header: 'Last login',
|
||||||
|
accessor: (row: IGroupUser) => row.seenAt || '',
|
||||||
|
Cell: ({ row: { original: user } }: any) => (
|
||||||
|
<TimeAgoCell value={user.seenAt} emptyText="Never logged" />
|
||||||
|
),
|
||||||
|
sortType: 'date',
|
||||||
|
maxWidth: 150,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface IProjectGroupViewProps {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
group: IGroup;
|
||||||
|
projectId: string;
|
||||||
|
subtitle: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectGroupView: VFC<IProjectGroupViewProps> = ({
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
group,
|
||||||
|
projectId,
|
||||||
|
subtitle,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
|
||||||
|
const [initialState] = useState(() => ({
|
||||||
|
sortBy: [
|
||||||
|
{
|
||||||
|
id: defaultSort.id,
|
||||||
|
desc: defaultSort.desc,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
const { data, getSearchText, getSearchContext } = useSearch(
|
||||||
|
columns,
|
||||||
|
searchValue,
|
||||||
|
group?.users ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
const { headerGroups, rows, prepareRow } = useTable(
|
||||||
|
{
|
||||||
|
columns: columns as any[],
|
||||||
|
data,
|
||||||
|
initialState,
|
||||||
|
sortTypes,
|
||||||
|
autoResetSortBy: false,
|
||||||
|
disableSortRemove: true,
|
||||||
|
disableMultiSort: true,
|
||||||
|
},
|
||||||
|
useSortBy,
|
||||||
|
useFlexLayout
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarModal
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
label={group?.name || 'Group'}
|
||||||
|
>
|
||||||
|
<StyledPageContent
|
||||||
|
header={
|
||||||
|
<PageHeader
|
||||||
|
secondary
|
||||||
|
titleElement={
|
||||||
|
<StyledTitle>
|
||||||
|
{group?.name} (
|
||||||
|
{rows.length < data.length
|
||||||
|
? `${rows.length} of ${data.length}`
|
||||||
|
: data.length}
|
||||||
|
)<span>{subtitle}</span>
|
||||||
|
</StyledTitle>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={!isSmallScreen}
|
||||||
|
show={
|
||||||
|
<>
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={
|
||||||
|
getSearchContext
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PageHeader.Divider />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PermissionIconButton
|
||||||
|
permission={UPDATE_PROJECT}
|
||||||
|
projectId={projectId}
|
||||||
|
tooltipProps={{
|
||||||
|
title: 'Edit group access',
|
||||||
|
}}
|
||||||
|
onClick={onEdit}
|
||||||
|
>
|
||||||
|
<Edit />
|
||||||
|
</PermissionIconButton>
|
||||||
|
<PermissionIconButton
|
||||||
|
permission={UPDATE_PROJECT}
|
||||||
|
projectId={projectId}
|
||||||
|
tooltipProps={{
|
||||||
|
title: 'Remove group access',
|
||||||
|
}}
|
||||||
|
onClick={onRemove}
|
||||||
|
>
|
||||||
|
<Delete />
|
||||||
|
</PermissionIconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isSmallScreen}
|
||||||
|
show={
|
||||||
|
<Search
|
||||||
|
initialValue={searchValue}
|
||||||
|
onChange={setSearchValue}
|
||||||
|
hasFilters
|
||||||
|
getSearchContext={getSearchContext}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
||||||
|
<VirtualizedTable
|
||||||
|
rows={rows}
|
||||||
|
headerGroups={headerGroups}
|
||||||
|
prepareRow={prepareRow}
|
||||||
|
/>
|
||||||
|
</SearchHighlightProvider>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={rows.length === 0}
|
||||||
|
show={
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={searchValue?.length > 0}
|
||||||
|
show={
|
||||||
|
<TablePlaceholder>
|
||||||
|
No users found matching “
|
||||||
|
{searchValue}
|
||||||
|
” in this group.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
elseShow={
|
||||||
|
<TablePlaceholder>
|
||||||
|
This group is empty. Get started by adding a
|
||||||
|
user to the group.
|
||||||
|
</TablePlaceholder>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledPageContent>
|
||||||
|
</SidebarModal>
|
||||||
|
);
|
||||||
|
};
|
@ -1,82 +0,0 @@
|
|||||||
import {
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
SelectChangeEvent,
|
|
||||||
} from '@mui/material';
|
|
||||||
import React from 'react';
|
|
||||||
import { IProjectRole } from 'interfaces/role';
|
|
||||||
|
|
||||||
import { useStyles } from '../ProjectAccess.styles';
|
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
|
||||||
|
|
||||||
interface IProjectRoleSelect {
|
|
||||||
roles: IProjectRole[];
|
|
||||||
labelId?: string;
|
|
||||||
id: string;
|
|
||||||
placeholder?: string;
|
|
||||||
onChange: (evt: SelectChangeEvent) => void;
|
|
||||||
value: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectRoleSelect: React.FC<IProjectRoleSelect> = ({
|
|
||||||
roles,
|
|
||||||
onChange,
|
|
||||||
labelId,
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
placeholder,
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const { classes: styles } = useStyles();
|
|
||||||
return (
|
|
||||||
<FormControl variant="outlined" size="small">
|
|
||||||
<ConditionallyRender
|
|
||||||
condition={Boolean(labelId)}
|
|
||||||
show={() => (
|
|
||||||
<InputLabel
|
|
||||||
style={{ backgroundColor: '#fff' }}
|
|
||||||
id={labelId}
|
|
||||||
>
|
|
||||||
Role
|
|
||||||
</InputLabel>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
labelId={labelId}
|
|
||||||
id={id}
|
|
||||||
classes={{ select: styles.projectRoleSelect }}
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value || ''}
|
|
||||||
onChange={onChange}
|
|
||||||
renderValue={roleId => {
|
|
||||||
const role = roles?.find(role => {
|
|
||||||
return String(role.id) === String(roleId);
|
|
||||||
});
|
|
||||||
return role?.name || '';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{roles?.map(role => (
|
|
||||||
<MenuItem
|
|
||||||
key={role.id}
|
|
||||||
value={role.id}
|
|
||||||
classes={{
|
|
||||||
root: styles.menuItem,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className={styles.roleName}>{role.name}</span>
|
|
||||||
<p>
|
|
||||||
{role.description ||
|
|
||||||
'No role description available.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,6 +1,6 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Box } from '@mui/material';
|
import { Box, styled } from '@mui/material';
|
||||||
import { Extension } from '@mui/icons-material';
|
import { Extension } from '@mui/icons-material';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -26,11 +26,11 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC
|
|||||||
import { sortTypes } from 'utils/sortTypes';
|
import { sortTypes } from 'utils/sortTypes';
|
||||||
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
|
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
|
||||||
import { AddStrategyButton } from './AddStrategyButton/AddStrategyButton';
|
import { AddStrategyButton } from './AddStrategyButton/AddStrategyButton';
|
||||||
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
|
|
||||||
import { StrategySwitch } from './StrategySwitch/StrategySwitch';
|
import { StrategySwitch } from './StrategySwitch/StrategySwitch';
|
||||||
import { StrategyEditButton } from './StrategyEditButton/StrategyEditButton';
|
import { StrategyEditButton } from './StrategyEditButton/StrategyEditButton';
|
||||||
import { StrategyDeleteButton } from './StrategyDeleteButton/StrategyDeleteButton';
|
import { StrategyDeleteButton } from './StrategyDeleteButton/StrategyDeleteButton';
|
||||||
import { Search } from 'component/common/Search/Search';
|
import { Search } from 'component/common/Search/Search';
|
||||||
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
|
||||||
interface IDialogueMetaData {
|
interface IDialogueMetaData {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@ -38,6 +38,11 @@ interface IDialogueMetaData {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
display: 'inline-block',
|
||||||
|
}));
|
||||||
|
|
||||||
export const StrategiesList = () => {
|
export const StrategiesList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [dialogueMetaData, setDialogueMetaData] = useState<IDialogueMetaData>(
|
const [dialogueMetaData, setDialogueMetaData] = useState<IDialogueMetaData>(
|
||||||
@ -191,9 +196,9 @@ export const StrategiesList = () => {
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={!editable}
|
condition={!editable}
|
||||||
show={() => (
|
show={() => (
|
||||||
<StatusBadge severity="success">
|
<StyledBadge color="success">
|
||||||
Predefined
|
Predefined
|
||||||
</StatusBadge>
|
</StyledBadge>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</LinkCell>
|
</LinkCell>
|
||||||
|
@ -10,7 +10,7 @@ exports[`renders an empty list correctly 1`] = `
|
|||||||
className="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 tss-15wj2kz-container mui-177gdp-MuiPaper-root"
|
className="MuiPaper-root MuiPaper-elevation MuiPaper-rounded MuiPaper-elevation1 tss-15wj2kz-container mui-177gdp-MuiPaper-root"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="tss-1ywhhai-headerContainer"
|
className="header tss-1ywhhai-headerContainer"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="tss-1ylehva-headerContainer"
|
className="tss-1ylehva-headerContainer"
|
||||||
@ -109,7 +109,7 @@ exports[`renders an empty list correctly 1`] = `
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="tss-54jt3w-bodyContainer"
|
className="body tss-54jt3w-bodyContainer"
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
className="MuiTable-root tss-rjdss1-table mui-133vm37-MuiTable-root"
|
className="MuiTable-root tss-rjdss1-table mui-133vm37-MuiTable-root"
|
||||||
@ -162,6 +162,7 @@ exports[`renders an empty list correctly 1`] = `
|
|||||||
onMouseOver={[Function]}
|
onMouseOver={[Function]}
|
||||||
onTouchEnd={[Function]}
|
onTouchEnd={[Function]}
|
||||||
onTouchStart={[Function]}
|
onTouchStart={[Function]}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden={true}
|
aria-hidden={true}
|
||||||
|
64
frontend/src/hooks/api/actions/useGroupApi/useGroupApi.ts
Normal file
64
frontend/src/hooks/api/actions/useGroupApi/useGroupApi.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import useAPI from '../useApi/useApi';
|
||||||
|
import { IGroupUserModel } from 'interfaces/group';
|
||||||
|
|
||||||
|
interface ICreateGroupPayload {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
users: IGroupUserModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroupApi = () => {
|
||||||
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
|
propagateErrors: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createGroup = async (payload: ICreateGroupPayload) => {
|
||||||
|
const path = `api/admin/groups`;
|
||||||
|
const req = createRequest(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await makeRequest(req.caller, req.id);
|
||||||
|
return await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGroup = async (
|
||||||
|
groupId: number,
|
||||||
|
payload: ICreateGroupPayload
|
||||||
|
) => {
|
||||||
|
const path = `api/admin/groups/${groupId}`;
|
||||||
|
const req = createRequest(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await makeRequest(req.caller, req.id);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGroup = async (groupId: number) => {
|
||||||
|
const path = `api/admin/groups/${groupId}`;
|
||||||
|
const req = createRequest(path, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await makeRequest(req.caller, req.id);
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
removeGroup,
|
||||||
|
errors,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
};
|
@ -6,6 +6,11 @@ interface ICreatePayload {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IAccessesPayload {
|
||||||
|
users: { id: number }[];
|
||||||
|
groups: { id: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
const useProjectApi = () => {
|
const useProjectApi = () => {
|
||||||
const { makeRequest, createRequest, errors, loading } = useAPI({
|
const { makeRequest, createRequest, errors, loading } = useAPI({
|
||||||
propagateErrors: true,
|
propagateErrors: true,
|
||||||
@ -124,6 +129,26 @@ const useProjectApi = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addAccessToProject = async (
|
||||||
|
projectId: string,
|
||||||
|
roleId: number,
|
||||||
|
accesses: IAccessesPayload
|
||||||
|
) => {
|
||||||
|
const path = `api/admin/projects/${projectId}/role/${roleId}/access`;
|
||||||
|
const req = createRequest(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(accesses),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await makeRequest(req.caller, req.id);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeUserFromRole = async (
|
const removeUserFromRole = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
roleId: number,
|
roleId: number,
|
||||||
@ -141,6 +166,23 @@ const useProjectApi = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeGroupFromRole = async (
|
||||||
|
projectId: string,
|
||||||
|
roleId: number,
|
||||||
|
groupId: number
|
||||||
|
) => {
|
||||||
|
const path = `api/admin/projects/${projectId}/groups/${groupId}/roles/${roleId}`;
|
||||||
|
const req = createRequest(path, { method: 'DELETE' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await makeRequest(req.caller, req.id);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const searchProjectUser = async (query: string): Promise<Response> => {
|
const searchProjectUser = async (query: string): Promise<Response> => {
|
||||||
const path = `api/admin/user-admin/search?q=${query}`;
|
const path = `api/admin/user-admin/search?q=${query}`;
|
||||||
|
|
||||||
@ -166,6 +208,17 @@ const useProjectApi = () => {
|
|||||||
return makeRequest(req.caller, req.id);
|
return makeRequest(req.caller, req.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeGroupRole = (
|
||||||
|
projectId: string,
|
||||||
|
roleId: number,
|
||||||
|
groupId: number
|
||||||
|
) => {
|
||||||
|
const path = `api/admin/projects/${projectId}/groups/${groupId}/roles/${roleId}`;
|
||||||
|
const req = createRequest(path, { method: 'PUT' });
|
||||||
|
|
||||||
|
return makeRequest(req.caller, req.id);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createProject,
|
createProject,
|
||||||
validateId,
|
validateId,
|
||||||
@ -174,8 +227,11 @@ const useProjectApi = () => {
|
|||||||
addEnvironmentToProject,
|
addEnvironmentToProject,
|
||||||
removeEnvironmentFromProject,
|
removeEnvironmentFromProject,
|
||||||
addUserToRole,
|
addUserToRole,
|
||||||
|
addAccessToProject,
|
||||||
removeUserFromRole,
|
removeUserFromRole,
|
||||||
|
removeGroupFromRole,
|
||||||
changeUserRole,
|
changeUserRole,
|
||||||
|
changeGroupRole,
|
||||||
errors,
|
errors,
|
||||||
loading,
|
loading,
|
||||||
searchProjectUser,
|
searchProjectUser,
|
||||||
|
42
frontend/src/hooks/api/getters/useGroup/useGroup.ts
Normal file
42
frontend/src/hooks/api/getters/useGroup/useGroup.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import useSWR from 'swr';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
|
||||||
|
export interface IUseGroupOutput {
|
||||||
|
group?: IGroup;
|
||||||
|
refetchGroup: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapGroupUsers = (users: any[]) =>
|
||||||
|
users.map(user => ({
|
||||||
|
...user.user,
|
||||||
|
joinedAt: new Date(user.joinedAt),
|
||||||
|
role: user.role,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const useGroup = (groupId: number): IUseGroupOutput => {
|
||||||
|
const { data, error, mutate } = useSWR(
|
||||||
|
formatApiPath(`api/admin/groups/${groupId}`),
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
group: data && { ...data, users: mapGroupUsers(data?.users ?? []) },
|
||||||
|
loading: !error && !data,
|
||||||
|
refetchGroup: () => mutate(),
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
[data, error, mutate]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetcher = (path: string) => {
|
||||||
|
return fetch(path)
|
||||||
|
.then(handleErrorResponses('Group'))
|
||||||
|
.then(res => res.json());
|
||||||
|
};
|
40
frontend/src/hooks/api/getters/useGroups/useGroups.ts
Normal file
40
frontend/src/hooks/api/getters/useGroups/useGroups.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import useSWR from 'swr';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
import { mapGroupUsers } from 'hooks/api/getters/useGroup/useGroup';
|
||||||
|
|
||||||
|
export interface IUseGroupsOutput {
|
||||||
|
groups?: IGroup[];
|
||||||
|
refetchGroups: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGroups = (): IUseGroupsOutput => {
|
||||||
|
const { data, error, mutate } = useSWR(
|
||||||
|
formatApiPath(`api/admin/groups`),
|
||||||
|
fetcher
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
groups:
|
||||||
|
data?.groups.map((group: any) => ({
|
||||||
|
...group,
|
||||||
|
users: mapGroupUsers(group.users ?? []),
|
||||||
|
})) ?? [],
|
||||||
|
loading: !error && !data,
|
||||||
|
refetchGroups: () => mutate(),
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
[data, error, mutate]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetcher = (path: string) => {
|
||||||
|
return fetch(path)
|
||||||
|
.then(handleErrorResponses('Groups'))
|
||||||
|
.then(res => res.json());
|
||||||
|
};
|
@ -3,19 +3,30 @@ import { useState, useEffect } from 'react';
|
|||||||
import { formatApiPath } from 'utils/formatPath';
|
import { formatApiPath } from 'utils/formatPath';
|
||||||
import handleErrorResponses from '../httpErrorResponseHandler';
|
import handleErrorResponses from '../httpErrorResponseHandler';
|
||||||
import { IProjectRole } from 'interfaces/role';
|
import { IProjectRole } from 'interfaces/role';
|
||||||
|
import { IGroup } from 'interfaces/group';
|
||||||
|
import { IUser } from 'interfaces/user';
|
||||||
|
|
||||||
export interface IProjectAccessUser {
|
export enum ENTITY_TYPE {
|
||||||
id: number;
|
USER = 'USERS',
|
||||||
imageUrl: string;
|
GROUP = 'GROUPS',
|
||||||
isAPI: boolean;
|
}
|
||||||
|
|
||||||
|
export interface IProjectAccess {
|
||||||
|
entity: IProjectAccessUser | IProjectAccessGroup;
|
||||||
|
type: ENTITY_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectAccessUser extends IUser {
|
||||||
|
roleId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectAccessGroup extends IGroup {
|
||||||
roleId: number;
|
roleId: number;
|
||||||
username?: string;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProjectAccessOutput {
|
export interface IProjectAccessOutput {
|
||||||
users: IProjectAccessUser[];
|
users: IProjectAccessUser[];
|
||||||
|
groups: IProjectAccessGroup[];
|
||||||
roles: IProjectRole[];
|
roles: IProjectRole[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +34,7 @@ const useProjectAccess = (
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
options: SWRConfiguration = {}
|
options: SWRConfiguration = {}
|
||||||
) => {
|
) => {
|
||||||
const path = formatApiPath(`api/admin/projects/${projectId}/users`);
|
const path = formatApiPath(`api/admin/projects/${projectId}/access`);
|
||||||
const fetcher = () => {
|
const fetcher = () => {
|
||||||
return fetch(path, {
|
return fetch(path, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@ -50,8 +61,21 @@ const useProjectAccess = (
|
|||||||
setLoading(!error && !data);
|
setLoading(!error && !data);
|
||||||
}, [data, error]);
|
}, [data, error]);
|
||||||
|
|
||||||
|
// TODO: Remove this and replace `mockData` back for `data` @79. This mocks what a group looks like when returned along with the access.
|
||||||
|
// const { groups } = useGroups();
|
||||||
|
// const mockData = useMemo(
|
||||||
|
// () => ({
|
||||||
|
// ...data,
|
||||||
|
// groups: groups?.map(group => ({
|
||||||
|
// ...group,
|
||||||
|
// roleId: 4,
|
||||||
|
// })) as IProjectAccessGroup[],
|
||||||
|
// }),
|
||||||
|
// [data, groups]
|
||||||
|
// );
|
||||||
|
|
||||||
return {
|
return {
|
||||||
access: data ? data : { roles: [], users: [] },
|
access: data ? data : { roles: [], users: [], groups: [] },
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
refetchProjectAccess,
|
refetchProjectAccess,
|
||||||
|
@ -15,6 +15,7 @@ export const defaultValue: IUiConfig = {
|
|||||||
SE: false,
|
SE: false,
|
||||||
T: false,
|
T: false,
|
||||||
UNLEASH_CLOUD: false,
|
UNLEASH_CLOUD: false,
|
||||||
|
UG: false,
|
||||||
},
|
},
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
|
@ -22,7 +22,11 @@ const useUiConfig = (): IUseUIConfigOutput => {
|
|||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const uiConfig: IUiConfig = useMemo(() => {
|
const uiConfig: IUiConfig = useMemo(() => {
|
||||||
return { ...defaultValue, ...data };
|
return {
|
||||||
|
...defaultValue,
|
||||||
|
...data,
|
||||||
|
flags: { ...defaultValue.flags, ...data?.flags },
|
||||||
|
};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
28
frontend/src/interfaces/group.ts
Normal file
28
frontend/src/interfaces/group.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { IUser } from './user';
|
||||||
|
|
||||||
|
export enum Role {
|
||||||
|
Owner = 'Owner',
|
||||||
|
Member = 'Member',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: Date;
|
||||||
|
users: IGroupUser[];
|
||||||
|
projects: string[];
|
||||||
|
addedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGroupUser extends IUser {
|
||||||
|
role: Role;
|
||||||
|
joinedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGroupUserModel {
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
role: Role;
|
||||||
|
}
|
@ -35,6 +35,7 @@ export interface IFlags {
|
|||||||
SE?: boolean;
|
SE?: boolean;
|
||||||
T?: boolean;
|
T?: boolean;
|
||||||
UNLEASH_CLOUD?: boolean;
|
UNLEASH_CLOUD?: boolean;
|
||||||
|
UG?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -12,6 +12,7 @@ export interface IUser {
|
|||||||
username?: string;
|
username?: string;
|
||||||
isAPI: boolean;
|
isAPI: boolean;
|
||||||
paid?: boolean;
|
paid?: boolean;
|
||||||
|
addedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPermission {
|
export interface IPermission {
|
||||||
|
@ -13,6 +13,7 @@ export default createTheme({
|
|||||||
},
|
},
|
||||||
boxShadows: {
|
boxShadows: {
|
||||||
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
||||||
|
card: '0px 2px 10px rgba(28, 25, 78, 0.12)',
|
||||||
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
|
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
@ -55,9 +56,10 @@ export default createTheme({
|
|||||||
dark: colors.purple[900],
|
dark: colors.purple[900],
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
|
light: colors.purple[50],
|
||||||
main: colors.purple[800],
|
main: colors.purple[800],
|
||||||
light: colors.purple[700],
|
|
||||||
dark: colors.purple[900],
|
dark: colors.purple[900],
|
||||||
|
border: colors.purple[300],
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
light: colors.blue[50],
|
light: colors.blue[50],
|
||||||
@ -83,6 +85,12 @@ export default createTheme({
|
|||||||
dark: colors.red[800],
|
dark: colors.red[800],
|
||||||
border: colors.red[300],
|
border: colors.red[300],
|
||||||
},
|
},
|
||||||
|
neutral: {
|
||||||
|
light: colors.grey[100],
|
||||||
|
main: colors.grey[700],
|
||||||
|
dark: colors.grey[800],
|
||||||
|
border: colors.grey[500],
|
||||||
|
},
|
||||||
divider: colors.grey[300],
|
divider: colors.grey[300],
|
||||||
dividerAlternative: colors.grey[400],
|
dividerAlternative: colors.grey[400],
|
||||||
tableHeaderHover: colors.grey[400],
|
tableHeaderHover: colors.grey[400],
|
||||||
@ -109,10 +117,6 @@ export default createTheme({
|
|||||||
inactive: colors.orange[200],
|
inactive: colors.orange[200],
|
||||||
abandoned: colors.red[200],
|
abandoned: colors.red[200],
|
||||||
},
|
},
|
||||||
statusBadge: {
|
|
||||||
success: colors.green[100],
|
|
||||||
warning: colors.orange[200],
|
|
||||||
},
|
|
||||||
inactiveIcon: colors.grey[600],
|
inactiveIcon: colors.grey[600],
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
|
@ -24,11 +24,16 @@ declare module '@mui/material/styles' {
|
|||||||
*/
|
*/
|
||||||
boxShadows: {
|
boxShadows: {
|
||||||
main: string;
|
main: string;
|
||||||
|
card: string;
|
||||||
elevated: string;
|
elevated: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CustomPalette {
|
interface CustomPalette {
|
||||||
|
/**
|
||||||
|
* Generic neutral palette color.
|
||||||
|
*/
|
||||||
|
neutral: PaletteColorOptions;
|
||||||
/**
|
/**
|
||||||
* Colors for event log output.
|
* Colors for event log output.
|
||||||
*/
|
*/
|
||||||
@ -50,13 +55,6 @@ declare module '@mui/material/styles' {
|
|||||||
abandoned: string;
|
abandoned: string;
|
||||||
};
|
};
|
||||||
dividerAlternative: string;
|
dividerAlternative: string;
|
||||||
/**
|
|
||||||
* Background colors for status badges.
|
|
||||||
*/
|
|
||||||
statusBadge: {
|
|
||||||
success: string;
|
|
||||||
warning: string;
|
|
||||||
};
|
|
||||||
/**
|
/**
|
||||||
* For table header hover effect.
|
* For table header hover effect.
|
||||||
*/
|
*/
|
||||||
|
@ -9,6 +9,11 @@ export const CF_TYPE_ID = 'CF_TYPE_ID';
|
|||||||
export const CF_DESC_ID = 'CF_DESC_ID';
|
export const CF_DESC_ID = 'CF_DESC_ID';
|
||||||
export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
|
export const CF_CREATE_BTN_ID = 'CF_CREATE_BTN_ID';
|
||||||
|
|
||||||
|
/* CREATE GROUP */
|
||||||
|
export const UG_NAME_ID = 'UG_NAME_ID';
|
||||||
|
export const UG_DESC_ID = 'UG_DESC_ID';
|
||||||
|
export const UG_CREATE_BTN_ID = 'UG_CREATE_BTN_ID';
|
||||||
|
|
||||||
/* SEGMENT */
|
/* SEGMENT */
|
||||||
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';
|
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';
|
||||||
export const SEGMENT_DESC_ID = 'SEGMENT_DESC_ID';
|
export const SEGMENT_DESC_ID = 'SEGMENT_DESC_ID';
|
||||||
|
Loading…
Reference in New Issue
Block a user