Skip to content
7 changes: 7 additions & 0 deletions .changeset/orange-pandas-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': patch
'@clerk/shared': patch
'@clerk/ui': patch
---

Add an overview to the organization profile Security page. The page now lands on a summary of the SSO connection — a status badge (Unconfigured, In Progress, Active, Inactive), the configuration details framed in a card (provider, domain, sign-on URL, issuer, certificate), and an actions menu with Edit, Activate / Deactivate, and Remove — and switches into the existing configuration flow on Start, Continue, or Edit.
26 changes: 26 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,32 @@ export const enUS: LocalizationResource = {
successMessage: '{{domain}} has been removed.',
title: 'Remove domain',
},
securityPage: {
ssoSection: {
badge__active: 'Active',
badge__inactive: 'Inactive',
badge__inProgress: 'In Progress',
badge__unconfigured: 'Unconfigured',
certificateLabel: 'Certificate',
descriptionLine1:
'Let members sign in through your identity provider using their domain email. Members without a matching domain are unaffected.',
descriptionLine2:
'Anyone who signs in will be automatically added to this organization. New members will be assigned to {{role}}.',
descriptionLine2__noRole: 'Anyone who signs in will be automatically added to this organization.',
domainLabel: 'Domain',
issuerLabel: 'Issuer',
menuAction__activate: 'Activate',
menuAction__deactivate: 'Deactivate',
menuAction__edit: 'Edit',
menuAction__remove: 'Remove',
primaryButton__continueConfiguration: 'Continue configuration',
primaryButton__startConfiguration: 'Start configuration',
providerLabel: 'Provider',
signOnUrlLabel: 'Sign on URL',
title: 'SSO',
},
title: 'Security',
},
start: {
headerTitle__general: 'General',
headerTitle__members: 'Members',
Expand Down
9 changes: 8 additions & 1 deletion packages/shared/src/types/elementIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type ProfileSectionId =
| 'manageVerifiedDomains'
| 'subscriptionsList'
| 'paymentMethods'
| 'sso'
| 'ssoStatus'
| 'enableSso'
| 'ssoDomain'
Expand All @@ -61,7 +62,13 @@ export type ProfileSectionId =
| 'resetSso'
| 'testSsoUrl'
| 'testResults';
export type ProfilePageId = 'account' | 'security' | 'organizationGeneral' | 'organizationMembers' | 'billing';
export type ProfilePageId =
| 'account'
| 'security'
| 'organizationGeneral'
| 'organizationMembers'
| 'organizationSecurity'
| 'billing';

export type UserPreviewId = 'userButton' | 'personalWorkspace';
export type OrganizationPreviewId =
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,30 @@ export type __internal_LocalizationResource = {
messageLine2: LocalizationValue;
successMessage: LocalizationValue;
};
securityPage: {
title: LocalizationValue;
ssoSection: {
title: LocalizationValue;
badge__unconfigured: LocalizationValue;
badge__inProgress: LocalizationValue;
badge__active: LocalizationValue;
badge__inactive: LocalizationValue;
descriptionLine1: LocalizationValue;
descriptionLine2: LocalizationValue<'role'>;
descriptionLine2__noRole: LocalizationValue;
primaryButton__startConfiguration: LocalizationValue;
primaryButton__continueConfiguration: LocalizationValue;
providerLabel: LocalizationValue;
domainLabel: LocalizationValue;
signOnUrlLabel: LocalizationValue;
issuerLabel: LocalizationValue;
certificateLabel: LocalizationValue;
menuAction__edit: LocalizationValue;
menuAction__activate: LocalizationValue;
menuAction__deactivate: LocalizationValue;
menuAction__remove: LocalizationValue;
};
};
membersPage: {
detailsTitle__emptyRow: LocalizationValue;
action__invite: LocalizationValue;
Expand Down
22 changes: 7 additions & 15 deletions packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,15 @@ import { Modal } from '@/elements/Modal';
import { useFormControl } from '@/ui/utils/useFormControl';
import { handleError } from '@/utils/errorHandler';

import { useConfigureSSO } from './ConfigureSSOContext';

type ResetConnectionDialogProps = {
isOpen: boolean;
onClose: () => void;
confirmationValue: string;
onDelete: () => Promise<unknown>;
contentRef: React.RefObject<HTMLDivElement>;
};

export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.Element | null => {
const { contentRef } = useConfigureSSO();

if (!props.isOpen) {
return null;
}
Expand All @@ -27,7 +25,7 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El
<Modal
handleClose={props.onClose}
canCloseModal={false}
portalRoot={contentRef}
portalRoot={props.contentRef}
containerSx={t => ({
alignItems: 'center',
position: 'absolute',
Expand All @@ -44,9 +42,8 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El
};

const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnectionDialogProps) => {
const { onClose, confirmationValue } = props;
const { onClose, onDelete, confirmationValue } = props;
const card = useCardState();
const { enterpriseConnection, mutations } = useConfigureSSO();

const confirmationField = useFormControl('deleteConfirmation', '', {
type: 'text',
Expand All @@ -60,18 +57,13 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti
const canSubmit = Boolean(confirmationValue && confirmationField.value === confirmationValue);

const onSubmit = async () => {
if (!enterpriseConnection || !canSubmit) {
if (!canSubmit) {
return;
}

try {
// Reset is a pure delete — no navigation. Dropping `hasConnection` breaks
// the active step's entry guard, so the wizard self-corrects to the
// furthest-reachable step. The mutation is already reverification-wrapped.
// No `useWizard()` here — that lets this dialog be triggered from ANY
// footer (including the nested SAML configure footers) without binding to
// a nested wizard.
await mutations.deleteConnection(enterpriseConnection.id);
// A pure delete, no navigation — the wizard self-corrects once the active step's entry guard breaks.
await onDelete();
onClose();
} catch (err) {
handleError(err as Error, [confirmationField], card.setError);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,13 @@
import type { EnterpriseConnectionResource } from '@clerk/shared/types';
import { describe, expect, it, vi } from 'vitest';

import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, screen, waitFor } from '@/test/utils';
import { CardStateProvider } from '@/ui/elements/contexts';

// The dialog no longer touches the wizard. On confirm it calls the
// reverification-wrapped `mutations.deleteConnection(id)` directly — a pure
// delete, no navigation — and the wizard self-corrects to the
// furthest-reachable step once the active step's guard breaks. That lets the
// dialog be triggered from ANY footer (including nested SAML configure footers)
// without binding to a nested wizard.
const deleteConnection = vi.fn();

const connectionMockState = vi.hoisted(() => ({
current: { id: 'idn_connection_1' } as Partial<EnterpriseConnectionResource> | null,
}));

vi.mock('../ConfigureSSOContext', () => ({
useConfigureSSO: () => ({
enterpriseConnection: connectionMockState.current,
contentRef: { current: null },
// The dialog's confirm calls the reverification-wrapped `deleteConnection`
// mutation directly. No navigation — the wizard self-corrects.
mutations: { deleteConnection },
}),
}));

import { ResetConnectionDialog } from '../ResetConnectionDialog';

const deleteConnection = vi.fn();

const { createFixtures } = bindCreateFixtures('ConfigureSSO');

const renderDialog = (
Expand All @@ -42,6 +21,8 @@ const renderDialog = (
isOpen={props.isOpen ?? true}
onClose={onClose}
confirmationValue={props.confirmationValue ?? 'Acme Inc'}
onDelete={() => deleteConnection('idn_connection_1')}
contentRef={{ current: null }}
/>
</CardStateProvider>,
{ wrapper },
Expand All @@ -52,7 +33,6 @@ const renderDialog = (
const resetMocks = () => {
deleteConnection.mockReset();
deleteConnection.mockResolvedValue(undefined);
connectionMockState.current = { id: 'idn_connection_1' };
};

describe('ResetConnectionDialog', () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/components/ConfigureSSO/elements/Step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ FooterContinue.displayName = 'Step.Footer.Continue';
* footer row, matching the prior destructive affordance.
*/
const FooterReset = (): JSX.Element | null => {
const { organizationEnterpriseConnection: c } = useConfigureSSO();
const { organizationEnterpriseConnection: c, enterpriseConnection, mutations, contentRef } = useConfigureSSO();
const organization = __internal_useOrganizationBase();
const [isOpen, setIsOpen] = useState(false);

Expand All @@ -229,6 +229,10 @@ const FooterReset = (): JSX.Element | null => {
isOpen={isOpen}
onClose={() => setIsOpen(false)}
confirmationValue={organization?.name ?? ''}
// The footer self-hides without a connection (`hasConnection` above), so the resource is set.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
onDelete={() => mutations.deleteConnection(enterpriseConnection!.id)}
contentRef={contentRef}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ const ConfigurationDetailsSection = (): JSX.Element => {
};

const ResetConnectionSection = (): JSX.Element => {
const { enterpriseConnection, mutations, contentRef } = useConfigureSSO();
const { organization } = useOrganization();
const [isOpen, setIsOpen] = useState(false);

Expand All @@ -277,6 +278,10 @@ const ResetConnectionSection = (): JSX.Element => {
isOpen={isOpen}
onClose={() => setIsOpen(false)}
confirmationValue={organization?.name ?? ''}
// The confirmation step is only reachable with a connection, so the resource is set.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
onDelete={() => mutations.deleteConnection(enterpriseConnection!.id)}
contentRef={contentRef}
/>
</ProfileSection.Root>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { useOrganization } from '@clerk/shared/react';
import { useState } from 'react';

import { Header } from '@/ui/elements/Header';
import { ProfileCard } from '@/ui/elements/ProfileCard';

import { Col, descriptors, localizationKeys } from '../../customizables';
import { ConfigureSSOProtect } from '../ConfigureSSO/ConfigureSSO';
import { ConfigureSSOSkeleton } from '../ConfigureSSO/ConfigureSSOSkeleton';
import { ConfigureSSOWizard } from '../ConfigureSSO/ConfigureSSOWizard';
import { useOrganizationEnterpriseConnection } from '../ConfigureSSO/hooks/useOrganizationEnterpriseConnection';
import { SecuritySsoSection } from './SecuritySsoSection';

type OrganizationSecurityPageProps = {
contentRef: React.RefObject<HTMLDivElement>;
Expand All @@ -24,28 +30,62 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag
const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => {
const {
isLoading,
organization,
enterpriseConnection,
organizationEnterpriseConnection,
testRuns,
mutations,
primaryEmailAddress,
} = useOrganizationEnterpriseConnection();

const [view, setView] = useState<'overview' | 'wizard'>('overview');

// Gate loading above the provider so the context never observes a loading state.
if (isLoading) {
return <ConfigureSSOSkeleton />;
}

return (
<ConfigureSSOProtect>
<ConfigureSSOWizard
organizationEnterpriseConnection={organizationEnterpriseConnection}
testRuns={testRuns}
enterpriseConnection={enterpriseConnection}
contentRef={contentRef}
mutations={mutations}
primaryEmailAddress={primaryEmailAddress}
/>
{view === 'overview' ? (
<ProfileCard.Page>
<Col
elementDescriptor={descriptors.page}
sx={t => ({ gap: t.space.$8 })}
>
<Col
elementDescriptor={descriptors.profilePage}
elementId={descriptors.profilePage.setId('organizationSecurity')}
>
<Header.Root>
<Header.Title
localizationKey={localizationKeys('organizationProfile.securityPage.title')}
sx={t => ({ marginBottom: t.space.$4 })}
textVariant='h2'
/>
</Header.Root>
<SecuritySsoSection
connection={organizationEnterpriseConnection}
enterpriseConnection={enterpriseConnection}
setConnectionActive={mutations.setConnectionActive}
deleteConnection={mutations.deleteConnection}
organizationName={organization?.name ?? ''}
contentRef={contentRef}
onConfigure={() => setView('wizard')}
/>
</Col>
</Col>
</ProfileCard.Page>
) : (
<ConfigureSSOWizard
organizationEnterpriseConnection={organizationEnterpriseConnection}
testRuns={testRuns}
enterpriseConnection={enterpriseConnection}
contentRef={contentRef}
mutations={mutations}
primaryEmailAddress={primaryEmailAddress}
/>
)}
</ConfigureSSOProtect>
);
};
Loading
Loading