Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 — status badge (Unconfigured, In Progress, Active, Inactive), an Enable SSO toggle, and the provider, domain, sign-on URL, issuer, and certificate details — with Edit and Delete actions, and switches into the existing configuration flow on Start, Continue, or Edit.
24 changes: 24 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1066,6 +1066,30 @@ 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',
description: 'Configure to require organization members to sign in through your identity provider',
description__configured:
'Configure SSO to require organization members to sign in through your identity provider',
domainLabel: 'Domain',
enableSsoLabel: 'Enable SSO',
issuerLabel: 'Issuer',
menuAction__delete: 'Delete',
menuAction__edit: 'Edit',
primaryButton__continueConfiguration: 'Continue configuration',
primaryButton__startConfiguration: 'Start configuration',
providerLabel: 'Provider',
signOnUrlLabel: 'Sign on URL',
startedNotFinished: 'You have started a configuration but haven’t finished',
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
23 changes: 23 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,29 @@ 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;
description: LocalizationValue;
description__configured: LocalizationValue;
startedNotFinished: LocalizationValue;
primaryButton__startConfiguration: LocalizationValue;
primaryButton__continueConfiguration: LocalizationValue;
enableSsoLabel: LocalizationValue;
providerLabel: LocalizationValue;
domainLabel: LocalizationValue;
signOnUrlLabel: LocalizationValue;
issuerLabel: LocalizationValue;
certificateLabel: LocalizationValue;
menuAction__edit: LocalizationValue;
menuAction__delete: 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