Skip to content

Commit 4cd455e

Browse files
authored
refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host (#8799)
1 parent 67c04a4 commit 4cd455e

8 files changed

Lines changed: 219 additions & 99 deletions

File tree

.changeset/big-files-shop.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

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

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,9 @@ import { ProfileCard } from '@/elements/ProfileCard';
1010
import { ExclamationTriangle } from '@/icons';
1111
import { Route, Switch } from '@/router';
1212

13-
import { ConfigureSSOProvider } from './ConfigureSSOContext';
1413
import { ConfigureSSONavbar } from './ConfigureSSONavbar';
1514
import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton';
16-
import { ConfigureSSOSteps } from './ConfigureSSOSteps';
15+
import { ConfigureSSOWizard } from './ConfigureSSOWizard';
1716
import { ProfileCardFooter, ProfileCardHeader } from './elements/ProfileCard';
1817
import { Step } from './elements/Step';
1918
import { useOrganizationEnterpriseConnection } from './hooks/useOrganizationEnterpriseConnection';
@@ -44,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
4443
);
4544
});
4645

47-
export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
46+
const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
4847
const {
4948
isLoading,
5049
enterpriseConnection,
@@ -54,31 +53,27 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec
5453
primaryEmailAddress,
5554
} = useOrganizationEnterpriseConnection();
5655

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.
56+
// Gate loading above the provider so the context never observes a loading state.
6157
if (isLoading) {
6258
return <ConfigureSSOSkeleton />;
6359
}
6460

6561
return (
6662
<ConfigureSSOProtect>
67-
<ConfigureSSOProvider
63+
<ConfigureSSOWizard
6864
organizationEnterpriseConnection={organizationEnterpriseConnection}
6965
testRuns={testRuns}
7066
enterpriseConnection={enterpriseConnection}
7167
contentRef={contentRef}
7268
mutations={mutations}
7369
primaryEmailAddress={primaryEmailAddress}
74-
>
75-
<ConfigureSSOSteps />
76-
</ConfigureSSOProvider>
70+
/>
7771
</ConfigureSSOProtect>
7872
);
7973
};
8074

