Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / components / containers / login / MinimalLoginContainer.tsx
blob7ca8834ba7937a99f62a2a5ee18280ca1bb99338
1 import type { ReactNode } from 'react';
2 import { useEffect, useRef, useState } from 'react';
4 import { c } from 'ttag';
6 import { Button, CircleLoader } from '@proton/atoms';
7 import InputFieldTwo from '@proton/components/components/v2/field/InputField';
8 import PasswordInputTwo from '@proton/components/components/v2/input/PasswordInput';
9 import useFormErrors from '@proton/components/components/v2/useFormErrors';
10 import TotpInputs from '@proton/components/containers/account/totp/TotpInputs';
11 import { startUnAuthFlow } from '@proton/components/containers/api/unAuthenticatedApi';
12 import useApi from '@proton/components/hooks/useApi';
13 import useConfig from '@proton/components/hooks/useConfig';
14 import useErrorHandler from '@proton/components/hooks/useErrorHandler';
15 import useNotifications from '@proton/components/hooks/useNotifications';
16 import { useLoading } from '@proton/hooks';
17 import { getApiErrorMessage } from '@proton/shared/lib/api/helpers/apiErrorHelper';
18 import { API_CUSTOM_ERROR_CODES } from '@proton/shared/lib/errors';
19 import { requiredValidator } from '@proton/shared/lib/helpers/formValidators';
20 import { KeyTransparencyActivation } from '@proton/shared/lib/interfaces';
21 import noop from '@proton/utils/noop';
23 import type { OnLoginCallback } from '../app/interface';
24 import Challenge from '../challenge/Challenge';
25 import ChallengeError from '../challenge/ChallengeError';
26 import type { ChallengeRef, ChallengeResult } from '../challenge/interface';
27 import AbuseModal from './AbuseModal';
28 import type { AuthActionResponse, AuthCacheResult } from './interface';
29 import { AuthStep, AuthType } from './interface';
30 import { handleLogin, handleNextLogin, handleTotp, handleUnlock } from './loginActions';
32 const UnlockForm = ({
33     onSubmit,
34     cancelButton,
35 }: {
36     onSubmit: (totp: string) => Promise<void>;
37     cancelButton?: ReactNode;
38 }) => {
39     const [loading, withLoading] = useLoading();
40     const [keyPassword, setKeyPassword] = useState('');
42     const { validator, onFormSubmit } = useFormErrors();
44     return (
45         <form
46             name="unlockForm"
47             onSubmit={(event) => {
48                 event.preventDefault();
49                 if (!onFormSubmit()) {
50                     return;
51                 }
52                 withLoading(onSubmit(keyPassword)).catch(noop);
53             }}
54             method="post"
55         >
56             <InputFieldTwo
57                 as={PasswordInputTwo}
58                 id="mailboxPassword"
59                 bigger
60                 label={c('Label').t`Second password`}
61                 error={validator([requiredValidator(keyPassword)])}
62                 disableChange={loading}
63                 autoFocus
64                 value={keyPassword}
65                 onValue={setKeyPassword}
66             />
67             <div className="flex justify-space-between">
68                 <Button
69                     color="norm"
70                     size="large"
71                     fullWidth
72                     type="submit"
73                     loading={loading}
74                     data-cy-login="submit mailbox password"
75                 >
76                     {c('Action').t`Submit`}
77                 </Button>
78                 {cancelButton}
79             </div>
80         </form>
81     );
84 const TOTPForm = ({
85     onSubmit,
86     cancelButton,
87 }: {
88     onSubmit: (totp: string) => Promise<void>;
89     cancelButton?: ReactNode;
90 }) => {
91     const [loading, withLoading] = useLoading();
93     const [code, setCode] = useState('');
94     const [type, setType] = useState<'totp' | 'recovery-code'>('totp');
95     const hasBeenAutoSubmitted = useRef(false);
97     const { validator, onFormSubmit, reset } = useFormErrors();
99     const safeCode = code.replaceAll(/\s+/g, '');
100     const requiredError = requiredValidator(safeCode);
102     useEffect(() => {
103         if (type !== 'totp' || loading || requiredError || hasBeenAutoSubmitted.current) {
104             return;
105         }
106         // Auto-submit the form once the user has entered the TOTP
107         if (safeCode.length === 6) {
108             // Do it just one time
109             hasBeenAutoSubmitted.current = true;
110             withLoading(onSubmit(safeCode)).catch(noop);
111         }
112     }, [safeCode]);
114     return (
115         <form
116             name="totpForm"
117             onSubmit={(event) => {
118                 event.preventDefault();
119                 if (!onFormSubmit()) {
120                     return;
121                 }
122                 withLoading(onSubmit(safeCode)).catch(noop);
123             }}
124             method="post"
125         >
126             <TotpInputs
127                 type={type}
128                 code={code}
129                 error={validator([requiredError])}
130                 loading={loading}
131                 setCode={setCode}
132                 bigger={true}
133             />
134             <div className="flex justify-space-between">
135                 <Button size="large" fullWidth color="norm" type="submit" loading={loading}>
136                     {c('Action').t`Submit`}
137                 </Button>
138                 <Button
139                     color="norm"
140                     shape="ghost"
141                     size="large"
142                     fullWidth
143                     className="mt-2"
144                     onClick={() => {
145                         if (loading) {
146                             return;
147                         }
148                         reset();
149                         setCode('');
150                         setType(type === 'totp' ? 'recovery-code' : 'totp');
151                     }}
152                 >
153                     {type === 'totp' ? c('Action').t`Use recovery code` : c('Action').t`Use authentication code`}
154                 </Button>
155                 {cancelButton}
156             </div>
157         </form>
158     );
161 const LoginForm = ({
162     onSubmit,
163     hasChallenge,
164     needHelp,
165     footer,
166 }: {
167     onSubmit: (username: string, password: string, payload: ChallengeResult) => Promise<void>;
168     hasChallenge?: boolean;
169     needHelp?: ReactNode;
170     footer?: ReactNode;
171 }) => {
172     const [loading, withLoading] = useLoading();
173     const [username, setUsername] = useState('');
174     const [password, setPassword] = useState('');
175     const challengeRefLogin = useRef<ChallengeRef>();
176     const usernameRef = useRef<HTMLInputElement>(null);
177     const [challengeLoading, setChallengeLoading] = useState(hasChallenge);
178     const [challengeError, setChallengeError] = useState(false);
180     const { validator, onFormSubmit } = useFormErrors();
182     useEffect(() => {
183         if (challengeLoading) {
184             return;
185         }
186         // Special focus management for challenge
187         usernameRef.current?.focus();
188     }, [challengeLoading]);
190     if (challengeError) {
191         return <ChallengeError />;
192     }
194     return (
195         <>
196             {challengeLoading && (
197                 <div className="text-center">
198                     <CircleLoader className="color-primary" size="large" />
199                 </div>
200             )}
201             <form
202                 name="loginForm"
203                 className={challengeLoading ? 'hidden' : undefined}
204                 onSubmit={(event) => {
205                     event.preventDefault();
206                     if (!onFormSubmit()) {
207                         return;
208                     }
209                     const run = async () => {
210                         const payload = await challengeRefLogin.current?.getChallenge().catch(noop);
211                         return onSubmit(username, password, payload);
212                     };
213                     withLoading(run()).catch(noop);
214                 }}
215                 method="post"
216             >
217                 {hasChallenge && (
218                     <Challenge
219                         className="h-0"
220                         tabIndex={-1}
221                         challengeRef={challengeRefLogin}
222                         name="login"
223                         type={0}
224                         onSuccess={() => {
225                             setChallengeLoading(false);
226                         }}
227                         onError={() => {
228                             setChallengeLoading(false);
229                             setChallengeError(true);
230                         }}
231                     />
232                 )}
233                 <InputFieldTwo
234                     id="username"
235                     bigger
236                     label={c('Label').t`Email or username`}
237                     error={validator([requiredValidator(username)])}
238                     autoComplete="username"
239                     value={username}
240                     onValue={setUsername}
241                     ref={usernameRef}
242                 />
243                 <InputFieldTwo
244                     id="password"
245                     bigger
246                     label={c('Label').t`Password`}
247                     error={validator([requiredValidator(password)])}
248                     as={PasswordInputTwo}
249                     autoComplete="current-password"
250                     value={password}
251                     onValue={setPassword}
252                     rootClassName="mt-2"
253                 />
254                 <div className="flex justify-space-between mt-4">
255                     {needHelp}
256                     <Button color="norm" size="large" type="submit" fullWidth loading={loading} data-cy-login="submit">
257                         {c('Action').t`Sign in`}
258                     </Button>
259                 </div>
260                 {footer}
261             </form>
262         </>
263     );
266 interface Props {
267     onLogin: OnLoginCallback;
268     needHelp?: ReactNode;
269     footer?: ReactNode;
270     hasChallenge?: boolean;
271     ignoreUnlock?: boolean;
274 const MinimalLoginContainer = ({ onLogin, hasChallenge = false, ignoreUnlock = false, needHelp, footer }: Props) => {
275     const { APP_NAME } = useConfig();
276     const { createNotification } = useNotifications();
277     const [abuseModal, setAbuseModal] = useState<{ apiErrorMessage?: string } | undefined>(undefined);
279     const normalApi = useApi();
280     const silentApi = <T,>(config: any) => normalApi<T>({ ...config, silence: true });
281     const errorHandler = useErrorHandler();
283     const cacheRef = useRef<AuthCacheResult | undefined>(undefined);
284     const [step, setStep] = useState(AuthStep.LOGIN);
286     const handleResult = async (result: AuthActionResponse) => {
287         if (result.to === AuthStep.DONE) {
288             await onLogin(result.session);
289             return;
290         }
291         cacheRef.current = result.cache;
292         setStep(result.to);
293     };
295     const handleCancel = () => {
296         cacheRef.current = undefined;
297         setStep(AuthStep.LOGIN);
298     };
300     const handleError = (e: any) => {
301         if (e.data?.Code === API_CUSTOM_ERROR_CODES.AUTH_ACCOUNT_DISABLED) {
302             setAbuseModal({ apiErrorMessage: getApiErrorMessage(e) });
303             return;
304         }
305         if (e.name === 'TOTPError' || e.name === 'PasswordError') {
306             createNotification({ type: 'error', text: e.message });
307             return;
308         }
309         if (
310             step === AuthStep.LOGIN ||
311             (step === AuthStep.UNLOCK && e.name !== 'PasswordError') ||
312             (step === AuthStep.TWO_FA && e.name !== 'TOTPError')
313         ) {
314             handleCancel();
315         }
316         errorHandler(e);
317     };
319     const cancelButton = (
320         <Button size="large" shape="ghost" type="button" fullWidth onClick={handleCancel} className="mt-2">
321             {c('Action').t`Cancel`}
322         </Button>
323     );
325     const cache = cacheRef.current;
327     return (
328         <>
329             <AbuseModal
330                 message={abuseModal?.apiErrorMessage}
331                 open={!!abuseModal}
332                 onClose={() => setAbuseModal(undefined)}
333             />
334             {step === AuthStep.LOGIN && (
335                 <LoginForm
336                     needHelp={needHelp}
337                     footer={footer}
338                     hasChallenge={hasChallenge}
339                     onSubmit={async (username, password, payload) => {
340                         try {
341                             await startUnAuthFlow();
342                             const loginResult = await handleLogin({
343                                 username,
344                                 persistent: false,
345                                 payload,
346                                 password,
347                                 api: silentApi,
348                             });
349                             const result = await handleNextLogin({
350                                 authType: AuthType.SRP,
351                                 authResponse: loginResult.authResult.result,
352                                 authVersion: loginResult.authResult.authVersion,
353                                 appName: APP_NAME,
354                                 toApp: APP_NAME,
355                                 productParam: APP_NAME,
356                                 username,
357                                 password,
358                                 api: silentApi,
359                                 verifyOutboundPublicKeys: null,
360                                 ignoreUnlock,
361                                 persistent: false,
362                                 setupVPN: false,
363                                 ktActivation: KeyTransparencyActivation.DISABLED,
364                             });
365                             return await handleResult(result);
366                         } catch (e) {
367                             handleError(e);
368                         }
369                     }}
370                 />
371             )}
372             {step === AuthStep.TWO_FA && cache && (
373                 <TOTPForm
374                     cancelButton={cancelButton}
375                     onSubmit={(totp) => {
376                         return handleTotp({
377                             cache,
378                             totp,
379                         })
380                             .then(handleResult)
381                             .catch(handleError);
382                     }}
383                 />
384             )}
385             {step === AuthStep.UNLOCK && cache && (
386                 <UnlockForm
387                     cancelButton={cancelButton}
388                     onSubmit={(keyPassword) => {
389                         return handleUnlock({
390                             cache,
391                             clearKeyPassword: keyPassword,
392                             isOnePasswordMode: false,
393                         })
394                             .then(handleResult)
395                             .catch(handleError);
396                     }}
397                 />
398             )}
399         </>
400     );
403 export default MinimalLoginContainer;