Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fluffy-readers-strive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': minor
---

Render OAuthConsent organization selector from `user:org:read` scope.
29 changes: 15 additions & 14 deletions packages/ui/src/components/OAuthConsent/OAuthConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string | null>(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);
Expand Down Expand Up @@ -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);
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.

The new scope takes precedence over __internal_enableOrgSelection when present, so we can safely remove the flag in a follow-up PR once confirmed working.

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'));
Expand Down Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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'],
Expand All @@ -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({
Expand All @@ -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(<OAuthConsent />, { wrapper });

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

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

await waitFor(() => {
Expand All @@ -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'] });
});
Expand Down Expand Up @@ -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(<OAuthConsent />, { wrapper });

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

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

Expand Down
Loading