mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-24 17:51:14 +02:00
https://linear.app/unleash/issue/2-543/show-relevant-information-on-the-service-accounts-table Shows relevant information on the table, like total PATs and the last time a service account was active based on latest seen PAT for that account. Adapts to the latest related PR on enterprise. 
452 lines
18 KiB
TypeScript
452 lines
18 KiB
TypeScript
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';
|
|
import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens';
|
|
import { IServiceAccount } from 'interfaces/service-account';
|
|
|
|
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',
|
|
paddingTop: 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?: IServiceAccount;
|
|
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: IServiceAccount) =>
|
|
serviceAccount.username === value
|
|
);
|
|
const isPATValid =
|
|
tokenGeneration === TokenGeneration.LATER ||
|
|
(isNotEmpty(patDescription) && patExpiresAt > new Date());
|
|
const isValid =
|
|
isNotEmpty(name) &&
|
|
isNotEmpty(username) &&
|
|
(editing || isUnique(username)) &&
|
|
isPATValid;
|
|
|
|
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>
|
|
}
|
|
elseShow={
|
|
<>
|
|
<StyledInputDescription>
|
|
Service account tokens
|
|
</StyledInputDescription>
|
|
<ServiceAccountTokens
|
|
serviceAccount={serviceAccount!}
|
|
/>
|
|
</>
|
|
}
|
|
/>
|
|
</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>
|
|
);
|
|
};
|