Skip to content

Commit

Permalink
PAT: add "never", "custom" options to expiry date (#2198)
Browse files Browse the repository at this point in the history
* add DateTimePicker component

* PAT expiry - custom, never

* show "never" in PAT table

* add alert, some styling
  • Loading branch information
nunogois committed Oct 18, 2022
1 parent 7524dad commit d261097
Show file tree
Hide file tree
Showing 3 changed files with 201 additions and 31 deletions.
65 changes: 65 additions & 0 deletions frontend/src/component/common/DateTimePicker/DateTimePicker.tsx
@@ -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 { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
Expand All @@ -13,6 +13,7 @@ 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';

const StyledForm = styled('form')(() => ({
display: 'flex',
Expand All @@ -31,18 +32,37 @@ const StyledInput = styled(Input)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));

const StyledExpirationPicker = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1.5),
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')]: {
flexDirection: 'column',
alignItems: 'flex-start',
width: theme.spacing(50),
},
}));

const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
minWidth: theme.spacing(20),
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 }) => ({
Expand All @@ -62,6 +82,8 @@ enum ExpirationOption {
'7DAYS' = '7d',
'30DAYS' = '30d',
'60DAYS' = '60d',
NEVER = 'never',
CUSTOM = 'custom',
}

const expirationOptions = [
Expand All @@ -80,8 +102,26 @@ const expirationOptions = [
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;
}

interface ICreatePersonalAPITokenProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
Expand All @@ -103,18 +143,26 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
const [expiration, setExpiration] = useState<ExpirationOption>(
ExpirationOption['30DAYS']
);
const [errors, setErrors] = useState<{ [key: string]: string }>({});
const [errors, setErrors] = useState<ICreatePersonalAPITokenErrors>({});

const clearErrors = () => {
setErrors({});
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 (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);
}
return expiresAt;
Expand All @@ -124,10 +172,12 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({

useEffect(() => {
setDescription('');
setErrors({});
setExpiration(ExpirationOption['30DAYS']);
}, [open]);

useEffect(() => {
clearError(ErrorField.EXPIRES_AT);
setExpiresAt(calculateDate());
}, [expiration]);

Expand Down Expand Up @@ -166,19 +216,26 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
const isDescriptionUnique = (description: string) =>
!tokens?.some(token => token.description === description);
const isValid =
isDescriptionEmpty(description) && isDescriptionUnique(description);
isDescriptionEmpty(description) &&
isDescriptionUnique(description) &&
expiresAt > new Date();

const onSetDescription = (description: string) => {
clearErrors();
clearError(ErrorField.DESCRIPTION);
if (!isDescriptionUnique(description)) {
setErrors({
description:
'A personal API token with that description already exists.',
});
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}
Expand Down Expand Up @@ -215,7 +272,7 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
<StyledInputDescription>
Token expiration date
</StyledInputDescription>
<StyledExpirationPicker>
<StyledExpirationPicker custom={customExpiration}>
<StyledSelectMenu
name="expiration"
id="expiration"
Expand All @@ -229,20 +286,61 @@ export const CreatePersonalAPIToken: FC<ICreatePersonalAPITokenProps> = ({
options={expirationOptions}
/>
<ConditionallyRender
condition={Boolean(expiresAt)}
condition={customExpiration}
show={() => (
<Typography variant="body2">
Token will expire on{' '}
<strong>
{formatDateYMD(
expiresAt!,
locationSettings.locale
)}
</strong>
</Typography>
<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>
}
/>
</div>

<StyledButtonContainer>
Expand Down
Expand Up @@ -17,6 +17,7 @@ import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
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 { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
import { useSearch } from 'hooks/useSearch';
Expand Down Expand Up @@ -116,7 +117,13 @@ export const PersonalAPITokensTab = ({ user }: IPersonalAPITokensTabProps) => {
{
Header: 'Expires',
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',
maxWidth: 150,
},
Expand Down

0 comments on commit d261097

Please sign in to comment.