[desktop] Add a check status button to the passkey waiting page (#2132)
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 1.1 KiB |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
11
web/packages/shared/components/ErrorComponents.tsx
Normal file
11
web/packages/shared/components/ErrorComponents.tsx
Normal 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"),
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user