diff --git a/web/packages/accounts/components/SecondFactorChoice.tsx b/web/packages/accounts/components/SecondFactorChoice.tsx new file mode 100644 index 0000000000..5f5bb35dea --- /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. + */ + onSelect: (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, + onSelect, +}) => ( + { + if (reason != "backdropClick") onClose(); + }} + fullWidth + PaperProps={{ sx: { maxWidth: "360px", padding: "12px" } }} + > + {t("two_factor")} + + + { + onClose(); + onSelect("totp"); + }} + > + {t("totp_login")} + + + { + onClose(); + onSelect("passkey"); + }} + > + {t("passkey_login")} + + + + +); diff --git a/web/packages/accounts/components/utils/second-factor-choice.ts b/web/packages/accounts/components/utils/second-factor-choice.ts new file mode 100644 index 0000000000..754691244d --- /dev/null +++ b/web/packages/accounts/components/utils/second-factor-choice.ts @@ -0,0 +1,92 @@ +/** + * @file This code is conceputally related to `SecondFactorChoice.tsx`, but + * needs to be in a separate file to allow fast refresh. + */ + +import { useModalVisibility } from "@/base/components/utils/modal"; +import { useCallback, useMemo, useRef } from "react"; +import type { UserVerificationResponse } from "../../services/user"; +import type { SecondFactorType } from "../SecondFactorChoice"; + +/** + * A convenience hook for keeping track of the state and logic that is needed + * after password verification to determine which second factor (if any) we + * should be asking the user for. + * + * This is a rather ad-hoc abstraction meant to be used in a very specific way; + * the only intent is to reduce code duplication between the two pages that need + * this choice. + */ +export const useSecondFactorChoiceIfNeeded = () => { + const resolveSecondFactorChoice = useRef< + | ((value: SecondFactorType | PromiseLike) => void) + | undefined + >(); + const { + show: showSecondFactorChoice, + props: secondFactorChoiceVisibilityProps, + } = useModalVisibility(); + + const onSelect = useCallback((factor: SecondFactorType) => { + const resolve = resolveSecondFactorChoice.current!; + resolveSecondFactorChoice.current = undefined; + resolve(factor); + }, []); + + const secondFactorChoiceProps = useMemo( + () => ({ ...secondFactorChoiceVisibilityProps, onSelect }), + [secondFactorChoiceVisibilityProps, onSelect], + ); + + const userVerificationResultAfterResolvingSecondFactorChoice = useCallback( + async (response: UserVerificationResponse) => { + const { + twoFactorSessionID: _twoFactorSessionIDV1, + twoFactorSessionIDV2: _twoFactorSessionIDV2, + passkeySessionID: _passkeySessionID, + } = response; + + // 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 both factors are set, ask the user which one they want to use. + if (_twoFactorSessionID && _passkeySessionID) { + 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; + } + + return { ...response, passkeySessionID, twoFactorSessionID }; + }, + [showSecondFactorChoice], + ); + + return { + secondFactorChoiceProps, + userVerificationResultAfterResolvingSecondFactorChoice, + }; +}; diff --git a/web/packages/accounts/pages/credentials.tsx b/web/packages/accounts/pages/credentials.tsx index 7a12367264..89aa8d1770 100644 --- a/web/packages/accounts/pages/credentials.tsx +++ b/web/packages/accounts/pages/credentials.tsx @@ -44,6 +44,8 @@ import { PasswordHeader, VerifyingPasskey, } from "../components/LoginComponents"; +import { SecondFactorChoice } from "../components/SecondFactorChoice"; +import { useSecondFactorChoiceIfNeeded } from "../components/utils/second-factor-choice"; import { PAGES } from "../constants/pages"; import { openPasskeyVerificationURL, @@ -76,6 +78,10 @@ const Page: React.FC = ({ appContext }) => { const [sessionValidityCheck, setSessionValidityCheck] = useState< Promise | undefined >(); + const { + secondFactorChoiceProps, + userVerificationResultAfterResolvingSecondFactorChoice, + } = useSecondFactorChoiceIfNeeded(); const router = useRouter(); @@ -218,8 +224,12 @@ const Page: React.FC = ({ appContext }) => { id, twoFactorSessionID, passkeySessionID, - } = await loginViaSRP(srpAttributes!, kek); + } = + await userVerificationResultAfterResolvingSecondFactorChoice( + await loginViaSRP(srpAttributes!, kek), + ); setIsFirstLogin(true); + if (passkeySessionID) { const sessionKeyAttributes = await cryptoWorker.generateKeyAndEncryptToB64(kek); @@ -387,6 +397,8 @@ const Page: React.FC = ({ appContext }) => { + + ); }; diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 666a410b20..dfbeb387d4 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -30,6 +30,8 @@ import { LoginFlowFormFooter, VerifyingPasskey, } from "../components/LoginComponents"; +import { SecondFactorChoice } from "../components/SecondFactorChoice"; +import { useSecondFactorChoiceIfNeeded } from "../components/utils/second-factor-choice"; import { PAGES } from "../constants/pages"; import { openPasskeyVerificationURL, @@ -51,6 +53,10 @@ const Page: React.FC = ({ appContext }) => { const [passkeyVerificationData, setPasskeyVerificationData] = useState< { passkeySessionID: string; url: string } | undefined >(); + const { + secondFactorChoiceProps, + userVerificationResultAfterResolvingSecondFactorChoice, + } = useSecondFactorChoiceIfNeeded(); const router = useRouter(); @@ -83,7 +89,9 @@ const Page: React.FC = ({ appContext }) => { id, twoFactorSessionID, passkeySessionID, - } = resp.data as UserVerificationResponse; + } = await userVerificationResultAfterResolvingSecondFactorChoice( + resp.data as UserVerificationResponse, + ); if (passkeySessionID) { const user = getData(LS_KEYS.USER); await setLSUser({ @@ -243,6 +251,8 @@ 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...",