diff --git a/web/apps/accounts/public/images/favicon.png b/web/apps/accounts/public/images/favicon.png index 2d769dadf4..fcb8d1054b 100644 Binary files a/web/apps/accounts/public/images/favicon.png and b/web/apps/accounts/public/images/favicon.png differ diff --git a/web/apps/accounts/src/pages/passkeys/verify.tsx b/web/apps/accounts/src/pages/passkeys/verify.tsx index 793576632e..5c909e76f4 100644 --- a/web/apps/accounts/src/pages/passkeys/verify.tsx +++ b/web/apps/accounts/src/pages/passkeys/verify.tsx @@ -6,9 +6,10 @@ import { VerticallyCentered } from "@ente/shared/components/Container"; import EnteButton from "@ente/shared/components/EnteButton"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; import InfoIcon from "@mui/icons-material/Info"; +import KeyIcon from "@mui/icons-material/Key"; import { Paper, Typography, styled } from "@mui/material"; import { t } from "i18next"; -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { beginPasskeyAuthentication, finishPasskeyAuthentication, @@ -17,6 +18,7 @@ import { passkeyAuthenticationSuccessRedirectURL, redirectToPasskeyRecoverPage, signChallenge, + type BeginPasskeyAuthenticationResponse, } from "services/passkey"; const Page = () => { @@ -29,14 +31,38 @@ const Page = () => { | "loading" /* Can happen multiple times in the flow */ | "webAuthnNotSupported" /* Unrecoverable error */ | "unknownRedirect" /* Unrecoverable error */ - | "unrecoverableFailure" /* Unrocevorable error - generic */ - | "failed" /* Recoverable error */ + | "unrecoverableFailure" /* Unrecoverable error - generic */ + | "failedDuringSignChallenge" /* Recoverable error in signChallenge */ + | "failed" /* Recoverable error otherwise */ + | "needUserFocus" /* See docs for `Continuation` */ | "waitingForUser" /* ...to authenticate with their passkey */ | "redirectingWeb" /* Redirect back to the requesting app (HTTP) */ | "redirectingApp"; /* Other redirects (mobile / desktop redirect) */ const [status, setStatus] = useState("loading"); + /** + * Safari keeps on saying "NotAllowedError: The document is not focused" + * even though it just opened the page and brought it to the front. + * + * Because of their incompetence, we need to break our entire flow into two + * parts, and stash away a lot of state when we're in the "needUserFocus" + * state. + */ + interface Continuation { + redirectURL: URL; + clientPackage: string; + passkeySessionID: string; + beginResponse: BeginPasskeyAuthenticationResponse; + } + const [continuation, setContinuation] = useState< + Continuation | undefined + >(); + + // Safari throws sometimes + // (no reason, just to show their incompetence). The retry doesn't seem to + // help mostly, but cargo cult anyway. + // The URL we're redirecting to on success. // // This will only be set when status is "redirecting*". @@ -44,8 +70,8 @@ const Page = () => { URL | undefined >(); - /** (re)start the authentication flow */ - const authenticate = async () => { + /** Phase 1 of {@link authenticate}. */ + const authenticateBegin = useCallback(async () => { if (!isWebAuthnSupported()) { setStatus("webAuthnNotSupported"); return; @@ -86,21 +112,59 @@ const Page = () => { return; } - let authorizationResponse: TwoFactorAuthorizationResponse; + let beginResponse: BeginPasskeyAuthenticationResponse; try { - const { ceremonySessionID, options } = - await beginPasskeyAuthentication(passkeySessionID); + beginResponse = await beginPasskeyAuthentication(passkeySessionID); + } catch (e) { + log.error("Failed to begin passkey authentication", e); + setStatus("failed"); + return; + } - setStatus("waitingForUser"); + return { + redirectURL, + passkeySessionID, + clientPackage, + beginResponse, + }; + }, []); - const credential = await signChallenge(options.publicKey); + /** + * Phase 2 of {@link authenticate}, separated by a potential user + * interaction. + */ + const authenticateContinue = useCallback(async (cont: Continuation) => { + const { redirectURL, passkeySessionID, clientPackage, beginResponse } = + cont; + const { ceremonySessionID, options } = beginResponse; + + setStatus("waitingForUser"); + + let credential: Credential | undefined; + try { + credential = await signChallenge(options.publicKey); if (!credential) { - setStatus("failed"); + setStatus("failedDuringSignChallenge"); return; } + } catch (e) { + log.error("Failed to get credentials", e); + if ( + e instanceof Error && + e.name == "NotAllowedError" && + e.message == "The document is not focused." + ) { + setStatus("needUserFocus"); + } else { + setStatus("failedDuringSignChallenge"); + } + return; + } - setStatus("loading"); + setStatus("loading"); + let authorizationResponse: TwoFactorAuthorizationResponse; + try { authorizationResponse = await finishPasskeyAuthentication({ passkeySessionID, ceremonySessionID, @@ -108,7 +172,7 @@ const Page = () => { credential, }); } catch (e) { - log.error("Passkey authentication failed", e); + log.error("Failed to finish passkey authentication", e); setStatus("failed"); return; } @@ -122,16 +186,27 @@ const Page = () => { authorizationResponse, ), ); - }; + }, []); + + /** (re)start the authentication flow */ + const authenticate = useCallback(async () => { + const cont = await authenticateBegin(); + if (cont) { + setContinuation(cont); + await authenticateContinue(cont); + } + }, [authenticateBegin, authenticateContinue]); useEffect(() => { void authenticate(); - }, []); + }, [authenticate]); useEffect(() => { if (successRedirectURL) redirectToURL(successRedirectURL); }, [successRedirectURL]); + const handleVerify = () => void authenticateContinue(ensure(continuation)); + const handleRetry = () => void authenticate(); const handleRecover = (() => { @@ -157,9 +232,17 @@ const Page = () => { unknownRedirect: , webAuthnNotSupported: , unrecoverableFailure: , + failedDuringSignChallenge: ( + + ), failed: ( ), + needUserFocus: , waitingForUser: , redirectingWeb: , redirectingApp: , @@ -237,7 +320,47 @@ const ContentPaper = styled(Paper)` gap: 1rem; `; +interface VerifyProps { + /** Called when the user presses the "Verify" button. */ + onVerify: () => void; +} + +/** + * Gain focus for the current page by requesting the user to explicitly click a + * button. For more details, see the documentation for `Continuation`. + */ +const Verify: React.FC = ({ onVerify }) => { + return ( + + + {t("passkey")} + + {t("passkey_verify_description")} + + + + {t("VERIFY")} + + + + ); +}; + interface RetriableFailedProps { + /** + * Set this attribute to indicate that this failure occurred during the + * actual passkey verification (`navigator.credentials.get`). + * + * We customize the error message for such cases to give a hint to the user + * that they can try on their other devices too. + */ + duringSignChallenge?: boolean; /** Callback invoked when the user presses the try again button. */ onRetry: () => void; /** @@ -251,6 +374,7 @@ interface RetriableFailedProps { } const RetriableFailed: React.FC = ({ + duringSignChallenge, onRetry, onRecover, }) => { @@ -259,7 +383,9 @@ const RetriableFailed: React.FC = ({ {t("passkey_login_failed")} - {t("passkey_login_generic_error")} + {duringSignChallenge + ? t("passkey_login_credential_hint") + : t("passkey_login_generic_error")} { - const go = async () => await navigator.credentials.get({ publicKey }); - - try { - return await go(); - } catch (e) { - // Safari throws "NotAllowedError: The document is not focused" for the - // first request sometimes (no reason, just to show their incompetence). - // "NotAllowedError" is also the error name that is thrown when the user - // explicitly cancels, so we can't even filter it out by name and also - // to do a message match. - if ( - e instanceof Error && - e.name == "NotAllowedError" && - e.message == "The document is not focused." - ) { - log.warn("Working around Safari bug by retrying after failure", e); - await wait(2000); - return await go(); - } else { - throw e; - } - } -}; +) => nullToUndefined(await navigator.credentials.get({ publicKey })); interface FinishPasskeyAuthenticationOptions { passkeySessionID: string; diff --git a/web/apps/auth/src/pages/auth.tsx b/web/apps/auth/src/pages/auth.tsx index 9c49ffbed3..7f40226e30 100644 --- a/web/apps/auth/src/pages/auth.tsx +++ b/web/apps/auth/src/pages/auth.tsx @@ -3,9 +3,9 @@ import { HorizontalFlex, VerticallyCentered, } from "@ente/shared/components/Container"; -import type { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types"; import { EnteLogo } from "@ente/shared/components/EnteLogo"; import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import { sessionExpiredDialogAttributes } from "@ente/shared/components/LoginComponents"; import NavbarBase from "@ente/shared/components/Navbar/base"; import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option"; @@ -140,19 +140,6 @@ const Page: React.FC = () => { export default Page; -const sessionExpiredDialogAttributes = ( - action: () => void, -): DialogBoxAttributesV2 => ({ - title: t("SESSION_EXPIRED"), - content: t("SESSION_EXPIRED_MESSAGE"), - nonClosable: true, - proceed: { - text: t("LOGIN"), - action, - variant: "accent", - }, -}); - const AuthNavbar: React.FC = () => { const { isMobile, logout } = ensure(useContext(AppContext)); diff --git a/web/apps/photos/src/components/Sidebar/index.tsx b/web/apps/photos/src/components/Sidebar/index.tsx index d2d7aa1e83..dc8c7b9c64 100644 --- a/web/apps/photos/src/components/Sidebar/index.tsx +++ b/web/apps/photos/src/components/Sidebar/index.tsx @@ -528,13 +528,11 @@ const UtilitySection: React.FC = ({ closeSidebar }) => { label={t("TWO_FACTOR")} /> - {isInternalUserViaEmailCheck() && ( - - )} + = ({ appContext }) => { const [keyAttributes, setKeyAttributes] = useState(); const [user, setUser] = useState(); const [passkeyVerificationData, setPasskeyVerificationData] = useState< - [string, string] | undefined + { passkeySessionID: string; url: string } | undefined >(); const router = useRouter(); + useEffect(() => { const main = async () => { const user: User = getData(LS_KEYS.USER); @@ -180,8 +181,8 @@ const Page: React.FC = ({ appContext }) => { appName, passkeySessionID, ); - setPasskeyVerificationData([passkeySessionID, url]); - openPasskeyVerificationURL(passkeySessionID, url); + setPasskeyVerificationData({ passkeySessionID, url }); + openPasskeyVerificationURL({ passkeySessionID, url }); throw Error(CustomError.TWO_FACTOR_ENABLED); } else if (twoFactorSessionID) { const sessionKeyAttributes = @@ -295,11 +296,11 @@ const Page: React.FC = ({ appContext }) => { return ( - openPasskeyVerificationURL(...passkeyVerificationData) + openPasskeyVerificationURL(passkeyVerificationData) } - onRecover={() => router.push("/passkeys/recover")} - onLogout={logout} + appContext={appContext} /> ); } diff --git a/web/packages/accounts/pages/passkeys/finish.tsx b/web/packages/accounts/pages/passkeys/finish.tsx index 2b1a8b8e61..be52cc02ae 100644 --- a/web/packages/accounts/pages/passkeys/finish.tsx +++ b/web/packages/accounts/pages/passkeys/finish.tsx @@ -90,6 +90,10 @@ const saveCredentialsAndNavigateTo = async ( // - The plaintext "token" will be passed during fresh signups, where we // don't yet have keys to encrypt it, the account itself is being created // as we go through this flow. + // TODO(MR): Conceptually this cannot happen. During a _real_ fresh signup + // we'll never enter the passkey verification flow. Remove this code after + // making sure that it doesn't get triggered in cases where an existing + // user goes through the new user flow. // // - The encrypted `encryptedToken` will be present otherwise (i.e. if the // user is signing into an existing account). diff --git a/web/packages/accounts/pages/verify.tsx b/web/packages/accounts/pages/verify.tsx index 1848b38be8..c6e6954d12 100644 --- a/web/packages/accounts/pages/verify.tsx +++ b/web/packages/accounts/pages/verify.tsx @@ -42,7 +42,7 @@ const Page: React.FC = ({ appContext }) => { const [email, setEmail] = useState(""); const [resend, setResend] = useState(0); const [passkeyVerificationData, setPasskeyVerificationData] = useState< - [string, string] | undefined + { passkeySessionID: string; url: string } | undefined >(); const router = useRouter(); @@ -98,8 +98,8 @@ const Page: React.FC = ({ appContext }) => { appName, passkeySessionID, ); - setPasskeyVerificationData([passkeySessionID, url]); - openPasskeyVerificationURL(passkeySessionID, url); + setPasskeyVerificationData({ passkeySessionID, url }); + openPasskeyVerificationURL({ passkeySessionID, url }); } else if (twoFactorSessionID) { setData(LS_KEYS.USER, { email, @@ -193,11 +193,11 @@ const Page: React.FC = ({ appContext }) => { return ( - openPasskeyVerificationURL(...passkeyVerificationData) + openPasskeyVerificationURL(passkeyVerificationData) } - onRecover={() => router.push("/passkeys/recover")} - onLogout={logout} + appContext={appContext} /> ); } diff --git a/web/packages/accounts/services/passkey.ts b/web/packages/accounts/services/passkey.ts index d2e854e64d..70cd3ae0fa 100644 --- a/web/packages/accounts/services/passkey.ts +++ b/web/packages/accounts/services/passkey.ts @@ -1,6 +1,9 @@ +import { clientPackageHeaderIfPresent } from "@/next/http"; import log from "@/next/log"; import type { AppName } from "@/next/types/app"; import { clientPackageName } from "@/next/types/app"; +import { TwoFactorAuthorizationResponse } from "@/next/types/credentials"; +import { ensure } from "@/utils/ensure"; import ComlinkCryptoWorker from "@ente/shared/crypto"; import { getRecoveryKey } from "@ente/shared/crypto/helpers"; import { @@ -10,6 +13,8 @@ import { import { CustomError } from "@ente/shared/error"; import HTTPService from "@ente/shared/network/HTTPService"; import { accountsAppURL, apiOrigin } from "@ente/shared/network/api"; +import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore"; +import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; /** @@ -46,16 +51,23 @@ export const passkeyVerificationRedirectURL = ( return `${accountsAppURL()}/passkeys/verify?${params.toString()}`; }; +interface OpenPasskeyVerificationURLOptions { + /** + * The passkeySessionID for which we are redirecting. + * + * This is compared to the saved session id in the browser's session storage + * to allow us to ignore redirects to the passkey flow finish page except + * the ones for this specific session we're awaiting. + */ + passkeySessionID: string; + /** The URL to redirect to or open in the system browser. */ + url: string; +} + /** * Open or redirect to a passkey verification URL previously constructed using * {@link passkeyVerificationRedirectURL}. * - * @param passkeySessionID The passkeySessionID for which we are redirecting. - * This is saved to session storage to allow us to ignore subsequent redirects - * to the passkey flow finish page except the ones for this specific session. - * - * @param url The URL to redirect to or open in the system browser. - * * [Note: Passkey verification in the desktop app] * * Our desktop app bundles the web app and serves it over a custom protocol. @@ -75,10 +87,10 @@ export const passkeyVerificationRedirectURL = ( * authentication happens at accounts.ente.io, and on success there is * redirected back to the desktop app. */ -export const openPasskeyVerificationURL = ( - passkeySessionID: string, - url: string, -) => { +export const openPasskeyVerificationURL = ({ + passkeySessionID, + url, +}: OpenPasskeyVerificationURLOptions) => { sessionStorage.setItem("inflightPasskeySessionID", passkeySessionID); if (globalThis.electron) window.open(url); @@ -192,3 +204,78 @@ const getAccountsToken = async () => { ); return resp.data["accountsToken"]; }; + +/** + * The passkey session whose status we are trying to check has already expired. + * The user should attempt to login again. + */ +export const passKeySessionExpiredErrorMessage = "Passkey session has expired"; + +/** + * Check if the user has already authenticated using their passkey for the given + * session. + * + * This is useful in case the automatic redirect back from accounts.ente.io to + * the desktop app does not work for some reason. In such cases, the user can + * press the "Check status" button: we'll make an API call to see if the + * authentication has already completed, and if so, get the same "response" + * object we'd have gotten as a query parameter in a redirect in + * {@link saveCredentialsAndNavigateTo} on the "/passkeys/finish" page. + * + * @param sessionID The passkey session whose session we wish to check the + * status of. + * + * @returns A {@link TwoFactorAuthorizationResponse} if the passkey + * authentication has completed, and `undefined` otherwise. + * + * @throws In addition to arbitrary errors, it throws errors with the message + * {@link passKeySessionExpiredErrorMessage}. + */ +export const checkPasskeyVerificationStatus = async ( + sessionID: string, +): Promise => { + const url = `${apiOrigin()}/users/two-factor/passkeys/get-token`; + const params = new URLSearchParams({ sessionID }); + const res = await fetch(`${url}?${params.toString()}`, { + headers: clientPackageHeaderIfPresent(), + }); + if (!res.ok) { + if (res.status == 404 || res.status == 410) + throw new Error(passKeySessionExpiredErrorMessage); + if (res.status == 400) return undefined; /* verification pending */ + throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`); + } + return TwoFactorAuthorizationResponse.parse(await res.json()); +}; + +/** + * Extract credentials from a successful passkey verification response and save + * them to local storage for use by subsequent steps (or normal functioning) of + * the app. + * + * @param response The result of a successful + * {@link checkPasskeyVerificationStatus}. + * + * @returns the slug that we should navigate to now. + */ +export const saveCredentialsAndNavigateTo = ( + response: TwoFactorAuthorizationResponse, +) => { + // This method somewhat duplicates `saveCredentialsAndNavigateTo` in the + // /passkeys/finish page. + const { id, encryptedToken, keyAttributes } = response; + + setData(LS_KEYS.USER, { + ...getData(LS_KEYS.USER), + encryptedToken, + id, + }); + setData(LS_KEYS.KEY_ATTRIBUTES, ensure(keyAttributes)); + + // TODO(MR): Remove the cast. + const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL) as + | string + | undefined; + InMemoryStore.delete(MS_KEYS.REDIRECT_URL); + return redirectURL ?? "/credentials"; +}; diff --git a/web/packages/next/http.ts b/web/packages/next/http.ts index bdcc9aef82..bf64a9b3a8 100644 --- a/web/packages/next/http.ts +++ b/web/packages/next/http.ts @@ -48,3 +48,13 @@ export const authenticatedRequestHeaders = (): Record => { if (_clientPackage) headers["X-Client-Package"] = _clientPackage; return headers; }; + +/** + * Return a headers object with "X-Client-Package" header if we have the client + * package value available to us from local storage. + */ +export const clientPackageHeaderIfPresent = (): Record => { + const headers: Record = {}; + if (_clientPackage) headers["X-Client-Package"] = _clientPackage; + return headers; +}; diff --git a/web/packages/next/locales/en-US/translation.json b/web/packages/next/locales/en-US/translation.json index 3fc3fde7b9..51b63abb2b 100644 --- a/web/packages/next/locales/en-US/translation.json +++ b/web/packages/next/locales/en-US/translation.json @@ -623,12 +623,16 @@ "passkey_login_failed": "Passkey login failed", "passkey_login_invalid_url": "The login URL is invalid.", "passkey_login_generic_error": "An error occurred while logging in with passkey.", + "passkey_login_credential_hint": "If your passkeys are on a different device, you can open this page on that device to verify.", "passkeys_not_supported": "Passkeys are not supported in this browser", "try_again": "Try again", + "check_status": "Check status", "passkey_login_instructions": "Follow the steps from your browser to continue logging in.", "passkey_login": "Login with passkey", "passkey": "Passkey", + "passkey_verify_description": "Verify your passkey to login into your account.", "waiting_for_verification": "Waiting for verification...", + "verification_still_pending": "Verification is still pending", "passkey_verified": "Passkey verified", "redirecting_back_to_app": "Redirecting you back to the app...", "redirect_close_instructions": "You can close this window after the app opens.", diff --git a/web/packages/next/types/credentials.ts b/web/packages/next/types/credentials.ts index 950cfffcca..483377d301 100644 --- a/web/packages/next/types/credentials.ts +++ b/web/packages/next/types/credentials.ts @@ -9,7 +9,11 @@ export const KeyAttributes = z.object({}).passthrough(); */ export const TwoFactorAuthorizationResponse = z.object({ id: z.number(), + /** TODO: keyAttributes is guaranteed to be returned by museum, update the + * types to reflect that. */ keyAttributes: KeyAttributes.nullish().transform(nullToUndefined), + /** TODO: encryptedToken is guaranteed to be returned by museum, update the + * types to reflect that. */ encryptedToken: z.string().nullish().transform(nullToUndefined), }); diff --git a/web/packages/shared/components/ErrorComponents.tsx b/web/packages/shared/components/ErrorComponents.tsx new file mode 100644 index 0000000000..57587ce667 --- /dev/null +++ b/web/packages/shared/components/ErrorComponents.tsx @@ -0,0 +1,11 @@ +import { t } from "i18next"; +import type { DialogBoxAttributesV2 } from "./DialogBoxV2/types"; + +/** + * {@link DialogBoxAttributesV2} for showing a generic error. + */ +export const genericErrorAttributes = (): DialogBoxAttributesV2 => ({ + title: t("ERROR"), + close: { variant: "critical" }, + content: t("UNKNOWN_ERROR"), +}); diff --git a/web/packages/shared/components/LoginComponents.tsx b/web/packages/shared/components/LoginComponents.tsx index 76960a23e9..950c6dc4cf 100644 --- a/web/packages/shared/components/LoginComponents.tsx +++ b/web/packages/shared/components/LoginComponents.tsx @@ -1,9 +1,20 @@ import { isDevBuild } from "@/next/env"; +import log from "@/next/log"; +import type { BaseAppContextT } from "@/next/types/app"; +import { + checkPasskeyVerificationStatus, + passKeySessionExpiredErrorMessage, + saveCredentialsAndNavigateTo, +} from "@ente/accounts/services/passkey"; import EnteButton from "@ente/shared/components/EnteButton"; import { apiOrigin } from "@ente/shared/network/api"; -import { Typography, styled } from "@mui/material"; +import { CircularProgress, Typography, styled } from "@mui/material"; import { t } from "i18next"; +import { useRouter } from "next/router"; +import React, { useState } from "react"; import { VerticallyCentered } from "./Container"; +import type { DialogBoxAttributesV2 } from "./DialogBoxV2/types"; +import { genericErrorAttributes } from "./ErrorComponents"; import FormPaper from "./Form/FormPaper"; import FormPaperFooter from "./Form/FormPaper/Footer"; import LinkButton from "./LinkButton"; @@ -52,35 +63,86 @@ const ConnectionDetails_ = styled("div")` `; interface VerifyingPasskeyProps { - /** The email of the user whose passkey we're verifying */ + /** ID of the current passkey verification session. */ + passkeySessionID: string; + /** The email of the user whose passkey we're verifying. */ email: string | undefined; /** Called when the user wants to redirect again. */ onRetry: () => void; - /** Called when the user presses the "Recover account" button. */ - onRecover: () => void; - /** Called when the user presses the "Change email" button. */ - onLogout: () => void; + /** + * The appContext. + * + * Needs to be explicitly passed since this component is used in a package + * where the pages are not wrapped in the provider. + */ + appContext: BaseAppContextT; } export const VerifyingPasskey: React.FC = ({ + passkeySessionID, email, onRetry, - onRecover, - onLogout, + appContext, }) => { + const { logout, setDialogBoxAttributesV2 } = appContext; + + type VerificationStatus = "waiting" | "checking" | "pending"; + const [verificationStatus, setVerificationStatus] = + useState("waiting"); + + const router = useRouter(); + + const handleRetry = () => { + setVerificationStatus("waiting"); + onRetry(); + }; + + const handleCheckStatus = async () => { + setVerificationStatus("checking"); + try { + const response = + await checkPasskeyVerificationStatus(passkeySessionID); + if (!response) setVerificationStatus("pending"); + else router.push(saveCredentialsAndNavigateTo(response)); + } catch (e) { + log.error("Passkey verification status check failed", e); + setDialogBoxAttributesV2( + e instanceof Error && + e.message == passKeySessionExpiredErrorMessage + ? sessionExpiredDialogAttributes(logout) + : genericErrorAttributes(), + ); + setVerificationStatus("waiting"); + } + }; + + const handleRecover = () => { + router.push("/passkeys/recover"); + }; + return ( {email ?? ""} - - {t("waiting_for_verification")} - + + {verificationStatus == "checking" ? ( + + + + ) : ( + + {verificationStatus == "waiting" + ? t("waiting_for_verification") + : t("verification_still_pending")} + + )} + = ({ {t("try_again")} - {/* TODO-PK: Uncomment once the API is ready {}} + onClick={handleCheckStatus} fullWidth color="accent" type="button" > - {"Check status"} - */} + {t("check_status")} + - + {t("RECOVER_ACCOUNT")} - + {t("CHANGE_EMAIL")} @@ -121,7 +182,13 @@ const VerifyingPasskeyMiddle = styled("div")` padding-block: 1rem; gap: 4rem; +`; + +const VerifyingPasskeyStatus = styled("div")` text-align: center; + /* Size of the CircularProgress (+ some margin) so that there is no layout + shift when it is shown */ + min-height: 2em; `; const ButtonStack = styled("div")` @@ -129,3 +196,26 @@ const ButtonStack = styled("div")` flex-direction: column; gap: 1rem; `; + +/** + * {@link DialogBoxAttributesV2} for showing the error when the user's session + * has expired. + * + * It asks them to login again. There is one button, which allows them to + * logout. + * + * @param onLogin Called when the user presses the "Login" button on the error + * dialog. + */ +export const sessionExpiredDialogAttributes = ( + onLogin: () => void, +): DialogBoxAttributesV2 => ({ + title: t("SESSION_EXPIRED"), + content: t("SESSION_EXPIRED_MESSAGE"), + nonClosable: true, + proceed: { + text: t("LOGIN"), + action: onLogin, + variant: "accent", + }, +});