diff --git a/.changeset/lucky-tables-learn.md b/.changeset/lucky-tables-learn.md
new file mode 100644
index 00000000000..ae4e51b2cb3
--- /dev/null
+++ b/.changeset/lucky-tables-learn.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Add wizard steps for the `<__experimental_ConfigureSSO />` component
diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts
index ce01dbe24a9..5334dc1496d 100644
--- a/packages/clerk-js/src/core/clerk.ts
+++ b/packages/clerk-js/src/core/clerk.ts
@@ -1457,6 +1457,15 @@ export class Clerk implements ClerkInterface {
return;
}
+ if (noUserExists(this)) {
+ if (this.#instanceType === 'development') {
+ throw new ClerkRuntimeError(warnings.cannotRenderConfigureSSOComponentWhenUserDoesNotExist, {
+ code: CANNOT_RENDER_USER_MISSING_ERROR_CODE,
+ });
+ }
+ return;
+ }
+
this.assertComponentsReady(this.#clerkUI);
const component = 'ConfigureSSO';
void this.#clerkUI
diff --git a/packages/shared/src/internal/clerk-js/warnings.ts b/packages/shared/src/internal/clerk-js/warnings.ts
index 94ba1e4e7e9..4d976b18e9d 100644
--- a/packages/shared/src/internal/clerk-js/warnings.ts
+++ b/packages/shared/src/internal/clerk-js/warnings.ts
@@ -64,6 +64,8 @@ const warnings = {
'The component cannot be rendered when organization API keys are disabled. Since organization API keys are disabled, this is no-op.',
cannotRenderOAuthConsentComponentWhenUserDoesNotExist:
' cannot render unless a user is signed in. Since no user is signed in, this is no-op.',
+ cannotRenderConfigureSSOComponentWhenUserDoesNotExist:
+ ' cannot render unless a user is signed in. Since no user is signed in, this is no-op.',
cannotRenderConfigureSSOComponentWhenDisabled:
'The component cannot be rendered when self-serve SSO is disabled. Visit `https://dashboard.clerk.com` to enable the feature. Since self-serve SSO is disabled, this is no-op.',
cannotRenderConfigureSSOComponentWhenEmailAddressDisabled:
diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
index 4b744a8a8d0..f1585e7a8de 100644
--- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
+++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
@@ -1,9 +1,9 @@
-import { useOrganization } from '@clerk/shared/react/index';
+import { __internal_useUserEnterpriseConnections, useOrganization, useUser } from '@clerk/shared/react';
import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types';
import React from 'react';
import { useEnvironment, withCoreUserGuard } from '@/contexts';
-import { Box, Col, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables';
+import { Box, Col, descriptors, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables';
import { ApplicationLogo } from '@/elements/ApplicationLogo';
import { withCardStateProvider } from '@/elements/contexts';
import { NavBar, NavbarContextProvider } from '@/elements/Navbar';
@@ -11,16 +11,18 @@ import { ProfileCard } from '@/elements/ProfileCard';
import { BoxIcon } from '@/icons';
import { Route, Switch } from '@/router';
+import { ConfigureSSOFlowProvider } from './ConfigureSSOContext';
+import { ConfigureCreateApp, ConfirmationStep, ProvideEmail, TestConfigurationStep, VerifyDomainStep } from './steps';
+import { ConfigureSSOWizard } from './wizard';
+
const ConfigureSSOInternal = () => {
return (
-
-
-
-
-
-
-
+
+
+
+
+
);
};
@@ -32,6 +34,11 @@ const AuthenticatedContent = withCoreUserGuard(() => {
const { parsedOptions } = useAppearance();
const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl);
+ const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections } =
+ __internal_useUserEnterpriseConnections({ enabled: true });
+ // Currently FAPI only supports one enterprise connection per user
+ const enterpriseConnection = enterpriseConnections?.[0];
+
return (
({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })}
@@ -89,12 +96,97 @@ const AuthenticatedContent = withCoreUserGuard(() => {
routes={[]}
contentRef={contentRef}
/>
-
+ ({
+ backgroundColor: t.colors.$colorBackground,
+ position: 'relative',
+ borderRadius: t.radii.$lg,
+ width: '100%',
+ overflow: 'hidden',
+ borderWidth: t.borderWidths.$normal,
+ borderStyle: t.borderStyles.$solid,
+ borderColor: t.colors.$borderAlpha150,
+ marginBlock: '-1px',
+ marginInlineEnd: '-1px',
+ flex: 1,
+ })}
+ >
+
+
+
+
);
});
+const ConfigureSSOSteps = () => {
+ const { user } = useUser();
+
+ const primaryEmailAddress = user?.primaryEmailAddress;
+
+ return (
+
+
+
+ {!primaryEmailAddress && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {/* TODO: Implement configure steps */}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
const OrganizationSidebarSubtitle = () => {
const { organization } = useOrganization();
diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx
new file mode 100644
index 00000000000..c182456d34e
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx
@@ -0,0 +1,52 @@
+import type { EnterpriseConnectionResource } from '@clerk/shared/types';
+import React, { type PropsWithChildren } from 'react';
+
+/**
+ * Shared form state for the ConfigureSSO wizard, persisted across steps
+ */
+export interface ConfigureSSOData {
+ /**
+ * The enterprise connection from the user's primary email address domain
+ */
+ enterpriseConnection: EnterpriseConnectionResource | undefined;
+}
+
+export interface ConfigureSSOContextValue extends ConfigureSSOData {
+ /**
+ * `true` while the parent is still fetching the user's enterprise
+ * connection
+ */
+ isLoading: boolean;
+}
+
+interface ConfigureSSOFlowProviderProps {
+ enterpriseConnection: EnterpriseConnectionResource | undefined;
+ isLoading: boolean;
+}
+
+const ConfigureSSOFlowContext = React.createContext(null);
+ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext';
+
+export const ConfigureSSOFlowProvider = ({
+ enterpriseConnection,
+ isLoading,
+ children,
+}: PropsWithChildren): JSX.Element => {
+ const value = React.useMemo(
+ () => ({
+ enterpriseConnection,
+ isLoading,
+ }),
+ [enterpriseConnection, isLoading],
+ );
+
+ return {children};
+};
+
+export const useConfigureSSOFlow = (): ConfigureSSOContextValue => {
+ const ctx = React.useContext(ConfigureSSOFlowContext);
+ if (!ctx) {
+ throw new Error('useConfigureSSOFlow called outside .');
+ }
+ return ctx;
+};
diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx
new file mode 100644
index 00000000000..15193247e84
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx
@@ -0,0 +1,23 @@
+import { Flow, Text } from '@/customizables';
+
+import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard';
+import { StepLayout } from './StepLayout';
+
+export const ConfigureCreateApp = (): JSX.Element => {
+ const { goNext } = useConfigureSSOWizard();
+
+ useRegisterContinueAction({
+ handler: () => goNext(),
+ });
+
+ return (
+
+
+ UI goes here
+
+
+ );
+};
diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx
new file mode 100644
index 00000000000..0f6cbf2c49e
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx
@@ -0,0 +1,13 @@
+import { Flow, Text } from '@/customizables';
+
+import { StepLayout } from './StepLayout';
+
+export const ConfirmationStep = (): JSX.Element => {
+ return (
+
+
+ UI goes here
+
+
+ );
+};
diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx
new file mode 100644
index 00000000000..bf5afe4762e
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx
@@ -0,0 +1,25 @@
+import { Flow, Text } from '@/customizables';
+
+import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard';
+import { StepLayout } from './StepLayout';
+
+export const ProvideEmail = (): JSX.Element => {
+ const { goNext } = useConfigureSSOWizard();
+
+ useRegisterContinueAction({
+ handler: () => {
+ return goNext();
+ },
+ });
+
+ return (
+
+
+ UI goes here
+
+
+ );
+};
diff --git a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx
new file mode 100644
index 00000000000..8845d841142
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+
+import { Col, Flex, Heading, Text } from '@/customizables';
+
+import { ConfigureSSOWizard } from '../wizard';
+
+interface StepLayoutProps {
+ title?: React.ReactNode;
+ subtitle?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+/**
+ * Renders the title row (with the Wizard's Step X/Y badge) on top, a divider, and the step body
+ * underneath. Each individual step file owns the body content
+ *
+ * The Step X/Y badge is rendered via `ConfigureSSOWizard.StepIndicator`,
+ * which self-hides on steps that have no inner sub-steps
+ */
+export const StepLayout = ({ title, subtitle, children }: StepLayoutProps): JSX.Element => {
+ return (
+
+ ({
+ gap: theme.space.$4,
+ padding: theme.space.$5,
+ })}
+ >
+ {title ? (
+ ({ gap: theme.space.$1, minWidth: 0 })}>
+ ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })}
+ >
+ {title}
+
+
+ {subtitle ? (
+ ({ color: theme.colors.$colorMutedForeground })}
+ >
+ {subtitle}
+
+ ) : null}
+
+ ) : null}
+
+
+ ({
+ flex: 1,
+ paddingInline: theme.space.$5,
+ overflowY: 'auto',
+ })}
+ >
+ {children}
+
+
+ );
+};
diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx
new file mode 100644
index 00000000000..31c1ab907de
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx
@@ -0,0 +1,16 @@
+import { Flow, Text } from '@/customizables';
+
+import { StepLayout } from './StepLayout';
+
+export const TestConfigurationStep = (): JSX.Element => {
+ return (
+
+
+ UI goes here
+
+
+ );
+};
diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx
new file mode 100644
index 00000000000..9c37d3261d9
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx
@@ -0,0 +1,25 @@
+import { Flow, Text } from '@/customizables';
+
+import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard';
+import { StepLayout } from './StepLayout';
+
+export const VerifyDomainStep = (): JSX.Element => {
+ const { goNext } = useConfigureSSOWizard();
+
+ useRegisterContinueAction({
+ handler: () => goNext(),
+ // TODO: Implement verification
+ isDisabled: true,
+ });
+
+ return (
+
+
+ UI goes here
+
+
+ );
+};
diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts
new file mode 100644
index 00000000000..300535512e9
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts
@@ -0,0 +1,6 @@
+export { ConfigureCreateApp } from './ConfigureCreateAppStep';
+export { ConfirmationStep } from './ConfirmationStep';
+export { ProvideEmail } from './ProvideEmailStep';
+export { StepLayout } from './StepLayout';
+export { TestConfigurationStep } from './TestConfigurationStep';
+export { VerifyDomainStep } from './VerifyDomainStep';
diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx
new file mode 100644
index 00000000000..0cbe9614367
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx
@@ -0,0 +1,554 @@
+import React from 'react';
+
+import { Badge, Box, Button, Col, descriptors, Flex, Icon, Spinner, Text, useLocalizations } from '@/customizables';
+import { CaretLeft, CaretRight, Check } from '@/icons';
+import { Route, Switch, useRouter } from '@/router';
+
+import { useConfigureSSOFlow } from '../ConfigureSSOContext';
+import {
+ ConfigureSSOWizardContext,
+ useConfigureSSOWizard,
+ useRegisterWizard,
+ useWizardChromeRegistry,
+ WizardChromeProvider,
+} from './ConfigureSSOWizardContext';
+import type {
+ ConfigureSSOWizardActiveStep,
+ ConfigureSSOWizardContextValue,
+ ConfigureSSOWizardStepProps,
+} from './types';
+
+const Step = (_: ConfigureSSOWizardStepProps): JSX.Element | null => null;
+Step.displayName = 'ConfigureSSOWizard.Step';
+
+interface RootProps {
+ children: React.ReactNode;
+}
+
+/**
+ * Walks the wizard's children and returns the descriptors for every
+ * `` element
+ */
+function extractSteps(children: React.ReactNode): ConfigureSSOWizardActiveStep[] {
+ const steps: ConfigureSSOWizardActiveStep[] = [];
+
+ React.Children.forEach(children, child => {
+ if (!React.isValidElement(child)) {
+ return;
+ }
+
+ // Tolerate fragments at the top level (e.g. when users factor a
+ // group of steps into a helper component that returns one)
+ if (child.type === React.Fragment) {
+ const fragmentChildren = (child.props as { children?: React.ReactNode }).children;
+ steps.push(...extractSteps(fragmentChildren));
+ return;
+ }
+
+ if (child.type !== Step) {
+ return;
+ }
+
+ const props = child.props as ConfigureSSOWizardStepProps;
+ steps.push({
+ id: props.id,
+ path: props.path,
+ label: props.label,
+ isCompleted: props.isCompleted,
+ children: props.children,
+ });
+ });
+
+ return steps;
+}
+
+const Root = ({ children }: RootProps): JSX.Element => {
+ const parentWizard = React.useContext(ConfigureSSOWizardContext);
+ const isNested = parentWizard !== null;
+
+ // Outermost wizard owns the shared chrome registry. Nested wizards
+ // reuse whatever the outer one provided, so registrations bubble up
+ if (!isNested) {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+interface RootInnerProps {
+ parentWizard: ConfigureSSOWizardContextValue | null;
+ isNested: boolean;
+ children: React.ReactNode;
+}
+
+const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.Element => {
+ const router = useRouter();
+ const flow = useConfigureSSOFlow();
+ const { isLoading } = flow;
+
+ const activeSteps = React.useMemo(() => extractSteps(children), [children]);
+
+ // Match the URL against non-first steps (most-specific first), the
+ // first step is mounted as the index route and is always the
+ // fallback when nothing else matches
+ const currentStep = React.useMemo(() => {
+ if (activeSteps.length === 0) {
+ return undefined;
+ }
+
+ return (
+ activeSteps
+ .slice(1)
+ .reverse()
+ .find(s => router.matches(s.path)) ?? activeSteps[0]
+ );
+ }, [activeSteps, router]);
+
+ const buildPath = React.useCallback(
+ (step: ConfigureSSOWizardActiveStep): string => {
+ const isFirst = activeSteps[0]?.id === step.id;
+ return isFirst ? './' : step.path;
+ },
+ [activeSteps],
+ );
+
+ const navigateTo = React.useCallback(
+ (step: ConfigureSSOWizardActiveStep | undefined) => (step ? router.navigate(buildPath(step)) : undefined),
+ [router, buildPath],
+ );
+
+ const goNext = React.useCallback(() => {
+ if (!currentStep) {
+ return;
+ }
+
+ const index = activeSteps.findIndex(s => s.id === currentStep.id);
+ const next = activeSteps[index + 1];
+ if (next) {
+ return navigateTo(next);
+ }
+
+ return parentWizard?.goNext();
+ }, [activeSteps, currentStep, navigateTo, parentWizard]);
+
+ const goPrev = React.useCallback(() => {
+ if (!currentStep) {
+ return;
+ }
+
+ const index = activeSteps.findIndex(s => s.id === currentStep.id);
+ const prev = activeSteps[index - 1];
+ if (prev) {
+ return navigateTo(prev);
+ }
+
+ return parentWizard?.goPrev();
+ }, [activeSteps, currentStep, navigateTo, parentWizard]);
+
+ const goToStep = React.useCallback(
+ (id: string) => navigateTo(activeSteps.find(s => s.id === id)),
+ [activeSteps, navigateTo],
+ );
+
+ const value = React.useMemo(() => {
+ const index = currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1;
+ return {
+ activeSteps,
+ currentStep,
+ currentIndex: index,
+ totalSteps: activeSteps.length,
+ isLoading,
+ goNext,
+ goPrev,
+ goToStep,
+ isNested,
+ isFirstStep: index <= 0 && (!parentWizard || parentWizard.isFirstStep),
+ isLastStep: index === activeSteps.length - 1 && (!parentWizard || parentWizard.isLastStep),
+ };
+ }, [activeSteps, currentStep, isLoading, goNext, goPrev, goToStep, isNested, parentWizard]);
+
+ // Push this wizard onto the chrome stack so the shared footer can
+ // dispatch Continue / Previous to the *deepest* mounted wizard,
+ // not just the outermost one
+ useRegisterWizard(value);
+
+ const body = ;
+
+ if (isNested) {
+ return {body};
+ }
+
+ // Outermost wizard owns the full layout
+ return (
+
+
+ {body}
+
+
+ );
+};
+
+/**
+ * Renders the active step's body
+ */
+const Body = ({ activeSteps }: { activeSteps: ConfigureSSOWizardActiveStep[] }): JSX.Element | null => {
+ const { isLoading, isNested } = useConfigureSSOWizard();
+
+ if (isLoading) {
+ if (isNested) {
+ return null;
+ }
+ return (
+
+
+
+ );
+ }
+
+ if (activeSteps.length === 0) {
+ return null;
+ }
+
+ const [firstStep, ...restSteps] = activeSteps;
+
+ return (
+
+ {restSteps.map(step => (
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+const StepBody = ({ step }: { step: ConfigureSSOWizardActiveStep }): JSX.Element => (
+
+ {step.children}
+
+);
+
+/**
+ * Numbered breadcrumb of the outermost wizard's active steps.
+ * Completed and current steps are clickable for backwards navigation,
+ * future steps are disabled
+ */
+const Header = (): JSX.Element => {
+ const { activeSteps, currentIndex, isLoading, goToStep } = useConfigureSSOWizard();
+ const { t } = useLocalizations();
+
+ return (
+ ({
+ gap: theme.space.$2,
+ padding: `${theme.space.$4} ${theme.space.$6}`,
+ borderBottomWidth: theme.borderWidths.$normal,
+ borderBottomStyle: theme.borderStyles.$solid,
+ borderBottomColor: theme.colors.$borderAlpha100,
+ flexWrap: 'wrap',
+ })}
+ >
+ {activeSteps.map((step, index) => {
+ const isCurrent = index === currentIndex;
+ const isCompleted = step.isCompleted ?? index < currentIndex;
+ const isReachable = isCompleted || index <= currentIndex;
+ const labelText = step.label ? (typeof step.label === 'string' ? step.label : t(step.label)) : '';
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+ {index < activeSteps.length - 1 && (
+ ({ color: theme.colors.$colorMutedForeground })}
+ />
+ )}
+
+ );
+ })}
+
+ );
+};
+
+const SkeletonBreadcrumbStep = (): JSX.Element => (
+ ({ gap: t.space.$1x5 })}
+ >
+ ({
+ width: t.sizes.$5,
+ height: t.sizes.$5,
+ borderRadius: t.radii.$circle,
+ backgroundColor: t.colors.$neutralAlpha100,
+ })}
+ />
+ ({
+ width: t.sizes.$16,
+ height: t.space.$3,
+ borderRadius: t.radii.$md,
+ backgroundColor: t.colors.$neutralAlpha100,
+ })}
+ />
+
+);
+
+/**
+ * Compact "Step X / Y" badge that mirrors the *nearest* wizard's
+ * progress. Renders nothing when the nearest wizard has only one
+ * step
+ */
+const StepIndicator = (): JSX.Element | null => {
+ const { totalSteps, currentIndex } = useConfigureSSOWizard();
+
+ if (totalSteps <= 1 || currentIndex < 0) {
+ return null;
+ }
+
+ return (
+
+
+ ({ fontSize: t.fontSizes.$xs })}
+ >
+ Step {currentIndex + 1}/{totalSteps}
+
+
+
+ );
+};
+
+interface FooterProps {
+ /**
+ * Override label for the Previous button
+ */
+ previousLabel?: string;
+ /**
+ * Override label for the Continue button (also overridable per
+ * step via `useRegisterContinueAction({ label })`)
+ */
+ continueLabel?: string;
+ /**
+ * Hides the Previous button entirely
+ */
+ hidePrevious?: boolean;
+ /**
+ * Force-disables both Previous and Continue regardless of the
+ * wizard's own state
+ */
+ isDisabled?: boolean;
+}
+
+/**
+ * Shared Previous / Continue footer. Owned by the outermost wizard.
+ * Continue dispatches to the currently registered step `ContinueAction`
+ * if any; otherwise it advances the outermost wizard
+ */
+const Footer = (props: FooterProps): JSX.Element => {
+ const { previousLabel = 'Previous', continueLabel = 'Continue', hidePrevious = false, isDisabled = false } = props;
+ const { isLoading } = useConfigureSSOWizard();
+ const { continueAction, deepestWizardRef } = useWizardChromeRegistry();
+ const isForceDisabled = isDisabled || isLoading;
+ const { t } = useLocalizations();
+
+ // Footer-level controls always dispatch to the deepest mounted
+ // wizard. That way Previous from the second inner sub-step lands
+ // on the first inner sub-step instead of jumping out to the
+ // previous outer step
+ const deepest = deepestWizardRef.current?.current;
+ const isFirstStep = deepest?.isFirstStep ?? true;
+ const isLastStep = deepest?.isLastStep ?? true;
+
+ const continueLabelToShow =
+ typeof continueAction?.label === 'string'
+ ? continueAction.label
+ : continueAction?.label
+ ? t(continueAction.label)
+ : continueLabel;
+
+ const handleContinue = () => {
+ if (continueAction?.handler) {
+ void continueAction.handler();
+ return;
+ }
+
+ void deepestWizardRef.current?.current.goNext();
+ };
+
+ const handlePrevious = () => {
+ void deepestWizardRef.current?.current.goPrev();
+ };
+
+ return (
+ ({
+ gap: theme.space.$2,
+ padding: `${theme.space.$3} ${theme.space.$6}`,
+ borderTopWidth: theme.borderWidths.$normal,
+ borderTopStyle: theme.borderStyles.$solid,
+ borderTopColor: theme.colors.$borderAlpha100,
+ })}
+ >
+ {!hidePrevious && (
+
+ )}
+
+
+ );
+};
+
+/**
+ * Declarative wizard for the ConfigureSSO flow.
+ *
+ * Steps are written as JSX children: render a ``
+ * for each step and toggle visibility with regular conditional
+ * expressions (`{cond && ...}`)
+ *
+ * Inner sub-steps are declared by nesting another `` inside
+ * a step's body
+ */
+export const ConfigureSSOWizard = Object.assign(Root, {
+ Step,
+ StepIndicator,
+});
diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizardContext.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizardContext.tsx
new file mode 100644
index 00000000000..ceed1ce86ce
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizardContext.tsx
@@ -0,0 +1,145 @@
+import React from 'react';
+
+import type { ConfigureSSOWizardContextValue, ContinueAction } from './types';
+
+export const ConfigureSSOWizardContext = React.createContext(null);
+ConfigureSSOWizardContext.displayName = 'ConfigureSSOWizardContext';
+
+export function useConfigureSSOWizard(): ConfigureSSOWizardContextValue {
+ const ctx = React.useContext(ConfigureSSOWizardContext);
+
+ if (!ctx) {
+ throw new Error('useConfigureSSOWizard called outside of ');
+ }
+
+ return ctx;
+}
+
+/**
+ * Mutable handle into a wizard's latest context value. Every wizard
+ * updates its own ref on every render, so consumers reading
+ * `ref.current` always see fresh `goNext`/`goPrev` callbacks
+ */
+type WizardValueRef = { current: ConfigureSSOWizardContextValue };
+
+interface WizardChromeRegistry {
+ /**
+ * The currently registered Continue action, if any. Updated by
+ * step components via `useRegisterContinueAction`
+ */
+ continueAction: ContinueAction | undefined;
+ setContinueAction: (action: ContinueAction | undefined) => void;
+ /**
+ * Marks a wizard as mounted, called by every ``
+ * on mount and unmount
+ * Footer-level controls always dispatch to the deepest wizard in this stack
+ */
+ pushWizard: (ref: WizardValueRef) => void;
+ popWizard: (ref: WizardValueRef) => void;
+ /**
+ * The deepest mounted wizard, or `undefined` if none has been
+ * registered yet
+ */
+ deepestWizardRef: React.MutableRefObject;
+}
+
+/**
+ * Single registry shared across the entire wizard tree. Provided by
+ * the outermost ``; nested wizards reuse it
+ */
+const WizardChromeContext = React.createContext(null);
+WizardChromeContext.displayName = 'ConfigureSSOWizardChromeContext';
+
+/**
+ * Mounted internally by the outermost ``
+ */
+export const WizardChromeProvider = ({ children }: { children: React.ReactNode }): JSX.Element => {
+ const [continueAction, setContinueAction] = React.useState(undefined);
+ const stackRef = React.useRef([]);
+ const deepestWizardRef = React.useRef(undefined);
+
+ const pushWizard = React.useCallback((ref: WizardValueRef) => {
+ stackRef.current = [...stackRef.current, ref];
+ deepestWizardRef.current = stackRef.current[stackRef.current.length - 1];
+ }, []);
+
+ const popWizard = React.useCallback((ref: WizardValueRef) => {
+ stackRef.current = stackRef.current.filter(r => r !== ref);
+ deepestWizardRef.current = stackRef.current[stackRef.current.length - 1];
+ }, []);
+
+ const value = React.useMemo(
+ () => ({ continueAction, setContinueAction, pushWizard, popWizard, deepestWizardRef }),
+ [continueAction, pushWizard, popWizard],
+ );
+
+ return {children};
+};
+
+/**
+ * Internal accessor used by `` and the footer
+ */
+export function useWizardChromeRegistry(): WizardChromeRegistry {
+ const ctx = React.useContext(WizardChromeContext);
+
+ if (!ctx) {
+ throw new Error('Wizard chrome registry is only available inside ');
+ }
+
+ return ctx;
+}
+
+/**
+ * Stable handle pushed/popped on mount-unmount. Wizards keep
+ * `valueRef.current` up to date every render so the footer reads the
+ * latest `goNext`/`goPrev` even after subsequent re-renders
+ */
+export function useRegisterWizard(value: ConfigureSSOWizardContextValue): void {
+ const { pushWizard, popWizard } = useWizardChromeRegistry();
+ const valueRef = React.useRef(value);
+ valueRef.current = value;
+
+ React.useEffect(() => {
+ const ref = valueRef;
+ pushWizard(ref);
+ return () => popWizard(ref);
+ }, [pushWizard, popWizard]);
+}
+
+/**
+ * Helper for step components that need to register a Continue action.
+ * Always writes to the outermost wizard's registry, so the shared
+ * footer sees actions registered from arbitrarily deeply nested
+ * wizards
+ */
+export function useRegisterContinueAction(action: ContinueAction | undefined): void {
+ const { setContinueAction } = useWizardChromeRegistry();
+
+ const handlerRef = React.useRef(action?.handler);
+ handlerRef.current = action?.handler;
+
+ const hasAction = action !== undefined;
+ const isDisabled = action?.isDisabled;
+ const isLoading = action?.isLoading;
+ const label = action?.label;
+
+ React.useEffect(() => {
+ if (!hasAction) {
+ setContinueAction(undefined);
+ return;
+ }
+
+ setContinueAction({
+ handler: () => handlerRef.current?.(),
+ isDisabled,
+ isLoading,
+ label,
+ });
+ }, [hasAction, isDisabled, isLoading, label, setContinueAction]);
+
+ // Separate unmount-only cleanup, so dep changes above don't
+ // transiently clear the registered action
+ React.useEffect(() => {
+ return () => setContinueAction(undefined);
+ }, [setContinueAction]);
+}
diff --git a/packages/ui/src/components/ConfigureSSO/wizard/index.ts b/packages/ui/src/components/ConfigureSSO/wizard/index.ts
new file mode 100644
index 00000000000..7497b387300
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/wizard/index.ts
@@ -0,0 +1,3 @@
+export { ConfigureSSOWizard } from './ConfigureSSOWizard';
+export { useConfigureSSOWizard, useRegisterContinueAction } from './ConfigureSSOWizardContext';
+export type { ConfigureSSOWizardContextValue, ConfigureSSOWizardStepProps, ContinueAction } from './types';
diff --git a/packages/ui/src/components/ConfigureSSO/wizard/types.ts b/packages/ui/src/components/ConfigureSSO/wizard/types.ts
new file mode 100644
index 00000000000..87d09bc229d
--- /dev/null
+++ b/packages/ui/src/components/ConfigureSSO/wizard/types.ts
@@ -0,0 +1,137 @@
+import type React from 'react';
+
+import type { LocalizationKey } from '@/customizables';
+
+/**
+ * Props for ``. Each rendered Step is one
+ * navigable position in its parent ``. Inner
+ * sub-steps are declared by nesting another ``
+ * inside the Step's body
+ */
+export interface ConfigureSSOWizardStepProps {
+ /**
+ * Stable identifier for the step. Used as a React key, for
+ * `goToStep(id)`, and as a fallback when two steps share a path
+ */
+ id: string;
+ /**
+ * Path fragment used by the SDK router. The first non-skipped
+ * sibling is mounted as the parent's index route, so its `path`
+ * is only used for `goToStep` / deep-linking purposes
+ */
+ path: string;
+ /**
+ * Label shown in the breadcrumb at the top of the wizard. Only
+ * outermost steps need a label — inner steps reuse their parent's
+ * breadcrumb entry
+ */
+ label?: LocalizationKey | string;
+ /**
+ * Marks this step as completed regardless of its position relative
+ * to the current step
+ */
+ isCompleted?: boolean;
+ /**
+ * The step body. Anything React, including a nested
+ * `` for inner sub-steps
+ */
+ children: React.ReactNode;
+}
+
+/**
+ * Action registered by the currently active step to be invoked when
+ * the "Continue" button in the Wizard footer is clicked
+ *
+ * If no step registers a `ContinueAction`, the footer falls back to
+ * calling `goNext()` directly
+ */
+export interface ContinueAction {
+ /**
+ * Called when the user clicks "Continue". Should typically validate /
+ * submit the step's form and then call `goNext()` on success
+ */
+ handler: () => void | Promise;
+ /**
+ * Disables the Continue button (e.g. while a form is invalid)
+ */
+ isDisabled?: boolean;
+ /**
+ * Renders a loading state on the Continue button
+ */
+ isLoading?: boolean;
+ /**
+ * Optional override for the Continue button label
+ */
+ label?: LocalizationKey | string;
+}
+
+/**
+ * Internal step descriptor extracted from a Step element's props.
+ * Consumers shouldn't need to construct these directly
+ */
+export interface ConfigureSSOWizardActiveStep {
+ id: string;
+ path: string;
+ label?: LocalizationKey | string;
+ isCompleted?: boolean;
+ children: React.ReactNode;
+}
+
+export interface ConfigureSSOWizardContextValue {
+ /**
+ * The active siblings inside the *current* Wizard scope (only the
+ * steps that survived conditional rendering)
+ */
+ activeSteps: ConfigureSSOWizardActiveStep[];
+ /**
+ * The step matched by the current SDK route, or `undefined` while
+ * the router is settling
+ */
+ currentStep: ConfigureSSOWizardActiveStep | undefined;
+ /**
+ * Index of `currentStep` within `activeSteps`. `-1` if not matched
+ */
+ currentIndex: number;
+ /**
+ * Convenience: `activeSteps.length`
+ */
+ totalSteps: number;
+ /**
+ * `true` when the user is at the very first position inside *this*
+ * wizard scope and there is no parent wizard to fall back on
+ */
+ isFirstStep: boolean;
+ /**
+ * `true` when the user is at the very last position inside *this*
+ * wizard scope and there is no parent wizard to fall back on
+ */
+ isLastStep: boolean;
+ /**
+ * `true` while the parent flow is still loading async dependencies.
+ * The header renders a skeleton breadcrumb, the content renders a
+ * centered spinner, and the footer's buttons are disabled
+ */
+ isLoading: boolean;
+ /**
+ * Navigate forward. Within this wizard, advances to the next active
+ * sibling. On the last sibling, falls through to the parent
+ * wizard's `goNext` (if any)
+ */
+ goNext: () => Promise | void;
+ /**
+ * Navigate backward. Mirror of `goNext`: previous sibling, then
+ * back to the parent's last sibling on overflow
+ */
+ goPrev: () => Promise | void;
+ /**
+ * Jump to a specific step by `id` within this wizard scope. No-op
+ * if the id is not in `activeSteps`
+ */
+ goToStep: (id: string) => Promise | void;
+ /**
+ * `true` when this wizard is rendered inside another wizard. The
+ * outermost wizard owns the breadcrumb / footer chrome; nested
+ * wizards render only the active step's body
+ */
+ isNested: boolean;
+}
diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts
index 7ebdb658e28..e437144ca16 100644
--- a/packages/ui/src/customizables/elementDescriptors.ts
+++ b/packages/ui/src/customizables/elementDescriptors.ts
@@ -536,6 +536,16 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'enterpriseConnectionButton',
'enterpriseConnectionButtonText',
+ 'configureSSOWizardHeader',
+ 'configureSSOWizardHeaderItem',
+ 'configureSSOWizardHeaderItemBullet',
+ 'configureSSOWizardHeaderItemLabel',
+ 'configureSSOWizardHeaderSeparator',
+ 'configureSSOWizardBody',
+ 'configureSSOWizardStepIndicator',
+ 'configureSSOWizardFooterPreviousButton',
+ 'configureSSOWizardFooterContinueButton',
+
'web3SolanaWalletButtonsRoot',
'web3SolanaWalletButtons',
'web3SolanaWalletButtonsIconButton',
diff --git a/packages/ui/src/elements/contexts/index.tsx b/packages/ui/src/elements/contexts/index.tsx
index 44eef53bf88..e0c5eff48d0 100644
--- a/packages/ui/src/elements/contexts/index.tsx
+++ b/packages/ui/src/elements/contexts/index.tsx
@@ -133,7 +133,13 @@ export type FlowMetadata = {
| 'chooseWallet'
| 'enterpriseConnections'
| 'organizationCreationDisabled'
- | 'methodSelectionMFA';
+ | 'methodSelectionMFA'
+ | 'provideEmail'
+ | 'verifyDomain'
+ | 'configureCreateApp'
+ | 'configureMapAttributes'
+ | 'test-sso'
+ | 'sso-confirmation';
};
const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('FlowMetadata');
diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts
index cd0ce04dcfe..5cc35d313be 100644
--- a/packages/ui/src/internal/appearance.ts
+++ b/packages/ui/src/internal/appearance.ts
@@ -672,6 +672,16 @@ export type ElementsConfig = {
enterpriseConnectionButton: WithOptions;
enterpriseConnectionButtonText: WithOptions;
+ configureSSOWizardHeader: WithOptions;
+ configureSSOWizardHeaderItem: WithOptions;
+ configureSSOWizardHeaderItemBullet: WithOptions;
+ configureSSOWizardHeaderItemLabel: WithOptions;
+ configureSSOWizardHeaderSeparator: WithOptions;
+ configureSSOWizardBody: WithOptions;
+ configureSSOWizardStepIndicator: WithOptions;
+ configureSSOWizardFooterPreviousButton: WithOptions;
+ configureSSOWizardFooterContinueButton: WithOptions;
+
web3SolanaWalletButtonsRoot: WithOptions;
web3SolanaWalletButtons: WithOptions;
web3SolanaWalletButtonsIconButton: WithOptions;