mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-31 13:47:02 +02:00
PAT: add "never", "custom" options to expiry date (#2198)
* add DateTimePicker component * PAT expiry - custom, never * show "never" in PAT table * add alert, some styling
This commit is contained in:
parent
7524dad7e8
commit
d261097151
@ -0,0 +1,65 @@
|
|||||||
|
import { INPUT_ERROR_TEXT } from 'utils/testIds';
|
||||||
|
import { TextField, OutlinedTextFieldProps } from '@mui/material';
|
||||||
|
import { parseValidDate } from '../util';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface IDateTimePickerProps extends Omit<OutlinedTextFieldProps, 'variant'> {
|
||||||
|
label: string;
|
||||||
|
type?: 'date' | 'datetime';
|
||||||
|
error?: boolean;
|
||||||
|
errorText?: string;
|
||||||
|
min?: Date;
|
||||||
|
max?: Date;
|
||||||
|
value: Date;
|
||||||
|
onChange: (e: any) => any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDate = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return format(date, 'yyyy-MM-dd');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateTime = (value: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return format(date, 'yyyy-MM-dd') + 'T' + format(date, 'HH:mm');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DateTimePicker = ({
|
||||||
|
label,
|
||||||
|
type = 'datetime',
|
||||||
|
error,
|
||||||
|
errorText,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
InputProps,
|
||||||
|
...rest
|
||||||
|
}: IDateTimePickerProps) => {
|
||||||
|
const getDate = type === 'datetime' ? formatDateTime : formatDate;
|
||||||
|
const inputType = type === 'datetime' ? 'datetime-local' : 'date';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
type={inputType}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={label}
|
||||||
|
error={error}
|
||||||
|
helperText={errorText}
|
||||||
|
value={getDate(value.toISOString())}
|
||||||
|
onChange={e => {
|
||||||
|
const parsedDate = parseValidDate(e.target.value);
|
||||||
|
onChange(parsedDate ?? value);
|
||||||
|
}}
|
||||||
|
FormHelperTextProps={{
|
||||||
|
['data-testid']: INPUT_ERROR_TEXT,
|
||||||
|
}}
|
||||||
|
inputProps={{
|
||||||
|
min: min ? getDate(min.toISOString()) : min,
|
||||||
|
max: max ? getDate(max.toISOString()) : max,
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { Button, styled, Typography } from '@mui/material';
|
import { Alert, Button, styled, Typography } from '@mui/material';
|
||||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
@ -13,6 +13,7 @@ import { formatDateYMD } from 'utils/formatDate';
|
|||||||
import { useLocationSettings } from 'hooks/useLocationSettings';
|
import { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
|
||||||
|
import { DateTimePicker } from 'component/common/DateTimePicker/DateTimePicker';
|
||||||
|
|
||||||
const StyledForm = styled('form')(() => ({
|
const StyledForm = styled('form')(() => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -31,18 +32,37 @@ const StyledInput = styled(Input)(({ theme }) => ({
|
|||||||
marginBottom: theme.spacing(2),
|
marginBottom: theme.spacing(2),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledExpirationPicker = styled('div')(({ theme }) => ({
|
const StyledExpirationPicker = styled('div')<{ custom?: boolean }>(
|
||||||
display: 'flex',
|
({ theme, custom }) => ({
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
gap: theme.spacing(1.5),
|
alignItems: custom ? 'start' : 'center',
|
||||||
[theme.breakpoints.down('sm')]: {
|
gap: theme.spacing(1.5),
|
||||||
flexDirection: 'column',
|
marginBottom: theme.spacing(2),
|
||||||
alignItems: 'flex-start',
|
[theme.breakpoints.down('sm')]: {
|
||||||
},
|
flexDirection: 'column',
|
||||||
}));
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
|
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
|
||||||
minWidth: theme.spacing(20),
|
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 }) => ({
|
const StyledButtonContainer = styled('div')(({ theme }) => ({
|
||||||
@ -62,6 +82,8 @@ enum ExpirationOption {
|
|||||||
'7DAYS' = '7d',
|
'7DAYS' = '7d',
|
||||||
'30DAYS' = '30d',
|
'30DAYS' = '30d',
|
||||||
'60DAYS' = '60d',
|
'60DAYS' = '60d',
|
||||||
|
NEVER = 'never',
|
||||||
|
CUSTOM = 'custom',
|
||||||
}
|
}
|
||||||
|
|
||||||
const expirationOptions = [
|
const expirationOptions = [
|
||||||
@ -80,8 +102,26 @@ const expirationOptions = [
|
|||||||
days: 60,
|
days: 60,
|
||||||
label: '60 days',
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
interface ICreatePersonalAPITokenProps {
|
interface ICreatePersonalAPITokenProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@ -103,10 +143,14 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
|
|||||||
const [expiration, setExpiration] = useState<ExpirationOption>(
|
const [expiration, setExpiration] = useState<ExpirationOption>(
|
||||||
ExpirationOption['30DAYS']
|
ExpirationOption['30DAYS']
|
||||||
);
|
);
|
||||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
const [errors, setErrors] = useState<ICreatePersonalAPITokenErrors>({});
|
||||||
|
|
||||||
const clearErrors = () => {
|
const clearError = (field: ErrorField) => {
|
||||||
setErrors({});
|
setErrors(errors => ({ ...errors, [field]: undefined }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setError = (field: ErrorField, error: string) => {
|
||||||
|
setErrors(errors => ({ ...errors, [field]: error }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateDate = () => {
|
const calculateDate = () => {
|
||||||
@ -114,7 +158,11 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
|
|||||||
const expirationOption = expirationOptions.find(
|
const expirationOption = expirationOptions.find(
|
||||||
({ key }) => key === expiration
|
({ key }) => key === expiration
|
||||||
);
|
);
|
||||||
if (expirationOption) {
|
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);
|
expiresAt.setDate(expiresAt.getDate() + expirationOption.days);
|
||||||
}
|
}
|
||||||
return expiresAt;
|
return expiresAt;
|
||||||
@ -124,10 +172,12 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDescription('');
|
setDescription('');
|
||||||
|
setErrors({});
|
||||||
setExpiration(ExpirationOption['30DAYS']);
|
setExpiration(ExpirationOption['30DAYS']);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
clearError(ErrorField.EXPIRES_AT);
|
||||||
setExpiresAt(calculateDate());
|
setExpiresAt(calculateDate());
|
||||||
}, [expiration]);
|
}, [expiration]);
|
||||||
|
|
||||||
@ -166,19 +216,26 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
|
|||||||
const isDescriptionUnique = (description: string) =>
|
const isDescriptionUnique = (description: string) =>
|
||||||
!tokens?.some(token => token.description === description);
|
!tokens?.some(token => token.description === description);
|
||||||
const isValid =
|
const isValid =
|
||||||
isDescriptionEmpty(description) && isDescriptionUnique(description);
|
isDescriptionEmpty(description) &&
|
||||||
|
isDescriptionUnique(description) &&
|
||||||
|
expiresAt > new Date();
|
||||||
|
|
||||||
const onSetDescription = (description: string) => {
|
const onSetDescription = (description: string) => {
|
||||||
clearErrors();
|
clearError(ErrorField.DESCRIPTION);
|
||||||
if (!isDescriptionUnique(description)) {
|
if (!isDescriptionUnique(description)) {
|
||||||
setErrors({
|
setError(
|
||||||
description:
|
ErrorField.DESCRIPTION,
|
||||||
'A personal API token with that description already exists.',
|
'A personal API token with that description already exists.'
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
setDescription(description);
|
setDescription(description);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const customExpiration = expiration === ExpirationOption.CUSTOM;
|
||||||
|
|
||||||
|
const neverExpires =
|
||||||
|
expiresAt.getFullYear() > new Date().getFullYear() + 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarModal
|
<SidebarModal
|
||||||
open={open}
|
open={open}
|
||||||
@ -215,7 +272,7 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
|
|||||||
<StyledInputDescription>
|
<StyledInputDescription>
|
||||||
Token expiration date
|
Token expiration date
|
||||||
</StyledInputDescription>
|
</StyledInputDescription>
|
||||||
<StyledExpirationPicker>
|
<StyledExpirationPicker custom={customExpiration}>
|
||||||
<StyledSelectMenu
|
<StyledSelectMenu
|
||||||
name="expiration"
|
name="expiration"
|
||||||
id="expiration"
|
id="expiration"
|
||||||
@ -229,20 +286,61 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
|
|||||||
options={expirationOptions}
|
options={expirationOptions}
|
||||||
/>
|
/>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(expiresAt)}
|
condition={customExpiration}
|
||||||
show={() => (
|
show={() => (
|
||||||
<Typography variant="body2">
|
<StyledDateTimePicker
|
||||||
Token will expire on{' '}
|
label="Date"
|
||||||
<strong>
|
value={expiresAt}
|
||||||
{formatDateYMD(
|
onChange={date => {
|
||||||
expiresAt!,
|
clearError(ErrorField.EXPIRES_AT);
|
||||||
locationSettings.locale
|
if (date < new Date()) {
|
||||||
)}
|
setError(
|
||||||
</strong>
|
ErrorField.EXPIRES_AT,
|
||||||
</Typography>
|
'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>
|
</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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StyledButtonContainer>
|
<StyledButtonContainer>
|
||||||
|
@ -17,6 +17,7 @@ import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
|
|||||||
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
|
||||||
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
|
||||||
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
|
||||||
|
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
|
||||||
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
|
import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
|
||||||
import { useSearch } from 'hooks/useSearch';
|
import { useSearch } from 'hooks/useSearch';
|
||||||
@ -116,7 +117,13 @@ export const PersonalAPITokensTab = ({ user }: IPersonalAPITokensTabProps) => {
|
|||||||
{
|
{
|
||||||
Header: 'Expires',
|
Header: 'Expires',
|
||||||
accessor: 'expiresAt',
|
accessor: 'expiresAt',
|
||||||
Cell: DateCell,
|
Cell: ({ value }: { value: string }) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (date.getFullYear() > new Date().getFullYear() + 100) {
|
||||||
|
return <TextCell>Never</TextCell>;
|
||||||
|
}
|
||||||
|
return <DateCell value={value} />;
|
||||||
|
},
|
||||||
sortType: 'date',
|
sortType: 'date',
|
||||||
maxWidth: 150,
|
maxWidth: 150,
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user