refactor(ui): drive ConfigureSSO wizard navigation with a state machine#8715
Draft
iagodahlem wants to merge 34 commits into
Draft
refactor(ui): drive ConfigureSSO wizard navigation with a state machine#8715iagodahlem wants to merge 34 commits into
iagodahlem wants to merge 34 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🦋 Changeset detectedLatest commit: e3d1ae0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@clerk/astro
@clerk/backend
@clerk/chrome-extension
@clerk/clerk-js
@clerk/expo
@clerk/expo-passkeys
@clerk/express
@clerk/fastify
@clerk/hono
@clerk/localizations
@clerk/nextjs
@clerk/nuxt
@clerk/react
@clerk/react-router
@clerk/shared
@clerk/tanstack-react-start
@clerk/testing
@clerk/ui
@clerk/upgrade
@clerk/vue
commit: |
6e9a085 to
5524ad3
Compare
LauraBeatris
reviewed
Jun 3, 2026
LauraBeatris
reviewed
Jun 3, 2026
| // — the wizard engine only ever sees the resolved value, never the meaning. | ||
| // An active connection counts configure + test satisfied so a live connection | ||
| // short-circuits to confirmation (matching the legacy active → confirmation | ||
| // initial step). |
LauraBeatris
reviewed
Jun 3, 2026
Comment on lines
+42
to
+53
| /** | ||
| * Whether the connection is owned by an organization other than the one passed | ||
| * in — i.e. the domain is already claimed elsewhere. | ||
| * | ||
| * Pure relationship between a connection and an organization id; the caller | ||
| * decides whether this matters (e.g. only once the user's email is verified). | ||
| * A `null`/`undefined` connection is never "taken". | ||
| */ | ||
| export const isOwnedByOtherOrganization = ( | ||
| connection: EnterpriseConnectionResource | undefined | null, | ||
| organizationId: string | null | undefined, | ||
| ): boolean => Boolean(connection && connection.organizationId !== (organizationId ?? null)); |
Member
There was a problem hiding this comment.
I believe we can remove this one here - also might need to delete the UI for that on Figma
Reason why, we now are scoping connections per organization, so if the user is a member of that org, there's no case where it would lead to conflicting usage.
Also if they try to add a domain that it's currently used, it would give an error.
LauraBeatris
reviewed
Jun 3, 2026
Comment on lines
+55
to
+66
| /** | ||
| * Whether the user's domain is already taken by another org's connection. | ||
| * | ||
| * Only meaningful once the email backing the domain is verified — an unverified | ||
| * email can never have its domain "taken", so the verified flag gates the | ||
| * ownership check. A pure function of its inputs. | ||
| */ | ||
| export const isDomainTakenByOtherOrganization = ( | ||
| connection: EnterpriseConnectionResource | undefined | null, | ||
| organizationId: string | null | undefined, | ||
| isEmailVerified: boolean, | ||
| ): boolean => isEmailVerified && isOwnedByOtherOrganization(connection, organizationId); |
… part Introduce the React primitives the ConfigureSSO state-machine cutover builds on, without changing the live wizard yet: - useMachine(facts): owns the reducer state and feeds the pure reducer the current facts at dispatch time via a render-updated ref (no effect-based facts sync), keeping dispatch identity stable across server-state refetches. - useSubmitRunner: centralizes the submit lifecycle (clear error -> loading -> advance/jump via NEXT/GOTO or surface error -> idle), building the step ctx from context + the machine dispatch. - Step.Footer.Submit: composed Continue button wired to the runner; owns its in-flight state from the card. - WizardMachineProvider/useWizardMachine: a single sibling context that exposes the machine to steps, footer, and header. - Wizard gains an onComplete terminal hook so a nested flow can bubble its last step into a host state machine instead of a parent wizard. Thread primaryEmailAddress through the data hook + context so the runner can derive the connection name without each step calling useUser.
Cut the live wizard over from the imperative <Wizard> engine to the pure state
machine. Top-level navigation is now reduce/initialState from the reducer, the
ordered STEPS from transitions, and the bodies from STEP_BODIES; steps no longer
own routing.
- ConfigureSSO.tsx mounts useMachine(facts) and renders STEP_BODIES[current]
inside the existing ProfileCard/NavBar chrome, via WizardMachineProvider. The
step-change error effect is gone (the runner clears the card error per submit).
- The header reads the machine + transitions: visible steps are the enabled
steps minus select-provider, completion stays positional (behavior-equivalent
to the old breadcrumb), current is machine.current, clicks dispatch GOTO.
- Simple steps (select-provider, test, confirmation) use Step.Footer.Submit +
the runner; Previous dispatches BACK; confirmation's reconfigure dispatches
GOTO configure and reset dispatches RESET.
- submitSelectProvider returns { ok: true, goTo: 'configure' }: a successful
create flips hasConnection, disabling select-provider, so a plain NEXT would
no-op; GOTO is required.
- Nested-delegating steps (verify-domain, configure) keep their inner <Wizard>
untouched; only their terminal step advances the machine via an injected
onComplete.
Re-point the select-provider tests at the machine dispatch and the submit test
at the new goTo result.
deriveInitialStep (and its test) are dead now that the machine's initialState is the sole authority for where the wizard mounts on (re)load; initialStepId no longer lives on the context. The ResetCardErrorOnStepChange effect sentinel is also gone — the submit runner clears the card error per submit.
…ed primitive
Replace the effect-timed step registration with a domain-agnostic state
machine whose step graph is derived synchronously from <Wizard.Step> children
during render, so the active step is always resolved against a known graph (no
inconsistency window).
- useWizardMachine(config) takes derived step descriptors + a named-guards
record ({ [name]: () => boolean }) and returns the nav surface; guards are
evaluated in the hook layer before dispatch. No domain types leak in.
- The pure reducer (NEXT/PREV/GOTO/RESET) reads the descriptors + guards as an
argument, kept fresh via a render-updated ref so dispatch identity stays
stable.
- useWizard() is now the only consumer surface: goNext / goPrev / goToStep /
reset + the derived current / activeSteps / isFirstStep / isLastStep. The
machine is an internal detail of the primitive.
- <Wizard.Step> carries id / label / guard / enabled / terminal / hidden as
props; children-derivation descends through fragments and intermediate
elements, stopping at nested-wizard and component boundaries.
- Remove the old parallel machine (per-component reducer + machine context +
the layout-effect step registry + the stepBodies/transitions tables).
Wire ConfigureSSO onto the generic Wizard primitive: a new ConfigureSSOSteps component declares the five top-level steps (verify-domain, select-provider hidden, configure, test, confirmation) as <Wizard.Step> children with a named guards record sourced from the existing derived facts. The step graph is now the JSX, replacing the stepBodies map + transitions table. - ConfigureSSO renders <ConfigureSSOSteps/> inside the existing providers; the loading gate stays one level up. - Steps, the breadcrumb, the submit runner, and the reset dialog consume useWizard() only (goNext / goPrev / goToStep / reset) — the state machine is hidden behind the primitive. - Each SAML provider sub-flow now hosts its own nested <Wizard> with statically declared steps so the graph is derivable at that boundary; the configure body mounts the provider component and passes onComplete through to bubble the inner terminal step into the top-level wizard.
Add a pure, tech-agnostic domain module (no React, no hooks) holding the connection-level predicates the ConfigureSSO wizard derives state from: isActive, hasMinimumConfiguration, getProvider, isOwnedByOtherOrganization. Connection facts live here; user/session and test-run facts are composed in the data hooks instead. Unit-tested in isolation.
…tionMutations Move the mutations hook into hooks/ and rename it (and its EnterpriseConnectionMutations type) to drop the wizard-specific naming. The surface is connection-domain only — no wizard/step/navigation concepts — so it can be reused for custom self-serve SSO flows. Every mutation stays useReverification-wrapped. Update all importers.
Move the test-run probe into hooks/ and drop "controller" from the name. The hook becomes the single source of test-run state and now exposes both an initial isLoading (no data yet → full skeleton) and an isFetching signal for background refetches that keep previously-loaded data visible. The Test step's own paginated list is unchanged here; folding it onto this source is a follow-up.
Compose the enterprise-connection flow into one SDK-grade hook: the source query (the single swappable seam for a future endpoints migration), the pure domain predicates, the reverification-wrapped mutations, and the test-run state. It returns the connection data, named derived state, mutations, and a test-run refresh handle — never an opaque facts bag. Delete deriveFacts and the old data/ hook; fold their logic into the domain module + this hook. Retire machine/guards.ts: the wizard guards are now trivial boolean reads sourced from the hook's derived state via context. The context keeps its shape, now fed by this hook. The deriveFacts unit coverage moves into the domain module's tests (including the verified-gated domain-taken predicate).
The Test step ran its own paginated test-run fetch while the umbrella hook also fetched a success probe, so test-runs were fetched in two places. Fold both onto useEnterpriseConnectionTestRuns as the single source: it now owns the success probe, the paginated list the table renders, and the page cursor. The Test step reads rows, totalCount, polling, page, and the table-level loading flag from context and no longer fetches anything itself. Loading is split into two signals: the cold isLoading drives the full skeleton on initial load (the global fetch covers the test-runs), and the list isFetching drives only the table spinner on re-entry while keeping previous rows visible. To honor "initial-load landing on the test step does not refetch, but navigating in later does", the generic Wizard now exposes isInitialStep, backed by a latched hasNavigated flag in the reducer (more robust than history length, which a back-step to the mount step would reset). The step fires a one-shot, mount-only refresh when isInitialStep is false.
A nested wizard's terminal goNext now bubbles to the parent wizard's goNext automatically — the nested wizard already holds the parent in context, so the explicit onComplete callback was redundant. Remove onComplete from the Wizard primitive and its callsites (the configure sub-flow, the verify-domain inner flow, and the four SAML provider sub-flows). A top-level wizard with no parent treats a terminal goNext as a no-op.
Delete the central submit runner and the pure submit use-cases that only existed to feed it. The footer Continue is a plain button again; each step owns a local handleContinue (clear error → set loading → await the context mutation → advance → catch via handleError → settle to idle) and passes it to the footer. SelectProviderStep creates the connection and jumps to configure (goToStep, since the create disables select-provider). TestConfiguration gates Continue on a successful test run, surfacing the inline validation message when none exists. The four SAML provider sub-flows already used this shape. Navigation stays on the useWizard facade; the state machine is never touched directly by a step.
…gate Replace the five standalone enterprise-connection predicate functions with a single pure functional aggregate that composes the connection, the admin's email verification, the test-run state, and the active org id. The verified gate for the domain-taken check now lives inside the aggregate.
…egate Drop the facts/toWizardFacts/state layering. The umbrella hook now builds the OrganizationEnterpriseConnection aggregate once and exposes it; the context surfaces it directly, the wizard guards read it, and each step reads its flow gates off the aggregate instead of a re-bundled facts bag.
Replace the JSX-children step derivation in the ConfigureSSO Wizard with an explicit steps config array. The primitive now takes `steps` (id + body component + optional guard/enabled/hidden) directly and renders the active step by looking it up by id, instead of walking React children to build the graph. A `header` prop carries persistent chrome (breadcrumb / shared step header) that previously sat as a sibling of the steps. Also remove the unused terminal/isTerminal capability: it had no runtime consumers, and the domain-taken dead-end already renders footerless by short-circuiting in the step body. The nested-to-parent goNext/goPrev fall-through (a separate mechanism, driven by NEXT returning referential-equal state) is preserved.
Move every ConfigureSSO wizard onto the new config-array API: - Top-level flow declares a steps array (verify-domain, select-provider, configure, test, confirmation) with guard names, and passes the breadcrumb via the header prop. Drops the standalone domain-taken guard + terminal slot and the stale "host suppresses its footer" JSDoc. - Each SAML provider sub-flow (Okta, Custom, Google, Microsoft) declares its own steps array; the per-step shared header + body is extracted into a body component referenced from the array. - The verify-domain inner provide-email -> verify-email sub-flow uses the same API: the shared step header becomes header chrome, and each sub-step body reads its per-instance refs from a small context so the body components stay at module scope. Behavior is unchanged: the nested sub-flows still advance the parent via the last-step goNext fall-through.
Connections are now fetched through the org-scoped endpoints, so the active organization is always the connection's owner. The "domain taken by another organization" check can never be true, so remove it: the aggregate predicate and its owned-by-other-org input, the wizard guard term, the VerifyDomainStep conflict branch, and the covering tests.
d88d8ea to
2318ef7
Compare
The Wizard steps config array now registers the step graph only (id, guard, enabled, hidden, label) with no body component. Rendering moves back to declarative <Wizard.Match id> children that render only when the active step matches. Removes the body-lookup ActiveStep renderer and the header prop; persistent chrome (breadcrumb, shared sub-flow header) is a normal child again.
…n mutation Update the connection list cache directly via setQueryData on create/update/delete instead of revalidating, so consumers reflect the change immediately without a refetch round-trip.
The Test step refreshes via the TestRunsView's own refresh(); the duplicate top-level passthrough is removed.
Creating an enterprise connection mid-flow used to fire the test-runs query as soon as the new connection id appeared, flipping the global loading flag and flashing the full skeleton for a connection that provably has zero runs. Gate the test-runs queries on whether a connection existed at initial load (captured once during render): an existing connection keeps them active so they fetch on load, cover the initial skeleton, and drive the tested guard; with no connection at load the queries stay dormant through create/configure and are woken only when the user lands on the Test step, where loading surfaces at the table level instead of the global skeleton. The global skeleton folds in test-runs loading only for the existing-connection-at-load case.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
<ConfigureSSO />'s wizard had accreted four structural problems that make it hard to extend and reason about — and would calcify as more providers (Google, Entra, OIDC) and DNS/TXT domain verification land:useUser/useSession/test-run reads were scattered across steps;isDomainTakenByOtherOrgwas computed twice; test runs were read three times.useReverificationwas hand-wrapped in some files and missing on others (connection create, the SAML configure submit, test-run create).setError → setLoading → await → advance → catch → setIdle, patched over with a step-change error-reset effect.This refactors the wizard onto a small state machine, with no changes to the public API.
What changed
NEXT/BACK/GOTO/RESETare pure functions of(state, event, facts); the breadcrumb and footer read from it. Steps no longer route — they emit submit intent and the machine decides where to go. Rendering is match-based (no first-frame flashes).factsobject consumed everywhere. The provider never sees a loading state — the skeleton is gated one level up.useSubmitRunnerowns the submit lifecycle; each step composes its own footer (Submit/Previous/Reset) with local state — no footer-action registry. The two SAML configure twins collapse into one shared step.Test plan
pnpm type-checkclean; no new lint findings.Notes
@clerk/uipatch changeset included.