Skip to content

Commit d1f3ede

Browse files
committed
refactor(ui): drive ConfigureSSO navigation from the state machine
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.
1 parent db9ad76 commit d1f3ede

12 files changed

Lines changed: 226 additions & 271 deletions

File tree

packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx

Lines changed: 28 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import React from 'react';
55
import { useProtect } from '@/common';
66
import { withCoreUserGuard } from '@/contexts';
77
import { Col, Flex, Flow, Heading, Icon, localizationKeys, Text } from '@/customizables';
8-
import { useCardState, withCardStateProvider } from '@/elements/contexts';
8+
import { withCardStateProvider } from '@/elements/contexts';
99
import { ProfileCard } from '@/elements/ProfileCard';
1010
import { ExclamationTriangle } from '@/icons';
1111
import { Route, Switch } from '@/router';
@@ -17,8 +17,9 @@ import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton';
1717
import { useConfigureSSOData } from './data/useConfigureSSOData';
1818
import { ProfileCardFooter, ProfileCardHeader } from './elements/ProfileCard';
1919
import { Step } from './elements/Step';
20-
import { useWizard, Wizard } from './elements/Wizard';
21-
import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps';
20+
import { useMachine } from './elements/useMachine';
21+
import { WizardMachineProvider } from './elements/WizardMachineContext';
22+
import { STEP_BODIES } from './machine/stepBodies';
2223

2324
const ConfigureSSOInternal = () => {
2425
return (
@@ -47,7 +48,8 @@ const AuthenticatedContent = withCoreUserGuard(() => {
4748
});
4849

4950
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
50-
const { isLoading, enterpriseConnection, facts, refreshTestRuns, mutations } = useConfigureSSOData();
51+
const { isLoading, enterpriseConnection, facts, refreshTestRuns, mutations, primaryEmailAddress } =
52+
useConfigureSSOData();
5153

5254
// Gate loading one level above the provider so the context never observes a
5355
// loading state.
@@ -63,62 +65,39 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec
6365
enterpriseConnection={enterpriseConnection}
6466
contentRef={contentRef}
6567
mutations={mutations}
68+
primaryEmailAddress={primaryEmailAddress}
6669
>
6770
<ConfigureSSOSteps />
6871
</ConfigureSSOProvider>
6972
</ConfigureSSOProtect>
7073
);
7174
};
7275

76+
/**
77+
* The wizard surface, driven by the pure state machine.
78+
*
79+
* `useMachine(facts)` owns where we are (`machine.current`) and how we got
80+
* there; the only view edge is `STEP_BODIES[machine.current]`, which mounts the
81+
* body for the active step. The machine is published via `WizardMachineProvider`
82+
* (a sibling to `ConfigureSSOProvider`, mounted at the same level — no inner
83+
* provider) so the header, footer, and steps read it.
84+
*
85+
* Steps no longer own routing: simple steps advance via `Step.Footer.Submit` ->
86+
* `useSubmitRunner` -> `dispatch`, and the nested-delegating steps
87+
* (verify-domain, configure) bubble their inner terminal step into the machine
88+
* through an injected `onComplete`.
89+
*/
7390
const ConfigureSSOSteps = () => {
74-
const { initialStepId, enterpriseConnection } = useConfigureSSO();
91+
const { facts } = useConfigureSSO();
92+
const machine = useMachine(facts);
93+
94+
const Body = STEP_BODIES[machine.current];
7595

7696
return (
77-
<Wizard initialStepId={initialStepId}>
78-
<ResetCardErrorOnStepChange />
97+
<WizardMachineProvider machine={machine}>
7998
<ConfigureSSOHeader />
80-
81-
{/*
82-
* `select-provider` is only a wizard step while there's no enterprise
83-
* connection yet — creating one unregisters this step, which:
84-
* 1. Hides it from the breadcrumb (no need for a manual filter), and
85-
* 2. Prevents `goPrev` from any later step (e.g. configure's first
86-
* substep) from ever bubbling back into provider selection.
87-
*/}
88-
{!enterpriseConnection && (
89-
<Wizard.Step id='select-provider'>
90-
<SelectProviderStep />
91-
</Wizard.Step>
92-
)}
93-
94-
<Wizard.Step
95-
id='verify-domain'
96-
label='Verify domain'
97-
>
98-
<VerifyDomainStep />
99-
</Wizard.Step>
100-
101-
<Wizard.Step
102-
id='configure'
103-
label='Configure'
104-
>
105-
<ConfigureStep />
106-
</Wizard.Step>
107-
108-
<Wizard.Step
109-
id='test'
110-
label='Test'
111-
>
112-
<TestConfigurationStep />
113-
</Wizard.Step>
114-
115-
<Wizard.Step
116-
id='confirmation'
117-
label='Confirmation'
118-
>
119-
<ConfirmationStep />
120-
</Wizard.Step>
121-
</Wizard>
99+
<Body />
100+
</WizardMachineProvider>
122101
);
123102
};
124103

