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..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 { useEnvironment, 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,9 +40,7 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag
};
const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => {
- const { shouldShowSelfServeSSO } = useOrganizationProfileContext();
- const canManageConnections = useProtect({ permission: 'org:sys_entconns:manage' }) && shouldShowSelfServeSSO;
- const canManageSSOBypass = useProtect({ permission: 'org:sys_entconns_sso_bypass:manage' });
+ const { canConfigureSso, canConfigureDirectorySync, canManageSSOBypass } = useSecurityCapabilities();
const {
organization,
@@ -59,10 +56,8 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
enterpriseConnectionMutations,
organizationDomains,
organizationDomainMutations,
- } = useOrganizationEnterpriseConnection({ manage: canManageConnections });
+ } = useOrganizationEnterpriseConnection({ manage: canConfigureSso });
- const { userSettings } = useEnvironment();
- const showDirectorySync = canManageConnections && userSettings.enterpriseSSO.self_serve_directory_sync;
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={canManageConnections ? openWizard : undefined}
- onOpenConnection={canManageConnections ? openConnection : undefined}
+ onConfigure={canConfigureSso ? openWizard : undefined}
+ onOpenConnection={canConfigureSso ? openConnection : undefined}
/>
{showSSOBypass && setRequestedView({ kind: 'ssoBypass' })} />}
- {showDirectorySync && (
+ {canConfigureDirectorySync && (
{
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/useSecurityCapabilities.ts b/packages/ui/src/components/OrganizationProfile/useSecurityCapabilities.ts
new file mode 100644
index 00000000000..814eebfbba4
--- /dev/null
+++ b/packages/ui/src/components/OrganizationProfile/useSecurityCapabilities.ts
@@ -0,0 +1,20 @@
+import { useProtect } from '../../common';
+import { useOrganizationProfileContext } from '../../contexts';
+
+export type SecurityCapabilities = {
+ canConfigureSso: boolean;
+ canConfigureDirectorySync: boolean;
+ canManageSSOBypass: boolean;
+};
+
+export const useSecurityCapabilities = (): SecurityCapabilities => {
+ 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 8d944072ee2..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,8 @@ export type SecurityRouteAccess = {
};
export const useSecurityRouteAccess = (): SecurityRouteAccess => {
- const { shouldShowSelfServeSSO } = 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 { canConfigureSso, canConfigureDirectorySync, canManageSSOBypass } = useSecurityCapabilities();
+ const needsConnections = canManageSSOBypass && !canConfigureSso && !canConfigureDirectorySync;
const { data: enterpriseConnections, isLoading } = __internal_useOrganizationEnterpriseConnections({
enabled: needsConnections,
@@ -21,7 +17,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,
};
};