Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/quiet-kangaroos-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/ui": patch
---

Use `user.organizationMemberships` from the already-loaded user object to populate the org select in the OAuth consent screen, avoiding a redundant memberships fetch.
42 changes: 16 additions & 26 deletions packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useClerk, useOAuthConsent, useOrganizationList, useUser } from '@clerk/shared/react';
import { useClerk, useOAuthConsent, useUser } from '@clerk/shared/react';
import { useState } from 'react';

import { useEnvironment, useOAuthConsentContext, withCoreUserGuard } from '@/ui/contexts';
Expand All @@ -22,7 +22,6 @@ 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';

Expand All @@ -32,16 +31,20 @@ function _OAuthConsent() {
const ctx = useOAuthConsentContext();
const clerk = useClerk();
const { user } = useUser();
const { applicationName, logoImageUrl } = useEnvironment().displayConfig;
const {
displayConfig: { applicationName, logoImageUrl },
organizationSettings,
} = useEnvironment();
const [isUriModalOpen, setIsUriModalOpen] = useState(false);
const { isLoaded: isMembershipsLoaded, userMemberships } = useOrganizationList({
userMemberships: ctx.enableOrgSelection ? { infinite: true } : undefined,
});
const orgOptions: OrgOption[] = (userMemberships.data ?? []).map(m => ({
value: m.organization.id,
label: m.organization.name,
logoUrl: m.organization.imageUrl,
}));

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<string | null>(null);
const effectiveOrg = selectedOrg ?? orgOptions[0]?.value ?? null;
Expand Down Expand Up @@ -114,17 +117,6 @@ function _OAuthConsent() {
}
}

if (ctx.enableOrgSelection && (!isMembershipsLoaded || userMemberships.isLoading)) {
return (
<Card.Root>
<Card.Content>
<LoadingCardContainer />
</Card.Content>
<Card.Footer />
</Card.Root>
);
}

const actionUrl = clerk.oauthApplication.buildConsentActionUrl({ clientId: oauthClientId });
const forwardedParams = getForwardedParams();

Expand Down Expand Up @@ -221,13 +213,11 @@ function _OAuthConsent() {
})}
/>
</Header.Root>
{ctx.enableOrgSelection && orgOptions.length > 0 && effectiveOrg && (
{orgSelectionEnabled && orgOptions.length > 0 && effectiveOrg && (
<OrgSelect
options={orgOptions}
value={effectiveOrg}
onChange={setSelectedOrg}
hasMore={userMemberships.hasNextPage}
onLoadMore={userMemberships.fetchNext}
/>
)}
<ListGroup>
Expand Down Expand Up @@ -308,7 +298,7 @@ function _OAuthConsent() {
value={value}
/>
))}
{!hasContextCallbacks && ctx.enableOrgSelection && effectiveOrg && (
{!hasContextCallbacks && orgSelectionEnabled && effectiveOrg && (
<input
type='hidden'
name='organization_id'
Expand Down
19 changes: 2 additions & 17 deletions packages/ui/src/components/OAuthConsent/OrgSelect.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<HTMLButtonElement>(null);
const selected = options.find(option => option.value === value);
const { ref: loadMoreRef } = useInView({
threshold: 0,
onChange: inView => {
if (inView && hasMore) {
onLoadMore?.();
}
},
});

return (
<Select
Expand Down Expand Up @@ -113,10 +101,7 @@ export function OrgSelect({ options, value, onChange, hasMore, onLoadMore }: Org
{selected?.label || 'Select an option'}
</Text>
</SelectButton>
<SelectOptionList
footer={hasMore ? <InfiniteListSpinner ref={loadMoreRef} /> : null}
onReachEnd={hasMore ? onLoadMore : undefined}
/>
<SelectOptionList />
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will remove the added infinite scrolling props to this shared element in a follow up PR

</Select>
);
}
150 changes: 37 additions & 113 deletions packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,10 @@
import { useOrganizationList } from '@clerk/shared/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { bindCreateFixtures } from '@/test/create-fixtures';
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<Record<string, unknown>>)();
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 = {
Expand Down Expand Up @@ -66,7 +41,6 @@ describe('OAuthConsent', () => {
const originalLocation = window.location;

beforeEach(() => {
capturedLoadMoreOnChange = undefined;
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
Expand Down Expand Up @@ -347,48 +321,55 @@ 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(<OAuthConsent />, { wrapper });

await waitFor(() => {
expect(queryByRole('combobox')).toBeNull();
});
});

it('renders the org selector when __internal_enableOrgSelection is true and orgs are loaded', async () => {
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()
});

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 { queryByRole } = render(<OAuthConsent />, { wrapper });

await waitFor(() => {
expect(queryByRole('combobox')).toBeNull();
});
});

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'],
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) });

const { getByText } = render(<OAuthConsent />, { wrapper });

Expand All @@ -397,25 +378,18 @@ describe('OAuthConsent', () => {
});
});

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(<OAuthConsent />, { wrapper });

await waitFor(() => {
Expand All @@ -442,54 +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'] });
});

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(<OAuthConsent />, { 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'] });
});

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(<OAuthConsent />, { wrapper });

await waitFor(() => expect(capturedLoadMoreOnChange).toBeDefined());
capturedLoadMoreOnChange!(true);
expect(fetchNext).not.toHaveBeenCalled();
});
});
});
Loading