From 78eaa32bba5065d766f76e44ffeecb7df0002b46 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Sun, 3 Aug 2025 14:13:11 +0300 Subject: [PATCH 01/13] feat: add WebAuthn credential management methods --- CHANGELOG.md | 2 + lib/build/recipe/webauthn/index.d.ts | 57 ++++++++++++++++++++++++++++ lib/build/recipe/webauthn/types.d.ts | 5 ++- lib/build/webauthn.js | 15 ++++++++ lib/ts/recipe/webauthn/index.ts | 48 +++++++++++++++++++++++ lib/ts/recipe/webauthn/types.ts | 5 ++- 6 files changed, 130 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aacc5e850..ef87d2459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +- Add WebAuthn credential management methods: `listCredentials`, `removeCredential`, `registerCredential2` + ## [0.49.1] - 2025-03-27 - Fixed a type issue making the WebauthnPreBuitlUI not produce a type error when added to the prebuiltUIList diff --git a/lib/build/recipe/webauthn/index.d.ts b/lib/build/recipe/webauthn/index.d.ts index e47ec016e..454bb5d56 100644 --- a/lib/build/recipe/webauthn/index.d.ts +++ b/lib/build/recipe/webauthn/index.d.ts @@ -389,6 +389,57 @@ export default class Wrapper { error: any; } >; + static listCredentials(input: { options?: RecipeFunctionOptions; userContext: any }): Promise< + | { + status: "OK"; + credentials: { + webauthnCredentialId: string; + relyingPartyId: string; + recipeUserId: string; + createdAt: number; + }[]; + } + | GeneralErrorResponse + >; + static removeCredential(input: { webauthnCredentialId: string; userContext: any }): Promise< + | { + status: "OK"; + } + | GeneralErrorResponse + | { + status: "CREDENTIAL_NOT_FOUND_ERROR"; + fetchResponse: Response; + } + >; + static registerCredential2(input: { + recipeUserId: string; + webauthnGeneratedOptionsId: string; + credential: RegistrationResponseJSON; + options?: RecipeFunctionOptions; + userContext: any; + }): Promise< + | { + status: "OK"; + } + | GeneralErrorResponse + | { + status: "REGISTER_CREDENTIAL_NOT_ALLOWED"; + reason: string; + } + | { + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "OPTIONS_NOT_FOUND_ERROR"; + } + | { + status: "INVALID_OPTIONS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + >; static doesBrowserSupportWebAuthn(input: { userContext: any }): Promise< | { status: "OK"; @@ -419,6 +470,9 @@ declare const authenticateCredential: typeof Wrapper.authenticateCredential; declare const registerCredentialWithSignUp: typeof Wrapper.registerCredentialWithSignUp; declare const authenticateCredentialWithSignIn: typeof Wrapper.authenticateCredentialWithSignIn; declare const registerCredentialWithRecoverAccount: typeof Wrapper.registerCredentialWithRecoverAccount; +declare const listCredentials: typeof Wrapper.listCredentials; +declare const removeCredential: typeof Wrapper.removeCredential; +declare const registerCredential2: typeof Wrapper.registerCredential2; declare const doesBrowserSupportWebAuthn: typeof Wrapper.doesBrowserSupportWebAuthn; declare const WebauthnComponentsOverrideProvider: import("react").FC< import("react").PropsWithChildren<{ @@ -441,4 +495,7 @@ export { registerCredentialWithRecoverAccount, doesBrowserSupportWebAuthn, WebauthnComponentsOverrideProvider, + listCredentials, + removeCredential, + registerCredential2, }; diff --git a/lib/build/recipe/webauthn/types.d.ts b/lib/build/recipe/webauthn/types.d.ts index 2fdb40499..cace33386 100644 --- a/lib/build/recipe/webauthn/types.d.ts +++ b/lib/build/recipe/webauthn/types.d.ts @@ -38,7 +38,10 @@ export declare type PreAndPostAPIHookAction = | "SIGN_IN" | "EMAIL_EXISTS" | "GENERATE_RECOVER_ACCOUNT_TOKEN" - | "RECOVER_ACCOUNT"; + | "RECOVER_ACCOUNT" + | "REGISTER_CREDENTIAL" + | "REMOVE_CREDENTIAL" + | "LIST_CREDENTIALS"; export declare type OnHandleEventContext = | { action: "SUCCESS"; diff --git a/lib/build/webauthn.js b/lib/build/webauthn.js index a412b3e76..867031618 100644 --- a/lib/build/webauthn.js +++ b/lib/build/webauthn.js @@ -79,6 +79,15 @@ var Wrapper = /** @class */ (function () { Wrapper.registerCredentialWithRecoverAccount = function (input) { return recipe.Webauthn.getInstanceOrThrow().webJSRecipe.registerCredentialWithRecoverAccount(input); }; + Wrapper.listCredentials = function (input) { + return recipe.Webauthn.getInstanceOrThrow().webJSRecipe.listCredentials(input); + }; + Wrapper.removeCredential = function (input) { + return recipe.Webauthn.getInstanceOrThrow().webJSRecipe.removeCredential(input); + }; + Wrapper.registerCredential2 = function (input) { + return recipe.Webauthn.getInstanceOrThrow().webJSRecipe.registerCredential2(input); + }; Wrapper.doesBrowserSupportWebAuthn = function (input) { return recipe.Webauthn.getInstanceOrThrow().webJSRecipe.doesBrowserSupportWebAuthn(input); }; @@ -98,6 +107,9 @@ var authenticateCredential = Wrapper.authenticateCredential; var registerCredentialWithSignUp = Wrapper.registerCredentialWithSignUp; var authenticateCredentialWithSignIn = Wrapper.authenticateCredentialWithSignIn; var registerCredentialWithRecoverAccount = Wrapper.registerCredentialWithRecoverAccount; +var listCredentials = Wrapper.listCredentials; +var removeCredential = Wrapper.removeCredential; +var registerCredential2 = Wrapper.registerCredential2; var doesBrowserSupportWebAuthn = Wrapper.doesBrowserSupportWebAuthn; var WebauthnComponentsOverrideProvider = Wrapper.ComponentsOverrideProvider; @@ -111,9 +123,12 @@ exports.getEmailExists = getEmailExists; exports.getRegisterOptions = getRegisterOptions; exports.getSignInOptions = getSignInOptions; exports.init = init; +exports.listCredentials = listCredentials; exports.recoverAccount = recoverAccount; exports.registerCredential = registerCredential; +exports.registerCredential2 = registerCredential2; exports.registerCredentialWithRecoverAccount = registerCredentialWithRecoverAccount; exports.registerCredentialWithSignUp = registerCredentialWithSignUp; +exports.removeCredential = removeCredential; exports.signIn = signIn; exports.signUp = signUp; diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index 4dfa3fbdb..ef87fb60b 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -439,6 +439,48 @@ export default class Wrapper { return Webauthn.getInstanceOrThrow().webJSRecipe.registerCredentialWithRecoverAccount(input); } + static listCredentials(input: { options?: RecipeFunctionOptions; userContext: any }): Promise< + | { + status: "OK"; + credentials: { + webauthnCredentialId: string; + relyingPartyId: string; + recipeUserId: string; + createdAt: number; + }[]; + } + | GeneralErrorResponse + > { + return Webauthn.getInstanceOrThrow().webJSRecipe.listCredentials(input); + } + + static removeCredential(input: { + webauthnCredentialId: string; + userContext: any; + }): Promise< + { status: "OK" } | GeneralErrorResponse | { status: "CREDENTIAL_NOT_FOUND_ERROR"; fetchResponse: Response } + > { + return Webauthn.getInstanceOrThrow().webJSRecipe.removeCredential(input); + } + + static registerCredential2(input: { + recipeUserId: string; + webauthnGeneratedOptionsId: string; + credential: RegistrationResponseJSON; + options?: RecipeFunctionOptions; + userContext: any; + }): Promise< + | { status: "OK" } + | GeneralErrorResponse + | { status: "REGISTER_CREDENTIAL_NOT_ALLOWED"; reason: string } + | { status: "INVALID_CREDENTIALS_ERROR" } + | { status: "OPTIONS_NOT_FOUND_ERROR" } + | { status: "INVALID_OPTIONS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + > { + return Webauthn.getInstanceOrThrow().webJSRecipe.registerCredential2(input); + } + static doesBrowserSupportWebAuthn(input: { userContext: any }): Promise< | { status: "OK"; @@ -469,6 +511,9 @@ const authenticateCredential = Wrapper.authenticateCredential; const registerCredentialWithSignUp = Wrapper.registerCredentialWithSignUp; const authenticateCredentialWithSignIn = Wrapper.authenticateCredentialWithSignIn; const registerCredentialWithRecoverAccount = Wrapper.registerCredentialWithRecoverAccount; +const listCredentials = Wrapper.listCredentials; +const removeCredential = Wrapper.removeCredential; +const registerCredential2 = Wrapper.registerCredential2; const doesBrowserSupportWebAuthn = Wrapper.doesBrowserSupportWebAuthn; const WebauthnComponentsOverrideProvider = Wrapper.ComponentsOverrideProvider; @@ -488,4 +533,7 @@ export { registerCredentialWithRecoverAccount, doesBrowserSupportWebAuthn, WebauthnComponentsOverrideProvider, + listCredentials, + removeCredential, + registerCredential2, }; diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index ec4b28081..6da6d01e1 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -57,7 +57,10 @@ export type PreAndPostAPIHookAction = | "SIGN_IN" | "EMAIL_EXISTS" | "GENERATE_RECOVER_ACCOUNT_TOKEN" - | "RECOVER_ACCOUNT"; + | "RECOVER_ACCOUNT" + | "REGISTER_CREDENTIAL" + | "REMOVE_CREDENTIAL" + | "LIST_CREDENTIALS"; export type OnHandleEventContext = | { From 7cf150dab8821b9ace6e038a019f9a1857f7b79f Mon Sep 17 00:00:00 2001 From: Bogdan Carpusor Date: Thu, 17 Jul 2025 16:13:24 +0300 Subject: [PATCH 02/13] feat: Add support for webauthn in MFA Update build Add paths Add component Add sign in Add forms --- lib/ts/components/assets/passkeyIcon.tsx | 6 +- .../components/themes/translations.ts | 3 + .../components/features/mfa/index.tsx | 375 ++++++++++++++++++ .../webauthn/components/themes/mfa/index.tsx | 342 ++++++++++++++++ .../webauthn/components/themes/styles.css | 8 + .../components/themes/translations.ts | 8 + lib/ts/recipe/webauthn/constants.ts | 2 + lib/ts/recipe/webauthn/index.ts | 9 +- lib/ts/recipe/webauthn/prebuiltui.tsx | 29 +- lib/ts/recipe/webauthn/recipe.tsx | 19 +- lib/ts/recipe/webauthn/types.ts | 48 +++ package.json | 2 +- 12 files changed, 842 insertions(+), 9 deletions(-) create mode 100644 lib/ts/recipe/webauthn/components/features/mfa/index.tsx create mode 100644 lib/ts/recipe/webauthn/components/themes/mfa/index.tsx diff --git a/lib/ts/components/assets/passkeyIcon.tsx b/lib/ts/components/assets/passkeyIcon.tsx index 47804db70..cbd97a99e 100644 --- a/lib/ts/components/assets/passkeyIcon.tsx +++ b/lib/ts/components/assets/passkeyIcon.tsx @@ -17,10 +17,10 @@ export default function PasskeyIcon(): JSX.Element { return ( ); diff --git a/lib/ts/recipe/multifactorauth/components/themes/translations.ts b/lib/ts/recipe/multifactorauth/components/themes/translations.ts index f4dd3e010..fc32665b7 100644 --- a/lib/ts/recipe/multifactorauth/components/themes/translations.ts +++ b/lib/ts/recipe/multifactorauth/components/themes/translations.ts @@ -16,6 +16,9 @@ export const defaultTranslationsMultiFactorAuth = { TOTP_MFA_NAME: "TOTP", TOTP_MFA_DESCRIPTION: "Use an authenticator app to complete the authentication request", + WEBAUTHN_MFA_NAME: "Passkeys", + WEBAUTHN_MFA_DESCRIPTION: "Use a passkey to complete the authentication request", + MFA_NO_AVAILABLE_OPTIONS: "You have no available secondary factors.", MFA_NO_AVAILABLE_OPTIONS_LOGIN: "You have no available secondary factors and cannot complete the sign-in process. Please contact support.", diff --git a/lib/ts/recipe/webauthn/components/features/mfa/index.tsx b/lib/ts/recipe/webauthn/components/features/mfa/index.tsx new file mode 100644 index 000000000..60c09a13d --- /dev/null +++ b/lib/ts/recipe/webauthn/components/features/mfa/index.tsx @@ -0,0 +1,375 @@ +/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ +/* + * Imports. + */ +import * as React from "react"; +import { Fragment } from "react"; +import { WindowHandlerReference } from "supertokens-web-js/utils/windowHandler"; + +import { ComponentOverrideContext } from "../../../../../components/componentOverride/componentOverrideContext"; +import FeatureWrapper from "../../../../../components/featureWrapper"; +import SuperTokens from "../../../../../superTokens"; +import { redirectToAuth } from "../../../../.."; +import SessionRecipe from "../../../../session/recipe"; +import { getAvailableFactors } from "../../../../multifactorauth/utils"; +import { useUserContext } from "../../../../../usercontext"; +import { defaultTranslationsWebauthn } from "../../themes/translations"; +import { FactorIds } from "../../../../multifactorauth/types"; +import MFAThemeWrapper from "../../themes/mfa"; +import MultiFactorAuth from "../../../../multifactorauth/recipe"; +import type { FieldState } from "../../../../emailpassword/components/library/formBase"; +import type { APIFormField } from "../../../../../types"; +import { + getQueryParams, + getRedirectToPathFromURL, + useOnMountAPICall, + handleCallAPI, + useRethrowInRender, +} from "../../../../../utils"; + +import type { FeatureBaseProps, UserContext, Navigate } from "../../../../../types"; +import type Recipe from "../../../recipe"; +import type { ComponentOverrideMap, WebAuthnMFAAction, WebAuthnMFAProps, WebAuthnMFAState } from "../../../types"; +import type { RecipeInterface } from "supertokens-web-js/recipe/webauthn"; + +export const useFeatureReducer = (): [WebAuthnMFAState, React.Dispatch] => { + return React.useReducer( + (oldState: WebAuthnMFAState, action: WebAuthnMFAAction): WebAuthnMFAState => { + switch (action.type) { + case "setError": + return { + ...oldState, + error: action.error, + accessDenied: action.accessDenied || false, + }; + case "load": + return { + ...oldState, + loaded: true, + deviceSupported: action.deviceSupported, + email: action.email, + showBackButton: action.showBackButton, + }; + default: + return oldState; + } + }, + { + error: undefined, + deviceSupported: false, + loaded: false, + showBackButton: true, + email: "", + accessDenied: false, + } + ); +}; + +export function useChildProps( + recipe: Recipe, + recipeImplementation: RecipeInterface, + state: WebAuthnMFAState, + dispatch: React.Dispatch, + userContext: UserContext, + navigate?: Navigate +): Omit { + const rethrowInRender = useRethrowInRender(); + const callSignInAPI = React.useCallback( + async (_: APIFormField[], __: (id: string, value: string) => any) => { + const response = await recipeImplementation.authenticateCredentialWithSignIn({ + shouldTryLinkingWithSessionUser: true, + userContext, + }); + + switch (response.status) { + case "INVALID_CREDENTIALS_ERROR": + dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_INVALID_CREDENTIALS_ERROR" }); + break; + case "FAILED_TO_AUTHENTICATE_USER": + case "INVALID_OPTIONS_ERROR": + dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_RECOVERABLE_ERROR" }); + break; + case "WEBAUTHN_NOT_SUPPORTED": + dispatch({ type: "setError", error: "WEBAUTHN_NOT_SUPPORTED_ERROR" }); + break; + } + + return response; + }, + [recipeImplementation, userContext] + ); + + const callSignUpAPI = React.useCallback( + async (email: string, _: APIFormField[], __: (id: string, value: string) => any) => { + const response = await recipeImplementation.registerCredentialWithSignUp({ + email, + shouldTryLinkingWithSessionUser: true, + userContext, + }); + + if (response.status !== "OK") { + dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_RECOVERABLE_ERROR" }); + } + + if (response.status === "EMAIL_ALREADY_EXISTS_ERROR") { + dispatch({ type: "setError", error: "WEBAUTHN_EMAIL_ALREADY_EXISTS_ERROR" }); + } + + if (response.status === "WEBAUTHN_NOT_SUPPORTED") { + dispatch({ type: "setError", error: "WEBAUTHN_NOT_SUPPORTED_ERROR" }); + } + + return response; + }, + [state] + ); + + const onSuccess = React.useCallback(() => { + const redirectToPath = getRedirectToPathFromURL(); + + return SessionRecipe.getInstanceOrThrow() + .validateGlobalClaimsAndHandleSuccessRedirection( + undefined, + recipe.recipeID, + redirectToPath, + userContext, + navigate + ) + .catch(rethrowInRender); + }, [recipe, userContext, navigate]); + + return React.useMemo(() => { + return { + onSignIn: async () => { + const fieldUpdates: FieldState[] = []; + try { + const { result, generalError, fetchError } = await handleCallAPI({ + apiFields: [], + fieldUpdates, + callAPI: callSignInAPI, + }); + + if (generalError !== undefined) { + dispatch({ type: "setError", error: generalError.message }); + } else if (fetchError !== undefined) { + dispatch({ type: "setError", error: "Failed to fetch from upstream" }); + } else if (result.status === "OK") { + dispatch({ type: "setError", error: undefined }); + onSuccess(); + } + } catch (e) { + dispatch({ type: "setError", error: "SOMETHING_WENT_WRONG_ERROR" }); + } + }, + onSignUp: async (email: string) => { + const fieldUpdates: FieldState[] = []; + + try { + const { result, generalError, fetchError } = await handleCallAPI({ + apiFields: [], + fieldUpdates, + callAPI: (...params) => callSignUpAPI(email, ...params), + }); + + if (generalError !== undefined) { + dispatch({ type: "setError", error: generalError.message }); + } else if (fetchError !== undefined) { + dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_RECOVERABLE_ERROR" }); + } else if (result?.status === "OK") { + dispatch({ type: "setError", error: undefined }); + onSuccess(); + } + } catch (e) { + dispatch({ type: "setError", error: "SOMETHING_WENT_WRONG_ERROR" }); + console.error("error", e); + } + }, + onSignOutClicked: async () => { + await SessionRecipe.getInstanceOrThrow().signOut({ userContext }); + await redirectToAuth({ redirectBack: false, navigate: navigate }); + }, + onBackButtonClicked: async () => { + // If we don't have navigate available this would mean we are using react-router-dom, so we use window's history + if (navigate === undefined) { + return WindowHandlerReference.getReferenceOrThrow().windowHandler.getWindowUnsafe().history.back(); + } + // If we do have navigate and goBack function on it this means we are using react-router-dom v5 or lower + if ("goBack" in navigate) { + return navigate.goBack(); + } + // If we reach this code this means we are using react-router-dom v6 + return navigate(-1); + }, + onRecoverAccountClick: () => { + recipe.redirect( + { action: "SEND_RECOVERY_EMAIL", tenantIdFromQueryParams: "" }, + navigate, + {}, + userContext + ); + }, + recipeImplementation: recipeImplementation, + config: recipe.config, + }; + }, [recipeImplementation, state, recipe, userContext, navigate]); +} + +export const MFAFeature: React.FC< + FeatureBaseProps<{ + recipe: Recipe; + useComponentOverrides: () => ComponentOverrideMap; + }> +> = (props) => { + const recipeComponentOverrides = props.useComponentOverrides(); + + return ( + + + + + + ); +}; + +export default MFAFeature; + +const MFAFeatureInner: React.FC< + FeatureBaseProps<{ + recipe: Recipe; + useComponentOverrides: () => ComponentOverrideMap; + }> +> = (props) => { + const userContext = useUserContext(); + const [state, dispatch] = useFeatureReducer(); + + const childProps = useChildProps( + props.recipe, + props.recipe.webJSRecipe as RecipeInterface, + state, + dispatch, + userContext, + props.navigate + )!; + + useOnLoad(props, props.recipe.webJSRecipe as RecipeInterface, dispatch, userContext); + + return ( + + {/* No custom theme, use default. */} + {props.children === undefined && ( + + )} + + {/* Otherwise, custom theme is provided, propagate props. */} + {props.children && + React.Children.map(props.children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + ...childProps, + featureState: state, + dispatch: dispatch, + }); + } + return child; + })} + + ); +}; + +function useOnLoad( + props: React.PropsWithChildren< + { navigate?: Navigate } & { children?: React.ReactNode } & { + recipe: Recipe; + useComponentOverrides: () => ComponentOverrideMap; + } + >, + recipeImplementation: RecipeInterface, + dispatch: React.Dispatch, + userContext: UserContext +) { + const fetchMFAInfo = React.useCallback( + async () => MultiFactorAuth.getInstanceOrThrow().webJSRecipe.resyncSessionAndFetchMFAInfo({ userContext }), + [userContext] + ); + + const handleLoadError = React.useCallback( + () => dispatch({ type: "setError", accessDenied: true, error: "SOMETHING_WENT_WRONG_ERROR_RELOAD" }), + [dispatch] + ); + + const onLoad = React.useCallback( + async (mfaInfo: Awaited>) => { + console.log("onLoad", mfaInfo); + let error: string | undefined = undefined; + const errorQueryParam = getQueryParams("error"); + const doSetup = getQueryParams("setup"); + const stepUp = getQueryParams("stepUp"); + + if (errorQueryParam !== null) { + error = "SOMETHING_WENT_WRONG_ERROR"; + } + + if (mfaInfo.factors.next.length === 0 && stepUp !== "true" && doSetup !== "true") { + const redirectToPath = getRedirectToPathFromURL(); + try { + await SessionRecipe.getInstanceOrThrow().validateGlobalClaimsAndHandleSuccessRedirection( + undefined, + props.recipe.recipeID, + redirectToPath, + userContext, + props.navigate + ); + } catch { + // If we couldn't redirect to EV (or an unknown claim validation failed or somehow the redirection threw an error) + // we fall back to showing the something went wrong error + dispatch({ + type: "setError", + accessDenied: true, + error: "SOMETHING_WENT_WRONG_ERROR_RELOAD", + }); + } + } + + // If the next array only has a single option, it means the we were redirected here + // automatically during the sign in process. In that case, anywhere the back button + // could go would redirect back here, making it useless. + const showBackButton = + mfaInfo.factors.next.length === 0 || + getAvailableFactors(mfaInfo.factors, undefined, MultiFactorAuth.getInstanceOrThrow(), userContext) + .length !== 1; + + const mfaInfoEmails = mfaInfo.emails[FactorIds.WEBAUTHN]; + const email = mfaInfoEmails ? mfaInfoEmails[0] : undefined; + + const browserSupportsWebauthn = await props.recipe.webJSRecipe.doesBrowserSupportWebAuthn({ + userContext: userContext, + }); + + dispatch({ + type: "load", + error, + showBackButton, + email, + deviceSupported: browserSupportsWebauthn.status === "OK", + }); + }, + [dispatch, recipeImplementation, props.recipe, userContext] + ); + + useOnMountAPICall(fetchMFAInfo, onLoad, handleLoadError); +} diff --git a/lib/ts/recipe/webauthn/components/themes/mfa/index.tsx b/lib/ts/recipe/webauthn/components/themes/mfa/index.tsx new file mode 100644 index 000000000..fbeca3202 --- /dev/null +++ b/lib/ts/recipe/webauthn/components/themes/mfa/index.tsx @@ -0,0 +1,342 @@ +import * as React from "react"; +import STGeneralError from "supertokens-web-js/utils/error"; +import { Fragment } from "react"; +import { defaultEmailValidator } from "../../../../emailpassword/validators"; +import Button from "../../../../emailpassword/components/library/button"; +import SpinnerIcon from "../../../../../components/assets/spinnerIcon"; +import PasskeyIcon from "../../../../../components/assets/passkeyIcon"; +import { WebAuthnMFAProps } from "../../../types"; +import SuperTokens from "../../../../../superTokens"; +import FormBase from "../../../../emailpassword/components/library/formBase"; +import { Label } from "../../../../emailpassword/components/library"; +import { useTranslation } from "../../../../../translation/translationContext"; +import UserContextWrapper from "../../../../../usercontext/userContextWrapper"; +import GeneralError from "../../../../emailpassword/components/library/generalError"; +import { ThemeBase } from "../themeBase"; +import BackButton from "../../../../emailpassword/components/library/backButton"; +import { SuperTokensBranding } from "../../../../../components/SuperTokensBranding"; +import { withOverride } from "../../../../../components/componentOverride/withOverride"; +import { PasskeyFeatureBlockList } from "../signUp/featureBlocks"; +import { PasskeyNotSupportedError } from "../error/passkeyNotSupportedError"; + +export enum MFAScreens { + SignIn, + SignUp, + SignUpConfirmation, +} + +function MFAThemeWrapper(props: WebAuthnMFAProps): JSX.Element { + const rootStyle = SuperTokens.getInstanceOrThrow().rootStyle; + + return ( + + + + + + ); +} + +export default MFAThemeWrapper; + +export function MFATheme(props: WebAuthnMFAProps): JSX.Element { + const { onBackButtonClicked, onSignIn } = props; + const [activeScreen, setActiveScreen] = React.useState(MFAScreens.SignIn); + const [signUpEmail, setSignUpEmail] = React.useState(""); + + const onRegisterPasskeyClick = React.useCallback(() => { + if (props.featureState.email) { + setActiveScreen(MFAScreens.SignUpConfirmation); + } else { + setActiveScreen(MFAScreens.SignUp); + } + }, [props.featureState.email]); + + const onSignUpContinue = React.useCallback((email: string) => { + setActiveScreen(MFAScreens.SignUpConfirmation); + setSignUpEmail(email); + }, []); + + const clearError = React.useCallback(() => { + props.dispatch({ type: "setError", error: undefined }); + }, [props]); + + const onError = React.useCallback( + (error: string) => { + props.dispatch({ type: "setError", error }); + }, + [props] + ); + + const onClickSignUpBackButton = React.useCallback(() => { + setActiveScreen(MFAScreens.SignIn); + }, []); + + const onClickSignUpConfirmationBackButton = React.useCallback(() => { + if (props.featureState.email) { + setActiveScreen(MFAScreens.SignIn); + } else { + setActiveScreen(MFAScreens.SignUp); + } + }, [props.featureState.email]); + + const onFetchError = React.useCallback(() => { + onError("SOMETHING_WENT_WRONG_ERROR"); + }, [onError]); + + if (!props.featureState.loaded) { + return ; + } + + return ( +
+
+ {activeScreen === MFAScreens.SignIn ? ( + + ) : activeScreen === MFAScreens.SignUp ? ( + + ) : ( + + )} +
+ +
+ ); +} + +export const WebauthnMFALoadingScreen = withOverride("WebauthnMFALoadingScreen", function WebauthnMFALoadingScreen() { + return ( +
+
+
+ +
+
+
+ ); +}); + +type MFASignInProps = { + onBackButtonClicked?: () => void; + onSignIn: () => Promise; + onRegisterPasskeyClick: () => void; + error: string | undefined; + deviceSupported: boolean; +}; + +export const WebauthnMFASignIn = withOverride( + "WebauthnMFASignIn", + function WebauthnMFASignIn(props: MFASignInProps): JSX.Element { + const t = useTranslation(); + const [isLoading, setIsLoading] = React.useState(false); + + const onClick = React.useCallback(async () => { + setIsLoading(true); + await props.onSignIn(); + setIsLoading(false); + }, [props]); + + return ( + + {props.onBackButtonClicked ? ( +
+ + {t("WEBAUTHN_MFA_SIGN_IN_HEADER_TITLE")} + + {/* empty span for spacing the back button */} + +
+ ) : ( +
{t("WEBAUTHN_MFA_SIGN_IN_HEADER_TITLE")}
+ )} +
{t("WEBAUTHN_MFA_SIGN_IN_HEADER_SUBTITLE")}
+