Skip to content

Commit 59400e3

Browse files
authored
feat(ui): Show OAuthConsent org selector from user:org:read scope (#8415)
1 parent 90beaeb commit 59400e3

3 files changed

Lines changed: 95 additions & 29 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': minor
3+
---
4+
5+
Render OAuthConsent organization selector from `user:org:read` scope.

packages/ui/src/components/OAuthConsent/OAuthConsent.tsx

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { OrgSelect } from './OrgSelect';
2626
import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getRedirectUriFromSearch } from './utils';
2727

2828
const OFFLINE_ACCESS_SCOPE = 'offline_access';
29+
const USER_ORG_READ_SCOPE = 'user:org:read';
2930

3031
function _OAuthConsent() {
3132
const ctx = useOAuthConsentContext();
@@ -37,20 +38,7 @@ function _OAuthConsent() {
3738
} = useEnvironment();
3839
const [isUriModalOpen, setIsUriModalOpen] = useState(false);
3940

40-
const orgSelectionEnabled = !!(ctx.enableOrgSelection && organizationSettings.enabled);
41-
const orgOptions = orgSelectionEnabled
42-
? (user?.organizationMemberships ?? []).map(m => ({
43-
value: m.organization.id,
44-
label: m.organization.name,
45-
logoUrl: m.organization.imageUrl,
46-
}))
47-
: [];
48-
49-
const lastActiveOrgId = clerk.session?.lastActiveOrganizationId;
50-
const defaultOrg = orgOptions.find(o => o.value === lastActiveOrgId)?.value ?? orgOptions[0]?.value ?? null;
51-
5241
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
53-
const effectiveOrg = selectedOrg ?? defaultOrg;
5442

5543
// onAllow and onDeny are always provided as a pair by the accounts portal.
5644
const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny);
@@ -83,6 +71,19 @@ function _OAuthConsent() {
8371
const oauthApplicationUrl = ctx.oauthApplicationUrl ?? data?.oauthApplicationUrl;
8472
const redirectUrl = ctx.redirectUrl ?? getRedirectUriFromSearch();
8573

74+
const hasOrgReadScope = scopes.some(s => s.scope === USER_ORG_READ_SCOPE);
75+
const orgSelectionEnabled = !!((hasOrgReadScope || ctx.enableOrgSelection) && organizationSettings.enabled);
76+
const orgOptions = orgSelectionEnabled
77+
? (user?.organizationMemberships ?? []).map(m => ({
78+
value: m.organization.id,
79+
label: m.organization.name,
80+
logoUrl: m.organization.imageUrl,
81+
}))
82+
: [];
83+
const lastActiveOrgId = clerk.session?.lastActiveOrganizationId;
84+
const defaultOrg = orgOptions.find(o => o.value === lastActiveOrgId)?.value ?? orgOptions[0]?.value ?? null;
85+
const effectiveOrg = selectedOrg ?? defaultOrg;
86+
8687
const { t } = useLocalizations();
8788
const domainAction = getRedirectDisplay(redirectUrl);
8889
const viewFullUrlText = t(localizationKeys('oauthConsent.viewFullUrl'));
@@ -139,7 +140,7 @@ function _OAuthConsent() {
139140

140141
const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber;
141142

142-
const displayedScopes = scopes.filter(item => item.scope !== OFFLINE_ACCESS_SCOPE);
143+
const displayedScopes = scopes.filter(item => ![OFFLINE_ACCESS_SCOPE, USER_ORG_READ_SCOPE].includes(item.scope));
143144
const hasOfflineAccess = scopes.some(item => item.scope === OFFLINE_ACCESS_SCOPE);
144145

145146
return (

packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ const fakeConsentInfo = {
1919
],
2020
};
2121

22+
const fakeConsentInfoWithOrgScope = {
23+
...fakeConsentInfo,
24+
scopes: [
25+
...fakeConsentInfo.scopes,
26+
{ scope: 'user:org:read', description: 'Access your organizations', requiresConsent: true },
27+
],
28+
};
29+
2230
/**
2331
* `oauthApplication` is a getter on the Clerk prototype and cannot be assigned
2432
* directly. Use Object.defineProperty to replace it with a configurable mock.
@@ -319,7 +327,7 @@ describe('OAuthConsent', () => {
319327
});
320328

321329
describe('org selection', () => {
322-
it('does not render the org selector when __internal_enableOrgSelection is not set', async () => {
330+
it('does not render the org selector when user:org:read scope is absent', async () => {
323331
const { wrapper, fixtures, props } = await createFixtures(f => {
324332
f.withUser({
325333
email_addresses: ['jane@example.com'],
@@ -339,7 +347,7 @@ describe('OAuthConsent', () => {
339347
});
340348

341349
it('does not render the org selector when organizations feature is disabled in the dashboard', async () => {
342-
// SDK-63: enableOrgSelection is set but organizationSettings.enabled is false,
350+
// SDK-63: user:org:read scope is present but organizationSettings.enabled is false,
343351
// so no org select and no useOrganizationList call.
344352
const { wrapper, fixtures, props } = await createFixtures(f => {
345353
f.withUser({
@@ -349,8 +357,10 @@ describe('OAuthConsent', () => {
349357
// intentionally NOT calling f.withOrganizations()
350358
});
351359

352-
props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
353-
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
360+
props.setProps({ componentName: 'OAuthConsent' } as any);
361+
mockOAuthApplication(fixtures.clerk, {
362+
getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope),
363+
});
354364

355365
const { queryByRole } = render(<OAuthConsent />, { wrapper });
356366

@@ -359,7 +369,7 @@ describe('OAuthConsent', () => {
359369
});
360370
});
361371

362-
it('renders the org selector when __internal_enableOrgSelection is true and user has memberships', async () => {
372+
it('renders the org selector when user:org:read scope is present and user has memberships', async () => {
363373
const { wrapper, fixtures, props } = await createFixtures(f => {
364374
f.withUser({
365375
email_addresses: ['jane@example.com'],
@@ -368,8 +378,10 @@ describe('OAuthConsent', () => {
368378
f.withOrganizations();
369379
});
370380

371-
props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
372-
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
381+
props.setProps({ componentName: 'OAuthConsent' } as any);
382+
mockOAuthApplication(fixtures.clerk, {
383+
getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope),
384+
});
373385

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

@@ -378,7 +390,7 @@ describe('OAuthConsent', () => {
378390
});
379391
});
380392

381-
it('includes a hidden organization_id input when org selection is enabled and user has memberships', async () => {
393+
it('renders the org selector when __internal_enableOrgSelection is true (fallback for existing apps)', async () => {
382394
const { wrapper, fixtures, props } = await createFixtures(f => {
383395
f.withUser({
384396
email_addresses: ['jane@example.com'],
@@ -390,6 +402,48 @@ describe('OAuthConsent', () => {
390402
props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
391403
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
392404

405+
const { getByText } = render(<OAuthConsent />, { wrapper });
406+
407+
await waitFor(() => {
408+
expect(getByText('Acme Corp')).toBeVisible();
409+
});
410+
});
411+
412+
it('does not display user:org:read in the scopes list', async () => {
413+
const { wrapper, fixtures, props } = await createFixtures(f => {
414+
f.withUser({
415+
email_addresses: ['jane@example.com'],
416+
organization_memberships: [{ id: 'org_1', name: 'Acme Corp' }],
417+
});
418+
f.withOrganizations();
419+
});
420+
421+
props.setProps({ componentName: 'OAuthConsent' } as any);
422+
mockOAuthApplication(fixtures.clerk, {
423+
getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope),
424+
});
425+
426+
const { queryByText } = render(<OAuthConsent />, { wrapper });
427+
428+
await waitFor(() => {
429+
expect(queryByText('Access your organizations')).toBeNull();
430+
});
431+
});
432+
433+
it('includes a hidden organization_id input when user:org:read scope is present and user has memberships', async () => {
434+
const { wrapper, fixtures, props } = await createFixtures(f => {
435+
f.withUser({
436+
email_addresses: ['jane@example.com'],
437+
organization_memberships: [{ id: 'org_1', name: 'Acme Corp' }],
438+
});
439+
f.withOrganizations();
440+
});
441+
442+
props.setProps({ componentName: 'OAuthConsent' } as any);
443+
mockOAuthApplication(fixtures.clerk, {
444+
getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope),
445+
});
446+
393447
const { baseElement } = render(<OAuthConsent />, { wrapper });
394448

395449
await waitFor(() => {
@@ -400,7 +454,7 @@ describe('OAuthConsent', () => {
400454
});
401455
});
402456

403-
it('does not include organization_id in the form when org selection is disabled', async () => {
457+
it('does not include organization_id in the form when user:org:read scope is absent', async () => {
404458
const { wrapper, fixtures, props } = await createFixtures(f => {
405459
f.withUser({ email_addresses: ['jane@example.com'] });
406460
});
@@ -431,8 +485,10 @@ describe('OAuthConsent', () => {
431485

432486
fixtures.clerk.session.lastActiveOrganizationId = 'org_3';
433487

434-
props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
435-
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
488+
props.setProps({ componentName: 'OAuthConsent' } as any);
489+
mockOAuthApplication(fixtures.clerk, {
490+
getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope),
491+
});
436492

437493
const { baseElement } = render(<OAuthConsent />, { wrapper });
438494

@@ -458,8 +514,10 @@ describe('OAuthConsent', () => {
458514

459515
fixtures.clerk.session.lastActiveOrganizationId = 'org_deleted';
460516

461-
props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
462-
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
517+
props.setProps({ componentName: 'OAuthConsent' } as any);
518+
mockOAuthApplication(fixtures.clerk, {
519+
getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope),
520+
});
463521

464522
const { baseElement } = render(<OAuthConsent />, { wrapper });
465523

@@ -485,8 +543,10 @@ describe('OAuthConsent', () => {
485543

486544
fixtures.clerk.session.lastActiveOrganizationId = null;
487545

488-
props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any);
489-
mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) });
546+
props.setProps({ componentName: 'OAuthConsent' } as any);
547+
mockOAuthApplication(fixtures.clerk, {
548+
getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope),
549+
});
490550

491551
const { baseElement } = render(<OAuthConsent />, { wrapper });
492552

0 commit comments

Comments
 (0)