diff --git a/.changeset/fluffy-readers-strive.md b/.changeset/fluffy-readers-strive.md new file mode 100644 index 00000000000..f3945a4530e --- /dev/null +++ b/.changeset/fluffy-readers-strive.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Render OAuthConsent organization selector from `user:org:read` scope. diff --git a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx index 21d5fac767a..5070c5745c3 100644 --- a/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx +++ b/packages/ui/src/components/OAuthConsent/OAuthConsent.tsx @@ -26,6 +26,7 @@ import { OrgSelect } from './OrgSelect'; import { getForwardedParams, getOAuthConsentFromSearch, getRedirectDisplay, getRedirectUriFromSearch } from './utils'; const OFFLINE_ACCESS_SCOPE = 'offline_access'; +const USER_ORG_READ_SCOPE = 'user:org:read'; function _OAuthConsent() { const ctx = useOAuthConsentContext(); @@ -37,20 +38,7 @@ function _OAuthConsent() { } = useEnvironment(); const [isUriModalOpen, setIsUriModalOpen] = useState(false); - 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 lastActiveOrgId = clerk.session?.lastActiveOrganizationId; - const defaultOrg = orgOptions.find(o => o.value === lastActiveOrgId)?.value ?? orgOptions[0]?.value ?? null; - const [selectedOrg, setSelectedOrg] = useState(null); - const effectiveOrg = selectedOrg ?? defaultOrg; // onAllow and onDeny are always provided as a pair by the accounts portal. const hasContextCallbacks = Boolean(ctx.onAllow || ctx.onDeny); @@ -83,6 +71,19 @@ function _OAuthConsent() { const oauthApplicationUrl = ctx.oauthApplicationUrl ?? data?.oauthApplicationUrl; const redirectUrl = ctx.redirectUrl ?? getRedirectUriFromSearch(); + const hasOrgReadScope = scopes.some(s => s.scope === USER_ORG_READ_SCOPE); + const orgSelectionEnabled = !!((hasOrgReadScope || ctx.enableOrgSelection) && organizationSettings.enabled); + const orgOptions = orgSelectionEnabled + ? (user?.organizationMemberships ?? []).map(m => ({ + value: m.organization.id, + label: m.organization.name, + logoUrl: m.organization.imageUrl, + })) + : []; + const lastActiveOrgId = clerk.session?.lastActiveOrganizationId; + const defaultOrg = orgOptions.find(o => o.value === lastActiveOrgId)?.value ?? orgOptions[0]?.value ?? null; + const effectiveOrg = selectedOrg ?? defaultOrg; + const { t } = useLocalizations(); const domainAction = getRedirectDisplay(redirectUrl); const viewFullUrlText = t(localizationKeys('oauthConsent.viewFullUrl')); @@ -139,7 +140,7 @@ function _OAuthConsent() { const primaryIdentifier = user?.primaryEmailAddress?.emailAddress || user?.primaryPhoneNumber?.phoneNumber; - const displayedScopes = scopes.filter(item => item.scope !== OFFLINE_ACCESS_SCOPE); + const displayedScopes = scopes.filter(item => ![OFFLINE_ACCESS_SCOPE, USER_ORG_READ_SCOPE].includes(item.scope)); const hasOfflineAccess = scopes.some(item => item.scope === OFFLINE_ACCESS_SCOPE); return ( diff --git a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx index dce4347d6fd..85a94478262 100644 --- a/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx +++ b/packages/ui/src/components/OAuthConsent/__tests__/OAuthConsent.test.tsx @@ -19,6 +19,14 @@ const fakeConsentInfo = { ], }; +const fakeConsentInfoWithOrgScope = { + ...fakeConsentInfo, + scopes: [ + ...fakeConsentInfo.scopes, + { scope: 'user:org:read', description: 'Access your organizations', requiresConsent: true }, + ], +}; + /** * `oauthApplication` is a getter on the Clerk prototype and cannot be assigned * directly. Use Object.defineProperty to replace it with a configurable mock. @@ -319,7 +327,7 @@ describe('OAuthConsent', () => { }); describe('org selection', () => { - it('does not render the org selector when __internal_enableOrgSelection is not set', async () => { + it('does not render the org selector when user:org:read scope is absent', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'], @@ -339,7 +347,7 @@ describe('OAuthConsent', () => { }); 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, + // SDK-63: user:org:read scope is present but organizationSettings.enabled is false, // so no org select and no useOrganizationList call. const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ @@ -349,8 +357,10 @@ describe('OAuthConsent', () => { // intentionally NOT calling f.withOrganizations() }); - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + props.setProps({ componentName: 'OAuthConsent' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); const { queryByRole } = render(, { wrapper }); @@ -359,7 +369,7 @@ describe('OAuthConsent', () => { }); }); - it('renders the org selector when __internal_enableOrgSelection is true and user has memberships', async () => { + it('renders the org selector when user:org:read scope is present and user has memberships', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'], @@ -368,8 +378,10 @@ describe('OAuthConsent', () => { f.withOrganizations(); }); - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + props.setProps({ componentName: 'OAuthConsent' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); const { getByText } = render(, { wrapper }); @@ -378,7 +390,7 @@ describe('OAuthConsent', () => { }); }); - it('includes a hidden organization_id input when org selection is enabled and user has memberships', async () => { + it('renders the org selector when __internal_enableOrgSelection is true (fallback for existing apps)', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'], @@ -390,6 +402,48 @@ describe('OAuthConsent', () => { props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + const { getByText } = render(, { wrapper }); + + await waitFor(() => { + expect(getByText('Acme Corp')).toBeVisible(); + }); + }); + + it('does not display user:org:read in the scopes list', 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' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); + + const { queryByText } = render(, { wrapper }); + + await waitFor(() => { + expect(queryByText('Access your organizations')).toBeNull(); + }); + }); + + it('includes a hidden organization_id input when user:org:read scope is present 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' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); + const { baseElement } = render(, { wrapper }); await waitFor(() => { @@ -400,7 +454,7 @@ describe('OAuthConsent', () => { }); }); - it('does not include organization_id in the form when org selection is disabled', async () => { + it('does not include organization_id in the form when user:org:read scope is absent', async () => { const { wrapper, fixtures, props } = await createFixtures(f => { f.withUser({ email_addresses: ['jane@example.com'] }); }); @@ -431,8 +485,10 @@ describe('OAuthConsent', () => { fixtures.clerk.session.lastActiveOrganizationId = 'org_3'; - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + props.setProps({ componentName: 'OAuthConsent' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); const { baseElement } = render(, { wrapper }); @@ -458,8 +514,10 @@ describe('OAuthConsent', () => { fixtures.clerk.session.lastActiveOrganizationId = 'org_deleted'; - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + props.setProps({ componentName: 'OAuthConsent' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); const { baseElement } = render(, { wrapper }); @@ -485,8 +543,10 @@ describe('OAuthConsent', () => { fixtures.clerk.session.lastActiveOrganizationId = null; - props.setProps({ componentName: 'OAuthConsent', __internal_enableOrgSelection: true } as any); - mockOAuthApplication(fixtures.clerk, { getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfo) }); + props.setProps({ componentName: 'OAuthConsent' } as any); + mockOAuthApplication(fixtures.clerk, { + getConsentInfo: vi.fn().mockResolvedValue(fakeConsentInfoWithOrgScope), + }); const { baseElement } = render(, { wrapper });