Remove payments API routing initialization
[ProtonMail-WebClient.git] / packages / components / containers / password / SrpAuthModal.tsx
blob1c76b143b5fbb5c0e342ae4f972b3e51206da801
1 import type { MutableRefObject } from 'react';
2 import { useEffect, useRef, useState } from 'react';
3 import { flushSync } from 'react-dom';
5 import { c } from 'ttag';
7 import { useUser } from '@proton/account/user/hooks';
8 import { useUserSettings } from '@proton/account/userSettings/hooks';
9 import { Button, InlineLinkButton } from '@proton/atoms';
10 import Form from '@proton/components/components/form/Form';
11 import type { ModalProps } from '@proton/components/components/modalTwo/Modal';
12 import Modal from '@proton/components/components/modalTwo/Modal';
13 import ModalContent from '@proton/components/components/modalTwo/ModalContent';
14 import ModalFooter from '@proton/components/components/modalTwo/ModalFooter';
15 import ModalHeader from '@proton/components/components/modalTwo/ModalHeader';
16 import Tabs from '@proton/components/components/tabs/Tabs';
17 import InputFieldTwo from '@proton/components/components/v2/field/InputField';
18 import PasswordInputTwo from '@proton/components/components/v2/input/PasswordInput';
19 import useFormErrors from '@proton/components/components/v2/useFormErrors';
20 import AuthSecurityKeyContent from '@proton/components/containers/account/fido/AuthSecurityKeyContent';
21 import useApi from '@proton/components/hooks/useApi';
22 import useConfig from '@proton/components/hooks/useConfig';
23 import useErrorHandler from '@proton/components/hooks/useErrorHandler';
24 import { useLoading } from '@proton/hooks';
25 import { PASSWORD_WRONG_ERROR, getInfo } from '@proton/shared/lib/api/auth';
26 import { getApiError } from '@proton/shared/lib/api/helpers/apiErrorHelper';
27 import type { Fido2Data, InfoAuthedResponse } from '@proton/shared/lib/authentication/interface';
28 import { requiredValidator } from '@proton/shared/lib/helpers/formValidators';
29 import { captureMessage } from '@proton/shared/lib/helpers/sentry';
30 import type { Unwrap } from '@proton/shared/lib/interfaces';
31 import { srpAuth } from '@proton/shared/lib/srp';
32 import { getAuthentication } from '@proton/shared/lib/webauthn/get';
33 import isTruthy from '@proton/utils/isTruthy';
34 import noop from '@proton/utils/noop';
36 import TotpInputs from '../account/totp/TotpInputs';
37 import { getAuthTypes } from './getAuthTypes';
38 import type { OwnAuthModalProps, SrpAuthModalResult } from './interface';
40 const FORM_ID = 'auth-form';
42 const TOTPForm = ({
43     onSubmit,
44     loading,
45     hasBeenAutoSubmitted,
46     defaultType,
47 }: {
48     onSubmit: (value: string) => void;
49     loading?: boolean;
50     hasBeenAutoSubmitted: MutableRefObject<boolean>;
51     defaultType: 'totp' | 'recovery-code';
52 }) => {
53     const { validator, onFormSubmit, reset } = useFormErrors();
54     const [code, setCode] = useState('');
55     const [type, setType] = useState(defaultType);
57     const safeCode = code.replaceAll(/\s+/g, '');
58     const requiredError = requiredValidator(safeCode);
60     useEffect(() => {
61         if (type !== 'totp' || loading || requiredError || hasBeenAutoSubmitted.current) {
62             return;
63         }
64         // Auto-submit the form once the user has entered the TOTP
65         if (safeCode.length === 6) {
66             // Do it just one time
67             hasBeenAutoSubmitted.current = true;
68             onSubmit(safeCode);
69         }
70     }, [safeCode]);
72     return (
73         <Form
74             id={FORM_ID}
75             onSubmit={(event) => {
76                 if (!onFormSubmit(event.currentTarget) || loading) {
77                     return;
78                 }
79                 onSubmit(safeCode);
80             }}
81         >
82             <TotpInputs
83                 type={type}
84                 code={code}
85                 error={validator([requiredError])}
86                 loading={loading}
87                 setCode={setCode}
88             />
89             <div className="mt-4">
90                 <InlineLinkButton
91                     type="button"
92                     onClick={() => {
93                         reset();
94                         setCode('');
95                         setType(type === 'totp' ? 'recovery-code' : 'totp');
96                     }}
97                 >
98                     {type === 'totp' ? c('Action').t`Use recovery code` : c('Action').t`Use authentication code`}
99                 </InlineLinkButton>
100             </div>
101         </Form>
102     );
105 const PasswordForm = ({
106     defaultPassword,
107     onSubmit,
108     loading,
109     isSignedInAsAdmin,
110 }: {
111     isSignedInAsAdmin: boolean;
112     defaultPassword: string;
113     onSubmit: (password: string) => void;
114     loading: boolean;
115 }) => {
116     const { validator, onFormSubmit } = useFormErrors();
117     const [password, setPassword] = useState(defaultPassword);
118     return (
119         <Form
120             id={FORM_ID}
121             onSubmit={(event) => {
122                 if (!onFormSubmit(event.currentTarget) || loading) {
123                     return;
124                 }
125                 onSubmit(password);
126             }}
127         >
128             {isSignedInAsAdmin && (
129                 <div className="mb-4">{c('Info').t`Enter your own password (as organization admin).`}</div>
130             )}
131             <InputFieldTwo
132                 autoFocus
133                 autoComplete="current-password"
134                 id="password"
135                 as={PasswordInputTwo}
136                 value={password}
137                 disableChange={loading}
138                 onValue={setPassword}
139                 error={validator([requiredValidator(password)])}
140                 label={isSignedInAsAdmin ? c('Label').t`Your password (admin)` : c('Label').t`Password`}
141                 placeholder={c('Placeholder').t`Password`}
142             />
143         </Form>
144     );
147 type TwoFactorData = { type: 'code'; payload: string } | { type: 'fido2'; payload: Promise<Fido2Data> };
149 const getTwoFaCredentials = async (
150     twoFa: TwoFactorData | undefined
151 ): Promise<{ totp: string } | { fido2: Fido2Data } | undefined> => {
152     if (twoFa?.type === 'code') {
153         return {
154             totp: twoFa.payload,
155         } as const;
156     }
157     if (twoFa?.type === 'fido2') {
158         return {
159             fido2: await twoFa.payload,
160         } as const;
161     }
164 enum Step {
165     Password,
166     TWO_FA,
169 export interface SrpAuthModalProps
170     extends Omit<OwnAuthModalProps, 'onSuccess'>,
171         Omit<ModalProps<'div'>, 'as' | 'onSubmit' | 'size' | 'onSuccess' | 'onError'> {
172     onSuccess?: (data: SrpAuthModalResult) => Promise<void> | void;
175 const SrpAuthModal = ({
176     config,
177     onSuccess,
178     onError,
179     onClose,
180     onCancel,
181     prioritised2FAItem = 'fido2',
182     onRecoveryClick,
183     scope,
184     ...rest
185 }: SrpAuthModalProps) => {
186     const { APP_NAME } = useConfig();
187     const api = useApi();
188     const [user] = useUser();
189     const [userSettings] = useUserSettings();
190     const [step, setStep] = useState(Step.Password);
191     const [submitting, withSubmitting] = useLoading();
192     const hasBeenAutoSubmitted = useRef(false);
193     const errorHandler = useErrorHandler();
194     const [fidoError, setFidoError] = useState(false);
195     const infoResultRef = useRef<{
196         data?: { infoResult?: InfoAuthedResponse; authTypes: ReturnType<typeof getAuthTypes> };
197     }>({});
199     const [password, setPassword] = useState('');
200     const [rerender, setRerender] = useState(0);
201     const [tabIndex, setTabIndex] = useState(0);
203     const cancelClose = () => {
204         onCancel?.();
205         onClose?.();
206     };
208     const handleSubmit = async ({
209         step,
210         password,
211         twoFa,
212     }: {
213         step: Step;
214         password: string;
215         twoFa: TwoFactorData | undefined;
216     }) => {
217         if (submitting) {
218             return;
219         }
221         const infoResult = await api<InfoAuthedResponse>(getInfo({ intent: 'Proton' }));
223         const authTypes = getAuthTypes({ scope, infoResult, userSettings, app: APP_NAME });
225         infoResultRef.current.data = { infoResult, authTypes };
227         if (step === Step.Password && authTypes.twoFactor) {
228             setPassword(password);
229             setStep(Step.TWO_FA);
230             return;
231         }
233         let twoFaCredentials: Unwrap<ReturnType<typeof getTwoFaCredentials>>;
234         try {
235             setFidoError(false);
236             twoFaCredentials = await getTwoFaCredentials(twoFa);
237         } catch (error) {
238             if (twoFa?.type === 'fido2') {
239                 setFidoError(true);
240                 captureMessage('Security key auth', { level: 'error', extra: { error } });
241                 // Purposefully logging the error for somewhat easier debugging
242                 console.error(error);
243             }
244             return;
245         }
246         try {
247             const credentials = {
248                 password,
249                 ...twoFaCredentials,
250             };
252             const response = await srpAuth({
253                 api,
254                 info: infoResult,
255                 credentials,
256                 config: {
257                     ...config,
258                     silence: true,
259                 },
260             });
261             // We want to just keep the modal open until the consumer's promise is finished. Not interested in errors.
262             await onSuccess?.({ type: 'srp', credentials, response })?.catch(noop);
263             onClose?.();
264         } catch (error: any) {
265             errorHandler(error);
267             const { code } = getApiError(error);
268             // Try again
269             if (code === PASSWORD_WRONG_ERROR) {
270                 flushSync(() => {
271                     setFidoError(false);
272                     setPassword('');
273                     setStep(Step.Password);
274                     // Rerender the password form to trigger autofocus and form validation reset
275                     setRerender((old) => ++old);
276                 });
277                 return;
278             }
280             onError?.(error);
281             cancelClose();
282         }
283     };
285     const loading = submitting;
287     // Don't allow to close this modal if it's loading as it could leave other consumers in an undefined state
288     const handleClose = loading ? noop : cancelClose;
290     const infoResult = infoResultRef.current.data?.infoResult;
291     const authTypes = infoResultRef.current.data?.authTypes;
292     const fido2 = infoResult?.['2FA']?.FIDO2;
293     // This is optimistically determining if we should show "Continue" or "Authenticate" since we don't have the /info result yet
294     // by looking at user settings.
295     // NOTE: This will give wrong values for admins signed in as sub-users.
296     const optimisticTwoFactorEnabled = authTypes ? authTypes.twoFactor : Boolean(userSettings?.['2FA']?.Enabled);
298     return (
299         <Modal {...rest} size="small" onClose={handleClose}>
300             <ModalHeader
301                 title={step === Step.TWO_FA ? c('Title').t`Enter 2FA code` : c('Title').t`Enter your password`}
302             />
303             <ModalContent>
304                 {step === Step.Password && (
305                     <>
306                         <PasswordForm
307                             key={`${rerender}`}
308                             isSignedInAsAdmin={user?.isSubUser}
309                             defaultPassword={password}
310                             onSubmit={(password) => {
311                                 return withSubmitting(handleSubmit({ step, password, twoFa: undefined }));
312                             }}
313                             loading={submitting}
314                         />
316                         {onRecoveryClick && (
317                             <Button shape="underline" color="norm" onClick={onRecoveryClick}>
318                                 {c('Action').t`Forgot password?`}
319                             </Button>
320                         )}
321                     </>
322                 )}
323                 {(() => {
324                     if (step !== Step.TWO_FA) {
325                         return null;
326                     }
328                     const fido2Tab = authTypes?.fido2 &&
329                         fido2 && {
330                             title: c('fido2: Label').t`Security key`,
331                             content: (
332                                 <Form
333                                     id={FORM_ID}
334                                     onSubmit={() => {
335                                         withSubmitting(
336                                             handleSubmit({
337                                                 step,
338                                                 password,
339                                                 twoFa: {
340                                                     type: 'fido2',
341                                                     payload: getAuthentication(fido2.AuthenticationOptions),
342                                                 },
343                                             })
344                                         ).catch(noop);
345                                     }}
346                                 >
347                                     <AuthSecurityKeyContent error={fidoError} />
348                                 </Form>
349                             ),
350                         };
352                     const totpTab = authTypes?.totp && {
353                         title: c('Label').t`Authenticator app`,
354                         content: (
355                             <TOTPForm
356                                 defaultType="totp"
357                                 hasBeenAutoSubmitted={hasBeenAutoSubmitted}
358                                 loading={submitting}
359                                 onSubmit={(payload) =>
360                                     withSubmitting(
361                                         handleSubmit({
362                                             step,
363                                             password,
364                                             twoFa: { type: 'code', payload },
365                                         })
366                                     )
367                                 }
368                             />
369                         ),
370                     };
372                     const tabs = (() => {
373                         if (prioritised2FAItem === 'totp') {
374                             return [totpTab, fido2Tab];
375                         }
377                         return [fido2Tab, totpTab];
378                     })().filter(isTruthy);
380                     return (
381                         <Tabs
382                             fullWidth
383                             value={tabIndex}
384                             onChange={(index) => {
385                                 setTabIndex(index);
386                                 setFidoError(false);
387                             }}
388                             tabs={tabs}
389                         />
390                     );
391                 })()}
392             </ModalContent>
393             <ModalFooter>
394                 <Button onClick={handleClose}>{c('Action').t`Cancel`}</Button>
395                 <Button color="norm" type="submit" form={FORM_ID} loading={submitting}>
396                     {step === Step.Password && optimisticTwoFactorEnabled
397                         ? c('Action').t`Continue`
398                         : c('Action').t`Authenticate`}
399                 </Button>
400             </ModalFooter>
401         </Modal>
402     );
405 export default SrpAuthModal;