Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/big-files-shop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -44,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => {
);
});

export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const {
isLoading,
enterpriseConnection,
Expand All @@ -54,31 +53,27 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec
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 <ConfigureSSOSkeleton />;
}

return (
<ConfigureSSOProtect>
<ConfigureSSOProvider
<ConfigureSSOWizard
organizationEnterpriseConnection={organizationEnterpriseConnection}
testRuns={testRuns}
enterpriseConnection={enterpriseConnection}
contentRef={contentRef}
mutations={mutations}
primaryEmailAddress={primaryEmailAddress}
>
<ConfigureSSOSteps />
</ConfigureSSOProvider>
/>
</ConfigureSSOProtect>
);
};

const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => {
/** Permission gate shared by the wizard's hosts — personal workspaces pass, since there is no membership to check. */
export const ConfigureSSOProtect = ({ children }: { children: React.ReactNode }) => {
const { session } = useSession();
const isPersonalWorkspace = !session?.lastActiveOrganizationId;
const canManageEnterpriseConnections = useProtect(
Expand Down
75 changes: 0 additions & 75 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSOSteps.tsx

This file was deleted.

67 changes: 67 additions & 0 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { type ComponentProps } from 'react';

import { CardStateProvider } from '@/elements/contexts';

import { ConfigureSSOProvider } 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';

export type ConfigureSSOWizardProps = Omit<ComponentProps<typeof ConfigureSSOProvider>, 'children'>;

/** Pure, data-injected ConfigureSSO flow — hosts own fetching, loading, and permission gating. */
export const ConfigureSSOWizard = (props: ConfigureSSOWizardProps): JSX.Element => {
// 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<WizardStepConfig[]>(
() => [
{ 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],
);

// Each step owns a `CardStateProvider` so card errors stay scoped to their step and clear when it unmounts.
return (
<ConfigureSSOProvider {...props}>
<Wizard steps={steps}>
<ConfigureSSOHeader />

<Wizard.Match id='verify-domain'>
<CardStateProvider>
<VerifyDomainStep />
</CardStateProvider>
</Wizard.Match>

<Wizard.Match id='select-provider'>
<CardStateProvider>
<SelectProviderStep />
</CardStateProvider>
</Wizard.Match>

<Wizard.Match id='configure'>
<CardStateProvider>
<ConfigureStep />
</CardStateProvider>
</Wizard.Match>

<Wizard.Match id='test'>
<CardStateProvider>
<TestConfigurationStep />
</CardStateProvider>
</Wizard.Match>

<Wizard.Match id='confirmation'>
<CardStateProvider>
<ConfirmationStep />
</CardStateProvider>
</Wizard.Match>
</Wizard>
</ConfigureSSOProvider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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` → `<Wizard>` → `useWizardMachine` → step wiring, driven
// → `ConfigureSSOWizard` → `<Wizard>` → `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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,72 @@ 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', () => {
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');
Expand All @@ -168,6 +234,7 @@ describe('organizationEnterpriseConnection', () => {
hasMinimumConfiguration: true,
isPrimaryEmailVerified: true,
hasSuccessfulTestRun: true,
status: 'active',
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ 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;
}

/** Display-facing lifecycle summary — the wizard's navigation guards keep reading the raw booleans. */
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.
Expand All @@ -40,22 +42,51 @@ export interface OrganizationEnterpriseConnection {
readonly hasMinimumConfiguration: boolean;
readonly isPrimaryEmailVerified: boolean;
readonly hasSuccessfulTestRun: boolean;
readonly status: OrganizationEnterpriseConnectionStatus;
}

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

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 }),
};
};
Loading
Loading