diff --git a/.changeset/big-files-shop.md b/.changeset/big-files-shop.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/big-files-shop.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 05aaffd7cc4..c43b9640f65 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -10,10 +10,9 @@ import { ProfileCard } from '@/elements/ProfileCard'; import { ExclamationTriangle } from '@/icons'; import { Route, Switch } from '@/router'; -import { ConfigureSSOProvider } from './ConfigureSSOContext'; import { ConfigureSSONavbar } from './ConfigureSSONavbar'; import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton'; -import { ConfigureSSOSteps } from './ConfigureSSOSteps'; +import { ConfigureSSOWizard } from './ConfigureSSOWizard'; import { ProfileCardFooter, ProfileCardHeader } from './elements/ProfileCard'; import { Step } from './elements/Step'; import { useOrganizationEnterpriseConnection } from './hooks/useOrganizationEnterpriseConnection'; @@ -44,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { ); }); -export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { +const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { const { isLoading, enterpriseConnection, @@ -54,31 +53,27 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec primaryEmailAddress, } = useOrganizationEnterpriseConnection(); - // Gate loading one level above the provider so the context never observes a - // loading state. The single test-run source is part of this initial fetch - // when a connection exists at load, so a cold landing on the test step is - // covered by the full skeleton here. + // Gate loading above the provider so the context never observes a loading state. if (isLoading) { return ; } return ( - - - + /> ); }; -const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => { +/** Permission gate shared by the wizard's hosts — personal workspaces pass, since there is no membership to check. */ +export const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => { const { session } = useSession(); const isPersonalWorkspace = !session?.lastActiveOrganizationId; const canManageEnterpriseConnections = useProtect( diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOSteps.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOSteps.tsx deleted file mode 100644 index b43e766f14f..00000000000 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOSteps.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; - -import { CardStateProvider } from '@/elements/contexts'; - -import { useConfigureSSO } from './ConfigureSSOContext'; -import { ConfigureSSOHeader } from './ConfigureSSOHeader'; -import { type WizardStepConfig } from './elements/Wizard'; -import { Wizard } from './elements/Wizard'; -import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps'; - -/** The ConfigureSSO step graph the entry-guard `` derives its machine from. */ -export const ConfigureSSOSteps = (): JSX.Element => { - const { organizationEnterpriseConnection: c } = useConfigureSSO(); - - const steps = React.useMemo( - () => [ - { id: 'verify-domain', label: 'Verify domain' }, - { id: 'select-provider', guard: () => c.isPrimaryEmailVerified }, - { id: 'configure', label: 'Configure', guard: () => c.isPrimaryEmailVerified && c.hasConnection }, - { id: 'test', label: 'Test', guard: () => c.hasMinimumConfiguration || c.isActive }, - { id: 'confirmation', label: 'Confirmation', guard: () => c.hasSuccessfulTestRun || c.isActive }, - ], - [c], - ); - - // Reset does NOT remount the wizard. Deleting the connection breaks the active - // step's entry guard, and the machine self-corrects in its render phase, - // re-seating to the furthest-reachable step (the same `initialState` - // derivation it uses on mount) — no key, no remount, no create-flash. - // - // Each top-level step owns its own `CardStateProvider`, so a card-level error - // raised on one step lives in that step's scope only. When the wizard moves - // (a breadcrumb jump, back-nav, or an emergent clamp/reset re-seat), the - // departed step unmounts and its card state — error and all — goes with it; - // the step we land on mounts a fresh, clean card scope. No cross-step error - // bleed, and no wizard-level callback needed to clear it. The root provider on - // `ConfigureSSO` stays as an ancestor for shared elements; these step-level - // providers shadow it. Context flows through portals, so the footer/dialog - // subtrees resolve to their step's provider. - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx new file mode 100644 index 00000000000..231a871e348 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx @@ -0,0 +1,67 @@ +import React, { type ComponentProps } from 'react'; + +import { CardStateProvider } from '@/elements/contexts'; + +import { ConfigureSSOProvider } from './ConfigureSSOContext'; +import { ConfigureSSOHeader } from './ConfigureSSOHeader'; +import { type WizardStepConfig } from './elements/Wizard'; +import { Wizard } from './elements/Wizard'; +import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps'; + +export type ConfigureSSOWizardProps = Omit, 'children'>; + +/** Pure, data-injected ConfigureSSO flow — hosts own fetching, loading, and permission gating. */ +export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element => { + // Guards read from props, not `useConfigureSSO()` — this component renders the provider, so the hook would throw here. + const { organizationEnterpriseConnection: c } = props; + + const steps = React.useMemo( + () => [ + { id: 'verify-domain', label: 'Verify domain' }, + { id: 'select-provider', guard: () => c.isPrimaryEmailVerified }, + { id: 'configure', label: 'Configure', guard: () => c.isPrimaryEmailVerified && c.hasConnection }, + { id: 'test', label: 'Test', guard: () => c.hasMinimumConfiguration || c.isActive }, + { id: 'confirmation', label: 'Confirmation', guard: () => c.hasSuccessfulTestRun || c.isActive }, + ], + [c], + ); + + // Each step owns a `CardStateProvider` so card errors stay scoped to their step and clear when it unmounts. + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.navigation.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.navigation.test.tsx index eb6b7a91cf8..7d73059a897 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.navigation.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.navigation.test.tsx @@ -7,7 +7,7 @@ import { ConfigureSSO } from '../ConfigureSSO'; // Integration coverage for the wizard's navigation contract at the rendered- // component level — the real `ConfigureSSO` → `useOrganizationEnterpriseConnection` -// → `ConfigureSSOSteps` → `` → `useWizardMachine` → step wiring, driven +// → `ConfigureSSOWizard` → `` → `useWizardMachine` → step wiring, driven // only through the connection data the (auto-mocked) FAPI handles return. The // machine-level behaviours (defer/resolve, clamp) are unit-tested in // `useWizardMachine.test.tsx`; these tests prove those behaviours hold when the diff --git a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts index 867c53d5506..58ed35d632c 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts @@ -146,6 +146,72 @@ describe('organizationEnterpriseConnection', () => { }); }); + describe('status', () => { + it('undefined connection → unconfigured', () => { + expect(derive({ connection: undefined }).status).toBe('unconfigured'); + }); + it('null connection → unconfigured', () => { + expect(derive({ connection: null }).status).toBe('unconfigured'); + }); + it('created but unconfigured connection → in_progress', () => { + expect(derive({ connection: makeConnection({ samlConnection: null }) }).status).toBe('in_progress'); + }); + it('partially configured connection → in_progress', () => { + expect( + derive({ + connection: makeConnection({ + samlConnection: makeSamlConnection({ idpSsoUrl: 'https://idp.example.com/sso' }), + }), + }).status, + ).toBe('in_progress'); + }); + it('configured but not yet successfully tested → in_progress', () => { + expect( + derive({ + connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: false }), + hasSuccessfulTestRun: false, + }).status, + ).toBe('in_progress'); + }); + it('successfully tested but not minimally configured → in_progress', () => { + expect( + derive({ + connection: makeConnection({ samlConnection: null, active: false }), + hasSuccessfulTestRun: true, + }).status, + ).toBe('in_progress'); + }); + it('configured + successfully tested + not active → inactive', () => { + expect( + derive({ + connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: false }), + hasSuccessfulTestRun: true, + }).status, + ).toBe('inactive'); + }); + it('active connection → active', () => { + expect(derive({ connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: true }) }).status).toBe( + 'active', + ); + }); + it('active wins over configured + successfully tested', () => { + expect( + derive({ + connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: true }), + hasSuccessfulTestRun: true, + }).status, + ).toBe('active'); + }); + it('active wins even for an unconfigured, untested connection', () => { + expect( + derive({ + connection: makeConnection({ samlConnection: null, active: true }), + hasSuccessfulTestRun: false, + }).status, + ).toBe('active'); + }); + }); + it('is pure: identical inputs produce a deep-equal entity', () => { const connection = makeConnection({ samlConnection: fullyConfiguredSaml, active: true }); const primaryEmail = makeEmail('verified'); @@ -168,6 +234,7 @@ describe('organizationEnterpriseConnection', () => { hasMinimumConfiguration: true, isPrimaryEmailVerified: true, hasSuccessfulTestRun: true, + status: 'active', }); }); }); diff --git a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts index b334f08772d..ffcde3c63db 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -23,12 +23,14 @@ export const connectionBackingEmail = (user: UserResource | null | undefined): E export interface OrganizationEnterpriseConnectionInput { /** FAPI currently supports a single connection per organization. */ connection: EnterpriseConnectionResource | null | undefined; - /** The email address whose domain backs the connection. */ primaryEmail: EmailAddressResource | null | undefined; /** Probed upstream — not a property of the connection resource itself. */ hasSuccessfulTestRun: boolean; } +/** Display-facing lifecycle summary — the wizard's navigation guards keep reading the raw booleans. */ +export type OrganizationEnterpriseConnectionStatus = 'unconfigured' | 'in_progress' | 'active' | 'inactive'; + /** * The active organization's SSO-config domain entity: an immutable, pure value * object the wizard makes every flow decision from. A snapshot of flattened booleans/values. @@ -40,6 +42,7 @@ export interface OrganizationEnterpriseConnection { readonly hasMinimumConfiguration: boolean; readonly isPrimaryEmailVerified: boolean; readonly hasSuccessfulTestRun: boolean; + readonly status: OrganizationEnterpriseConnectionStatus; } // TODO - Update to support OpenID Connect @@ -47,15 +50,43 @@ export const isEnterpriseConnectionConfigured = ( connection: EnterpriseConnectionResource | null | undefined, ): boolean => Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId); +const connectionStatus = ({ + hasConnection, + isActive, + hasMinimumConfiguration, + hasSuccessfulTestRun, +}: Pick< + OrganizationEnterpriseConnection, + 'hasConnection' | 'isActive' | 'hasMinimumConfiguration' | 'hasSuccessfulTestRun' +>): OrganizationEnterpriseConnectionStatus => { + if (!hasConnection) { + return 'unconfigured'; + } + if (isActive) { + return 'active'; + } + if (hasMinimumConfiguration && hasSuccessfulTestRun) { + return 'inactive'; + } + return 'in_progress'; +}; + export const organizationEnterpriseConnection = ({ connection, primaryEmail, hasSuccessfulTestRun, -}: OrganizationEnterpriseConnectionInput): OrganizationEnterpriseConnection => ({ - provider: connection?.provider as ProviderType | undefined, - hasConnection: Boolean(connection), - isActive: Boolean(connection?.active), - hasMinimumConfiguration: isEnterpriseConnectionConfigured(connection), - isPrimaryEmailVerified: primaryEmail?.verification?.status === 'verified', - hasSuccessfulTestRun, -}); +}: OrganizationEnterpriseConnectionInput): OrganizationEnterpriseConnection => { + const hasConnection = Boolean(connection); + const isActive = Boolean(connection?.active); + const hasMinimumConfiguration = isEnterpriseConnectionConfigured(connection); + + return { + provider: connection?.provider as ProviderType | undefined, + hasConnection, + isActive, + hasMinimumConfiguration, + isPrimaryEmailVerified: primaryEmail?.verification?.status === 'verified', + hasSuccessfulTestRun, + status: connectionStatus({ hasConnection, isActive, hasMinimumConfiguration, hasSuccessfulTestRun }), + }; +}; diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 0dd215f9f77..87ff924593d 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -1,6 +1,9 @@ import { useOrganization } from '@clerk/shared/react'; -import { ConfigureSSOContent } from '../ConfigureSSO/ConfigureSSO'; +import { ConfigureSSOProtect } from '../ConfigureSSO/ConfigureSSO'; +import { ConfigureSSOSkeleton } from '../ConfigureSSO/ConfigureSSOSkeleton'; +import { ConfigureSSOWizard } from '../ConfigureSSO/ConfigureSSOWizard'; +import { useOrganizationEnterpriseConnection } from '../ConfigureSSO/hooks/useOrganizationEnterpriseConnection'; type OrganizationSecurityPageProps = { contentRef: React.RefObject; @@ -14,5 +17,35 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag return null; } - return ; + return ; +}; + +/** Separate from the page so the connection hook only runs behind the organization check. */ +const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => { + const { + isLoading, + enterpriseConnection, + organizationEnterpriseConnection, + testRuns, + mutations, + primaryEmailAddress, + } = useOrganizationEnterpriseConnection(); + + // Gate loading above the provider so the context never observes a loading state. + if (isLoading) { + return ; + } + + return ( + + + + ); };