Skip to content

Commit 12b1ebb

Browse files
committed
feat(shared,ui,react,nextjs): add public <OAuthConsent /> component
Introduce a zero-config <OAuthConsent /> React component exported from @clerk/react and @clerk/nextjs that renders the OAuth consent screen for a signed-in user. The component reads client_id, scope, and redirect_uri from the URL by default and submits the consent decision via a native form POST to /v1/internal/oauth-consent. The accounts portal continues to work unchanged via the existing clerk.__internal_mountOAuthConsent path; the underlying UI component is a hybrid that uses context values when provided (accounts portal path) and falls back to the useOAuthConsent hook + URL parsing otherwise (public path). Highlights: - New public OAuthConsentProps type in @clerk/shared - OAuthConsentCtx refactored to lowercase oauth* casing with translation layer in ComponentContextProvider for accounts portal backward compat - Hybrid _OAuthConsent component with native form wrapping and submit buttons (proper a11y semantics) - URL parsing moved out of useOAuthConsent hook into the component for cleaner separation of concerns and SSR safety - New <OAuthConsent /> wrapper in @clerk/react re-exported from @clerk/nextjs - 7 unit tests covering public and accounts portal paths - Export snapshot updates for react-router and tanstack-react-start
1 parent f9ff9e9 commit 12b1ebb

17 files changed

Lines changed: 705 additions & 342 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@clerk/nextjs': minor
3+
'@clerk/react': minor
4+
'@clerk/shared': minor
5+
'@clerk/ui': minor
6+
---
7+
8+
Introduce `<OAuthConsent />` component for rendering a zero-config OAuth consent screen on an OAuth authorize redirect page.
9+
10+
Usage example:
11+
12+
```tsx
13+
import { OAuthConsent } from '@clerk/nextjs';
14+
15+
export default function OAuthConsentPage() {
16+
return <OAuthConsent />;
17+
}
18+
```
19+
20+
The component reads `client_id`, `scope`, and `redirect_uri` from the current URL by default and submits the consent decision internally, so no boilerplate is required for the common OAuth redirect flow. Customization options include `oauthClientId`, `scope`, `appearance`, and `fallback`.

packages/nextjs/src/client-boundary/uiComponents.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export {
1515
APIKeys,
1616
CreateOrganization,
1717
GoogleOneTap,
18+
OAuthConsent,
1819
OrganizationList,
1920
OrganizationSwitcher,
2021
PricingTable,

packages/nextjs/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export {
2525
APIKeys,
2626
CreateOrganization,
2727
GoogleOneTap,
28+
OAuthConsent,
2829
OrganizationList,
2930
OrganizationProfile,
3031
OrganizationSwitcher,

packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
2626
"CreateOrganization",
2727
"GoogleOneTap",
2828
"HandleSSOCallback",
29+
"OAuthConsent",
2930
"OrganizationList",
3031
"OrganizationProfile",
3132
"OrganizationSwitcher",

packages/react/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export {
22
APIKeys,
33
CreateOrganization,
44
GoogleOneTap,
5+
OAuthConsent,
56
OrganizationList,
67
OrganizationProfile,
78
OrganizationSwitcher,

packages/react/src/components/uiComponents.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
APIKeysProps,
33
CreateOrganizationProps,
44
GoogleOneTapProps,
5+
OAuthConsentProps,
56
OrganizationListProps,
67
OrganizationProfileProps,
78
OrganizationSwitcherProps,
@@ -643,6 +644,37 @@ export const APIKeys = withClerk(
643644
{ component: 'ApiKeys', renderWhileLoading: true },
644645
);
645646

647+
/**
648+
* @internal
649+
*/
650+
export const OAuthConsent = withClerk(
651+
({ clerk, component, fallback, ...props }: WithClerkProp<OAuthConsentProps & FallbackProp>) => {
652+
const mountingStatus = useWaitForComponentMount(component);
653+
const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded;
654+
655+
const rendererRootProps = {
656+
...(shouldShowFallback && fallback && { style: { display: 'none' } }),
657+
};
658+
659+
return (
660+
<>
661+
{shouldShowFallback && fallback}
662+
{clerk.loaded && (
663+
<ClerkHostRenderer
664+
component={component}
665+
mount={clerk.__internal_mountOAuthConsent}
666+
unmount={clerk.__internal_unmountOAuthConsent}
667+
updateProps={(clerk as any).__internal_updateProps}
668+
props={props}
669+
rootProps={rendererRootProps}
670+
/>
671+
)}
672+
</>
673+
);
674+
},
675+
{ component: 'OAuthConsent', renderWhileLoading: true },
676+
);
677+
646678
export const UserAvatar = withClerk(
647679
({ clerk, component, fallback, ...props }: WithClerkProp<UserAvatarProps & FallbackProp>) => {
648680
const mountingStatus = useWaitForComponentMount(component);

packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ describe('useOAuthConsent', () => {
5050
mockClerk.oauthApplication = {
5151
getConsentInfo: getConsentInfoSpy,
5252
};
53-
window.history.replaceState({}, '', '/');
5453
});
5554

5655
it('fetches consent metadata when signed in', async () => {
@@ -104,45 +103,4 @@ describe('useOAuthConsent', () => {
104103
expect(getConsentInfoSpy).not.toHaveBeenCalled();
105104
expect(result.current.isLoading).toBe(false);
106105
});
107-
108-
it('uses client_id and scope from the URL when hook params omit them', async () => {
109-
window.history.replaceState({}, '', '/?client_id=from_url&scope=openid%20email');
110-
111-
const { result } = renderHook(() => useOAuthConsent(), { wrapper });
112-
113-
await waitFor(() => expect(result.current.isLoading).toBe(false));
114-
115-
expect(getConsentInfoSpy).toHaveBeenCalledTimes(1);
116-
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'from_url', scope: 'openid email' });
117-
expect(result.current.data).toEqual(consentInfo);
118-
});
119-
120-
it('prefers explicit oauthClientId over URL client_id', async () => {
121-
window.history.replaceState({}, '', '/?client_id=from_url');
122-
123-
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'explicit_id' }), { wrapper });
124-
125-
await waitFor(() => expect(result.current.isLoading).toBe(false));
126-
127-
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'explicit_id' });
128-
});
129-
130-
it('does not fall back to URL client_id when oauthClientId is explicitly empty', () => {
131-
window.history.replaceState({}, '', '/?client_id=from_url');
132-
133-
const { result } = renderHook(() => useOAuthConsent({ oauthClientId: '' }), { wrapper });
134-
135-
expect(getConsentInfoSpy).not.toHaveBeenCalled();
136-
expect(result.current.isLoading).toBe(false);
137-
});
138-
139-
it('prefers explicit scope over URL scope', async () => {
140-
window.history.replaceState({}, '', '/?client_id=cid&scope=from_url');
141-
142-
const { result } = renderHook(() => useOAuthConsent({ scope: 'explicit_scope' }), { wrapper });
143-
144-
await waitFor(() => expect(result.current.isLoading).toBe(false));
145-
146-
expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'cid', scope: 'explicit_scope' });
147-
});
148106
});

