1
0
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:
Nuno Góis 2023-01-05 08:11:28 +00:00 committed by GitHub
parent 878560e2f1
commit 005e4b6858
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 889 additions and 0 deletions

View File

@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": {},

View File

@ -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',