mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-28 17:55:15 +02:00
https://linear.app/unleash/issue/2-579/improve-user-like-behaviour-for-service-accounts-accounts-concept <img width="803" alt="image" src="https://user-images.githubusercontent.com/14320932/213011584-75870595-988d-49bc-a7bf-cd1ffd146bca.png"> Makes SAs behave more like users. Even though they share the same `users` database table, the `is_service` column distinguishes them. This PR makes the distinction a bit less obvious by not filtering out SAs for some methods in the user store, returning both account types and their respective account type information so we can handle them properly on the UI. We felt like this was a good enough approach for now, and a decent compromise to move SAs forward. In the future, we may want to make a full refactor with the `accounts` concept in mind, which we've experimented with in the [accounts-refactoring](https://github.com/Unleash/unleash/tree/accounts-refactoring) branches (both OSS and Enterprise). https://github.com/Unleash/unleash/pull/2918 - Moves this a bit further, by introducing the account service and store.
499 lines
19 KiB
TypeScript
499 lines
19 KiB
TypeScript
import { useEffect, useMemo, useState, VFC } from 'react';
|
|
import { SortingRule, useFlexLayout, useSortBy, useTable } from 'react-table';
|
|
import { VirtualizedTable, TablePlaceholder } from 'component/common/Table';
|
|
import { styled, useMediaQuery, useTheme } from '@mui/material';
|
|
import { Add, Delete, Edit } from '@mui/icons-material';
|
|
import { sortTypes } from 'utils/sortTypes';
|
|
import useProjectAccess, {
|
|
ENTITY_TYPE,
|
|
IProjectAccess,
|
|
} from 'hooks/api/getters/useProjectAccess/useProjectAccess';
|
|
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 { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
|
import {
|
|
Link,
|
|
Route,
|
|
Routes,
|
|
useNavigate,
|
|
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 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';
|
|
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
|
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
|
|
import { ProjectAccessCreate } from 'component/project/ProjectAccess/ProjectAccessCreate/ProjectAccessCreate';
|
|
import { ProjectAccessEditUser } from 'component/project/ProjectAccess/ProjectAccessEditUser/ProjectAccessEditUser';
|
|
import { ProjectAccessEditGroup } from 'component/project/ProjectAccess/ProjectAccessEditGroup/ProjectAccessEditGroup';
|
|
import { ProjectAccessRoleCell } from './ProjectAccessRoleCell/ProjectAccessRoleCell';
|
|
import {
|
|
PA_ASSIGN_BUTTON_ID,
|
|
PA_EDIT_BUTTON_ID,
|
|
PA_REMOVE_BUTTON_ID,
|
|
} from 'utils/testIds';
|
|
|
|
export type PageQueryType = Partial<
|
|
Record<'sort' | 'order' | 'search', string>
|
|
>;
|
|
|
|
const defaultSort: SortingRule<string> = { id: 'added' };
|
|
|
|
const { value: storedParams, setValue: setStoredParams } = createLocalStorage(
|
|
'ProjectAccess:v1',
|
|
defaultSort
|
|
);
|
|
|
|
const StyledUserAvatars = styled('div')(({ theme }) => ({
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
flexWrap: 'wrap',
|
|
marginLeft: theme.spacing(1),
|
|
}));
|
|
|
|
const StyledEmptyAvatar = styled(UserAvatar)(({ theme }) => ({
|
|
marginRight: theme.spacing(-3.5),
|
|
}));
|
|
|
|
const StyledGroupAvatar = styled(UserAvatar)(({ theme }) => ({
|
|
outline: `${theme.spacing(0.25)} solid ${theme.palette.background.paper}`,
|
|
}));
|
|
|
|
const hiddenColumnsSmall = ['imageUrl', 'role', 'added', 'lastLogin'];
|
|
const hiddenColumnsMedium = ['lastLogin', 'added'];
|
|
|
|
export const ProjectAccessTable: VFC = () => {
|
|
const projectId = useRequiredPathParam('projectId');
|
|
|
|
const { uiConfig } = useUiConfig();
|
|
const { flags } = uiConfig;
|
|
const entityType = flags.UG ? 'user / group' : 'user';
|
|
|
|
const navigate = useNavigate();
|
|
const theme = useTheme();
|
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
|
const isMediumScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
|
const { setToastData } = useToast();
|
|
|
|
const { access, refetchProjectAccess } = useProjectAccess(projectId);
|
|
const { removeUserFromRole, removeGroupFromRole } = useProjectApi();
|
|
const [removeOpen, setRemoveOpen] = useState(false);
|
|
const [groupOpen, setGroupOpen] = useState(false);
|
|
const [selectedRow, setSelectedRow] = useState<IProjectAccess>();
|
|
|
|
const columns = useMemo(
|
|
() => [
|
|
{
|
|
Header: 'Avatar',
|
|
accessor: 'imageUrl',
|
|
Cell: ({ row: { original: row } }: any) => (
|
|
<StyledUserAvatars>
|
|
<ConditionallyRender
|
|
condition={row.type === ENTITY_TYPE.GROUP}
|
|
show={<StyledEmptyAvatar />}
|
|
/>
|
|
<StyledGroupAvatar user={row.entity}>
|
|
{row.entity.users?.length}
|
|
</StyledGroupAvatar>
|
|
</StyledUserAvatars>
|
|
),
|
|
maxWidth: 85,
|
|
disableSortBy: true,
|
|
},
|
|
{
|
|
id: 'name',
|
|
Header: '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}
|
|
subtitle={
|
|
row.entity?.username || row.entity?.email
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
),
|
|
minWidth: 100,
|
|
searchable: true,
|
|
},
|
|
{
|
|
id: 'username',
|
|
Header: 'Username',
|
|
accessor: (row: IProjectAccess) => {
|
|
if (row.type !== ENTITY_TYPE.GROUP) {
|
|
const userRow = row.entity as IUser;
|
|
return userRow.username || userRow.email;
|
|
}
|
|
return '';
|
|
},
|
|
Cell: HighlightCell,
|
|
minWidth: 100,
|
|
searchable: true,
|
|
},
|
|
{
|
|
id: 'role',
|
|
Header: 'Role',
|
|
accessor: (row: IProjectAccess) =>
|
|
access?.roles.find(({ id }) => id === row.entity.roleId)
|
|
?.name,
|
|
Cell: ({ value, row: { original: row } }: any) => (
|
|
<ProjectAccessRoleCell
|
|
roleId={row.entity.roleId}
|
|
projectId={projectId}
|
|
value={value}
|
|
/>
|
|
),
|
|
maxWidth: 125,
|
|
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" />
|
|
),
|
|
sortType: 'date',
|
|
maxWidth: 130,
|
|
},
|
|
{
|
|
id: 'lastLogin',
|
|
Header: 'Last login',
|
|
accessor: (row: IProjectAccess) => {
|
|
if (row.type !== ENTITY_TYPE.GROUP) {
|
|
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" />
|
|
),
|
|
sortType: 'date',
|
|
maxWidth: 130,
|
|
},
|
|
{
|
|
id: 'actions',
|
|
Header: 'Actions',
|
|
disableSortBy: true,
|
|
align: 'center',
|
|
maxWidth: 150,
|
|
Cell: ({
|
|
row: { original: row },
|
|
}: {
|
|
row: { original: IProjectAccess };
|
|
}) => (
|
|
<ActionCell>
|
|
<PermissionIconButton
|
|
data-testid={PA_EDIT_BUTTON_ID}
|
|
component={Link}
|
|
permission={UPDATE_PROJECT}
|
|
projectId={projectId}
|
|
to={`edit/${
|
|
row.type === ENTITY_TYPE.GROUP
|
|
? 'group'
|
|
: 'user'
|
|
}/${row.entity.id}`}
|
|
disabled={access?.rows.length === 1}
|
|
tooltipProps={{
|
|
title:
|
|
access?.rows.length === 1
|
|
? 'Cannot edit access. A project must have at least one owner'
|
|
: 'Edit access',
|
|
}}
|
|
>
|
|
<Edit />
|
|
</PermissionIconButton>
|
|
<PermissionIconButton
|
|
data-testid={PA_REMOVE_BUTTON_ID}
|
|
permission={UPDATE_PROJECT}
|
|
projectId={projectId}
|
|
onClick={() => {
|
|
setSelectedRow(row);
|
|
setRemoveOpen(true);
|
|
}}
|
|
disabled={access?.rows.length === 1}
|
|
tooltipProps={{
|
|
title:
|
|
access?.rows.length === 1
|
|
? 'Cannot remove access. A project must have at least one owner'
|
|
: 'Remove access',
|
|
}}
|
|
>
|
|
<Delete />
|
|
</PermissionIconButton>
|
|
</ActionCell>
|
|
),
|
|
},
|
|
],
|
|
[access, projectId]
|
|
);
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [initialState] = useState(() => ({
|
|
sortBy: [
|
|
{
|
|
id: searchParams.get('sort') || storedParams.id,
|
|
desc: searchParams.has('order')
|
|
? searchParams.get('order') === 'desc'
|
|
: storedParams.desc,
|
|
},
|
|
],
|
|
globalFilter: searchParams.get('search') || '',
|
|
}));
|
|
const [searchValue, setSearchValue] = useState(initialState.globalFilter);
|
|
|
|
const { data, getSearchText, getSearchContext } = useSearch(
|
|
columns,
|
|
searchValue,
|
|
access?.rows ?? []
|
|
);
|
|
|
|
const {
|
|
headerGroups,
|
|
rows,
|
|
prepareRow,
|
|
setHiddenColumns,
|
|
state: { sortBy },
|
|
} = useTable(
|
|
{
|
|
columns: columns as any[],
|
|
data,
|
|
initialState,
|
|
sortTypes,
|
|
autoResetHiddenColumns: false,
|
|
autoResetSortBy: false,
|
|
disableSortRemove: true,
|
|
disableMultiSort: true,
|
|
defaultColumn: {
|
|
Cell: TextCell,
|
|
},
|
|
},
|
|
useSortBy,
|
|
useFlexLayout
|
|
);
|
|
|
|
useConditionallyHiddenColumns(
|
|
[
|
|
{
|
|
condition: isSmallScreen,
|
|
columns: hiddenColumnsSmall,
|
|
},
|
|
{
|
|
condition: isMediumScreen,
|
|
columns: hiddenColumnsMedium,
|
|
},
|
|
],
|
|
setHiddenColumns,
|
|
columns
|
|
);
|
|
|
|
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.GROUP) {
|
|
const user = userOrGroup.entity as IUser;
|
|
name = name || user.email || user.username || '';
|
|
}
|
|
|
|
try {
|
|
if (userOrGroup.type !== ENTITY_TYPE.GROUP) {
|
|
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);
|
|
};
|
|
|
|
return (
|
|
<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 />
|
|
</>
|
|
}
|
|
/>
|
|
<ResponsiveButton
|
|
onClick={() => navigate('create')}
|
|
maxWidth="700px"
|
|
Icon={Add}
|
|
permission={UPDATE_PROJECT}
|
|
projectId={projectId}
|
|
data-testid={PA_ASSIGN_BUTTON_ID}
|
|
>
|
|
Assign {entityType}
|
|
</ResponsiveButton>
|
|
</>
|
|
}
|
|
>
|
|
<ConditionallyRender
|
|
condition={isSmallScreen}
|
|
show={
|
|
<Search
|
|
initialValue={searchValue}
|
|
onChange={setSearchValue}
|
|
hasFilters
|
|
getSearchContext={getSearchContext}
|
|
/>
|
|
}
|
|
/>
|
|
</PageHeader>
|
|
}
|
|
>
|
|
<SearchHighlightProvider value={getSearchText(searchValue)}>
|
|
<VirtualizedTable
|
|
rows={rows}
|
|
headerGroups={headerGroups}
|
|
prepareRow={prepareRow}
|
|
/>
|
|
</SearchHighlightProvider>
|
|
<ConditionallyRender
|
|
condition={rows.length === 0}
|
|
show={
|
|
<ConditionallyRender
|
|
condition={searchValue?.length > 0}
|
|
show={
|
|
<TablePlaceholder>
|
|
No access found matching “
|
|
{searchValue}
|
|
”
|
|
</TablePlaceholder>
|
|
}
|
|
elseShow={
|
|
<TablePlaceholder>
|
|
No access available. Get started by assigning a{' '}
|
|
{entityType}.
|
|
</TablePlaceholder>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<Routes>
|
|
<Route path="create" element={<ProjectAccessCreate />} />
|
|
<Route
|
|
path="edit/group/:groupId"
|
|
element={<ProjectAccessEditGroup />}
|
|
/>
|
|
<Route
|
|
path="edit/user/:userId"
|
|
element={<ProjectAccessEditUser />}
|
|
/>
|
|
</Routes>
|
|
<Dialogue
|
|
open={removeOpen}
|
|
onClick={() => removeAccess(selectedRow)}
|
|
onClose={() => {
|
|
setRemoveOpen(false);
|
|
}}
|
|
title={`Really remove ${entityType} from this project?`}
|
|
/>
|
|
<ProjectGroupView
|
|
open={groupOpen}
|
|
setOpen={setGroupOpen}
|
|
group={selectedRow?.entity as IGroup}
|
|
projectId={projectId}
|
|
subtitle={`Role: ${
|
|
access?.roles.find(
|
|
({ id }) => id === selectedRow?.entity.roleId
|
|
)?.name
|
|
}`}
|
|
onEdit={() => {
|
|
navigate(`edit/group/${selectedRow?.entity.id}`);
|
|
}}
|
|
onRemove={() => {
|
|
setGroupOpen(false);
|
|
setRemoveOpen(true);
|
|
}}
|
|
/>
|
|
</PageContent>
|
|
);
|
|
};
|