81-
const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => {
75+
/** Permission gate shared by the wizard's hosts — personal workspaces pass, since there is no membership to check. */
76+
export const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => {
8277
const { session } = useSession();
8378
const isPersonalWorkspace = !session?.lastActiveOrganizationId;
8479
const canManageEnterpriseConnections = useProtect(

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

Lines changed: 0 additions & 75 deletions
This file was deleted.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React, { type ComponentProps } from 'react';
2+
3+
import { CardStateProvider } from '@/elements/contexts';
4+
5+
import { ConfigureSSOProvider } from './ConfigureSSOContext';
6+
import { ConfigureSSOHeader } from './ConfigureSSOHeader';
7+
import { type WizardStepConfig } from './elements/Wizard';
8+
import { Wizard } from './elements/Wizard';
9+
import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps';
10+
11+
export type ConfigureSSOWizardProps = Omit<ComponentProps<typeof ConfigureSSOProvider>, 'children'>;
12+
13+
/** Pure, data-injected ConfigureSSO flow — hosts own fetching, loading, and permission gating. */
14+
export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element => {
15+
// Guards read from props, not `useConfigureSSO()` — this component renders the provider, so the hook would throw here.
16+
const { organizationEnterpriseConnection: c } = props;
17+
18+
const steps = React.useMemo<WizardStepConfig[]>(
19+
() => [
20+
{ id: 'verify-domain', label: 'Verify domain' },
21+
{ id: 'select-provider', guard: () => c.isPrimaryEmailVerified },
22+
{ id: 'configure', label: 'Configure', guard: () => c.isPrimaryEmailVerified && c.hasConnection },
23+
{ id: 'test', label: 'Test', guard: () => c.hasMinimumConfiguration || c.isActive },
24+
{ id: 'confirmation', label: 'Confirmation', guard: () => c.hasSuccessfulTestRun || c.isActive },
25+
],
26+
[c],
27+
);
28+
29+
// Each step owns a `CardStateProvider` so card errors stay scoped to their step and clear when it unmounts.
30+
return (
31+
<ConfigureSSOProvider {...props}>
32+
<Wizard steps={steps}>
33+
<ConfigureSSOHeader />
34+
35+
<Wizard.Match id='verify-domain'>
36+
<CardStateProvider>
37+
<VerifyDomainStep />
38+
</CardStateProvider>
39+
</Wizard.Match>
40+
41+
<Wizard.Match id='select-provider'>
42+
<CardStateProvider>
43+
<SelectProviderStep />
44+
</CardStateProvider>
45+
</Wizard.Match>
46+
47+
<Wizard.Match id='configure'>
48+
<CardStateProvider>
49+
<ConfigureStep />
50+
</CardStateProvider>
51+
</Wizard.Match>
52+
53+
<Wizard.Match id='test'>
54+
<CardStateProvider>
55+
<TestConfigurationStep />
56+
</CardStateProvider>
57+
</Wizard.Match>
58+
59+
<Wizard.Match id='confirmation'>
60+
<CardStateProvider>
61+
<ConfirmationStep />
62+
</CardStateProvider>
63+
</Wizard.Match>
64+
</Wizard>
65+
</ConfigureSSOProvider>
66+
);
67+
};

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { ConfigureSSO } from '../ConfigureSSO';
77

88
// Integration coverage for the wizard's navigation contract at the rendered-
99
// component level — the real `ConfigureSSO` → `useOrganizationEnterpriseConnection`
10-
// → `ConfigureSSOSteps` → `<Wizard>` → `useWizardMachine` → step wiring, driven
10+
// → `ConfigureSSOWizard` → `<Wizard>` → `useWizardMachine` → step wiring, driven
1111
// only through the connection data the (auto-mocked) FAPI handles return. The
1212
// machine-level behaviours (defer/resolve, clamp) are unit-tested in
1313
// `useWizardMachine.test.tsx`; these tests prove those behaviours hold when the

packages/ui/src/components/ConfigureSSO/domain/__tests__/organizationEnterpriseConnection.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,72 @@ describe('organizationEnterpriseConnection', () => {
146146
});
147147
});
148148

149+
describe('status', () => {
150+
it('undefined connection → unconfigured', () => {
151+
expect(derive({ connection: undefined }).status).toBe('unconfigured');
152+
});
153+
it('null connection → unconfigured', () => {
154+
expect(derive({ connection: null }).status).toBe('unconfigured');
155+
});
156+
it('created but unconfigured connection → in_progress', () => {
157+
expect(derive({ connection: makeConnection({ samlConnection: null }) }).status).toBe('in_progress');
158+
});
159+
it('partially configured connection → in_progress', () => {
160+
expect(
161+
derive({
162+
connection: makeConnection({
163+
samlConnection: makeSamlConnection({ idpSsoUrl: 'https://idp.example.com/sso' }),
164+
}),
165+
}).status,
166+
).toBe('in_progress');
167+
});
168+
it('configured but not yet successfully tested → in_progress', () => {
169+
expect(
170+
derive({
171+
connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: false }),
172+
hasSuccessfulTestRun: false,
173+
}).status,
174+
).toBe('in_progress');
175+
});
176+
it('successfully tested but not minimally configured → in_progress', () => {
177+
expect(
178+
derive({
179+
connection: makeConnection({ samlConnection: null, active: false }),
180+
hasSuccessfulTestRun: true,
181+
}).status,
182+
).toBe('in_progress');
183+
});
184+
it('configured + successfully tested + not active → inactive', () => {
185+
expect(
186+
derive({
187+
connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: false }),
188+
hasSuccessfulTestRun: true,
189+
}).status,
190+
).toBe('inactive');
191+
});
192+
it('active connection → active', () => {
193+
expect(derive({ connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: true }) }).status).toBe(
194+
'active',
195+
);
196+
});
197+
it('active wins over configured + successfully tested', () => {
198+
expect(
199+
derive({
200+
connection: makeConnection({ samlConnection: fullyConfiguredSaml, active: true }),
201+
hasSuccessfulTestRun: true,
202+
}).status,
203+
).toBe('active');
204+
});
205+
it('active wins even for an unconfigured, untested connection', () => {
206+
expect(
207+
derive({
208+
connection: makeConnection({ samlConnection: null, active: true }),
209+
hasSuccessfulTestRun: false,
210+
}).status,
211+
).toBe('active');
212+
});
213+
});
214+
149215
it('is pure: identical inputs produce a deep-equal entity', () => {
150216
const connection = makeConnection({ samlConnection: fullyConfiguredSaml, active: true });
151217
const primaryEmail = makeEmail('verified');
@@ -168,6 +234,7 @@ describe('organizationEnterpriseConnection', () => {
168234
hasMinimumConfiguration: true,
169235
isPrimaryEmailVerified: true,
170236
hasSuccessfulTestRun: true,
237+
status: 'active',
171238
});
172239
});
173240
});

