1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

feat: add user groups (#1130)

* feat: add user groups table

* add groups and group view

* fix top level await on mock data

* add UG flag

* create group files, refactor group cards

* add generic badge component

* adapt hooks to use endpoints

* implement basic create group

* fix: update snap

* fix: type id as string for now

* implement create group, use api, refactoring

* add stars to group owners

* refactor GroupForm.tsx to use styled components

* feat: remove group

* add edit group

* add group card actions

* feat: edit and remove group users

* add users to groups

* Initial commit

* refine project access table

* add project access group view

* Take users and groups from backend

* Add onsubmit

* new project access, assign and edit

* fix EditGroup, Group

* Finish assigning roles in project

* List assigned projects in group card

* Run prettier

* Add added column to project access table

Co-authored-by: Jaanus Sellin <jaanus@getunleash.ai>
Co-authored-by: sighphyre <liquidwicked64@gmail.com>
This commit is contained in:
Nuno Góis 2022-07-22 08:31:08 +01:00 committed by GitHub
parent e5b2f907e4
commit df6208e309
56 changed files with 3789 additions and 645 deletions

View File

@ -14,6 +14,7 @@ import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol';
import { GridColLink } from './GridColLink/GridColLink';
import { STRIPE } from 'component/admin/billing/flags';
import { Badge } from 'component/common/Badge/Badge';
const StyledPlanBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(2.5),
@ -30,15 +31,6 @@ const StyledInfoLabel = styled(Typography)(({ theme }) => ({
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 }) => ({
fontSize: '3.25rem',
lineHeight: 1,
@ -116,7 +108,7 @@ export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
</StyledAlert>
}
/>
<StyledPlanBadge>Current plan</StyledPlanBadge>
<Badge color="success">Current plan</Badge>
<Grid container>
<GridRow sx={theme => ({ marginBottom: theme.spacing(3) })}>
<GridCol>

View File

@ -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>
);
};

View 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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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 &ldquo;
{searchValue}
&rdquo; 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>
</>
}
/>
);
};

View File

@ -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>
);
};

View 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>
);

View File

@ -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>
);
};

View File

@ -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}
/>
}
/>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,11 @@
import { GroupsList } from './GroupsList/GroupsList';
import AdminMenu from '../menu/AdminMenu';
export const GroupsAdmin = () => {
return (
<div>
<AdminMenu />
<GroupsList />
</div>
);
};

View File

@ -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!}
/>
</>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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 &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No groups available. Get started by adding a new
group.
</TablePlaceholder>
}
/>
}
/>
</PageContent>
);
};

View File

@ -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>
);
};

View 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,
};
};

View File

@ -51,6 +51,19 @@ function AdminMenu() {
</NavLink>
}
/>
{flags.UG && (
<Tab
value="/admin/groups"
label={
<NavLink
to="/admin/groups"
style={createNavLinkStyle}
>
<span>Groups</span>
</NavLink>
}
/>
)}
{flags.RE && (
<Tab
value="/admin/roles"

View 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>
)
);

View 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>
);
};

View File

