Skip to content

Commit 032632c

Browse files
authored
refactor(ui): drive ConfigureSSO wizard navigation with a state machine (#8715)
1 parent 90bc732 commit 032632c

44 files changed

Lines changed: 3781 additions & 1274 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/quick-mammals-flash.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@clerk/ui': patch
3+
'@clerk/shared': patch
4+
'@clerk/clerk-js': patch
5+
---
6+
7+
Internal refactor for self-serve SSO wizard navigation to leverage a guard-based state machine.
8+
9+
It makes the step navigation more predictable: the step you land on (including after a reload) and which steps you can move to are derived from the connection's state, the connection reset flow lands you on the right step.

packages/clerk-js/src/core/resources/__tests__/Organization.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ describe('Organization', () => {
148148
const conn = await organization.createEnterpriseConnection({
149149
provider: 'saml_okta',
150150
name: 'New SSO',
151+
// `domains` is required by the org-scoped create endpoint; it is sent as
152+
// a `domains` array on the body and serialized as repeated form fields
153+
// (`domains=acme.com`) by the form serializer downstream.
154+
domains: ['acme.com'],
151155
// Even though callers may still pass this for convenience, the SDK
152156
// must not include it in the body — the org URL is authoritative.
153157
organizationId: ORG_ID,
@@ -161,6 +165,7 @@ describe('Organization', () => {
161165
body: {
162166
provider: 'saml_okta',
163167
name: 'New SSO',
168+
domains: ['acme.com'],
164169
saml_idp_entity_id: 'https://idp.example.com',
165170
},
166171
});
@@ -173,6 +178,42 @@ describe('Organization', () => {
173178
expect(conn.name).toBe('New SSO');
174179
});
175180

181+
it('omits domains from the create body when none are provided', async () => {
182+
const enterpriseConnectionJSON = {
183+
id: 'ec_new',
184+
object: 'enterprise_connection' as const,
185+
name: 'New SSO',
186+
active: true,
187+
provider: 'saml_okta',
188+
logo_public_url: null,
189+
domains: [],
190+
organization_id: ORG_ID,
191+
sync_user_attributes: true,
192+
disable_additional_identifications: false,
193+
allow_organization_account_linking: false,
194+
custom_attributes: [],
195+
oauth_config: null,
196+
saml_connection: null,
197+
created_at: 1234567890,
198+
updated_at: 1234567890,
199+
};
200+
201+
// @ts-ignore
202+
BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: enterpriseConnectionJSON }));
203+
204+
const organization = createOrganization();
205+
206+
await organization.createEnterpriseConnection({
207+
provider: 'saml_okta',
208+
name: 'New SSO',
209+
});
210+
211+
// @ts-ignore
212+
const callBody = BaseResource._fetch.mock.calls[0][0].body;
213+
// Backwards-compatible: an omitted (or empty) `domains` is not sent.
214+
expect('domains' in callBody).toBe(false);
215+
});
216+
176217
it('updates an enterprise connection without forwarding organization_id in the body', async () => {
177218
const enterpriseConnectionJSON = {
178219
id: 'ec_123',

packages/clerk-js/src/utils/enterpriseConnection.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,17 @@ export function toEnterpriseConnectionBody(
3535
): Record<string, unknown> {
3636
const body: Record<string, unknown> = {};
3737

38-
// Top-level fields. `provider` is only on Create, the rest are shared.
38+
// Top-level fields. `provider` and `domains` are only on Create, the rest are shared.
3939
setIfDefined(body, 'provider', (params as CreateOrganizationEnterpriseConnectionParams).provider);
4040
setIfDefined(body, 'name', params.name);
41+
// `domains` is an array of FQDN strings. The form serializer downstream emits
42+
// each element as a repeated `domains` form field (e.g. `domains=a.com&domains=b.com`),
43+
// matching how the backend parses repeated form params. An omitted or empty
44+
// array is not sent, keeping older callers backwards-compatible.
45+
const domains = (params as CreateOrganizationEnterpriseConnectionParams).domains;
46+
if (domains && domains.length > 0) {
47+
body.domains = domains;
48+
}
4149
if (!options.omitOrganizationId) {
4250
setIfDefined(body, 'organization_id', params.organizationId);
4351
}

packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
GetEnterpriseConnectionTestRunsParams,
66
} from '../../types/enterpriseConnectionTestRun';
77
import { useClerkInstanceContext } from '../contexts';
8+
import { defineKeepPreviousDataFn } from '../query/keep-previous-data';
89
import { useClerkQueryClient } from '../query/use-clerk-query-client';
910
import { useClerkQuery } from '../query/useQuery';
1011
import { useOrganizationBase } from './base/useOrganizationBase';
@@ -33,6 +34,14 @@ export type UseOrganizationEnterpriseConnectionTestRunsParams = {
3334
* @default true
3435
*/
3536
enabled?: boolean;
37+
/**
38+
* When `true`, a background refetch keeps the previously-loaded page visible
39+
* (`isFetching` stays `true`, `isLoading` does not flip back to `true`) instead
40+
* of clearing to a cold-load state.
41+
*
42+
* @default false
43+
*/
44+
keepPreviousData?: boolean;
3645
};
3746

3847
export type UseOrganizationEnterpriseConnectionTestRunsReturn = {
@@ -46,9 +55,26 @@ export type UseOrganizationEnterpriseConnectionTestRunsReturn = {
4655
*/
4756
isPolling: boolean;
4857
/**
49-
* Force a refetch and (if the list is currently empty) arm polling
58+
* Force a refetch.
59+
*
60+
* By default this also arms polling when the list is currently empty, so a run
61+
* kicked off elsewhere is picked up as it lands. Pass `{ armPolling: false }`
62+
* for an entry/pagination refetch that should never arm polling merely because
63+
* the list happens to be empty — polling is then armed only by an explicit
64+
* `revalidate()` (or `revalidate({ armPolling: true })`) after a run is kicked
65+
* off.
5066
*/
51-
revalidate: () => Promise<void>;
67+
revalidate: (options?: RevalidateTestRunsOptions) => Promise<void>;
68+
};
69+
70+
export type RevalidateTestRunsOptions = {
71+
/**
72+
* Whether to arm polling for the first record when the list is currently
73+
* empty.
74+
*
75+
* @default true
76+
*/
77+
armPolling?: boolean;
5278
};
5379

5480
/**
@@ -64,6 +90,7 @@ function useOrganizationEnterpriseConnectionTestRuns(
6490
params: fetchParams = { initialPage: 1, pageSize: 10 },
6591
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
6692
enabled = true,
93+
keepPreviousData = false,
6794
} = params;
6895

6996
const clerk = useClerkInstanceContext();
@@ -86,6 +113,12 @@ function useOrganizationEnterpriseConnectionTestRuns(
86113

87114
const [shouldPoll, setShouldPoll] = useState(false);
88115

116+
useEffect(() => {
117+
// Polling intent is scoped to the current connection — clear it when the
118+
// connection changes so a reset/recreate doesn't inherit a stale armed poll.
119+
setShouldPoll(false);
120+
}, [enterpriseConnectionId]);
121+
89122
const query = useClerkQuery({
90123
queryKey,
91124
queryFn: () => {
@@ -104,6 +137,7 @@ function useOrganizationEnterpriseConnectionTestRuns(
104137
},
105138
enabled: queryEnabled,
106139
refetchIntervalInBackground: false,
140+
placeholderData: defineKeepPreviousDataFn(keepPreviousData),
107141
});
108142

109143
const hasRows = (query.data?.data?.length ?? 0) > 0;
@@ -114,14 +148,21 @@ function useOrganizationEnterpriseConnectionTestRuns(
114148
}
115149
}, [shouldPoll, hasRows]);
116150

117-
const revalidate = useCallback(async () => {
118-
// Only arm polling when there is nothing in the list yet — once any record
119-
// has been seen, this is a one-shot refetch.
120-
if (!hasRows) {
121-
setShouldPoll(true);
122-
}
123-
await queryClient.invalidateQueries({ queryKey: invalidationKey });
124-
}, [queryClient, invalidationKey, hasRows]);
151+
const revalidate = useCallback(
152+
async (options?: RevalidateTestRunsOptions) => {
153+
// Arm polling only when the caller opts in (the default) AND there is
154+
// nothing in the list yet. An entry/pagination refetch passes
155+
// `armPolling: false` so an empty list on entry never arms polling on its
156+
// own — that stays the job of an explicit refetch after a run is kicked
157+
// off. Once any record has been seen, this is a one-shot refetch.
158+
const armPolling = options?.armPolling ?? true;
159+
if (armPolling && !hasRows) {
160+
setShouldPoll(true);
161+
}
162+
await queryClient.invalidateQueries({ queryKey: invalidationKey });
163+
},
164+
[queryClient, invalidationKey, hasRows],
165+
);
125166

126167
const isPolling = queryEnabled && shouldPoll && !hasRows;
127168

packages/shared/src/types/enterpriseConnection.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ export type MeEnterpriseConnectionOidcInput = OrganizationEnterpriseConnectionOi
141141
export type CreateOrganizationEnterpriseConnectionParams = {
142142
provider: OrganizationEnterpriseConnectionProvider;
143143
name: string;
144+
/** FQDN strings the connection authenticates. Required by the org-scoped create endpoint. */
145+
domains?: string[];
144146
organizationId?: string | null;
145147
saml?: OrganizationEnterpriseConnectionSamlInput | null;
146148
oidc?: OrganizationEnterpriseConnectionOidcInput | null;
Lines changed: 22 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,22 @@
1-
import {
2-
__internal_useOrganizationEnterpriseConnections,
3-
__internal_useOrganizationEnterpriseConnectionTestRuns,
4-
useSession,
5-
} from '@clerk/shared/react';
6-
import type { ConfigureSSOProps, EnterpriseConnectionResource } from '@clerk/shared/types';
1+
import { useSession } from '@clerk/shared/react';
2+
import type { ConfigureSSOProps } from '@clerk/shared/types';
73
import React from 'react';
84

95
import { useProtect } from '@/common';
106
import { withCoreUserGuard } from '@/contexts';
117
import { Col, Flex, Flow, Heading, Icon, localizationKeys, Text } from '@/customizables';
12-
import { useCardState, withCardStateProvider } from '@/elements/contexts';
8+
import { withCardStateProvider } from '@/elements/contexts';
139
import { ProfileCard } from '@/elements/ProfileCard';
1410
import { ExclamationTriangle } from '@/icons';
1511
import { Route, Switch } from '@/router';
1612

17-
import { ConfigureSSOProvider, useConfigureSSO } from './ConfigureSSOContext';
18-
import { ConfigureSSOHeader } from './ConfigureSSOHeader';
13+
import { ConfigureSSOProvider } from './ConfigureSSOContext';
1914
import { ConfigureSSONavbar } from './ConfigureSSONavbar';
2015
import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton';
16+
import { ConfigureSSOSteps } from './ConfigureSSOSteps';
2117
import { ProfileCardFooter, ProfileCardHeader } from './elements/ProfileCard';
2218
import { Step } from './elements/Step';
23-
import { useWizard, Wizard } from './elements/Wizard';
24-
import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps';
19+
import { useOrganizationEnterpriseConnection } from './hooks/useOrganizationEnterpriseConnection';
2520

2621
const ConfigureSSOInternal = () => {
2722
return (
@@ -51,90 +46,38 @@ const AuthenticatedContent = withCoreUserGuard(() => {
5146

5247
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
5348
const {
54-
data: enterpriseConnections,
55-
isLoading: isLoadingEnterpriseConnections,
56-
createEnterpriseConnection,
57-
updateEnterpriseConnection,
58-
deleteEnterpriseConnection,
59-
} = __internal_useOrganizationEnterpriseConnections({ enabled: true });
60-
// Currently the self-serve SSO UI flow only supports one enterprise connection per organization
61-
const enterpriseConnection = enterpriseConnections?.[0];
62-
63-
const { hasSuccessfulTestRun, isLoading: isLoadingTestRuns } = useHasSuccessfulTestRun(enterpriseConnection);
64-
65-
const isLoading = isLoadingEnterpriseConnections || isLoadingTestRuns;
49+
isLoading,
50+
enterpriseConnection,
51+
organizationEnterpriseConnection,
52+
testRuns,
53+
mutations,
54+
primaryEmailAddress,
55+
} = useOrganizationEnterpriseConnection();
56+
57+
// Gate loading one level above the provider so the context never observes a
58+
// loading state. The single test-run source is part of this initial fetch
59+
// when a connection exists at load, so a cold landing on the test step is
60+
// covered by the full skeleton here.
6661
if (isLoading) {
6762
return <ConfigureSSOSkeleton />;
6863
}
6964

7065
return (
7166
<ConfigureSSOProtect>
7267
<ConfigureSSOProvider
73-
hasSuccessfulTestRun={hasSuccessfulTestRun}
68+
organizationEnterpriseConnection={organizationEnterpriseConnection}
69+
testRuns={testRuns}
7470
enterpriseConnection={enterpriseConnection}
7571
contentRef={contentRef}
76-
createEnterpriseConnection={createEnterpriseConnection}
77-
updateEnterpriseConnection={updateEnterpriseConnection}
78-
deleteEnterpriseConnection={deleteEnterpriseConnection}
72+
mutations={mutations}
73+
primaryEmailAddress={primaryEmailAddress}
7974
>
8075
<ConfigureSSOSteps />
8176
</ConfigureSSOProvider>
8277
</ConfigureSSOProtect>
8378
);
8479
};
8580

86-
const ConfigureSSOSteps = () => {
87-
const { initialStepId, enterpriseConnection } = useConfigureSSO();
88-
89-
return (
90-
<Wizard initialStepId={initialStepId}>
91-
<ResetCardErrorOnStepChange />
92-
<ConfigureSSOHeader />
93-
94-
{/*
95-
* `select-provider` is only a wizard step while there's no enterprise
96-
* connection yet — creating one unregisters this step, which:
97-
* 1. Hides it from the breadcrumb (no need for a manual filter), and
98-
* 2. Prevents `goPrev` from any later step (e.g. configure's first
99-
* substep) from ever bubbling back into provider selection.
100-
*/}
101-
{!enterpriseConnection && (
102-
<Wizard.Step id='select-provider'>
103-
<SelectProviderStep />
104-
</Wizard.Step>
105-
)}
106-
107-
<Wizard.Step
108-
id='verify-domain'
109-
label='Verify domain'
110-
>
111-
<VerifyDomainStep />
112-
</Wizard.Step>
113-
114-
<Wizard.Step
115-
id='configure'
116-
label='Configure'
117-
>
118-
<ConfigureStep />
119-
</Wizard.Step>
120-
121-
<Wizard.Step
122-
id='test'
123-
label='Test'
124-
>
125-
<TestConfigurationStep />
126-
</Wizard.Step>
127-
128-
<Wizard.Step
129-
id='confirmation'
130-
label='Confirmation'
131-
>
132-
<ConfirmationStep />
133-
</Wizard.Step>
134-
</Wizard>
135-
);
136-
};
137-
13881
const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => {
13982
const { session } = useSession();
14083
const isPersonalWorkspace = !session?.lastActiveOrganizationId;
@@ -193,44 +136,4 @@ const MissingManageEnterpriseConnectionsPermission = () => (
193136
</>
194137
);
195138

196-
/**
197-
* Sentinel component rendered inside `<Wizard>`
198-
*
199-
* Clears any card-level error whenever the active step transitions, so a stale failure from one step
200-
* doesn't leak into the next
201-
*/
202-
const ResetCardErrorOnStepChange = (): null => {
203-
const { currentStep } = useWizard();
204-
const card = useCardState();
205-
const previousStepIdRef = React.useRef(currentStep?.id);
206-
207-
React.useEffect(() => {
208-
if (previousStepIdRef.current === currentStep?.id) {
209-
return;
210-
}
211-
212-
previousStepIdRef.current = currentStep?.id;
213-
card.setError(undefined);
214-
}, [currentStep?.id, card]);
215-
216-
return null;
217-
};
218-
219-
/**
220-
* Fetches a single successful test run for the given connection on mount
221-
*/
222-
const useHasSuccessfulTestRun = (
223-
enterpriseConnection: EnterpriseConnectionResource | undefined,
224-
): { hasSuccessfulTestRun: boolean; isLoading: boolean } => {
225-
const { data: successfulTestRuns, isLoading } = __internal_useOrganizationEnterpriseConnectionTestRuns({
226-
enterpriseConnectionId: enterpriseConnection?.id ?? null,
227-
params: { initialPage: 1, pageSize: 1, status: ['success'] },
228-
});
229-
230-
return {
231-
hasSuccessfulTestRun: (successfulTestRuns?.length ?? 0) > 0,
232-
isLoading,
233-
};
234-
};
235-
236139
export const ConfigureSSO: React.ComponentType<ConfigureSSOProps> = withCardStateProvider(ConfigureSSOInternal);

0 commit comments

Comments
 (0)