From 306f496664507ec8fd78d9abdcae7fdbc5264d58 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 14 Apr 2026 17:53:01 -0700 Subject: [PATCH 1/9] docs: add accounts portal OAuth consent refactor spec --- ...-accounts-oauth-consent-refactor-design.md | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md new file mode 100644 index 00000000000..212aab20054 --- /dev/null +++ b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md @@ -0,0 +1,88 @@ +# Accounts Portal OAuth Consent Refactor Design + +> **For agentic workers:** This spec targets the accounts repo at `/Users/wobsoriano/Documents/projects/clerk/accounts`, not the javascript repo. + +**Goal:** Replace the accounts portal's manual OAuth consent implementation with the new `` component from `@clerk/nextjs/internal`, deleting all custom fetch utilities, hidden forms, and types in the process. + +**Context:** The `OAuthConsent` component (in `packages/ui`) now handles the full public path: it reads `client_id`, `redirect_uri`, and `scope` from the URL, fetches consent info via `clerk.oauthApplication.getConsentInfo`, renders scopes, and submits the consent form to `clerk.oauthApplication.buildConsentActionUrl`. The accounts portal's manual implementation duplicates all of this and can be deleted entirely. + +--- + +## Files Deleted + +- `components/oauth-consent/index.tsx` — manual fetch + `__internal_mountOAuthConsent` + hidden forms +- `utils/oauth-consent.ts` — `getConsentInfoForOAuth` FAPI fetch utility +- `types/OAuthConsent.ts` — `OAuthConsentInfo` type (only used by the above two files) + +## Files Modified + +### `types/index.ts` + +Remove the re-export of the deleted type file: + +```diff +- export * from './OAuthConsent'; + export * from './AccountPortalJSON'; + export * from './constants'; +``` + +`constants.ts` and `AccountPortalJSON.ts` are untouched — `DEV_BROWSER_JWT_MARKER` and `CLIENT_COOKIE_NAME` are still used elsewhere. + +### `pages/oauth-consent/[[...index]].tsx` + +Replace the entire file. `getServerSideProps` is removed — clerk-js handles `devBrowserJWT` and session auth automatically, and the new component reads all params from `window.location.search`. The referrer meta tag is kept (FAPI requires the `Origin` header on consent form POSTs). + +```tsx +import React from 'react'; +import Head from 'next/head'; +import { OAuthConsent } from '@clerk/nextjs/internal'; + +export default function ConsentPage(): JSX.Element { + return ( +
+
+ + + + +
+
+ ); +} +``` + +### `e2e/features/oauth-consent.test.ts` + +Error message text changes to match the new component's wording. Happy path assertion changes from hidden inputs (old hidden forms) to the Allow/Deny buttons the new component renders. + +| Old assertion | New assertion | +| --------------------------------------------------------- | ----------------------------------------- | +| `'Error: Authorization failed: The client ID is missing'` | `'The client ID is missing.'` | +| `'Error: Redirect URI not found'` | `'The redirect URI is missing.'` | +| `input[name="consented"][value="true"]` | `button[name="consented"][value="true"]` | +| `input[name="consented"][value="false"]` | `button[name="consented"][value="false"]` | + +### `e2e/unauthenticated/oauth-consent.test.ts` + +The old component returned an explicit `"Error: No session found"` div. The new component is wrapped with `withCoreUserGuard` which renders `null` for unauthenticated users. Update both tests to assert that the Allow button is not visible instead. + +```ts +// Before +await expect(page.getByText('Error: No session found')).toBeVisible(); + +// After +await expect(page.getByRole('button', { name: 'Allow' })).not.toBeVisible(); +``` + +--- + +## What Is Not Changing + +- `types/constants.ts` — stays, used by `utils/devBrowser.ts`, `utils/settings/environment.ts`, `utils/settings/accountPortal.ts` +- `utils/devBrowser.ts` — stays, unrelated to OAuth consent +- The page URL (`/oauth-consent`) and its Next.js route — unchanged +- The referrer meta tag — kept +- CSS class names (`pageContainer`, `componentContainer`) — unchanged From dc3157bb9a2adaf6c9598c5f39a6565d34d6b05e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 17 Apr 2026 09:59:29 -0700 Subject: [PATCH 2/9] fix(ui): prevent org-required dev dialog when organizations feature is disabled When the Organizations feature was disabled in the dashboard, the OAuth consent screen triggered the "Organizations feature required" dev dialog because useOrganizationList was called unconditionally whenever enableOrgSelection was set. Extracts the hook and OrgSelect into a child component (OrgSelection) that only mounts when organizationSettings.enabled is true, so the hook never fires on disabled instances. --- .../components/OAuthConsent/OAuthConsent.tsx | 80 +++++++++++-------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 7ecc740f1b4..5a6cde5e3cd 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -28,14 +28,14 @@ import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getR const OFFLINE_ACCESS_SCOPE = 'offline_access'; -function _OAuthConsent() { - const ctx = useOAuthConsentContext(); - const clerk = useClerk(); - const { user } = useUser(); - const { applicationName, logoImageUrl } = useEnvironment().displayConfig; - const [isUriModalOpen, setIsUriModalOpen] = useState(false); +type OrgSelectionProps = { + selectedOrg: string | null; + onChange: (value: string) => void; +}; + +function OrgSelection({ selectedOrg, onChange }: OrgSelectionProps) { const { isLoaded: isMembershipsLoaded, userMemberships } = useOrganizationList({ - userMemberships: ctx.enableOrgSelection ? { infinite: true } : undefined, + userMemberships: { infinite: true }, }); const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({ value: m.organization.id, @@ -43,9 +43,46 @@ function _OAuthConsent() { logoUrl: m.organization.imageUrl, })); - const [selectedOrg, setSelectedOrg] = useState(null); const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null; + if (!isMembershipsLoaded || userMemberships.isLoading) { + return ; + } + + if (orgOptions.length === 0 || !effectiveOrg) { + return null; + } + + return ( + <> + + + + ); +} + +function _OAuthConsent() { + const ctx = useOAuthConsentContext(); + const clerk = useClerk(); + const { user } = useUser(); + const { + displayConfig: { applicationName, logoImageUrl }, + organizationSettings, + } = useEnvironment(); + const [isUriModalOpen, setIsUriModalOpen] = useState(false); + + const [selectedOrg, setSelectedOrg] = useState(null); + // onAllow and onDeny are always provided as a pair by the accounts portal. const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny); @@ -114,17 +151,6 @@ function _OAuthConsent() { } } - if (ctx.enableOrgSelection && (!isMembershipsLoaded || userMemberships.isLoading)) { - return ( - - - - - - - ); - } - const actionUrl = clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId }); const forwardedParams = getForwardedParams(); @@ -221,13 +247,10 @@ function _OAuthConsent() { })} /> - {ctx.enableOrgSelection && orgOptions.length > 0 && effectiveOrg && ( - )} @@ -308,13 +331,6 @@ function _OAuthConsent() { value={value} /> ))} - {!hasContextCallbacks && ctx.enableOrgSelection && effectiveOrg && ( - - )} Date: Fri, 17 Apr 2026 10:00:10 -0700 Subject: [PATCH 3/9] remove doc --- ...-accounts-oauth-consent-refactor-design.md | 88 ------------------- 1 file changed, 88 deletions(-) delete mode 100644 docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md diff --git a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md b/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md deleted file mode 100644 index 212aab20054..00000000000 --- a/docs/superpowers/specs/2026-04-14-accounts-oauth-consent-refactor-design.md +++ /dev/null @@ -1,88 +0,0 @@ -# Accounts Portal OAuth Consent Refactor Design - -> **For agentic workers:** This spec targets the accounts repo at `/Users/wobsoriano/Documents/projects/clerk/accounts`, not the javascript repo. - -**Goal:** Replace the accounts portal's manual OAuth consent implementation with the new `` component from `@clerk/nextjs/internal`, deleting all custom fetch utilities, hidden forms, and types in the process. - -**Context:** The `OAuthConsent` component (in `packages/ui`) now handles the full public path: it reads `client_id`, `redirect_uri`, and `scope` from the URL, fetches consent info via `clerk.oauthApplication.getConsentInfo`, renders scopes, and submits the consent form to `clerk.oauthApplication.buildConsentActionUrl`. The accounts portal's manual implementation duplicates all of this and can be deleted entirely. - ---- - -## Files Deleted - -- `components/oauth-consent/index.tsx` — manual fetch + `__internal_mountOAuthConsent` + hidden forms -- `utils/oauth-consent.ts` — `getConsentInfoForOAuth` FAPI fetch utility -- `types/OAuthConsent.ts` — `OAuthConsentInfo` type (only used by the above two files) - -## Files Modified - -### `types/index.ts` - -Remove the re-export of the deleted type file: - -```diff -- export * from './OAuthConsent'; - export * from './AccountPortalJSON'; - export * from './constants'; -``` - -`constants.ts` and `AccountPortalJSON.ts` are untouched — `DEV_BROWSER_JWT_MARKER` and `CLIENT_COOKIE_NAME` are still used elsewhere. - -### `pages/oauth-consent/[[...index]].tsx` - -Replace the entire file. `getServerSideProps` is removed — clerk-js handles `devBrowserJWT` and session auth automatically, and the new component reads all params from `window.location.search`. The referrer meta tag is kept (FAPI requires the `Origin` header on consent form POSTs). - -```tsx -import React from 'react'; -import Head from 'next/head'; -import { OAuthConsent } from '@clerk/nextjs/internal'; - -export default function ConsentPage(): JSX.Element { - return ( -
-
- - - - -
-
- ); -} -``` - -### `e2e/features/oauth-consent.test.ts` - -Error message text changes to match the new component's wording. Happy path assertion changes from hidden inputs (old hidden forms) to the Allow/Deny buttons the new component renders. - -| Old assertion | New assertion | -| --------------------------------------------------------- | ----------------------------------------- | -| `'Error: Authorization failed: The client ID is missing'` | `'The client ID is missing.'` | -| `'Error: Redirect URI not found'` | `'The redirect URI is missing.'` | -| `input[name="consented"][value="true"]` | `button[name="consented"][value="true"]` | -| `input[name="consented"][value="false"]` | `button[name="consented"][value="false"]` | - -### `e2e/unauthenticated/oauth-consent.test.ts` - -The old component returned an explicit `"Error: No session found"` div. The new component is wrapped with `withCoreUserGuard` which renders `null` for unauthenticated users. Update both tests to assert that the Allow button is not visible instead. - -```ts -// Before -await expect(page.getByText('Error: No session found')).toBeVisible(); - -// After -await expect(page.getByRole('button', { name: 'Allow' })).not.toBeVisible(); -``` - ---- - -## What Is Not Changing - -- `types/constants.ts` — stays, used by `utils/devBrowser.ts`, `utils/settings/environment.ts`, `utils/settings/accountPortal.ts` -- `utils/devBrowser.ts` — stays, unrelated to OAuth consent -- The page URL (`/oauth-consent`) and its Next.js route — unchanged -- The referrer meta tag — kept -- CSS class names (`pageContainer`, `componentContainer`) — unchanged From 8c875a6f1cfcd979b1dbb7fa7fc4271cc790daba Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Fri, 17 Apr 2026 10:02:42 -0700 Subject: [PATCH 4/9] Update changeset to clarify UI fix --- .changeset/quiet-kangaroos-heal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/quiet-kangaroos-heal.md diff --git a/.changeset/quiet-kangaroos-heal.md b/.changeset/quiet-kangaroos-heal.md new file mode 100644 index 00000000000..83739e325a8 --- /dev/null +++ b/.changeset/quiet-kangaroos-heal.md @@ -0,0 +1,5 @@ +--- +"@clerk/ui": patch +--- + +Prevent org-required dev dialog when organizations feature is disabled. From fc9fc96cd9b7e8da8f06d7e3a4436a9ca41bd2b4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 17 Apr 2026 10:27:18 -0700 Subject: [PATCH 5/9] fix(ui): prevent org select pop-in by holding card behind loading state --- .../components/OAuthConsent/OAuthConsent.tsx | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 5a6cde5e3cd..a81a031dc7a 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,5 +1,5 @@ import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react'; -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts'; import { Box, Button, Flow, Grid, localizationKeys, Text, useLocalizations } from '@/ui/customizables'; @@ -31,9 +31,10 @@ const OFFLINE_ACCESS_SCOPE = 'offline_access'; type OrgSelectionProps = { selectedOrg: string | null; onChange: (value: string) => void; + onReady: () => void; }; -function OrgSelection({ selectedOrg, onChange }: OrgSelectionProps) { +function OrgSelection({ selectedOrg, onChange, onReady }: OrgSelectionProps) { const { isLoaded: isMembershipsLoaded, userMemberships } = useOrganizationList({ userMemberships: { infinite: true }, }); @@ -44,12 +45,15 @@ function OrgSelection({ selectedOrg, onChange }: OrgSelectionProps) { })); const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null; + const isReady = isMembershipsLoaded && !userMemberships.isLoading; - if (!isMembershipsLoaded || userMemberships.isLoading) { - return ; - } + useEffect(() => { + if (isReady) { + onReady(); + } + }, [isReady, onReady]); - if (orgOptions.length === 0 || !effectiveOrg) { + if (!isReady || orgOptions.length === 0 || !effectiveOrg) { return null; } @@ -80,8 +84,10 @@ function _OAuthConsent() { organizationSettings, } = useEnvironment(); const [isUriModalOpen, setIsUriModalOpen] = useState(false); - const [selectedOrg, setSelectedOrg] = useState(null); + const needsOrgLoading = ctx.enableOrgSelection && organizationSettings.enabled; + const [isOrgSelectionReady, setIsOrgSelectionReady] = useState(!needsOrgLoading); + const handleOrgSelectionReady = useCallback(() => setIsOrgSelectionReady(true), []); // onAllow and onDeny are always provided as a pair by the accounts portal. const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny); @@ -151,6 +157,24 @@ function _OAuthConsent() { } } + if (!isOrgSelectionReady) { + return ( + <> + + + + + + + + + ); + } + const actionUrl = clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId }); const forwardedParams = getForwardedParams(); @@ -251,6 +275,7 @@ function _OAuthConsent() { )} From 9c8a037729a3dd02c5b5094f903e2e7aa64806bf Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 17 Apr 2026 11:08:14 -0700 Subject: [PATCH 6/9] fix(ui): gate useOrganizationList behind organizationSettings.enabled to prevent spurious dev dialog When enableOrgSelection is passed but the Organizations feature is disabled in the dashboard, the component previously called useOrganizationList unconditionally which triggered the "Organizations feature required" dev dialog. The fix moves the hook into a child component (OrgSelectionGate) that only mounts when both flags are true, using a context provider pattern to surface the org select node and hidden input to their render sites without prop drilling. Tests updated to use f.withOrganizations() where required and a new assertion confirms the hook is not called when orgs are disabled. --- .../components/OAuthConsent/OAuthConsent.tsx | 134 +++++++++++------- .../__tests__/OAuthConsent.test.tsx | 44 ++++++ 2 files changed, 123 insertions(+), 55 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index a81a031dc7a..f4f41158406 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,5 +1,5 @@ import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react'; -import { useCallback, useEffect, useState } from 'react'; +import { createContext, useContext, useState } from 'react'; import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts'; import { Box, Button, Flow, Grid, localizationKeys, Text, useLocalizations } from '@/ui/customizables'; @@ -28,37 +28,54 @@ import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getR const OFFLINE_ACCESS_SCOPE = 'offline_access'; -type OrgSelectionProps = { +type OrgSelectionContextValue = { + orgSelectNode: React.ReactNode; + hiddenOrgInput: React.ReactNode; +}; + +const OrgSelectionContext = createContext(null); + +function OrgSelectSlot() { + const ctx = useContext(OrgSelectionContext); + return <>{ctx?.orgSelectNode ?? null}; +} + +function OrgHiddenInputSlot() { + const ctx = useContext(OrgSelectionContext); + return <>{ctx?.hiddenOrgInput ?? null}; +} + +type OrgSelectionGateProps = { selectedOrg: string | null; onChange: (value: string) => void; - onReady: () => void; + children: React.ReactNode; }; -function OrgSelection({ selectedOrg, onChange, onReady }: OrgSelectionProps) { - const { isLoaded: isMembershipsLoaded, userMemberships } = useOrganizationList({ +function OrgSelectionGate({ selectedOrg, onChange, children }: OrgSelectionGateProps) { + const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: { infinite: true }, }); + + if (!isLoaded || userMemberships.isLoading) { + return ( + + + + + + + ); + } + const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({ value: m.organization.id, label: m.organization.name, logoUrl: m.organization.imageUrl, })); - const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null; - const isReady = isMembershipsLoaded && !userMemberships.isLoading; - useEffect(() => { - if (isReady) { - onReady(); - } - }, [isReady, onReady]); - - if (!isReady || orgOptions.length === 0 || !effectiveOrg) { - return null; - } - - return ( - <> + const orgSelectNode = + orgOptions.length > 0 && effectiveOrg ? ( - - + ) : null; + + const hiddenOrgInput = effectiveOrg ? ( + + ) : null; + + return ( + {children} + ); +} + +type OrgSelectionProps = { + enabled: boolean; + selectedOrg: string | null; + onChange: (value: string) => void; + children: React.ReactNode; +}; + +function OrgSelection({ enabled, selectedOrg, onChange, children }: OrgSelectionProps) { + if (!enabled) { + return <>{children}; + } + return ( + + {children} + ); } @@ -85,9 +129,8 @@ function _OAuthConsent() { } = useEnvironment(); const [isUriModalOpen, setIsUriModalOpen] = useState(false); const [selectedOrg, setSelectedOrg] = useState(null); - const needsOrgLoading = ctx.enableOrgSelection && organizationSettings.enabled; - const [isOrgSelectionReady, setIsOrgSelectionReady] = useState(!needsOrgLoading); - const handleOrgSelectionReady = useCallback(() => setIsOrgSelectionReady(true), []); + + const orgSelectionEnabled = !!(ctx.enableOrgSelection && organizationSettings.enabled); // onAllow and onDeny are always provided as a pair by the accounts portal. const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny); @@ -157,24 +200,6 @@ function _OAuthConsent() { } } - if (!isOrgSelectionReady) { - return ( - <> - - - - - - - - - ); - } - const actionUrl = clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId }); const forwardedParams = getForwardedParams(); @@ -198,7 +223,11 @@ function _OAuthConsent() { const hasOfflineAccess = scopes.some(item => item.scope === OFFLINE_ACCESS_SCOPE); return ( - <> +
- {ctx.enableOrgSelection && organizationSettings.enabled && ( - - )} + ))} + {!hasContextCallbacks && } - +
); } diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index e871fd41fb1..c60d8cff5a8 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -371,9 +371,30 @@ describe('OAuthConsent', () => { }); }); + it('does not call useOrganizationList when organizations feature is disabled in the dashboard', async () => { + // SDK-63: even when enableOrgSelection is passed, if organizationSettings.enabled is false + // the hook must not be called to avoid the "Organizations feature required" dev dialog. + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['jane@example.com'] }); + // intentionally NOT calling f.withOrganizations() + }); + + props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); + mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + + const { queryByRole } = render(, { wrapper }); + + await waitFor(() => { + expect(queryByRole('combobox')).toBeNull(); + }); + + expect(useOrganizationList).not.toHaveBeenCalled(); + }); + it('renders the org selector when __internal_enableOrgSelection is true and orgs are loaded', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); + f.withOrganizations(); }); props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); @@ -397,9 +418,30 @@ describe('OAuthConsent', () => { }); }); + it('shows a loading card while org memberships are being fetched', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['jane@example.com'] }); + f.withOrganizations(); + }); + + props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); + mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + + vi.mocked(useOrganizationList).mockReturnValue({ + isLoaded: false, + userMemberships: { data: [], hasNextPage: false, fetchNext: vi.fn(), isLoading: true }, + } as any); + + const { queryByText } = render(, { wrapper }); + + // The consent card content must not be visible while memberships are loading. + expect(queryByText('Clerk CLI')).toBeNull(); + }); + it('includes a hidden organization_id input in the form when org selection is enabled and an org is selected', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); + f.withOrganizations(); }); props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); @@ -453,6 +495,7 @@ describe('OAuthConsent', () => { const fetchNext = vi.fn(); const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); + f.withOrganizations(); }); props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); @@ -475,6 +518,7 @@ describe('OAuthConsent', () => { const fetchNext = vi.fn(); const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); + f.withOrganizations(); }); props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); From f334994b077637c950b3376c82af0bb10332cd10 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 17 Apr 2026 12:12:51 -0700 Subject: [PATCH 7/9] fix(ui): use user.organizationMemberships instead of useOrganizationList for org select Replaces the useOrganizationList hook with user.organizationMemberships, which is populated synchronously from the user object and contains the complete list of memberships without pagination. This eliminates the loading gate, the double spinner, and the useAttemptToEnableOrganizations call that was triggering the dev dialog for instances with the Organizations feature disabled. The org select is now gated on both ctx.enableOrgSelection and organizationSettings.enabled, and renders immediately. --- .../components/OAuthConsent/OAuthConsent.tsx | 131 +++----------- .../src/components/OAuthConsent/OrgSelect.tsx | 19 +- .../__tests__/OAuthConsent.test.tsx | 164 +++--------------- 3 files changed, 52 insertions(+), 262 deletions(-) diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index f4f41158406..394fd953833 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -1,5 +1,5 @@ -import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react'; -import { createContext, useContext, useState } from 'react'; +import { useClerk, useOAuthConsent, useUser } from '@clerk/shared/react'; +import { useState } from 'react'; import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts'; import { Box, Button, Flow, Grid, localizationKeys, Text, useLocalizations } from '@/ui/customizables'; @@ -22,103 +22,11 @@ import { ListGroupItemLabel, } from './ListGroup'; import { LogoGroup, LogoGroupIcon, LogoGroupItem, LogoGroupSeparator } from './LogoGroup'; -import type { OrgOption } from './OrgSelect'; import { OrgSelect } from './OrgSelect'; import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getRedirectUriFromSearch } from './utils'; const OFFLINE_ACCESS_SCOPE = 'offline_access'; -type OrgSelectionContextValue = { - orgSelectNode: React.ReactNode; - hiddenOrgInput: React.ReactNode; -}; - -const OrgSelectionContext = createContext(null); - -function OrgSelectSlot() { - const ctx = useContext(OrgSelectionContext); - return <>{ctx?.orgSelectNode ?? null}; -} - -function OrgHiddenInputSlot() { - const ctx = useContext(OrgSelectionContext); - return <>{ctx?.hiddenOrgInput ?? null}; -} - -type OrgSelectionGateProps = { - selectedOrg: string | null; - onChange: (value: string) => void; - children: React.ReactNode; -}; - -function OrgSelectionGate({ selectedOrg, onChange, children }: OrgSelectionGateProps) { - const { isLoaded, userMemberships } = useOrganizationList({ - userMemberships: { infinite: true }, - }); - - if (!isLoaded || userMemberships.isLoading) { - return ( - - - - - - - ); - } - - const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({ - value: m.organization.id, - label: m.organization.name, - logoUrl: m.organization.imageUrl, - })); - const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null; - - const orgSelectNode = - orgOptions.length > 0 && effectiveOrg ? ( - - ) : null; - - const hiddenOrgInput = effectiveOrg ? ( - - ) : null; - - return ( - {children} - ); -} - -type OrgSelectionProps = { - enabled: boolean; - selectedOrg: string | null; - onChange: (value: string) => void; - children: React.ReactNode; -}; - -function OrgSelection({ enabled, selectedOrg, onChange, children }: OrgSelectionProps) { - if (!enabled) { - return <>{children}; - } - return ( - - {children} - - ); -} - function _OAuthConsent() { const ctx = useOAuthConsentContext(); const clerk = useClerk(); @@ -128,9 +36,18 @@ function _OAuthConsent() { organizationSettings, } = useEnvironment(); const [isUriModalOpen, setIsUriModalOpen] = useState(false); - const [selectedOrg, setSelectedOrg] = useState(null); const orgSelectionEnabled = !!(ctx.enableOrgSelection && organizationSettings.enabled); + const orgOptions = orgSelectionEnabled + ? (user?.organizationMemberships ?? []).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); @@ -223,11 +140,7 @@ function _OAuthConsent() { const hasOfflineAccess = scopes.some(item => item.scope === OFFLINE_ACCESS_SCOPE); return ( - + <>
- + {orgSelectionEnabled && orgOptions.length > 0 && effectiveOrg && ( + + )} ))} - {!hasContextCallbacks && } + {!hasContextCallbacks && orgSelectionEnabled && effectiveOrg && ( + + )} -
+ ); } diff --git a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx index 5579f3c3de0..aac8314e78b 100644 --- a/packages/ui/src/components/OAuthConsent/OrgSelect.tsx +++ b/packages/ui/src/components/OAuthConsent/OrgSelect.tsx @@ -1,9 +1,7 @@ import { useRef } from 'react'; -import { InfiniteListSpinner } from '@/ui/common/InfiniteListSpinner'; import { Box, Icon, Image, Text } from '@/ui/customizables'; import { Select, SelectButton, SelectOptionList } from '@/ui/elements/Select'; -import { useInView } from '@/ui/hooks/useInView'; import { Check } from '@/ui/icons'; import { common } from '@/ui/styledSystem'; @@ -17,21 +15,11 @@ type OrgSelectProps = { options: OrgOption[]; value: string | null; onChange: (value: string) => void; - hasMore?: boolean; - onLoadMore?: () => void; }; -export function OrgSelect({ options, value, onChange, hasMore, onLoadMore }: OrgSelectProps) { +export function OrgSelect({ options, value, onChange }: OrgSelectProps) { const buttonRef = useRef(null); const selected = options.find(option => option.value === value); - const { ref: loadMoreRef } = useInView({ - threshold: 0, - onChange: inView => { - if (inView && hasMore) { - onLoadMore?.(); - } - }, - }); return ( ); } diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index c60d8cff5a8..c641e0c69ba 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -1,4 +1,3 @@ -import { useOrganizationList } from '@clerk/shared/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; @@ -6,30 +5,6 @@ import { render, waitFor } from '@/test/utils'; import { OAuthConsent } from '../OAuthConsent'; -// Captures the onChange injected into SelectOptionList's useInView so tests -// can simulate "user scrolled to the bottom of the org dropdown". -let capturedLoadMoreOnChange: ((inView: boolean) => void) | undefined; - -// Default: useOrganizationList returns no memberships and is not loaded. -// Individual tests override this mock to inject org data. -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await (importOriginal as () => Promise>)(); - return { - ...actual, - useOrganizationList: vi.fn().mockReturnValue({ - isLoaded: false, - userMemberships: { data: [], hasNextPage: false, fetchNext: vi.fn(), isLoading: false }, - }), - }; -}); - -vi.mock('@/ui/hooks/useInView', () => ({ - useInView: vi.fn().mockImplementation(({ onChange }: { onChange?: (inView: boolean) => void }) => { - capturedLoadMoreOnChange = onChange; - return { ref: vi.fn(), inView: false }; - }), -})); - const { createFixtures } = bindCreateFixtures('OAuthConsent'); const fakeConsentInfo = { @@ -66,7 +41,6 @@ describe('OAuthConsent', () => { const originalLocation = window.location; beforeEach(() => { - capturedLoadMoreOnChange = undefined; Object.defineProperty(window, 'location', { configurable: true, writable: true, @@ -347,23 +321,16 @@ describe('OAuthConsent', () => { describe('org selection', () => { it('does not render the org selector when __internal_enableOrgSelection is not set', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { - f.withUser({ email_addresses: ['jane@example.com'] }); + f.withUser({ + email_addresses: ['jane@example.com'], + organization_memberships: [{ id: 'org_1', name: 'Acme Corp' }], + }); + f.withOrganizations(); }); props.setProps({ componentName: 'OAuthConsent' } as any); mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); - vi.mocked(useOrganizationList).mockReturnValue({ - isLoaded: true, - userMemberships: { - data: [ - { - organization: { id: 'org_1', name: 'Acme Corp', imageUrl: 'https://img.clerk.com/static/clerk.png' }, - }, - ], - }, - } as any); - const { queryByRole } = render(, { wrapper }); await waitFor(() => { @@ -371,11 +338,14 @@ describe('OAuthConsent', () => { }); }); - it('does not call useOrganizationList when organizations feature is disabled in the dashboard', async () => { - // SDK-63: even when enableOrgSelection is passed, if organizationSettings.enabled is false - // the hook must not be called to avoid the "Organizations feature required" dev dialog. + it('does not render the org selector when organizations feature is disabled in the dashboard', async () => { + // SDK-63: enableOrgSelection is set but organizationSettings.enabled is false, + // so no org select and no useOrganizationList call. const { wrapper, fixtures, props } = await createFixtures(f => { - f.withUser({ email_addresses: ['jane@example.com'] }); + f.withUser({ + email_addresses: ['jane@example.com'], + organization_memberships: [{ id: 'org_1', name: 'Acme Corp' }], + }); // intentionally NOT calling f.withOrganizations() }); @@ -387,30 +357,20 @@ describe('OAuthConsent', () => { await waitFor(() => { expect(queryByRole('combobox')).toBeNull(); }); - - expect(useOrganizationList).not.toHaveBeenCalled(); }); - it('renders the org selector when __internal_enableOrgSelection is true and orgs are loaded', async () => { + it('renders the org selector when __internal_enableOrgSelection is true and user has memberships', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { - f.withUser({ email_addresses: ['jane@example.com'] }); + f.withUser({ + email_addresses: ['jane@example.com'], + organization_memberships: [{ id: 'org_1', name: 'Acme Corp' }], + }); f.withOrganizations(); }); props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); - vi.mocked(useOrganizationList).mockReturnValue({ - isLoaded: true, - userMemberships: { - data: [ - { - organization: { id: 'org_1', name: 'Acme Corp', imageUrl: 'https://img.clerk.com/static/clerk.png' }, - }, - ], - }, - } as any); - const { getByText } = render(, { wrapper }); await waitFor(() => { @@ -418,46 +378,18 @@ describe('OAuthConsent', () => { }); }); - it('shows a loading card while org memberships are being fetched', async () => { - const { wrapper, fixtures, props } = await createFixtures(f => { - f.withUser({ email_addresses: ['jane@example.com'] }); - f.withOrganizations(); - }); - - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); - - vi.mocked(useOrganizationList).mockReturnValue({ - isLoaded: false, - userMemberships: { data: [], hasNextPage: false, fetchNext: vi.fn(), isLoading: true }, - } as any); - - const { queryByText } = render(, { wrapper }); - - // The consent card content must not be visible while memberships are loading. - expect(queryByText('Clerk CLI')).toBeNull(); - }); - - it('includes a hidden organization_id input in the form when org selection is enabled and an org is selected', async () => { + it('includes a hidden organization_id input when org selection is enabled and user has memberships', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { - f.withUser({ email_addresses: ['jane@example.com'] }); + f.withUser({ + email_addresses: ['jane@example.com'], + organization_memberships: [{ id: 'org_1', name: 'Acme Corp' }], + }); f.withOrganizations(); }); props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); - vi.mocked(useOrganizationList).mockReturnValue({ - isLoaded: true, - userMemberships: { - data: [ - { - organization: { id: 'org_1', name: 'Acme Corp', imageUrl: 'https://img.clerk.com/static/clerk.png' }, - }, - ], - }, - } as any); - const { baseElement } = render(, { wrapper }); await waitFor(() => { @@ -484,56 +416,4 @@ describe('OAuthConsent', () => { }); }); }); - - describe('org selection — infinite scroll', () => { - const twoOrgs = [ - { organization: { id: 'org_1', name: 'Acme Corp', imageUrl: 'https://img.clerk.com/static/clerk.png' } }, - { organization: { id: 'org_2', name: 'Beta Inc', imageUrl: 'https://img.clerk.com/static/beta.png' } }, - ]; - - it('calls fetchNext when the load-more sentinel enters view and more pages are available', async () => { - const fetchNext = vi.fn(); - const { wrapper, fixtures, props } = await createFixtures(f => { - f.withUser({ email_addresses: ['jane@example.com'] }); - f.withOrganizations(); - }); - - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); - - vi.mocked(useOrganizationList).mockReturnValue({ - isLoaded: true, - userMemberships: { data: twoOrgs, hasNextPage: true, fetchNext, isLoading: false }, - } as any); - - render(, { wrapper }); - - await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined()); - - capturedLoadMoreOnChange!(true); - expect(fetchNext).toHaveBeenCalledTimes(1); - }); - - it('does not call fetchNext when hasNextPage is false', async () => { - const fetchNext = vi.fn(); - const { wrapper, fixtures, props } = await createFixtures(f => { - f.withUser({ email_addresses: ['jane@example.com'] }); - f.withOrganizations(); - }); - - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); - - vi.mocked(useOrganizationList).mockReturnValue({ - isLoaded: true, - userMemberships: { data: twoOrgs, hasNextPage: false, fetchNext, isLoading: false }, - } as any); - - render(, { wrapper }); - - await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined()); - capturedLoadMoreOnChange!(true); - expect(fetchNext).not.toHaveBeenCalled(); - }); - }); }); From 4920531ddff193401332d88d4650b2da77e6d67e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 17 Apr 2026 12:16:36 -0700 Subject: [PATCH 8/9] chore: update changeset description --- .changeset/quiet-kangaroos-heal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-kangaroos-heal.md b/.changeset/quiet-kangaroos-heal.md index 83739e325a8..fe20a9607d9 100644 --- a/.changeset/quiet-kangaroos-heal.md +++ b/.changeset/quiet-kangaroos-heal.md @@ -2,4 +2,4 @@ "@clerk/ui": patch --- -Prevent org-required dev dialog when organizations feature is disabled. +Fix org select in OAuth consent triggering the "Organizations feature required" dev dialog on instances with the Organizations feature disabled. Replaces `useOrganizationList` with `user.organizationMemberships`, which is populated synchronously with the user object and requires no loading state. From a574c2b0cfdfb16ec3348e72deff5865b5c792f0 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 17 Apr 2026 12:17:02 -0700 Subject: [PATCH 9/9] chore: update changeset description --- .changeset/quiet-kangaroos-heal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-kangaroos-heal.md b/.changeset/quiet-kangaroos-heal.md index fe20a9607d9..bc526d86bf0 100644 --- a/.changeset/quiet-kangaroos-heal.md +++ b/.changeset/quiet-kangaroos-heal.md @@ -2,4 +2,4 @@ "@clerk/ui": patch --- -Fix org select in OAuth consent triggering the "Organizations feature required" dev dialog on instances with the Organizations feature disabled. Replaces `useOrganizationList` with `user.organizationMemberships`, which is populated synchronously with the user object and requires no loading state. +Use `user.organizationMemberships` from the already-loaded user object to populate the org select in the OAuth consent screen, avoiding a redundant memberships fetch.