From 04f73cfc38bfb23c7ecafe4b422522afe97d950a Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 11:00:48 -0300 Subject: [PATCH 1/5] feat(ui): add status to the organization enterprise connection entity --- .../organizationEnterpriseConnection.test.ts | 69 +++++++++++++++++++ .../organizationEnterpriseConnection.ts | 69 ++++++++++++++++--- 2 files changed, 130 insertions(+), 8 deletions(-) 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..ff1716d6c24 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,74 @@ 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', () => { + // `inactive` requires BOTH minimum configuration and a successful run; a + // stray successful run on an unconfigured connection stays 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 +236,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..17a7870b35c 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -29,6 +29,20 @@ export interface OrganizationEnterpriseConnectionInput { hasSuccessfulTestRun: boolean; } +/** + * The display-facing summary of the connection lifecycle — the state model the + * Security page's badge/states read from. The wizard's navigation guards keep + * reading the raw booleans; this only collapses them into one label for UI that + * talks about the connection as a whole. + * + * - `unconfigured` — no connection exists yet. + * - `in_progress` — a connection exists but is mid-configuration, or configured + * but not yet successfully tested. + * - `inactive` — fully built and successfully tested, just toggled off. + * - `active` — the connection is live. + */ +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 +54,8 @@ export interface OrganizationEnterpriseConnection { readonly hasMinimumConfiguration: boolean; readonly isPrimaryEmailVerified: boolean; readonly hasSuccessfulTestRun: boolean; + /** The lifecycle summary derived from the booleans above — see {@link OrganizationEnterpriseConnectionStatus}. */ + readonly status: OrganizationEnterpriseConnectionStatus; } // TODO - Update to support OpenID Connect @@ -47,15 +63,52 @@ export const isEnterpriseConnectionConfigured = ( connection: EnterpriseConnectionResource | null | undefined, ): boolean => Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId); +/** + * Collapses the lifecycle booleans into the display-facing status. Precedence, + * first match wins: + * + * 1. no connection → `unconfigured` + * 2. connection active → `active` (activation trumps configuration/test state) + * 3. minimally configured AND successfully tested → `inactive` (fully built, toggled off) + * 4. otherwise → `in_progress` (mid-configuration, or configured but not yet successfully tested) + */ +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 }), + }; +}; From 0720136f494423e32ec054798d21bdd868807360 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 11:04:40 -0300 Subject: [PATCH 2/5] refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host --- .changeset/configure-sso-wizard-extraction.md | 2 + .../components/ConfigureSSO/ConfigureSSO.tsx | 26 +++++++---- .../ConfigureSSO/ConfigureSSOWizard.tsx | 24 ++++++++++ .../OrganizationSecurityPage.tsx | 46 ++++++++++++++++++- 4 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 .changeset/configure-sso-wizard-extraction.md create mode 100644 packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx diff --git a/.changeset/configure-sso-wizard-extraction.md b/.changeset/configure-sso-wizard-extraction.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/configure-sso-wizard-extraction.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..b54489ac5ba 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'; @@ -38,13 +37,19 @@ const AuthenticatedContent = withCoreUserGuard(() => { sx={t => ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })} > - + ); }); -export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { +/** + * The standalone mount's data owner: fetches through the umbrella hook, gates + * loading and permission, and injects the resolved data into the pure + * `ConfigureSSOWizard` — mirroring what the Security page does for the + * `OrganizationProfile` mount. + */ +const StandaloneConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { const { isLoading, enterpriseConnection, @@ -64,21 +69,24 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec return ( - - - + /> ); }; -const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => { +/** + * Permission gate shared by the wizard's hosts: renders the missing-permission + * state unless the active organization membership can manage enterprise + * connections (personal workspaces pass — 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/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx new file mode 100644 index 00000000000..68be7d9d916 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx @@ -0,0 +1,24 @@ +import type { ComponentProps } from 'react'; + +import { ConfigureSSOProvider } from './ConfigureSSOContext'; +import { ConfigureSSOSteps } from './ConfigureSSOSteps'; + +/** + * The wizard's data surface — exactly `ConfigureSSOProvider`'s props, derived + * from the provider so the two can never drift. + */ +export type ConfigureSSOWizardProps = Omit, 'children'>; + +/** + * The pure, data-injected ConfigureSSO flow: the provider seeded with + * host-supplied data plus the step graph. It performs no fetching and never + * observes a loading state — hosts own data fetching, the loading skeleton, and + * permission gating, and inject the resolved data here. Today's hosts are the + * Security page in `OrganizationProfile` and the standalone `ConfigureSSO` + * component backing the internal mount. + */ +export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element => ( + + + +); diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 0dd215f9f77..3f46002722f 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,44 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag return null; } - return ; + return ; +}; + +/** + * The page-owned data layer: fetches through the umbrella hook, gates loading + * and permission, and injects the resolved data into the pure + * `ConfigureSSOWizard`. 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, + enterpriseConnection, + organizationEnterpriseConnection, + testRuns, + mutations, + 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. + if (isLoading) { + return ; + } + + return ( + + + + ); }; From a60265fc681750f389bd79649a7f6beeb44f67de Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 11:47:14 -0300 Subject: [PATCH 3/5] refactor(ui): inline the ConfigureSSO step graph into the wizard The standalone host content keeps its original ConfigureSSOContent name. --- .../components/ConfigureSSO/ConfigureSSO.tsx | 4 +- .../ConfigureSSO/ConfigureSSOSteps.tsx | 75 --------------- .../ConfigureSSO/ConfigureSSOWizard.tsx | 94 ++++++++++++++++--- .../ConfigureSSO.navigation.test.tsx | 2 +- 4 files changed, 85 insertions(+), 90 deletions(-) delete mode 100644 packages/ui/src/components/ConfigureSSO/ConfigureSSOSteps.tsx diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index b54489ac5ba..116189c567c 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -37,7 +37,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { sx={t => ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })} > - + ); @@ -49,7 +49,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { * `ConfigureSSOWizard` — mirroring what the Security page does for the * `OrganizationProfile` mount. */ -const StandaloneConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { +const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { const { isLoading, enterpriseConnection, 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 index 68be7d9d916..66743f887fd 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx @@ -1,7 +1,12 @@ -import type { ComponentProps } from 'react'; +import React, { type ComponentProps } from 'react'; + +import { CardStateProvider } from '@/elements/contexts'; import { ConfigureSSOProvider } from './ConfigureSSOContext'; -import { ConfigureSSOSteps } from './ConfigureSSOSteps'; +import { ConfigureSSOHeader } from './ConfigureSSOHeader'; +import { type WizardStepConfig } from './elements/Wizard'; +import { Wizard } from './elements/Wizard'; +import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps'; /** * The wizard's data surface — exactly `ConfigureSSOProvider`'s props, derived @@ -11,14 +16,79 @@ export type ConfigureSSOWizardProps = Omit` derives its + * machine from. It performs no fetching and never observes a loading state — + * hosts own data fetching, the loading skeleton, and permission gating, and + * inject the resolved data here. Today's hosts are the Security page in + * `OrganizationProfile` and the standalone `ConfigureSSO` component backing the + * internal mount. */ -export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element => ( - - - -); +export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element => { + // The guards read the connection from the prop, not from `useConfigureSSO()`: + // this component renders the provider itself, so the hook would throw at this + // level. The step bodies render below the provider and keep reading context. + 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], + ); + + // 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/__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 From 25afad42e9f3baa89416e96d61b79b28855abff6 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 12:14:42 -0300 Subject: [PATCH 4/5] chore(repo): recreate the empty changeset via the changesets CLI --- .../{configure-sso-wizard-extraction.md => big-files-shop.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changeset/{configure-sso-wizard-extraction.md => big-files-shop.md} (100%) diff --git a/.changeset/configure-sso-wizard-extraction.md b/.changeset/big-files-shop.md similarity index 100% rename from .changeset/configure-sso-wizard-extraction.md rename to .changeset/big-files-shop.md From 9a40534cb620c2dcf1ff7f2cc5185dc6d5f17854 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 10 Jun 2026 12:22:47 -0300 Subject: [PATCH 5/5] refactor(ui): trim comments across the wizard extraction --- .../components/ConfigureSSO/ConfigureSSO.tsx | 17 ++-------- .../ConfigureSSO/ConfigureSSOWizard.tsx | 33 ++----------------- .../organizationEnterpriseConnection.test.ts | 2 -- .../organizationEnterpriseConnection.ts | 24 +------------- .../OrganizationSecurityPage.tsx | 13 ++------ 5 files changed, 8 insertions(+), 81 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 116189c567c..c43b9640f65 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -43,12 +43,6 @@ const AuthenticatedContent = withCoreUserGuard(() => { ); }); -/** - * The standalone mount's data owner: fetches through the umbrella hook, gates - * loading and permission, and injects the resolved data into the pure - * `ConfigureSSOWizard` — mirroring what the Security page does for the - * `OrganizationProfile` mount. - */ const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { const { isLoading, @@ -59,10 +53,7 @@ const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject; } @@ -81,11 +72,7 @@ const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject { const { session } = useSession(); const isPersonalWorkspace = !session?.lastActiveOrganizationId; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx index 66743f887fd..231a871e348 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx @@ -8,25 +8,11 @@ import { type WizardStepConfig } from './elements/Wizard'; import { Wizard } from './elements/Wizard'; import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps'; -/** - * The wizard's data surface — exactly `ConfigureSSOProvider`'s props, derived - * from the provider so the two can never drift. - */ export type ConfigureSSOWizardProps = Omit, 'children'>; -/** - * The pure, data-injected ConfigureSSO flow: the provider seeded with - * host-supplied data plus the step graph the entry-guard `` derives its - * machine from. It performs no fetching and never observes a loading state — - * hosts own data fetching, the loading skeleton, and permission gating, and - * inject the resolved data here. Today's hosts are the Security page in - * `OrganizationProfile` and the standalone `ConfigureSSO` component backing the - * internal mount. - */ +/** Pure, data-injected ConfigureSSO flow — hosts own fetching, loading, and permission gating. */ export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element => { - // The guards read the connection from the prop, not from `useConfigureSSO()`: - // this component renders the provider itself, so the hook would throw at this - // level. The step bodies render below the provider and keep reading context. + // 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( @@ -40,20 +26,7 @@ export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element [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. + // 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/domain/__tests__/organizationEnterpriseConnection.test.ts b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts index ff1716d6c24..58ed35d632c 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts @@ -174,8 +174,6 @@ describe('organizationEnterpriseConnection', () => { ).toBe('in_progress'); }); it('successfully tested but not minimally configured → in_progress', () => { - // `inactive` requires BOTH minimum configuration and a successful run; a - // stray successful run on an unconfigured connection stays in_progress. expect( derive({ connection: makeConnection({ samlConnection: null, active: false }), diff --git a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts index 17a7870b35c..ffcde3c63db 100644 --- a/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts +++ b/packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts @@ -23,24 +23,12 @@ 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; } -/** - * The display-facing summary of the connection lifecycle — the state model the - * Security page's badge/states read from. The wizard's navigation guards keep - * reading the raw booleans; this only collapses them into one label for UI that - * talks about the connection as a whole. - * - * - `unconfigured` — no connection exists yet. - * - `in_progress` — a connection exists but is mid-configuration, or configured - * but not yet successfully tested. - * - `inactive` — fully built and successfully tested, just toggled off. - * - `active` — the connection is live. - */ +/** Display-facing lifecycle summary — the wizard's navigation guards keep reading the raw booleans. */ export type OrganizationEnterpriseConnectionStatus = 'unconfigured' | 'in_progress' | 'active' | 'inactive'; /** @@ -54,7 +42,6 @@ export interface OrganizationEnterpriseConnection { readonly hasMinimumConfiguration: boolean; readonly isPrimaryEmailVerified: boolean; readonly hasSuccessfulTestRun: boolean; - /** The lifecycle summary derived from the booleans above — see {@link OrganizationEnterpriseConnectionStatus}. */ readonly status: OrganizationEnterpriseConnectionStatus; } @@ -63,15 +50,6 @@ export const isEnterpriseConnectionConfigured = ( connection: EnterpriseConnectionResource | null | undefined, ): boolean => Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId); -/** - * Collapses the lifecycle booleans into the display-facing status. Precedence, - * first match wins: - * - * 1. no connection → `unconfigured` - * 2. connection active → `active` (activation trumps configuration/test state) - * 3. minimally configured AND successfully tested → `inactive` (fully built, toggled off) - * 4. otherwise → `in_progress` (mid-configuration, or configured but not yet successfully tested) - */ const connectionStatus = ({ hasConnection, isActive, diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx index 3f46002722f..87ff924593d 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationSecurityPage.tsx @@ -20,13 +20,7 @@ export const OrganizationSecurityPage = ({ contentRef }: OrganizationSecurityPag return ; }; -/** - * The page-owned data layer: fetches through the umbrella hook, gates loading - * and permission, and injects the resolved data into the pure - * `ConfigureSSOWizard`. A separate component below the page (rather than - * inlined into it) so the connection hook only ever runs behind the page's - * organization check. - */ +/** Separate from the page so the connection hook only runs behind the organization check. */ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPageProps) => { const { isLoading, @@ -37,10 +31,7 @@ const OrganizationSecurityPageContent = ({ contentRef }: OrganizationSecurityPag 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 ; }