@@ -180,27 +159,4 @@ const MissingManageEnterpriseConnectionsPermission = () => (
180159
</>
181160
);
182161

183-
/**
184-
* Sentinel component rendered inside `<Wizard>`
185-
*
186-
* Clears any card-level error whenever the active step transitions, so a stale failure from one step
187-
* doesn't leak into the next
188-
*/
189-
const ResetCardErrorOnStepChange = (): null => {
190-
const { currentStep } = useWizard();
191-
const card = useCardState();
192-
const previousStepIdRef = React.useRef(currentStep?.id);
193-
194-
React.useEffect(() => {
195-
if (previousStepIdRef.current === currentStep?.id) {
196-
return;
197-
}
198-
199-
previousStepIdRef.current = currentStep?.id;
200-
card.setError(undefined);
201-
}, [currentStep?.id, card]);
202-
203-
return null;
204-
};
205-
206162
export const ConfigureSSO: React.ComponentType<ConfigureSSOProps> = withCardStateProvider(ConfigureSSOInternal);

packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,35 @@
11
import { useLocalizations } from '@/customizables';
22

3+
import { useConfigureSSO } from './ConfigureSSOContext';
34
import { ProfileCardHeader } from './elements/ProfileCard';
45
import { Stepper } from './elements/Stepper';
5-
import { useWizard } from './elements/Wizard';
6+
import { useWizardMachine } from './elements/WizardMachineContext';
7+
import { STEPS } from './machine/transitions';
68

