From 7699847cc0dea856f208c1b1a4117527e0a85189 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 16 Apr 2024 20:41:06 +0300 Subject: [PATCH 01/12] fix(clerk-js): Reprompt google one tap only when user id changes (#3198) (cherry picked from commit 7ad7d11268abb92ef18e3541c27083f39c828469) --- .changeset/two-worms-yawn.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/two-worms-yawn.md diff --git a/.changeset/two-worms-yawn.md b/.changeset/two-worms-yawn.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/two-worms-yawn.md @@ -0,0 +1,2 @@ +--- +--- From 534ce72649133909da848355ea5c8daac3f1a2c9 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 14 May 2024 12:13:13 +0300 Subject: [PATCH 02/12] feat(clerk-js): Handle PSU and MFA flows for `` (#3250) --- .changeset/breezy-monkeys-develop.md | 38 ++++++ packages/clerk-js/src/core/clerk.ts | 110 ++++++++++++++++-- .../components/GoogleOneTap/one-tap-start.tsx | 82 +++++++------ .../ui/contexts/ClerkUIComponentsContext.tsx | 77 ++++++++++-- packages/clerk-js/src/utils/one-tap.ts | 4 +- packages/types/src/clerk.ts | 45 ++++++- packages/types/src/signIn.ts | 7 +- 7 files changed, 305 insertions(+), 58 deletions(-) create mode 100644 .changeset/breezy-monkeys-develop.md diff --git a/.changeset/breezy-monkeys-develop.md b/.changeset/breezy-monkeys-develop.md new file mode 100644 index 00000000000..36f161d3e0f --- /dev/null +++ b/.changeset/breezy-monkeys-develop.md @@ -0,0 +1,38 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Updates related to experimental Google One Tap support +- By default we are returning back to the location where the flow started. + To accomplish that internally we will use the redirect_url query parameter to build the url. +```tsx +<__experimental_GoogleOneTap /> +``` + +- In the above example if there is a SIGN_UP_FORCE_REDIRECT_URL or SIGN_IN_FORCE_REDIRECT_URL set then the developer would need to pass new values as props like this +```tsx +<__experimental_GoogleOneTap + signInForceRedirectUrl="" + signUpForceRedirectUrl="" +/> +``` + +- Let the developer configure the experience they want to offer. (All these values are true by default) +```tsx +<__experimental_GoogleOneTap + cancelOnTapOutside={false} + itpSupport={false} + fedCmSupport={false} +/> +``` + +- Moved authenticateWithGoogleOneTap to Clerk singleton +```ts +Clerk.__experimental_authenticateWithGoogleOneTap +``` + +- Created the handleGoogleOneTapCallback in Clerk singleton +```ts +Clerk.__experimental_handleGoogleOneTapCallback +``` diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 3bc9144f629..94f1ad35660 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, @@ -16,6 +17,7 @@ import { stripScheme, } from '@clerk/shared'; import type { + __experimental_AuthenticateWithGoogleOneTapParams, ActiveSessionResource, AuthenticateWithMetamaskParams, BeforeEmitCallback, @@ -966,14 +968,47 @@ export default class Clerk implements ClerkInterface { return null; }; - public handleRedirectCallback = async ( - params: HandleOAuthCallbackParams = {}, + public __experimental_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 +1018,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 +1026,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 +1060,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 +1086,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 +1155,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 +1198,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 +1232,29 @@ export default class Clerk implements ClerkInterface { return this.setActive({ session: null }); }; + public __experimental_authenticateWithGoogleOneTap = async ( + params: __experimental_AuthenticateWithGoogleOneTapParams, + ): Promise => { + return this.client?.signIn + .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 this.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 authenticateWithMetamask = async ({ redirectUrl, signUpContinueUrl, 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..e0b260d5171 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,46 +1,52 @@ 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(); + const { + signInUrl, + signUpUrl, + continueSignUpUrl, + secondFactorUrl, + firstFactorUrl, + signUpForceRedirectUrl, + signInForceRedirectUrl, + } = ctx; async function oneTapCallback(response: GISCredentialResponse) { + isPromptedRef.current = false; try { - const res = await signIn.__experimental_authenticateWithGoogleOneTap({ + const res = await clerk.__experimental_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.__experimental_handleGoogleOneTapCallback( + res, + { + signInUrl, + signUpUrl, + continueSignUpUrl, + secondFactorUrl, + firstFactorUrl, + signUpForceRedirectUrl, + signInForceRedirectUrl, + }, + navigate, + ); + } catch (e) { + console.error(e); } } @@ -50,33 +56,39 @@ function _OneTapStart(): JSX.Element | null { /** * Prevent GIS from initializing multiple times */ - const { data: google } = useFetch(shouldLoadGIS ? loadGIS : undefined, 'google-identity-services-script', { + useFetch(shouldLoadGIS ? loadGIS : undefined, 'google-identity-services-script', { onSuccess(google) { google.accounts.id.initialize({ - client_id: environmentClientID!, + client_id: environmentClientID, // eslint-disable-next-line @typescript-eslint/no-misused-promises callback: oneTapCallback, - itp_support: true, + itp_support: ctx.itpSupport, cancel_on_tap_outside: ctx.cancelOnTapOutside, auto_select: false, - use_fedcm_for_prompt: true, + use_fedcm_for_prompt: ctx.fedCmSupport, }); google.accounts.id.prompt(); + isPromptedRef.current = true; }, }); - // Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization useEffect(() => { - if (google && !user?.id) { - google.accounts.id.prompt(); + if (window.google && !user?.id && !isPromptedRef.current) { + window.google.accounts.id.prompt(); + isPromptedRef.current = true; } + }, [user?.id]); + + // Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization + useEffect(() => { return () => { - if (google) { - google.accounts.id.cancel(); + if (window.google && isPromptedRef.current) { + isPromptedRef.current = false; + window.google.accounts.id.cancel(); } }; - }, [user?.id]); + }, []); return null; } diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 98df5652859..a38ddc0eaee 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -49,7 +49,7 @@ export type SignUpContextType = SignUpCtx & { }; export const useSignUpContext = (): SignUpContextType => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignUpCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -125,7 +125,7 @@ export type SignInContextType = SignInCtx & { }; export const useSignInContext = (): SignInContextType => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignInCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -202,7 +202,7 @@ export type UserProfileContextType = UserProfileCtx & { }; export const useUserProfileContext = (): UserProfileContextType => { - const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as UserProfileCtx; + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); const { queryParams } = useRouter(); if (componentName !== 'UserProfile') { @@ -221,7 +221,7 @@ export const useUserProfileContext = (): UserProfileContextType => { }; export const useUserButtonContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as UserButtonCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const Clerk = useCoreClerk(); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -258,7 +258,7 @@ export const useUserButtonContext = () => { }; export const useOrganizationSwitcherContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationSwitcherCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -421,7 +421,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { }; export const useOrganizationProfileContext = (): OrganizationProfileContextType => { - const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationProfileCtx; + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -458,7 +458,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType }; export const useCreateOrganizationContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as CreateOrganizationCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -491,14 +491,75 @@ export const useCreateOrganizationContext = () => { }; export const useGoogleOneTapContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OneTapCtx; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const options = useOptions(); + const { displayConfig } = useEnvironment(); + const { queryParams } = useRouter(); if (componentName !== 'OneTap') { throw new Error('Clerk: useGoogleOneTapContext called outside GoogleOneTap.'); } + const redirectUrls = new RedirectUrls( + options, + { + ...ctx, + redirectUrl: window.location.href, + }, + queryParams, + ); + + let signUpUrl = options.signUpUrl || displayConfig.signUpUrl; + let signInUrl = options.signInUrl || displayConfig.signInUrl; + + const preservedParams = redirectUrls.getPreservedSearchParams(); + signInUrl = buildURL({ base: signInUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + + const signInForceRedirectUrl = redirectUrls.getAfterSignInUrl(); + const signUpForceRedirectUrl = redirectUrls.getAfterSignUpUrl(); + + const signUpContinueUrl = buildURL( + { + base: signUpUrl, + hashPath: '/continue', + hashSearch: new URLSearchParams({ + sign_up_force_redirect_url: signUpForceRedirectUrl, + }).toString(), + }, + { stringify: true }, + ); + + const firstFactorUrl = buildURL( + { + base: signInUrl, + hashPath: '/factor-one', + hashSearch: new URLSearchParams({ + sign_in_force_redirect_url: signInForceRedirectUrl, + }).toString(), + }, + { stringify: true }, + ); + const secondFactorUrl = buildURL( + { + base: signInUrl, + hashPath: '/factor-two', + hashSearch: new URLSearchParams({ + sign_in_force_redirect_url: signInForceRedirectUrl, + }).toString(), + }, + { stringify: true }, + ); + return { ...ctx, componentName, + signInUrl, + signUpUrl, + firstFactorUrl, + secondFactorUrl, + continueSignUpUrl: signUpContinueUrl, + signInForceRedirectUrl, + signUpForceRedirectUrl, }; }; diff --git a/packages/clerk-js/src/utils/one-tap.ts b/packages/clerk-js/src/utils/one-tap.ts index 4698b64445a..54ffd6cdf66 100644 --- a/packages/clerk-js/src/utils/one-tap.ts +++ b/packages/clerk-js/src/utils/one-tap.ts @@ -31,7 +31,7 @@ interface Google { declare global { export interface Window { - google: Google; + google?: Google; } } @@ -44,7 +44,7 @@ async function loadGIS() { clerkFailedToLoadThirdPartyScript('Google Identity Services'); } } - return window.google; + return window.google as Google; } export { loadGIS }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index e18cfa3c4b5..9eebe337f69 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'; @@ -422,6 +424,16 @@ export interface Clerk { */ redirectToHome: () => void; + /** + * Completes an Google One Tap redirection flow started by + * {@link Clerk.__experimental_authenticateWithGoogleOneTap} + */ + __experimental_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 +466,14 @@ export interface Clerk { */ authenticateWithMetamask: (params?: AuthenticateWithMetamaskParams) => Promise; + /** + * @experimental + * Authenticates user using a google token generated from google identity services. + */ + __experimental_authenticateWithGoogleOneTap: ( + params: __experimental_AuthenticateWithGoogleOneTapParams, + ) => Promise; + /** * Creates an organization, adding the current user as admin. */ @@ -685,8 +705,27 @@ export type SignInProps = { initialValues?: SignInInitialValues; } & RedirectOptions; -export type OneTapProps = { +type OneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; + +export type OneTapProps = OneTapRedirectUrlProps & { + /** + * 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 +1101,10 @@ export interface AuthenticateWithMetamaskParams { unsafeMetadata?: SignUpUnsafeMetadata; } +export interface __experimental_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..1f92ee7b229 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -1,3 +1,4 @@ +import type { __experimental_AuthenticateWithGoogleOneTapParams } from './clerk'; import type { BackupCodeAttempt, BackupCodeFactor, @@ -99,7 +100,7 @@ export interface SignInResource extends ClerkResource { createMagicLinkFlow: () => CreateMagicLinkFlowReturn; /** - * @experimental + * @deprecated Use `Clerk.__experimental_authenticateWithGoogleOneTap` */ __experimental_authenticateWithGoogleOneTap: ( params: __experimental_AuthenticateWithGoogleOneTapParams, @@ -220,10 +221,6 @@ export interface SignInStartMagicLinkFlowParams extends StartMagicLinkFlowParams emailAddressId: string; } -export type __experimental_AuthenticateWithGoogleOneTapParams = { - token: string; -}; - export interface SignInStartEmailLinkFlowParams extends StartEmailLinkFlowParams { emailAddressId: string; } From 38122c397e7d8e37fb0ce6f0f7dd9caaf5c36fc5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 16 May 2024 12:54:14 +0300 Subject: [PATCH 03/12] feat(clerk-js,clerk-react): Replace mount with open for GoogleOneTap (#3379) * feat(clerk-js,clerk-react): Replace mount with open for GoogleOneTap * feat(clerk-js,clerk-react): Add changeset * feat(clerk-js,clerk-react): Reuse initialized GIS instance (cherry picked from commit 3d790d5ea347a51ef16557c015c901a9f277effe) --- .changeset/warm-pumas-fetch.md | 7 +++ packages/clerk-js/src/core/clerk.ts | 36 ++++-------- packages/clerk-js/src/ui/Components.tsx | 33 +++++++++-- .../components/GoogleOneTap/one-tap-start.tsx | 52 ++++++++++------- .../ui/contexts/ClerkUIComponentsContext.tsx | 19 ++++--- .../clerk-js/src/ui/lazyModules/providers.tsx | 25 ++++++++ packages/clerk-js/src/ui/portal/index.tsx | 57 +++++++++++-------- packages/clerk-js/src/utils/one-tap.ts | 6 +- .../react/src/components/uiComponents.tsx | 49 ++++++++++++---- packages/react/src/isomorphicClerk.ts | 37 +++++++----- packages/react/src/types.ts | 6 ++ packages/types/src/clerk.ts | 30 +++++----- 12 files changed, 231 insertions(+), 126 deletions(-) create mode 100644 .changeset/warm-pumas-fetch.md diff --git a/.changeset/warm-pumas-fetch.md b/.changeset/warm-pumas-fetch.md new file mode 100644 index 00000000000..18a43bd927b --- /dev/null +++ b/.changeset/warm-pumas-fetch.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Replace mount with open for GoogleOneTap. New api is `__experimental_openGoogleOneTap`. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 94f1ad35660..f82b011712c 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -366,6 +366,18 @@ export default class Clerk implements ClerkInterface { } }; + public __experimental_openGoogleOneTap = (props?: OneTapProps): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls + .ensureMounted({ preloadHint: 'OneTap' }) + .then(controls => controls.openModal('googleOneTap', props || {})); + }; + + public __experimental_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') { @@ -459,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 => diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index ef8fb3a3160..15678e9de0b 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, + OneTapProps, 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 | OneTapProps; 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/one-tap-start.tsx b/packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx index e0b260d5171..806a98a939e 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 @@ -53,42 +53,50 @@ function _OneTapStart(): JSX.Element | null { 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 */ - 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: ctx.itpSupport, - cancel_on_tap_outside: ctx.cancelOnTapOutside, - auto_select: false, - use_fedcm_for_prompt: ctx.fedCmSupport, - }); - - google.accounts.id.prompt(); - isPromptedRef.current = true; - }, - }); + const { data: initializedGoogle } = useFetch( + shouldLoadGIS ? initializeGIS : undefined, + 'google-identity-services-script', + ); useEffect(() => { - if (window.google && !user?.id && !isPromptedRef.current) { - window.google.accounts.id.prompt(); + 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.__experimental_closeGoogleOneTap(); + } + }); isPromptedRef.current = true; } - }, [user?.id]); + }, [clerk, initializedGoogle, user?.id]); // Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization useEffect(() => { return () => { - if (window.google && isPromptedRef.current) { + if (initializedGoogle && isPromptedRef.current) { isPromptedRef.current = false; - window.google.accounts.id.cancel(); + initializedGoogle.accounts.id.cancel(); } }; - }, []); + }, [initializedGoogle]); return null; } diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index a38ddc0eaee..9d2f16d171a 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -49,7 +49,7 @@ export type SignUpContextType = SignUpCtx & { }; export const useSignUpContext = (): SignUpContextType => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -125,7 +125,7 @@ export type SignInContextType = SignInCtx & { }; export const useSignInContext = (): SignInContextType => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -202,7 +202,7 @@ export type UserProfileContextType = UserProfileCtx & { }; export const useUserProfileContext = (): UserProfileContextType => { - const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, customPages, ...ctx } = React.useContext(ComponentContext) || {}; const { queryParams } = useRouter(); if (componentName !== 'UserProfile') { @@ -221,7 +221,7 @@ export const useUserProfileContext = (): UserProfileContextType => { }; export const useUserButtonContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; const Clerk = useCoreClerk(); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -258,7 +258,7 @@ export const useUserButtonContext = () => { }; export const useOrganizationSwitcherContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -421,7 +421,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { }; export const useOrganizationProfileContext = (): OrganizationProfileContextType => { - const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, customPages, ...ctx } = React.useContext(ComponentContext) || {}; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -458,7 +458,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType }; export const useCreateOrganizationContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -491,7 +491,7 @@ export const useCreateOrganizationContext = () => { }; export const useGoogleOneTapContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; const options = useOptions(); const { displayConfig } = useEnvironment(); const { queryParams } = useRouter(); @@ -504,7 +504,8 @@ export const useGoogleOneTapContext = () => { options, { ...ctx, - redirectUrl: window.location.href, + signInFallbackRedirectUrl: window.location.href, + signUpFallbackRedirectUrl: window.location.href, }, queryParams, ); diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx index a76e0ac432f..97ffdb5f6b6 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -2,6 +2,7 @@ import type { Appearance } from '@clerk/types'; import React, { lazy, Suspense } from 'react'; import type { FlowMetadata } from '../elements'; +import { VirtualBodyRootPortal } from '../portal'; import type { ThemableCssProp } from '../styledSystem'; import type { ClerkComponentName } from './components'; import { ClerkComponents } from './components'; @@ -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/utils/one-tap.ts b/packages/clerk-js/src/utils/one-tap.ts index 54ffd6cdf66..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; } diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 01ffab53b48..74d57a62a2c 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -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 }))} ); } @@ -125,7 +151,7 @@ export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp ); @@ -136,7 +162,7 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp ); @@ -231,7 +257,7 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< ); @@ -264,7 +290,7 @@ export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp ); @@ -273,9 +299,8 @@ export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( ); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 12e10e41af3..a057c8ba288 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -91,7 +91,6 @@ type IsomorphicLoadedClerk = Omit< | 'mountSignUp' | 'mountSignIn' | 'mountUserProfile' - | '__experimental_mountGoogleOneTap' | 'client' | 'getOrganizationMemberships' > & { @@ -133,7 +132,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 +144,7 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { private readonly options: IsomorphicClerkOptions; private readonly Clerk: ClerkProp; private clerkjs: BrowserClerk | HeadlessBrowserClerk | null = null; + private preopenOneTap?: null | OneTapProps = null; private preopenSignIn?: null | SignInProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; @@ -450,6 +449,10 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.openUserProfile(this.preopenUserProfile); } + if (this.preopenOneTap !== null) { + clerkjs.__experimental_openGoogleOneTap(this.preopenOneTap); + } + if (this.preopenOrganizationProfile !== null) { clerkjs.openOrganizationProfile(this.preopenOrganizationProfile); } @@ -578,6 +581,22 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __experimental_openGoogleOneTap = (props?: OneTapProps): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_openGoogleOneTap(props); + } else { + this.preopenOneTap = props; + } + }; + + __experimental_closeGoogleOneTap = (): void => { + if (this.clerkjs && this.#loaded) { + this.clerkjs.__experimental_closeGoogleOneTap(); + } else { + this.preopenOneTap = null; + } + }; + openUserProfile = (props?: UserProfileProps): void => { if (this.clerkjs && this.#loaded) { this.clerkjs.openUserProfile(props); @@ -658,18 +677,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); @@ -943,7 +950,7 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { signOutCallbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions, ): Promise => { - const callback = () => this.clerkjs?.signOut(signOutCallbackOrOptions as any, options); + const callback = () => this.clerkjs?.signOut(signOutCallbackOrOptions , options); if (this.clerkjs && this.#loaded) { return callback() as Promise; } else { 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 9eebe337f69..70def22dbb0 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -130,6 +130,20 @@ export interface Clerk { */ closeSignIn: () => void; + /** + * Opens the Google One Tap component. + * @experimental + * @param props Optional props that will be passed to the GoogleOneTap component. + */ + __experimental_openGoogleOneTap: (props?: OneTapProps) => void; + + /** + * Opens the Google One Tap component. + * If the component is not already open, results in a noop. + * @experimental + */ + __experimental_closeGoogleOneTap: () => void; + /** * Opens the Clerk SignUp component in a modal. * @param props Optional props that will be passed to the SignUp component. @@ -189,22 +203,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. * From 2af4d6f5e8f7adcb06aaf9bc08c64dd2036d1191 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 20 May 2024 20:39:35 +0300 Subject: [PATCH 04/12] feat(clerk-js,clerk-react,nextjs,types): Drop experimental tag from GoogleOneTap (#3392) * feat(clerk-js,clerk-react,nextjs,types): Drop experimental tag from GoogleOneTap * feat(clerk-js,clerk-react,nextjs,types): Add changeset * test(chrome-extension): Update snapshots (cherry picked from commit d6a9b3f5dd8c64b1bd49f74c3707eb01dcd6aff4) --- .changeset/shiny-yaks-wave.md | 53 ++++++++++++++++ .../src/__snapshots__/exports.test.ts.snap | 2 +- packages/clerk-js/src/core/clerk.ts | 25 ++++---- .../clerk-js/src/core/resources/SignIn.ts | 29 +-------- packages/clerk-js/src/ui/Components.tsx | 4 +- .../src/ui/components/GoogleOneTap/index.tsx | 4 +- .../components/GoogleOneTap/one-tap-start.tsx | 6 +- .../ui/contexts/ClerkUIComponentsContext.tsx | 19 +++--- .../clerk-js/src/ui/lazyModules/components.ts | 8 ++- .../clerk-js/src/ui/lazyModules/providers.tsx | 4 +- packages/clerk-js/src/ui/types.ts | 10 +-- .../src/client-boundary/uiComponents.tsx | 2 +- packages/nextjs/src/index.ts | 8 +-- packages/react/src/components/index.ts | 2 +- .../react/src/components/uiComponents.tsx | 18 +++--- packages/react/src/isomorphicClerk.ts | 61 ++++++++++++++++--- packages/types/src/clerk.ts | 25 ++++---- packages/types/src/signIn.ts | 14 ++--- packages/types/src/signUp.ts | 4 +- packages/types/src/strategies.ts | 1 + 20 files changed, 184 insertions(+), 115 deletions(-) create mode 100644 .changeset/shiny-yaks-wave.md diff --git a/.changeset/shiny-yaks-wave.md b/.changeset/shiny-yaks-wave.md new file mode 100644 index 00000000000..7d5810ee137 --- /dev/null +++ b/.changeset/shiny-yaks-wave.md @@ -0,0 +1,53 @@ +--- +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Add support for GoogleOneTap. New APIs listed: +### React component +- `` + +Customize the UX of the prompt + +```tsx + +``` + +### Use the component from with Vanilla JS +- `Clerk.openGoogleOneTap(props: GoogleOneTapProps)` +- `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 to perform the correct redirections. +```tsx +google.accounts.id.initialize({ + callback: async response => { + const signInOrUp = await Clerk.authenticateWithGoogleOneTap({ token: response.credential}) + await Clerk.handleGoogleOneTapCallback(signInOrUp, { + signInForceRedirectUrl: 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/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap index 374c803e3af..49bc7a04a74 100644 --- a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap @@ -10,6 +10,7 @@ exports[`public exports should not include a breaking change 1`] = ` "EmailLinkErrorCode", "MagicLinkErrorCode", "MultisessionAppSupport", + "GoogleOneTap", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", @@ -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 f82b011712c..c8b04b0a060 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -17,8 +17,8 @@ import { stripScheme, } from '@clerk/shared'; import type { - __experimental_AuthenticateWithGoogleOneTapParams, ActiveSessionResource, + AuthenticateWithGoogleOneTapParams, AuthenticateWithMetamaskParams, BeforeEmitCallback, Clerk as ClerkInterface, @@ -30,12 +30,13 @@ import type { DomainOrProxyUrl, EnvironmentJSON, EnvironmentResource, + GoogleOneTapProps, HandleEmailLinkVerificationParams, HandleMagicLinkVerificationParams, HandleOAuthCallbackParams, InstanceType, ListenerCallback, - OneTapProps, + NavigateOptions, OrganizationInvitationResource, OrganizationListProps, OrganizationMembershipResource, @@ -366,14 +367,14 @@ export default class Clerk implements ClerkInterface { } }; - public __experimental_openGoogleOneTap = (props?: OneTapProps): void => { + public openGoogleOneTap = (props?: GoogleOneTapProps): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls - .ensureMounted({ preloadHint: 'OneTap' }) + .ensureMounted({ preloadHint: 'GoogleOneTap' }) .then(controls => controls.openModal('googleOneTap', props || {})); }; - public __experimental_closeGoogleOneTap = (): void => { + public closeGoogleOneTap = (): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls.ensureMounted().then(controls => controls.closeModal('googleOneTap')); }; @@ -956,7 +957,7 @@ export default class Clerk implements ClerkInterface { return null; }; - public __experimental_handleGoogleOneTapCallback = async ( + public handleGoogleOneTapCallback = async ( signInOrUp: SignInResource | SignUpResource, params: HandleOAuthCallbackParams, customNavigate?: (to: string) => Promise, @@ -1220,23 +1221,19 @@ export default class Clerk implements ClerkInterface { return this.setActive({ session: null }); }; - public __experimental_authenticateWithGoogleOneTap = async ( - params: __experimental_AuthenticateWithGoogleOneTapParams, + public authenticateWithGoogleOneTap = async ( + params: AuthenticateWithGoogleOneTapParams, ): Promise => { return this.client?.signIn .create({ - // TODO-ONETAP: Add new types when feature is ready for public beta - // @ts-expect-error strategy: 'google_one_tap', - googleOneTapToken: params.token, + token: params.token, }) .catch(err => { if (isClerkAPIResponseError(err) && err.errors[0].code === 'external_account_not_found') { return this.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, + token: params.token, }); } throw err; diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 7ae41eafcdb..35091df7d2a 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')) { @@ -282,7 +259,7 @@ export class SignIn extends BaseResource implements SignInResource { const { nonce } = this.firstFactorVerification; const signature = await generateSignature({ identifier: this.identifier!, - nonce: nonce!, + nonce: nonce, }); return this.attemptFirstFactor({ @@ -302,7 +279,7 @@ export class SignIn extends BaseResource implements SignInResource { validatePassword: ReturnType = (password, cb) => { if (SignIn.clerk.__unstable__environment?.userSettings.passwordSettings) { return createValidatePassword({ - ...(SignIn.clerk.__unstable__environment?.userSettings.passwordSettings as any), + ...(SignIn.clerk.__unstable__environment?.userSettings.passwordSettings ), validatePassword: true, })(password, cb); } diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 15678e9de0b..2b94fa63e53 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -6,7 +6,7 @@ import type { ClerkOptions, CreateOrganizationProps, EnvironmentResource, - OneTapProps, + GoogleOneTapProps, OrganizationProfileProps, SignInProps, SignUpProps, @@ -84,7 +84,7 @@ interface ComponentsProps { interface ComponentsState { appearance: Appearance | undefined; options: ClerkOptions | undefined; - googleOneTapModal: null | OneTapProps; + googleOneTapModal: null | GoogleOneTapProps; signInModal: null | SignInProps; signUpModal: null | SignUpProps; userProfileModal: null | UserProfileProps; 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 806a98a939e..a15b9fef3d0 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 @@ -29,10 +29,10 @@ function _OneTapStart(): JSX.Element | null { async function oneTapCallback(response: GISCredentialResponse) { isPromptedRef.current = false; try { - const res = await clerk.__experimental_authenticateWithGoogleOneTap({ + const res = await clerk.authenticateWithGoogleOneTap({ token: response.credential, }); - await clerk.__experimental_handleGoogleOneTapCallback( + await clerk.handleGoogleOneTapCallback( res, { signInUrl, @@ -81,7 +81,7 @@ function _OneTapStart(): JSX.Element | null { // 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.__experimental_closeGoogleOneTap(); + clerk.closeGoogleOneTap(); } }); isPromptedRef.current = true; diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 9d2f16d171a..b417fd9f416 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -12,13 +12,12 @@ import { useRouter } from '../router'; import type { AvailableComponentCtx, CreateOrganizationCtx, - OneTapCtx, + GoogleOneTapCtx, OrganizationListCtx, OrganizationProfileCtx, OrganizationSwitcherCtx, SignInCtx, SignUpCtx, - UserButtonCtx, UserProfileCtx, } from '../types'; import type { CustomPageContent } from '../utils'; @@ -49,7 +48,7 @@ export type SignUpContextType = SignUpCtx & { }; export const useSignUpContext = (): SignUpContextType => { - const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -125,7 +124,7 @@ export type SignInContextType = SignInCtx & { }; export const useSignInContext = (): SignInContextType => { - const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -202,7 +201,7 @@ export type UserProfileContextType = UserProfileCtx & { }; export const useUserProfileContext = (): UserProfileContextType => { - const { componentName, customPages, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); const { queryParams } = useRouter(); if (componentName !== 'UserProfile') { @@ -258,7 +257,7 @@ export const useUserButtonContext = () => { }; export const useOrganizationSwitcherContext = () => { - const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -421,7 +420,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { }; export const useOrganizationProfileContext = (): OrganizationProfileContextType => { - const { componentName, customPages, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -458,7 +457,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType }; export const useCreateOrganizationContext = () => { - const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -491,12 +490,12 @@ export const useCreateOrganizationContext = () => { }; export const useGoogleOneTapContext = () => { - const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); const options = useOptions(); const { displayConfig } = useEnvironment(); const { queryParams } = useRouter(); - if (componentName !== 'OneTap') { + if (componentName !== 'GoogleOneTap') { throw new Error('Clerk: useGoogleOneTapContext called outside GoogleOneTap.'); } 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 97ffdb5f6b6..87e01937321 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -146,9 +146,9 @@ export const LazyOneTapRenderer = (props: LazyOneTapRendererProps) => { > ); 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/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..5f6215ee50f 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'; /** @@ -99,11 +99,11 @@ export const Protect = ComponentsModule.Protect as ServerComponentsServerModuleT export const auth = ServerHelperModule.auth as ServerHelpersServerModuleTypes['auth']; export const currentUser = ServerHelperModule.currentUser as ServerHelpersServerModuleTypes['currentUser']; // export const getAuth = ServerHelperModule.getAuth as ServerHelpersServerModuleTypes['getAuth']; -export const clerkClient = ServerHelperModule.clerkClient as ServerHelpersServerModuleTypes['clerkClient']; +export const clerkClient = ServerHelperModule.clerkClient ; export const authMiddleware = ServerHelperModule.authMiddleware as ServerHelpersServerModuleTypes['authMiddleware']; export const redirectToSignIn = - ServerHelperModule.redirectToSignIn as ServerHelpersServerModuleTypes['redirectToSignIn']; + ServerHelperModule.redirectToSignIn ; export const redirectToSignUp = - ServerHelperModule.redirectToSignUp as ServerHelpersServerModuleTypes['redirectToSignUp']; + ServerHelperModule.redirectToSignUp ; export const withClerkMiddleware = ServerHelperModule.withClerkMiddleware as ServerHelpersServerModuleTypes['withClerkMiddleware']; 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 74d57a62a2c..43cef29bb62 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, @@ -151,7 +151,7 @@ export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp ); @@ -162,7 +162,7 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp ); @@ -257,7 +257,7 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< ); @@ -290,18 +290,18 @@ export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp ); }, 'OrganizationList'); -export const __experimental_GoogleOneTap = 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 a057c8ba288..28ab5234c13 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' @@ -102,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) @@ -144,7 +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 | OneTapProps = null; + private preopenOneTap?: null | GoogleOneTapProps = null; private preopenSignIn?: null | SignInProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; @@ -330,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,7 +468,7 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } if (this.preopenOneTap !== null) { - clerkjs.__experimental_openGoogleOneTap(this.preopenOneTap); + clerkjs.openGoogleOneTap(this.preopenOneTap); } if (this.preopenOrganizationProfile !== null) { @@ -581,17 +599,17 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __experimental_openGoogleOneTap = (props?: OneTapProps): void => { + openGoogleOneTap = (props?: GoogleOneTapProps): void => { if (this.clerkjs && this.#loaded) { - this.clerkjs.__experimental_openGoogleOneTap(props); + this.clerkjs.openGoogleOneTap(props); } else { this.preopenOneTap = props; } }; - __experimental_closeGoogleOneTap = (): void => { + closeGoogleOneTap = (): void => { if (this.clerkjs && this.#loaded) { - this.clerkjs.__experimental_closeGoogleOneTap(); + this.clerkjs.closeGoogleOneTap(); } else { this.preopenOneTap = null; } @@ -901,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) { @@ -919,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) { @@ -950,7 +995,7 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { signOutCallbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions, ): Promise => { - const callback = () => this.clerkjs?.signOut(signOutCallbackOrOptions , options); + const callback = () => this.clerkjs?.signOut(signOutCallbackOrOptions, options); if (this.clerkjs && this.#loaded) { return callback() as Promise; } else { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 70def22dbb0..c1175964b0c 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -132,17 +132,15 @@ export interface Clerk { /** * Opens the Google One Tap component. - * @experimental * @param props Optional props that will be passed to the GoogleOneTap component. */ - __experimental_openGoogleOneTap: (props?: OneTapProps) => void; + openGoogleOneTap: (props?: GoogleOneTapProps) => void; /** * Opens the Google One Tap component. * If the component is not already open, results in a noop. - * @experimental */ - __experimental_closeGoogleOneTap: () => void; + closeGoogleOneTap: () => void; /** * Opens the Clerk SignUp component in a modal. @@ -423,10 +421,10 @@ export interface Clerk { redirectToHome: () => void; /** - * Completes an Google One Tap redirection flow started by - * {@link Clerk.__experimental_authenticateWithGoogleOneTap} + * Completes a Google One Tap redirection flow started by + * {@link Clerk.authenticateWithGoogleOneTap} */ - __experimental_handleGoogleOneTapCallback: ( + handleGoogleOneTapCallback: ( signInOrUp: SignInResource | SignUpResource, params: HandleOAuthCallbackParams, customNavigate?: (to: string) => Promise, @@ -465,11 +463,10 @@ export interface Clerk { authenticateWithMetamask: (params?: AuthenticateWithMetamaskParams) => Promise; /** - * @experimental - * Authenticates user using a google token generated from google identity services. + * Authenticates user using a Google token generated from Google identity services. */ - __experimental_authenticateWithGoogleOneTap: ( - params: __experimental_AuthenticateWithGoogleOneTapParams, + authenticateWithGoogleOneTap: ( + params: AuthenticateWithGoogleOneTapParams, ) => Promise; /** @@ -703,9 +700,9 @@ export type SignInProps = { initialValues?: SignInInitialValues; } & RedirectOptions; -type OneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; +type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; -export type OneTapProps = OneTapRedirectUrlProps & { +export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & { /** * Whether to cancel the Google One Tap request if a user clicks outside the prompt. * @default true @@ -1099,7 +1096,7 @@ export interface AuthenticateWithMetamaskParams { unsafeMetadata?: SignUpUnsafeMetadata; } -export interface __experimental_AuthenticateWithGoogleOneTapParams { +export interface AuthenticateWithGoogleOneTapParams { token: string; } diff --git a/packages/types/src/signIn.ts b/packages/types/src/signIn.ts index 1f92ee7b229..b9288e1a584 100644 --- a/packages/types/src/signIn.ts +++ b/packages/types/src/signIn.ts @@ -1,4 +1,3 @@ -import type { __experimental_AuthenticateWithGoogleOneTapParams } from './clerk'; import type { BackupCodeAttempt, BackupCodeFactor, @@ -45,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, @@ -99,13 +98,6 @@ export interface SignInResource extends ClerkResource { */ createMagicLinkFlow: () => CreateMagicLinkFlowReturn; - /** - * @deprecated Use `Clerk.__experimental_authenticateWithGoogleOneTap` - */ - __experimental_authenticateWithGoogleOneTap: ( - params: __experimental_AuthenticateWithGoogleOneTapParams, - ) => Promise; - createEmailLinkFlow: () => CreateEmailLinkFlowReturn; validatePassword: (password: string, callbacks?: ValidatePasswordCallbacks) => void; @@ -183,6 +175,10 @@ export type SignInCreateParams = ( strategy: TicketStrategy; ticket: string; } + | { + strategy: GoogleOneTapStrategy; + token: string; + } | { strategy: PasswordStrategy; password: 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'; From 8dfc2f45fb63251d18b9be00d478dffa05e361ea Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 12:43:12 +0300 Subject: [PATCH 05/12] chore(clerk-js): Fix issues after conflicts --- .../src/__snapshots__/exports.test.ts.snap | 2 +- packages/clerk-js/src/core/clerk.ts | 1 - .../clerk-js/src/core/resources/SignIn.ts | 4 +- .../components/GoogleOneTap/one-tap-start.tsx | 18 ++---- .../ui/contexts/ClerkUIComponentsContext.tsx | 63 ++++++++++--------- .../clerk-js/src/ui/lazyModules/providers.tsx | 4 +- packages/nextjs/src/index.ts | 6 +- .../react/src/components/uiComponents.tsx | 8 +-- packages/react/src/isomorphicClerk.ts | 2 +- packages/types/src/clerk.ts | 2 +- 10 files changed, 51 insertions(+), 59 deletions(-) diff --git a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap index 49bc7a04a74..08dbadcf503 100644 --- a/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__snapshots__/exports.test.ts.snap @@ -8,9 +8,9 @@ exports[`public exports should not include a breaking change 1`] = ` "ClerkProvider", "CreateOrganization", "EmailLinkErrorCode", + "GoogleOneTap", "MagicLinkErrorCode", "MultisessionAppSupport", - "GoogleOneTap", "OrganizationList", "OrganizationProfile", "OrganizationSwitcher", diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c8b04b0a060..4a9cfe2af1e 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -36,7 +36,6 @@ import type { HandleOAuthCallbackParams, InstanceType, ListenerCallback, - NavigateOptions, OrganizationInvitationResource, OrganizationListProps, OrganizationMembershipResource, diff --git a/packages/clerk-js/src/core/resources/SignIn.ts b/packages/clerk-js/src/core/resources/SignIn.ts index 35091df7d2a..9df5c10d3b0 100644 --- a/packages/clerk-js/src/core/resources/SignIn.ts +++ b/packages/clerk-js/src/core/resources/SignIn.ts @@ -259,7 +259,7 @@ export class SignIn extends BaseResource implements SignInResource { const { nonce } = this.firstFactorVerification; const signature = await generateSignature({ identifier: this.identifier!, - nonce: nonce, + nonce: nonce!, }); return this.attemptFirstFactor({ @@ -279,7 +279,7 @@ export class SignIn extends BaseResource implements SignInResource { validatePassword: ReturnType = (password, cb) => { if (SignIn.clerk.__unstable__environment?.userSettings.passwordSettings) { return createValidatePassword({ - ...(SignIn.clerk.__unstable__environment?.userSettings.passwordSettings ), + ...(SignIn.clerk.__unstable__environment?.userSettings.passwordSettings as any), validatePassword: true, })(password, cb); } 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 a15b9fef3d0..171272de4ce 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 @@ -16,15 +16,7 @@ function _OneTapStart(): JSX.Element | null { const { navigate } = useRouter(); const ctx = useGoogleOneTapContext(); - const { - signInUrl, - signUpUrl, - continueSignUpUrl, - secondFactorUrl, - firstFactorUrl, - signUpForceRedirectUrl, - signInForceRedirectUrl, - } = ctx; + const { continueSignUpUrl, secondFactorUrl, firstFactorUrl, afterSignInUrl, afterSignUpUrl } = ctx; async function oneTapCallback(response: GISCredentialResponse) { isPromptedRef.current = false; @@ -35,13 +27,11 @@ function _OneTapStart(): JSX.Element | null { await clerk.handleGoogleOneTapCallback( res, { - signInUrl, - signUpUrl, continueSignUpUrl, secondFactorUrl, firstFactorUrl, - signUpForceRedirectUrl, - signInForceRedirectUrl, + afterSignInUrl, + afterSignUpUrl, }, navigate, ); @@ -56,7 +46,7 @@ function _OneTapStart(): JSX.Element | null { async function initializeGIS() { const google = await loadGIS(); google.accounts.id.initialize({ - client_id: environmentClientID, + client_id: environmentClientID!, // eslint-disable-next-line @typescript-eslint/no-misused-promises callback: oneTapCallback, itp_support: ctx.itpSupport, diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index b417fd9f416..17147f11e02 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -18,6 +18,7 @@ import type { OrganizationSwitcherCtx, SignInCtx, SignUpCtx, + UserButtonCtx, UserProfileCtx, } from '../types'; import type { CustomPageContent } from '../utils'; @@ -48,7 +49,7 @@ export type SignUpContextType = SignUpCtx & { }; export const useSignUpContext = (): SignUpContextType => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignUpCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -124,7 +125,7 @@ export type SignInContextType = SignInCtx & { }; export const useSignInContext = (): SignInContextType => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignInCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); const { queryParams, queryString } = useRouter(); @@ -201,7 +202,7 @@ export type UserProfileContextType = UserProfileCtx & { }; export const useUserProfileContext = (): UserProfileContextType => { - const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as UserProfileCtx; const { queryParams } = useRouter(); if (componentName !== 'UserProfile') { @@ -220,7 +221,7 @@ export const useUserProfileContext = (): UserProfileContextType => { }; export const useUserButtonContext = () => { - const { componentName, ...ctx } = React.useContext(ComponentContext) || {}; + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as UserButtonCtx; const Clerk = useCoreClerk(); const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -257,7 +258,7 @@ export const useUserButtonContext = () => { }; export const useOrganizationSwitcherContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationSwitcherCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -420,7 +421,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { }; export const useOrganizationProfileContext = (): OrganizationProfileContextType => { - const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationProfileCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -457,7 +458,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType }; export const useCreateOrganizationContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as CreateOrganizationCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -490,41 +491,43 @@ export const useCreateOrganizationContext = () => { }; export const useGoogleOneTapContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}); + const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as GoogleOneTapCtx; const options = useOptions(); const { displayConfig } = useEnvironment(); const { queryParams } = useRouter(); + const clerk = useCoreClerk(); if (componentName !== 'GoogleOneTap') { throw new Error('Clerk: useGoogleOneTapContext called outside GoogleOneTap.'); } - const redirectUrls = new RedirectUrls( - options, - { - ...ctx, - signInFallbackRedirectUrl: window.location.href, - signUpFallbackRedirectUrl: window.location.href, - }, - queryParams, - ); - - let signUpUrl = options.signUpUrl || displayConfig.signUpUrl; - let signInUrl = options.signInUrl || displayConfig.signInUrl; + const signInUrl = pickRedirectionProp('signInUrl', { options, displayConfig }, false); + const signUpUrl = pickRedirectionProp('signUpUrl', { options, displayConfig }, false); - const preservedParams = redirectUrls.getPreservedSearchParams(); - signInUrl = buildURL({ base: signInUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); - signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + const afterSignUpUrl = clerk.buildUrlWithAuth( + pickRedirectionProp('afterSignUpUrl', { + queryParams, + ctx, + options, + displayConfig, + }), + ); - const signInForceRedirectUrl = redirectUrls.getAfterSignInUrl(); - const signUpForceRedirectUrl = redirectUrls.getAfterSignUpUrl(); + const afterSignInUrl = clerk.buildUrlWithAuth( + pickRedirectionProp('afterSignInUrl', { + queryParams, + ctx, + options, + displayConfig, + }), + ); const signUpContinueUrl = buildURL( { base: signUpUrl, hashPath: '/continue', hashSearch: new URLSearchParams({ - sign_up_force_redirect_url: signUpForceRedirectUrl, + after_sign_up: afterSignUpUrl, }).toString(), }, { stringify: true }, @@ -535,7 +538,7 @@ export const useGoogleOneTapContext = () => { base: signInUrl, hashPath: '/factor-one', hashSearch: new URLSearchParams({ - sign_in_force_redirect_url: signInForceRedirectUrl, + after_sign_in: afterSignInUrl, }).toString(), }, { stringify: true }, @@ -545,7 +548,7 @@ export const useGoogleOneTapContext = () => { base: signInUrl, hashPath: '/factor-two', hashSearch: new URLSearchParams({ - sign_in_force_redirect_url: signInForceRedirectUrl, + after_sign_in: afterSignInUrl, }).toString(), }, { stringify: true }, @@ -559,7 +562,7 @@ export const useGoogleOneTapContext = () => { firstFactorUrl, secondFactorUrl, continueSignUpUrl: signUpContinueUrl, - signInForceRedirectUrl, - signUpForceRedirectUrl, + afterSignUpUrl, + afterSignInUrl, }; }; diff --git a/packages/clerk-js/src/ui/lazyModules/providers.tsx b/packages/clerk-js/src/ui/lazyModules/providers.tsx index 87e01937321..74244e232d6 100644 --- a/packages/clerk-js/src/ui/lazyModules/providers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/providers.tsx @@ -2,7 +2,6 @@ import type { Appearance } from '@clerk/types'; import React, { lazy, Suspense } from 'react'; import type { FlowMetadata } from '../elements'; -import { VirtualBodyRootPortal } from '../portal'; import type { ThemableCssProp } from '../styledSystem'; import type { ClerkComponentName } from './components'; import { ClerkComponents } from './components'; @@ -13,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 }))); diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 5f6215ee50f..66cb7664660 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -99,11 +99,11 @@ export const Protect = ComponentsModule.Protect as ServerComponentsServerModuleT export const auth = ServerHelperModule.auth as ServerHelpersServerModuleTypes['auth']; export const currentUser = ServerHelperModule.currentUser as ServerHelpersServerModuleTypes['currentUser']; // export const getAuth = ServerHelperModule.getAuth as ServerHelpersServerModuleTypes['getAuth']; -export const clerkClient = ServerHelperModule.clerkClient ; +export const clerkClient = ServerHelperModule.clerkClient as ServerHelpersServerModuleTypes['clerkClient']; export const authMiddleware = ServerHelperModule.authMiddleware as ServerHelpersServerModuleTypes['authMiddleware']; export const redirectToSignIn = - ServerHelperModule.redirectToSignIn ; + ServerHelperModule.redirectToSignIn as ServerHelpersServerModuleTypes['redirectToSignIn']; export const redirectToSignUp = - ServerHelperModule.redirectToSignUp ; + ServerHelperModule.redirectToSignUp as ServerHelpersServerModuleTypes['redirectToSignUp']; export const withClerkMiddleware = ServerHelperModule.withClerkMiddleware as ServerHelpersServerModuleTypes['withClerkMiddleware']; diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 43cef29bb62..a9bc9f67be0 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -151,7 +151,7 @@ export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp ); @@ -162,7 +162,7 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp ); @@ -257,7 +257,7 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< ); @@ -290,7 +290,7 @@ export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp ); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 28ab5234c13..648ed613f4c 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -995,7 +995,7 @@ export default class IsomorphicClerk implements IsomorphicLoadedClerk { signOutCallbackOrOptions?: SignOutCallback | SignOutOptions, options?: SignOutOptions, ): Promise => { - const callback = () => this.clerkjs?.signOut(signOutCallbackOrOptions, options); + const callback = () => this.clerkjs?.signOut(signOutCallbackOrOptions as any, options); if (this.clerkjs && this.#loaded) { return callback() as Promise; } else { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index c1175964b0c..18ee116e405 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -700,7 +700,7 @@ export type SignInProps = { initialValues?: SignInInitialValues; } & RedirectOptions; -type GoogleOneTapRedirectUrlProps = SignInForceRedirectUrl & SignUpForceRedirectUrl; +type GoogleOneTapRedirectUrlProps = RedirectOptions; export type GoogleOneTapProps = GoogleOneTapRedirectUrlProps & { /** From 6558d19db73b0b0f102c3d1fa226f8c515e7bdd7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 14:10:41 +0300 Subject: [PATCH 06/12] feat(clerk-js): Come back to where the flow started --- .../src/ui/contexts/ClerkUIComponentsContext.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 17147f11e02..635ba04fb7f 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -507,7 +507,10 @@ export const useGoogleOneTapContext = () => { const afterSignUpUrl = clerk.buildUrlWithAuth( pickRedirectionProp('afterSignUpUrl', { queryParams, - ctx, + ctx: { + ...ctx, + afterSignUpUrl: window.location.href, + }, options, displayConfig, }), @@ -516,7 +519,10 @@ export const useGoogleOneTapContext = () => { const afterSignInUrl = clerk.buildUrlWithAuth( pickRedirectionProp('afterSignInUrl', { queryParams, - ctx, + ctx: { + ...ctx, + afterSignInUrl: window.location.href, + }, options, displayConfig, }), @@ -527,7 +533,7 @@ export const useGoogleOneTapContext = () => { base: signUpUrl, hashPath: '/continue', hashSearch: new URLSearchParams({ - after_sign_up: afterSignUpUrl, + after_sign_up_url: afterSignUpUrl, }).toString(), }, { stringify: true }, @@ -538,7 +544,7 @@ export const useGoogleOneTapContext = () => { base: signInUrl, hashPath: '/factor-one', hashSearch: new URLSearchParams({ - after_sign_in: afterSignInUrl, + after_sign_in_url: afterSignInUrl, }).toString(), }, { stringify: true }, @@ -548,7 +554,7 @@ export const useGoogleOneTapContext = () => { base: signInUrl, hashPath: '/factor-two', hashSearch: new URLSearchParams({ - after_sign_in: afterSignInUrl, + after_sign_in_url: afterSignInUrl, }).toString(), }, { stringify: true }, From 84176cac7c28cb51ae716a0e06b9fd3262b182db Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 14:13:45 +0300 Subject: [PATCH 07/12] chore(*): Add changelog --- .changeset/breezy-monkeys-develop.md | 38 ------------------- ...{shiny-yaks-wave.md => itchy-timers-do.md} | 6 ++- .changeset/two-worms-yawn.md | 2 - .changeset/warm-pumas-fetch.md | 7 ---- 4 files changed, 4 insertions(+), 49 deletions(-) delete mode 100644 .changeset/breezy-monkeys-develop.md rename .changeset/{shiny-yaks-wave.md => itchy-timers-do.md} (91%) delete mode 100644 .changeset/two-worms-yawn.md delete mode 100644 .changeset/warm-pumas-fetch.md diff --git a/.changeset/breezy-monkeys-develop.md b/.changeset/breezy-monkeys-develop.md deleted file mode 100644 index 36f161d3e0f..00000000000 --- a/.changeset/breezy-monkeys-develop.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@clerk/clerk-js': minor -'@clerk/types': minor ---- - -Updates related to experimental Google One Tap support -- By default we are returning back to the location where the flow started. - To accomplish that internally we will use the redirect_url query parameter to build the url. -```tsx -<__experimental_GoogleOneTap /> -``` - -- In the above example if there is a SIGN_UP_FORCE_REDIRECT_URL or SIGN_IN_FORCE_REDIRECT_URL set then the developer would need to pass new values as props like this -```tsx -<__experimental_GoogleOneTap - signInForceRedirectUrl="" - signUpForceRedirectUrl="" -/> -``` - -- Let the developer configure the experience they want to offer. (All these values are true by default) -```tsx -<__experimental_GoogleOneTap - cancelOnTapOutside={false} - itpSupport={false} - fedCmSupport={false} -/> -``` - -- Moved authenticateWithGoogleOneTap to Clerk singleton -```ts -Clerk.__experimental_authenticateWithGoogleOneTap -``` - -- Created the handleGoogleOneTapCallback in Clerk singleton -```ts -Clerk.__experimental_handleGoogleOneTapCallback -``` diff --git a/.changeset/shiny-yaks-wave.md b/.changeset/itchy-timers-do.md similarity index 91% rename from .changeset/shiny-yaks-wave.md rename to .changeset/itchy-timers-do.md index 7d5810ee137..a7402f6b460 100644 --- a/.changeset/shiny-yaks-wave.md +++ b/.changeset/itchy-timers-do.md @@ -1,10 +1,12 @@ --- +'@clerk/chrome-extension': minor '@clerk/clerk-js': minor '@clerk/nextjs': minor '@clerk/clerk-react': minor '@clerk/types': minor --- + Add support for GoogleOneTap. New APIs listed: ### React component - `` @@ -26,13 +28,13 @@ Customize the UX of the prompt - `await Clerk.authenticateWithGoogleOneTap({ token: 'xxxx'})` - `await Clerk.handleGoogleOneTapCallback()` -We recommend using this two methods together in order and let Clerk to perform the correct redirections. +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, { - signInForceRedirectUrl: window.location.href, + afterSignInUrl: window.location.href, }) }, }); diff --git a/.changeset/two-worms-yawn.md b/.changeset/two-worms-yawn.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/two-worms-yawn.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/warm-pumas-fetch.md b/.changeset/warm-pumas-fetch.md deleted file mode 100644 index 18a43bd927b..00000000000 --- a/.changeset/warm-pumas-fetch.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@clerk/clerk-js': minor -'@clerk/clerk-react': minor -'@clerk/types': minor ---- - -Replace mount with open for GoogleOneTap. New api is `__experimental_openGoogleOneTap`. From deb84f491b6dca26cd14bf53e8a47285b5b90cf7 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 14:38:00 +0300 Subject: [PATCH 08/12] fix(clerk-js): Use the correct returnBack url when GoogleOneTap remains open across navigations --- .../components/GoogleOneTap/one-tap-start.tsx | 13 +- .../ui/contexts/ClerkUIComponentsContext.tsx | 137 +++++++++--------- 2 files changed, 73 insertions(+), 77 deletions(-) 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 171272de4ce..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 @@ -16,7 +16,6 @@ function _OneTapStart(): JSX.Element | null { const { navigate } = useRouter(); const ctx = useGoogleOneTapContext(); - const { continueSignUpUrl, secondFactorUrl, firstFactorUrl, afterSignInUrl, afterSignUpUrl } = ctx; async function oneTapCallback(response: GISCredentialResponse) { isPromptedRef.current = false; @@ -24,17 +23,7 @@ function _OneTapStart(): JSX.Element | null { const res = await clerk.authenticateWithGoogleOneTap({ token: response.credential, }); - await clerk.handleGoogleOneTapCallback( - res, - { - continueSignUpUrl, - secondFactorUrl, - firstFactorUrl, - afterSignInUrl, - afterSignUpUrl, - }, - navigate, - ); + await clerk.handleGoogleOneTapCallback(res, ctx.generateCallbackUrls(window.location.href), navigate); } catch (e) { console.error(e); } diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 635ba04fb7f..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'; @@ -501,74 +501,81 @@ export const useGoogleOneTapContext = () => { throw new Error('Clerk: useGoogleOneTapContext called outside GoogleOneTap.'); } - const signInUrl = pickRedirectionProp('signInUrl', { options, displayConfig }, false); - const signUpUrl = pickRedirectionProp('signUpUrl', { options, displayConfig }, false); - - const afterSignUpUrl = clerk.buildUrlWithAuth( - pickRedirectionProp('afterSignUpUrl', { - queryParams, - ctx: { - ...ctx, - afterSignUpUrl: window.location.href, - }, - options, - displayConfig, - }), - ); - - const afterSignInUrl = clerk.buildUrlWithAuth( - pickRedirectionProp('afterSignInUrl', { - queryParams, - ctx: { - ...ctx, - afterSignInUrl: window.location.href, - }, - options, - displayConfig, - }), - ); - - const signUpContinueUrl = buildURL( - { - base: signUpUrl, - hashPath: '/continue', - hashSearch: new URLSearchParams({ - after_sign_up_url: afterSignUpUrl, - }).toString(), + 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, + }; }, - { 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 }, + [ctx, displayConfig.signInUrl, displayConfig.signUpUrl, options, queryParams], ); return { ...ctx, componentName, - signInUrl, - signUpUrl, - firstFactorUrl, - secondFactorUrl, - continueSignUpUrl: signUpContinueUrl, - afterSignUpUrl, - afterSignInUrl, + generateCallbackUrls, }; }; From a9d2c6f0737366a4820c621f607d681aee93f5bc Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 18:01:21 +0300 Subject: [PATCH 09/12] chore(types): Add changelog --- .changeset/itchy-timers-do.md | 55 ----------------------------------- .changeset/silly-mugs-type.md | 21 +++++++++++++ 2 files changed, 21 insertions(+), 55 deletions(-) delete mode 100644 .changeset/itchy-timers-do.md create mode 100644 .changeset/silly-mugs-type.md diff --git a/.changeset/itchy-timers-do.md b/.changeset/itchy-timers-do.md deleted file mode 100644 index a7402f6b460..00000000000 --- a/.changeset/itchy-timers-do.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@clerk/chrome-extension': minor -'@clerk/clerk-js': minor -'@clerk/nextjs': minor -'@clerk/clerk-react': minor -'@clerk/types': minor ---- - - -Add support for GoogleOneTap. New APIs listed: -### React component -- `` - -Customize the UX of the prompt - -```tsx - -``` - -### Use the component from with Vanilla JS -- `Clerk.openGoogleOneTap(props: GoogleOneTapProps)` -- `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/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' +``` From 3d91d138cd879216804a637954d3404b7e99b736 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 18:04:34 +0300 Subject: [PATCH 10/12] chore(chrome-extension): Add changelog --- .changeset/shaggy-elephants-shop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shaggy-elephants-shop.md 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`. From c1e8dc3fb60e8ffa05632b1f3eac2f2331b5f6c3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 18:08:21 +0300 Subject: [PATCH 11/12] chore(clerk-js): Add changelog --- .changeset/mighty-dolphins-look.md | 37 ++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/mighty-dolphins-look.md 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 + }) + } + }, +}); +``` From 38951932310e4b0dd4a88436402f89865be0165c Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 21 May 2024 18:11:27 +0300 Subject: [PATCH 12/12] chore(clerk-react,nextjs): Add changelog --- .changeset/funny-berries-nail.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/funny-berries-nail.md 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 + +```