packages/ui/src/components/ConfigureSSO/domain/organizationEnterpriseConnection.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ export const connectionBackingEmail = (user: UserResource | null | undefined): E
2323
export interface OrganizationEnterpriseConnectionInput {
2424
/** FAPI currently supports a single connection per organization. */
2525
connection: EnterpriseConnectionResource | null | undefined;
26-
/** The email address whose domain backs the connection. */
2726
primaryEmail: EmailAddressResource | null | undefined;
2827
/** Probed upstream — not a property of the connection resource itself. */
2928
hasSuccessfulTestRun: boolean;
3029
}
3130

31+
/** Display-facing lifecycle summary — the wizard's navigation guards keep reading the raw booleans. */
32+
export type OrganizationEnterpriseConnectionStatus = 'unconfigured' | 'in_progress' | 'active' | 'inactive';
33+
3234
/**
3335
* The active organization's SSO-config domain entity: an immutable, pure value
3436
* object the wizard makes every flow decision from. A snapshot of flattened booleans/values.
@@ -40,22 +42,51 @@ export interface OrganizationEnterpriseConnection {
4042
readonly hasMinimumConfiguration: boolean;
4143
readonly isPrimaryEmailVerified: boolean;
4244
readonly hasSuccessfulTestRun: boolean;
45+
readonly status: OrganizationEnterpriseConnectionStatus;
4346
}
4447

4548
// TODO - Update to support OpenID Connect
4649
export const isEnterpriseConnectionConfigured = (
4750
connection: EnterpriseConnectionResource | null | undefined,
4851
): boolean => Boolean(connection?.samlConnection?.idpSsoUrl && connection?.samlConnection?.idpEntityId);
4952

53+
const connectionStatus = ({
54+
hasConnection,
55+
isActive,
56+
hasMinimumConfiguration,
57+
hasSuccessfulTestRun,
58+
}: Pick<
59+
OrganizationEnterpriseConnection,
60+
'hasConnection' | 'isActive' | 'hasMinimumConfiguration' | 'hasSuccessfulTestRun'
61+
>): OrganizationEnterpriseConnectionStatus => {
62+
if (!hasConnection) {
63+
return 'unconfigured';
64+
}
65+
if (isActive) {
66+
return 'active';
67+
}
68+
if (hasMinimumConfiguration && hasSuccessfulTestRun) {
69+
return 'inactive';
70+
}
71+
return 'in_progress';
72+
};
73+
5074
export const organizationEnterpriseConnection = ({
5175
connection,
5276
primaryEmail,
5377
hasSuccessfulTestRun,
54-
}: OrganizationEnterpriseConnectionInput): OrganizationEnterpriseConnection => ({
55-
provider: connection?.provider as ProviderType | undefined,
56-
hasConnection: Boolean(connection),
57-
isActive: Boolean(connection?.active),
58-
hasMinimumConfiguration: isEnterpriseConnectionConfigured(connection),
59-
isPrimaryEmailVerified: primaryEmail?.verification?.status === 'verified',
60-
hasSuccessfulTestRun,
61-
});
78+
}: OrganizationEnterpriseConnectionInput): OrganizationEnterpriseConnection => {
79+
const hasConnection = Boolean(connection);
80+
const isActive = Boolean(connection?.active);
81+
const hasMinimumConfiguration = isEnterpriseConnectionConfigured(connection);
82+
83+
return {
84+
provider: connection?.provider as ProviderType | undefined,
85+
hasConnection,
86+
isActive,
87+
hasMinimumConfiguration,
88+
isPrimaryEmailVerified: primaryEmail?.verification?.status === 'verified',
89+
hasSuccessfulTestRun,
90+
status: connectionStatus({ hasConnection, isActive, hasMinimumConfiguration, hasSuccessfulTestRun }),
91+
};
92+
};

0 commit comments

Comments
 (0)