diff --git a/.changeset/orange-pandas-shake.md b/.changeset/orange-pandas-shake.md new file mode 100644 index 00000000000..c9f0dd92ce3 --- /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 — 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. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index b6d14fad930..c4823368473 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -1066,6 +1066,38 @@ export const enUS: LocalizationResource = { successMessage: '{{domain}} has been removed.', title: 'Remove domain', }, + securityPage: { + removeDialog: { + confirmButton: 'Remove connection', + subtitle: + 'Are you sure you want to remove the connection? This action is irreversible and deletes the connection and all of its configuration.', + title: 'Remove SSO connection', + }, + ssoSection: { + badge__active: 'Active', + badge__inactive: 'Inactive', + badge__inProgress: 'In Progress', + badge__unconfigured: 'Unconfigured', + certificateLabel: 'Certificate', + descriptionLine1: + 'Require members to 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', 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..421a63c5efd 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1130,6 +1130,35 @@ export type __internal_LocalizationResource = { messageLine2: LocalizationValue; successMessage: LocalizationValue; }; + securityPage: { + title: LocalizationValue; + removeDialog: { + title: LocalizationValue; + subtitle: LocalizationValue; + confirmButton: 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; diff --git a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx index c35bbf9bb46..2f25c627ac2 100644 --- a/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx +++ b/packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx @@ -1,3 +1,4 @@ +import type { LocalizationKey } from '@/customizables'; import { Col, descriptors, localizationKeys } from '@/customizables'; import { Card } from '@/elements/Card'; import { useCardState, withCardStateProvider } from '@/elements/contexts'; @@ -8,17 +9,19 @@ 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; + /** Defaults to the Reset copy; overridden when the dialog is reused for the Remove action. */ + title?: LocalizationKey; + subtitle?: LocalizationKey; + confirmButtonLabel?: LocalizationKey; }; export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.Element | null => { - const { contentRef } = useConfigureSSO(); - if (!props.isOpen) { return null; } @@ -27,7 +30,7 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El ({ alignItems: 'center', position: 'absolute', @@ -44,9 +47,12 @@ export const ResetConnectionDialog = (props: ResetConnectionDialogProps): JSX.El }; const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnectionDialogProps) => { - const { onClose, confirmationValue } = props; + const { onClose, onDelete, confirmationValue } = props; + const title = props.title ?? localizationKeys('configureSSO.resetConnectionDialog.title'); + const subtitle = props.subtitle ?? localizationKeys('configureSSO.resetConnectionDialog.subtitle'); + const confirmButtonLabel = + props.confirmButtonLabel ?? localizationKeys('configureSSO.resetConnectionDialog.resetButton'); const card = useCardState(); - const { enterpriseConnection, mutations } = useConfigureSSO(); const confirmationField = useFormControl('deleteConfirmation', '', { type: 'text', @@ -60,18 +66,12 @@ 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); + await onDelete(); onClose(); } catch (err) { handleError(err as Error, [confirmationField], card.setError); @@ -85,8 +85,8 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti > ({ textAlign: 'start', padding: t.sizes.$5 })}> ({ gap: t.space.$4 })} > @@ -104,7 +104,7 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti block={false} colorScheme='danger' isDisabled={!canSubmit} - localizationKey={localizationKeys('configureSSO.resetConnectionDialog.resetButton')} + localizationKey={confirmButtonLabel} /> ({ - 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 = ( wrapper: React.ComponentType<{ children?: React.ReactNode }>, - props: { isOpen?: boolean; onClose?: () => void; confirmationValue?: string } = {}, + props: { + isOpen?: boolean; + onClose?: () => void; + confirmationValue?: string; + title?: ReturnType; + subtitle?: ReturnType; + confirmButtonLabel?: ReturnType; + } = {}, ) => { const onClose = props.onClose ?? vi.fn(); const utils = render( @@ -42,6 +29,11 @@ const renderDialog = ( isOpen={props.isOpen ?? true} onClose={onClose} confirmationValue={props.confirmationValue ?? 'Acme Inc'} + onDelete={() => deleteConnection('idn_connection_1')} + contentRef={{ current: null }} + title={props.title} + subtitle={props.subtitle} + confirmButtonLabel={props.confirmButtonLabel} /> , { wrapper }, @@ -52,7 +44,6 @@ const renderDialog = ( const resetMocks = () => { deleteConnection.mockReset(); deleteConnection.mockResolvedValue(undefined); - connectionMockState.current = { id: 'idn_connection_1' }; }; describe('ResetConnectionDialog', () => { @@ -81,6 +72,25 @@ describe('ResetConnectionDialog', () => { expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); }); + it('renders override copy when title, subtitle, and confirm label props are supplied', async () => { + resetMocks(); + const { wrapper } = await createFixtures(); + renderDialog(wrapper, { + confirmationValue: 'Acme Inc', + title: localizationKeys('organizationProfile.securityPage.removeDialog.title'), + subtitle: localizationKeys('organizationProfile.securityPage.removeDialog.subtitle'), + confirmButtonLabel: localizationKeys('organizationProfile.securityPage.removeDialog.confirmButton'), + }); + + expect(screen.getByRole('heading', { name: 'Remove SSO connection' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Reset connection' })).not.toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to remove the connection\?/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove connection' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Reset connection' })).not.toBeInTheDocument(); + // Type-to-confirm is unchanged by the override. + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + it('keeps Reset disabled while the input is empty', async () => { resetMocks(); const { wrapper } = await createFixtures(); diff --git a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx index 96c70eaadd8..e8ec6275839 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx @@ -206,11 +206,11 @@ FooterContinue.displayName = 'Step.Footer.Continue'; * footer row, matching the prior destructive affordance. */ const FooterReset = (): JSX.Element | null => { - const { organizationEnterpriseConnection: c } = useConfigureSSO(); + const { enterpriseConnection, mutations, contentRef } = useConfigureSSO(); const organization = __internal_useOrganizationBase(); const [isOpen, setIsOpen] = useState(false); - if (!c.hasConnection) { + if (!enterpriseConnection) { return null; } @@ -229,6 +229,8 @@ const FooterReset = (): JSX.Element | null => { isOpen={isOpen} onClose={() => setIsOpen(false)} confirmationValue={organization?.name ?? ''} + 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..1a1ac78a0ef --- /dev/null +++ b/packages/ui/src/components/OrganizationProfile/SecuritySsoSection.tsx @@ -0,0 +1,504 @@ +import { iconImageUrl } from '@clerk/shared/constants'; +import type { EnterpriseConnectionResource } from '@clerk/shared/types'; +import type { PropsWithChildren } from 'react'; +import { useState } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileSection } from '@/ui/elements/Section'; +import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; +import { handleError } from '@/utils/errorHandler'; + +import { useEnvironment } from '../../contexts'; +import type { LocalizationKey } from '../../customizables'; +import { Badge, Button, Col, descriptors, Flex, Link, localizationKeys, Span, Text } from '../../customizables'; +import { useFetchRoles, useLocalizeCustomRoles } from '../../hooks/useFetchRoles'; +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'; + +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'), + }, +}; + +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 isConfigured = connection.status === 'active' || connection.status === 'inactive'; + + // The badge and menu read straight from the entity; revalidation drives the settled state. + const status: OrganizationEnterpriseConnectionStatus = connection.status; + const badge = STATUS_BADGES[status]; + + return ( + + } + > + {status === 'unconfigured' && ( + + )} + + {status === 'in_progress' && ( + + )} + + {isConfigured && ( + + + + )} + + ); +}; + +type NotConfiguredContentProps = { + primaryButtonKey: LocalizationKey; + primaryButtonId: string; + onConfigure: () => void; +}; + +const NotConfiguredContent = ({ + primaryButtonKey, + primaryButtonId, + onConfigure, +}: NotConfiguredContentProps): JSX.Element => ( + + + +