packages/shared/src/react/hooks/useOAuthConsent.shared.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,6 @@ import type { GetOAuthConsentInfoParams } from '../../types';
44
import { STABLE_KEYS } from '../stable-keys';
55
import { createCacheKeys } from './createCacheKeys';
66

7-
/**
8-
* Parses OAuth authorize-style query data from a search string (typically `window.location.search`).
9-
*
10-
* @internal
11-
*/
12-
export function readOAuthConsentFromSearch(search: string): {
13-
oauthClientId: string;
14-
scope?: string;
15-
} {
16-
const sp = new URLSearchParams(search);
17-
const oauthClientId = sp.get('client_id') ?? '';
18-
const scopeValue = sp.get('scope');
19-
if (scopeValue === null) {
20-
return { oauthClientId };
21-
}
22-
return { oauthClientId, scope: scopeValue };
23-
}
24-
257
export function useOAuthConsentCacheKeys(params: { userId: string | null; oauthClientId: string; scope?: string }) {
268
const { userId, oauthClientId, scope } = params;
279
return useMemo(() => {

packages/shared/src/react/hooks/useOAuthConsent.tsx

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
'use client';
22

3-
import { useMemo } from 'react';
4-
53
import { eventMethodCalled } from '../../telemetry/events/method-called';
64
import type { LoadedClerk } from '../../types/clerk';
75
import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data';
86
import { useClerkQuery } from '../clerk-rq/useQuery';
97
import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts';
108
import { useUserBase } from './base/useUserBase';
11-
import { readOAuthConsentFromSearch, useOAuthConsentCacheKeys } from './useOAuthConsent.shared';
9+
import { useOAuthConsentCacheKeys } from './useOAuthConsent.shared';
1210
import type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types';
1311

1412
const HOOK_NAME = 'useOAuthConsent';
@@ -18,26 +16,13 @@ const HOOK_NAME = 'useOAuthConsent';
1816
* (`GET /me/oauth/consent/{oauthClientId}`). Ensure the user is authenticated before relying on this hook
1917
* (for example, redirect to sign-in on your custom consent route).
2018
*
21-
* `oauthClientId` and `scope` are optional. On the client, values default from a single snapshot of
22-
* `window.location.search` (`client_id` and `scope`). Pass them explicitly to override.
19+
* The hook is a pure data fetcher: it takes an explicit `oauthClientId` and optional `scope` and
20+
* issues the fetch when both the user is signed in and `oauthClientId` is non-empty. The query is
21+
* disabled when `oauthClientId` is empty or omitted.
2322
*
2423
* @internal
2524
*
2625
* @example
27-
* ### From the URL (`?client_id=...&scope=...`)
28-
*
29-
* ```tsx
30-
* import { useOAuthConsent } from '@clerk/react/internal'
31-
*
32-
* export default function OAuthConsentPage() {
33-
* const { data, isLoading, error } = useOAuthConsent()
34-
* // ...
35-
* }
36-
* ```
37-
*
38-
* @example
39-
* ### Explicit values (override URL)
40-
*
4126
* ```tsx
4227
* import { useOAuthConsent } from '@clerk/react/internal'
4328
*
@@ -50,19 +35,11 @@ const HOOK_NAME = 'useOAuthConsent';
5035
export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthConsentReturn {
5136
useAssertWrappedByClerkProvider(HOOK_NAME);
5237

53-
const { oauthClientId: oauthClientIdParam, scope: scopeParam, keepPreviousData = true, enabled = true } = params;
38+
const { oauthClientId: oauthClientIdParam, scope, keepPreviousData = true, enabled = true } = params;
5439
const clerk = useClerkInstanceContext();
5540
const user = useUserBase();
5641

57-
const fromUrl = useMemo(() => {
58-
if (typeof window === 'undefined' || !window.location) {
59-
return { oauthClientId: '' };
60-
}
61-
return readOAuthConsentFromSearch(window.location.search);
62-
}, []);
63-
64-
const oauthClientId = (oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId).trim();
65-
const scope = scopeParam !== undefined ? scopeParam : fromUrl.scope;
42+
const oauthClientId = (oauthClientIdParam ?? '').trim();
6643

6744
clerk.telemetry?.record(eventMethodCalled(HOOK_NAME));
6845

0 commit comments

Comments
 (0)