9+
/**
10+
* The wizard breadcrumb, driven by the pure state machine + transitions.
11+
*
12+
* Visible steps are the enabled steps for the current facts, minus
13+
* `select-provider` (per design it never appears in the breadcrumb, even while
14+
* it's an active step). Completion stays positional — exactly as the legacy
15+
* `useWizard()`-backed header computed it (`index < currentIndex`) — so the
16+
* breadcrumb's visual completion state is behavior-equivalent. The current step
17+
* is `machine.current`; clicking a reachable item jumps via `GOTO`.
18+
*/
719
export const ConfigureSSOHeader = (): JSX.Element => {
8-
const { activeSteps, currentStep, goToStep } = useWizard();
20+
const { facts } = useConfigureSSO();
21+
const { current, dispatch } = useWizardMachine();
922
const { t } = useLocalizations();
1023

11-
// `select-provider` is only mounted while there's no enterprise connection,
12-
// but per design it should never appear in the visual breadcrumb regardless,
13-
// so we always filter it out here
14-
const visibleSteps = activeSteps.filter(step => step.id !== 'select-provider');
15-
const currentIndex = visibleSteps.findIndex(step => step.id === currentStep?.id);
24+
const visibleSteps = STEPS.filter(step => step.id !== 'select-provider' && (step.enabled?.(facts) ?? true));
25+
const currentIndex = visibleSteps.findIndex(step => step.id === current);
1626

1727
return (
1828
<ProfileCardHeader>
1929
<Stepper>
2030
{visibleSteps.map((step, index) => {
2131
const isCurrent = index === currentIndex;
22-
const isCompleted = step.isCompleted ?? index < currentIndex;
32+
const isCompleted = index < currentIndex;
2333
const isReachable = isCompleted || index <= currentIndex;
2434
const labelText = step.label ? (typeof step.label === 'string' ? step.label : t(step.label)) : '';
2535

@@ -30,7 +40,7 @@ export const ConfigureSSOHeader = (): JSX.Element => {
3040
isCurrent={isCurrent}
3141
isCompleted={isCompleted}
3242
isReachable={isReachable}
33-
onClick={() => void goToStep(step.id)}
43+
onClick={() => dispatch({ type: 'GOTO', step: step.id })}
3444
>
3545
{labelText}
3646
</Stepper.Item>

packages/ui/src/components/ConfigureSSO/ResetConnectionDialog.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useFormControl } from '@/ui/utils/useFormControl';
99
import { handleError } from '@/utils/errorHandler';
1010

1111
import { useConfigureSSO } from './ConfigureSSOContext';
12-
import { useWizard } from './elements/Wizard';
12+
import { useWizardMachine } from './elements/WizardMachineContext';
1313

1414
type ResetConnectionDialogProps = {
1515
isOpen: boolean;
@@ -52,7 +52,7 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti
5252
setProvider,
5353
mutations: { deleteConnection },
5454
} = useConfigureSSO();
55-
const { goToStep } = useWizard();
55+
const { dispatch } = useWizardMachine();
5656

5757
const confirmationField = useFormControl('deleteConfirmation', '', {
5858
type: 'text',
@@ -73,7 +73,9 @@ const ResetConnectionDialogContent = withCardStateProvider((props: ResetConnecti
7373
try {
7474
await deleteConnection(enterpriseConnection.id);
7575
setProvider(undefined);
76-
await goToStep('select-provider');
76+
// RESET force-enables select-provider: `facts.hasConnection` won't have
77+
// refetched after the delete, so a plain GOTO would be gated out.
78+
dispatch({ type: 'RESET' });
7779
onClose();
7880
} catch (err) {
7981
handleError(err as Error, [confirmationField], card.setError);

packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,52 @@ describe('ConfigureSSO', () => {
6767
expect(queryByText(/you do not have permission to manage single sign-on/i)).not.toBeInTheDocument();
6868
});
6969
});
70+
71+
describe('state machine mounts on the right step', () => {
72+
it('mounts on select-provider with a verified email and no connection', async () => {
73+
const { wrapper, fixtures } = await createFixtures(f => {
74+
f.withEnterpriseSso({ selfServeSSO: true });
75+
f.withEmailAddress();
76+
f.withUser({ email_addresses: ['test@clerk.com'] });
77+
});
78+
79+
fixtures.clerk.user?.getEnterpriseConnections.mockResolvedValue([]);
80+
81+
const { findByText } = render(<ConfigureSSO />, { wrapper });
82+
83+
// Verified primary email fulfills verify-domain, so the machine skips it
84+
// and lands on select-provider — the first non-fulfilled enabled step.
85+
await findByText(/select your identity provider/i);
86+
});
87+
88+
it('short-circuits to the confirmation step for an active connection', async () => {
89+
const { wrapper, fixtures } = await createFixtures(f => {
90+
f.withEnterpriseSso({ selfServeSSO: true });
91+
f.withEmailAddress();
92+
f.withUser({ email_addresses: ['test@clerk.com'] });
93+
});
94+
95+
fixtures.clerk.user?.getEnterpriseConnections.mockResolvedValue([
96+
{
97+
id: 'ent_active',
98+
name: 'clerk.com',
99+
provider: 'saml_okta',
100+
active: true,
101+
organizationId: null,
102+
domains: ['clerk.com'],
103+
samlConnection: {
104+
idpSsoUrl: 'https://idp.example.com/sso',
105+
idpEntityId: 'https://idp.example.com/entity',
106+
idpCertificate: 'CERT',
107+
},
108+
} as any,
109+
]);
110+
111+
const { findByText, queryByText } = render(<ConfigureSSO />, { wrapper });
112+
113+
// An active connection lands on confirmation even if never tested.
114+
await findByText(/configuration/i);
115+
expect(queryByText(/select your identity provider/i)).not.toBeInTheDocument();
116+
});
117+
});
70118
});

packages/ui/src/components/ConfigureSSO/machine/__tests__/submit.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,30 +37,32 @@ const makeCtx = (overrides: Partial<SubmitCtx> = {}): SubmitCtx => ({
3737
});
3838

3939
describe('submitSelectProvider', () => {
40-
it('provider selected → creates the connection and returns { ok: true }', async () => {
40+
it('provider selected → creates the connection and jumps to configure', async () => {
4141
// Under the new order verify-domain ran first, so the create is
42-
// unconditional once a provider is chosen.
42+
// unconditional once a provider is chosen. The create flips
43+
// `facts.hasConnection`, which disables select-provider, so the handler must
44+
// jump with an explicit `goTo: 'configure'` (a plain NEXT would no-op).
4345
const mutations = makeMutations();
4446
const ctx = makeCtx({ mutations, provider: 'saml_okta' });
4547

4648
const result = await submitSelectProvider(ctx);
4749

4850
expect(ctx.setProvider).toHaveBeenCalledWith('saml_okta');
4951
expect(mutations.createConnection).toHaveBeenCalledWith('saml_okta', ctx.primaryEmailAddress);
50-
expect(result).toEqual({ ok: true });
52+
expect(result).toEqual({ ok: true, goTo: 'configure' });
5153
});
5254

5355
it('always creates regardless of email-verified facts (verify-domain ran first)', async () => {
5456
// The old isPrimaryEmailVerified branch is dead under the new order: even
55-
// with the fact false, the create still fires.
57+
// with the fact false, the create still fires and jumps to configure.
5658
const mutations = makeMutations();
5759
const ctx = makeCtx({ facts: { ...baseFacts, isPrimaryEmailVerified: false }, mutations, provider: 'saml_custom' });
5860

5961
const result = await submitSelectProvider(ctx);
6062

6163
expect(ctx.setProvider).toHaveBeenCalledWith('saml_custom');
6264
expect(mutations.createConnection).toHaveBeenCalledWith('saml_custom', ctx.primaryEmailAddress);
63-
expect(result).toEqual({ ok: true });
65+
expect(result).toEqual({ ok: true, goTo: 'configure' });
6466
});
6567

6668
it('no provider selected → returns { ok: false } without creating or setting provider', async () => {

packages/ui/src/components/ConfigureSSO/machine/submit.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,14 @@ export interface SubmitCtx {
6060
* reaches select-provider their domain/email is already verified — the create
6161
* is unconditional:
6262
* - No provider selected → `{ ok: false }` (footer surfaces the validation).
63-
* - Provider selected → create the connection now, then `{ ok: true }`. The
64-
* reducer's NEXT advances into configure.
63+
* - Provider selected → create the connection now, then jump to `configure`.
6564
* - Create throws → `{ ok: false; error }`.
65+
*
66+
* The jump MUST be an explicit `goTo: 'configure'`, not a plain advance: a
67+
* successful create flips `facts.hasConnection`, which disables `select-provider`
68+
* (its `enabled` predicate is `!hasConnection`). Once disabled, the machine can
69+
* no longer be "at" it, so a plain `NEXT` no-ops. `GOTO` bypasses `fulfilled`
70+
* and lands on configure regardless.
6671
*/
6772
export const submitSelectProvider = async (ctx: SubmitCtx): Promise<SubmitResult> => {
6873
const { provider, setProvider, mutations, primaryEmailAddress } = ctx;
@@ -75,7 +80,7 @@ export const submitSelectProvider = async (ctx: SubmitCtx): Promise<SubmitResult
7580

7681
try {
7782
await mutations.createConnection(provider, primaryEmailAddress);
78-
return { ok: true };
83+
return { ok: true, goTo: 'configure' };
7984
} catch (error) {
8085
return { ok: false, error: error as ClerkAPIError | ClerkRuntimeError | string };
8186
}

packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { descriptors, Flow } from '@/customizables';
55
import { useConfigureSSO } from '../../ConfigureSSOContext';
66
import { Step } from '../../elements/Step';
77
import { Wizard } from '../../elements/Wizard';
8+
import { useWizardMachine } from '../../elements/WizardMachineContext';
89
import type { ProviderType } from '../../types';
910
import {
1011
SamlCustomConfigureSteps,
@@ -22,6 +23,10 @@ const STEPS_BY_PROVIDER: Record<ProviderType, () => JSX.Element> = {
2223

2324
export const ConfigureStep = (): JSX.Element | null => {
2425
const { provider } = useConfigureSSO();
26+
// The inner SAML sub-step flow keeps its own nested <Wizard> (untouched —
27+
// slated for the TXT rework). Only its terminal step advances the top-level
28+
// machine, via the injected `onComplete`.
29+
const { dispatch } = useWizardMachine();
2530

2631
// Type guard, at this point the provider should have been defined
2732
if (!provider) {
@@ -41,7 +46,7 @@ export const ConfigureStep = (): JSX.Element | null => {
4146
elementDescriptor={descriptors.configureSSOStep}
4247
elementId={descriptors.configureSSOStep.setId('configure')}
4348
>
44-
<Wizard>
49+
<Wizard onComplete={() => dispatch({ type: 'NEXT' })}>
4550
<StepsByProvider />
4651
</Wizard>
4752
</Step>

0 commit comments

Comments
 (0)