1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-23 13:46:45 +02:00

feat: service accounts (UI - extract PAT form) (#2733)

https://linear.app/unleash/issue/2-540/extract-pat-form

By refactoring the UI logic and extracting the PAT form we can use the
same component on our service account creation form.
This commit is contained in:
Nuno Góis 2023-01-04 14:51:30 +00:00 committed by GitHub
parent 11f4435a9e
commit 4005bb8a8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 279 additions and 228 deletions

View File

@ -1,4 +1,4 @@
import { Alert, Button, styled, Typography } from '@mui/material';
import { Button, styled } 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';
@ -7,13 +7,13 @@ import { FC, FormEvent, useEffect, useState } from 'react';
import { formatUnknownError } from 'utils/formatUnknownError';
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
import Input from 'component/common/Input/Input';
import SelectMenu from 'component/common/select';
import { formatDateYMD } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import { DateTimePicker } from 'component/common/DateTimePicker/DateTimePicker';
import {
calculateExpirationDate,
ExpirationOption,
IPersonalAPITokenFormErrors,
PersonalAPITokenForm,
} from './PersonalAPITokenForm/PersonalAPITokenForm';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -21,50 +21,6 @@ const StyledForm = styled('form')(() => ({
height: '100%',
}));
const StyledInputDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1),
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
marginBottom: theme.spacing(2),
}));
const StyledExpirationPicker = styled('div')<{ custom?: boolean }>(
({ theme, custom }) => ({
display: 'flex',
alignItems: custom ? 'start' : 'center',
gap: theme.spacing(1.5),
marginBottom: theme.spacing(2),
[theme.breakpoints.down('sm')]: {
flexDirection: 'column',
alignItems: 'flex-start',
},
})
);
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
minWidth: theme.spacing(20),
marginRight: theme.spacing(0.5),
[theme.breakpoints.down('sm')]: {
width: theme.spacing(50),
},
}));
const StyledDateTimePicker = styled(DateTimePicker)(({ theme }) => ({
width: theme.spacing(28),
[theme.breakpoints.down('sm')]: {
width: theme.spacing(50),
},
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(2),
maxWidth: theme.spacing(50),
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
@ -78,49 +34,7 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
enum ExpirationOption {
'7DAYS' = '7d',
'30DAYS' = '30d',
'60DAYS' = '60d',
NEVER = 'never',
CUSTOM = 'custom',
}
const expirationOptions = [
{
key: ExpirationOption['7DAYS'],
days: 7,
label: '7 days',
},
{
key: ExpirationOption['30DAYS'],
days: 30,
label: '30 days',
},
{
key: ExpirationOption['60DAYS'],
days: 60,
label: '60 days',
},
{
key: ExpirationOption.NEVER,
label: 'Never',
},
{
key: ExpirationOption.CUSTOM,
label: 'Custom',
},
];
enum ErrorField {
DESCRIPTION = 'description',
EXPIRES_AT = 'expiresAt',
}
interface ICreatePersonalAPITokenErrors {
[ErrorField.DESCRIPTION]?: string;
[ErrorField.EXPIRES_AT]?: string;
}
const DEFAULT_EXPIRATION = ExpirationOption['30DAYS'];
interface ICreatePersonalAPITokenProps {
open: boolean;
@ -137,50 +51,22 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
const { createPersonalAPIToken, loading } = usePersonalAPITokensApi();
const { setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const { locationSettings } = useLocationSettings();
const [description, setDescription] = useState('');
const [expiration, setExpiration] = useState<ExpirationOption>(
ExpirationOption['30DAYS']
const [expiration, setExpiration] =
useState<ExpirationOption>(DEFAULT_EXPIRATION);
const [expiresAt, setExpiresAt] = useState(
calculateExpirationDate(DEFAULT_EXPIRATION)
);
const [errors, setErrors] = useState<ICreatePersonalAPITokenErrors>({});
const clearError = (field: ErrorField) => {
setErrors(errors => ({ ...errors, [field]: undefined }));
};
const setError = (field: ErrorField, error: string) => {
setErrors(errors => ({ ...errors, [field]: error }));
};
const calculateDate = () => {
const expiresAt = new Date();
const expirationOption = expirationOptions.find(
({ key }) => key === expiration
);
if (expiration === ExpirationOption.NEVER) {
expiresAt.setFullYear(expiresAt.getFullYear() + 1000);
} else if (expiration === ExpirationOption.CUSTOM) {
expiresAt.setMinutes(expiresAt.getMinutes() + 30);
} else if (expirationOption?.days) {
expiresAt.setDate(expiresAt.getDate() + expirationOption.days);
}
return expiresAt;
};
const [expiresAt, setExpiresAt] = useState(calculateDate());
const [errors, setErrors] = useState<IPersonalAPITokenFormErrors>({});
useEffect(() => {
setDescription('');
setExpiration(DEFAULT_EXPIRATION);
setExpiresAt(calculateExpirationDate(DEFAULT_EXPIRATION));
setErrors({});
setExpiration(ExpirationOption['30DAYS']);
}, [open]);
useEffect(() => {
clearError(ErrorField.EXPIRES_AT);
setExpiresAt(calculateDate());
}, [expiration]);
const getPersonalAPITokenPayload = () => {
return {
description,
@ -220,22 +106,6 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
isDescriptionUnique(description) &&
expiresAt > new Date();
const onSetDescription = (description: string) => {
clearError(ErrorField.DESCRIPTION);
if (!isDescriptionUnique(description)) {
setError(
ErrorField.DESCRIPTION,
'A personal API token with that description already exists.'
);
}
setDescription(description);
};
const customExpiration = expiration === ExpirationOption.CUSTOM;
const neverExpires =
expiresAt.getFullYear() > new Date().getFullYear() + 100;
return (
<SidebarModal
open={open}
@ -257,89 +127,16 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
>
<StyledForm onSubmit={handleSubmit}>
<div>
<StyledInputDescription>
Describe what this token will be used for
</StyledInputDescription>
<StyledInput
autoFocus
label="Description"
error={Boolean(errors.description)}
errorText={errors.description}
value={description}
onChange={e => onSetDescription(e.target.value)}
required
/>
<StyledInputDescription>
Token expiration date
</StyledInputDescription>
<StyledExpirationPicker custom={customExpiration}>
<StyledSelectMenu
name="expiration"
id="expiration"
label="Token will expire in"
value={expiration}
onChange={e =>
setExpiration(
e.target.value as ExpirationOption
)
}
options={expirationOptions}
/>
<ConditionallyRender
condition={customExpiration}
show={() => (
<StyledDateTimePicker
label="Date"
value={expiresAt}
onChange={date => {
clearError(ErrorField.EXPIRES_AT);
if (date < new Date()) {
setError(
ErrorField.EXPIRES_AT,
'Invalid date, must be in the future'
);
}
setExpiresAt(date);
}}
min={new Date()}
error={Boolean(errors.expiresAt)}
errorText={errors.expiresAt}
required
/>
)}
elseShow={
<ConditionallyRender
condition={neverExpires}
show={
<Typography variant="body2">
The token will{' '}
<strong>never</strong> expire!
</Typography>
}
elseShow={() => (
<Typography variant="body2">
Token will expire on{' '}
<strong>
{formatDateYMD(
expiresAt!,
locationSettings.locale
)}
</strong>
</Typography>
)}
/>
}
/>
</StyledExpirationPicker>
<ConditionallyRender
condition={neverExpires}
show={
<StyledAlert severity="warning">
We strongly recommend that you set an
expiration date for your token to help keep
your information secure.
</StyledAlert>
}
<PersonalAPITokenForm
description={description}
setDescription={setDescription}
isDescriptionUnique={isDescriptionUnique}
expiration={expiration}
setExpiration={setExpiration}
expiresAt={expiresAt}
setExpiresAt={setExpiresAt}
errors={errors}
setErrors={setErrors}
/>
</div>

View File

@ -0,0 +1,254 @@
import Input from 'component/common/Input/Input';
import SelectMenu from 'component/common/select';
import { formatDateYMD } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { DateTimePicker } from 'component/common/DateTimePicker/DateTimePicker';
import { Alert, styled, Typography } from '@mui/material';
import { useEffect } from 'react';
const StyledInputDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
marginBottom: theme.spacing(1),
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
marginBottom: theme.spacing(2),
}));
const StyledExpirationPicker = styled('div')<{ custom?: boolean }>(
({ theme, custom }) => ({
display: 'flex',
alignItems: custom ? 'start' : 'center',
gap: theme.spacing(1.5),
marginBottom: theme.spacing(2),
[theme.breakpoints.down('sm')]: {
flexDirection: 'column',
alignItems: 'flex-start',
},
})
);
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
minWidth: theme.spacing(20),
marginRight: theme.spacing(0.5),
[theme.breakpoints.down('sm')]: {
width: theme.spacing(50),
},
}));
const StyledDateTimePicker = styled(DateTimePicker)(({ theme }) => ({
width: theme.spacing(28),
[theme.breakpoints.down('sm')]: {
width: theme.spacing(50),
},
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(2),
maxWidth: theme.spacing(50),
}));
export enum ExpirationOption {
'7DAYS' = '7d',
'30DAYS' = '30d',
'60DAYS' = '60d',
NEVER = 'never',
CUSTOM = 'custom',
}
export const expirationOptions = [
{
key: ExpirationOption['7DAYS'],
days: 7,
label: '7 days',
},
{
key: ExpirationOption['30DAYS'],
days: 30,
label: '30 days',
},
{
key: ExpirationOption['60DAYS'],
days: 60,
label: '60 days',
},
{
key: ExpirationOption.NEVER,
label: 'Never',
},
{
key: ExpirationOption.CUSTOM,
label: 'Custom',
},
];
export const calculateExpirationDate = (expiration: ExpirationOption) => {
const expiresAt = new Date();
const expirationOption = expirationOptions.find(
({ key }) => key === expiration
);
if (expiration === ExpirationOption.NEVER) {
expiresAt.setFullYear(expiresAt.getFullYear() + 1000);
} else if (expiration === ExpirationOption.CUSTOM) {
expiresAt.setMinutes(expiresAt.getMinutes() + 30);
} else if (expirationOption?.days) {
expiresAt.setDate(expiresAt.getDate() + expirationOption.days);
}
return expiresAt;
};
enum ErrorField {
DESCRIPTION = 'description',
EXPIRES_AT = 'expiresAt',
}
export interface IPersonalAPITokenFormErrors {
[ErrorField.DESCRIPTION]?: string;
[ErrorField.EXPIRES_AT]?: string;
}
interface IPersonalAPITokenFormProps {
description: string;
setDescription: React.Dispatch<React.SetStateAction<string>>;
isDescriptionUnique?: (description: string) => boolean;
expiration: ExpirationOption;
setExpiration: (expiration: ExpirationOption) => void;
expiresAt: Date;
setExpiresAt: React.Dispatch<React.SetStateAction<Date>>;
errors: IPersonalAPITokenFormErrors;
setErrors: React.Dispatch<
React.SetStateAction<IPersonalAPITokenFormErrors>
>;
}
export const PersonalAPITokenForm = ({
description,
setDescription,
isDescriptionUnique,
expiration,
setExpiration,
expiresAt,
setExpiresAt,
errors,
setErrors,
}: IPersonalAPITokenFormProps) => {
const { locationSettings } = useLocationSettings();
const clearError = (field: ErrorField) => {
setErrors(errors => ({ ...errors, [field]: undefined }));
};
const setError = (field: ErrorField, error: string) => {
setErrors(errors => ({ ...errors, [field]: error }));
};
useEffect(() => {
clearError(ErrorField.EXPIRES_AT);
setExpiresAt(calculateExpirationDate(expiration));
}, [expiration]);
const onSetDescription = (description: string) => {
clearError(ErrorField.DESCRIPTION);
if (isDescriptionUnique && !isDescriptionUnique(description)) {
setError(
ErrorField.DESCRIPTION,
'A personal API token with that description already exists.'
);
}
setDescription(description);
};
const customExpiration = expiration === ExpirationOption.CUSTOM;
const neverExpires =
expiresAt.getFullYear() > new Date().getFullYear() + 100;
return (
<>
<StyledInputDescription>
Describe what this token will be used for
</StyledInputDescription>
<StyledInput
autoFocus
label="Description"
error={Boolean(errors.description)}
errorText={errors.description}
value={description}
onChange={e => onSetDescription(e.target.value)}
required
/>
<StyledInputDescription>
Token expiration date
</StyledInputDescription>
<StyledExpirationPicker custom={customExpiration}>
<StyledSelectMenu
name="expiration"
id="expiration"
label="Token will expire in"
value={expiration}
onChange={e =>
setExpiration(e.target.value as ExpirationOption)
}
options={expirationOptions}
/>
<ConditionallyRender
condition={customExpiration}
show={() => (
<StyledDateTimePicker
label="Date"
value={expiresAt}
onChange={date => {
clearError(ErrorField.EXPIRES_AT);
if (date < new Date()) {
setError(
ErrorField.EXPIRES_AT,
'Invalid date, must be in the future'
);
}
setExpiresAt(date);
}}
min={new Date()}
error={Boolean(errors.expiresAt)}
errorText={errors.expiresAt}
required
/>
)}
elseShow={
<ConditionallyRender
condition={neverExpires}
show={
<Typography variant="body2">
The token will <strong>never</strong>{' '}
expire!
</Typography>
}
elseShow={() => (
<Typography variant="body2">
Token will expire on{' '}
<strong>
{formatDateYMD(
expiresAt!,
locationSettings.locale
)}
</strong>
</Typography>
)}
/>
}
/>
</StyledExpirationPicker>
<ConditionallyRender
condition={neverExpires}
show={
<StyledAlert severity="warning">
We strongly recommend that you set an expiration date
for your token to help keep your information secure.
</StyledAlert>
}
/>
</>
);
};