diff --git a/.changeset/funny-berries-nail.md b/.changeset/funny-berries-nail.md new file mode 100644 index 00000000000..fd64c2d070b --- /dev/null +++ b/.changeset/funny-berries-nail.md @@ -0,0 +1,14 @@ +--- +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +--- + +Add support for GoogleOneTap +### React component +- `` + + Customize the UX of the prompt + +```tsx + +``` diff --git a/.changeset/mighty-dolphins-look.md b/.changeset/mighty-dolphins-look.md new file mode 100644 index 00000000000..9132439216b --- /dev/null +++ b/.changeset/mighty-dolphins-look.md @@ -0,0 +1,37 @@ +--- +'@clerk/clerk-js': minor +--- + +### Use the Google One Tap component from with Vanilla JS +- `Clerk.openGoogleOneTap({ cancelOnTapOutside: false, fedCmSupport: false, itpSupport: false })` +- `Clerk.closeGoogleOneTap()` +### Low level APIs for custom flows +- `await Clerk.authenticateWithGoogleOneTap({ token: 'xxxx'})` +- `await Clerk.handleGoogleOneTapCallback()` + + +We recommend using this two methods together in order and let Clerk perform the correct redirections. +```tsx +google.accounts.id.initialize({ + callback: async response => { + const signInOrUp = await Clerk.authenticateWithGoogleOneTap({ token: response.credential}) + await Clerk.handleGoogleOneTapCallback(signInOrUp, { + afterSignInUrl: window.location.href, + }) + }, +}); +``` + +In case you want to handle the redirection and session management yourself you can do so like this +```tsx +google.accounts.id.initialize({ + callback: async response => { + const signInOrUp = await Clerk.authenticateWithGoogleOneTap({ token: response.credential}) + if(signInOrUp.status === 'complete') { + await Clerk.setActive({ + session: signInOrUp.createdSessionId + }) + } + }, +}); +``` diff --git a/.changeset/shaggy-elephants-shop.md b/.changeset/shaggy-elephants-shop.md new file mode 100644 index 00000000000..c7e5d2b4274 --- /dev/null +++ b/.changeset/shaggy-elephants-shop.md @@ -0,0 +1,5 @@ +--- +'@clerk/chrome-extension': minor +--- + +Update export snapshot tests to include `GoogleOneTap`. diff --git a/.changeset/silly-mugs-type.md b/.changeset/silly-mugs-type.md new file mode 100644 index 00000000000..a6029c59763 --- /dev/null +++ b/.changeset/silly-mugs-type.md @@ -0,0 +1,21 @@ +--- +'@clerk/types': minor +--- + +Added the following types +```tsx +interface Clerk { + ... + openGoogleOneTap: (props?: GoogleOneTapProps) => void; + closeGoogleOneTap: () => void; + authenticateWithGoogleOneTap: (params: AuthenticateWithGoogleOneTapParams) => Promise; + handleGoogleOneTapCallback: ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + customNavigate?: (to: string) => Promise, + ) => Promise; + ... +} + +type GoogleOneTapStrategy = 'google_one_tap' +``` diff --git a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap index 374c803e3af..08dbadcf503 100644 --- a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap @@ -8,6 +8,7 @@ exports[`public exports should not include a breaking change 1`] = ` "ClerkProvider", "CreateOrganization", "EmailLinkErrorCode", + "GoogleOneTap", "MagicLinkErrorCode", "MultisessionAppSupport", "OrganizationList", @@ -32,7 +33,6 @@ exports[`public exports should not include a breaking change 1`] = ` "WithClerk", "WithSession", "WithUser", - "__experimental_GoogleOneTap", "__internal__setErrorThrowerOptions", "isClerkAPIResponseError", "isEmailLinkError", diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 3bc9144f629..4a9cfe2af1e 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -5,6 +5,7 @@ import { handleValueOrFn, inBrowser as inClientSide, is4xxError, + isClerkAPIResponseError, isHttpOrHttps, isLegacyFrontendApiKey, isValidBrowserOnline, @@ -17,6 +18,7 @@ import { } from '@clerk/shared'; import type { ActiveSessionResource, + AuthenticateWithGoogleOneTapParams, AuthenticateWithMetamaskParams, BeforeEmitCallback, Clerk as ClerkInterface, @@ -28,12 +30,12 @@ import type { DomainOrProxyUrl, EnvironmentJSON, EnvironmentResource, + GoogleOneTapProps, HandleEmailLinkVerificationParams, HandleMagicLinkVerificationParams, HandleOAuthCallbackParams, InstanceType, ListenerCallback, - OneTapProps, OrganizationInvitationResource, OrganizationListProps, OrganizationMembershipResource, @@ -364,6 +366,18 @@ export default class Clerk implements ClerkInterface { } }; + public openGoogleOneTap = (props?: GoogleOneTapProps): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls + .ensureMounted({ preloadHint: 'GoogleOneTap' }) + .then(controls => controls.openModal('googleOneTap', props || {})); + }; + + public closeGoogleOneTap = (): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.closeModal('googleOneTap')); + }; + public openSignIn = (props?: SignInProps): void => { this.assertComponentsReady(this.#componentControls); if (sessionExistsAndSingleSessionModeEnabled(this, this.#environment) && this.#instanceType === 'development') { @@ -457,30 +471,6 @@ export default class Clerk implements ClerkInterface { ); }; - public __experimental_mountGoogleOneTap = (node: HTMLDivElement, props?: OneTapProps): void => { - this.assertComponentsReady(this.#componentControls); - - void this.#componentControls.ensureMounted({ preloadHint: 'OneTap' }).then(controls => - controls.mountComponent({ - name: 'OneTap', - appearanceKey: 'oneTap', - node, - props, - }), - ); - // TODO-ONETAP: Enable telemetry one feature is ready for public beta - // this.telemetry?.record(eventComponentMounted('GoogleOneTap', props)); - }; - - public __experimental_unmountGoogleOneTap = (node: HTMLDivElement): void => { - this.assertComponentsReady(this.#componentControls); - void this.#componentControls.ensureMounted().then(controls => - controls.unmountComponent({ - node, - }), - ); - }; - public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls.ensureMounted({ preloadHint: 'SignUp' }).then(controls => @@ -966,14 +956,47 @@ export default class Clerk implements ClerkInterface { return null; }; - public handleRedirectCallback = async ( - params: HandleOAuthCallbackParams = {}, + public handleGoogleOneTapCallback = async ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, customNavigate?: (to: string) => Promise, ): Promise => { if (!this.#isReady || !this.#environment || !this.client) { return; } - const { signIn, signUp } = this.client; + const { signIn: _signIn, signUp: _signUp } = this.client; + + const signIn = 'identifier' in (signInOrUp || {}) ? (signInOrUp as SignInResource) : _signIn; + const signUp = 'missingFields' in (signInOrUp || {}) ? (signInOrUp as SignUpResource) : _signUp; + + const navigate = (to: string) => + customNavigate && typeof customNavigate === 'function' + ? customNavigate(this.buildUrlWithAuth(to)) + : this.navigate(this.buildUrlWithAuth(to)); + + return this._handleRedirectCallback(params, { + signUp, + signIn, + navigate, + }); + }; + + private _handleRedirectCallback = async ( + params: HandleOAuthCallbackParams, + { + signIn, + signUp, + navigate, + }: { + signIn: SignInResource; + signUp: SignUpResource; + navigate: (to: string) => Promise; + }, + ): Promise => { + if (!this.loaded || !this.#environment || !this.client) { + return; + } + const { displayConfig } = this.#environment; const { firstFactorVerification } = signIn; const { externalAccount } = signUp.verifications; @@ -983,6 +1006,7 @@ export default class Clerk implements ClerkInterface { externalAccountStatus: externalAccount.status, externalAccountErrorCode: externalAccount.error?.code, externalAccountSessionId: externalAccount.error?.meta?.sessionId, + sessionId: signUp.createdSessionId, }; const si = { @@ -990,11 +1014,9 @@ export default class Clerk implements ClerkInterface { firstFactorVerificationStatus: firstFactorVerification.status, firstFactorVerificationErrorCode: firstFactorVerification.error?.code, firstFactorVerificationSessionId: firstFactorVerification.error?.meta?.sessionId, + sessionId: signIn.createdSessionId, }; - const navigate = (to: string) => - customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to); - const makeNavigate = (to: string) => () => navigate(to); const navigateToSignIn = makeNavigate(displayConfig.signInUrl); @@ -1026,7 +1048,13 @@ export default class Clerk implements ClerkInterface { const navigateToContinueSignUp = makeNavigate( params.continueSignUpUrl || - buildURL({ base: displayConfig.signUpUrl, hashPath: '/continue' }, { stringify: true }), + buildURL( + { + base: displayConfig.signUpUrl, + hashPath: '/continue', + }, + { stringify: true }, + ), ); const navigateToNextStepSignUp = ({ missingFields }: { missingFields: SignUpField[] }) => { @@ -1046,6 +1074,13 @@ export default class Clerk implements ClerkInterface { }); }; + if (si.status === 'complete') { + return this.setActive({ + session: si.sessionId, + beforeEmit: navigateAfterSignIn, + }); + } + const userExistsButNeedsToSignIn = su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists'; @@ -1108,6 +1143,13 @@ export default class Clerk implements ClerkInterface { } } + if (su.status === 'complete') { + return this.setActive({ + session: su.sessionId, + beforeEmit: navigateAfterSignUp, + }); + } + if (si.status === 'needs_second_factor') { return navigateToFactorTwo(); } @@ -1144,6 +1186,25 @@ export default class Clerk implements ClerkInterface { return navigateToSignIn(); }; + public handleRedirectCallback = async ( + params: HandleOAuthCallbackParams = {}, + customNavigate?: (to: string) => Promise, + ): Promise => { + if (!this.loaded || !this.#environment || !this.client) { + return; + } + const { signIn, signUp } = this.client; + + const navigate = (to: string) => + customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to); + + return this._handleRedirectCallback(params, { + signUp, + signIn, + navigate, + }); + }; + public handleUnauthenticated = async (opts = { broadcast: true }): Promise => { if (!this.client || !this.session) { return; @@ -1159,6 +1220,25 @@ export default class Clerk implements ClerkInterface { return this.setActive({ session: null }); }; + public authenticateWithGoogleOneTap = async ( + params: AuthenticateWithGoogleOneTapParams, + ): Promise => { + return this.client?.signIn + .create({ + strategy: 'google_one_tap', + token: params.token, + }) + .catch(err => { + if (isClerkAPIResponseError(err) && err.errors[0].code === 'external_account_not_found') { + return this.client?.signUp.create({ + strategy: 'google_one_tap', + token: params.token, + }); + } + throw err; + }) as Promise; + }; + public authenticateWithMetamask = async ({ redirectUrl, signUpContinueUrl, diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 7ae41eafcdb..9df5c10d3b0 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -1,6 +1,5 @@ -import { deepSnakeToCamel, deprecated, isClerkAPIResponseError, Poller } from '@clerk/shared'; +import { deepSnakeToCamel, deprecated, Poller } from '@clerk/shared'; import type { - __experimental_AuthenticateWithGoogleOneTapParams, AttemptFirstFactorParams, AttemptSecondFactorParams, AuthenticateWithRedirectParams, @@ -25,7 +24,6 @@ import type { SignInStartEmailLinkFlowParams, SignInStartMagicLinkFlowParams, SignInStatus, - SignUpResource, VerificationResource, Web3SignatureConfig, Web3SignatureFactor, @@ -240,27 +238,6 @@ export class SignIn extends BaseResource implements SignInResource { } }; - public __experimental_authenticateWithGoogleOneTap = async ( - params: __experimental_AuthenticateWithGoogleOneTapParams, - ): Promise => { - return this.create({ - // TODO-ONETAP: Add new types when feature is ready for public beta - // @ts-expect-error - strategy: 'google_one_tap', - googleOneTapToken: params.token, - }).catch(err => { - if (isClerkAPIResponseError(err) && err.errors[0].code === 'external_account_not_found') { - return SignIn.clerk.client?.signUp.create({ - // TODO-ONETAP: Add new types when feature is ready for public beta - // @ts-expect-error - strategy: 'google_one_tap', - googleOneTapToken: params.token, - }); - } - throw err; - }) as Promise; - }; - public authenticateWithWeb3 = async (params: AuthenticateWithWeb3Params): Promise => { const { identifier, generateSignature } = params || {}; if (!(typeof generateSignature === 'function')) { diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index ef8fb3a3160..2b94fa63e53 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -6,6 +6,7 @@ import type { ClerkOptions, CreateOrganizationProps, EnvironmentResource, + GoogleOneTapProps, OrganizationProfileProps, SignInProps, SignUpProps, @@ -32,6 +33,7 @@ import { LazyComponentRenderer, LazyImpersonationFabProvider, LazyModalRenderer, + LazyOneTapRenderer, LazyProviders, } from './lazyModules/providers'; import type { AvailableComponentProps } from './types'; @@ -52,11 +54,15 @@ export type ComponentControls = { node?: HTMLDivElement; props?: unknown; }) => void; - openModal: ( + openModal: < + T extends 'googleOneTap' | 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization', + >( modal: T, props: T extends 'signIn' ? SignInProps : T extends 'signUp' ? SignUpProps : UserProfileProps, ) => void; - closeModal: (modal: 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization') => void; + closeModal: ( + modal: 'googleOneTap' | 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization', + ) => void; // Special case, as the impersonation fab mounts automatically mountImpersonationFab: () => void; }; @@ -78,6 +84,7 @@ interface ComponentsProps { interface ComponentsState { appearance: Appearance | undefined; options: ClerkOptions | undefined; + googleOneTapModal: null | GoogleOneTapProps; signInModal: null | SignInProps; signUpModal: null | SignUpProps; userProfileModal: null | UserProfileProps; @@ -153,6 +160,7 @@ const Components = (props: ComponentsProps) => { const [state, setState] = React.useState({ appearance: props.options.appearance, options: props.options, + googleOneTapModal: null, signInModal: null, signUpModal: null, userProfileModal: null, @@ -161,8 +169,15 @@ const Components = (props: ComponentsProps) => { nodes: new Map(), impersonationFab: false, }); - const { signInModal, signUpModal, userProfileModal, organizationProfileModal, createOrganizationModal, nodes } = - state; + const { + googleOneTapModal, + signInModal, + signUpModal, + userProfileModal, + organizationProfileModal, + createOrganizationModal, + nodes, + } = state; const { urlStateParam, clearUrlStateParam, decodedRedirectParams } = useClerkModalStateParams(); @@ -219,6 +234,15 @@ const Components = (props: ComponentsProps) => { props.onComponentsMounted(); }, []); + const mountedOneTapModal = ( + + ); + const mountedSignInModal = ( { ); })} + {googleOneTapModal && mountedOneTapModal} {signInModal && mountedSignInModal} {signUpModal && mountedSignUpModal} {userProfileModal && mountedUserProfileModal} diff --git a/packages/clerk-js/src/ui/components/GoogleOneTap/index.tsx b/packages/clerk-js/src/ui/components/GoogleOneTap/index.tsx index 28c1756432a..3a755535db8 100644 --- a/packages/clerk-js/src/ui/components/GoogleOneTap/index.tsx +++ b/packages/clerk-js/src/ui/components/GoogleOneTap/index.tsx @@ -1,4 +1,4 @@ -import type { OneTapProps } from '@clerk/types'; +import type { GoogleOneTapProps } from '@clerk/types'; import React from 'react'; import { withCoreSessionSwitchGuard } from '../../contexts'; @@ -22,4 +22,4 @@ function OneTapRoutes(): JSX.Element { OneTapRoutes.displayName = 'OneTap'; -export const OneTap: React.ComponentType = withCoreSessionSwitchGuard(OneTapRoutes); +export const OneTap: React.ComponentType = withCoreSessionSwitchGuard(OneTapRoutes); diff --git a/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx b/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx index c06270bfa05..1822bb39bb2 100644 --- a/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx +++ b/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx @@ -1,82 +1,81 @@ import { useUserContext } from '@clerk/shared/react'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; -import { clerkInvalidFAPIResponse } from '../../../core/errors'; import type { GISCredentialResponse } from '../../../utils/one-tap'; import { loadGIS } from '../../../utils/one-tap'; -import { useCoreClerk, useCoreSignIn, useEnvironment, useGoogleOneTapContext } from '../../contexts'; +import { useCoreClerk, useEnvironment, useGoogleOneTapContext } from '../../contexts'; import { withCardStateProvider } from '../../elements'; import { useFetch } from '../../hooks'; -import { useSupportEmail } from '../../hooks/useSupportEmail'; +import { useRouter } from '../../router'; function _OneTapStart(): JSX.Element | null { const clerk = useCoreClerk(); - const signIn = useCoreSignIn(); const user = useUserContext(); const environment = useEnvironment(); + const isPromptedRef = useRef(false); + const { navigate } = useRouter(); - const supportEmail = useSupportEmail(); const ctx = useGoogleOneTapContext(); async function oneTapCallback(response: GISCredentialResponse) { + isPromptedRef.current = false; try { - const res = await signIn.__experimental_authenticateWithGoogleOneTap({ + const res = await clerk.authenticateWithGoogleOneTap({ token: response.credential, }); - - switch (res.status) { - case 'complete': - await clerk.setActive({ - session: res.createdSessionId, - }); - break; - // TODO-ONETAP: Add a new case in order to handle the `missing_requirements` status and the PSU flow - default: - clerkInvalidFAPIResponse(res.status, supportEmail); - break; - } - } catch (err) { - /** - * Currently it is not possible to display an error in the UI. - * As a fallback we simply open the SignIn modal for the user to sign in. - */ - clerk.openSignIn(); + await clerk.handleGoogleOneTapCallback(res, ctx.generateCallbackUrls(window.location.href), navigate); + } catch (e) { + console.error(e); } } const environmentClientID = environment.displayConfig.googleOneTapClientId; const shouldLoadGIS = !user?.id && !!environmentClientID; + async function initializeGIS() { + const google = await loadGIS(); + google.accounts.id.initialize({ + client_id: environmentClientID!, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + callback: oneTapCallback, + itp_support: ctx.itpSupport, + cancel_on_tap_outside: ctx.cancelOnTapOutside, + auto_select: false, + use_fedcm_for_prompt: ctx.fedCmSupport, + }); + return google; + } + /** * Prevent GIS from initializing multiple times */ - const { data: google } = useFetch(shouldLoadGIS ? loadGIS : undefined, 'google-identity-services-script', { - onSuccess(google) { - google.accounts.id.initialize({ - client_id: environmentClientID!, - // eslint-disable-next-line @typescript-eslint/no-misused-promises - callback: oneTapCallback, - itp_support: true, - cancel_on_tap_outside: ctx.cancelOnTapOutside, - auto_select: false, - use_fedcm_for_prompt: true, - }); + const { data: initializedGoogle } = useFetch( + shouldLoadGIS ? initializeGIS : undefined, + 'google-identity-services-script', + ); - google.accounts.id.prompt(); - }, - }); + useEffect(() => { + if (initializedGoogle && !user?.id && !isPromptedRef.current) { + initializedGoogle.accounts.id.prompt(notification => { + // Close the modal, when the user clicks outside the prompt or cancels + if (notification.getMomentType() === 'skipped') { + // Unmounts the component will cause the useEffect cleanup function from below to be called + clerk.closeGoogleOneTap(); + } + }); + isPromptedRef.current = true; + } + }, [clerk, initializedGoogle, user?.id]); // Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization useEffect(() => { - if (google && !user?.id) { - google.accounts.id.prompt(); - } return () => { - if (google) { - google.accounts.id.cancel(); + if (initializedGoogle && isPromptedRef.current) { + isPromptedRef.current = false; + initializedGoogle.accounts.id.cancel(); } }; - }, [user?.id]); + }, [initializedGoogle]); return null; } diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 98df5652859..3726ca4e254 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -1,6 +1,6 @@ import { deprecated, snakeToCamel } from '@clerk/shared'; -import type { OrganizationResource, UserResource } from '@clerk/types'; -import React, { useMemo } from 'react'; +import type { HandleOAuthCallbackParams, OrganizationResource, UserResource } from '@clerk/types'; +import React, { useCallback, useMemo } from 'react'; import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_INITIAL_VALUE_KEYS } from '../../core/constants'; import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils'; @@ -12,7 +12,7 @@ import { useRouter } from '../router'; import type { AvailableComponentCtx, CreateOrganizationCtx, - OneTapCtx, + GoogleOneTapCtx, OrganizationListCtx, OrganizationProfileCtx, OrganizationSwitcherCtx, @@ -491,14 +491,91 @@ export const useCreateOrganizationContext = () => { }; export const useGoogleOneTapContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OneTapCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as GoogleOneTapCtx; + const options = useOptions(); + const { displayConfig } = useEnvironment(); + const { queryParams } = useRouter(); + const clerk = useCoreClerk(); - if (componentName !== 'OneTap') { + if (componentName !== 'GoogleOneTap') { throw new Error('Clerk: useGoogleOneTapContext called outside GoogleOneTap.'); } + const generateCallbackUrls = useCallback( + (returnBackUrl: string): HandleOAuthCallbackParams => { + const signInUrl = pickRedirectionProp('signInUrl', { options, displayConfig }, false); + const signUpUrl = pickRedirectionProp('signUpUrl', { options, displayConfig }, false); + + const afterSignUpUrl = clerk.buildUrlWithAuth( + pickRedirectionProp('afterSignUpUrl', { + queryParams, + ctx: { + ...ctx, + afterSignUpUrl: returnBackUrl, + }, + options, + displayConfig, + }), + ); + + const afterSignInUrl = clerk.buildUrlWithAuth( + pickRedirectionProp('afterSignInUrl', { + queryParams, + ctx: { + ...ctx, + afterSignInUrl: returnBackUrl, + }, + options, + displayConfig, + }), + ); + + const signUpContinueUrl = buildURL( + { + base: signUpUrl, + hashPath: '/continue', + hashSearch: new URLSearchParams({ + after_sign_up_url: afterSignUpUrl, + }).toString(), + }, + { stringify: true }, + ); + + const firstFactorUrl = buildURL( + { + base: signInUrl, + hashPath: '/factor-one', + hashSearch: new URLSearchParams({ + after_sign_in_url: afterSignInUrl, + }).toString(), + }, + { stringify: true }, + ); + const secondFactorUrl = buildURL( + { + base: signInUrl, + hashPath: '/factor-two', + hashSearch: new URLSearchParams({ + after_sign_in_url: afterSignInUrl, + }).toString(), + }, + { stringify: true }, + ); + + return { + firstFactorUrl, + secondFactorUrl, + continueSignUpUrl: signUpContinueUrl, + afterSignUpUrl, + afterSignInUrl, + }; + }, + [ctx, displayConfig.signInUrl, displayConfig.signUpUrl, options, queryParams], + ); + return { ...ctx, componentName, + generateCallbackUrls, }; }; diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index ed3cf529350..5cac14588f9 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -12,13 +12,15 @@ const componentImportPaths = { import(/* webpackChunkName: "organizationswitcher" */ './../components/OrganizationSwitcher'), OrganizationList: () => import(/* webpackChunkName: "organizationlist" */ './../components/OrganizationList'), ImpersonationFab: () => import(/* webpackChunkName: "impersonationfab" */ './../components/ImpersonationFab'), - OneTap: () => import(/* webpackChunkName: "oneTap" */ './../components/GoogleOneTap'), + GoogleOneTap: () => import(/* webpackChunkName: "oneTap" */ './../components/GoogleOneTap'), } as const; export const SignIn = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignIn }))); export const SignInModal = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignInModal }))); -export const OneTap = lazy(() => componentImportPaths.OneTap().then(module => ({ default: module.OneTap }))); +export const GoogleOneTap = lazy(() => + componentImportPaths.GoogleOneTap().then(module => ({ default: module.OneTap })), +); export const SignUp = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUp }))); @@ -79,7 +81,7 @@ export const ClerkComponents = { UserProfileModal, OrganizationProfileModal, CreateOrganizationModal, - OneTap, + GoogleOneTap, }; export type ClerkComponentName = keyof typeof ClerkComponents; diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx index a76e0ac432f..74244e232d6 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -12,7 +12,8 @@ const OptionsProvider = lazy(() => import('../contexts').then(m => ({ default: m const AppearanceProvider = lazy(() => import('../customizables').then(m => ({ default: m.AppearanceProvider }))); const VirtualRouter = lazy(() => import('../router').then(m => ({ default: m.VirtualRouter }))); const InternalThemeProvider = lazy(() => import('../styledSystem').then(m => ({ default: m.InternalThemeProvider }))); -const Portal = lazy(() => import('./../portal')); +const Portal = lazy(() => import('./../portal').then(m => ({ default: m.Portal }))); +const VirtualBodyRootPortal = lazy(() => import('./../portal').then(m => ({ default: m.VirtualBodyRootPortal }))); const FlowMetadataProvider = lazy(() => import('./../elements').then(m => ({ default: m.FlowMetadataProvider }))); const Modal = lazy(() => import('./../elements').then(m => ({ default: m.Modal }))); @@ -128,3 +129,27 @@ export const LazyImpersonationFabProvider = ( ); }; + +type LazyOneTapRendererProps = React.PropsWithChildren< + { + componentProps: any; + startPath: string; + } & Omit +>; + +export const LazyOneTapRenderer = (props: LazyOneTapRendererProps) => { + return ( + + + + ); +}; diff --git a/packages/clerk-js/src/ui/portal/index.tsx b/packages/clerk-js/src/ui/portal/index.tsx index 4ea9078f54b..4190079f47c 100644 --- a/packages/clerk-js/src/ui/portal/index.tsx +++ b/packages/clerk-js/src/ui/portal/index.tsx @@ -3,7 +3,6 @@ import ReactDOM from 'react-dom'; import { PRESERVED_QUERYSTRING_PARAMS } from '../../core/constants'; import { clerkErrorPathRouterMissingPath } from '../../core/errors'; -import { buildVirtualRouterUrl } from '../../utils'; import { ComponentContext } from '../contexts'; import { HashRouter, PathRouter, VirtualRouter } from '../router'; import type { AvailableComponentCtx } from '../types'; @@ -15,21 +14,7 @@ type PortalProps; -export default class Portal extends React.PureComponent> { - private elRef = document.createElement('div'); - - componentDidMount() { - if (this.props.componentName === 'OneTap') { - document.body.appendChild(this.elRef); - } - } - - componentWillUnmount() { - if (this.props.componentName === 'OneTap') { - document.body.removeChild(this.elRef); - } - } - +export class Portal extends React.PureComponent> { render() { const { props, component, componentName, node } = this.props; @@ -39,13 +24,6 @@ export default class Portal extends React ); - if (componentName === 'OneTap') { - return ReactDOM.createPortal( - {el}, - this.elRef, - ); - } - if (props?.routing === 'path') { if (!props?.path) { clerkErrorPathRouterMissingPath(componentName); @@ -65,3 +43,36 @@ export default class Portal extends React return ReactDOM.createPortal({el}, node); } } + +type VirtualBodyRootPortalProps> = { + component: React.FunctionComponent | React.ComponentClass; + props?: PropsType; + startPath: string; +} & Pick; + +export class VirtualBodyRootPortal extends React.PureComponent< + VirtualBodyRootPortalProps +> { + private elRef = document.createElement('div'); + + componentDidMount() { + document.body.appendChild(this.elRef); + } + + componentWillUnmount() { + document.body.removeChild(this.elRef); + } + + render() { + const { props, startPath, component, componentName } = this.props; + + return ReactDOM.createPortal( + + + {React.createElement(component, props as PortalProps['props'])} + + , + this.elRef, + ); + } +} diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index fa9ee82c3a4..ce03e28c098 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -1,6 +1,6 @@ import type { CreateOrganizationProps, - OneTapProps, + GoogleOneTapProps, OrganizationListProps, OrganizationProfileProps, OrganizationSwitcherProps, @@ -11,7 +11,7 @@ import type { } from '@clerk/types'; export type { - OneTapProps, + GoogleOneTapProps, SignInProps, SignUpProps, UserButtonProps, @@ -74,8 +74,8 @@ export type OrganizationListCtx = OrganizationListProps & { mode?: ComponentMode; }; -export type OneTapCtx = OneTapProps & { - componentName: 'OneTap'; +export type GoogleOneTapCtx = GoogleOneTapProps & { + componentName: 'GoogleOneTap'; }; export type AvailableComponentCtx = @@ -87,4 +87,4 @@ export type AvailableComponentCtx = | CreateOrganizationCtx | OrganizationSwitcherCtx | OrganizationListCtx - | OneTapCtx; + | GoogleOneTapCtx; diff --git a/packages/clerk-js/src/utils/one-tap.ts b/packages/clerk-js/src/utils/one-tap.ts index 4698b64445a..5284cdd294d 100644 --- a/packages/clerk-js/src/utils/one-tap.ts +++ b/packages/clerk-js/src/utils/one-tap.ts @@ -15,9 +15,13 @@ interface InitializeProps { use_fedcm_for_prompt?: boolean; } +interface PromptMomentNotification { + getMomentType: () => 'display' | 'skipped' | 'dismissed'; +} + interface OneTapMethods { initialize: (params: InitializeProps) => void; - prompt: () => void; + prompt: (promptListener: (promptMomentNotification: PromptMomentNotification) => void) => void; cancel: () => void; } @@ -31,7 +35,7 @@ interface Google { declare global { export interface Window { - google: Google; + google?: Google; } } @@ -44,7 +48,7 @@ async function loadGIS() { clerkFailedToLoadThirdPartyScript('Google Identity Services'); } } - return window.google; + return window.google as Google; } export { loadGIS }; diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index 160ecf9ed5d..9bcb981b3a1 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -17,7 +17,7 @@ export { SignOutButton, SignInWithMetamaskButton, OrganizationList, - __experimental_GoogleOneTap, + GoogleOneTap, } from '@clerk/clerk-react'; export const SignIn = (props: SignInProps) => { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 0adf5348311..66cb7664660 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -31,7 +31,7 @@ export { SignOutButton, SignInWithMetamaskButton, OrganizationList, - __experimental_GoogleOneTap, + GoogleOneTap, } from './client-boundary/uiComponents'; /** diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index e5b4b140ec3..9883d3642d3 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -7,7 +7,7 @@ export { OrganizationProfile, CreateOrganization, OrganizationList, - __experimental_GoogleOneTap, + GoogleOneTap, } from './uiComponents'; export { diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 01ffab53b48..a9bc9f67be0 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,7 +1,7 @@ import { logErrorInDevMode } from '@clerk/shared'; import type { CreateOrganizationProps, - OneTapProps, + GoogleOneTapProps, OrganizationListProps, OrganizationProfileProps, OrganizationSwitcherProps, @@ -21,6 +21,7 @@ import { } from '../errors'; import type { MountProps, + OpenProps, OrganizationProfileLinkProps, OrganizationProfilePageProps, UserProfileLinkProps, @@ -86,10 +87,23 @@ type OrganizationSwitcherPropsWithoutCustomPages = Omit { + +const isMountProps = (props: any): props is MountProps => { + return 'mount' in props; +}; + +const isOpenProps = (props: any): props is OpenProps => { + return 'open' in props; +}; + +class Portal extends React.PureComponent { private portalRef = React.createRef(); - componentDidUpdate(prevProps: Readonly) { + componentDidUpdate(prevProps: Readonly) { + if (!isMountProps(prevProps) || !isMountProps(this.props)) { + return; + } + if ( prevProps.props.appearance !== this.props.props.appearance || prevProps.props?.customPages?.length !== this.props.props?.customPages?.length @@ -100,13 +114,24 @@ class Portal extends React.PureComponent { componentDidMount() { if (this.portalRef.current) { - this.props.mount(this.portalRef.current, this.props.props); + if (isMountProps(this.props)) { + this.props.mount(this.portalRef.current, this.props.props); + } + + if (isOpenProps(this.props)) { + this.props.open(this.props.props); + } } } componentWillUnmount() { if (this.portalRef.current) { - this.props.unmount(this.portalRef.current); + if (isMountProps(this.props)) { + this.props.unmount(this.portalRef.current); + } + if (isOpenProps(this.props)) { + this.props.close(); + } } } @@ -114,7 +139,8 @@ class Portal extends React.PureComponent { return ( <>
- {this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} + {isMountProps(this.props) && + this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} ); } @@ -270,13 +296,12 @@ export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { +export const GoogleOneTap = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( ); -}, 'OneTap'); +}, 'GoogleOneTap'); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 12e10e41af3..648ed613f4c 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -3,6 +3,7 @@ import { deprecated } from '@clerk/shared/deprecated'; import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import type { ActiveSessionResource, + AuthenticateWithGoogleOneTapParams, AuthenticateWithMetamaskParams, BeforeEmitCallback, Clerk, @@ -10,13 +11,13 @@ import type { CreateOrganizationParams, CreateOrganizationProps, DomainOrProxyUrl, + GoogleOneTapProps, HandleEmailLinkVerificationParams, HandleMagicLinkVerificationParams, HandleOAuthCallbackParams, InstanceType, ListenerCallback, LoadedClerk, - OneTapProps, OrganizationListProps, OrganizationMembershipResource, OrganizationProfileProps, @@ -27,11 +28,13 @@ import type { SetActiveParams, SignInProps, SignInRedirectOptions, + SignInResource, SignOut, SignOutCallback, SignOutOptions, SignUpProps, SignUpRedirectOptions, + SignUpResource, UnsubscribeCallback, UserButtonProps, UserProfileProps, @@ -79,8 +82,10 @@ type IsomorphicLoadedClerk = Omit< | 'redirectToSignIn' | 'redirectToSignUp' | 'handleRedirectCallback' + | 'handleGoogleOneTapCallback' | 'handleUnauthenticated' | 'authenticateWithMetamask' + | 'authenticateWithGoogleOneTap' | 'createOrganization' | 'getOrganization' | 'mountUserButton' @@ -91,7 +96,6 @@ type IsomorphicLoadedClerk = Omit< | 'mountSignUp' | 'mountSignIn' | 'mountUserProfile' - | '__experimental_mountGoogleOneTap' | 'client' | 'getOrganizationMemberships' > & { @@ -103,9 +107,13 @@ type IsomorphicLoadedClerk = Omit< redirectToSignUp: (options: SignUpRedirectOptions) => void; // TODO: Align return type and parms handleRedirectCallback: (params: HandleOAuthCallbackParams) => void; + handleGoogleOneTapCallback: (signInOrUp: SignInResource | SignUpResource, params: HandleOAuthCallbackParams) => void; handleUnauthenticated: () => void; // TODO: Align Promise unknown authenticateWithMetamask: (params: AuthenticateWithMetamaskParams) => Promise; + authenticateWithGoogleOneTap: ( + params: AuthenticateWithGoogleOneTapParams, + ) => Promise; // TODO: Align return type (maybe not possible or correct) createOrganization: (params: CreateOrganizationParams) => Promise; // TODO: Align return type (maybe not possible or correct) @@ -133,7 +141,6 @@ type IsomorphicLoadedClerk = Omit< mountOrganizationProfile: (node: HTMLDivElement, props: OrganizationProfileProps) => void; mountCreateOrganization: (node: HTMLDivElement, props: CreateOrganizationProps) => void; mountSignUp: (node: HTMLDivElement, props: SignUpProps) => void; - __experimental_mountGoogleOneTap: (node: HTMLDivElement, props: OneTapProps) => void; mountSignIn: (node: HTMLDivElement, props: SignInProps) => void; mountUserProfile: (node: HTMLDivElement, props: UserProfileProps) => void; client: ClientResource | undefined; @@ -146,6 +153,7 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { private readonly options: IsomorphicClerkOptions; private readonly Clerk: ClerkProp; private clerkjs: BrowserClerk | HeadlessBrowserClerk | null = null; + private preopenOneTap?: null | GoogleOneTapProps = null; private preopenSignIn?: null | SignInProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; @@ -331,6 +339,15 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + #waitForClerkJS(): Promise { + return new Promise(resolve => { + if (this.#loaded) { + resolve(this.clerkjs!); + } + this.addOnLoaded(() => resolve(this.clerkjs!)); + }); + } + async loadClerkJS(): Promise { if (this.mode !== 'browser' || this.#loaded) { return; @@ -450,6 +467,10 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.openUserProfile(this.preopenUserProfile); } + if (this.preopenOneTap !== null) { + clerkjs.openGoogleOneTap(this.preopenOneTap); + } + if (this.preopenOrganizationProfile !== null) { clerkjs.openOrganizationProfile(this.preopenOrganizationProfile); } @@ -578,6 +599,22 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + openGoogleOneTap = (props?: GoogleOneTapProps): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.openGoogleOneTap(props); + } else { + this.preopenOneTap = props; + } + }; + + closeGoogleOneTap = (): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.closeGoogleOneTap(); + } else { + this.preopenOneTap = null; + } + }; + openUserProfile = (props?: UserProfileProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.openUserProfile(props); @@ -658,18 +695,6 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __experimental_mountGoogleOneTap = (node: HTMLDivElement, props: OneTapProps): void => { - if (this.clerkjs && this.#loaded) { - this.clerkjs.__experimental_mountGoogleOneTap(node, props); - } - }; - - __experimental_unmountGoogleOneTap = (node: HTMLDivElement): void => { - if (this.clerkjs && this.#loaded) { - this.clerkjs.__experimental_unmountGoogleOneTap(node); - } - }; - mountSignUp = (node: HTMLDivElement, props: SignUpProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.mountSignUp(node, props); @@ -894,6 +919,26 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + handleGoogleOneTapCallback = ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + ): void => { + const callback = () => this.clerkjs?.handleGoogleOneTapCallback(signInOrUp, params); + if (this.clerkjs && this.#loaded) { + void callback()?.catch(() => { + // This error is caused when the host app is using React18 + // and strictMode is enabled. This useEffects runs twice because + // the clerk-react ui components mounts, unmounts and mounts again + // so the clerk-js component loses its state because of the custom + // unmount callback we're using. + // This needs to be solved by tweaking the logic in uiComponents.tsx + // or by making handleRedirectCallback idempotent + }); + } else { + this.premountMethodCalls.set('handleGoogleOneTapCallback', callback); + } + }; + handleEmailLinkVerification = async (params: HandleEmailLinkVerificationParams): Promise => { const callback = () => this.clerkjs?.handleEmailLinkVerification(params); if (this.clerkjs && this.#loaded) { @@ -912,6 +957,13 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + authenticateWithGoogleOneTap = async ( + params: AuthenticateWithGoogleOneTapParams, + ): Promise => { + const clerkjs = await this.#waitForClerkJS(); + return clerkjs.authenticateWithGoogleOneTap(params); + }; + createOrganization = async (params: CreateOrganizationParams): Promise => { const callback = () => this.clerkjs?.createOrganization(params); if (this.clerkjs && this.#loaded) { diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index dd9bb60028b..307b4dd29aa 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -55,6 +55,12 @@ export interface MountProps { customPagesPortals?: any[]; } +export interface OpenProps { + open: (props: any) => void; + close: () => void; + props?: any; +} + export interface HeadlessBrowserClerk extends Clerk { load: (opts?: Omit) => Promise; updateClient: (client: ClientResource) => void; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index e18cfa3c4b5..18ee116e405 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -18,6 +18,8 @@ import type { OrganizationResource } from './organization'; import type { OrganizationInvitationResource } from './organizationInvitation'; import type { MembershipRole, OrganizationMembershipResource } from './organizationMembership'; import type { ActiveSessionResource } from './session'; +import type { SignInResource } from './signIn'; +import type { SignUpResource } from './signUp'; import type { UserResource } from './user'; import type { DeepPartial, DeepSnakeToCamel } from './utils'; @@ -128,6 +130,18 @@ export interface Clerk { */ closeSignIn: () => void; + /** + * Opens the Google One Tap component. + * @param props Optional props that will be passed to the GoogleOneTap component. + */ + openGoogleOneTap: (props?: GoogleOneTapProps) => void; + + /** + * Opens the Google One Tap component. + * If the component is not already open, results in a noop. + */ + closeGoogleOneTap: () => void; + /** * Opens the Clerk SignUp component in a modal. * @param props Optional props that will be passed to the SignUp component. @@ -187,22 +201,6 @@ export interface Clerk { */ unmountSignIn: (targetNode: HTMLDivElement) => void; - /** - * Mounts a Google one tap flow component at the target element. - * @experimental - * @param targetNode Target node to mount the GoogleOneTap component. - * @param oneTapProps sign in configuration parameters. - */ - __experimental_mountGoogleOneTap: (targetNode: HTMLDivElement, oneTapProps?: OneTapProps) => void; - - /** - * Unmount a Google one tap flow component from the target element. - * If there is no component mounted at the target node, results in a noop. - * @experimental - * @param targetNode Target node to unmount the SignIn component from. - */ - __experimental_unmountGoogleOneTap: (targetNode: HTMLDivElement) => void; - /** * Mounts a sign up flow component at the target element. * @@ -422,6 +420,16 @@ export interface Clerk { */ redirectToHome: () => void; + /** + * Completes a Google One Tap redirection flow started by + * {@link Clerk.authenticateWithGoogleOneTap} + */ + handleGoogleOneTapCallback: ( + signInOrUp: SignInResource | SignUpResource, + params: HandleOAuthCallbackParams, + customNavigate?: (to: string) => Promise, + ) => Promise; + /** * Completes an OAuth or SAML redirection flow started by * {@link Clerk.client.signIn.authenticateWithRedirect} or {@link Clerk.client.signUp.authenticateWithRedirect} @@ -454,6 +462,13 @@ export interface Clerk { */ authenticateWithMetamask: (params?: AuthenticateWithMetamaskParams) => Promise; + /** + * Authenticates user using a Google token generated from Google identity services. + */ + authenticateWithGoogleOneTap: ( + params: AuthenticateWithGoogleOneTapParams, + ) => Promise; + /** * Creates an organization, adding the current user as admin. */ @@ -685,8 +700,27 @@ export type SignInProps = { initialValues?: SignInInitialValues; } & RedirectOptions; -export type OneTapProps = { +type GoogleOneTapRedirectUrlProps = RedirectOptions; + +export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & { + /** + * Whether to cancel the Google One Tap request if a user clicks outside the prompt. + * @default true + */ cancelOnTapOutside?: boolean; + /** + * Enables upgraded One Tap UX on ITP browsers. + * Turning this options off, would hide any One Tap UI in such browsers. + * @default true + */ + itpSupport?: boolean; + /** + * FedCM enables more private sign-in flows without requiring the use of third-party cookies. + * The browser controls user settings, displays user prompts, and only contacts an Identity Provider such as Google after explicit user consent is given. + * Backwards compatible with browsers that still support third-party cookies. + * @default true + */ + fedCmSupport?: boolean; appearance?: SignInTheme; }; @@ -1062,6 +1096,10 @@ export interface AuthenticateWithMetamaskParams { unsafeMetadata?: SignUpUnsafeMetadata; } +export interface AuthenticateWithGoogleOneTapParams { + token: string; +} + export interface LoadedClerk extends Clerk { client: ClientResource; } diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index a3afaa806bd..b9288e1a584 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -44,11 +44,11 @@ import type { import type { ValidatePasswordCallbacks } from './passwords'; import type { AuthenticateWithRedirectParams } from './redirects'; import type { ClerkResource } from './resource'; -import type { SignUpResource } from './signUp'; import type { BackupCodeStrategy, EmailCodeStrategy, EmailLinkStrategy, + GoogleOneTapStrategy, OAuthStrategy, PasswordStrategy, PhoneCodeStrategy, @@ -98,13 +98,6 @@ export interface SignInResource extends ClerkResource { */ createMagicLinkFlow: () => CreateMagicLinkFlowReturn; - /** - * @experimental - */ - __experimental_authenticateWithGoogleOneTap: ( - params: __experimental_AuthenticateWithGoogleOneTapParams, - ) => Promise; - createEmailLinkFlow: () => CreateEmailLinkFlowReturn; validatePassword: (password: string, callbacks?: ValidatePasswordCallbacks) => void; @@ -182,6 +175,10 @@ export type SignInCreateParams = ( strategy: TicketStrategy; ticket: string; } + | { + strategy: GoogleOneTapStrategy; + token: string; + } | { strategy: PasswordStrategy; password: string; @@ -220,10 +217,6 @@ export interface SignInStartMagicLinkFlowParams extends StartMagicLinkFlowParams emailAddressId: string; } -export type __experimental_AuthenticateWithGoogleOneTapParams = { - token: string; -}; - export interface SignInStartEmailLinkFlowParams extends StartEmailLinkFlowParams { emailAddressId: string; } diff --git a/packages/types/src/signUp.ts b/packages/types/src/signUp.ts index 024993875ca..8bc0a826b82 100644 --- a/packages/types/src/signUp.ts +++ b/packages/types/src/signUp.ts @@ -20,6 +20,7 @@ import type { ClerkResource } from './resource'; import type { EmailCodeStrategy, EmailLinkStrategy, + GoogleOneTapStrategy, OAuthStrategy, PhoneCodeStrategy, SamlStrategy, @@ -168,12 +169,13 @@ export type SignUpCreateParams = Partial< externalAccountStrategy: string; externalAccountRedirectUrl: string; externalAccountActionCompleteRedirectUrl: string; - strategy: OAuthStrategy | SamlStrategy | TicketStrategy; + strategy: OAuthStrategy | SamlStrategy | TicketStrategy | GoogleOneTapStrategy; redirectUrl: string; actionCompleteRedirectUrl: string; transfer: boolean; unsafeMetadata: SignUpUnsafeMetadata; ticket: string; + token: string; } & SnakeToCamel> >; diff --git a/packages/types/src/strategies.ts b/packages/types/src/strategies.ts index f6005d60fcb..c34017a6f64 100644 --- a/packages/types/src/strategies.ts +++ b/packages/types/src/strategies.ts @@ -1,6 +1,7 @@ import type { OAuthProvider } from './oauth'; import type { Web3Provider } from './web3'; +export type GoogleOneTapStrategy = 'google_one_tap'; export type PasswordStrategy = 'password'; export type PhoneCodeStrategy = 'phone_code'; export type EmailCodeStrategy = 'email_code';