From d261097151e4e8e0c988f2fac9ac9922e75ac374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 18 Oct 2022 12:22:28 +0100 Subject: [PATCH] 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 --- .../common/DateTimePicker/DateTimePicker.tsx | 65 +++++++ .../CreatePersonalAPIToken.tsx | 160 ++++++++++++++---- .../PersonalAPITokensTab.tsx | 9 +- 3 files changed, 202 insertions(+), 32 deletions(-) create mode 100644 frontend/src/component/common/DateTimePicker/DateTimePicker.tsx diff --git a/frontend/src/component/common/DateTimePicker/DateTimePicker.tsx b/frontend/src/component/common/DateTimePicker/DateTimePicker.tsx new file mode 100644 index 0000000000..92330d4f78 --- /dev/null +++ b/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 { + 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 ( + { + 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} + /> + ); +}; diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx index 64b6cd1b93..b4d12f0df9 100644 --- a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/CreatePersonalAPIToken.tsx @@ -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'; @@ -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', @@ -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), - [theme.breakpoints.down('sm')]: { - flexDirection: 'column', - alignItems: 'flex-start', - }, -})); +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 }) => ({ @@ -62,6 +82,8 @@ enum ExpirationOption { '7DAYS' = '7d', '30DAYS' = '30d', '60DAYS' = '60d', + NEVER = 'never', + CUSTOM = 'custom', } const expirationOptions = [ @@ -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>; @@ -103,10 +143,14 @@ export const CreatePersonalAPIToken: FC = ({ const [expiration, setExpiration] = useState( ExpirationOption['30DAYS'] ); - const [errors, setErrors] = useState<{ [key: string]: string }>({}); + const [errors, setErrors] = useState({}); - 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 = () => { @@ -114,7 +158,11 @@ export const CreatePersonalAPIToken: FC = ({ 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; @@ -124,10 +172,12 @@ export const CreatePersonalAPIToken: FC = ({ useEffect(() => { setDescription(''); + setErrors({}); setExpiration(ExpirationOption['30DAYS']); }, [open]); useEffect(() => { + clearError(ErrorField.EXPIRES_AT); setExpiresAt(calculateDate()); }, [expiration]); @@ -166,19 +216,26 @@ export const CreatePersonalAPIToken: FC = ({ 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 ( = ({ Token expiration date - + = ({ options={expirationOptions} /> ( - - Token will expire on{' '} - - {formatDateYMD( - expiresAt!, - locationSettings.locale - )} - - + { + 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={ + + The token will{' '} + never expire! + + } + elseShow={() => ( + + Token will expire on{' '} + + {formatDateYMD( + expiresAt!, + locationSettings.locale + )} + + + )} + /> + } /> + + We strongly recommend that you set an + expiration date for your token to help keep + your information secure. + + } + /> diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx index 0d735c40ff..21f93a7268 100644 --- a/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx +++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/PersonalAPITokensTab.tsx @@ -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'; @@ -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 Never; + } + return ; + }, sortType: 'date', maxWidth: 150, },