Skip to content

Commit 3327226

Browse files
author
Iago Dahlem
authored
fix(ui): hide OrganizationProfile Security tab when lacking manage enterprise connections permission (#8971)
1 parent 6224165 commit 3327226

5 files changed

Lines changed: 116 additions & 69 deletions

File tree

‎.changeset/olive-pans-sin.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
The Security tab in `<OrganizationProfile />` is now hidden for members who lack the manage enterprise connections permission (`org:sys_entconns:manage`), instead of rendering a permission-denied state. This matches how the Members, Billing, and API keys tabs are gated.

‎packages/ui/src/components/OrganizationProfile/OrganizationProfileNavbar.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export const OrganizationProfileNavbar = (
2929
}) || has({ permission: 'org:sys_billing:manage' }),
3030
);
3131

32+
const allowSecurityRoute = useProtect(has => has({ permission: 'org:sys_entconns:manage' }));
33+
3234
const routes = pages.routes
3335
.filter(
3436
r =>
@@ -40,7 +42,12 @@ export const OrganizationProfileNavbar = (
4042
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING ||
4143
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING && allowBillingRoutes),
4244
)
43-
.filter(r => r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.API_KEYS || !apiKeysProps?.hide);
45+
.filter(r => r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.API_KEYS || !apiKeysProps?.hide)
46+
.filter(
47+
r =>
48+
r.id !== ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SECURITY ||
49+
(r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SECURITY && allowSecurityRoute),
50+
);
4451
if (!organization) {
4552
return null;
4653
}

‎packages/ui/src/components/OrganizationProfile/OrganizationProfileRoutes.tsx‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -155,15 +155,17 @@ export const OrganizationProfileRoutes = ({ contentRef }: OrganizationProfileRou
155155
</Protect>
156156
)}
157157
{shouldShowSelfServeSSO ? (
158-
<Route path={isSecurityPageRoot ? undefined : 'organization-security'}>
159-
<Switch>
160-
<Route index>
161-
<Suspense fallback={''}>
162-
<OrganizationSecurityPage contentRef={contentRef} />
163-
</Suspense>
164-
</Route>
165-
</Switch>
166-
</Route>
158+
<Protect condition={has => has({ permission: 'org:sys_entconns:manage' })}>
159+
<Route path={isSecurityPageRoot ? undefined : 'organization-security'}>
160+
<Switch>
161+
<Route index>
162+
<Suspense fallback={''}>
163+
<OrganizationSecurityPage contentRef={contentRef} />
164+
</Suspense>
165+
</Route>
166+
</Switch>
167+
</Route>
168+
</Protect>
167169
) : null}
168170
</Route>
169171
</Switch>

‎packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx‎

Lines changed: 38 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { ProfileCard } from '@/ui/elements/ProfileCard';
66

77
import { Col, descriptors, Flex, Icon, localizationKeys, SimpleButton, Spinner, Text } from '../../customizables';
88
import { ChevronLeft } from '../../icons';
9-
import { ConfigureSSOProtect } from '../ConfigureSSO/ConfigureSSO';
109
import { ConfigureSSOWizard } from '../ConfigureSSO/ConfigureSSOWizard';
1110
import { useOrganizationEnterpriseConnection } from '../ConfigureSSO/hooks/useOrganizationEnterpriseConnection';
1211
import { SecuritySsoSection } from './SecuritySsoSection';
@@ -50,21 +49,19 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
5049

