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. - - - -