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/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 090841b697f..6270f7494c6 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -204,6 +204,26 @@ export const enUS: LocalizationResource = { navbar: { title: 'Configure Single Sign-On (SSO)', }, + verifyEmailDomainStep: { + title: 'Verify email address', + subtitle: 'Verify the email address you want to enable the enterprise connection on.', + addEmailAddress: { + formTitle: 'We need your email', + formSubtitle: 'In order to start we will need your email address', + inputPlaceholder: 'name@company.com', + inputLabel: 'Email address', + }, + emailCode: { + formTitle: 'Verify your email address', + formSubtitle: 'Enter the verification code sent to {{identifier}}', + resendButton: "Didn't receive a code? Resend", + verified: { + title: 'We got your email', + subtitle: "You've verified your email address with the following email", + inputLabel: 'Verified email address', + }, + }, + }, }, createOrganization: { formButtonSubmit: 'Create organization', 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/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 49ce96a0616..0bae1eca0da 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1296,6 +1296,26 @@ export type __internal_LocalizationResource = { navbar: { title: LocalizationValue; }; + verifyEmailDomainStep: { + title: LocalizationValue; + subtitle: LocalizationValue; + addEmailAddress: { + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + inputPlaceholder: LocalizationValue; + inputLabel: LocalizationValue; + }; + emailCode: { + formTitle: LocalizationValue; + formSubtitle: LocalizationValue<'identifier'>; + resendButton: LocalizationValue; + verified: { + title: LocalizationValue; + subtitle: LocalizationValue; + inputLabel: LocalizationValue; + }; + }; + }; }; apiKeys: { formTitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 4b744a8a8d0..413764823df 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 hasEmailAddress = Boolean(user?.emailAddresses?.length); + + return ( + + + + {!hasEmailAddress && ( + + + + )} + + + + + + + + {/* 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..95dcce1cdaa --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx @@ -0,0 +1,133 @@ +import { useReverification, useUser } from '@clerk/shared/react/index'; +import React from 'react'; + +import { Col, Flow, Form, Heading, Icon, Input, localizationKeys, Text, useLocalizations } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { DuotoneAtSymbol } from '@/icons'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; +import { StepLayout } from './StepLayout'; + +const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str); + +export const ProvideEmail = (): JSX.Element => { + const { goNext } = useConfigureSSOWizard(); + const { user } = useUser(); + const card = useCardState(); + const { t } = useLocalizations(); + const [email, setEmail] = React.useState(''); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const createEmailAddress = useReverification((value: string) => user?.createEmailAddress({ email: value })); + + const canSubmit = isEmail(email) && !isSubmitting; + + const submit = React.useCallback(async () => { + if (!canSubmit) { + return; + } + + setIsSubmitting(true); + card.setError(undefined); + + try { + await createEmailAddress(email); + await goNext(); + } catch (err) { + handleError(err as Error, [], card.setError); + } finally { + setIsSubmitting(false); + } + }, [canSubmit, email, createEmailAddress, card, goNext]); + + useRegisterContinueAction({ + handler: submit, + isDisabled: !canSubmit, + isLoading: isSubmitting, + }); + + // Clear any stale card error when this step mounts so it doesn't leak in + // from a previous flow / step + React.useEffect(() => { + card.setError(undefined); + return () => card.setError(undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + +
{ + e.preventDefault(); + void submit(); + }} + sx={{ flex: 1, display: 'flex' }} + > + ({ + flex: 1, + justifyContent: 'center', + gap: t.space.$5, + maxWidth: t.sizes.$66, + marginInline: 'auto', + textAlign: 'center', + width: '100%', + paddingBlock: t.space.$8, + })} + > + ({ + width: t.sizes.$8, + height: t.sizes.$8, + color: t.colors.$neutralAlpha600, + })} + /> + + ({ gap: t.space.$1 })}> + ({ color: t.colors.$colorForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.addEmailAddress.formTitle')} + /> + ({ color: t.colors.$colorMutedForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.addEmailAddress.formSubtitle')} + /> + + + ({ gap: t.space.$1x5, width: '100%' })}> + setEmail(e.currentTarget.value)} + hasError={Boolean(card.error)} + isDisabled={isSubmitting} + aria-label={t(localizationKeys('configureSSO.verifyEmailDomainStep.addEmailAddress.inputLabel'))} + /> + {card.error ? ( + ({ color: t.colors.$danger500, fontSize: t.fontSizes.$sm, textAlign: 'start' })} + > + {card.error} + + ) : null} + + + +
+
+ ); +}; 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..901718b0d3a --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx @@ -0,0 +1,67 @@ +import React from 'react'; + +import { Col, Flex, Heading, type LocalizationKey, Text } from '@/customizables'; + +import { ConfigureSSOWizard } from '../wizard'; + +interface StepLayoutProps { + title?: LocalizationKey | string; + subtitle?: LocalizationKey | string; + 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 })} + localizationKey={title} + /> + + {subtitle ? ( + ({ color: theme.colors.$colorMutedForeground })} + localizationKey={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..42a1e50648d --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -0,0 +1,175 @@ +import { useReverification, useUser } from '@clerk/shared/react'; +import React from 'react'; + +import { Col, Flow, Heading, Icon, Input, localizationKeys, Text, useLocalizations } from '@/customizables'; +import { useFieldOTP } from '@/elements/CodeControl'; +import { useCardState } from '@/elements/contexts'; +import { Form } from '@/elements/Form'; +import { DuotoneAtSymbol } from '@/icons'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; +import { StepLayout } from './StepLayout'; + +export const VerifyDomainStep = (): JSX.Element | null => { + const { goNext, goToStep } = useConfigureSSOWizard(); + const card = useCardState(); + const { t } = useLocalizations(); + const { user } = useUser(); + + const emailToVerify = + user?.primaryEmailAddress ?? user?.emailAddresses?.find(e => e.verification.status !== 'verified'); + const isVerified = emailToVerify?.verification.status === 'verified'; + const isAlreadyPrimary = Boolean(emailToVerify && emailToVerify.id === user?.primaryEmailAddressId); + + const prepareEmailVerification = useReverification(() => + emailToVerify?.prepareVerification({ strategy: 'email_code' }), + ); + const attemptEmailVerification = useReverification((code: string) => emailToVerify?.attemptVerification({ code })); + const setPrimaryEmailAddress = useReverification((emailAddressId: string) => + user?.update({ primaryEmailAddressId: emailAddressId }), + ); + + const prepare = React.useCallback( + () => prepareEmailVerification()?.catch(err => handleError(err, [], card.setError)), + [prepareEmailVerification, card], + ); + + const codeSubmittedRef = React.useRef(false); + + const otp = useFieldOTP({ + onCodeEntryFinished: (code, resolve, reject) => { + codeSubmittedRef.current = true; + attemptEmailVerification(code) + .then(() => resolve()) + .catch(reject); + }, + onResendCodeClicked: () => { + void prepare(); + }, + onResolve: async () => { + if (emailToVerify && !isAlreadyPrimary) { + try { + await setPrimaryEmailAddress(emailToVerify.id); + } catch (err) { + handleError(err as Error, [], card.setError); + return; + } + } + void goNext(); + }, + }); + + const { values, length } = otp.otpControl.otpInputProps; + const isCodeComplete = values.filter(Boolean).length === length; + const showVerifiedView = isVerified && !codeSubmittedRef.current; + + useRegisterContinueAction( + showVerifiedView + ? { + handler: () => { + void goNext(); + }, + } + : { + handler: otp.onFakeContinue, + isDisabled: !isCodeComplete, + isLoading: otp.isLoading, + }, + ); + + React.useEffect(() => { + if (!emailToVerify) { + void goToStep('provide-email'); + } + }, [emailToVerify, goToStep]); + + // Send the first code on mount (only when there's something to verify), + // and clear any stale card error that could be lingering from a previous step + React.useEffect(() => { + if (emailToVerify && !isVerified) { + void prepare(); + } + card.setError(undefined); + return () => card.setError(undefined); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (!emailToVerify) { + return null; + } + + return ( + + + ({ + flex: 1, + justifyContent: 'center', + gap: t.space.$2, + paddingBlock: t.space.$8, + })} + > + {showVerifiedView ? ( + <> + ({ + width: t.sizes.$8, + height: t.sizes.$8, + color: t.colors.$neutralAlpha600, + })} + /> + ({ gap: t.space.$1, textAlign: 'center', maxWidth: t.sizes.$66 })}> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.verified.title')} + /> + ({ color: t.colors.$colorMutedForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.verified.subtitle')} + /> + + ({ width: '100%', maxWidth: t.sizes.$66, backgroundColor: t.colors.$neutralAlpha50 })} + /> + + ) : ( + <> + ({ gap: t.space.$1, textAlign: 'center' })}> + ({ color: t.colors.$colorForeground, fontSize: t.fontSizes.$sm })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.formTitle')} + /> + ({ color: t.colors.$colorMutedForeground })} + localizationKey={localizationKeys('configureSSO.verifyEmailDomainStep.emailCode.formSubtitle', { + identifier: emailToVerify.emailAddress, + })} + /> + + + + )} + + + + ); +}; 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} +