Skip to content

Commit

Permalink
feat(clerk-js): Introduce experimental support for Google One Tap (#3176
Browse files Browse the repository at this point in the history
) (#3196)

* feat(clerk-js): Introduce experimental support for Google One Tap (#3176)

* chore(clerk-js): format

* chore(clerk-js): Replace with useCore* hooks

* chore(clerk-js): Update tests snapshots

* chore(clerk-js): Make linter happy

* chore(clerk-js): Use `useUserContext` instead of `useCoreUser`

* fix(clerk-js): Reprompt google one tap only when user id changes
  • Loading branch information
panteliselef committed Apr 17, 2024
1 parent e97c925 commit 4cf2a21
Show file tree
Hide file tree
Showing 24 changed files with 344 additions and 6 deletions.
10 changes: 10 additions & 0 deletions .changeset/grumpy-maps-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/clerk-js': minor
'@clerk/nextjs': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

Introduce experimental support for Google One Tap
- React Component `<__experimental_GoogleOneTap/>`
- JS `clerk.__experimental_mountGoogleOneTap(node,props)`
2 changes: 2 additions & 0 deletions .changeset/two-worms-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ exports[`public exports should not include a breaking change 1`] = `
"WithClerk",
"WithSession",
"WithUser",
"__experimental_GoogleOneTap",
"__internal__setErrorThrowerOptions",
"isClerkAPIResponseError",
"isEmailLinkError",
Expand Down
25 changes: 25 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
HandleOAuthCallbackParams,
InstanceType,
ListenerCallback,
OneTapProps,
OrganizationInvitationResource,
OrganizationListProps,
OrganizationMembershipResource,
Expand Down Expand Up @@ -456,6 +457,30 @@ 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 =>
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/DisplayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
createOrganizationUrl!: string;
afterLeaveOrganizationUrl!: string;
afterCreateOrganizationUrl!: string;
googleOneTapClientId?: string;

public constructor(data: DisplayConfigJSON) {
super();
Expand Down Expand Up @@ -76,6 +77,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
this.createOrganizationUrl = data.create_organization_url;
this.afterLeaveOrganizationUrl = data.after_leave_organization_url;
this.afterCreateOrganizationUrl = data.after_create_organization_url;
this.googleOneTapClientId = data.google_one_tap_client_id;
return this;
}
}
Expand Down
25 changes: 24 additions & 1 deletion packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { deepSnakeToCamel, deprecated, Poller } from '@clerk/shared';
import { deepSnakeToCamel, deprecated, isClerkAPIResponseError, Poller } from '@clerk/shared';
import type {
__experimental_AuthenticateWithGoogleOneTapParams,
AttemptFirstFactorParams,
AttemptSecondFactorParams,
AuthenticateWithRedirectParams,
Expand All @@ -24,6 +25,7 @@ import type {
SignInStartEmailLinkFlowParams,
SignInStartMagicLinkFlowParams,
SignInStatus,
SignUpResource,
VerificationResource,
Web3SignatureConfig,
Web3SignatureFactor,
Expand Down Expand Up @@ -238,6 +240,27 @@ export class SignIn extends BaseResource implements SignInResource {
}
};

public __experimental_authenticateWithGoogleOneTap = async (
params: __experimental_AuthenticateWithGoogleOneTapParams,
): Promise<SignInResource | SignUpResource> => {
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<SignInResource | SignUpResource>;
};

public authenticateWithWeb3 = async (params: AuthenticateWithWeb3Params): Promise<SignInResource> => {
const { identifier, generateSignature } = params || {};
if (!(typeof generateSignature === 'function')) {
Expand Down
1 change: 0 additions & 1 deletion packages/clerk-js/src/ui/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,6 @@ const Components = (props: ComponentsProps) => {

componentsControls.mountComponent = params => {
const { node, name, props, appearanceKey } = params;

assertDOMElement(node);
setState(s => {
s.nodes.set(node, { key: `p${++portalCt}`, name, props, appearanceKey });
Expand Down
25 changes: 25 additions & 0 deletions packages/clerk-js/src/ui/components/GoogleOneTap/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { OneTapProps } from '@clerk/types';
import React from 'react';

import { withCoreSessionSwitchGuard } from '../../contexts';
import { Flow } from '../../customizables';
import { Route, Switch } from '../../router';
import { OneTapStart } from './one-tap-start';

function OneTapRoutes(): JSX.Element {
return (
<Route path='one-tap'>
<Flow.Root flow='oneTap'>
<Switch>
<Route index>
<OneTapStart />
</Route>
</Switch>
</Flow.Root>
</Route>
);
}

OneTapRoutes.displayName = 'OneTap';

export const OneTap: React.ComponentType<OneTapProps> = withCoreSessionSwitchGuard(OneTapRoutes);
84 changes: 84 additions & 0 deletions packages/clerk-js/src/ui/components/GoogleOneTap/one-tap-start.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useUserContext } from '@clerk/shared/react';
import { useEffect } 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 { withCardStateProvider } from '../../elements';
import { useFetch } from '../../hooks';
import { useSupportEmail } from '../../hooks/useSupportEmail';

function _OneTapStart(): JSX.Element | null {
const clerk = useCoreClerk();
const signIn = useCoreSignIn();
const user = useUserContext();
const environment = useEnvironment();

const supportEmail = useSupportEmail();
const ctx = useGoogleOneTapContext();

async function oneTapCallback(response: GISCredentialResponse) {
try {
const res = await signIn.__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();
}
}

const environmentClientID = environment.displayConfig.googleOneTapClientId;
const shouldLoadGIS = !user?.id && !!environmentClientID;

/**
* Prevent GIS from initializing multiple times
*/
const { data: google } = useFetch(shouldLoadGIS ? loadGIS : undefined, 'google-identity-services-script', {
onSuccess(google) {
google.accounts.id.initialize({
client_id: environmentClientID!,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
callback: oneTapCallback,
itp_support: true,
cancel_on_tap_outside: ctx.cancelOnTapOutside,
auto_select: false,
use_fedcm_for_prompt: true,
});

google.accounts.id.prompt();
},
});

// Trigger only on mount/unmount. Above we handle the logic for the initial fetch + initialization
useEffect(() => {
if (google && !user?.id) {
google.accounts.id.prompt();
}
return () => {
if (google) {
google.accounts.id.cancel();
}
};
}, [user?.id]);

return null;
}

export const OneTapStart = withCardStateProvider(_OneTapStart);
14 changes: 14 additions & 0 deletions packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useRouter } from '../router';
import type {
AvailableComponentCtx,
CreateOrganizationCtx,
OneTapCtx,
OrganizationListCtx,
OrganizationProfileCtx,
OrganizationSwitcherCtx,
Expand Down Expand Up @@ -488,3 +489,16 @@ export const useCreateOrganizationContext = () => {
componentName,
};
};

export const useGoogleOneTapContext = () => {
const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OneTapCtx;

if (componentName !== 'OneTap') {
throw new Error('Clerk: useGoogleOneTapContext called outside GoogleOneTap.');
}

return {
...ctx,
componentName,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ type FlowMetadata = {
| 'organizationProfile'
| 'createOrganization'
| 'organizationSwitcher'
| 'organizationList';
| 'organizationList'
| 'oneTap';
part?:
| 'start'
| 'emailCode'
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/ui/lazyModules/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ 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'),
} 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 SignUp = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUp })));

Expand Down Expand Up @@ -77,6 +79,7 @@ export const ClerkComponents = {
UserProfileModal,
OrganizationProfileModal,
CreateOrganizationModal,
OneTap,
};

export type ClerkComponentName = keyof typeof ClerkComponents;
26 changes: 24 additions & 2 deletions packages/clerk-js/src/ui/portal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ 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 } from '../router';
import { HashRouter, PathRouter, VirtualRouter } from '../router';
import type { AvailableComponentCtx } from '../types';

type PortalProps<CtxType extends AvailableComponentCtx, PropsType = Omit<CtxType, 'componentName'>> = {
Expand All @@ -15,7 +16,21 @@ type PortalProps<CtxType extends AvailableComponentCtx, PropsType = Omit<CtxType
} & Pick<CtxType, 'componentName'>;

export default class Portal<CtxType extends AvailableComponentCtx> extends React.PureComponent<PortalProps<CtxType>> {
render(): React.ReactPortal {
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);
}
}

render() {
const { props, component, componentName, node } = this.props;

const el = (
Expand All @@ -24,6 +39,13 @@ export default class Portal<CtxType extends AvailableComponentCtx> extends React
</ComponentContext.Provider>
);

if (componentName === 'OneTap') {
return ReactDOM.createPortal(
<VirtualRouter startPath={buildVirtualRouterUrl({ base: '/one-tap', path: '' })}>{el}</VirtualRouter>,
this.elRef,
);
}

if (props?.routing === 'path') {
if (!props?.path) {
clerkErrorPathRouterMissingPath(componentName);
Expand Down
9 changes: 8 additions & 1 deletion packages/clerk-js/src/ui/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
CreateOrganizationProps,
OneTapProps,
OrganizationListProps,
OrganizationProfileProps,
OrganizationSwitcherProps,
Expand All @@ -10,6 +11,7 @@ import type {
} from '@clerk/types';

export type {
OneTapProps,
SignInProps,
SignUpProps,
UserButtonProps,
Expand Down Expand Up @@ -72,6 +74,10 @@ export type OrganizationListCtx = OrganizationListProps & {
mode?: ComponentMode;
};

export type OneTapCtx = OneTapProps & {
componentName: 'OneTap';
};

export type AvailableComponentCtx =
| SignInCtx
| SignUpCtx
Expand All @@ -80,4 +86,5 @@ export type AvailableComponentCtx =
| OrganizationProfileCtx
| CreateOrganizationCtx
| OrganizationSwitcherCtx
| OrganizationListCtx;
| OrganizationListCtx
| OneTapCtx;
Loading

0 comments on commit 4cf2a21

Please sign in to comment.