diff --git a/.changeset/public-oauth-consent-component.md b/.changeset/public-oauth-consent-component.md
new file mode 100644
index 00000000000..82dd0033c4b
--- /dev/null
+++ b/.changeset/public-oauth-consent-component.md
@@ -0,0 +1,20 @@
+---
+'@clerk/nextjs': minor
+'@clerk/react': minor
+'@clerk/shared': minor
+'@clerk/ui': minor
+'@clerk/tanstack-react-start': minor
+'@clerk/react-router': minor
+---
+
+Introduce internal `` component for rendering a zero-config OAuth consent screen on an OAuth authorize redirect page.
+
+Usage example:
+
+```tsx
+import { OAuthConsent } from '@clerk/nextjs';
+
+export default function OAuthConsentPage() {
+ return ;
+}
+```
diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts
index 3be79d71860..064c740eb27 100644
--- a/packages/clerk-js/sandbox/app.ts
+++ b/packages/clerk-js/sandbox/app.ts
@@ -470,7 +470,7 @@ void (async () => {
},
'/oauth-consent': () => {
const searchParams = new URLSearchParams(window.location.search);
- const scopes = (searchParams.get('scopes')?.split(',') ?? []).map(scope => ({
+ const scopes = (searchParams.get('scope')?.split(',') ?? []).map(scope => ({
scope,
description: scope === 'offline_access' ? null : `Grants access to your ${scope}`,
requires_consent: true,
@@ -479,10 +479,9 @@ void (async () => {
app,
componentControls.oauthConsent.getProps() ?? {
scopes,
- oAuthApplicationName: searchParams.get('oauth-application-name'),
- redirectUrl: searchParams.get('redirect_uri'),
- oAuthApplicationLogoUrl: searchParams.get('logo-url'),
- oAuthApplicationUrl: searchParams.get('app-url'),
+ oauthClientId: 'Wg9fP2d0pSFXCZ1u',
+ redirectUrl: searchParams.get('redirect_uri') ?? 'http://localhost:4000/oauth/callback',
+ __internal_enableOrgSelection: true,
},
);
},
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index 50957d7b7e3..4663e15eafe 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -178,8 +178,9 @@ import { createClientFromJwt } from './jwt-client';
import { APIKeys } from './modules/apiKeys';
import { Billing } from './modules/billing';
import { createCheckoutInstance } from './modules/checkout/instance';
+import { OAuthApplication } from './modules/oauthApplication';
import { Protect } from './protect';
-import { BaseResource, Client, Environment, OAuthApplication, Organization, Waitlist } from './resources/internal';
+import { BaseResource, Client, Environment, Organization, Waitlist } from './resources/internal';
import { State } from './state';
type SetActiveHook = (intent?: 'sign-out') => void | Promise;
@@ -407,9 +408,7 @@ export class Clerk implements ClerkInterface {
get oauthApplication(): OAuthApplicationNamespace {
if (!Clerk._oauthApplication) {
- Clerk._oauthApplication = {
- getConsentInfo: params => OAuthApplication.getConsentInfo(params),
- };
+ Clerk._oauthApplication = new OAuthApplication();
}
return Clerk._oauthApplication;
}
diff --git a/packages/clerk-js/src/core/modules/oauthApplication/__tests__/OAuthApplication.test.ts b/packages/clerk-js/src/core/modules/oauthApplication/__tests__/OAuthApplication.test.ts
new file mode 100644
index 00000000000..8cc770ff8bb
--- /dev/null
+++ b/packages/clerk-js/src/core/modules/oauthApplication/__tests__/OAuthApplication.test.ts
@@ -0,0 +1,225 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import type { InstanceType, OAuthConsentInfoJSON } from '@clerk/shared/types';
+import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
+
+import { mockFetch } from '@/test/core-fixtures';
+
+import { SUPPORTED_FAPI_VERSION } from '../../../constants';
+import { createFapiClient } from '../../../fapiClient';
+import { BaseResource } from '../../../resources/internal';
+import { OAuthApplication } from '../index';
+
+const consentPayload: OAuthConsentInfoJSON = {
+ object: 'oauth_consent_info',
+ id: 'client_abc',
+ oauth_application_name: 'My App',
+ oauth_application_logo_url: 'https://img.example/logo.png',
+ oauth_application_url: 'https://app.example',
+ client_id: 'client_abc',
+ state: 'st',
+ scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }],
+};
+
+describe('OAuthApplication', () => {
+ let oauthApp: OAuthApplication;
+
+ beforeEach(() => {
+ oauthApp = new OAuthApplication();
+ });
+
+ afterEach(() => {
+ (global.fetch as Mock)?.mockClear?.();
+ BaseResource.clerk = null as any;
+ vi.restoreAllMocks();
+ });
+
+ describe('getConsentInfo', () => {
+ it('calls _fetch with GET, encoded path, optional scope, and skipUpdateClient', async () => {
+ const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
+ response: consentPayload,
+ } as any);
+
+ BaseResource.clerk = {} as any;
+
+ await oauthApp.getConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' });
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ {
+ method: 'GET',
+ path: '/me/oauth/consent/my%2Fclient%20id',
+ search: { scope: 'openid email' },
+ },
+ { skipUpdateClient: true },
+ );
+ });
+
+ it('omits search when scope is undefined', async () => {
+ const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
+ response: consentPayload,
+ } as any);
+
+ BaseResource.clerk = {} as any;
+
+ await oauthApp.getConsentInfo({ oauthClientId: 'cid' });
+
+ expect(fetchSpy).toHaveBeenCalledWith(expect.objectContaining({ search: undefined }), { skipUpdateClient: true });
+ });
+
+ it('returns OAuthConsentInfo from a non-enveloped FAPI response', async () => {
+ vi.spyOn(BaseResource, '_fetch').mockResolvedValue(consentPayload as any);
+
+ BaseResource.clerk = {} as any;
+
+ const info = await oauthApp.getConsentInfo({ oauthClientId: 'client_abc' });
+
+ expect(info).toEqual({
+ oauthApplicationName: 'My App',
+ oauthApplicationLogoUrl: 'https://img.example/logo.png',
+ oauthApplicationUrl: 'https://app.example',
+ clientId: 'client_abc',
+ state: 'st',
+ scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
+ });
+ });
+
+ it('returns OAuthConsentInfo from an enveloped FAPI response', async () => {
+ vi.spyOn(BaseResource, '_fetch').mockResolvedValue({ response: consentPayload } as any);
+
+ BaseResource.clerk = {} as any;
+
+ const info = await oauthApp.getConsentInfo({ oauthClientId: 'client_abc' });
+
+ expect(info).toEqual({
+ oauthApplicationName: 'My App',
+ oauthApplicationLogoUrl: 'https://img.example/logo.png',
+ oauthApplicationUrl: 'https://app.example',
+ clientId: 'client_abc',
+ state: 'st',
+ scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
+ });
+ });
+
+ it('defaults scopes to [] when absent', async () => {
+ vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
+ response: { ...consentPayload, scopes: undefined },
+ } as any);
+
+ BaseResource.clerk = {} as any;
+
+ const info = await oauthApp.getConsentInfo({ oauthClientId: 'client_abc' });
+ expect(info.scopes).toEqual([]);
+ });
+
+ it('throws ClerkAPIResponseError on non-2xx', async () => {
+ mockFetch(false, 422, {
+ errors: [{ code: 'oauth_consent_error', long_message: 'Consent metadata unavailable' }],
+ });
+
+ BaseResource.clerk = {
+ getFapiClient: () =>
+ createFapiClient({
+ frontendApi: 'clerk.example.com',
+ getSessionId: () => undefined,
+ instanceType: 'development' as InstanceType,
+ }),
+ __internal_setCountry: vi.fn(),
+ handleUnauthenticated: vi.fn(),
+ __internal_handleUnauthenticatedDevBrowser: vi.fn(),
+ } as any;
+
+ await expect(oauthApp.getConsentInfo({ oauthClientId: 'cid' })).rejects.toSatisfy(
+ (err: unknown) => err instanceof ClerkAPIResponseError && err.message === 'Consent metadata unavailable',
+ );
+
+ const [url] = (global.fetch as Mock).mock.calls[0];
+ expect(url.toString()).toContain('/v1/me/oauth/consent/cid');
+ expect(url.toString()).toContain(`__clerk_api_version=${SUPPORTED_FAPI_VERSION}`);
+ });
+
+ it('throws ClerkRuntimeError with network_error when _fetch returns null', async () => {
+ vi.spyOn(BaseResource, '_fetch').mockResolvedValue(null);
+
+ BaseResource.clerk = {} as any;
+
+ await expect(oauthApp.getConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({
+ code: 'network_error',
+ });
+ });
+ });
+
+ describe('buildConsentActionUrl', () => {
+ // Minimal fapiClient mock: constructs a URL from path + sessionId the same
+ // way the real fapiClient does, so assertions on the returned URL still work.
+ const makeFapiClient = () => ({
+ buildUrl: ({ path, sessionId }: { path?: string; sessionId?: string }) => {
+ const url = new URL(`https://clerk.example.com/v1${path}`);
+ if (sessionId) {
+ url.searchParams.set('_clerk_session_id', sessionId);
+ }
+ return url;
+ },
+ });
+
+ it('returns a URL with the correct FAPI path', () => {
+ BaseResource.clerk = {
+ session: { id: 'sess_123' },
+ buildUrlWithAuth: (url: string) => url,
+ getFapiClient: () => makeFapiClient(),
+ } as any;
+
+ const result = oauthApp.buildConsentActionUrl({ clientId: 'client_abc' });
+
+ expect(result).toContain('/v1/me/oauth/consent/client_abc');
+ });
+
+ it('URL-encodes the client ID', () => {
+ BaseResource.clerk = {
+ session: { id: 'sess_123' },
+ buildUrlWithAuth: (url: string) => url,
+ getFapiClient: () => makeFapiClient(),
+ } as any;
+
+ const result = oauthApp.buildConsentActionUrl({ clientId: 'my/client id' });
+
+ expect(result).toContain('/v1/me/oauth/consent/my%2Fclient%20id');
+ });
+
+ it('appends _clerk_session_id when session exists', () => {
+ BaseResource.clerk = {
+ session: { id: 'sess_123' },
+ buildUrlWithAuth: (url: string) => url,
+ getFapiClient: () => makeFapiClient(),
+ } as any;
+
+ const result = oauthApp.buildConsentActionUrl({ clientId: 'cid' });
+
+ expect(new URL(result).searchParams.get('_clerk_session_id')).toBe('sess_123');
+ });
+
+ it('omits _clerk_session_id when session is null', () => {
+ BaseResource.clerk = {
+ session: null,
+ buildUrlWithAuth: (url: string) => url,
+ getFapiClient: () => makeFapiClient(),
+ } as any;
+
+ const result = oauthApp.buildConsentActionUrl({ clientId: 'cid' });
+
+ expect(new URL(result).searchParams.has('_clerk_session_id')).toBe(false);
+ });
+
+ it('delegates to buildUrlWithAuth for dev browser JWT', () => {
+ const buildUrlWithAuth = vi.fn((url: string) => `${url}&__clerk_db_jwt=devjwt`);
+ BaseResource.clerk = {
+ session: { id: 'sess_123' },
+ buildUrlWithAuth,
+ getFapiClient: () => makeFapiClient(),
+ } as any;
+
+ const result = oauthApp.buildConsentActionUrl({ clientId: 'cid' });
+
+ expect(buildUrlWithAuth).toHaveBeenCalledOnce();
+ expect(result).toContain('__clerk_db_jwt=devjwt');
+ });
+ });
+});
diff --git a/packages/clerk-js/src/core/resources/OAuthApplication.ts b/packages/clerk-js/src/core/modules/oauthApplication/index.ts
similarity index 58%
rename from packages/clerk-js/src/core/resources/OAuthApplication.ts
rename to packages/clerk-js/src/core/modules/oauthApplication/index.ts
index 87a45b509a3..11d54c099af 100644
--- a/packages/clerk-js/src/core/resources/OAuthApplication.ts
+++ b/packages/clerk-js/src/core/modules/oauthApplication/index.ts
@@ -1,21 +1,15 @@
import { ClerkRuntimeError } from '@clerk/shared/error';
import type {
- ClerkResourceJSON,
GetOAuthConsentInfoParams,
+ OAuthApplicationNamespace,
OAuthConsentInfo,
OAuthConsentInfoJSON,
} from '@clerk/shared/types';
-import { BaseResource } from './internal';
+import { BaseResource } from '../../resources/internal';
-export class OAuthApplication extends BaseResource {
- pathRoot = '';
-
- protected fromJSON(_data: ClerkResourceJSON | null): this {
- return this;
- }
-
- static async getConsentInfo(params: GetOAuthConsentInfoParams): Promise {
+export class OAuthApplication implements OAuthApplicationNamespace {
+ async getConsentInfo(params: GetOAuthConsentInfoParams): Promise {
const { oauthClientId, scope } = params;
const json = await BaseResource._fetch(
{
@@ -30,7 +24,6 @@ export class OAuthApplication extends BaseResource {
throw new ClerkRuntimeError('Network request failed while offline', { code: 'network_error' });
}
- // Handle in case we start wrapping the response in the future
const data = json.response ?? json;
return {
oauthApplicationName: data.oauth_application_name,
@@ -39,11 +32,19 @@ export class OAuthApplication extends BaseResource {
clientId: data.client_id,
state: data.state,
scopes:
- data.scopes?.map(scope => ({
- scope: scope.scope,
- description: scope.description,
- requiresConsent: scope.requires_consent,
+ data.scopes?.map(s => ({
+ scope: s.scope,
+ description: s.description,
+ requiresConsent: s.requires_consent,
})) ?? [],
};
}
+
+ buildConsentActionUrl({ clientId }: { clientId: string }): string {
+ const url = BaseResource.fapiClient.buildUrl({
+ path: `/me/oauth/consent/${encodeURIComponent(clientId)}`,
+ sessionId: BaseResource.clerk.session?.id,
+ });
+ return BaseResource.clerk.buildUrlWithAuth(url.toString());
+ }
}
diff --git a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts b/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts
deleted file mode 100644
index 0a56c70f2d9..00000000000
--- a/packages/clerk-js/src/core/resources/__tests__/OAuthApplication.test.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { ClerkAPIResponseError } from '@clerk/shared/error';
-import type { InstanceType, OAuthConsentInfoJSON } from '@clerk/shared/types';
-import { afterEach, describe, expect, it, type Mock, vi } from 'vitest';
-
-import { mockFetch } from '@/test/core-fixtures';
-
-import { SUPPORTED_FAPI_VERSION } from '../../constants';
-import { createFapiClient } from '../../fapiClient';
-import { BaseResource } from '../internal';
-import { OAuthApplication } from '../OAuthApplication';
-
-const consentPayload: OAuthConsentInfoJSON = {
- object: 'oauth_consent_info',
- id: 'client_abc',
- oauth_application_name: 'My App',
- oauth_application_logo_url: 'https://img.example/logo.png',
- oauth_application_url: 'https://app.example',
- client_id: 'client_abc',
- state: 'st',
- scopes: [{ scope: 'openid', description: 'OpenID', requires_consent: true }],
-};
-
-describe('OAuthApplication.getConsentInfo', () => {
- afterEach(() => {
- (global.fetch as Mock)?.mockClear?.();
- BaseResource.clerk = null as any;
- vi.restoreAllMocks();
- });
-
- it('calls BaseResource._fetch with GET, encoded path, optional scope, and skipUpdateClient', async () => {
- const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
- response: consentPayload,
- } as any);
-
- BaseResource.clerk = {} as any;
-
- await OAuthApplication.getConsentInfo({ oauthClientId: 'my/client id', scope: 'openid email' });
-
- expect(fetchSpy).toHaveBeenCalledWith(
- {
- method: 'GET',
- path: '/me/oauth/consent/my%2Fclient%20id',
- search: { scope: 'openid email' },
- },
- { skipUpdateClient: true },
- );
- });
-
- it('omits search when scope is undefined', async () => {
- const fetchSpy = vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
- response: consentPayload,
- } as any);
-
- BaseResource.clerk = {} as any;
-
- await OAuthApplication.getConsentInfo({ oauthClientId: 'cid' });
-
- expect(fetchSpy).toHaveBeenCalledWith(
- expect.objectContaining({
- search: undefined,
- }),
- { skipUpdateClient: true },
- );
- });
-
- it('returns OAuthConsentInfo from the FAPI response', async () => {
- vi.spyOn(BaseResource, '_fetch').mockResolvedValue(consentPayload as any);
-
- BaseResource.clerk = {} as any;
-
- const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' });
-
- expect(info).toEqual({
- oauthApplicationName: 'My App',
- oauthApplicationLogoUrl: 'https://img.example/logo.png',
- oauthApplicationUrl: 'https://app.example',
- clientId: 'client_abc',
- state: 'st',
- scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
- });
- });
-
- it('returns OAuthConsentInfo from the FAPI response (enveloped)', async () => {
- vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
- response: consentPayload,
- } as any);
-
- BaseResource.clerk = {} as any;
-
- const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' });
-
- expect(info).toEqual({
- oauthApplicationName: 'My App',
- oauthApplicationLogoUrl: 'https://img.example/logo.png',
- oauthApplicationUrl: 'https://app.example',
- clientId: 'client_abc',
- state: 'st',
- scopes: [{ scope: 'openid', description: 'OpenID', requiresConsent: true }],
- });
- });
-
- it('defaults scopes to an empty array when absent', async () => {
- vi.spyOn(BaseResource, '_fetch').mockResolvedValue({
- response: { ...consentPayload, scopes: undefined },
- } as any);
-
- BaseResource.clerk = {} as any;
-
- const info = await OAuthApplication.getConsentInfo({ oauthClientId: 'client_abc' });
- expect(info.scopes).toEqual([]);
- });
-
- it('maps ClerkAPIResponseError from FAPI on non-2xx', async () => {
- mockFetch(false, 422, {
- errors: [{ code: 'oauth_consent_error', long_message: 'Consent metadata unavailable' }],
- });
-
- BaseResource.clerk = {
- getFapiClient: () =>
- createFapiClient({
- frontendApi: 'clerk.example.com',
- getSessionId: () => undefined,
- instanceType: 'development' as InstanceType,
- }),
- __internal_setCountry: vi.fn(),
- handleUnauthenticated: vi.fn(),
- __internal_handleUnauthenticatedDevBrowser: vi.fn(),
- } as any;
-
- await expect(OAuthApplication.getConsentInfo({ oauthClientId: 'cid' })).rejects.toSatisfy(
- (err: unknown) => err instanceof ClerkAPIResponseError && err.message === 'Consent metadata unavailable',
- );
-
- expect(global.fetch).toHaveBeenCalledTimes(1);
- const [url] = (global.fetch as Mock).mock.calls[0];
- expect(url.toString()).toContain(`/v1/me/oauth/consent/cid`);
- expect(url.toString()).toContain(`__clerk_api_version=${SUPPORTED_FAPI_VERSION}`);
- });
-
- it('throws ClerkRuntimeError when _fetch returns null (offline)', async () => {
- vi.spyOn(BaseResource, '_fetch').mockResolvedValue(null);
-
- BaseResource.clerk = {} as any;
-
- await expect(OAuthApplication.getConsentInfo({ oauthClientId: 'cid' })).rejects.toMatchObject({
- code: 'network_error',
- });
- });
-});
diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts
index d9294e3e8f8..0cdb99971d1 100644
--- a/packages/clerk-js/src/core/resources/internal.ts
+++ b/packages/clerk-js/src/core/resources/internal.ts
@@ -22,7 +22,6 @@ export * from './ExternalAccount';
export * from './Feature';
export * from './IdentificationLink';
export * from './Image';
-export * from './OAuthApplication';
export * from './Organization';
export * from './OrganizationDomain';
export * from './OrganizationInvitation';
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index 70ea6c728fa..3b5b3e1bc61 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -294,6 +294,23 @@ export const enUS: LocalizationResource = {
title: 'Choose an account',
titleWithoutPersonal: 'Choose an organization',
},
+ oauthConsent: {
+ action__allow: 'Allow',
+ action__deny: 'Deny',
+ offlineAccessNotice: " You'll stay signed in until you sign out or revoke access.",
+ redirectNotice: 'If you allow access, this app will redirect you to {{domainAction}}.',
+ redirectUriModal: {
+ subtitle: 'Make sure you trust {{applicationName}} and that this URL belongs to {{applicationName}}.',
+ title: 'Redirect URL',
+ },
+ scopeList: {
+ title: 'This will allow {{applicationName}} access to:',
+ },
+ subtitle: 'wants to access {{applicationName}} on behalf of {{identifier}}',
+ viewFullUrl: 'View full URL',
+ warning:
+ 'Make sure that you trust {{applicationName}} ({{domainAction}}). You may be sharing sensitive data with this site or app.',
+ },
organizationProfile: {
apiKeysPage: {
title: 'API keys',
diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx
index 0eaf4bfabe0..9fd5a6d6d37 100644
--- a/packages/nextjs/src/client-boundary/uiComponents.tsx
+++ b/packages/nextjs/src/client-boundary/uiComponents.tsx
@@ -31,6 +31,8 @@ export {
HandleSSOCallback,
} from '@clerk/react';
+export { OAuthConsent } from '@clerk/react/internal';
+
// The assignment of UserProfile with BaseUserProfile props is used
// to support the CustomPage functionality (eg UserProfile.Page)
// Also the `typeof BaseUserProfile` is used to resolve the following error:
diff --git a/packages/nextjs/src/internal.ts b/packages/nextjs/src/internal.ts
index 3c877f8df45..be6d80fb216 100644
--- a/packages/nextjs/src/internal.ts
+++ b/packages/nextjs/src/internal.ts
@@ -3,4 +3,5 @@
* If you do, app router will break.
*/
export { MultisessionAppSupport } from './client-boundary/controlComponents';
+export { OAuthConsent } from './client-boundary/uiComponents';
export { useOAuthConsent } from '@clerk/shared/react';
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 7ec10579ea7..a2d4368c1b2 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -42,6 +42,10 @@
"types": "./dist/api/index.d.ts",
"default": "./dist/api/index.js"
},
+ "./internal": {
+ "types": "./dist/internal.d.ts",
+ "default": "./dist/internal.js"
+ },
"./errors": {
"types": "./dist/errors.d.ts",
"default": "./dist/errors.js"
@@ -73,6 +77,9 @@
"api.server": [
"dist/api/index.d.ts"
],
+ "internal": [
+ "dist/internal.d.ts"
+ ],
"webhooks": [
"dist/webhooks.d.ts"
],
diff --git a/packages/react-router/src/internal.ts b/packages/react-router/src/internal.ts
new file mode 100644
index 00000000000..14f123a78ba
--- /dev/null
+++ b/packages/react-router/src/internal.ts
@@ -0,0 +1 @@
+export { useOAuthConsent, OAuthConsent } from '@clerk/react/internal';
diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx
index ea07a530bbb..a87b83af675 100644
--- a/packages/react/src/components/uiComponents.tsx
+++ b/packages/react/src/components/uiComponents.tsx
@@ -1,4 +1,5 @@
import type {
+ __internal_OAuthConsentProps,
APIKeysProps,
CreateOrganizationProps,
GoogleOneTapProps,
@@ -643,6 +644,34 @@ export const APIKeys = withClerk(
{ component: 'ApiKeys', renderWhileLoading: true },
);
+export const OAuthConsent = withClerk(
+ ({ clerk, component, fallback, ...props }: WithClerkProp<__internal_OAuthConsentProps & FallbackProp>) => {
+ const mountingStatus = useWaitForComponentMount(component);
+ const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded;
+
+ const rendererRootProps = {
+ ...(shouldShowFallback && fallback && { style: { display: 'none' } }),
+ };
+
+ return (
+ <>
+ {shouldShowFallback && fallback}
+ {clerk.loaded && (
+
+ )}
+ >
+ );
+ },
+ { component: 'OAuthConsent', renderWhileLoading: true },
+);
+
export const UserAvatar = withClerk(
({ clerk, component, fallback, ...props }: WithClerkProp) => {
const mountingStatus = useWaitForComponentMount(component);
diff --git a/packages/react/src/internal.ts b/packages/react/src/internal.ts
index fb6cad96021..0161dab0279 100644
--- a/packages/react/src/internal.ts
+++ b/packages/react/src/internal.ts
@@ -8,6 +8,7 @@ import type { ClerkProviderProps } from './types';
export { setErrorThrowerOptions } from './errors/errorThrower';
export { MultisessionAppSupport } from './components/controlComponents';
export { useOAuthConsent } from '@clerk/shared/react';
+export { OAuthConsent } from './components/uiComponents';
export { useRoutingProps } from './hooks/useRoutingProps';
export { useDerivedAuth } from './hooks/useAuth';
export { IS_REACT_SHARED_VARIANT_COMPATIBLE } from './utils/versionCheck';
diff --git a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts
deleted file mode 100644
index 4e0549e9c4a..00000000000
--- a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.shared.spec.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { describe, expect, it } from 'vitest';
-
-import { readOAuthConsentFromSearch } from '../useOAuthConsent.shared';
-
-describe('readOAuthConsentFromSearch', () => {
- it('parses client_id and scope from a location.search-style string', () => {
- expect(readOAuthConsentFromSearch('?client_id=myapp&scope=openid%20email')).toEqual({
- oauthClientId: 'myapp',
- scope: 'openid email',
- });
- });
-
- it('parses without a leading question mark', () => {
- expect(readOAuthConsentFromSearch('client_id=x&scope=y')).toEqual({
- oauthClientId: 'x',
- scope: 'y',
- });
- });
-
- it('returns empty client id and undefined scope when search is empty', () => {
- expect(readOAuthConsentFromSearch('')).toEqual({
- oauthClientId: '',
- });
- });
-
- it('omits scope in the result when scope is absent', () => {
- expect(readOAuthConsentFromSearch('?client_id=only')).toEqual({
- oauthClientId: 'only',
- });
- });
-});
diff --git a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx
index 19136ed9341..99708152485 100644
--- a/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx
+++ b/packages/shared/src/react/hooks/__tests__/useOAuthConsent.spec.tsx
@@ -50,7 +50,6 @@ describe('useOAuthConsent', () => {
mockClerk.oauthApplication = {
getConsentInfo: getConsentInfoSpy,
};
- window.history.replaceState({}, '', '/');
});
it('fetches consent metadata when signed in', async () => {
@@ -104,45 +103,4 @@ describe('useOAuthConsent', () => {
expect(getConsentInfoSpy).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
});
-
- it('uses client_id and scope from the URL when hook params omit them', async () => {
- window.history.replaceState({}, '', '/?client_id=from_url&scope=openid%20email');
-
- const { result } = renderHook(() => useOAuthConsent(), { wrapper });
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(getConsentInfoSpy).toHaveBeenCalledTimes(1);
- expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'from_url', scope: 'openid email' });
- expect(result.current.data).toEqual(consentInfo);
- });
-
- it('prefers explicit oauthClientId over URL client_id', async () => {
- window.history.replaceState({}, '', '/?client_id=from_url');
-
- const { result } = renderHook(() => useOAuthConsent({ oauthClientId: 'explicit_id' }), { wrapper });
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'explicit_id' });
- });
-
- it('does not fall back to URL client_id when oauthClientId is explicitly empty', () => {
- window.history.replaceState({}, '', '/?client_id=from_url');
-
- const { result } = renderHook(() => useOAuthConsent({ oauthClientId: '' }), { wrapper });
-
- expect(getConsentInfoSpy).not.toHaveBeenCalled();
- expect(result.current.isLoading).toBe(false);
- });
-
- it('prefers explicit scope over URL scope', async () => {
- window.history.replaceState({}, '', '/?client_id=cid&scope=from_url');
-
- const { result } = renderHook(() => useOAuthConsent({ scope: 'explicit_scope' }), { wrapper });
-
- await waitFor(() => expect(result.current.isLoading).toBe(false));
-
- expect(getConsentInfoSpy).toHaveBeenCalledWith({ oauthClientId: 'cid', scope: 'explicit_scope' });
- });
});
diff --git a/packages/shared/src/react/hooks/useOAuthConsent.shared.ts b/packages/shared/src/react/hooks/useOAuthConsent.shared.ts
index e26b8074a8f..9b21222ffe1 100644
--- a/packages/shared/src/react/hooks/useOAuthConsent.shared.ts
+++ b/packages/shared/src/react/hooks/useOAuthConsent.shared.ts
@@ -4,24 +4,6 @@ import type { GetOAuthConsentInfoParams } from '../../types';
import { STABLE_KEYS } from '../stable-keys';
import { createCacheKeys } from './createCacheKeys';
-/**
- * Parses OAuth authorize-style query data from a search string (typically `window.location.search`).
- *
- * @internal
- */
-export function readOAuthConsentFromSearch(search: string): {
- oauthClientId: string;
- scope?: string;
-} {
- const sp = new URLSearchParams(search);
- const oauthClientId = sp.get('client_id') ?? '';
- const scopeValue = sp.get('scope');
- if (scopeValue === null) {
- return { oauthClientId };
- }
- return { oauthClientId, scope: scopeValue };
-}
-
export function useOAuthConsentCacheKeys(params: { userId: string | null; oauthClientId: string; scope?: string }) {
const { userId, oauthClientId, scope } = params;
return useMemo(() => {
diff --git a/packages/shared/src/react/hooks/useOAuthConsent.tsx b/packages/shared/src/react/hooks/useOAuthConsent.tsx
index c87b3d08ade..aba685682d9 100644
--- a/packages/shared/src/react/hooks/useOAuthConsent.tsx
+++ b/packages/shared/src/react/hooks/useOAuthConsent.tsx
@@ -1,14 +1,10 @@
-'use client';
-
-import { useMemo } from 'react';
-
import { eventMethodCalled } from '../../telemetry/events/method-called';
import type { LoadedClerk } from '../../types/clerk';
import { defineKeepPreviousDataFn } from '../clerk-rq/keep-previous-data';
import { useClerkQuery } from '../clerk-rq/useQuery';
import { useAssertWrappedByClerkProvider, useClerkInstanceContext } from '../contexts';
import { useUserBase } from './base/useUserBase';
-import { readOAuthConsentFromSearch, useOAuthConsentCacheKeys } from './useOAuthConsent.shared';
+import { useOAuthConsentCacheKeys } from './useOAuthConsent.shared';
import type { UseOAuthConsentParams, UseOAuthConsentReturn } from './useOAuthConsent.types';
const HOOK_NAME = 'useOAuthConsent';
@@ -18,26 +14,13 @@ const HOOK_NAME = 'useOAuthConsent';
* (`GET /me/oauth/consent/{oauthClientId}`). Ensure the user is authenticated before relying on this hook
* (for example, redirect to sign-in on your custom consent route).
*
- * `oauthClientId` and `scope` are optional. On the client, values default from a single snapshot of
- * `window.location.search` (`client_id` and `scope`). Pass them explicitly to override.
+ * The hook is a pure data fetcher: it takes an explicit `oauthClientId` and optional `scope` and
+ * issues the fetch when both the user is signed in and `oauthClientId` is non-empty. The query is
+ * disabled when `oauthClientId` is empty or omitted.
*
* @internal
*
* @example
- * ### From the URL (`?client_id=...&scope=...`)
- *
- * ```tsx
- * import { useOAuthConsent } from '@clerk/react/internal'
- *
- * export default function OAuthConsentPage() {
- * const { data, isLoading, error } = useOAuthConsent()
- * // ...
- * }
- * ```
- *
- * @example
- * ### Explicit values (override URL)
- *
* ```tsx
* import { useOAuthConsent } from '@clerk/react/internal'
*
@@ -50,19 +33,11 @@ const HOOK_NAME = 'useOAuthConsent';
export function useOAuthConsent(params: UseOAuthConsentParams = {}): UseOAuthConsentReturn {
useAssertWrappedByClerkProvider(HOOK_NAME);
- const { oauthClientId: oauthClientIdParam, scope: scopeParam, keepPreviousData = true, enabled = true } = params;
+ const { oauthClientId: oauthClientIdParam, scope, keepPreviousData = true, enabled = true } = params;
const clerk = useClerkInstanceContext();
const user = useUserBase();
- const fromUrl = useMemo(() => {
- if (typeof window === 'undefined' || !window.location) {
- return { oauthClientId: '' };
- }
- return readOAuthConsentFromSearch(window.location.search);
- }, []);
-
- const oauthClientId = (oauthClientIdParam !== undefined ? oauthClientIdParam : fromUrl.oauthClientId).trim();
- const scope = scopeParam !== undefined ? scopeParam : fromUrl.scope;
+ const oauthClientId = (oauthClientIdParam ?? '').trim();
clerk.telemetry?.record(eventMethodCalled(HOOK_NAME));
diff --git a/packages/shared/src/react/hooks/useOAuthConsent.types.ts b/packages/shared/src/react/hooks/useOAuthConsent.types.ts
index 59ee444c7eb..c8972cd483b 100644
--- a/packages/shared/src/react/hooks/useOAuthConsent.types.ts
+++ b/packages/shared/src/react/hooks/useOAuthConsent.types.ts
@@ -4,11 +4,11 @@ import type { GetOAuthConsentInfoParams, OAuthConsentInfo } from '../../types';
/**
* Options for {@link useOAuthConsent}.
*
- * `oauthClientId` and `scope` are optional. On the browser, the hook reads a one-time snapshot of
- * `window.location.search` and uses `client_id` and `scope` query keys when you omit them here.
- * Any value you pass explicitly overrides the snapshot for that field only.
+ * Pass `oauthClientId` and `scope` explicitly. The hook does not read from `window.location` or
+ * any other ambient source. The hook is disabled when `oauthClientId` is empty or omitted.
*
* @internal
+ *
* @interface
*/
export type UseOAuthConsentParams = Partial> & {
@@ -28,6 +28,7 @@ export type UseOAuthConsentParams = Partial void;
+ onAllow?: () => void;
/**
* Called when user denies access.
+ *
+ * @deprecated Used by the accounts portal. Pass `client_id` and `redirect_uri` as URL parameters instead.
*/
- onDeny: () => void;
+ onDeny?: () => void;
};
export interface HandleEmailLinkVerificationParams {
diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts
index c21c6d0b094..222509565bb 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -1253,6 +1253,22 @@ export type __internal_LocalizationResource = {
suggestionsAcceptedLabel: LocalizationValue;
action__createOrganization: LocalizationValue;
};
+ oauthConsent: {
+ subtitle: LocalizationValue<'applicationName' | 'identifier'>;
+ scopeList: {
+ title: LocalizationValue<'applicationName'>;
+ };
+ action__deny: LocalizationValue;
+ action__allow: LocalizationValue;
+ warning: LocalizationValue<'applicationName' | 'domainAction'>;
+ redirectNotice: LocalizationValue<'domainAction'>;
+ offlineAccessNotice: LocalizationValue;
+ viewFullUrl: LocalizationValue;
+ redirectUriModal: {
+ title: LocalizationValue;
+ subtitle: LocalizationValue<'applicationName'>;
+ };
+ };
unstable__errors: UnstableErrors;
dates: {
previous6Days: LocalizationValue<'date'>;
diff --git a/packages/shared/src/types/oauthApplication.ts b/packages/shared/src/types/oauthApplication.ts
index 33f1c580383..c355cfd0dc7 100644
--- a/packages/shared/src/types/oauthApplication.ts
+++ b/packages/shared/src/types/oauthApplication.ts
@@ -59,4 +59,12 @@ export interface OAuthApplicationNamespace {
* Loads consent metadata for the given OAuth client for the signed-in user.
*/
getConsentInfo: (params: GetOAuthConsentInfoParams) => Promise;
+
+ /**
+ * Returns the URL to use as the `action` attribute of the consent form.
+ * Includes `_clerk_session_id` and, in development, the dev browser JWT.
+ * Custom-flow developers building their own consent UI use this alongside
+ * the `useOAuthConsent` hook.
+ */
+ buildConsentActionUrl: (params: { clientId: string }) => string;
}
diff --git a/packages/tanstack-react-start/package.json b/packages/tanstack-react-start/package.json
index 36693927088..bfefdfb299e 100644
--- a/packages/tanstack-react-start/package.json
+++ b/packages/tanstack-react-start/package.json
@@ -35,6 +35,10 @@
"types": "./dist/server/index.d.ts",
"default": "./dist/server/index.js"
},
+ "./internal": {
+ "types": "./dist/internal.d.ts",
+ "default": "./dist/internal.js"
+ },
"./errors": {
"types": "./dist/errors.d.ts",
"default": "./dist/errors.js"
diff --git a/packages/tanstack-react-start/src/internal.ts b/packages/tanstack-react-start/src/internal.ts
new file mode 100644
index 00000000000..14f123a78ba
--- /dev/null
+++ b/packages/tanstack-react-start/src/internal.ts
@@ -0,0 +1 @@
+export { useOAuthConsent, OAuthConsent } from '@clerk/react/internal';
diff --git a/packages/ui/src/components/OAuthConsent/InlineAction.tsx b/packages/ui/src/components/OAuthConsent/InlineAction.tsx
new file mode 100644
index 00000000000..8d6895d6582
--- /dev/null
+++ b/packages/ui/src/components/OAuthConsent/InlineAction.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+
+import { Text } from '@/ui/customizables';
+import { Tooltip } from '@/ui/elements/Tooltip';
+
+type InlineActionProps = {
+ text: string;
+ actionText: string;
+ onClick: () => void;
+ tooltipText: string;
+};
+
+export function InlineAction({ text, actionText, onClick, tooltipText }: InlineActionProps) {
+ const idx = text.indexOf(actionText);
+ if (idx === -1) {
+ return <>{text}>;
+ }
+
+ let before = text.slice(0, idx);
+ let after = text.slice(idx + actionText.length);
+
+ // Pull adjacent parentheses into the action span so they don't wrap separately.
+ let prefix = '';
+ let suffix = '';
+ if (before.endsWith('(')) {
+ before = before.slice(0, -1);
+ prefix = '(';
+ }
+ if (after.startsWith(')')) {
+ after = after.slice(1);
+ suffix = ')';
+ }
+
+ const actionContent = (
+
+
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onClick();
+ }
+ }}
+ sx={t => ({
+ textDecoration: 'underline',
+ textDecorationStyle: 'dotted',
+ cursor: 'pointer',
+ outline: 'none',
+ display: 'inline-block',
+ '&:focus-visible': {
+ outline: '2px solid',
+ outlineColor: t.colors.$colorRing,
+ },
+ })}
+ >
+ {actionText}
+
+
+
+
+ );
+
+ return (
+ <>
+ {before}
+ {prefix || suffix ? (
+
+ {prefix}
+ {actionContent}
+ {suffix}
+
+ ) : (
+ actionContent
+ )}
+ {after}
+ >
+ );
+}
diff --git a/packages/ui/src/components/OAuthConsent/ListGroup.tsx b/packages/ui/src/components/OAuthConsent/ListGroup.tsx
new file mode 100644
index 00000000000..0f14b3528ee
--- /dev/null
+++ b/packages/ui/src/components/OAuthConsent/ListGroup.tsx
@@ -0,0 +1,124 @@
+import type { ComponentProps } from 'react';
+
+import { Box, descriptors, Text } from '@/ui/customizables';
+import { common } from '@/ui/styledSystem';
+import { colors } from '@/ui/utils/colors';
+
+export function ListGroup({ children, sx, ...props }: Omit, 'elementDescriptor'>) {
+ return (
+ ({
+ textAlign: 'start',
+ borderWidth: t.borderWidths.$normal,
+ borderStyle: t.borderStyles.$solid,
+ borderColor: t.colors.$borderAlpha100,
+ borderRadius: t.radii.$lg,
+ overflow: 'hidden',
+ }),
+ sx,
+ ]}
+ elementDescriptor={descriptors.listGroup}
+ >
+ {children}
+
+ );
+}
+
+export function ListGroupHeader({ children, sx, ...props }: Omit, 'elementDescriptor'>) {
+ return (
+ ({
+ padding: t.space.$3,
+ background: common.mergedColorsBackground(
+ colors.setAlpha(t.colors.$colorBackground, 1),
+ t.colors.$neutralAlpha50,
+ ),
+ }),
+ sx,
+ ]}
+ elementDescriptor={descriptors.listGroupHeader}
+ >
+ {children}
+
+ );
+}
+
+export function ListGroupHeaderTitle(props: Omit, 'elementDescriptor'>) {
+ return (
+
+ );
+}
+
+export function ListGroupContent({
+ children,
+ sx,
+ ...props
+}: Omit, 'as' | 'elementDescriptor'>) {
+ return (
+ ({ margin: t.sizes.$none, padding: t.sizes.$none }), sx]}
+ elementDescriptor={descriptors.listGroupContent}
+ >
+ {children}
+
+ );
+}
+
+export function ListGroupItem({
+ children,
+ sx,
+ ...props
+}: Omit, 'as' | 'elementDescriptor'>) {
+ return (
+ ({
+ display: 'flex',
+ alignItems: 'baseline',
+ paddingInline: t.space.$3,
+ paddingBlock: t.space.$2,
+ borderTopWidth: t.borderWidths.$normal,
+ borderTopStyle: t.borderStyles.$solid,
+ borderTopColor: t.colors.$borderAlpha100,
+ '&::before': {
+ content: '""',
+ display: 'inline-block',
+ width: t.space.$1,
+ height: t.space.$1,
+ background: t.colors.$colorMutedForeground,
+ borderRadius: t.radii.$circle,
+ transform: 'translateY(-0.1875rem)',
+ marginInlineEnd: t.space.$2,
+ flexShrink: 0,
+ },
+ }),
+ sx,
+ ]}
+ elementDescriptor={descriptors.listGroupItem}
+ >
+ {children}
+
+ );
+}
+
+export function ListGroupItemLabel(props: Omit, 'elementDescriptor'>) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/OAuthConsent/LogoGroup.tsx b/packages/ui/src/components/OAuthConsent/LogoGroup.tsx
new file mode 100644
index 00000000000..09292f3f7c3
--- /dev/null
+++ b/packages/ui/src/components/OAuthConsent/LogoGroup.tsx
@@ -0,0 +1,94 @@
+import type { ComponentProps } from 'react';
+import React from 'react';
+
+import { Box, descriptors, Flex, Icon } from '@/ui/customizables';
+import { LockDottedCircle } from '@/ui/icons';
+import type { ThemableCssProp } from '@/ui/styledSystem';
+import { common } from '@/ui/styledSystem';
+import { colors } from '@/ui/utils/colors';
+
+export function LogoGroup({ children }: { children: React.ReactNode }) {
+ return (
+ ({
+ marginBlockEnd: t.space.$6,
+ })}
+ elementDescriptor={descriptors.logoGroup}
+ >
+ {children}
+
+ );
+}
+
+export function LogoGroupItem({ children, sx, ...props }: ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function LogoGroupIcon({ size = 'md', sx }: { size?: 'sm' | 'md'; sx?: ThemableCssProp }) {
+ const scale: ThemableCssProp = t => {
+ const value = size === 'sm' ? t.space.$6 : t.space.$12;
+ return { width: value, height: value };
+ };
+
+ return (
+ [
+ {
+ background: common.mergedColorsBackground(
+ colors.setAlpha(t.colors.$colorBackground, 1),
+ t.colors.$neutralAlpha50,
+ ),
+ borderRadius: t.radii.$circle,
+ borderWidth: t.borderWidths.$normal,
+ borderStyle: t.borderStyles.$solid,
+ borderColor: t.colors.$borderAlpha100,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ scale,
+ sx,
+ ]}
+ elementDescriptor={descriptors.logoGroupIcon}
+ >
+ ({ color: t.colors.$primary500 })}
+ />
+
+ );
+}
+
+export function LogoGroupSeparator() {
+ return (
+ ({ color: t.colors.$colorMutedForeground })}
+ elementDescriptor={descriptors.logoGroupSeparator}
+ >
+
+
+ );
+}
diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
index 496eddb787a..430d8779103 100644
--- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
+++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
@@ -1,261 +1,332 @@
-import { useUser } from '@clerk/shared/react';
-import type { ComponentProps } from 'react';
+import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react';
import { useState } from 'react';
-import { useEnvironment, useOAuthConsentContext } from '@/ui/contexts';
-import { Box, Button, Flex, Flow, Grid, Icon, Text } from '@/ui/customizables';
+import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts';
+import { Box, Button, Flow, Grid, localizationKeys, Text, useLocalizations } from '@/ui/customizables';
import { ApplicationLogo } from '@/ui/elements/ApplicationLogo';
import { Card } from '@/ui/elements/Card';
import { withCardStateProvider } from '@/ui/elements/contexts';
import { Header } from '@/ui/elements/Header';
+import { LoadingCardContainer } from '@/ui/elements/LoadingCard';
import { Modal } from '@/ui/elements/Modal';
-import { Tooltip } from '@/ui/elements/Tooltip';
-import { LockDottedCircle } from '@/ui/icons';
import { Alert, Textarea } from '@/ui/primitives';
-import type { ThemableCssProp } from '@/ui/styledSystem';
-import { common } from '@/ui/styledSystem';
-import { colors } from '@/ui/utils/colors';
+
+import { InlineAction } from './InlineAction';
+import {
+ ListGroup,
+ ListGroupContent,
+ ListGroupHeader,
+ ListGroupHeaderTitle,
+ ListGroupItem,
+ ListGroupItemLabel,
+} from './ListGroup';
+import { LogoGroup, LogoGroupIcon, LogoGroupItem, LogoGroupSeparator } from './LogoGroup';
+import type { OrgOption } from './OrgSelect';
+import { OrgSelect } from './OrgSelect';
+import { getForwardedParams, getOAuthConsentFromSearch, getRedirectUriFromSearch, getRootDomain } from './utils';
const OFFLINE_ACCESS_SCOPE = 'offline_access';
-export function OAuthConsentInternal() {
- const { scopes, oAuthApplicationName, oAuthApplicationLogoUrl, oAuthApplicationUrl, redirectUrl, onDeny, onAllow } =
- useOAuthConsentContext();
+function _OAuthConsent() {
+ const ctx = useOAuthConsentContext();
+ const clerk = useClerk();
const { user } = useUser();
const { applicationName, logoImageUrl } = useEnvironment().displayConfig;
const [isUriModalOpen, setIsUriModalOpen] = useState(false);
+ const { isLoaded: isMembershipsLoaded, userMemberships } = useOrganizationList({
+ // TODO(rob): Implement lazy loading in another PR
+ userMemberships: ctx.enableOrgSelection ? { infinite: true, pageSize: 50 } : undefined,
+ });
- const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber;
+ const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({
+ value: m.organization.id,
+ label: m.organization.name,
+ logoUrl: m.organization.imageUrl,
+ }));
+
+ const [selectedOrg, setSelectedOrg] = useState(null);
+ const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null;
+
+ // onAllow and onDeny are always provided as a pair by the accounts portal.
+ const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny);
+
+ // Resolve oauthClientId and scope once: context overrides URL fallback.
+ const fromUrl = getOAuthConsentFromSearch();
+ const oauthClientId = ctx.oauthClientId ?? fromUrl.oauthClientId;
+ const scope = ctx.scope ?? fromUrl.scope;
+
+ // Public path: fetch via hook. Disabled on the accounts portal path
+ // (which already has all data via context) to avoid a wasted FAPI request.
+ const { data, isLoading, error } = useOAuthConsent({
+ oauthClientId,
+ scope,
+ // TODO: Remove this once account portal is refactored to use this component
+ enabled: !hasContextCallbacks,
+ });
+
+ // Hook returns camelCase `requiresConsent`; the render logic uses snake_case.
+ const mappedHookScopes = data?.scopes?.map(s => ({
+ scope: s.scope,
+ description: s.description,
+ requires_consent: s.requiresConsent,
+ }));
+
+ // Context (accounts portal path) wins over hook data (public path).
+ const scopes = ctx.scopes ?? mappedHookScopes ?? [];
+ const oauthApplicationName = ctx.oauthApplicationName ?? data?.oauthApplicationName ?? '';
+ const oauthApplicationLogoUrl = ctx.oauthApplicationLogoUrl ?? data?.oauthApplicationLogoUrl;
+ const oauthApplicationUrl = ctx.oauthApplicationUrl ?? data?.oauthApplicationUrl;
+ const redirectUrl = ctx.redirectUrl ?? getRedirectUriFromSearch();
+
+ const { t } = useLocalizations();
+ const domainAction = getRootDomain(redirectUrl);
+ const viewFullUrlText = t(localizationKeys('oauthConsent.viewFullUrl'));
- // Filter out offline_access from displayed scopes as it doesn't describe what can be accessed
- const displayedScopes = (scopes || []).filter(item => item.scope !== OFFLINE_ACCESS_SCOPE);
- const hasOfflineAccess = (scopes || []).some(item => item.scope === OFFLINE_ACCESS_SCOPE);
+ // Error states only apply to the public flow.
+ if (!hasContextCallbacks) {
+ const errorMessage = !oauthClientId
+ ? 'The client ID is missing.'
+ : !redirectUrl
+ ? 'The redirect URI is missing.'
+ : error
+ ? (error.message ?? 'Failed to load consent information.')
+ : undefined;
- function getRootDomain(): string {
- try {
- const { hostname } = new URL(redirectUrl);
- return hostname.split('.').slice(-2).join('.');
- } catch {
- return '';
+ if (errorMessage) {
+ return (
+
+
+
+ {errorMessage}
+
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+
+
+
+ );
}
}
+ if (ctx.enableOrgSelection && (!isMembershipsLoaded || userMemberships.isLoading)) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const actionUrl = clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId });
+ const forwardedParams = getForwardedParams();
+
+ // Accounts portal path delegates to context callbacks; public path lets the form submit natively.
+ const handleSubmit = (e: React.FormEvent) => {
+ if (!hasContextCallbacks) {
+ return;
+ }
+ e.preventDefault();
+ const submitter = (e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement | null;
+ if (submitter?.value === 'true') {
+ ctx.onAllow?.();
+ } else {
+ ctx.onDeny?.();
+ }
+ };
+
+ const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber;
+
+ const displayedScopes = scopes.filter(item => item.scope !== OFFLINE_ACCESS_SCOPE);
+ const hasOfflineAccess = scopes.some(item => item.scope === OFFLINE_ACCESS_SCOPE);
+
return (
-
-
-
- {/* both have avatars */}
- {oAuthApplicationLogoUrl && logoImageUrl && (
-
-
-
-
-
-
-
-
-
- )}
- {/* only OAuth app has an avatar */}
- {oAuthApplicationLogoUrl && !logoImageUrl && (
-
-
-
- ({
- position: 'absolute',
- bottom: `calc(${t.space.$3} * -1)`,
- insetInlineEnd: `calc(${t.space.$3} * -1)`,
- })}
- />
-
-
- )}
- {/* only Clerk application has an avatar */}
- {!oAuthApplicationLogoUrl && logoImageUrl && (
-
-
-
-
-
-
-
-
-
- )}
- {/* no avatars */}
- {!oAuthApplicationLogoUrl && !logoImageUrl && (
-
-
-
- )}
-
-
-
- ({
- textAlign: 'start',
- borderWidth: t.borderWidths.$normal,
- borderStyle: t.borderStyles.$solid,
- borderColor: t.colors.$borderAlpha100,
- borderRadius: t.radii.$lg,
- overflow: 'hidden',
- })}
- >
- ({
- padding: t.space.$3,
- background: common.mergedColorsBackground(
- colors.setAlpha(t.colors.$colorBackground, 1),
- t.colors.$neutralAlpha50,
- ),
- })}
- >
-
+
+
+
+ {/* both have avatars */}
+ {oauthApplicationLogoUrl && logoImageUrl && (
+
+
+
+
+
+
+
+
+
+ )}
+ {/* only OAuth app has an avatar */}
+ {oauthApplicationLogoUrl && !logoImageUrl && (
+
+
+
+ ({
+ position: 'absolute',
+ bottom: `calc(${t.space.$3} * -1)`,
+ insetInlineEnd: `calc(${t.space.$3} * -1)`,
+ })}
+ />
+
+
+ )}
+ {/* only Clerk application has an avatar */}
+ {!oauthApplicationLogoUrl && logoImageUrl && (
+
+
+
+
+
+
+
+
+
+ )}
+ {/* no avatars */}
+ {!oauthApplicationLogoUrl && !logoImageUrl && (
+
+
+
+ )}
+
+
-
- ({ margin: t.sizes.$none, padding: t.sizes.$none })}
- >
- {displayedScopes.map(item => (
- ({
- display: 'flex',
- alignItems: 'baseline',
- paddingInline: t.space.$3,
- paddingBlock: t.space.$2,
- borderTopWidth: t.borderWidths.$normal,
- borderTopStyle: t.borderStyles.$solid,
- borderTopColor: t.colors.$borderAlpha100,
- '&::before': {
- content: '""',
- display: 'inline-block',
- width: t.space.$1,
- height: t.space.$1,
- background: t.colors.$colorMutedForeground,
- borderRadius: t.radii.$circle,
- transform: 'translateY(-0.1875rem)',
- marginInlineEnd: t.space.$2,
- flexShrink: 0,
- },
+
+ {ctx.enableOrgSelection && orgOptions.length > 0 && effectiveOrg && (
+
+ )}
+
+
+
-
-
- ))}
-
-
-
-
+
+
+ {displayedScopes.map(item => (
+
+ {item.description || item.scope || ''}
+
+ ))}
+
+
+
+
+ setIsUriModalOpen(true)}
+ tooltipText={viewFullUrlText}
+ />
+
+
+
- Make sure that you trust {oAuthApplicationName} {''}
-
-
- setIsUriModalOpen(true)}
- >
- ({getRootDomain()})
-
-
-
-
- {''}. You may be sharing sensitive data with this site or app.
-
-
-
-
-
+
+
+ setIsUriModalOpen(true)}
+ tooltipText={viewFullUrlText}
+ />
+ {hasOfflineAccess && t(localizationKeys('oauthConsent.offlineAccessNotice'))}
+
+
+
+
+
+ {!hasContextCallbacks &&
+ forwardedParams.map(([key, value]) => (
+
-
- If you allow access, this app will redirect you to{' '}
-
-
- setIsUriModalOpen(true)}
- >
- {getRootDomain()}
-
-
-
-
- .{hasOfflineAccess && " You'll stay signed in until you sign out or revoke access."}
-
-
-
-
-
+ ))}
+ {!hasContextCallbacks && ctx.enableOrgSelection && effectiveOrg && (
+
+ )}
+
setIsUriModalOpen(true)}
onClose={() => setIsUriModalOpen(false)}
redirectUri={redirectUrl}
- oAuthApplicationName={oAuthApplicationName}
+ oauthApplicationName={oauthApplicationName}
/>
);
@@ -266,10 +337,10 @@ type RedirectUriModalProps = {
onClose: () => void;
isOpen: boolean;
redirectUri: string;
- oAuthApplicationName: string;
+ oauthApplicationName: string;
};
-function RedirectUriModal({ onOpen, onClose, isOpen, redirectUri, oAuthApplicationName }: RedirectUriModalProps) {
+function RedirectUriModal({ onOpen, onClose, isOpen, redirectUri, oauthApplicationName }: RedirectUriModalProps) {
if (!isOpen) {
return null;
}
@@ -282,9 +353,11 @@ function RedirectUriModal({ onOpen, onClose, isOpen, redirectUri, oAuthApplicati
-
+