Skip to content

Commit 29f54bf

Browse files
NightSkyHighNightSkyHighjuliajforesticubic-dev-ai[bot]
authored
fix(a11y): TOTP modal validation and error handling (#37049)
Co-authored-by: NightSkyHigh <thomas@Thomas.localdomain> Co-authored-by: juliajforesti <juliajforesti@gmail.com> Co-authored-by: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent 9498359 commit 29f54bf

9 files changed

Lines changed: 245 additions & 187 deletions

File tree

apps/meteor/client/components/TwoFactorModal/TwoFactorEmailModal.tsx

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { Box, FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError, Button } from '@rocket.chat/fuselage';
2-
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
1+
import { Box, Button } from '@rocket.chat/fuselage';
2+
import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms';
33
import { GenericModal } from '@rocket.chat/ui-client';
44
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
5-
import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react';
6-
import { useId, useState } from 'react';
5+
import type { ReactElement } from 'react';
6+
import { useForm, Controller } from 'react-hook-form';
77
import { useTranslation } from 'react-i18next';
88

99
import type { OnConfirm } from './TwoFactorModal';
@@ -13,14 +13,25 @@ type TwoFactorEmailModalProps = {
1313
onConfirm: OnConfirm;
1414
onClose: () => void;
1515
emailOrUsername: string;
16-
invalidAttempt?: boolean;
1716
};
1817

19-
const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttempt }: TwoFactorEmailModalProps): ReactElement => {
18+
type TwoFactorEmailFormData = {
19+
code: string;
20+
};
21+
22+
const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername }: TwoFactorEmailModalProps): ReactElement => {
2023
const dispatchToastMessage = useToastMessageDispatch();
2124
const { t } = useTranslation();
22-
const [code, setCode] = useState<string>('');
23-
const ref = useAutoFocus<HTMLInputElement>();
25+
26+
const {
27+
control,
28+
handleSubmit,
29+
setError,
30+
setValue,
31+
formState: { errors, isSubmitting },
32+
} = useForm<TwoFactorEmailFormData>({
33+
defaultValues: { code: '' },
34+
});
2435

2536
const sendEmailCode = useEndpoint('POST', '/v1/users.2fa.sendEmailCode');
2637

@@ -36,46 +47,51 @@ const TwoFactorEmailModal = ({ onConfirm, onClose, emailOrUsername, invalidAttem
3647
}
3748
};
3849

39-
const onConfirmEmailCode = (e: SyntheticEvent): void => {
40-
e.preventDefault();
41-
onConfirm(code, Method.EMAIL);
42-
};
43-
44-
const onChange = ({ currentTarget }: ChangeEvent<HTMLInputElement>): void => {
45-
setCode(currentTarget.value);
46-
};
47-
48-
const id = useId();
50+
const onSubmit = handleSubmit(async ({ code }) => {
51+
try {
52+
await onConfirm(code, Method.EMAIL);
53+
} catch (error) {
54+
setError('code', {
55+
type: 'manual',
56+
message: t('Invalid_two_factor_code'),
57+
});
58+
setValue('code', '');
59+
}
60+
});
4961

5062
return (
5163
<GenericModal
52-
wrapperFunction={(props) => <Box is='form' onSubmit={onConfirmEmailCode} {...props} />}
64+
wrapperFunction={(props) => <Box is='form' onSubmit={onSubmit} {...props} />}
5365
onCancel={onClose}
5466
confirmText={t('Verify')}
5567
title={t('Enter_authentication_code')}
5668
onClose={onClose}
5769
variant='warning'
58-
confirmDisabled={!code}
70+
confirmDisabled={isSubmitting}
5971
tagline={t('Email_two-factor_authentication')}
6072
icon={null}
6173
>
6274
<FieldGroup>
6375
<Field>
64-
<FieldLabel alignSelf='stretch' htmlFor={id}>
65-
{t('Enter_the_code_we_just_emailed_you')}
66-
</FieldLabel>
76+
<FieldLabel alignSelf='stretch'>{t('Enter_the_code_we_just_emailed_you')}</FieldLabel>
6777
<FieldRow>
68-
<TextInput
69-
id={id}
70-
ref={ref}
71-
value={code}
72-
onChange={onChange}
73-
placeholder={t('Enter_code_here')}
74-
autoComplete='one-time-code'
75-
inputMode='numeric'
78+
<Controller
79+
name='code'
80+
control={control}
81+
rules={{ required: t('Required_field', { field: t('Code') }) }}
82+
render={({ field }) => (
83+
<TextInput
84+
{...field}
85+
placeholder={t('Enter_code_here')}
86+
autoComplete='one-time-code'
87+
inputMode='numeric'
88+
disabled={isSubmitting}
89+
error={errors.code?.message}
90+
/>
91+
)}
7692
/>
7793
</FieldRow>
78-
{invalidAttempt && <FieldError>{t('Invalid_password')}</FieldError>}
94+
{errors.code && <FieldError>{errors.code.message}</FieldError>}
7995
</Field>
8096
</FieldGroup>
8197
<Button display='flex' justifyContent='end' onClick={onClickResendCode} small mbs={24}>

apps/meteor/client/components/TwoFactorModal/TwoFactorModal.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ export enum Method {
1010
PASSWORD = 'password',
1111
}
1212

13-
export type OnConfirm = (code: string, method: Method) => void;
13+
export type OnConfirm = (code: string, method: Method) => void | Promise<void>;
1414

1515
type TwoFactorModalProps = {
1616
onConfirm: OnConfirm;
1717
onClose: () => void;
18-
invalidAttempt?: boolean;
1918
} & (
2019
| {
2120
method: 'totp' | 'password';
@@ -26,19 +25,19 @@ type TwoFactorModalProps = {
2625
}
2726
);
2827

29-
const TwoFactorModal = ({ onConfirm, onClose, invalidAttempt, ...props }: TwoFactorModalProps): ReactElement => {
28+
const TwoFactorModal = ({ onConfirm, onClose, ...props }: TwoFactorModalProps): ReactElement => {
3029
if (props.method === Method.TOTP) {
31-
return <TwoFactorTotp onConfirm={onConfirm} onClose={onClose} invalidAttempt={invalidAttempt} />;
30+
return <TwoFactorTotp onConfirm={onConfirm} onClose={onClose} />;
3231
}
3332

3433
if (props.method === Method.EMAIL) {
3534
const { emailOrUsername } = props;
3635

37-
return <TwoFactorEmail onConfirm={onConfirm} onClose={onClose} emailOrUsername={emailOrUsername} invalidAttempt={invalidAttempt} />;
36+
return <TwoFactorEmail onConfirm={onConfirm} onClose={onClose} emailOrUsername={emailOrUsername} />;
3837
}
3938

4039
if (props.method === Method.PASSWORD) {
41-
return <TwoFactorPassword onConfirm={onConfirm} onClose={onClose} invalidAttempt={invalidAttempt} />;
40+
return <TwoFactorPassword onConfirm={onConfirm} onClose={onClose} />;
4241
}
4342

4443
throw new Error('Invalid Two Factor method');

apps/meteor/client/components/TwoFactorModal/TwoFactorPasswordModal.tsx

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Box, PasswordInput, FieldGroup, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage';
2-
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
1+
import { Box } from '@rocket.chat/fuselage';
2+
import { PasswordInput, FieldGroup, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms';
33
import { GenericModal } from '@rocket.chat/ui-client';
4-
import type { ReactElement, ChangeEvent, Ref, SyntheticEvent } from 'react';
5-
import { useId, useState } from 'react';
4+
import type { ReactElement } from 'react';
5+
import { useForm, Controller } from 'react-hook-form';
66
import { useTranslation } from 'react-i18next';
77

88
import type { OnConfirm } from './TwoFactorModal';
@@ -11,51 +11,62 @@ import { Method } from './TwoFactorModal';
1111
type TwoFactorPasswordModalProps = {
1212
onConfirm: OnConfirm;
1313
onClose: () => void;
14-
invalidAttempt?: boolean;
1514
};
1615

17-
const TwoFactorPasswordModal = ({ onConfirm, onClose, invalidAttempt }: TwoFactorPasswordModalProps): ReactElement => {
18-
const { t } = useTranslation();
19-
const [code, setCode] = useState<string>('');
20-
const ref = useAutoFocus();
16+
type TwoFactorPasswordFormData = {
17+
password: string;
18+
};
2119

22-
const onConfirmTotpCode = (e: SyntheticEvent): void => {
23-
e.preventDefault();
24-
onConfirm(code, Method.PASSWORD);
25-
};
20+
const TwoFactorPasswordModal = ({ onConfirm, onClose }: TwoFactorPasswordModalProps): ReactElement => {
21+
const { t } = useTranslation();
2622

27-
const onChange = ({ currentTarget }: ChangeEvent<HTMLInputElement>): void => {
28-
setCode(currentTarget.value);
29-
};
23+
const {
24+
control,
25+
handleSubmit,
26+
setError,
27+
setValue,
28+
formState: { errors, isSubmitting },
29+
} = useForm<TwoFactorPasswordFormData>({
30+
defaultValues: { password: '' },
31+
});
3032

31-
const id = useId();
33+
const onSubmit = handleSubmit(async ({ password }) => {
34+
try {
35+
await onConfirm(password, Method.PASSWORD);
36+
} catch (error) {
37+
setError('password', {
38+
type: 'manual',
39+
message: t('Invalid_password'),
40+
});
41+
setValue('password', '');
42+
}
43+
});
3244

3345
return (
3446
<GenericModal
35-
wrapperFunction={(props) => <Box is='form' onSubmit={onConfirmTotpCode} {...props} />}
47+
wrapperFunction={(props) => <Box is='form' onSubmit={onSubmit} {...props} />}
3648
onCancel={onClose}
3749
confirmText={t('Verify')}
3850
title={t('Please_enter_your_password')}
3951
onClose={onClose}
4052
variant='warning'
4153
icon='info'
42-
confirmDisabled={!code}
54+
confirmDisabled={isSubmitting}
4355
>
4456
<FieldGroup>
4557
<Field>
46-
<FieldLabel alignSelf='stretch' htmlFor={id}>
47-
{t('For_your_security_you_must_enter_your_current_password_to_continue')}
48-
</FieldLabel>
58+
<FieldLabel alignSelf='stretch'>{t('For_your_security_you_must_enter_your_current_password_to_continue')}</FieldLabel>
4959
<FieldRow>
50-
<PasswordInput
51-
id={id}
52-
ref={ref as Ref<HTMLInputElement>}
53-
value={code}
54-
onChange={onChange}
55-
placeholder={t('Password')}
56-
></PasswordInput>
60+
<Controller
61+
name='password'
62+
control={control}
63+
rules={{ required: t('Required_field', { field: t('Password') }) }}
64+
render={({ field }) => (
65+
<PasswordInput {...field} placeholder={t('Password')} disabled={isSubmitting} error={errors.password?.message} />
66+
)}
67+
/>
5768
</FieldRow>
58-
{invalidAttempt && <FieldError>{t('Invalid_password')}</FieldError>}
69+
{errors.password && <FieldError>{errors.password.message}</FieldError>}
5970
</Field>
6071
</FieldGroup>
6172
</GenericModal>

apps/meteor/client/components/TwoFactorModal/TwoFactorTotpModal.tsx

Lines changed: 47 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { Box, TextInput, Field, FieldGroup, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage';
2-
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
1+
import { Box } from '@rocket.chat/fuselage';
2+
import { FieldGroup, TextInput, Field, FieldLabel, FieldRow, FieldError } from '@rocket.chat/fuselage-forms';
33
import { GenericModal } from '@rocket.chat/ui-client';
4-
import type { ReactElement, ChangeEvent, SyntheticEvent } from 'react';
5-
import { useId, useState } from 'react';
4+
import type { ReactElement } from 'react';
5+
import { useForm, Controller } from 'react-hook-form';
66
import { useTranslation } from 'react-i18next';
77

88
import type { OnConfirm } from './TwoFactorModal';
@@ -12,54 +12,71 @@ type TwoFactorTotpModalProps = {
1212
onConfirm: OnConfirm;
1313
onClose: () => void;
1414
onDismiss?: () => void;
15-
invalidAttempt?: boolean;
1615
};
1716

18-
const TwoFactorTotpModal = ({ onConfirm, onClose, onDismiss, invalidAttempt }: TwoFactorTotpModalProps): ReactElement => {
17+
type TwoFactorTotpFormData = {
18+
code: string;
19+
};
20+
21+
const TwoFactorTotpModal = ({ onConfirm, onClose, onDismiss }: TwoFactorTotpModalProps): ReactElement => {
1922
const { t } = useTranslation();
20-
const [code, setCode] = useState<string>('');
21-
const ref = useAutoFocus<HTMLInputElement>();
2223

23-
const onConfirmTotpCode = (e: SyntheticEvent): void => {
24-
e.preventDefault();
25-
onConfirm(code, Method.TOTP);
26-
};
24+
const {
25+
control,
26+
handleSubmit,
27+
setError,
28+
setValue,
29+
formState: { errors, isSubmitting },
30+
} = useForm<TwoFactorTotpFormData>({
31+
defaultValues: { code: '' },
32+
});
2733

28-
const onChange = ({ currentTarget }: ChangeEvent<HTMLInputElement>): void => {
29-
setCode(currentTarget.value);
30-
};
34+
const onSubmit = handleSubmit(async ({ code }) => {
35+
try {
36+
await onConfirm(code, Method.TOTP);
37+
} catch (error) {
38+
setError('code', {
39+
type: 'manual',
40+
message: t('Invalid_two_factor_code'),
41+
});
42+
setValue('code', '');
43+
}
44+
});
3145

32-
const id = useId();
3346
return (
3447
<GenericModal
35-
wrapperFunction={(props) => <Box is='form' onSubmit={onConfirmTotpCode} {...props} />}
48+
wrapperFunction={(props) => <Box is='form' onSubmit={onSubmit} {...props} />}
3649
onCancel={onClose}
3750
confirmText={t('Verify')}
3851
title={t('Enter_TOTP_password')}
3952
onClose={onClose}
4053
onDismiss={onDismiss}
4154
variant='warning'
42-
confirmDisabled={!code}
55+
confirmDisabled={isSubmitting}
4356
tagline={t('Two-factor_authentication')}
4457
icon={null}
4558
>
4659
<FieldGroup>
4760
<Field>
48-
<FieldLabel alignSelf='stretch' htmlFor={id}>
49-
{t('Enter_the_code_provided_by_your_authentication_app_to_continue')}
50-
</FieldLabel>
61+
<FieldLabel alignSelf='stretch'>{t('Enter_the_code_provided_by_your_authentication_app_to_continue')}</FieldLabel>
5162
<FieldRow>
52-
<TextInput
53-
id={id}
54-
ref={ref}
55-
value={code}
56-
onChange={onChange}
57-
placeholder={t('Enter_code_here')}
58-
autoComplete='one-time-code'
59-
inputMode='numeric'
63+
<Controller
64+
name='code'
65+
control={control}
66+
rules={{ required: t('Required_field', { field: t('Code') }) }}
67+
render={({ field }) => (
68+
<TextInput
69+
{...field}
70+
placeholder={t('Enter_code_here')}
71+
autoComplete='one-time-code'
72+
inputMode='numeric'
73+
disabled={isSubmitting}
74+
error={errors.code?.message}
75+
/>
76+
)}
6077
/>
6178
</FieldRow>
62-
{invalidAttempt && <FieldError>{t('Invalid_password')}</FieldError>}
79+
{errors.code && <FieldError>{errors.code.message}</FieldError>}
6380
</Field>
6481
</FieldGroup>
6582
</GenericModal>

0 commit comments

Comments
 (0)