@ -47,12 +47,13 @@ export const PageContent: FC<IPageContentProps> = ({
}) => {
const { classes: styles } = useStyles();
const headerClasses = classnames(styles.headerContainer, {
const headerClasses = classnames('header', styles.headerContainer, {
[styles.paddingDisabled]: disablePadding,
[styles.borderDisabled]: disableBorder,
});
const bodyClasses = classnames(
'body',
bodyClass ? bodyClass : styles.bodyContainer,
{
[styles.paddingDisabled]: disablePadding,

View File

@ -33,6 +33,7 @@ interface IPageHeaderProps {
loading?: boolean;
actions?: ReactNode;
className?: string;
secondary?: boolean;
}
const PageHeaderComponent: FC<IPageHeaderProps> & {
@ -45,12 +46,13 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
variant,
loading,
className = '',
secondary,
children,
}) => {
const { classes: styles } = useStyles();
const headerClasses = classnames({ skeleton: loading });
usePageTitle(title);
usePageTitle(secondary ? '' : title);
return (
<div className={styles.headerContainer}>
@ -60,7 +62,7 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
data-loading
>
<Typography
variant={variant || 'h1'}
variant={variant || secondary ? 'h2' : 'h1'}
className={classnames(styles.headerTitle, className)}
>
{titleElement || title}

View File

@ -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>
);
};

View File

@ -109,6 +109,7 @@ export const CellSortable: FC<ICellSortableProps> = ({
show={
<Tooltip title={title} arrow>
<button
type="button"
className={classnames(
isSorted && styles.sortedButton,
styles.sortButton,

View File

@ -10,12 +10,14 @@ import classnames from 'classnames';
interface ILinkCellProps {
title?: string;
to?: string;
onClick?: () => void;
subtitle?: string;
}
export const LinkCell: FC<ILinkCellProps> = ({
title,
to,
onClick,
subtitle,
children,
}) => {
@ -63,6 +65,14 @@ export const LinkCell: FC<ILinkCellProps> = ({
>
{content}
</Link>
) : onClick ? (
<Link
onClick={onClick}
underline="hover"
className={classnames(styles.wrapper, styles.link)}
>
{content}
</Link>
) : (
<span className={styles.wrapper}>{content}</span>
);

View File

@ -4,3 +4,4 @@ export const E = 'E';
export const EEA = 'EEA';
export const RE = 'RE';
export const SE = 'SE';
export const UG = 'UG';

View File

@ -1,9 +1,14 @@
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IEnvironment } from 'interfaces/environments';
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 { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { styled } from '@mui/material';
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
interface IEnvironmentNameCellProps {
environment: IEnvironment;
@ -19,11 +24,11 @@ export const EnvironmentNameCell = ({
<Highlighter search={searchQuery}>{environment.name}</Highlighter>
<ConditionallyRender
condition={!environment.enabled}
show={<StatusBadge severity="warning">Disabled</StatusBadge>}
show={<StyledBadge color="warning">Disabled</StyledBadge>}
/>
<ConditionallyRender
condition={environment.protected}
show={<StatusBadge severity="success">Predefined</StatusBadge>}
show={<StyledBadge color="success">Predefined</StyledBadge>}
/>
</TextCell>
);

View File

@ -1,6 +1,5 @@
import { Accordion, AccordionDetails, AccordionSummary } from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import React from 'react';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import useFeatureMetrics from 'hooks/api/getters/useFeatureMetrics/useFeatureMetrics';
import { IFeatureEnvironment } from 'interfaces/featureToggle';
@ -15,8 +14,8 @@ import FeatureOverviewEnvironmentMetrics from './FeatureOverviewEnvironmentMetri
import { FeatureStrategyMenu } from 'component/feature/FeatureStrategy/FeatureStrategyMenu/FeatureStrategyMenu';
import { FEATURE_ENVIRONMENT_ACCORDION } from 'utils/testIds';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
import { FeatureStrategyIcons } from 'component/feature/FeatureStrategy/FeatureStrategyIcons/FeatureStrategyIcons';
import { Badge } from 'component/common/Badge/Badge';
interface IFeatureOverviewEnvironmentProps {
env: IFeatureEnvironment;
@ -87,12 +86,12 @@ const FeatureOverviewEnvironment = ({
<ConditionallyRender
condition={!env.enabled}
show={
<StatusBadge
severity="warning"
<Badge
color="warning"
className={styles.disabledIndicatorPos}
>
Disabled
</StatusBadge>
</Badge>
}
/>
</div>

View File

@ -398,6 +398,44 @@ exports[`returns all baseRoutes 1`] = `
"title": "Users",
"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],
"flag": "RE",

View File

@ -6,9 +6,10 @@ import { AddonList } from 'component/addons/AddonList/AddonList';
import Admin from 'component/admin';
import AdminApi from 'component/admin/api';
import AdminUsers from 'component/admin/users/UsersAdmin';
import { GroupsAdmin } from 'component/admin/groups/GroupsAdmin';
import { AuthSettings } from 'component/admin/auth/AuthSettings';
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 ResetPassword from 'component/user/ResetPassword/ResetPassword';
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
@ -53,6 +54,9 @@ import FlaggedBillingRedirect from 'component/admin/billing/FlaggedBillingRedire
import { FeaturesArchiveTable } from '../archive/FeaturesArchiveTable';
import { Billing } from 'component/admin/billing/Billing';
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[] = [
// Splash
@ -450,6 +454,42 @@ export const routes: IRoute[] = [
type: 'protected',
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',
parent: '/admin',

View File

@ -1,5 +1,4 @@
import React, { useContext, VFC } from 'react';
import { ProjectAccessPage } from 'component/project/ProjectAccess/ProjectAccessPage';
import { useContext, VFC } from 'react';
import { PageContent } from 'component/common/PageContent/PageContent';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { Alert } from '@mui/material';
@ -8,6 +7,7 @@ import AccessContext from 'contexts/AccessContext';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { usePageTitle } from 'hooks/usePageTitle';
import { ProjectAccessTable } from 'component/project/ProjectAccess/ProjectAccessTable/ProjectAccessTable';
interface IProjectAccess {
projectName: string;
@ -15,6 +15,7 @@ interface IProjectAccess {
export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
const projectId = useRequiredPathParam('projectId');
const { hasAccess } = useContext(AccessContext);
const { isOss } = useUiConfig();
usePageTitle(`Project access ${projectName}`);
@ -48,5 +49,5 @@ export const ProjectAccess: VFC<IProjectAccess> = ({ projectName }) => {
);
}
return <ProjectAccessPage />;
return <ProjectAccessTable />;
};

View File

@ -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>
</>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -1,109 +1,240 @@
import { useMemo, VFC } from 'react';
import { useSortBy, useTable } from 'react-table';
import {
Table,
TableBody,
TableRow,
TableCell,
SortableTableHeader,
} from 'component/common/Table';
import { Avatar, SelectChangeEvent } from '@mui/material';
import { Delete } from '@mui/icons-material';
import { useEffect, useMemo, useState, VFC } from 'react';
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
import { VirtualizedTable, TablePlaceholder } from 'component/common/Table';
import { Avatar, Button, styled, useMediaQuery, useTheme } from '@mui/material';
import { Delete, Edit } from '@mui/icons-material';
import { sortTypes } from 'utils/sortTypes';
import {
IProjectAccessOutput,
IProjectAccessUser,
import useProjectAccess, {
ENTITY_TYPE,
IProjectAccess,
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import { ProjectRoleCell } from './ProjectRoleCell/ProjectRoleCell';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
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 = {
sortBy: [{ id: 'name' }],
};
const StyledAvatar = styled(Avatar)(({ theme }) => ({
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 {
access: IProjectAccessOutput;
projectId: string;
handleRoleChange: (
userId: number
) => (event: SelectChangeEvent) => Promise<void>;
handleRemoveAccess: (user: IProjectAccessUser) => void;
}
export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string>
>;
export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
access,
projectId,
handleRoleChange,
handleRemoveAccess,
}) => {
const data = access.users;
const defaultSort: SortingRule<string> = { id: 'added' };
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
'ProjectAccess:v1',
defaultSort
);
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(
() => [
{
Header: 'Avatar',
accessor: 'imageUrl',
disableSortBy: true,
width: 80,
Cell: ({ value }: { value: string }) => (
<Avatar
alt="Gravatar"
src={value}
sx={{ width: 32, height: 32, mx: 'auto' }}
/>
Cell: ({ row: { original: row } }: any) => (
<TextCell>
<StyledAvatar
data-loading
alt="Gravatar"
src={row.entity.imageUrl}
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',
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',
Header: 'Username',
accessor: 'email',
Cell: ({ row: { original: user } }: any) => (
<TextCell>{user.email || user.username}</TextCell>
),
accessor: (row: IProjectAccess) => {
if (row.type === ENTITY_TYPE.USER) {
const userRow = row.entity as IUser;
return userRow.username || userRow.email;
}
return '';
},
Cell: HighlightCell,
minWidth: 100,
searchable: true,
},
{
Header: 'Role',
accessor: 'roleId',
Cell: ({
value,
row: { original: user },
}: {
value: number;
row: { original: IProjectAccessUser };
}) => (
<ProjectRoleCell
value={value}
user={user}
roles={access.roles}
onChange={handleRoleChange(user.id)}
/>
accessor: (row: IProjectAccess) =>
roles.find(({ id }) => id === row.entity.roleId)?.name,
minWidth: 120,
filterName: 'role',
},
{
id: 'added',
Header: 'Added',
accessor: (row: IProjectAccess) => {
const userRow = row.entity as IUser | IGroup;
return userRow.addedAt || '';
},
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',
Header: 'Actions',
disableSortBy: true,
align: 'center',
width: 80,
Cell: ({ row: { original: user } }: any) => (
maxWidth: 200,
Cell: ({ row: { original: row } }: any) => (
<ActionCell>
<PermissionIconButton
permission={UPDATE_PROJECT}
projectId={projectId}
onClick={() => handleRemoveAccess(user)}
disabled={access.users.length === 1}
onClick={() => {
setSelectedRow(row);
setAssignOpen(true);
}}
disabled={mappedData.length === 1}
tooltipProps={{
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'
: 'Remove access',
}}
@ -114,50 +245,214 @@ export const ProjectAccessTable: VFC<IProjectAccessTableProps> = ({
),
},
],
[
access.roles,
access.users.length,
handleRemoveAccess,
handleRoleChange,
projectId,
]
[roles, mappedData.length, projectId]
);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
useTable(
const [searchParams, setSearchParams] = useSearchParams();
const [initialState] = useState(() => ({
sortBy: [
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
defaultColumn: {
Cell: TextCell,
},
id: searchParams.get('sort') || storedParams.id,
desc: searchParams.has('order')
? searchParams.get('order') === 'desc'
: storedParams.desc,
},
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 (
<Table {...getTableProps()} rowHeight="compact">
{/* @ts-expect-error -- react-table */}
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<PageContent
header={
<PageHeader
secondary
title={`Access (${
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={() => 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 &ldquo;
{searchValue}
&rdquo;
</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>
);
};

View File

@ -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',
},
}));

View File

@ -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>
);
};

View File

@ -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 &ldquo;
{searchValue}
&rdquo; in this group.
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
This group is empty. Get started by adding a
user to the group.
</TablePlaceholder>
}
/>
}
/>
</StyledPageContent>
</SidebarModal>
);
};

View File

@ -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>
);
};

View File

@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box } from '@mui/material';
import { Box, styled } from '@mui/material';
import { Extension } from '@mui/icons-material';
import {
Table,
@ -26,11 +26,11 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC
import { sortTypes } from 'utils/sortTypes';
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
import { AddStrategyButton } from './AddStrategyButton/AddStrategyButton';
import { StatusBadge } from 'component/common/StatusBadge/StatusBadge';
import { StrategySwitch } from './StrategySwitch/StrategySwitch';
import { StrategyEditButton } from './StrategyEditButton/StrategyEditButton';
import { StrategyDeleteButton } from './StrategyDeleteButton/StrategyDeleteButton';
import { Search } from 'component/common/Search/Search';
import { Badge } from 'component/common/Badge/Badge';
interface IDialogueMetaData {
show: boolean;
@ -38,6 +38,11 @@ interface IDialogueMetaData {
onConfirm: () => void;
}
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
display: 'inline-block',
}));
export const StrategiesList = () => {
const navigate = useNavigate();
const [dialogueMetaData, setDialogueMetaData] = useState<IDialogueMetaData>(
@ -191,9 +196,9 @@ export const StrategiesList = () => {
<ConditionallyRender
condition={!editable}
show={() => (
<StatusBadge severity="success">
<StyledBadge color="success">
Predefined
</StatusBadge>
</StyledBadge>
)}
/>
</LinkCell>

View File

@ -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"
>
<div
className="tss-1ywhhai-headerContainer"
className="header tss-1ywhhai-headerContainer"
>
<div
className="tss-1ylehva-headerContainer"
@ -109,7 +109,7 @@ exports[`renders an empty list correctly 1`] = `
</div>
</div>
<div
className="tss-54jt3w-bodyContainer"
className="body tss-54jt3w-bodyContainer"
>
<table
className="MuiTable-root tss-rjdss1-table mui-133vm37-MuiTable-root"
@ -162,6 +162,7 @@ exports[`renders an empty list correctly 1`] = `
onMouseOver={[Function]}
onTouchEnd={[Function]}
onTouchStart={[Function]}
type="button"
>
<span
aria-hidden={true}

View 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,
};
};

View File

@ -6,6 +6,11 @@ interface ICreatePayload {
description: string;
}
interface IAccessesPayload {
users: { id: number }[];
groups: { id: number }[];
}
const useProjectApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
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 (
projectId: string,
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 path = `api/admin/user-admin/search?q=${query}`;
@ -166,6 +208,17 @@ const useProjectApi = () => {
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 {
createProject,
validateId,
@ -174,8 +227,11 @@ const useProjectApi = () => {
addEnvironmentToProject,
removeEnvironmentFromProject,
addUserToRole,
addAccessToProject,
removeUserFromRole,
removeGroupFromRole,
changeUserRole,
changeGroupRole,
errors,
loading,
searchProjectUser,

View 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());
};

View 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());
};

View File

@ -3,19 +3,30 @@ import { useState, useEffect } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IProjectRole } from 'interfaces/role';
import { IGroup } from 'interfaces/group';
import { IUser } from 'interfaces/user';
export interface IProjectAccessUser {
id: number;
imageUrl: string;
isAPI: boolean;
export enum ENTITY_TYPE {
USER = 'USERS',
GROUP = 'GROUPS',
}
export interface IProjectAccess {
entity: IProjectAccessUser | IProjectAccessGroup;
type: ENTITY_TYPE;
}
export interface IProjectAccessUser extends IUser {
roleId: number;
}
export interface IProjectAccessGroup extends IGroup {
roleId: number;
username?: string;
name?: string;
email?: string;
}
export interface IProjectAccessOutput {
users: IProjectAccessUser[];
groups: IProjectAccessGroup[];
roles: IProjectRole[];
}
@ -23,7 +34,7 @@ const useProjectAccess = (
projectId: string,
options: SWRConfiguration = {}
) => {
const path = formatApiPath(`api/admin/projects/${projectId}/users`);
const path = formatApiPath(`api/admin/projects/${projectId}/access`);
const fetcher = () => {
return fetch(path, {
method: 'GET',
@ -50,8 +61,21 @@ const useProjectAccess = (
setLoading(!error && !data);
}, [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 {
access: data ? data : { roles: [], users: [] },
access: data ? data : { roles: [], users: [], groups: [] },
error,
loading,
refetchProjectAccess,

View File

@ -15,6 +15,7 @@ export const defaultValue: IUiConfig = {
SE: false,
T: false,
UNLEASH_CLOUD: false,
UG: false,
},
links: [
{

View File

@ -22,7 +22,11 @@ const useUiConfig = (): IUseUIConfigOutput => {
}, [data]);
const uiConfig: IUiConfig = useMemo(() => {
return { ...defaultValue, ...data };
return {
...defaultValue,
...data,
flags: { ...defaultValue.flags, ...data?.flags },
};
}, [data]);
return {

View 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;
}

View File

@ -35,6 +35,7 @@ export interface IFlags {
SE?: boolean;
T?: boolean;
UNLEASH_CLOUD?: boolean;
UG?: boolean;
}
export interface IVersionInfo {

View File

@ -12,6 +12,7 @@ export interface IUser {
username?: string;
isAPI: boolean;
paid?: boolean;
addedAt?: string;
}
export interface IPermission {

View File

@ -13,6 +13,7 @@ export default createTheme({
},
boxShadows: {
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)',
},
typography: {
@ -55,9 +56,10 @@ export default createTheme({
dark: colors.purple[900],
},
secondary: {
light: colors.purple[50],
main: colors.purple[800],
light: colors.purple[700],
dark: colors.purple[900],
border: colors.purple[300],
},
info: {
light: colors.blue[50],
@ -83,6 +85,12 @@ export default createTheme({
dark: colors.red[800],
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],
dividerAlternative: colors.grey[400],
tableHeaderHover: colors.grey[400],
@ -109,10 +117,6 @@ export default createTheme({
inactive: colors.orange[200],
abandoned: colors.red[200],
},
statusBadge: {
success: colors.green[100],
warning: colors.orange[200],
},
inactiveIcon: colors.grey[600],
},
components: {

View File

@ -24,11 +24,16 @@ declare module '@mui/material/styles' {
*/
boxShadows: {
main: string;
card: string;
elevated: string;
};
}
interface CustomPalette {
/**
* Generic neutral palette color.
*/
neutral: PaletteColorOptions;
/**
* Colors for event log output.
*/
@ -50,13 +55,6 @@ declare module '@mui/material/styles' {
abandoned: string;
};
dividerAlternative: string;
/**
* Background colors for status badges.
*/
statusBadge: {
success: string;
warning: string;
};
/**
* For table header hover effect.
*/

View File

@ -9,6 +9,11 @@ export const CF_TYPE_ID = 'CF_TYPE_ID';
export const CF_DESC_ID = 'CF_DESC_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 */
export const SEGMENT_NAME_ID = 'SEGMENT_NAME_ID';
export const SEGMENT_DESC_ID = 'SEGMENT_DESC_ID';