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 1431b178f0d..8f5f0d7cec9 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -4,6 +4,7 @@ import { handleValueOrFn, inBrowser as inClientSide, is4xxError, + isClerkAPIResponseError, isHttpOrHttps, isValidBrowserOnline, isValidProxyUrl, @@ -16,6 +17,7 @@ import { } from '@clerk/shared'; import { eventPrebuiltComponentMounted, TelemetryCollector } from '@clerk/shared/telemetry'; import type { + __experimental_AuthenticateWithGoogleOneTapParams, ActiveSessionResource, AuthenticateWithMetamaskParams, Clerk as ClerkInterface, @@ -1015,14 +1017,47 @@ export 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.loaded || !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; @@ -1032,6 +1067,7 @@ export class Clerk implements ClerkInterface { externalAccountStatus: externalAccount.status, externalAccountErrorCode: externalAccount.error?.code, externalAccountSessionId: externalAccount.error?.meta?.sessionId, + sessionId: signUp.createdSessionId, }; const si = { @@ -1039,11 +1075,9 @@ export 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(params.signInUrl || displayConfig.signInUrl); @@ -1071,7 +1105,13 @@ export 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[] }) => { @@ -1091,8 +1131,16 @@ export 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'; + if (userExistsButNeedsToSignIn) { const res = await signIn.create({ transfer: true }); switch (res.status) { @@ -1152,6 +1200,13 @@ export class Clerk implements ClerkInterface { } } + if (su.status === 'complete') { + return this.setActive({ + session: su.sessionId, + beforeEmit: navigateAfterSignUp, + }); + } + if (si.status === 'needs_second_factor') { return navigateToFactorTwo(); } @@ -1188,6 +1243,25 @@ export 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; @@ -1203,6 +1277,29 @@ export 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 591e2e65bc1..750b703c759 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 { useClerk, useUser } 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 { useCoreSignIn, useEnvironment, useGoogleOneTapContext } from '../../contexts'; +import { 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 = useClerk(); - const signIn = useCoreSignIn(); const { user } = useUser(); 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,32 +56,38 @@ 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!, 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 2236f2bab3d..fbeb992a31c 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -498,13 +498,74 @@ export const useCreateOrganizationContext = () => { export const useGoogleOneTapContext = () => { const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OneTapCtx; + 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 f3a253f17c8..343105eee99 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -30,6 +30,8 @@ import type { SignUpForceRedirectUrl, } from './redirects'; import type { ActiveSessionResource } from './session'; +import type { SignInResource } from './signIn'; +import type { SignUpResource } from './signUp'; import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils'; @@ -441,6 +443,16 @@ export interface Clerk { */ redirectToAfterSignOut: () => 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} @@ -463,6 +475,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. */ @@ -713,8 +733,27 @@ export type SignInProps = RoutingOptions & { export type SignInModalProps = WithoutRouting; -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; }; @@ -1039,6 +1078,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 8cc66a8dd5c..52c203207bc 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, @@ -101,7 +102,7 @@ export interface SignInResource extends ClerkResource { authenticateWithPasskey: (params?: AuthenticateWithPasskeyParams) => Promise; /** - * @experimental + * @deprecated Use `Clerk.__experimental_authenticateWithGoogleOneTap` */ __experimental_authenticateWithGoogleOneTap: ( params: __experimental_AuthenticateWithGoogleOneTapParams, @@ -218,10 +219,6 @@ export type AuthenticateWithPasskeyParams = { flow?: 'autofill' | 'discoverable'; }; -export type __experimental_AuthenticateWithGoogleOneTapParams = { - token: string; -}; - export interface SignInStartEmailLinkFlowParams extends StartEmailLinkFlowParams { emailAddressId: string; } diff --git a/playground/nextjs/middleware.ts b/playground/nextjs/middleware.ts index 7c387d67ed0..e41136e55ed 100644 --- a/playground/nextjs/middleware.ts +++ b/playground/nextjs/middleware.ts @@ -9,4 +9,4 @@ export default authMiddleware({ export const config = { matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'], -} \ No newline at end of file +};