From 762aae7c5abc1d214f2b62ee992334d4009e93b3 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 11:04:55 -0300 Subject: [PATCH 01/31] refactor(ui): copy ConfigureSSO wizard into elements/ and split FooterActions context Adds a parallel wizard primitive at packages/ui/src/components/ConfigureSSO/elements/Wizard/, copied verbatim from packages/ui/src/components/ConfigureSSO/wizard/, then refactored: - Renamed exports from ConfigureSSOWizard / useConfigureSSOWizard to Wizard / useWizard. - Split FooterActionsContext from WizardContext. WizardContext owns navigation only (activeSteps, currentStep, goNext, goPrev, isLoading). FooterActionsContext owns step-action registration (continueAction, setContinueAction, chrome stack registry). - Stripped UI from the Wizard root: removed Header, Footer, StepIndicator, Body, and helpers. Wizard now provides context + step extraction + active-step rendering only. Header and Footer will be added back as ConfigureSSO layout components in a follow-up. - Preserved nested-wizard URL scoping via Option (a): the Wizard root keeps a Switch with a Route per step, so each Route children get a scoped RouteContext for nested wizards. Step itself stays a null-returning descriptor (its children are harvested by extractSteps and rendered inside the Switch). This stays close to the existing shape and avoids requiring a free-floating Route from Step. Laura's existing wizard/ folder is untouched and still in use by ConfigureSSO.tsx. The new elements/Wizard/ is dormant until ConfigureSSO.tsx switches over in a later commit. --- .../elements/Wizard/FooterActionsContext.tsx | 135 ++++++++++ .../ConfigureSSO/elements/Wizard/Wizard.tsx | 239 ++++++++++++++++++ .../elements/Wizard/WizardContext.tsx | 16 ++ .../ConfigureSSO/elements/Wizard/index.ts | 4 + .../ConfigureSSO/elements/Wizard/types.ts | 136 ++++++++++ 5 files changed, 530 insertions(+) create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Wizard/FooterActionsContext.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Wizard/WizardContext.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Wizard/index.ts create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/FooterActionsContext.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/FooterActionsContext.tsx new file mode 100644 index 00000000000..2fc7f9465dc --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/FooterActionsContext.tsx @@ -0,0 +1,135 @@ +import React from 'react'; + +import type { ContinueAction, WizardContextValue } from './types'; + +/** + * 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: WizardContextValue }; + +interface FooterActionsContextValue { + /** + * 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 + * `` mounted at the layout level; nested + * wizards reuse it via context + */ +const FooterActionsContext = React.createContext(null); +FooterActionsContext.displayName = 'FooterActionsContext'; + +/** + * Mounted at the layout level so the Footer (which lives outside the + * Wizard's direct child tree) can read the registered action and the + * deepest wizard's navigation handlers + */ +export const FooterActionsProvider = ({ 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 useFooterActions(): FooterActionsContextValue { + const ctx = React.useContext(FooterActionsContext); + + if (!ctx) { + throw new Error('Footer actions 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: WizardContextValue): void { + const { pushWizard, popWizard } = useFooterActions(); + 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 } = useFooterActions(); + + 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/elements/Wizard/Wizard.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx new file mode 100644 index 00000000000..c726d5482be --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx @@ -0,0 +1,239 @@ +import React from 'react'; + +import { Col, descriptors } from '@/customizables'; +import { Route, Switch, useRouter } from '@/router'; + +import { useConfigureSSOFlow } from '../../ConfigureSSOContext'; +import { FooterActionsProvider, useRegisterWizard } from './FooterActionsContext'; +import type { WizardActiveStep, WizardContextValue, WizardStepProps } from './types'; +import { WizardContext } from './WizardContext'; + +const Step = (_: WizardStepProps): JSX.Element | null => null; +Step.displayName = 'Wizard.Step'; + +interface RootProps { + children: React.ReactNode; +} + +/** + * Walks the wizard's children and returns the descriptors for every + * `` element + */ +function extractSteps(children: React.ReactNode): WizardActiveStep[] { + const steps: WizardActiveStep[] = []; + + 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 WizardStepProps; + 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(WizardContext); + const isNested = parentWizard !== null; + + // Outermost wizard owns the shared footer-actions registry. Nested + // wizards reuse whatever the outer one provided, so registrations + // bubble up + if (!isNested) { + return ( + + + {children} + + + ); + } + + return ( + + {children} + + ); +}; + +interface RootInnerProps { + parentWizard: WizardContextValue | 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: WizardActiveStep): string => { + const isFirst = activeSteps[0]?.id === step.id; + return isFirst ? './' : step.path; + }, + [activeSteps], + ); + + const navigateTo = React.useCallback( + (step: WizardActiveStep | 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); + + if (activeSteps.length === 0) { + return {null}; + } + + const [firstStep, ...restSteps] = activeSteps; + + // The Wizard root is UI-less: no header, no footer, no step + // indicator. It only provides navigation context and renders the + // active step's children inside a Switch/Route so nested wizards + // get a scoped RouteContext + return ( + + + {restSteps.map(step => ( + + + {step.children} + + + ))} + + + {firstStep.children} + + + + + ); +}; + +/** + * Declarative wizard primitive — UI-less. + * + * 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 + * + * The Wizard root renders no chrome — Header, Footer, and any step + * indicator are provided by the host layout via `useWizard()` and + * `useFooterActions()` + */ +export const Wizard = Object.assign(Root, { + Step, +}); diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/WizardContext.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/WizardContext.tsx new file mode 100644 index 00000000000..fb46e59ad83 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/WizardContext.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import type { WizardContextValue } from './types'; + +export const WizardContext = React.createContext(null); +WizardContext.displayName = 'WizardContext'; + +export function useWizard(): WizardContextValue { + const ctx = React.useContext(WizardContext); + + if (!ctx) { + throw new Error('useWizard called outside of '); + } + + return ctx; +} diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/index.ts b/packages/ui/src/components/ConfigureSSO/elements/Wizard/index.ts new file mode 100644 index 00000000000..f2d72ec9fcc --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/index.ts @@ -0,0 +1,4 @@ +export { Wizard } from './Wizard'; +export { useWizard } from './WizardContext'; +export { FooterActionsProvider, useFooterActions, useRegisterContinueAction } from './FooterActionsContext'; +export type { ContinueAction, WizardContextValue, WizardStepProps } from './types'; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts b/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts new file mode 100644 index 00000000000..56cd6e4a6a9 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts @@ -0,0 +1,136 @@ +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 WizardStepProps { + /** + * 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 WizardActiveStep { + id: string; + path: string; + label?: LocalizationKey | string; + isCompleted?: boolean; + children: React.ReactNode; +} + +export interface WizardContextValue { + /** + * The active siblings inside the *current* Wizard scope (only the + * steps that survived conditional rendering) + */ + activeSteps: WizardActiveStep[]; + /** + * The step matched by the current SDK route, or `undefined` while + * the router is settling + */ + currentStep: WizardActiveStep | 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; +} From e31a32b837fed1d10b0d1e228bff0520246c2e7d Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 11:22:23 -0300 Subject: [PATCH 02/31] refactor(ui): drop layout wrapper from Wizard root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Wizard primitive renders step children directly inside each Route, with no Col wrapper and no element descriptors. Layout sizing belongs to the host's ConfigureSSO layout component, and per-step descriptors belong to each step's own root element — neither is the wizard's concern. --- .../ConfigureSSO/elements/Wizard/Wizard.tsx | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx index c726d5482be..78164f167e4 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx @@ -1,6 +1,5 @@ import React from 'react'; -import { Col, descriptors } from '@/customizables'; import { Route, Switch, useRouter } from '@/router'; import { useConfigureSSOFlow } from '../../ConfigureSSOContext'; @@ -197,24 +196,10 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El key={step.id} path={step.path} > - - {step.children} - + {step.children} ))} - - - {firstStep.children} - - + {firstStep.children} ); From da8c880558da070057b5abeb47ad5ee25f0316ee Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 11:31:36 -0300 Subject: [PATCH 03/31] feat(ui): add Breadcrumbs primitive for ConfigureSSO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the numbered breadcrumb rendering from the existing wizard Header into a presentational primitive at packages/ui/src/components/ ConfigureSSO/elements/Breadcrumbs/. The primitive takes items, currentIndex, and onItemClick — no awareness of the wizard or its context. Wires existing element descriptors (configureSSOWizardHeader*) through to the same DOM elements they covered in the old Header. --- .../elements/Breadcrumbs/Breadcrumbs.tsx | 114 ++++++++++++++++++ .../elements/Breadcrumbs/index.ts | 2 + .../elements/Breadcrumbs/types.ts | 41 +++++++ 3 files changed, 157 insertions(+) create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts create mode 100644 packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts diff --git a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..186fe909a5f --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,114 @@ +import React from 'react'; + +import { Button, descriptors, Flex, Icon, Text, useLocalizations } from '@/customizables'; +import { CaretRight, Check } from '@/icons'; + +import type { BreadcrumbsProps } from './types'; + +/** + * Numbered breadcrumb of wizard steps. + * + * Presentational primitive — no awareness of the wizard or its + * context. Items at indices `<= currentIndex` are clickable (calls + * `onItemClick(id)`); later items are disabled. Items marked + * `isCompleted` render the check icon and are reachable regardless of + * position + */ +export const Breadcrumbs = ({ items, currentIndex, onItemClick }: BreadcrumbsProps): JSX.Element => { + 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', + })} + > + {items.map((item, index) => { + const isCurrent = index === currentIndex; + const isCompleted = item.isCompleted ?? index < currentIndex; + const isReachable = isCompleted || index <= currentIndex; + const labelText = item.label ? (typeof item.label === 'string' ? item.label : t(item.label)) : ''; + + return ( + + + {index < items.length - 1 && ( + ({ color: theme.colors.$colorMutedForeground })} + /> + )} + + ); + })} + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts new file mode 100644 index 00000000000..759ad86de28 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts @@ -0,0 +1,2 @@ +export { Breadcrumbs } from './Breadcrumbs'; +export type { BreadcrumbsItem, BreadcrumbsProps } from './types'; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts new file mode 100644 index 00000000000..a00f9a37b76 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts @@ -0,0 +1,41 @@ +import type { LocalizationKey } from '@/customizables'; + +/** + * A single breadcrumb item. Items are positional — their order in the + * `items` array determines the displayed step number and which items + * are reachable relative to the active one + */ +export interface BreadcrumbsItem { + /** + * Stable identifier — used as a React key and as the argument to + * `onItemClick` + */ + id: string; + /** + * Display label. Optional — items without a label render the bullet + * only + */ + label?: LocalizationKey | string; + /** + * Marks this item as completed regardless of its position relative + * to `currentIndex` + */ + isCompleted?: boolean; +} + +export interface BreadcrumbsProps { + /** + * The items to render, in order + */ + items: BreadcrumbsItem[]; + /** + * Index of the currently active item. Items at indices `<= currentIndex` + * are clickable; later items are disabled. `-1` means none active + */ + currentIndex: number; + /** + * Called with the item's `id` when an item is clicked. Only fired + * for reachable items (`isCompleted || index <= currentIndex`) + */ + onItemClick: (id: string) => void; +} From 02317deb8adcd847a1026ab90b2ef0a9bbce143c Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 13:13:23 -0300 Subject: [PATCH 04/31] refactor(ui): convert Breadcrumbs to declarative children-based API Replaces the items/currentIndex prop pair with `` children and a `currentId` prop on the parent. Mirrors the pattern used by `` / `` in the same component folder. Breadcrumbs walks its children via an extractItems helper (with fragment support) and computes the active index from `currentId` internally. Reachability logic, element descriptors, and the visual rendering are unchanged. --- .../elements/Breadcrumbs/Breadcrumbs.tsx | 67 ++++++++++++++++--- .../elements/Breadcrumbs/index.ts | 2 +- .../elements/Breadcrumbs/types.ts | 52 +++++++------- 3 files changed, 82 insertions(+), 39 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx index 186fe909a5f..97b14cf207a 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/Breadcrumbs.tsx @@ -3,20 +3,52 @@ import React from 'react'; import { Button, descriptors, Flex, Icon, Text, useLocalizations } from '@/customizables'; import { CaretRight, Check } from '@/icons'; -import type { BreadcrumbsProps } from './types'; +import type { BreadcrumbsActiveItem, BreadcrumbsItemProps, BreadcrumbsProps } from './types'; + +const Item = (_: BreadcrumbsItemProps): JSX.Element | null => null; +Item.displayName = 'Breadcrumbs.Item'; /** - * Numbered breadcrumb of wizard steps. - * - * Presentational primitive — no awareness of the wizard or its - * context. Items at indices `<= currentIndex` are clickable (calls - * `onItemClick(id)`); later items are disabled. Items marked - * `isCompleted` render the check icon and are reachable regardless of - * position + * Walks the breadcrumbs' children and returns the descriptors for + * every `` element */ -export const Breadcrumbs = ({ items, currentIndex, onItemClick }: BreadcrumbsProps): JSX.Element => { +function extractItems(children: React.ReactNode): BreadcrumbsActiveItem[] { + const items: BreadcrumbsActiveItem[] = []; + + React.Children.forEach(children, child => { + if (!React.isValidElement(child)) { + return; + } + + // Tolerate fragments at the top level (e.g. when callers map over + // an array of steps and wrap items in a fragment) + if (child.type === React.Fragment) { + const fragmentChildren = (child.props as { children?: React.ReactNode }).children; + items.push(...extractItems(fragmentChildren)); + return; + } + + if (child.type !== Item) { + return; + } + + const props = child.props as BreadcrumbsItemProps; + items.push({ + id: props.id, + label: props.label, + isCompleted: props.isCompleted, + }); + }); + + return items; +} + +const Breadcrumbs_ = ({ currentId, onItemClick, children }: BreadcrumbsProps): JSX.Element => { const { t } = useLocalizations(); + const items = React.useMemo(() => extractItems(children), [children]); + const currentIndex = React.useMemo(() => items.findIndex(i => i.id === currentId), [items, currentId]); + return ( ); }; + +/** + * Numbered breadcrumb of wizard steps. + * + * Items are written as JSX children: render a `` + * for each step. Reachability is computed internally from `currentId` + * + the items list — items at indices `<= currentIndex` are clickable + * (calls `onItemClick(id)`), later items are disabled. Items marked + * `isCompleted` render the check icon and are reachable regardless of + * position + * + * Presentational primitive — no awareness of the wizard or its + * context + */ +export const Breadcrumbs = Object.assign(Breadcrumbs_, { + Item, +}); diff --git a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts index 759ad86de28..ddb1cfe54e6 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts +++ b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/index.ts @@ -1,2 +1,2 @@ export { Breadcrumbs } from './Breadcrumbs'; -export type { BreadcrumbsItem, BreadcrumbsProps } from './types'; +export type { BreadcrumbsItemProps, BreadcrumbsProps } from './types'; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts index a00f9a37b76..4f1116d0896 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts +++ b/packages/ui/src/components/ConfigureSSO/elements/Breadcrumbs/types.ts @@ -1,41 +1,35 @@ +import type React from 'react'; + import type { LocalizationKey } from '@/customizables'; /** - * A single breadcrumb item. Items are positional — their order in the - * `items` array determines the displayed step number and which items - * are reachable relative to the active one + * Props for ``. Items are renderless descriptors — + * Breadcrumbs walks its children to collect them + */ +export interface BreadcrumbsItemProps { + /** Stable identifier — used as React key, as the value compared against `Breadcrumbs.currentId`, and as the argument to `onItemClick` */ + id: string; + /** Display label. Optional — items without a label render the bullet only */ + label?: LocalizationKey | string; + /** Marks this item as completed regardless of its position relative to the current item */ + isCompleted?: boolean; +} + +/** + * Internal descriptor extracted from a `` element's + * props. Consumers should not need to construct these directly */ -export interface BreadcrumbsItem { - /** - * Stable identifier — used as a React key and as the argument to - * `onItemClick` - */ +export interface BreadcrumbsActiveItem { id: string; - /** - * Display label. Optional — items without a label render the bullet - * only - */ label?: LocalizationKey | string; - /** - * Marks this item as completed regardless of its position relative - * to `currentIndex` - */ isCompleted?: boolean; } export interface BreadcrumbsProps { - /** - * The items to render, in order - */ - items: BreadcrumbsItem[]; - /** - * Index of the currently active item. Items at indices `<= currentIndex` - * are clickable; later items are disabled. `-1` means none active - */ - currentIndex: number; - /** - * Called with the item's `id` when an item is clicked. Only fired - * for reachable items (`isCompleted || index <= currentIndex`) - */ + /** ID of the currently active item. Items at indices ≤ currentIndex are reachable; later items are disabled. `undefined` means none active */ + currentId: string | undefined; + /** Called with the item's id when an item is clicked. Only fired for reachable items. */ onItemClick: (id: string) => void; + /** `` descriptors to render */ + children: React.ReactNode; } From 801810d8bd789d7a2d224722330661ce73ac43e1 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 13:23:02 -0300 Subject: [PATCH 05/31] feat(ui): add ConfigureSSO layout components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new top-level components under packages/ui/src/components/ ConfigureSSO/ that consume the wizard primitive and the breadcrumbs primitive: - ConfigureSSOLayout: ProfileCard shell with the navbar sidebar and a body Col that owns the flex sizing for the wizard's step output. - ConfigureSSOHeader: thin wrapper that drives the declarative Breadcrumbs primitive from the wizard's active steps + currentStep. - ConfigureSSOFooter: shared Previous / Continue footer that dispatches to the deepest mounted wizard via the FooterActions context registry. Nothing wires these in yet — that swap lands in a follow-up commit. --- .../ConfigureSSO/ConfigureSSOFooter.tsx | 103 +++++++++++++++ .../ConfigureSSO/ConfigureSSOHeader.tsx | 30 +++++ .../ConfigureSSO/ConfigureSSOLayout.tsx | 121 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/ConfigureSSOLayout.tsx diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx new file mode 100644 index 00000000000..dcaf5e0aaa4 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx @@ -0,0 +1,103 @@ +import { Button, descriptors, Flex, Icon, useLocalizations } from '@/customizables'; +import { CaretLeft, CaretRight } from '@/icons'; + +import { useFooterActions, useWizard } from './elements/Wizard'; + +interface ConfigureSSOFooterProps { + /** 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 wizard state */ + isDisabled?: boolean; +} + +/** + * Shared Previous / Continue footer for the ConfigureSSO surface. + * Dispatches to the deepest mounted wizard so Previous from a nested + * sub-step lands on its own previous sibling instead of jumping out + * to the parent wizard's previous main step + */ +export const ConfigureSSOFooter = ({ + previousLabel = 'Previous', + continueLabel = 'Continue', + hidePrevious = false, + isDisabled = false, +}: ConfigureSSOFooterProps): JSX.Element => { + const { isLoading } = useWizard(); + const { continueAction, deepestWizardRef } = useFooterActions(); + const { t } = useLocalizations(); + + const isForceDisabled = isDisabled || isLoading; + 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 && ( + + )} + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx new file mode 100644 index 00000000000..6a3e1756799 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx @@ -0,0 +1,30 @@ +import { Breadcrumbs } from './elements/Breadcrumbs'; +import { useWizard } from './elements/Wizard'; + +/** + * Renders the wizard's active steps as a numbered breadcrumb. Sits + * above the body in the ConfigureSSO layout. Reads navigation state + * from `useWizard()` and feeds the data declaratively into the + * `` primitive + */ +export const ConfigureSSOHeader = (): JSX.Element => { + const { activeSteps, currentStep, goToStep } = useWizard(); + + return ( + { + void goToStep(id); + }} + > + {activeSteps.map(step => ( + + ))} + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOLayout.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOLayout.tsx new file mode 100644 index 00000000000..5614822983c --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOLayout.tsx @@ -0,0 +1,121 @@ +import { useOrganization } from '@clerk/shared/react'; +import React, { type PropsWithChildren } from 'react'; + +import { useEnvironment } from '@/contexts'; +import { Box, Col, descriptors, Flex, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; +import { ApplicationLogo } from '@/elements/ApplicationLogo'; +import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; +import { ProfileCard } from '@/elements/ProfileCard'; +import { BoxIcon } from '@/icons'; + +/** + * Visual shell for the ConfigureSSO surface — ProfileCard with the + * navbar sidebar and a body content area. Children render inside the + * body Col with flex sizing so the wizard / pre-wizard gates can fill + * the available space without needing their own sizing chrome + */ +export const ConfigureSSOLayout = ({ children }: PropsWithChildren): JSX.Element => { + const contentRef = React.useRef(null); + const { applicationName, logoImageUrl } = useEnvironment().displayConfig; + const { organizationSettings } = useEnvironment(); + const { parsedOptions } = useAppearance(); + const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); + + return ( + ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })} + > + + ({ + gap: t.space.$2, + padding: `${t.space.$none} ${t.space.$3}`, + maxWidth: '100%', + })} + > + {hasLogo ? ( + ({ width: t.space.$9, height: t.space.$9, borderRadius: t.radii.$md, overflow: 'hidden' })} + /> + ) : ( + ({ + width: t.space.$9, + height: t.space.$9, + flexShrink: 0, + borderRadius: t.radii.$md, + backgroundColor: t.colors.$primary500, + color: t.colors.$colorPrimaryForeground, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + })} + aria-hidden + > + ({ width: t.sizes.$4, height: t.sizes.$4 })} + /> + + )} + + + + {applicationName} + + {organizationSettings.enabled && } + + + } + titleSx={t => ({ fontSize: t.fontSizes.$lg })} + title={localizationKeys('configureSSO.navbar.title')} + 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, + })} + > + {children} + + + + ); +}; + +const OrganizationSidebarSubtitle = (): JSX.Element | null => { + const { organization } = useOrganization(); + + if (!organization) { + return null; + } + + return ( + ({ color: t.colors.$colorMutedForeground })} + > + {organization?.name} + + ); +}; From 23b49fd97970b18d6c22bf3a49c1c26a31509f0e Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 13:31:35 -0300 Subject: [PATCH 06/31] refactor(ui): inline StepLayout chrome into each ConfigureSSO step Removes the shared StepLayout wrapper from each step file and inlines the header (Heading + subtitle) plus body Col directly. Each step now owns its own visual chrome: - ProvideEmailStep, VerifyDomainStep, ConfigureCreateAppStep, TestConfigurationStep render their own header row and body. - ConfirmationStep drops the header entirely per the Figma design. Step files also switch wizard hook imports from the old wizard/ folder to the new elements/Wizard/ primitive, and each step attaches its own configureSSOWizardBody descriptor at its outermost rendered element. Flow.Part does not accept elementDescriptor / elementId props, so each step uses the fallback shape: a Col wrapper inside Flow.Part carries the descriptor. The wrapper Col keeps flex: 1 / minHeight: 0 so the inner body Col's flex: 1 / overflowY: auto continues to size against the layout's flex column context. useRegisterContinueAction usage is unchanged in this commit; the move to a Wizard.Step canContinue prop lands separately. The shared StepLayout file is left on disk (no more imports) and gets removed in a final cleanup commit. --- .../steps/ConfigureCreateAppStep.tsx | 41 +++++++++++++++---- .../ConfigureSSO/steps/ConfirmationStep.tsx | 23 ++++++++--- .../ConfigureSSO/steps/ProvideEmailStep.tsx | 41 +++++++++++++++---- .../steps/TestConfigurationStep.tsx | 38 +++++++++++++---- .../ConfigureSSO/steps/VerifyDomainStep.tsx | 41 +++++++++++++++---- .../components/ConfigureSSO/steps/index.ts | 1 - 6 files changed, 143 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx index 15193247e84..90ec7913fec 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx @@ -1,10 +1,9 @@ -import { Flow, Text } from '@/customizables'; +import { Col, descriptors, Flex, Flow, Heading, Text } from '@/customizables'; -import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; -import { StepLayout } from './StepLayout'; +import { useRegisterContinueAction, useWizard } from '../elements/Wizard'; export const ConfigureCreateApp = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); + const { goNext } = useWizard(); useRegisterContinueAction({ handler: () => goNext(), @@ -12,12 +11,36 @@ export const ConfigureCreateApp = (): JSX.Element => { return ( - - UI goes here - + ({ gap: theme.space.$4, padding: theme.space.$5 })} + > + ({ gap: theme.space.$1, minWidth: 0 })}> + ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} + > + Configure Okta Workforce + + ({ color: theme.colors.$colorMutedForeground })} + > + Create a new enterprise application in your Okta Dashboard. + + + + ({ flex: 1, paddingInline: theme.space.$5, overflowY: 'auto' })}> + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index 0f6cbf2c49e..0f769962172 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -1,13 +1,24 @@ -import { Flow, Text } from '@/customizables'; - -import { StepLayout } from './StepLayout'; +import { Col, descriptors, Flow, Text } from '@/customizables'; export const ConfirmationStep = (): JSX.Element => { return ( - - UI goes here - + + ({ + flex: 1, + paddingInline: theme.space.$5, + paddingTop: theme.space.$5, + overflowY: 'auto', + })} + > + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx index bf5afe4762e..c7f29ef835d 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx @@ -1,10 +1,9 @@ -import { Flow, Text } from '@/customizables'; +import { Col, descriptors, Flex, Flow, Heading, Text } from '@/customizables'; -import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; -import { StepLayout } from './StepLayout'; +import { useRegisterContinueAction, useWizard } from '../elements/Wizard'; export const ProvideEmail = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); + const { goNext } = useWizard(); useRegisterContinueAction({ handler: () => { @@ -14,12 +13,36 @@ export const ProvideEmail = (): JSX.Element => { return ( - - UI goes here - + ({ gap: theme.space.$4, padding: theme.space.$5 })} + > + ({ gap: theme.space.$1, minWidth: 0 })}> + ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} + > + Verify your domain + + ({ color: theme.colors.$colorMutedForeground })} + > + Verify the domain you want to enable the enterprise connection on. + + + + ({ flex: 1, paddingInline: theme.space.$5, overflowY: 'auto' })}> + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 31c1ab907de..29cda0e0314 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,16 +1,38 @@ -import { Flow, Text } from '@/customizables'; - -import { StepLayout } from './StepLayout'; +import { Col, descriptors, Flex, Flow, Heading, Text } from '@/customizables'; export const TestConfigurationStep = (): JSX.Element => { return ( - - UI goes here - + ({ gap: theme.space.$4, padding: theme.space.$5 })} + > + ({ gap: theme.space.$1, minWidth: 0 })}> + ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} + > + Test your SSO connection + + ({ color: theme.colors.$colorMutedForeground })} + > + Test your SSO configuration to verify you can successfully authenticate via your identity provider + + + + ({ flex: 1, paddingInline: theme.space.$5, overflowY: 'auto' })}> + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 9c37d3261d9..e07bc2e994e 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -1,10 +1,9 @@ -import { Flow, Text } from '@/customizables'; +import { Col, descriptors, Flex, Flow, Heading, Text } from '@/customizables'; -import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; -import { StepLayout } from './StepLayout'; +import { useRegisterContinueAction, useWizard } from '../elements/Wizard'; export const VerifyDomainStep = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); + const { goNext } = useWizard(); useRegisterContinueAction({ handler: () => goNext(), @@ -14,12 +13,36 @@ export const VerifyDomainStep = (): JSX.Element => { return ( - - UI goes here - + ({ gap: theme.space.$4, padding: theme.space.$5 })} + > + ({ gap: theme.space.$1, minWidth: 0 })}> + ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} + > + Verify your domain + + ({ color: theme.colors.$colorMutedForeground })} + > + Verify the domain you want to enable the enterprise connection on. + + + + ({ flex: 1, paddingInline: theme.space.$5, overflowY: 'auto' })}> + UI goes here + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts index 300535512e9..29f9fc0faf4 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/index.ts +++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts @@ -1,6 +1,5 @@ export { ConfigureCreateApp } from './ConfigureCreateAppStep'; export { ConfirmationStep } from './ConfirmationStep'; export { ProvideEmail } from './ProvideEmailStep'; -export { StepLayout } from './StepLayout'; export { TestConfigurationStep } from './TestConfigurationStep'; export { VerifyDomainStep } from './VerifyDomainStep'; From 8dd147b0ec716fcef6f461094ec3326d6841f887 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 14:07:43 -0300 Subject: [PATCH 07/31] refactor(ui): switch ConfigureSSO to layered wizard architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new ConfigureSSO surface into the layered components shipped in the previous commits: - ConfigureSSO.tsx adopts the new shape — Flow.Part wrap inside Flow.Root for convention parity with OrganizationProfile, an initial load gate at the root that returns a centered spinner inside ConfigureSSOLayout, and a FooterActionsProvider wrap around the layout. The inline ProfileCard / NavBar / Col chrome moves out; ConfigureSSOLayout owns it now. - ConfigureSSOSteps switches its wizard imports from the old wizard/ folder to the new elements/Wizard/ primitive. The declarative step tree shape is preserved. - isLoading is lifted out of ConfigureSSOFlowContext and out of the wizard's context value. The root-level gate handles initial loading; subsequent re-fetches no longer flip the wizard into a loading state in context. ConfigureSSOFooter drops the isLoading-driven disable. - The Wizard primitive no longer imports useConfigureSSOFlow — it's a fully generic primitive at the navigation layer now. - The unused legacy wizard folder also drops its isLoading reads (spinner branch in Body, skeleton in Header, force-disable in Footer) so the typechecker stays clean while it sits on disk. The old wizard/ folder is unused after this commit but stays on disk for a final cleanup commit. --- .../components/ConfigureSSO/ConfigureSSO.tsx | 204 ++++++------------ .../ConfigureSSO/ConfigureSSOContext.tsx | 19 +- .../ConfigureSSO/ConfigureSSOFooter.tsx | 5 +- .../ConfigureSSO/elements/Wizard/Wizard.tsx | 6 +- .../ConfigureSSO/elements/Wizard/types.ts | 6 - .../wizard/ConfigureSSOWizard.tsx | 174 ++++++--------- .../components/ConfigureSSO/wizard/types.ts | 6 - 7 files changed, 130 insertions(+), 290 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index f1585e7a8de..654ed24089b 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -1,127 +1,67 @@ -import { __internal_useUserEnterpriseConnections, useOrganization, useUser } from '@clerk/shared/react'; +import { __internal_useUserEnterpriseConnections, 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, descriptors, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; -import { ApplicationLogo } from '@/elements/ApplicationLogo'; +import { withCoreUserGuard } from '@/contexts'; +import { descriptors, Flex, Flow, Spinner } from '@/customizables'; import { withCardStateProvider } from '@/elements/contexts'; -import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; -import { ProfileCard } from '@/elements/ProfileCard'; -import { BoxIcon } from '@/icons'; import { Route, Switch } from '@/router'; import { ConfigureSSOFlowProvider } from './ConfigureSSOContext'; +import { ConfigureSSOFooter } from './ConfigureSSOFooter'; +import { ConfigureSSOHeader } from './ConfigureSSOHeader'; +import { ConfigureSSOLayout } from './ConfigureSSOLayout'; +import { FooterActionsProvider, Wizard } from './elements/Wizard'; import { ConfigureCreateApp, ConfirmationStep, ProvideEmail, TestConfigurationStep, VerifyDomainStep } from './steps'; -import { ConfigureSSOWizard } from './wizard'; const ConfigureSSOInternal = () => { return ( - - - - - + + + + + + + ); }; const AuthenticatedContent = withCoreUserGuard(() => { - const contentRef = React.useRef(null); - const { applicationName, logoImageUrl } = useEnvironment().displayConfig; - const { organizationSettings } = useEnvironment(); - const { parsedOptions } = useAppearance(); - const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); - - const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections } = - __internal_useUserEnterpriseConnections({ enabled: true }); + const { data: enterpriseConnections, isLoading } = __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' })} - > - - ({ - gap: t.space.$2, - padding: `${t.space.$none} ${t.space.$3}`, - maxWidth: '100%', - })} - > - {hasLogo ? ( - ({ width: t.space.$9, height: t.space.$9, borderRadius: t.radii.$md, overflow: 'hidden' })} - /> - ) : ( - ({ - width: t.space.$9, - height: t.space.$9, - flexShrink: 0, - borderRadius: t.radii.$md, - backgroundColor: t.colors.$primary500, - color: t.colors.$colorPrimaryForeground, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - })} - aria-hidden - > - ({ width: t.sizes.$4, height: t.sizes.$4 })} - /> - - )} - - - - {applicationName} - - {organizationSettings.enabled && } - - - } - titleSx={t => ({ fontSize: t.fontSizes.$lg })} - title={localizationKeys('configureSSO.navbar.title')} - 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, - })} + // Initial-load gate at root — wizard never sees isLoading + if (isLoading && !enterpriseConnections) { + return ( + + - - - - - - + + + + ); + } + + return ( + + + + + + + + + ); }); @@ -131,77 +71,59 @@ const ConfigureSSOSteps = () => { const primaryEmailAddress = user?.primaryEmailAddress; return ( - - + - + {!primaryEmailAddress && ( - - + )} - - - - - + + + - + {/* TODO: Implement configure steps */} - - - - - + + + - - + - - - ); -}; - -const OrganizationSidebarSubtitle = () => { - const { organization } = useOrganization(); - - if (!organization) { - return null; - } - - return ( - ({ color: t.colors.$colorMutedForeground })} - > - {organization?.name} - + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index c182456d34e..9513ca41a5e 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -11,39 +11,28 @@ export interface ConfigureSSOData { 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); +const ConfigureSSOFlowContext = React.createContext(null); ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext'; export const ConfigureSSOFlowProvider = ({ enterpriseConnection, - isLoading, children, }: PropsWithChildren): JSX.Element => { - const value = React.useMemo( + const value = React.useMemo( () => ({ enterpriseConnection, - isLoading, }), - [enterpriseConnection, isLoading], + [enterpriseConnection], ); return {children}; }; -export const useConfigureSSOFlow = (): ConfigureSSOContextValue => { +export const useConfigureSSOFlow = (): ConfigureSSOData => { const ctx = React.useContext(ConfigureSSOFlowContext); if (!ctx) { throw new Error('useConfigureSSOFlow called outside .'); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx index dcaf5e0aaa4..83ba9c81acc 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOFooter.tsx @@ -1,7 +1,7 @@ import { Button, descriptors, Flex, Icon, useLocalizations } from '@/customizables'; import { CaretLeft, CaretRight } from '@/icons'; -import { useFooterActions, useWizard } from './elements/Wizard'; +import { useFooterActions } from './elements/Wizard'; interface ConfigureSSOFooterProps { /** Override label for the Previous button */ @@ -26,11 +26,10 @@ export const ConfigureSSOFooter = ({ hidePrevious = false, isDisabled = false, }: ConfigureSSOFooterProps): JSX.Element => { - const { isLoading } = useWizard(); const { continueAction, deepestWizardRef } = useFooterActions(); const { t } = useLocalizations(); - const isForceDisabled = isDisabled || isLoading; + const isForceDisabled = isDisabled; const deepest = deepestWizardRef.current?.current; const isFirstStep = deepest?.isFirstStep ?? true; const isLastStep = deepest?.isLastStep ?? true; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx index 78164f167e4..bc05173b1a3 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Route, Switch, useRouter } from '@/router'; -import { useConfigureSSOFlow } from '../../ConfigureSSOContext'; import { FooterActionsProvider, useRegisterWizard } from './FooterActionsContext'; import type { WizardActiveStep, WizardContextValue, WizardStepProps } from './types'; import { WizardContext } from './WizardContext'; @@ -89,8 +88,6 @@ interface RootInnerProps { const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.Element => { const router = useRouter(); - const flow = useConfigureSSOFlow(); - const { isLoading } = flow; const activeSteps = React.useMemo(() => extractSteps(children), [children]); @@ -163,7 +160,6 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El currentStep, currentIndex: index, totalSteps: activeSteps.length, - isLoading, goNext, goPrev, goToStep, @@ -171,7 +167,7 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El isFirstStep: index <= 0 && (!parentWizard || parentWizard.isFirstStep), isLastStep: index === activeSteps.length - 1 && (!parentWizard || parentWizard.isLastStep), }; - }, [activeSteps, currentStep, isLoading, goNext, goPrev, goToStep, isNested, parentWizard]); + }, [activeSteps, currentStep, 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, diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts b/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts index 56cd6e4a6a9..508d070cdc4 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts @@ -105,12 +105,6 @@ export interface WizardContextValue { * 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 diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx index 0cbe9614367..b2c9c36d413 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx +++ b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { Badge, Box, Button, Col, descriptors, Flex, Icon, Spinner, Text, useLocalizations } from '@/customizables'; +import { Badge, Button, Col, descriptors, Flex, Icon, Text, useLocalizations } from '@/customizables'; import { CaretLeft, CaretRight, Check } from '@/icons'; import { Route, Switch, useRouter } from '@/router'; -import { useConfigureSSOFlow } from '../ConfigureSSOContext'; import { ConfigureSSOWizardContext, useConfigureSSOWizard, @@ -99,8 +98,6 @@ interface RootInnerProps { const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.Element => { const router = useRouter(); - const flow = useConfigureSSOFlow(); - const { isLoading } = flow; const activeSteps = React.useMemo(() => extractSteps(children), [children]); @@ -173,7 +170,6 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El currentStep, currentIndex: index, totalSteps: activeSteps.length, - isLoading, goNext, goPrev, goToStep, @@ -181,7 +177,7 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El isFirstStep: index <= 0 && (!parentWizard || parentWizard.isFirstStep), isLastStep: index === activeSteps.length - 1 && (!parentWizard || parentWizard.isLastStep), }; - }, [activeSteps, currentStep, isLoading, goNext, goPrev, goToStep, isNested, parentWizard]); + }, [activeSteps, currentStep, 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, @@ -208,27 +204,6 @@ const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.El * 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; } @@ -268,7 +243,7 @@ const StepBody = ({ step }: { step: ConfigureSSOWizardActiveStep }): JSX.Element * future steps are disabled */ const Header = (): JSX.Element => { - const { activeSteps, currentIndex, isLoading, goToStep } = useConfigureSSOWizard(); + const { activeSteps, currentIndex, goToStep } = useConfigureSSOWizard(); const { t } = useLocalizations(); return ( @@ -292,70 +267,66 @@ const Header = (): JSX.Element => { return ( - {isLoading ? ( - - ) : ( - - )} + {isCompleted && !isCurrent ? ( + + ) : ( + index + 1 + )} + + + {labelText} + + {index < activeSteps.length - 1 && ( { ); }; -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 @@ -457,9 +404,8 @@ interface FooterProps { */ 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 isForceDisabled = isDisabled; const { t } = useLocalizations(); // Footer-level controls always dispatch to the deepest mounted diff --git a/packages/ui/src/components/ConfigureSSO/wizard/types.ts b/packages/ui/src/components/ConfigureSSO/wizard/types.ts index 87d09bc229d..ecda52e2301 100644 --- a/packages/ui/src/components/ConfigureSSO/wizard/types.ts +++ b/packages/ui/src/components/ConfigureSSO/wizard/types.ts @@ -106,12 +106,6 @@ export interface ConfigureSSOWizardContextValue { * 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 From 05b559dab2733781496a86ae06cadbce412c1563 Mon Sep 17 00:00:00 2001 From: Iago Dahlem Lorensini Date: Wed, 6 May 2026 16:27:14 -0300 Subject: [PATCH 08/31] refactor(ui): make ConfigureSSO Wizard state-driven and render children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the route-based wizard primitive with a state-driven version: - Wizard renders {children} directly. Header, Footer, and step descriptors all render as siblings inside the wizard scope. The Switch / Route / useRouter routing layer is gone. - Wizard.Step is a self-rendering component now: it registers itself with the parent wizard via useLayoutEffect on mount, then renders its children only when its id matches the wizard's currentStep. Inactive steps render null. - Active-step state is internal — first registered step becomes the default; goNext / goPrev / goToStep mutate that state. Nested wizards still bubble to parent.goNext on inner-last-step continue. - Step descriptors no longer carry a path; navigation is purely by id. - Footer now reads the deepest wizard's value from reactive context state (not a ref), so navigation inside a nested wizard re-renders the Footer and isFirstStep / isLastStep stay in sync. Also picks up earlier in-flight work in ConfigureSSO: rename ConfigureSSOLayout to ConfigureSSOCard (the inner Col flex wrapper was dropped — the body Col on the card scrollbox owns sizing now), and Header / Steps / Footer mount as siblings inside the wizard scope so the breadcrumb and footer chrome render alongside the active step body. --- packages/clerk-js/sandbox/template.html | 2 +- .../components/ConfigureSSO/ConfigureSSO.tsx | 98 ++++---- ...gureSSOLayout.tsx => ConfigureSSOCard.tsx} | 4 +- .../ConfigureSSO/ConfigureSSOFooter.tsx | 11 +- .../elements/Wizard/FooterActionsContext.tsx | 130 +++++++--- .../ConfigureSSO/elements/Wizard/Wizard.tsx | 228 ++++++++---------- .../ConfigureSSO/elements/Wizard/types.ts | 42 ++-- 7 files changed, 269 insertions(+), 246 deletions(-) rename packages/ui/src/components/ConfigureSSO/{ConfigureSSOLayout.tsx => ConfigureSSOCard.tsx} (96%) diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html index 9591fe7e852..fd15427212c 100644 --- a/packages/clerk-js/sandbox/template.html +++ b/packages/clerk-js/sandbox/template.html @@ -456,7 +456,7 @@