mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-12 01:17:04 +02:00
feat: service accounts (UI) (#2734)
https://linear.app/unleash/issue/2-541/ui-for-service-accounts Adds basic CRUD UI to Service Accounts - New tab/page, table, create/edit form in a modal, token dialog, delete dialog, actions cell.
This commit is contained in:
parent
878560e2f1
commit
005e4b6858
@ -35,6 +35,16 @@ function AdminMenu() {
|
||||
</CenteredNavLink>
|
||||
}
|
||||
/>
|
||||
{flags.serviceAccounts && (
|
||||
<Tab
|
||||
value="service-accounts"
|
||||
label={
|
||||
<CenteredNavLink to="/admin/service-accounts">
|
||||
<span>Service accounts</span>
|
||||
</CenteredNavLink>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{flags.UG && (
|
||||
<Tab
|
||||
value="groups"
|
||||
|
@ -0,0 +1,22 @@
|
||||
import { useContext } from 'react';
|
||||
import AdminMenu from '../menu/AdminMenu';
|
||||
import AccessContext from 'contexts/AccessContext';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
|
||||
import { ServiceAccountsTable } from './ServiceAccountsTable/ServiceAccountsTable';
|
||||
|
||||
export const ServiceAccounts = () => {
|
||||
const { hasAccess } = useContext(AccessContext);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminMenu />
|
||||
<ConditionallyRender
|
||||
condition={hasAccess(ADMIN)}
|
||||
show={<ServiceAccountsTable />}
|
||||
elseShow={<AdminAlert />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
import { Alert, styled } from '@mui/material';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import { IUser } from 'interfaces/user';
|
||||
|
||||
const StyledLabel = styled('p')(({ theme }) => ({
|
||||
marginTop: theme.spacing(3),
|
||||
}));
|
||||
|
||||
interface IServiceAccountDeleteDialogProps {
|
||||
serviceAccount?: IUser;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
onConfirm: (serviceAccount: IUser) => void;
|
||||
}
|
||||
|
||||
export const ServiceAccountDeleteDialog = ({
|
||||
serviceAccount,
|
||||
open,
|
||||
setOpen,
|
||||
onConfirm,
|
||||
}: IServiceAccountDeleteDialogProps) => {
|
||||
return (
|
||||
<Dialogue
|
||||
title="Delete service account?"
|
||||
open={open}
|
||||
primaryButtonText="Delete service account"
|
||||
secondaryButtonText="Cancel"
|
||||
onClick={() => onConfirm(serviceAccount!)}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Alert severity="error">
|
||||
Deleting this service account may break any existing
|
||||
implementations currently using it.
|
||||
</Alert>
|
||||
<StyledLabel>
|
||||
You are about to delete service account:{' '}
|
||||
<strong>{serviceAccount?.name}</strong>
|
||||
</StyledLabel>
|
||||
</Dialogue>
|
||||
);
|
||||
};
|
@ -0,0 +1,438 @@
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Link,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
styled,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import Input from 'component/common/Input/Input';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { IUser } from 'interfaces/user';
|
||||
import {
|
||||
IServiceAccountPayload,
|
||||
useServiceAccountsApi,
|
||||
} from 'hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi';
|
||||
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
||||
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
|
||||
import {
|
||||
calculateExpirationDate,
|
||||
ExpirationOption,
|
||||
IPersonalAPITokenFormErrors,
|
||||
PersonalAPITokenForm,
|
||||
} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm';
|
||||
import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
|
||||
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||
|
||||
const StyledForm = styled('form')(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}));
|
||||
|
||||
const StyledInputDescription = styled('p')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
color: theme.palette.text.primary,
|
||||
marginBottom: theme.spacing(1),
|
||||
'&:not(:first-of-type)': {
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
marginBottom: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const StyledInput = styled(Input)(({ theme }) => ({
|
||||
width: '100%',
|
||||
maxWidth: theme.spacing(50),
|
||||
}));
|
||||
|
||||
const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({
|
||||
margin: theme.spacing(0.5, 0),
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
padding: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledRoleRadio = styled(Radio)(({ theme }) => ({
|
||||
marginRight: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledSecondaryContainer = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(3),
|
||||
backgroundColor: theme.palette.secondaryContainer,
|
||||
borderRadius: theme.shape.borderRadiusMedium,
|
||||
marginTop: theme.spacing(4),
|
||||
marginBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledInlineContainer = styled('div')(({ theme }) => ({
|
||||
padding: theme.spacing(0, 4),
|
||||
'& > p:not(:first-of-type)': {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||
marginTop: 'auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
marginTop: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledCancelButton = styled(Button)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(3),
|
||||
}));
|
||||
|
||||
enum TokenGeneration {
|
||||
LATER = 'later',
|
||||
NOW = 'now',
|
||||
}
|
||||
|
||||
enum ErrorField {
|
||||
USERNAME = 'username',
|
||||
}
|
||||
|
||||
interface IServiceAccountModalErrors {
|
||||
[ErrorField.USERNAME]?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_EXPIRATION = ExpirationOption['30DAYS'];
|
||||
|
||||
interface IServiceAccountModalProps {
|
||||
serviceAccount?: IUser;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
newToken: (token: INewPersonalAPIToken) => void;
|
||||
}
|
||||
|
||||
export const ServiceAccountModal = ({
|
||||
serviceAccount,
|
||||
open,
|
||||
setOpen,
|
||||
newToken,
|
||||
}: IServiceAccountModalProps) => {
|
||||
const { users } = useUsers();
|
||||
const { serviceAccounts, roles, refetch } = useServiceAccounts();
|
||||
const { addServiceAccount, updateServiceAccount, loading } =
|
||||
useServiceAccountsApi();
|
||||
const { createUserPersonalAPIToken } = usePersonalAPITokensApi();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
const { uiConfig } = useUiConfig();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [rootRole, setRootRole] = useState(1);
|
||||
const [tokenGeneration, setTokenGeneration] = useState<TokenGeneration>(
|
||||
TokenGeneration.LATER
|
||||
);
|
||||
const [errors, setErrors] = useState<IServiceAccountModalErrors>({});
|
||||
|
||||
const clearError = (field: ErrorField) => {
|
||||
setErrors(errors => ({ ...errors, [field]: undefined }));
|
||||
};
|
||||
|
||||
const setError = (field: ErrorField, error: string) => {
|
||||
setErrors(errors => ({ ...errors, [field]: error }));
|
||||
};
|
||||
|
||||
const [patDescription, setPatDescription] = useState('');
|
||||
const [patExpiration, setPatExpiration] =
|
||||
useState<ExpirationOption>(DEFAULT_EXPIRATION);
|
||||
const [patExpiresAt, setPatExpiresAt] = useState(
|
||||
calculateExpirationDate(DEFAULT_EXPIRATION)
|
||||
);
|
||||
const [patErrors, setPatErrors] = useState<IPersonalAPITokenFormErrors>({});
|
||||
|
||||
const editing = serviceAccount !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
setName(serviceAccount?.name || '');
|
||||
setUsername(serviceAccount?.username || '');
|
||||
setRootRole(serviceAccount?.rootRole || 1);
|
||||
setTokenGeneration(TokenGeneration.LATER);
|
||||
setErrors({});
|
||||
|
||||
setPatDescription('');
|
||||
setPatExpiration(DEFAULT_EXPIRATION);
|
||||
setPatExpiresAt(calculateExpirationDate(DEFAULT_EXPIRATION));
|
||||
setPatErrors({});
|
||||
}, [open, serviceAccount]);
|
||||
|
||||
const getServiceAccountPayload = (): IServiceAccountPayload => ({
|
||||
name,
|
||||
username,
|
||||
rootRole,
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await updateServiceAccount(
|
||||
serviceAccount.id,
|
||||
getServiceAccountPayload()
|
||||
);
|
||||
} else {
|
||||
const { id } = await addServiceAccount(
|
||||
getServiceAccountPayload()
|
||||
);
|
||||
if (tokenGeneration === TokenGeneration.NOW) {
|
||||
const token = await createUserPersonalAPIToken(id, {
|
||||
description: patDescription,
|
||||
expiresAt: patExpiresAt,
|
||||
});
|
||||
newToken(token);
|
||||
}
|
||||
}
|
||||
setToastData({
|
||||
title: `Service account ${
|
||||
editing ? 'updated' : 'added'
|
||||
} successfully`,
|
||||
type: 'success',
|
||||
});
|
||||
refetch();
|
||||
setOpen(false);
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const formatApiCode = () => {
|
||||
return `curl --location --request ${editing ? 'PUT' : 'POST'} '${
|
||||
uiConfig.unleashUrl
|
||||
}/api/admin/service-account${editing ? `/${serviceAccount.id}` : ''}' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '${JSON.stringify(getServiceAccountPayload(), undefined, 2)}'`;
|
||||
};
|
||||
|
||||
const isNotEmpty = (value: string) => value.length;
|
||||
const isUnique = (value: string) =>
|
||||
!users?.some((user: IUser) => user.username === value) &&
|
||||
!serviceAccounts?.some(
|
||||
(serviceAccount: IUser) => serviceAccount.username === value
|
||||
);
|
||||
const isValid =
|
||||
isNotEmpty(name) &&
|
||||
isNotEmpty(username) &&
|
||||
(editing || isUnique(username)) &&
|
||||
(tokenGeneration === TokenGeneration.LATER ||
|
||||
isNotEmpty(patDescription));
|
||||
|
||||
const suggestUsername = () => {
|
||||
if (isNotEmpty(name) && !isNotEmpty(username)) {
|
||||
const normalizedFromName = `service:${name
|
||||
.toLowerCase()
|
||||
.replace(/ /g, '-')
|
||||
.replace(/[^\w_-]/g, '')}`;
|
||||
if (isUnique(normalizedFromName)) {
|
||||
setUsername(normalizedFromName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSetUsername = (username: string) => {
|
||||
clearError(ErrorField.USERNAME);
|
||||
if (!isUnique(username)) {
|
||||
setError(
|
||||
ErrorField.USERNAME,
|
||||
'A service account or user with that username already exists.'
|
||||
);
|
||||
}
|
||||
setUsername(username);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarModal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
label={editing ? 'Edit service account' : 'New service account'}
|
||||
>
|
||||
<FormTemplate
|
||||
loading={loading}
|
||||
modal
|
||||
title={editing ? 'Edit service account' : 'New service account'}
|
||||
description="A service account is a special type of account that can only be used to authenticate with the Unleash API. Service accounts can be used to automate tasks."
|
||||
documentationLink="https://docs.getunleash.io"
|
||||
documentationLinkLabel="Service accounts documentation"
|
||||
formatApiCode={formatApiCode}
|
||||
>
|
||||
<StyledForm onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<StyledInputDescription>
|
||||
What is your new service account name?
|
||||
</StyledInputDescription>
|
||||
<StyledInput
|
||||
autoFocus
|
||||
label="Service account name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onBlur={suggestUsername}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
<StyledInputDescription>
|
||||
What is your new service account username?
|
||||
</StyledInputDescription>
|
||||
<StyledInput
|
||||
label="Service account username"
|
||||
error={Boolean(errors.username)}
|
||||
errorText={errors.username}
|
||||
value={username}
|
||||
onChange={e => onSetUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
required
|
||||
disabled={editing}
|
||||
/>
|
||||
<StyledInputDescription>
|
||||
What is your service account allowed to do?
|
||||
</StyledInputDescription>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
name="rootRole"
|
||||
value={rootRole || ''}
|
||||
onChange={e => setRootRole(+e.target.value)}
|
||||
data-loading
|
||||
>
|
||||
{roles
|
||||
.sort((a, b) => (a.name < b.name ? -1 : 1))
|
||||
.map(role => (
|
||||
<StyledRoleBox
|
||||
key={`role-${role.id}`}
|
||||
labelPlacement="end"
|
||||
label={
|
||||
<div>
|
||||
<strong>{role.name}</strong>
|
||||
<Typography variant="body2">
|
||||
{role.description}
|
||||
</Typography>
|
||||
</div>
|
||||
}
|
||||
control={
|
||||
<StyledRoleRadio
|
||||
checked={
|
||||
role.id === rootRole
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={role.id}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<ConditionallyRender
|
||||
condition={!editing}
|
||||
show={
|
||||
<StyledSecondaryContainer>
|
||||
<StyledInputDescription>
|
||||
Token
|
||||
</StyledInputDescription>
|
||||
<StyledInputSecondaryDescription>
|
||||
In order to connect your newly created
|
||||
service account, you will also need a
|
||||
token.{' '}
|
||||
<Link
|
||||
href="https://docs.getunleash.io/reference/api-tokens-and-client-keys"
|
||||
target="_blank"
|
||||
>
|
||||
Read more about API tokens
|
||||
</Link>
|
||||
.
|
||||
</StyledInputSecondaryDescription>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
value={tokenGeneration}
|
||||
onChange={e =>
|
||||
setTokenGeneration(
|
||||
e.target
|
||||
.value as TokenGeneration
|
||||
)
|
||||
}
|
||||
name="token-generation"
|
||||
>
|
||||
<FormControlLabel
|
||||
value={TokenGeneration.LATER}
|
||||
control={<Radio />}
|
||||
label="I want to generate a token later"
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={TokenGeneration.NOW}
|
||||
control={<Radio />}
|
||||
label="Generate a token now"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<StyledInlineContainer>
|
||||
<StyledInputSecondaryDescription>
|
||||
A new personal access token (PAT)
|
||||
will be generated for the service
|
||||
account, so you can get started
|
||||
right away.
|
||||
</StyledInputSecondaryDescription>
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
tokenGeneration ===
|
||||
TokenGeneration.NOW
|
||||
}
|
||||
show={
|
||||
<PersonalAPITokenForm
|
||||
description={patDescription}
|
||||
setDescription={
|
||||
setPatDescription
|
||||
}
|
||||
expiration={patExpiration}
|
||||
setExpiration={
|
||||
setPatExpiration
|
||||
}
|
||||
expiresAt={patExpiresAt}
|
||||
setExpiresAt={
|
||||
setPatExpiresAt
|
||||
}
|
||||
errors={patErrors}
|
||||
setErrors={setPatErrors}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</StyledInlineContainer>
|
||||
</StyledSecondaryContainer>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StyledButtonContainer>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!isValid}
|
||||
>
|
||||
{editing ? 'Save' : 'Add'} service account
|
||||
</Button>
|
||||
<StyledCancelButton
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</StyledCancelButton>
|
||||
</StyledButtonContainer>
|
||||
</StyledForm>
|
||||
</FormTemplate>
|
||||
</SidebarModal>
|
||||
);
|
||||
};
|
@ -0,0 +1,39 @@
|
||||
import { Alert, styled, Typography } from '@mui/material';
|
||||
import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken';
|
||||
import { Dialogue } from 'component/common/Dialogue/Dialogue';
|
||||
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||
import { FC } from 'react';
|
||||
|
||||
const StyledAlert = styled(Alert)(({ theme }) => ({
|
||||
marginBottom: theme.spacing(3),
|
||||
}));
|
||||
|
||||
interface IServiceAccountDialogProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
token?: INewPersonalAPIToken;
|
||||
}
|
||||
|
||||
export const ServiceAccountTokenDialog: FC<IServiceAccountDialogProps> = ({
|
||||
open,
|
||||
setOpen,
|
||||
token,
|
||||
}) => (
|
||||
<Dialogue
|
||||
open={open}
|
||||
secondaryButtonText="Close"
|
||||
onClose={(_, muiCloseReason?: string) => {
|
||||
if (!muiCloseReason) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
title="Service account token created"
|
||||
>
|
||||
<StyledAlert severity="info">
|
||||
Make sure to copy your service account API token now. You won't be
|
||||
able to see it again!
|
||||
</StyledAlert>
|
||||
<Typography variant="body1">Your token:</Typography>
|
||||
<UserToken token={token?.secret || ''} />
|
||||
</Dialogue>
|
||||
);
|
@ -0,0 +1,44 @@
|
||||
import { Delete, Edit } from '@mui/icons-material';
|
||||
import { Box, styled } from '@mui/material';
|
||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||
import { VFC } from 'react';
|
||||
|
||||
const StyledBox = styled(Box)(() => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}));
|
||||
|
||||
interface IServiceAccountsActionsCellProps {
|
||||
onEdit: (event: React.SyntheticEvent) => void;
|
||||
onDelete: (event: React.SyntheticEvent) => void;
|
||||
}
|
||||
|
||||
export const ServiceAccountsActionsCell: VFC<
|
||||
IServiceAccountsActionsCellProps
|
||||
> = ({ onEdit, onDelete }) => {
|
||||
return (
|
||||
<StyledBox>
|
||||
<PermissionIconButton
|
||||
data-loading
|
||||
onClick={onEdit}
|
||||
permission={ADMIN}
|
||||
tooltipProps={{
|
||||
title: 'Edit service account',
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</PermissionIconButton>
|
||||
<PermissionIconButton
|
||||
data-loading
|
||||
onClick={onDelete}
|
||||
permission={ADMIN}
|
||||
tooltipProps={{
|
||||
title: 'Remove service account',
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</PermissionIconButton>
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
@ -0,0 +1,272 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { IUser } from 'interfaces/user';
|
||||
import IRole from 'interfaces/role';
|
||||
import useToast from 'hooks/useToast';
|
||||
import { formatUnknownError } from 'utils/formatUnknownError';
|
||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
||||
import { Button, useMediaQuery } from '@mui/material';
|
||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||
import { useFlexLayout, useSortBy, useTable } from 'react-table';
|
||||
import { sortTypes } from 'utils/sortTypes';
|
||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||
import theme from 'themes/theme';
|
||||
import { Search } from 'component/common/Search/Search';
|
||||
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
|
||||
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
|
||||
import { useSearch } from 'hooks/useSearch';
|
||||
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
|
||||
import { useServiceAccountsApi } from 'hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi';
|
||||
import { ServiceAccountModal } from './ServiceAccountModal/ServiceAccountModal';
|
||||
import { ServiceAccountDeleteDialog } from './ServiceAccountDeleteDialog/ServiceAccountDeleteDialog';
|
||||
import { ServiceAccountsActionsCell } from './ServiceAccountsActionsCell/ServiceAccountsActionsCell';
|
||||
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||
import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAccountTokenDialog';
|
||||
|
||||
export const ServiceAccountsTable = () => {
|
||||
const navigate = useNavigate();
|
||||
const { setToastData, setToastApiError } = useToast();
|
||||
|
||||
const { serviceAccounts, roles, refetch, loading } = useServiceAccounts();
|
||||
const { removeServiceAccount } = useServiceAccountsApi();
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [tokenDialog, setTokenDialog] = useState(false);
|
||||
const [newToken, setNewToken] = useState<INewPersonalAPIToken>();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selectedServiceAccount, setSelectedServiceAccount] =
|
||||
useState<IUser>();
|
||||
|
||||
const onDeleteConfirm = async (serviceAccount: IUser) => {
|
||||
try {
|
||||
await removeServiceAccount(serviceAccount.id);
|
||||
setToastData({
|
||||
title: `${serviceAccount.name} has been deleted`,
|
||||
type: 'success',
|
||||
});
|
||||
refetch();
|
||||
setDeleteOpen(false);
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: 'Created',
|
||||
accessor: 'createdAt',
|
||||
Cell: DateCell,
|
||||
sortType: 'date',
|
||||
width: 120,
|
||||
maxWidth: 120,
|
||||
},
|
||||
{
|
||||
Header: 'Avatar',
|
||||
accessor: 'imageUrl',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<TextCell>
|
||||
<UserAvatar user={user} />
|
||||
</TextCell>
|
||||
),
|
||||
disableSortBy: true,
|
||||
maxWidth: 80,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
Header: 'Name',
|
||||
accessor: (row: any) => row.name || '',
|
||||
minWidth: 200,
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<HighlightCell
|
||||
value={user.name}
|
||||
subtitle={user.email || user.username}
|
||||
/>
|
||||
),
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
id: 'role',
|
||||
Header: 'Role',
|
||||
accessor: (row: any) =>
|
||||
roles.find((role: IRole) => role.id === row.rootRole)
|
||||
?.name || '',
|
||||
maxWidth: 120,
|
||||
},
|
||||
{
|
||||
Header: 'Actions',
|
||||
id: 'Actions',
|
||||
align: 'center',
|
||||
Cell: ({ row: { original: user } }: any) => (
|
||||
<ServiceAccountsActionsCell
|
||||
onEdit={() => {
|
||||
setSelectedServiceAccount(user);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setSelectedServiceAccount(user);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 150,
|
||||
disableSortBy: true,
|
||||
},
|
||||
// Always hidden -- for search
|
||||
{
|
||||
accessor: 'username',
|
||||
Header: 'Username',
|
||||
searchable: true,
|
||||
},
|
||||
],
|
||||
[roles, navigate]
|
||||
);
|
||||
|
||||
const [initialState] = useState({
|
||||
sortBy: [{ id: 'createdAt' }],
|
||||
hiddenColumns: ['username'],
|
||||
});
|
||||
|
||||
const { data, getSearchText } = useSearch(
|
||||
columns,
|
||||
searchValue,
|
||||
serviceAccounts
|
||||
);
|
||||
|
||||
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
|
||||
{
|
||||
columns: columns as any,
|
||||
data,
|
||||
initialState,
|
||||
sortTypes,
|
||||
autoResetSortBy: false,
|
||||
disableSortRemove: true,
|
||||
disableMultiSort: true,
|
||||
defaultColumn: {
|
||||
Cell: TextCell,
|
||||
},
|
||||
},
|
||||
useSortBy,
|
||||
useFlexLayout
|
||||
);
|
||||
|
||||
useConditionallyHiddenColumns(
|
||||
[
|
||||
{
|
||||
condition: isExtraSmallScreen,
|
||||
columns: ['imageUrl', 'role'],
|
||||
},
|
||||
{
|
||||
condition: isSmallScreen,
|
||||
columns: ['createdAt', 'last-login'],
|
||||
},
|
||||
],
|
||||
setHiddenColumns,
|
||||
columns
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContent
|
||||
isLoading={loading}
|
||||
header={
|
||||
<PageHeader
|
||||
title={`Service Accounts (${rows.length})`}
|
||||
actions={
|
||||
<>
|
||||
<ConditionallyRender
|
||||
condition={!isSmallScreen}
|
||||
show={
|
||||
<>
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
<PageHeader.Divider />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setSelectedServiceAccount(undefined);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
New service account
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={isSmallScreen}
|
||||
show={
|
||||
<Search
|
||||
initialValue={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</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 service accounts found matching “
|
||||
{searchValue}
|
||||
”
|
||||
</TablePlaceholder>
|
||||
}
|
||||
elseShow={
|
||||
<TablePlaceholder>
|
||||
No service accounts available. Get started by
|
||||
adding one.
|
||||
</TablePlaceholder>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ServiceAccountModal
|
||||
serviceAccount={selectedServiceAccount}
|
||||
open={modalOpen}
|
||||
setOpen={setModalOpen}
|
||||
newToken={(token: INewPersonalAPIToken) => {
|
||||
setNewToken(token);
|
||||
setTokenDialog(true);
|
||||
}}
|
||||
/>
|
||||
<ServiceAccountTokenDialog
|
||||
open={tokenDialog}
|
||||
setOpen={setTokenDialog}
|
||||
token={newToken}
|
||||
/>
|
||||
<ServiceAccountDeleteDialog
|
||||
serviceAccount={selectedServiceAccount}
|
||||
open={deleteOpen}
|
||||
setOpen={setDeleteOpen}
|
||||
onConfirm={onDeleteConfirm}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
};
|
@ -378,6 +378,17 @@ exports[`returns all baseRoutes 1`] = `
|
||||
"title": "Users",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"flag": "serviceAccounts",
|
||||
"menu": {
|
||||
"adminSettings": true,
|
||||
},
|
||||
"parent": "/admin",
|
||||
"path": "/admin/service-accounts",
|
||||
"title": "Service accounts",
|
||||
"type": "protected",
|
||||
},
|
||||
{
|
||||
"component": [Function],
|
||||
"menu": {},
|
||||
|
@ -62,6 +62,7 @@ import { Profile } from 'component/user/Profile/Profile';
|
||||
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
|
||||
import { Network } from 'component/admin/network/Network';
|
||||
import { MaintenanceAdmin } from '../admin/maintenance';
|
||||
import { ServiceAccounts } from 'component/admin/serviceAccounts/ServiceAccounts';
|
||||
|
||||
export const routes: IRoute[] = [
|
||||
// Splash
|
||||
@ -432,6 +433,15 @@ export const routes: IRoute[] = [
|
||||
type: 'protected',
|
||||
menu: { adminSettings: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/service-accounts',
|
||||
parent: '/admin',
|
||||
title: 'Service accounts',
|
||||
component: ServiceAccounts,
|
||||
type: 'protected',
|
||||
menu: { adminSettings: true },
|
||||
flag: 'serviceAccounts',
|
||||
},
|
||||
{
|
||||
path: '/admin/create-user',
|
||||
parent: '/admin',
|
||||
|
Loading…
Reference in New Issue
Block a user