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/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/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/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; diff --git a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx index c35bbf9bb46..2ae450a7d2e 100644 --- a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx +++ b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx @@ -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; + contentRef: React.RefObject; }; export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.Element | null => { - const { contentRef } = useConfigureSSO(); - if (!props.isOpen) { return null; } @@ -27,7 +25,7 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El ({ alignItems: 'center', position: 'absolute', @@ -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', @@ -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); diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx index d16271cd6fe..dff7186523c 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ResetConnectionDialog.test.tsx @@ -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 | 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 = ( @@ -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 }} /> , { wrapper }, @@ -52,7 +33,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..53eccada5db 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,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} /> ); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index 6e1a29d3fea..2f0a10385b3 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,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} /> ); diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 87ff924593d..98ee5378068 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; @@ -24,6 +30,7 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => { const { isLoading, + organization, enterpriseConnection, organizationEnterpriseConnection, testRuns, @@ -31,6 +38,8 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag 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 ; @@ -38,14 +47,45 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag 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..1a5745b68bb --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/SecuritySsoSection.tsx @@ -0,0 +1,476 @@ +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'; + +/** Props-driven: the Security page owns the data — no wizard context below the page. */ +type SecuritySsoSectionProps = { + connection: OrganizationEnterpriseConnection; + enterpriseConnection: EnterpriseConnectionResource | undefined; + setConnectionActive: EnterpriseConnectionMutations['setConnectionActive']; + deleteConnection: EnterpriseConnectionMutations['deleteConnection']; + organizationName: string; + contentRef: React.RefObject; + 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'), + }, +}; + +/** Keep in sync with the select-provider step's MONOCHROMATIC_PROVIDER_ICONS. */ +const MONOCHROMATIC_PROVIDER_ICONS: ReadonlySet = new Set(['okta']); + +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 && ( + + + + )} + + ); +}; + +type NotConfiguredContentProps = { + noticeKey?: LocalizationKey; + primaryButtonKey: LocalizationKey; + primaryButtonId: string; + onConfigure: () => void; +}; + +const NotConfiguredContent = ({ + noticeKey, + primaryButtonKey, + primaryButtonId, + onConfigure, +}: NotConfiguredContentProps): JSX.Element => ( + + + + {noticeKey && ( + + )} + + +