diff --git a/web/packages/accounts/components/SecondFactorChoice.tsx b/web/packages/accounts/components/SecondFactorChoice.tsx new file mode 100644 index 0000000000..4ef28e44dc --- /dev/null +++ b/web/packages/accounts/components/SecondFactorChoice.tsx @@ -0,0 +1,60 @@ +import { FocusVisibleButton } from "@/base/components/mui/FocusVisibleButton"; +import type { ModalVisibilityProps } from "@/base/components/utils/modal"; +import { Dialog, DialogContent, DialogTitle, Stack } from "@mui/material"; +import { t } from "i18next"; +import React from "react"; + +export type SecondFactorType = "totp" | "passkey"; + +type SecondFactorChoiceProps = ModalVisibilityProps & { + /** + * Callback invoked with the selected choice. + * + * The dialog will automatically be closed before this callback is invoked. + */ + didSelect: (factor: SecondFactorType) => void; +}; + +/** + * A {@link Dialog} that allow the user to choose which second factor they'd + * like to verify during login. + */ +export const SecondFactorChoice: React.FC = ({ + open, + onClose, + didSelect, +}) => ( + { + if (reason != "backdropClick") onClose(); + }} + fullWidth + PaperProps={{ sx: { maxWidth: "360px", padding: "12px" } }} + > + {t("two_factor")} + + + { + onClose(); + didSelect("totp"); + }} + > + {t("totp_login")} + + + { + onClose(); + didSelect("passkey"); + }} + > + {t("passkey_login")} + + + + +); diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 7a12367264..a6621ea68e 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -1,6 +1,7 @@ import { sessionExpiredDialogAttributes } from "@/accounts/components/utils/dialog"; import { FormPaper } from "@/base/components/FormPaper"; import { ActivityIndicator } from "@/base/components/mui/ActivityIndicator"; +import { useModalVisibility } from "@/base/components/utils/modal"; import { sharedCryptoWorker } from "@/base/crypto"; import type { B64EncryptionResult } from "@/base/crypto/libsodium"; import { clearLocalStorage } from "@/base/local-storage"; @@ -38,12 +39,16 @@ import type { KeyAttributes, User } from "@ente/shared/user/types"; import { Stack } from "@mui/material"; import { t } from "i18next"; import { useRouter } from "next/router"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { LoginFlowFormFooter, PasswordHeader, VerifyingPasskey, } from "../components/LoginComponents"; +import { + SecondFactorChoice, + type SecondFactorType, +} from "../components/SecondFactorChoice"; import { PAGES } from "../constants/pages"; import { openPasskeyVerificationURL, @@ -76,6 +81,14 @@ const Page: React.FC = ({ appContext }) => { const [sessionValidityCheck, setSessionValidityCheck] = useState< Promise | undefined >(); + const resolveSecondFactorChoice = useRef< + | ((value: SecondFactorType | PromiseLike) => void) + | undefined + >(); + const { + show: showSecondFactorChoice, + props: secondFactorChoiceVisibilityProps, + } = useModalVisibility(); const router = useRouter(); @@ -216,10 +229,47 @@ const Page: React.FC = ({ appContext }) => { encryptedToken, token, id, - twoFactorSessionID, - passkeySessionID, + twoFactorSessionID: _twoFactorSessionIDV1, + twoFactorSessionIDV2: _twoFactorSessionIDV2, + passkeySessionID: _passkeySessionID, } = await loginViaSRP(srpAttributes!, kek); setIsFirstLogin(true); + + // When the user has both TOTP and pk set as the second factor, + // we'll get two session IDs. For backward compat, the TOTP + // session ID will be in a V2 attribute during a transient + // migration period. + // + // Note the use of || instead of ?? since _twoFactorSessionIDV1 + // will be an empty string, not undefined, if it is unset. We + // might need to add a `xxx-eslint-disable + // @typescript-eslint/prefer-nullish-coalescing` here too later. + const _twoFactorSessionID = + _twoFactorSessionIDV1 || _twoFactorSessionIDV2; + let passkeySessionID: string | undefined; + let twoFactorSessionID: string | undefined; + if (_twoFactorSessionID && _passkeySessionID) { + // If both factors are set, ask the user which one they wish + // to use. + const choice = await new Promise( + (resolve) => { + resolveSecondFactorChoice.current = resolve; + showSecondFactorChoice(); + }, + ); + switch (choice) { + case "passkey": + passkeySessionID = _passkeySessionID; + break; + case "totp": + twoFactorSessionID = _twoFactorSessionID; + break; + } + } else { + passkeySessionID = _passkeySessionID; + twoFactorSessionID = _twoFactorSessionID; + } + if (passkeySessionID) { const sessionKeyAttributes = await cryptoWorker.generateKeyAndEncryptToB64(kek); @@ -322,6 +372,12 @@ const Page: React.FC = ({ appContext }) => { } }; + const handleSecondFactorChoice = (factor: SecondFactorType) => { + const resolve = resolveSecondFactorChoice.current!; + resolveSecondFactorChoice.current = undefined; + resolve(factor); + }; + if (!keyAttributes && !srpAttributes) { return ( @@ -387,6 +443,11 @@ const Page: React.FC = ({ appContext }) => { + + ); }; diff --git a/web/packages/accounts/services/user.ts b/web/packages/accounts/services/user.ts index a680e09950..3ed5e7c257 100644 --- a/web/packages/accounts/services/user.ts +++ b/web/packages/accounts/services/user.ts @@ -13,6 +13,12 @@ export interface UserVerificationResponse { token?: string; twoFactorSessionID: string; passkeySessionID: string; + /** + * If both passkeys and TOTP based two factors are enabled, then {@link + * twoFactorSessionIDV2} will be set to the TOTP session ID instead of + * {@link twoFactorSessionID}. + */ + twoFactorSessionIDV2?: string | undefined; srpM2?: string; } diff --git a/web/packages/base/locales/en-US/translation.json b/web/packages/base/locales/en-US/translation.json index 42cc995254..0608e4a78b 100644 --- a/web/packages/base/locales/en-US/translation.json +++ b/web/packages/base/locales/en-US/translation.json @@ -637,6 +637,7 @@ "check_status": "Check status", "passkey_login_instructions": "Follow the steps from your browser to continue logging in.", "passkey_login": "Login with passkey", + "totp_login": "Login with TOTP", "passkey": "Passkey", "passkey_verify_description": "Verify your passkey to login into your account.", "waiting_for_verification": "Waiting for verification...",