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';
48 onSubmit: (value: string) => void;
50 hasBeenAutoSubmitted: MutableRefObject<boolean>;
51 defaultType: 'totp' | 'recovery-code';
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);
61 if (type !== 'totp' || loading || requiredError || hasBeenAutoSubmitted.current) {
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;
75 onSubmit={(event) => {
76 if (!onFormSubmit(event.currentTarget) || loading) {
85 error={validator([requiredError])}
89 <div className="mt-4">
95 setType(type === 'totp' ? 'recovery-code' : 'totp');
98 {type === 'totp' ? c('Action').t`Use recovery code` : c('Action').t`Use authentication code`}
105 const PasswordForm = ({
111 isSignedInAsAdmin: boolean;
112 defaultPassword: string;
113 onSubmit: (password: string) => void;
116 const { validator, onFormSubmit } = useFormErrors();
117 const [password, setPassword] = useState(defaultPassword);
121 onSubmit={(event) => {
122 if (!onFormSubmit(event.currentTarget) || loading) {
128 {isSignedInAsAdmin && (
129 <div className="mb-4">{c('Info').t`Enter your own password (as organization admin).`}</div>
133 autoComplete="current-password"
135 as={PasswordInputTwo}
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`}
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') {
157 if (twoFa?.type === 'fido2') {
159 fido2: await twoFa.payload,
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 = ({
181 prioritised2FAItem = 'fido2',
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> };
199 const [password, setPassword] = useState('');
200 const [rerender, setRerender] = useState(0);
201 const [tabIndex, setTabIndex] = useState(0);
203 const cancelClose = () => {
208 const handleSubmit = async ({
215 twoFa: TwoFactorData | undefined;
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);
233 let twoFaCredentials: Unwrap<ReturnType<typeof getTwoFaCredentials>>;
236 twoFaCredentials = await getTwoFaCredentials(twoFa);
238 if (twoFa?.type === 'fido2') {
240 captureMessage('Security key auth', { level: 'error', extra: { error } });
241 // Purposefully logging the error for somewhat easier debugging
242 console.error(error);
247 const credentials = {
252 const response = await srpAuth({
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);
264 } catch (error: any) {
267 const { code } = getApiError(error);
269 if (code === PASSWORD_WRONG_ERROR) {
273 setStep(Step.Password);
274 // Rerender the password form to trigger autofocus and form validation reset
275 setRerender((old) => ++old);
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);
299 <Modal {...rest} size="small" onClose={handleClose}>
301 title={step === Step.TWO_FA ? c('Title').t`Enter 2FA code` : c('Title').t`Enter your password`}
304 {step === Step.Password && (
308 isSignedInAsAdmin={user?.isSubUser}
309 defaultPassword={password}
310 onSubmit={(password) => {
311 return withSubmitting(handleSubmit({ step, password, twoFa: undefined }));
316 {onRecoveryClick && (
317 <Button shape="underline" color="norm" onClick={onRecoveryClick}>
318 {c('Action').t`Forgot password?`}
324 if (step !== Step.TWO_FA) {
328 const fido2Tab = authTypes?.fido2 &&
330 title: c('fido2: Label').t`Security key`,
341 payload: getAuthentication(fido2.AuthenticationOptions),
347 <AuthSecurityKeyContent error={fidoError} />
352 const totpTab = authTypes?.totp && {
353 title: c('Label').t`Authenticator app`,
357 hasBeenAutoSubmitted={hasBeenAutoSubmitted}
359 onSubmit={(payload) =>
364 twoFa: { type: 'code', payload },
372 const tabs = (() => {
373 if (prioritised2FAItem === 'totp') {
374 return [totpTab, fido2Tab];
377 return [fido2Tab, totpTab];
378 })().filter(isTruthy);
384 onChange={(index) => {
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`}
405 export default SrpAuthModal;