5150
if (isLoading) {
5251
return (
53-
<ConfigureSSOProtect>
54-
<SecurityPageOverview fillHeight>
55-
<Flex
56-
align='center'
57-
justify='center'
58-
sx={t => ({ flex: 1, paddingBlock: t.space.$5 })}
59-
>
60-
<Spinner
61-
size='xs'
62-
colorScheme='neutral'
63-
elementDescriptor={descriptors.spinner}
64-
/>
65-
</Flex>
66-
</SecurityPageOverview>
67-
</ConfigureSSOProtect>
52+
<SecurityPageOverview fillHeight>
53+
<Flex
54+
align='center'
55+
justify='center'
56+
sx={t => ({ flex: 1, paddingBlock: t.space.$5 })}
57+
>
58+
<Spinner
59+
size='xs'
60+
colorScheme='neutral'
61+
elementDescriptor={descriptors.spinner}
62+
/>
63+
</Flex>
64+
</SecurityPageOverview>
6865
);
6966
}
7067

@@ -89,35 +86,31 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag
8986
</SimpleButton>
9087
);
9188

92-
return (
93-
<ConfigureSSOProtect>
94-
{view === 'overview' ? (
95-
<SecurityPageOverview>
96-
<SecuritySsoSection
97-
connection={organizationEnterpriseConnection}
98-
enterpriseConnection={enterpriseConnection}
99-
setConnectionActive={enterpriseConnectionMutations.setConnectionActive}
100-
deleteConnection={enterpriseConnectionMutations.deleteConnection}
101-
organizationName={organization?.name ?? ''}
102-
contentRef={contentRef}
103-
onConfigure={openWizard}
104-
/>
105-
</SecurityPageOverview>
106-
) : (
107-
<ConfigureSSOWizard
108-
organizationEnterpriseConnection={organizationEnterpriseConnection}
109-
testRuns={testRuns}
110-
enterpriseConnection={enterpriseConnection}
111-
contentRef={contentRef}
112-
enterpriseConnectionMutations={enterpriseConnectionMutations}
113-
organizationDomainMutations={organizationDomainMutations}
114-
organizationDomains={organizationDomains}
115-
forceInitialStep={forceFirstStep}
116-
title={backControl}
117-
onExit={exitWizard}
118-
/>
119-
)}
120-
</ConfigureSSOProtect>
89+
return view === 'overview' ? (
90+
<SecurityPageOverview>
91+
<SecuritySsoSection
92+
connection={organizationEnterpriseConnection}
93+
enterpriseConnection={enterpriseConnection}
94+
setConnectionActive={enterpriseConnectionMutations.setConnectionActive}
95+
deleteConnection={enterpriseConnectionMutations.deleteConnection}
96+
organizationName={organization?.name ?? ''}
97+
contentRef={contentRef}
98+
onConfigure={openWizard}
99+
/>
100+
</SecurityPageOverview>
101+
) : (
102+
<ConfigureSSOWizard
103+
organizationEnterpriseConnection={organizationEnterpriseConnection}
104+
testRuns={testRuns}
105+
enterpriseConnection={enterpriseConnection}
106+
contentRef={contentRef}
107+
enterpriseConnectionMutations={enterpriseConnectionMutations}
108+
organizationDomainMutations={organizationDomainMutations}
109+
organizationDomains={organizationDomains}
110+
forceInitialStep={forceFirstStep}
111+
title={backControl}
112+
onExit={exitWizard}
113+
/>
121114
);
122115
};
123116

‎packages/ui/src/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx‎

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import type { CustomPage } from '@clerk/shared/types';
22
import { describe, expect, it } from 'vitest';
33

44
import { bindCreateFixtures } from '@/test/create-fixtures';
5-
import { cleanup, render, screen, waitFor } from '@/test/utils';
5+
import { render, screen, waitFor } from '@/test/utils';
6+
import { VirtualRouter } from '@/ui/router';
67

7-
import { OrganizationProfile } from '../';
8-
import { OrganizationSecurityPage } from '../OrganizationSecurityPage';
8+
import { OrganizationProfile } from '..';
9+
import { OrganizationProfileRoutes } from '../OrganizationProfileRoutes';
910

1011
const { createFixtures } = bindCreateFixtures('OrganizationProfile');
1112

