Skip to content

Commit ed2cf75

Browse files
authored
feat(ui,localizations,shared): add the organization Security page SSO overview (#8813)
1 parent 14e5171 commit ed2cf75

14 files changed

Lines changed: 1208 additions & 57 deletions

File tree

.changeset/orange-pandas-shake.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/shared': patch
4+
'@clerk/ui': patch
5+
---
6+
7+
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.

packages/localizations/src/en-US.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1066,6 +1066,38 @@ export const enUS: LocalizationResource = {
10661066
successMessage: '{{domain}} has been removed.',
10671067
title: 'Remove domain',
10681068
},
1069+
securityPage: {
1070+
removeDialog: {
1071+
confirmButton: 'Remove connection',
1072+
subtitle:
1073+
'Are you sure you want to remove the connection? This action is irreversible and deletes the connection and all of its configuration.',
1074+
title: 'Remove SSO connection',
1075+
},
1076+
ssoSection: {
1077+
badge__active: 'Active',
1078+
badge__inactive: 'Inactive',
1079+
badge__inProgress: 'In Progress',
1080+
badge__unconfigured: 'Unconfigured',
1081+
certificateLabel: 'Certificate',
1082+
descriptionLine1:
1083+
'Require members to sign in through your identity provider using their domain email. Members without a matching domain are unaffected.',
1084+
descriptionLine2:
1085+
'Anyone who signs in will be automatically added to this organization. New members will be assigned to {{role}}.',
1086+
descriptionLine2__noRole: 'Anyone who signs in will be automatically added to this organization.',
1087+
domainLabel: 'Domain',
1088+
issuerLabel: 'Issuer',
1089+
menuAction__activate: 'Activate',
1090+
menuAction__deactivate: 'Deactivate',
1091+
menuAction__edit: 'Edit',
1092+
menuAction__remove: 'Remove',
1093+
primaryButton__continueConfiguration: 'Continue configuration',
1094+
primaryButton__startConfiguration: 'Start configuration',
1095+
providerLabel: 'Provider',
1096+
signOnUrlLabel: 'Sign on URL',
1097+
title: 'SSO',
1098+
},
1099+
title: 'Security',
1100+
},
10691101
start: {
10701102
headerTitle__general: 'General',
10711103
headerTitle__members: 'Members',

packages/shared/src/types/elementIds.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export type ProfileSectionId =
5353
| 'manageVerifiedDomains'
5454
| 'subscriptionsList'
5555
| 'paymentMethods'
56+
| 'sso'
5657
| 'ssoStatus'
5758
| 'enableSso'
5859
| 'ssoDomain'
@@ -61,7 +62,13 @@ export type ProfileSectionId =
6162
| 'resetSso'
6263
| 'testSsoUrl'
6364
| 'testResults';
64-
export type ProfilePageId = 'account' | 'security' | 'organizationGeneral' | 'organizationMembers' | 'billing';
65+
export type ProfilePageId =
66+
| 'account'
67+
| 'security'
68+
| 'organizationGeneral'
69+
| 'organizationMembers'
70+
| 'organizationSecurity'
71+
| 'billing';
6572

6673
export type UserPreviewId = 'userButton' | 'personalWorkspace';
6774
export type OrganizationPreviewId =

packages/shared/src/types/localization.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,35 @@ export type __internal_LocalizationResource = {
11301130
messageLine2: LocalizationValue;
11311131
successMessage: LocalizationValue;
11321132
};
1133+
securityPage: {
1134+
title: LocalizationValue;
1135+
removeDialog: {
1136+
title: LocalizationValue;
1137+
subtitle: LocalizationValue;
1138+
confirmButton: LocalizationValue;
1139+
};
1140+
ssoSection: {
1141+
title: LocalizationValue;
1142+
badge__unconfigured: LocalizationValue;
1143+
badge__inProgress: LocalizationValue;
1144+
badge__active: LocalizationValue;
1145+
badge__inactive: LocalizationValue;
1146+
descriptionLine1: LocalizationValue;
1147+
descriptionLine2: LocalizationValue<'role'>;
1148+
descriptionLine2__noRole: LocalizationValue;
1149+
primaryButton__startConfiguration: LocalizationValue;
1150+
primaryButton__continueConfiguration: LocalizationValue;
1151+
providerLabel: LocalizationValue;
1152+
domainLabel: LocalizationValue;
1153+
signOnUrlLabel: LocalizationValue;
1154+
issuerLabel: LocalizationValue;
1155+
certificateLabel: LocalizationValue;
1156+
menuAction__edit: LocalizationValue;
1157+
menuAction__activate: LocalizationValue;
1158+
menuAction__deactivate: LocalizationValue;
1159+
menuAction__remove: LocalizationValue;
1160+
};
1161+
};
11331162
membersPage: {
11341163
detailsTitle__emptyRow: LocalizationValue;
11351164
action__invite: LocalizationValue;

packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { LocalizationKey } from '@/customizables';
12
import { Col, descriptors, localizationKeys } from '@/customizables';
23
import { Card } from '@/elements/Card';
34
import { useCardState, withCardStateProvider } from '@/elements/contexts';
@@ -8,17 +9,19 @@ import { Modal } from '@/elements/Modal';
89
import { useFormControl } from '@/ui/utils/useFormControl';
910
import { handleError } from '@/utils/errorHandler';
1011

11-
import { useConfigureSSO } from './ConfigureSSOContext';
12-
1312
type ResetConnectionDialogProps = {
1413
isOpen: boolean;
1514
onClose: () => void;
1615
confirmationValue: string;
16+
onDelete: () => Promise<unknown>;
17+
contentRef: React.RefObject<HTMLDivElement>;
18+
/** Defaults to the Reset copy; overridden when the dialog is reused for the Remove action. */
19+
title?: LocalizationKey;
20+
subtitle?: LocalizationKey;
21+
confirmButtonLabel?: LocalizationKey;
1722
};
1823

1924
export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.Element | null => {
20-
const { contentRef } = useConfigureSSO();
21-
2225
if (!props.isOpen) {
2326
return null;
2427
}
@@ -27,7 +30,7 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El
2730
<Modal
2831
handleClose={props.onClose}
2932
canCloseModal={false}
30-
portalRoot={contentRef}
33+
portalRoot={props.contentRef}
3134
containerSx={t => ({
3235
alignItems: 'center',
3336
position: 'absolute',
@@ -44,9 +47,12 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El
4447
};
4548

4649
const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnectionDialogProps) => {
47-
const { onClose, confirmationValue } = props;
50+
const { onClose, onDelete, confirmationValue } = props;
51+
const title = props.title ?? localizationKeys('configureSSO.resetConnectionDialog.title');
52+
const subtitle = props.subtitle ?? localizationKeys('configureSSO.resetConnectionDialog.subtitle');
53+
const confirmButtonLabel =
54+
props.confirmButtonLabel ?? localizationKeys('configureSSO.resetConnectionDialog.resetButton');
4855
const card = useCardState();
49-
const { enterpriseConnection, mutations } = useConfigureSSO();
5056

5157
const confirmationField = useFormControl('deleteConfirmation', '', {
5258
type: 'text',
@@ -60,18 +66,12 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti
6066
const canSubmit = Boolean(confirmationValue && confirmationField.value === confirmationValue);
6167

6268
const onSubmit = async () => {
63-
if (!enterpriseConnection || !canSubmit) {
69+
if (!canSubmit) {
6470
return;
6571
}
6672

6773
try {
68-
// Reset is a pure delete — no navigation. Dropping `hasConnection` breaks
69-
// the active step's entry guard, so the wizard self-corrects to the
70-
// furthest-reachable step. The mutation is already reverification-wrapped.
71-
// No `useWizard()` here — that lets this dialog be triggered from ANY
72-
// footer (including the nested SAML configure footers) without binding to
73-
// a nested wizard.
74-
await mutations.deleteConnection(enterpriseConnection.id);
74+
await onDelete();
7575
onClose();
7676
} catch (err) {
7777
handleError(err as Error, [confirmationField], card.setError);
@@ -85,8 +85,8 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti
8585
>
8686
<Card.Content sx={t => ({ textAlign: 'start', padding: t.sizes.$5 })}>
8787
<FormContainer
88-
headerTitle={localizationKeys('configureSSO.resetConnectionDialog.title')}
89-
headerSubtitle={localizationKeys('configureSSO.resetConnectionDialog.subtitle')}
88+
headerTitle={title}
89+
headerSubtitle={subtitle}
9090
sx={t => ({ gap: t.space.$4 })}
9191
>
9292
<Form.Root onSubmit={onSubmit}>
@@ -104,7 +104,7 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti
104104
block={false}
105105
colorScheme='danger'
106106
isDisabled={!canSubmit}
107-
localizationKey={localizationKeys('configureSSO.resetConnectionDialog.resetButton')}
107+
localizationKey={confirmButtonLabel}
108108
/>
109109
<Form.ResetButton
110110
elementDescriptor={descriptors.configureSSOResetConnectionDialogCancelButton}

packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,26 @@
1-
import type { EnterpriseConnectionResource } from '@clerk/shared/types';
21
import { describe, expect, it, vi } from 'vitest';
32

3+
import { localizationKeys } from '@/customizables';
44
import { bindCreateFixtures } from '@/test/create-fixtures';
55
import { render, screen, waitFor } from '@/test/utils';
66
import { CardStateProvider } from '@/ui/elements/contexts';
77

8-
// The dialog no longer touches the wizard. On confirm it calls the
9-
// reverification-wrapped `mutations.deleteConnection(id)` directly — a pure
10-
// delete, no navigation — and the wizard self-corrects to the
11-
// furthest-reachable step once the active step's guard breaks. That lets the
12-
// dialog be triggered from ANY footer (including nested SAML configure footers)
13-
// without binding to a nested wizard.
14-
const deleteConnection = vi.fn();
15-
16-
const connectionMockState = vi.hoisted(() => ({
17-
current: { id: 'idn_connection_1' } as Partial<EnterpriseConnectionResource> | null,
18-
}));
19-
20-
vi.mock('../ConfigureSSOContext', () => ({
21-
useConfigureSSO: () => ({
22-
enterpriseConnection: connectionMockState.current,
23-
contentRef: { current: null },
24-
// The dialog's confirm calls the reverification-wrapped `deleteConnection`
25-
// mutation directly. No navigation — the wizard self-corrects.
26-
mutations: { deleteConnection },
27-
}),
28-
}));
29-
308
import { ResetConnectionDialog } from '../ResetConnectionDialog';
319

10+
const deleteConnection = vi.fn();
11+
3212
const { createFixtures } = bindCreateFixtures('ConfigureSSO');
3313

3414
const renderDialog = (
3515
wrapper: React.ComponentType<{ children?: React.ReactNode }>,
36-
props: { isOpen?: boolean; onClose?: () => void; confirmationValue?: string } = {},
16+
props: {
17+
isOpen?: boolean;
18+
onClose?: () => void;
19+
confirmationValue?: string;
20+
title?: ReturnType<typeof localizationKeys>;
21+
subtitle?: ReturnType<typeof localizationKeys>;
22+
confirmButtonLabel?: ReturnType<typeof localizationKeys>;
23+
} = {},
3724
) => {
3825
const onClose = props.onClose ?? vi.fn();
3926
const utils = render(
@@ -42,6 +29,11 @@ const renderDialog = (
4229
isOpen={props.isOpen ?? true}
4330
onClose={onClose}
4431
confirmationValue={props.confirmationValue ?? 'Acme Inc'}
32+
onDelete={() => deleteConnection('idn_connection_1')}
33+
contentRef={{ current: null }}
34+
title={props.title}
35+
subtitle={props.subtitle}
36+
confirmButtonLabel={props.confirmButtonLabel}
4537
/>
4638
</CardStateProvider>,
4739
{ wrapper },
@@ -52,7 +44,6 @@ const renderDialog = (
5244
const resetMocks = () => {
5345
deleteConnection.mockReset();
5446
deleteConnection.mockResolvedValue(undefined);
55-
connectionMockState.current = { id: 'idn_connection_1' };
5647
};
5748

5849
describe('ResetConnectionDialog', () => {
@@ -81,6 +72,25 @@ describe('ResetConnectionDialog', () => {
8172
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
8273
});
8374

75+
it('renders override copy when title, subtitle, and confirm label props are supplied', async () => {
76+
resetMocks();
77+
const { wrapper } = await createFixtures();
78+
renderDialog(wrapper, {
79+
confirmationValue: 'Acme Inc',
80+
title: localizationKeys('organizationProfile.securityPage.removeDialog.title'),
81+
subtitle: localizationKeys('organizationProfile.securityPage.removeDialog.subtitle'),
82+
confirmButtonLabel: localizationKeys('organizationProfile.securityPage.removeDialog.confirmButton'),
83+
});
84+
85+
expect(screen.getByRole('heading', { name: 'Remove SSO connection' })).toBeInTheDocument();
86+
expect(screen.queryByRole('heading', { name: 'Reset connection' })).not.toBeInTheDocument();
87+
expect(screen.getByText(/Are you sure you want to remove the connection\?/i)).toBeInTheDocument();
88+
expect(screen.getByRole('button', { name: 'Remove connection' })).toBeInTheDocument();
89+
expect(screen.queryByRole('button', { name: 'Reset connection' })).not.toBeInTheDocument();
90+
// Type-to-confirm is unchanged by the override.
91+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
92+
});
93+
8494
it('keeps Reset disabled while the input is empty', async () => {
8595
resetMocks();
8696
const { wrapper } = await createFixtures();

packages/ui/src/components/ConfigureSSO/elements/Step.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,11 +206,11 @@ FooterContinue.displayName = 'Step.Footer.Continue';
206206
* footer row, matching the prior destructive affordance.
207207
*/
208208
const FooterReset = (): JSX.Element | null => {
209-
const { organizationEnterpriseConnection: c } = useConfigureSSO();
209+
const { enterpriseConnection, mutations, contentRef } = useConfigureSSO();
210210
const organization = __internal_useOrganizationBase();
211211
const [isOpen, setIsOpen] = useState(false);
212212

213-
if (!c.hasConnection) {
213+
if (!enterpriseConnection) {
214214
return null;
215215
}
216216

@@ -229,6 +229,8 @@ const FooterReset = (): JSX.Element | null => {
229229
isOpen={isOpen}
230230
onClose={() => setIsOpen(false)}
231231
confirmationValue={organization?.name ?? ''}
232+
onDelete={() => mutations.deleteConnection(enterpriseConnection.id)}
233+
contentRef={contentRef}
232234
/>
233235
</>
234236
);

packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ const ConfigurationDetailsSection = (): JSX.Element => {
254254
};
255255

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

@@ -277,6 +278,10 @@ const ResetConnectionSection = (): JSX.Element => {
277278
isOpen={isOpen}
278279
onClose={() => setIsOpen(false)}
279280
confirmationValue={organization?.name ?? ''}
281+
// The confirmation step is only reachable with a connection, so the resource is set.
282+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
283+
onDelete={() => mutations.deleteConnection(enterpriseConnection!.id)}
284+
contentRef={contentRef}
280285
/>
281286
</ProfileSection.Root>
282287
);

0 commit comments

Comments
 (0)