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>
|
</CenteredNavLink>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{flags.serviceAccounts && (
|
||||||
|
<Tab
|
||||||
|
value="service-accounts"
|
||||||
|
label={
|
||||||
|
<CenteredNavLink to="/admin/service-accounts">
|
||||||
|
<span>Service accounts</span>
|
||||||
|
</CenteredNavLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{flags.UG && (
|
{flags.UG && (
|
||||||
<Tab
|
<Tab
|
||||||
value="groups"
|
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",
|
"title": "Users",
|
||||||
"type": "protected",
|
"type": "protected",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"component": [Function],
|
||||||
|
"flag": "serviceAccounts",
|
||||||
|
"menu": {
|
||||||
|
"adminSettings": true,
|
||||||
|
},
|
||||||
|
"parent": "/admin",
|
||||||
|
"path": "/admin/service-accounts",
|
||||||
|
"title": "Service accounts",
|
||||||
|
"type": "protected",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"component": [Function],
|
"component": [Function],
|
||||||
"menu": {},
|
"menu": {},
|
||||||
|
@ -62,6 +62,7 @@ import { Profile } from 'component/user/Profile/Profile';
|
|||||||
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
|
import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin';
|
||||||
import { Network } from 'component/admin/network/Network';
|
import { Network } from 'component/admin/network/Network';
|
||||||
import { MaintenanceAdmin } from '../admin/maintenance';
|
import { MaintenanceAdmin } from '../admin/maintenance';
|
||||||
|
import { ServiceAccounts } from 'component/admin/serviceAccounts/ServiceAccounts';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -432,6 +433,15 @@ export const routes: IRoute[] = [
|
|||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: { adminSettings: true },
|
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',
|
path: '/admin/create-user',
|
||||||
parent: '/admin',
|
parent: '/admin',
|
||||||
|
Loading…
Reference in New Issue
Block a user