@@ -538,8 +539,8 @@ describe('OrganizationProfile', () => {
538539
await waitFor(() => expect(queryByText('Security')).toBeNull());
539540
});
540541

541-
it('includes SSO even when the user does not have the manage enterprise connections permission, but the page surfaces a warning', async () => {
542-
const { wrapper, fixtures } = await createFixtures(f => {
542+
it('does not include SSO when the user lacks the manage enterprise connections permission', async () => {
543+
const { wrapper } = await createFixtures(f => {
543544
f.withEnterpriseSso({ selfServeSSO: true });
544545
f.withOrganizations();
545546
f.withUser({
@@ -554,17 +555,56 @@ describe('OrganizationProfile', () => {
554555
});
555556
});
556557

557-
fixtures.clerk.user?.getEnterpriseConnections.mockResolvedValue([]);
558+
const { queryByText } = render(<OrganizationProfile />, { wrapper });
559+
expect(queryByText('Security')).not.toBeInTheDocument();
560+
});
561+
});
558562

559-
render(<OrganizationProfile />, { wrapper });
560-
expect(await screen.findByText('Security')).toBeDefined();
563+
describe('SSO route guard', () => {
564+
const SECURITY_DESCRIPTION =
565+
'Require members with a matching email domain to sign in through your identity provider.';
566+
567+
// The navbar tests above only prove the link is hidden. This block exercises the
568+
// route guard wrapping the security route, so direct navigation is blocked too.
569+
// The shared fixture wrapper installs a mocked RouteContext that never matches a
570+
// path, so the guarded sub-route only mounts under a real router seeded to its path.
571+
const renderSecurityRoute = (wrapper: React.ComponentType<{ children?: React.ReactNode }>) =>
572+
render(
573+
<VirtualRouter startPath='/organization-security'>
574+
<OrganizationProfileRoutes contentRef={{ current: null }} />
575+
</VirtualRouter>,
576+
{ wrapper },
577+
);
578+
579+
const withSelfServeSSO = (permissions: string[]) => (f: Parameters<Parameters<typeof createFixtures>[0]>[0]) => {
580+
f.withEnterpriseSso({ selfServeSSO: true });
581+
f.withEmailAddress();
582+
f.withOrganizations();
583+
f.withOrganizationDomains(undefined, 'org:member');
584+
f.withUser({
585+
email_addresses: ['test@clerk.com'],
586+
organization_memberships: [{ name: 'Org1', self_serve_sso_enabled: true, permissions }],
587+
});
588+
};
589+
590+
it('renders the security page on the guarded route when the user can manage enterprise connections', async () => {
591+
const { wrapper, fixtures } = await createFixtures(withSelfServeSSO(['org:sys_entconns:manage']));
592+
fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]);
593+
594+
renderSecurityRoute(wrapper);
595+
596+
expect(await screen.findByText(SECURITY_DESCRIPTION)).toBeInTheDocument();
597+
expect(screen.getByRole('button', { name: 'Start configuration' })).toBeInTheDocument();
598+
});
599+
600+
it('blocks the security page on the guarded route when the user lacks the manage enterprise connections permission', async () => {
601+
const { wrapper, fixtures } = await createFixtures(withSelfServeSSO([]));
602+
fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]);
603+
604+
renderSecurityRoute(wrapper);
561605

562-
cleanup();
563-
render(<OrganizationSecurityPage />, { wrapper });
564-
expect(await screen.findByText(/you do not have permission to manage single sign-on/i)).toBeDefined();
565-
expect(
566-
screen.queryByText(/contact your organization.*administrator to upgrade your permissions/i),
567-
).toBeInTheDocument();
606+
await waitFor(() => expect(screen.queryByText(SECURITY_DESCRIPTION)).not.toBeInTheDocument());
607+
expect(screen.queryByRole('button', { name: 'Start configuration' })).not.toBeInTheDocument();
568608
});
569609
});
570610

0 commit comments

Comments
 (0)