Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host by iagodahlem · Pull Request #8799 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host by iagodahlem · Pull Request #8799 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host by iagodahlem · Pull Request #8799 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host by iagodahlem · Pull Request #8799 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host by iagodahlem · Pull Request #8799 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host by iagodahlem · Pull Request #8799 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); refactor(ui): extract ConfigureSSOWizard and lift data ownership to the host by iagodahlem · Pull Request #8799 · clerk/javascript · GitHub
Skip to content
Merged
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 numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
19 changes: 7 additions & 12 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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