[desktop] Add a check status button to the passkey waiting page (#2132)

This commit is contained in:
Manav Rathi
2024-06-14 13:55:54 +05:30
committed by GitHub
14 changed files with 400 additions and 103 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -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<Status>("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: <UnknownRedirect />,
webAuthnNotSupported: <WebAuthnNotSupported />,
unrecoverableFailure: <UnrecoverableFailure />,
failedDuringSignChallenge: (
<RetriableFailed
duringSignChallenge
onRetry={handleRetry}
onRecover={handleRecover}
/>
),
failed: (
<RetriableFailed onRetry={handleRetry} onRecover={handleRecover} />
),
needUserFocus: <Verify onVerify={handleVerify} />,
waitingForUser: <WaitingForUser />,
redirectingWeb: <RedirectingWeb />,
redirectingApp: <RedirectingApp onRetry={handleRedirectAgain} />,
@@ -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<VerifyProps> = ({ onVerify }) => {
return (
<Content>
<KeyIcon color="secondary" fontSize="large" />
<Typography variant="h3">{t("passkey")}</Typography>
<Typography color="text.muted">
{t("passkey_verify_description")}
</Typography>
<ButtonStack>
<EnteButton
onClick={onVerify}
fullWidth
color="accent"
type="button"
variant="contained"
>
{t("VERIFY")}
</EnteButton>
</ButtonStack>
</Content>
);
};
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<RetriableFailedProps> = ({
duringSignChallenge,
onRetry,
onRecover,
}) => {
@@ -259,7 +383,9 @@ const RetriableFailed: React.FC<RetriableFailedProps> = ({
<InfoIcon color="secondary" fontSize="large" />
<Typography variant="h3">{t("passkey_login_failed")}</Typography>
<Typography color="text.muted">
{t("passkey_login_generic_error")}
{duringSignChallenge
? t("passkey_login_credential_hint")
: t("passkey_login_generic_error")}
</Typography>
<ButtonStack>
<EnteButton

View File

@@ -1,9 +1,7 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
import { clientPackageName } from "@/next/types/app";
import { TwoFactorAuthorizationResponse } from "@/next/types/credentials";
import { ensure } from "@/utils/ensure";
import { wait } from "@/utils/promise";
import { nullToUndefined } from "@/utils/transform";
import {
fromB64URLSafeNoPadding,
@@ -424,30 +422,7 @@ export const beginPasskeyAuthentication = async (
*/
export const signChallenge = async (
publicKey: PublicKeyCredentialRequestOptions,
) => {
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;

View File

@@ -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));

View File

@@ -528,13 +528,11 @@ const UtilitySection: React.FC<UtilitySectionProps> = ({ closeSidebar }) => {
label={t("TWO_FACTOR")}
/>
{isInternalUserViaEmailCheck() && (
<EnteMenuItem
variant="secondary"
onClick={redirectToAccountsPage}
label={t("passkeys")}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={redirectToAccountsPage}
label={t("passkeys")}
/>
<EnteMenuItem
variant="secondary"

View File

@@ -67,10 +67,11 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
const [user, setUser] = useState<User>();
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<PageProps> = ({ 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<PageProps> = ({ appContext }) => {
return (
<VerifyingPasskey
email={user?.email}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
onRetry={() =>
openPasskeyVerificationURL(...passkeyVerificationData)
openPasskeyVerificationURL(passkeyVerificationData)
}
onRecover={() => router.push("/passkeys/recover")}
onLogout={logout}
appContext={appContext}
/>
);
}

View File

@@ -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).

View File

@@ -42,7 +42,7 @@ const Page: React.FC<PageProps> = ({ 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<PageProps> = ({ 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<PageProps> = ({ appContext }) => {
return (
<VerifyingPasskey
email={email}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
onRetry={() =>
openPasskeyVerificationURL(...passkeyVerificationData)
openPasskeyVerificationURL(passkeyVerificationData)
}
onRecover={() => router.push("/passkeys/recover")}
onLogout={logout}
appContext={appContext}
/>
);
}

View File

@@ -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<TwoFactorAuthorizationResponse | undefined> => {
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";
};

View File

@@ -48,3 +48,13 @@ export const authenticatedRequestHeaders = (): Record<string, string> => {
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<string, string> => {
const headers: Record<string, string> = {};
if (_clientPackage) headers["X-Client-Package"] = _clientPackage;
return headers;
};

View File

@@ -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.",

View File

@@ -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),
});

View File

@@ -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"),
});

View File

@@ -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<VerifyingPasskeyProps> = ({
passkeySessionID,
email,
onRetry,
onRecover,
onLogout,
appContext,
}) => {
const { logout, setDialogBoxAttributesV2 } = appContext;
type VerificationStatus = "waiting" | "checking" | "pending";
const [verificationStatus, setVerificationStatus] =
useState<VerificationStatus>("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 (
<VerticallyCentered>
<FormPaper style={{ minWidth: "320px" }}>
<PasskeyHeader>{email ?? ""}</PasskeyHeader>
<VerifyingPasskeyMiddle>
<Typography color="text.muted">
{t("waiting_for_verification")}
</Typography>
<VerifyingPasskeyStatus>
{verificationStatus == "checking" ? (
<Typography>
<CircularProgress color="accent" size="1.5em" />
</Typography>
) : (
<Typography color="text.muted">
{verificationStatus == "waiting"
? t("waiting_for_verification")
: t("verification_still_pending")}
</Typography>
)}
</VerifyingPasskeyStatus>
<ButtonStack>
<EnteButton
onClick={onRetry}
onClick={handleRetry}
fullWidth
color="secondary"
type="button"
@@ -88,23 +150,22 @@ export const VerifyingPasskey: React.FC<VerifyingPasskeyProps> = ({
{t("try_again")}
</EnteButton>
{/* TODO-PK: Uncomment once the API is ready
<EnteButton
onClick={() => {}}
onClick={handleCheckStatus}
fullWidth
color="accent"
type="button"
>
{"Check status"}
</EnteButton> */}
{t("check_status")}
</EnteButton>
</ButtonStack>
</VerifyingPasskeyMiddle>
<FormPaperFooter style={{ justifyContent: "space-between" }}>
<LinkButton onClick={onRecover}>
<LinkButton onClick={handleRecover}>
{t("RECOVER_ACCOUNT")}
</LinkButton>
<LinkButton onClick={onLogout}>
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</FormPaperFooter>
@@ -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",
},
});