From 7460da39ffba7b96b49126632657616718bbbe6a Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 12:46:08 -0300 Subject: [PATCH 1/5] refactor(ui): make ResetConnectionDialog context-free --- .../ConfigureSSO/ResetConnectionDialog.tsx | 35 +++++++++++-------- .../__tests__/ResetConnectionDialog.test.tsx | 34 ++++++------------ .../components/ConfigureSSO/elements/Step.tsx | 7 +++- .../ConfigureSSO/steps/ConfirmationStep.tsx | 6 ++++ 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx index c35bbf9bb46..dea7a27364c 100644 --- a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx +++ b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx @@ -8,17 +8,22 @@ 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; + /** The value the user must type to confirm (the organization name). */ confirmationValue: string; + /** + * The host-bound delete action, awaited on confirm before the dialog closes. + * The dialog is context-free: hosts (wizard footers, the Security page + * overview) bind the connection id and mutation themselves. + */ + onDelete: () => Promise; + /** The host's scrollable content container — the modal portals into it. */ + contentRef: React.RefObject; }; export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.Element | null => { - const { contentRef } = useConfigureSSO(); - if (!props.isOpen) { return null; } @@ -27,7 +32,7 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El ({ alignItems: 'center', position: 'absolute', @@ -44,9 +49,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', @@ -60,18 +64,19 @@ 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); + // Reset is a pure delete — no navigation. The host binds the delete + // mutation; in the wizard, dropping `hasConnection` breaks the active + // step's entry guard and the machine self-corrects to the + // furthest-reachable step. No `useWizard()` (or any context) here — that + // lets this dialog be triggered from ANY footer (including the nested + // SAML configure footers) and from the Security page overview without + // binding to a wizard. + await onDelete(); onClose(); } catch (err) { handleError(err as Error, [confirmationField], card.setError); diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx index d16271cd6fe..21055b49410 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx @@ -1,34 +1,19 @@ -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 | 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'; +// The dialog is context-free. On confirm it awaits the host-bound `onDelete` +// action — a pure delete, no navigation — and in the wizard the machine +// 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) and from the Security page overview without binding +// to a wizard. +const deleteConnection = vi.fn(); + const { createFixtures } = bindCreateFixtures('ConfigureSSO'); const renderDialog = ( @@ -42,6 +27,8 @@ const renderDialog = ( isOpen={props.isOpen ?? true} onClose={onClose} confirmationValue={props.confirmationValue ?? 'Acme Inc'} + onDelete={() => deleteConnection('idn_connection_1')} + contentRef={{ current: null }} /> , { wrapper }, @@ -52,7 +39,6 @@ const renderDialog = ( const resetMocks = () => { deleteConnection.mockReset(); deleteConnection.mockResolvedValue(undefined); - connectionMockState.current = { id: 'idn_connection_1' }; }; describe('ResetConnectionDialog', () => { diff --git a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx index 96c70eaadd8..52139c52bc7 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx @@ -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); @@ -229,6 +229,11 @@ 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 guaranteed to be set at this point. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + onDelete={() => mutations.deleteConnection(enterpriseConnection!.id)} + contentRef={contentRef} /> ); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index 6e1a29d3fea..8dc34be7323 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -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); @@ -277,6 +278,11 @@ 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 guaranteed to be set at this point. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + onDelete={() => mutations.deleteConnection(enterpriseConnection!.id)} + contentRef={contentRef} /> ); From 33df3df54eb664ce96a1ab48db9df6c065b05219 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 12:46:17 -0300 Subject: [PATCH 2/5] feat(localizations,shared): add Security page SSO overview localization --- packages/localizations/src/en-US.ts | 24 +++++++++++++++++++++++ packages/shared/src/types/localization.ts | 23 ++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index b6d14fad930..2a33cc67cb6 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -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', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 5b4feb90948..c3392f3c548 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -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; From 86addc45d48ba7b8e054c9cfa3f30174d702f574 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 12:46:27 -0300 Subject: [PATCH 3/5] feat(ui,shared): render the organization Security page SSO overview --- .changeset/orange-pandas-shake.md | 7 + packages/shared/src/types/elementIds.ts | 9 +- .../OrganizationSecurityPage.tsx | 73 ++- .../SecuritySsoSection.tsx | 498 ++++++++++++++++++ .../OrganizationSecurityPage.test.tsx | 299 +++++++++++ .../src/customizables/elementDescriptors.ts | 13 + packages/ui/src/elements/Section.tsx | 13 +- packages/ui/src/internal/appearance.ts | 13 + 8 files changed, 911 insertions(+), 14 deletions(-) create mode 100644 .changeset/orange-pandas-shake.md create mode 100644 packages/ui/src/components/OrganizationProfile/SecuritySsoSection.tsx create mode 100644 packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx diff --git a/.changeset/orange-pandas-shake.md b/.changeset/orange-pandas-shake.md new file mode 100644 index 00000000000..280d85dc93c --- /dev/null +++ b/.changeset/orange-pandas-shake.md @@ -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. diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 336279a9a07..bc03eced10d 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -53,6 +53,7 @@ export type ProfileSectionId = | 'manageVerifiedDomains' | 'subscriptionsList' | 'paymentMethods' + | 'sso' | 'ssoStatus' | 'enableSso' | 'ssoDomain' @@ -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 = diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 87ff924593d..7fe52fd244a 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -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; @@ -20,10 +26,18 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag return ; }; -/** Separate from the page so the connection hook only runs behind the organization check. */ +/** + * The page-owned data layer: fetches through the umbrella hook ONCE, gates + * loading and permission, and injects the resolved data down — as props into + * the overview section, or into the pure `ConfigureSSOWizard` (which mounts its + * own provider). A separate component below the page (rather than inlined into + * it) so the connection hook only ever runs behind the page's organization + * check. + */ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => { const { isLoading, + organization, enterpriseConnection, organizationEnterpriseConnection, testRuns, @@ -31,21 +45,60 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag primaryEmailAddress, } = useOrganizationEnterpriseConnection(); - // Gate loading above the provider so the context never observes a loading state. + // The page always lands on the overview; the wizard is entered explicitly via + // Start / Continue / Edit. There is no back-from-wizard affordance yet — + // returning is a reload for now; a follow-up adds the wizard-header button. + const [view, setView] = useState<'overview' | 'wizard'>('overview'); + + // Gate loading one level above both views so neither ever observes a loading + // state. The single test-run source is part of this initial fetch when a + // connection exists at load, so the overview's status (and a cold landing on + // the wizard's test step) is covered by the full skeleton here. if (isLoading) { return ; } return ( - + {view === 'overview' ? ( + + ({ gap: t.space.$8 })} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + setView('wizard')} + /> + + + + ) : ( + + )} ); }; diff --git a/packages/ui/src/components/OrganizationProfile/SecuritySsoSection.tsx b/packages/ui/src/components/OrganizationProfile/SecuritySsoSection.tsx new file mode 100644 index 00000000000..b68945d89a8 --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/SecuritySsoSection.tsx @@ -0,0 +1,498 @@ +import { iconImageUrl } from '@clerk/shared/constants'; +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import type { PropsWithChildren } from 'react'; +import { useId, useState } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileSection } from '@/ui/elements/Section'; +import { Switch } from '@/ui/elements/Switch'; +import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; +import { handleError } from '@/utils/errorHandler'; + +import type { LocalizationKey } from '../../customizables'; +import { Badge, Button, Col, descriptors, Flex, Link, localizationKeys, Span, Text } from '../../customizables'; +import type { + OrganizationEnterpriseConnection, + OrganizationEnterpriseConnectionStatus, +} from '../ConfigureSSO/domain/organizationEnterpriseConnection'; +import type { EnterpriseConnectionMutations } from '../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; +import { ResetConnectionDialog } from '../ConfigureSSO/ResetConnectionDialog'; +import type { ProviderType } from '../ConfigureSSO/types'; + +/** + * Presentational, props-driven: the Security page owns the data (one + * `useOrganizationEnterpriseConnection()` call) and injects everything the + * overview needs here. No `useConfigureSSO` / wizard context below the page. + */ +type SecuritySsoSectionProps = { + /** The display-facing domain entity — `status` drives the per-state rendering. */ + connection: OrganizationEnterpriseConnection; + /** The raw connection resource backing the detail rows (provider, domains, SAML config). */ + enterpriseConnection: EnterpriseConnectionResource | undefined; + setConnectionActive: EnterpriseConnectionMutations['setConnectionActive']; + deleteConnection: EnterpriseConnectionMutations['deleteConnection']; + /** The delete dialog's type-to-confirm value. */ + organizationName: string; + /** The delete dialog's portal root. */ + contentRef: React.RefObject; + /** Start / Continue / Edit — the host switches the page to the wizard view. */ + onConfigure: () => void; +}; + +const STATUS_BADGES: Record< + OrganizationEnterpriseConnectionStatus, + { id: string; colorScheme: 'danger' | 'warning' | 'success'; label: LocalizationKey } +> = { + unconfigured: { + id: 'unconfigured', + colorScheme: 'danger', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__unconfigured'), + }, + in_progress: { + id: 'inProgress', + colorScheme: 'warning', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__inProgress'), + }, + active: { + id: 'active', + colorScheme: 'success', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__active'), + }, + inactive: { + id: 'inactive', + colorScheme: 'danger', + label: localizationKeys('organizationProfile.securityPage.ssoSection.badge__inactive'), + }, +}; + +/** + * Provider icons whose SVGs are monochromatic and should flip with the theme. + * Mirrors the SUPPORTS_MASK_IMAGE list in `common/ProviderIcon.tsx` and the + * select-provider step — keep in sync if any of them grows. + */ +const MONOCHROMATIC_PROVIDER_ICONS: ReadonlySet = new Set(['okta']); + +/** Reuses the select-provider step's labels/icons so the overview can never disagree with the wizard. */ +const PROVIDER_PRESENTATION: Record = { + saml_okta: { label: localizationKeys('configureSSO.selectProviderStep.saml.okta'), iconId: 'okta' }, + saml_microsoft: { label: localizationKeys('configureSSO.selectProviderStep.saml.microsoft'), iconId: 'microsoft' }, + saml_google: { label: localizationKeys('configureSSO.selectProviderStep.saml.google'), iconId: 'google' }, + saml_custom: { label: localizationKeys('configureSSO.selectProviderStep.saml.customSaml'), iconId: 'saml' }, +}; + +export const SecuritySsoSection = (props: SecuritySsoSectionProps): JSX.Element => { + const { connection, onConfigure } = props; + const badge = STATUS_BADGES[connection.status]; + const isConfigured = connection.status === 'active' || connection.status === 'inactive'; + + return ( + + } + > + {connection.status === 'unconfigured' && ( + + )} + + {connection.status === 'in_progress' && ( + + )} + + {isConfigured && ( + // The toggle's optimistic error state lives in its own card scope so it + // can never bleed into another surface (the wizard steps own theirs). + + + + )} + + ); +}; + +type NotConfiguredContentProps = { + /** The in-progress "started but haven't finished" line, when applicable. */ + noticeKey?: LocalizationKey; + primaryButtonKey: LocalizationKey; + primaryButtonId: string; + onConfigure: () => void; +}; + +const NotConfiguredContent = ({ + noticeKey, + primaryButtonKey, + primaryButtonId, + onConfigure, +}: NotConfiguredContentProps): JSX.Element => ( + + + + {noticeKey && ( + + )} + + +