2FA choice place 1
This commit is contained in:
60
web/packages/accounts/components/SecondFactorChoice.tsx
Normal file
60
web/packages/accounts/components/SecondFactorChoice.tsx
Normal file
@@ -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<SecondFactorChoiceProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
didSelect,
|
||||
}) => (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={(_, reason) => {
|
||||
if (reason != "backdropClick") onClose();
|
||||
}}
|
||||
fullWidth
|
||||
PaperProps={{ sx: { maxWidth: "360px", padding: "12px" } }}
|
||||
>
|
||||
<DialogTitle>{t("two_factor")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: "12px" }}>
|
||||
<FocusVisibleButton
|
||||
color="accent"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
didSelect("totp");
|
||||
}}
|
||||
>
|
||||
{t("totp_login")}
|
||||
</FocusVisibleButton>
|
||||
|
||||
<FocusVisibleButton
|
||||
color="accent"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
didSelect("passkey");
|
||||
}}
|
||||
>
|
||||
{t("passkey_login")}
|
||||
</FocusVisibleButton>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -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<PageProps> = ({ appContext }) => {
|
||||
const [sessionValidityCheck, setSessionValidityCheck] = useState<
|
||||
Promise<void> | undefined
|
||||
>();
|
||||
const resolveSecondFactorChoice = useRef<
|
||||
| ((value: SecondFactorType | PromiseLike<SecondFactorType>) => void)
|
||||
| undefined
|
||||
>();
|
||||
const {
|
||||
show: showSecondFactorChoice,
|
||||
props: secondFactorChoiceVisibilityProps,
|
||||
} = useModalVisibility();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -216,10 +229,47 @@ const Page: React.FC<PageProps> = ({ 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<SecondFactorType>(
|
||||
(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<PageProps> = ({ appContext }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecondFactorChoice = (factor: SecondFactorType) => {
|
||||
const resolve = resolveSecondFactorChoice.current!;
|
||||
resolveSecondFactorChoice.current = undefined;
|
||||
resolve(factor);
|
||||
};
|
||||
|
||||
if (!keyAttributes && !srpAttributes) {
|
||||
return (
|
||||
<VerticallyCentered>
|
||||
@@ -387,6 +443,11 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
|
||||
</Stack>
|
||||
</LoginFlowFormFooter>
|
||||
</FormPaper>
|
||||
|
||||
<SecondFactorChoice
|
||||
{...secondFactorChoiceVisibilityProps}
|
||||
didSelect={handleSecondFactorChoice}
|
||||
/>
|
||||
</VerticallyCentered>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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...",
|
||||
|
||||
Reference in New Issue
Block a user