From c1e441917e6b4321b3dc34a6a430cd3df0072e7b Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Mon, 19 Oct 2026 20:02:16 -0700 Subject: [PATCH 1/2] feat(clerk-js,ui): gate self-serve Directory Sync on its own organization flag Parse `self_serve_directory_sync_enabled` into `Organization.selfServeDirectorySyncEnabled` and make `disabledSelfServeDirectorySyncFeature` depend on it instead of `selfServeSSOEnabled`. The OrganizationProfile Security page now shows for Directory Sync-only organizations, listing their connections read-only, and hides connection management when the organization's SSO flag is off. Refs ORGS-1953 --- .../self-serve-directory-sync-org-flag.md | 8 ++ .../src/core/resources/Organization.ts | 3 + .../resources/__tests__/Organization.test.ts | 28 ++++ .../__tests__/switcher.pages.test.tsx | 17 ++- .../src/features/switcher/switcher.pages.tsx | 3 +- .../__tests__/componentGuards.test.ts | 76 ++++++++++ .../src/internal/clerk-js/componentGuards.ts | 5 +- packages/shared/src/types/json.ts | 1 + packages/shared/src/types/organization.ts | 4 + .../OrganizationSecurityPage.tsx | 16 +-- .../__tests__/OrganizationProfile.test.tsx | 21 +++ .../OrganizationSecurityPage.test.tsx | 132 +++++++++++++++--- .../useSecurityRouteAccess.ts | 8 +- .../components/OrganizationProfile.ts | 9 +- 14 files changed, 297 insertions(+), 34 deletions(-) create mode 100644 .changeset/self-serve-directory-sync-org-flag.md create mode 100644 packages/shared/src/internal/clerk-js/__tests__/componentGuards.test.ts diff --git a/.changeset/self-serve-directory-sync-org-flag.md b/.changeset/self-serve-directory-sync-org-flag.md new file mode 100644 index 00000000000..b1e18e5475d --- /dev/null +++ b/.changeset/self-serve-directory-sync-org-flag.md @@ -0,0 +1,8 @@ +--- +'@clerk/shared': minor +'@clerk/clerk-js': minor +'@clerk/ui': minor +'@clerk/mosaic': patch +--- + +Add `selfServeDirectorySyncEnabled` to `OrganizationResource`. Self-serve Directory Sync is now enabled per organization by this setting instead of following `selfServeSSOEnabled`. In ``, the Security page now appears for organizations that have only Directory Sync enabled, and their admins can manage the connection's directory. Organizations that have self-serve SSO without Directory Sync no longer see the Directory Sync section. diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 9874676110a..95058ec2ead 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -82,6 +82,7 @@ export class Organization extends BaseResource implements OrganizationResource { pendingInvitationsCount = 0; maxAllowedMemberships!: number; selfServeSSOEnabled = false; + selfServeDirectorySyncEnabled = false; exclusiveMembership = false; constructor(data: OrganizationJSON | OrganizationJSONSnapshot) { @@ -509,6 +510,7 @@ export class Organization extends BaseResource implements OrganizationResource { this.maxAllowedMemberships = data.max_allowed_memberships || 0; this.adminDeleteEnabled = data.admin_delete_enabled || false; this.selfServeSSOEnabled = data.self_serve_sso_enabled || false; + this.selfServeDirectorySyncEnabled = data.self_serve_directory_sync_enabled || false; this.exclusiveMembership = data.exclusive_membership || false; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); @@ -529,6 +531,7 @@ export class Organization extends BaseResource implements OrganizationResource { max_allowed_memberships: this.maxAllowedMemberships, admin_delete_enabled: this.adminDeleteEnabled, self_serve_sso_enabled: this.selfServeSSOEnabled, + self_serve_directory_sync_enabled: this.selfServeDirectorySyncEnabled, exclusive_membership: this.exclusiveMembership, created_at: this.createdAt.getTime(), updated_at: this.updatedAt.getTime(), diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index 34867fb44dc..d400d7ae9b0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -56,6 +56,7 @@ describe('Organization', () => { maxAllowedMemberships: 3, adminDeleteEnabled: true, selfServeSSOEnabled: true, + selfServeDirectorySyncEnabled: false, createdAt: expect.any(Date), updatedAt: expect.any(Date), publicMetadata: { @@ -64,6 +65,33 @@ describe('Organization', () => { }); }); + it('parses self_serve_directory_sync_enabled and keeps it in the snapshot', () => { + const organization = new Organization({ + object: 'organization', + id: 'test_id', + name: 'test_name', + public_metadata: {}, + slug: 'test_slug', + image_url: '', + created_at: 12345, + updated_at: 5678, + members_count: 1, + pending_invitations_count: 0, + admin_delete_enabled: true, + max_allowed_memberships: 3, + has_image: false, + self_serve_sso_enabled: false, + self_serve_directory_sync_enabled: true, + }); + + expect(organization.selfServeSSOEnabled).toBe(false); + expect(organization.selfServeDirectorySyncEnabled).toBe(true); + expect(organization.__internal_toSnapshot()).toMatchObject({ + self_serve_sso_enabled: false, + self_serve_directory_sync_enabled: true, + }); + }); + describe('enterprise connections', () => { it('fetches enterprise connections from the org-scoped path', async () => { const enterpriseConnectionsJSON: EnterpriseConnectionJSON[] = [ diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx index f9b7bd1f965..da89171b4bb 100644 --- a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx +++ b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx @@ -8,17 +8,18 @@ import type { CustomPagesOptions } from '../switcher.pages'; import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from '../switcher.pages'; let selfServeSSOEnabled: boolean; +let selfServeDirectorySyncEnabled: boolean; let environment: { commerceSettings: { billing: { user: { enabled: boolean }; organization: { enabled: boolean } } }; apiKeysSettings: { user_api_keys_enabled: boolean; orgs_api_keys_enabled: boolean }; - userSettings: { enterpriseSSO: { self_serve_sso: boolean } }; + userSettings: { enterpriseSSO: { self_serve_sso: boolean; self_serve_directory_sync: boolean } }; }; vi.mock('@clerk/shared/react', async importOriginal => { const actual = await importOriginal(); return { ...actual, - useClerk: () => ({ organization: { selfServeSSOEnabled } }), + useClerk: () => ({ organization: { selfServeSSOEnabled, selfServeDirectorySyncEnabled } }), }; }); @@ -66,10 +67,11 @@ const docs: CustomProfileItem = { beforeEach(() => { emitted = undefined; selfServeSSOEnabled = false; + selfServeDirectorySyncEnabled = false; environment = { commerceSettings: { billing: { user: { enabled: false }, organization: { enabled: false } } }, apiKeysSettings: { user_api_keys_enabled: false, orgs_api_keys_enabled: false }, - userSettings: { enterpriseSSO: { self_serve_sso: false } }, + userSettings: { enterpriseSSO: { self_serve_sso: false, self_serve_directory_sync: false } }, }; }); @@ -113,6 +115,15 @@ describe('useOrganizationProfilePages', () => { selfServeSSOEnabled = true; expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members', 'security']); }); + + it('adds security when the active organization allows self-serve Directory Sync without SSO', () => { + environment.userSettings.enterpriseSSO.self_serve_sso = true; + environment.userSettings.enterpriseSSO.self_serve_directory_sync = true; + expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members']); + + selfServeDirectorySyncEnabled = true; + expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members', 'security']); + }); }); describe('useCustomPages', () => { diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx index 53644d7ae25..301e64b0d0c 100644 --- a/packages/mosaic/src/features/switcher/switcher.pages.tsx +++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx @@ -1,6 +1,7 @@ import { disabledOrganizationAPIKeysFeature, disabledOrganizationBillingFeature, + disabledSelfServeDirectorySyncFeature, disabledSelfServeSSOFeature, disabledUserAPIKeysFeature, disabledUserBillingFeature, @@ -51,7 +52,7 @@ export function useOrganizationProfilePages(): OrganizationProfilePageId[] { if (!disabledOrganizationAPIKeysFeature(clerk, environment)) { pages.push('apiKeys'); } - if (!disabledSelfServeSSOFeature(clerk, environment)) { + if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) { pages.push('security'); } return pages; diff --git a/packages/shared/src/internal/clerk-js/__tests__/componentGuards.test.ts b/packages/shared/src/internal/clerk-js/__tests__/componentGuards.test.ts new file mode 100644 index 00000000000..0cdb19a3ae9 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/componentGuards.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import type { Clerk, EnvironmentResource } from '@/types'; + +import { disabledSelfServeDirectorySyncFeature, disabledSelfServeSSOFeature } from '../componentGuards'; + +type Flags = { + selfServeSSO?: boolean; + selfServeDirectorySync?: boolean; + orgSSO?: boolean; + orgDirectorySync?: boolean; +}; + +const setup = ({ + selfServeSSO = true, + selfServeDirectorySync = true, + orgSSO = false, + orgDirectorySync = false, +}: Flags) => { + const clerk = { + organization: { selfServeSSOEnabled: orgSSO, selfServeDirectorySyncEnabled: orgDirectorySync }, + } as unknown as Clerk; + const environment = { + userSettings: { + enterpriseSSO: { + enabled: true, + self_serve_sso: selfServeSSO, + self_serve_directory_sync: selfServeDirectorySync, + }, + }, + } as unknown as EnvironmentResource; + return [clerk, environment] as const; +}; + +describe('disabledSelfServeSSOFeature', () => { + it('is enabled when the instance and the organization allow self-serve SSO', () => { + expect(disabledSelfServeSSOFeature(...setup({ orgSSO: true }))).toBe(false); + }); + + it('is disabled when the organization does not allow self-serve SSO', () => { + expect(disabledSelfServeSSOFeature(...setup({ orgSSO: false, orgDirectorySync: true }))).toBe(true); + }); + + it('is disabled when the instance does not allow self-serve SSO', () => { + expect(disabledSelfServeSSOFeature(...setup({ selfServeSSO: false, orgSSO: true }))).toBe(true); + }); +}); + +describe('disabledSelfServeDirectorySyncFeature', () => { + it('is enabled when the organization allows Directory Sync, even without self-serve SSO', () => { + expect(disabledSelfServeDirectorySyncFeature(...setup({ orgSSO: false, orgDirectorySync: true }))).toBe(false); + }); + + it('is enabled when the organization allows both SSO and Directory Sync', () => { + expect(disabledSelfServeDirectorySyncFeature(...setup({ orgSSO: true, orgDirectorySync: true }))).toBe(false); + }); + + it('is disabled when the organization allows SSO but not Directory Sync', () => { + expect(disabledSelfServeDirectorySyncFeature(...setup({ orgSSO: true, orgDirectorySync: false }))).toBe(true); + }); + + it('is disabled when the instance does not allow self-serve SSO', () => { + expect(disabledSelfServeDirectorySyncFeature(...setup({ selfServeSSO: false, orgDirectorySync: true }))).toBe(true); + }); + + it('is disabled when the instance does not allow self-serve Directory Sync', () => { + expect( + disabledSelfServeDirectorySyncFeature(...setup({ selfServeDirectorySync: false, orgDirectorySync: true })), + ).toBe(true); + }); + + it('is disabled without an active organization', () => { + const [, environment] = setup({}); + expect(disabledSelfServeDirectorySyncFeature({ organization: null } as unknown as Clerk, environment)).toBe(true); + }); +}); diff --git a/packages/shared/src/internal/clerk-js/componentGuards.ts b/packages/shared/src/internal/clerk-js/componentGuards.ts index bb81268a6b9..50a97fcc638 100644 --- a/packages/shared/src/internal/clerk-js/componentGuards.ts +++ b/packages/shared/src/internal/clerk-js/componentGuards.ts @@ -52,8 +52,9 @@ export const disabledSelfServeSSOFeature: ComponentGuard = (clerk, environment) export const disabledSelfServeDirectorySyncFeature: ComponentGuard = (clerk, environment) => { return ( - disabledSelfServeSSOFeature(clerk, environment) || - !environment?.userSettings.enterpriseSSO.self_serve_directory_sync + !environment?.userSettings.enterpriseSSO.self_serve_sso || + !environment?.userSettings.enterpriseSSO.self_serve_directory_sync || + !clerk.organization?.selfServeDirectorySyncEnabled ); }; diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 9c0fd4e5cba..b22b70dc019 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -476,6 +476,7 @@ export interface OrganizationJSON extends ClerkResourceJSON { admin_delete_enabled: boolean; max_allowed_memberships: number; self_serve_sso_enabled?: boolean; + self_serve_directory_sync_enabled?: boolean; exclusive_membership?: boolean; } diff --git a/packages/shared/src/types/organization.ts b/packages/shared/src/types/organization.ts index ff5685dd2c8..671b89fc838 100644 --- a/packages/shared/src/types/organization.ts +++ b/packages/shared/src/types/organization.ts @@ -95,6 +95,10 @@ export interface OrganizationResource extends ClerkResource, BillingPayerMethods * Whether the Organization allows self-serve SSO. */ selfServeSSOEnabled: boolean; + /** + * Whether the Organization allows self-serve Directory Sync. + */ + selfServeDirectorySyncEnabled: boolean; /** * Whether the Organization enforces exclusive membership, meaning members must have it set as their active Organization. Defaults to `false` for instances that have not adopted the feature. */ diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 0b5a1cd68c3..6448031c866 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -5,7 +5,7 @@ import { Header } from '@/ui/elements/Header'; import { ProfileCard } from '@/ui/elements/ProfileCard'; import { useProtect } from '../../common'; -import { useEnvironment, useOrganizationProfileContext } from '../../contexts'; +import { useOrganizationProfileContext } from '../../contexts'; import { Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; import { ConfigureDirectorySyncWizard } from '../ConfigureDirectorySync/ConfigureDirectorySyncWizard'; import { SecurityDirectorySyncSection } from '../ConfigureDirectorySync/SecurityDirectorySyncSection'; @@ -41,9 +41,10 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag }; const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => { - const { shouldShowSelfServeSSO } = useOrganizationProfileContext(); - const canManageConnections = useProtect({ permission: 'org:sys_entconns:manage' }) && shouldShowSelfServeSSO; + const canManageConnections = useProtect({ permission: 'org:sys_entconns:manage' }); const canManageSSOBypass = useProtect({ permission: 'org:sys_entconns_sso_bypass:manage' }); + const { shouldShowSelfServeSSO, shouldShowSelfServeDirectorySync } = useOrganizationProfileContext(); + const canManageSso = canManageConnections && shouldShowSelfServeSSO; const { organization, @@ -59,10 +60,9 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag enterpriseConnectionMutations, organizationDomains, organizationDomainMutations, - } = useOrganizationEnterpriseConnection({ manage: canManageConnections }); + } = useOrganizationEnterpriseConnection({ manage: canManageSso }); - const { userSettings } = useEnvironment(); - const showDirectorySync = canManageConnections && userSettings.enterpriseSSO.self_serve_directory_sync; + const showDirectorySync = canManageConnections && shouldShowSelfServeDirectorySync; const showSSOBypass = canManageSSOBypass && enterpriseConnections.length > 0; const [requestedView, setRequestedView] = useState({ kind: 'overview' }); @@ -161,8 +161,8 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag enterpriseConnectionMutations={enterpriseConnectionMutations} organizationName={organization?.name ?? ''} contentRef={contentRef} - onConfigure={canManageConnections ? openWizard : undefined} - onOpenConnection={canManageConnections ? openConnection : undefined} + onConfigure={canManageSso ? openWizard : undefined} + onOpenConnection={canManageSso ? openConnection : undefined} /> {showSSOBypass && setRequestedView({ kind: 'ssoBypass' })} />} {showDirectorySync && ( diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx index 8a53c98c04a..c8028d73c1e 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx @@ -565,6 +565,27 @@ describe('OrganizationProfile', () => { await waitFor(() => expect(queryByText('Security')).toBeNull()); }); + it('includes Security when only Directory Sync is enabled for the org', async () => { + const { wrapper } = await createFixtures(f => { + f.withEnterpriseSso({ selfServeSSO: true, selfServeDirectorySync: true }); + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [ + { + name: 'Org1', + self_serve_sso_enabled: false, + self_serve_directory_sync_enabled: true, + permissions: ['org:sys_entconns:manage'], + }, + ], + }); + }); + + render(, { wrapper }); + expect(await screen.findByText('Security')).toBeDefined(); + }); + it('does not include SSO when the user lacks the manage enterprise connections permission', async () => { const { wrapper } = await createFixtures(f => { f.withEnterpriseSso({ selfServeSSO: true }); diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx index 8bfd5ce215f..81f9ca5f8db 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx @@ -11,17 +11,31 @@ const { createFixtures } = bindCreateFixtures('OrganizationProfile'); vi.setConfig({ testTimeout: 15_000 }); -const withSecurityPageFixtures = (f: Parameters[0]>[0]) => { +type FixturesBuilder = Parameters[0]>[0]; + +const withOrganizationSettings = + ({ sso, directorySync }: { sso: boolean; directorySync: boolean }) => + (f: FixturesBuilder) => { + f.withEnterpriseSso({ selfServeSSO: true, selfServeDirectorySync: true }); + f.withEmailAddress(); + f.withOrganizations(); + f.withOrganizationDomains(undefined, 'org:member'); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [ + { + name: 'Org1', + self_serve_sso_enabled: sso, + self_serve_directory_sync_enabled: directorySync, + permissions: ['org:sys_entconns:manage'], + }, + ], + }); + }; + +const withSecurityPageFixtures = (f: FixturesBuilder) => { + withOrganizationSettings({ sso: true, directorySync: false })(f); f.withEnterpriseSso({ selfServeSSO: true }); - f.withEmailAddress(); - f.withOrganizations(); - f.withOrganizationDomains(undefined, 'org:member'); - f.withUser({ - email_addresses: ['test@clerk.com'], - organization_memberships: [ - { name: 'Org1', self_serve_sso_enabled: true, permissions: ['org:sys_entconns:manage'] }, - ], - }); }; const DESCRIPTION_LINE_1 = 'Require members with a matching email domain to sign in through your identity provider.'; @@ -491,10 +505,7 @@ describe('OrganizationSecurityPage', () => { }); describe('directory sync section', () => { - const withDirectorySyncFixtures = (f: Parameters[0]>[0]) => { - withSecurityPageFixtures(f); - f.withEnterpriseSso({ selfServeSSO: true, selfServeDirectorySync: true }); - }; + const withDirectorySyncFixtures = withOrganizationSettings({ sso: true, directorySync: true }); // The mutations live on the DirectorySyncResource resolved by getDirectorySync. const directory = (overrides: Record = {}) => @@ -523,8 +534,23 @@ describe('OrganizationSecurityPage', () => { fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: [], total_count: 0 } as any); }; - it('is hidden when the instance is not flagged into self-serve Directory Sync', async () => { - const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); + it('is hidden when the organization has self-serve Directory Sync turned off', async () => { + const { wrapper, fixtures } = await createFixtures(withOrganizationSettings({ sso: true, directorySync: false })); + withActiveConnection(fixtures); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValue(directory()); + + renderPage(wrapper); + + expect(await screen.findByText('Active')).toBeInTheDocument(); + expect(screen.queryByText('Directory Sync')).not.toBeInTheDocument(); + expect(fixtures.clerk.organization?.getDirectorySync).not.toHaveBeenCalled(); + }); + + it('is hidden when the instance has self-serve Directory Sync turned off', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + withOrganizationSettings({ sso: true, directorySync: true })(f); + f.withEnterpriseSso({ selfServeSSO: true }); + }); withActiveConnection(fixtures); fixtures.clerk.organization?.getDirectorySync.mockResolvedValue(directory()); @@ -678,4 +704,78 @@ describe('OrganizationSecurityPage', () => { await waitFor(() => expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument()); }); }); + describe('organization settings', () => { + const directory = () => + ({ + id: 'scimdir_1', + enterpriseConnectionId: 'ent_1', + endpointUrl: 'https://api.example.com/scim/v2', + provider: 'okta', + enabled: true, + attributeMapping: {}, + apiKey: null, + }) as any; + + const withActiveConnection = (fixtures: any) => { + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([configuredConnection({ active: true })]); + fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({ + data: [], + total_count: 0, + } as any); + fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: [], total_count: 0 } as any); + fixtures.clerk.organization?.getDirectorySync.mockResolvedValue(directory()); + }; + + it('with SSO only, manages connections and hides Directory Sync', async () => { + const { wrapper, fixtures } = await createFixtures(withOrganizationSettings({ sso: true, directorySync: false })); + withActiveConnection(fixtures); + + renderPage(wrapper); + + expect(await findMenuButtons('sso')).toHaveLength(1); + expect(section('sso').getByRole('button', { name: /Add connection/i })).toBeInTheDocument(); + expect(screen.queryByText('Directory Sync')).not.toBeInTheDocument(); + expect(fixtures.clerk.organization?.getDirectorySync).not.toHaveBeenCalled(); + }); + + it('with Directory Sync only, lists connections read-only and manages the directory', async () => { + const { wrapper, fixtures } = await createFixtures(withOrganizationSettings({ sso: false, directorySync: true })); + withActiveConnection(fixtures); + + renderPage(wrapper); + + expect(await findMenuButtons('directorySync')).toHaveLength(1); + expect(section('sso').getByText('Active')).toBeInTheDocument(); + expect(section('sso').queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); + expect(section('sso').queryByRole('button', { name: /Add connection|Configure/i })).not.toBeInTheDocument(); + expect(fixtures.clerk.organization?.getEnterpriseConnectionTestRuns).not.toHaveBeenCalled(); + expect(fixtures.clerk.organization?.getDomains).not.toHaveBeenCalled(); + }); + + it('with both, manages connections and the directory', async () => { + const { wrapper, fixtures } = await createFixtures(withOrganizationSettings({ sso: true, directorySync: true })); + withActiveConnection(fixtures); + + renderPage(wrapper); + + expect(await findMenuButtons('directorySync')).toHaveLength(1); + expect(await findMenuButtons('sso')).toHaveLength(1); + expect(section('sso').getByRole('button', { name: /Add connection/i })).toBeInTheDocument(); + }); + + it('with neither, lists connections read-only and hides Directory Sync', async () => { + const { wrapper, fixtures } = await createFixtures( + withOrganizationSettings({ sso: false, directorySync: false }), + ); + withActiveConnection(fixtures); + + renderPage(wrapper); + + expect(await screen.findByText('Active')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open menu/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Add connection|Configure/i })).not.toBeInTheDocument(); + expect(screen.queryByText('Directory Sync')).not.toBeInTheDocument(); + expect(fixtures.clerk.organization?.getEnterpriseConnectionTestRuns).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts b/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts index 8d944072ee2..d0db3f5ac8d 100644 --- a/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts +++ b/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts @@ -9,11 +9,12 @@ export type SecurityRouteAccess = { }; export const useSecurityRouteAccess = (): SecurityRouteAccess => { - const { shouldShowSelfServeSSO } = useOrganizationProfileContext(); + const { shouldShowSelfServeSSO, shouldShowSelfServeDirectorySync } = useOrganizationProfileContext(); const canManageConnections = useProtect({ permission: 'org:sys_entconns:manage' }); const canManageSSOBypass = useProtect({ permission: 'org:sys_entconns_sso_bypass:manage' }); const canConfigureSso = shouldShowSelfServeSSO && canManageConnections; - const needsConnections = canManageSSOBypass && !canConfigureSso; + const canConfigureDirectorySync = shouldShowSelfServeDirectorySync && canManageConnections; + const needsConnections = canManageSSOBypass && !canConfigureSso && !canConfigureDirectorySync; const { data: enterpriseConnections, isLoading } = __internal_useOrganizationEnterpriseConnections({ enabled: needsConnections, @@ -21,7 +22,8 @@ export const useSecurityRouteAccess = (): SecurityRouteAccess => { }); return { - allowed: canConfigureSso || (canManageSSOBypass && (enterpriseConnections?.length ?? 0) > 0), + allowed: + canConfigureSso || canConfigureDirectorySync || (canManageSSOBypass && (enterpriseConnections?.length ?? 0) > 0), pending: needsConnections && isLoading, }; }; diff --git a/packages/ui/src/contexts/components/OrganizationProfile.ts b/packages/ui/src/contexts/components/OrganizationProfile.ts index 43bb8d7cc25..c6c4dc7bf4b 100644 --- a/packages/ui/src/contexts/components/OrganizationProfile.ts +++ b/packages/ui/src/contexts/components/OrganizationProfile.ts @@ -28,6 +28,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { isSecurityPageRoot: boolean; shouldShowBilling: boolean; shouldShowSelfServeSSO: boolean; + shouldShowSelfServeDirectorySync: boolean; shouldShowSecurityPage: boolean; }; @@ -64,8 +65,13 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType const shouldShowSelfServeSSO = environment.userSettings.enterpriseSSO.self_serve_sso && !!organization?.selfServeSSOEnabled; + const shouldShowSelfServeDirectorySync = + environment.userSettings.enterpriseSSO.self_serve_sso && + environment.userSettings.enterpriseSSO.self_serve_directory_sync && + !!organization?.selfServeDirectorySyncEnabled; + const canManageSSOBypass = Boolean(session?.checkAuthorization({ permission: 'org:sys_entconns_sso_bypass:manage' })); - const shouldShowSecurityPage = shouldShowSelfServeSSO || canManageSSOBypass; + const shouldShowSecurityPage = shouldShowSelfServeSSO || shouldShowSelfServeDirectorySync || canManageSSOBypass; const pages = useMemo( () => @@ -103,6 +109,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType isSecurityPageRoot, shouldShowBilling, shouldShowSelfServeSSO, + shouldShowSelfServeDirectorySync, shouldShowSecurityPage, }; }; From 5d1197c8c799e913d1d970d8b9b00dbd27a7b066 Mon Sep 17 00:00:00 2001 From: Jim Kalafut Date: Fri, 23 Oct 2026 09:32:08 -0700 Subject: [PATCH 2/2] refactor(ui): centralize Security page capabilities in useSecurityCapabilities --- .../OrganizationSecurityPage.tsx | 17 ++++++---------- .../useSecurityCapabilities.ts | 20 +++++++++++++++++++ .../useSecurityRouteAccess.ts | 9 ++------- 3 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/components/OrganizationProfile/useSecurityCapabilities.ts diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 6448031c866..d5d0749bbc6 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -4,8 +4,6 @@ import React, { useState } from 'react'; import { Header } from '@/ui/elements/Header'; import { ProfileCard } from '@/ui/elements/ProfileCard'; -import { useProtect } from '../../common'; -import { useOrganizationProfileContext } from '../../contexts'; import { Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; import { ConfigureDirectorySyncWizard } from '../ConfigureDirectorySync/ConfigureDirectorySyncWizard'; import { SecurityDirectorySyncSection } from '../ConfigureDirectorySync/SecurityDirectorySyncSection'; @@ -17,6 +15,7 @@ import { SecurityBackControl } from './SecurityBackControl'; import { SecuritySSOBypassSection } from './SecuritySSOBypassSection'; import { SecuritySsoSection } from './SecuritySsoSection'; import { SSOBypassAllowlistPage } from './SSOBypassAllowlistPage'; +import { useSecurityCapabilities } from './useSecurityCapabilities'; type OrganizationSecurityPageProps = { contentRef: React.RefObject; @@ -41,10 +40,7 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag }; const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => { - const canManageConnections = useProtect({ permission: 'org:sys_entconns:manage' }); - const canManageSSOBypass = useProtect({ permission: 'org:sys_entconns_sso_bypass:manage' }); - const { shouldShowSelfServeSSO, shouldShowSelfServeDirectorySync } = useOrganizationProfileContext(); - const canManageSso = canManageConnections && shouldShowSelfServeSSO; + const { canConfigureSso, canConfigureDirectorySync, canManageSSOBypass } = useSecurityCapabilities(); const { organization, @@ -60,9 +56,8 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag enterpriseConnectionMutations, organizationDomains, organizationDomainMutations, - } = useOrganizationEnterpriseConnection({ manage: canManageSso }); + } = useOrganizationEnterpriseConnection({ manage: canConfigureSso }); - const showDirectorySync = canManageConnections && shouldShowSelfServeDirectorySync; const showSSOBypass = canManageSSOBypass && enterpriseConnections.length > 0; const [requestedView, setRequestedView] = useState({ kind: 'overview' }); @@ -161,11 +156,11 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag enterpriseConnectionMutations={enterpriseConnectionMutations} organizationName={organization?.name ?? ''} contentRef={contentRef} - onConfigure={canManageSso ? openWizard : undefined} - onOpenConnection={canManageSso ? openConnection : undefined} + onConfigure={canConfigureSso ? openWizard : undefined} + onOpenConnection={canConfigureSso ? openConnection : undefined} /> {showSSOBypass && setRequestedView({ kind: 'ssoBypass' })} />} - {showDirectorySync && ( + {canConfigureDirectorySync && ( { + const { shouldShowSelfServeSSO, shouldShowSelfServeDirectorySync } = useOrganizationProfileContext(); + const canManageConnections = useProtect({ permission: 'org:sys_entconns:manage' }); + const canManageSSOBypass = useProtect({ permission: 'org:sys_entconns_sso_bypass:manage' }); + + return { + canConfigureSso: shouldShowSelfServeSSO && canManageConnections, + canConfigureDirectorySync: shouldShowSelfServeDirectorySync && canManageConnections, + canManageSSOBypass, + }; +}; diff --git a/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts b/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts index d0db3f5ac8d..43ca26b2373 100644 --- a/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts +++ b/packages/ui/src/components/OrganizationProfile/useSecurityRouteAccess.ts @@ -1,7 +1,6 @@ import { __internal_useOrganizationEnterpriseConnections } from '@clerk/shared/react'; -import { useProtect } from '../../common'; -import { useOrganizationProfileContext } from '../../contexts'; +import { useSecurityCapabilities } from './useSecurityCapabilities'; export type SecurityRouteAccess = { allowed: boolean; @@ -9,11 +8,7 @@ export type SecurityRouteAccess = { }; export const useSecurityRouteAccess = (): SecurityRouteAccess => { - const { shouldShowSelfServeSSO, shouldShowSelfServeDirectorySync } = useOrganizationProfileContext(); - const canManageConnections = useProtect({ permission: 'org:sys_entconns:manage' }); - const canManageSSOBypass = useProtect({ permission: 'org:sys_entconns_sso_bypass:manage' }); - const canConfigureSso = shouldShowSelfServeSSO && canManageConnections; - const canConfigureDirectorySync = shouldShowSelfServeDirectorySync && canManageConnections; + const { canConfigureSso, canConfigureDirectorySync, canManageSSOBypass } = useSecurityCapabilities(); const needsConnections = canManageSSOBypass && !canConfigureSso && !canConfigureDirectorySync; const { data: enterpriseConnections, isLoading } = __internal_useOrganizationEnterpriseConnections({