Skip to content
163 changes: 102 additions & 61 deletions packages/ui/src/components/ConfigureSSO/ChangeProviderDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { useId, useState } from 'react';

import type { LocalizationKey } from '@/customizables';
import { Button, Col, descriptors, Flex, Heading, localizationKeys, Text, useLocalizations } from '@/customizables';
import { Card } from '@/elements/Card';
import { withCardStateProvider } from '@/elements/contexts';
import { useCardState, withCardStateProvider } from '@/elements/contexts';
import { Modal } from '@/elements/Modal';
import { Alert } from '@/ui/elements/Alert';
import { handleError } from '@/utils/errorHandler';

type ChangeProviderDialogProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isSubmitting?: boolean;
/** Performs the provider change. Rejecting keeps the dialog open with the error shown inline. */
onConfirm: () => Promise<unknown>;
nextProviderLabel: LocalizationKey;
currentProviderLabel: LocalizationKey;
contentRef: React.RefObject<HTMLDivElement>;
Expand All @@ -19,11 +23,52 @@ export const ChangeProviderDialog = (props: ChangeProviderDialogProps): JSX.Elem
return null;
}

return <ChangeProviderDialogContent {...props} />;
};

const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProviderDialogProps) => {
const { onClose, onConfirm, nextProviderLabel, currentProviderLabel, contentRef } = props;
const { t } = useLocalizations();
const card = useCardState();

const [isSubmitting, setIsSubmitting] = useState(false);
const titleId = useId();

const nextProvider = t(nextProviderLabel);
const currentProvider = t(currentProviderLabel);

const handleConfirm = async () => {
card.setError(undefined);
setIsSubmitting(true);

try {
// On success the wizard advances and unmounts this dialog; on failure we
// surface the error inline and keep the dialog open so the user can retry.
await onConfirm();
} catch (err) {
handleError(err as Error, [], card.setError);
setIsSubmitting(false);
}
};

const handleClose = () => {
if (isSubmitting) {
return;
}
onClose();
};

return (
<Modal
handleClose={props.onClose}
handleClose={onClose}
canCloseModal={false}
portalRoot={props.contentRef}
portalRoot={contentRef}
aria-labelledby={titleId}
onKeyDown={event => {
if (event.key === 'Escape') {
handleClose();
}
}}
containerSx={t => ({
alignItems: 'center',
position: 'absolute',
Expand All @@ -34,64 +79,60 @@ export const ChangeProviderDialog = (props: ChangeProviderDialogProps): JSX.Elem
backdropFilter: `blur(${t.sizes.$2})`,
})}
>
<ChangeProviderDialogContent {...props} />
</Modal>
);
};

const ChangeProviderDialogContent = withCardStateProvider((props: ChangeProviderDialogProps) => {
const { onClose, onConfirm, isSubmitting, nextProviderLabel, currentProviderLabel } = props;
const { t } = useLocalizations();
<Card.Root
elementDescriptor={descriptors.configureSSOChangeProviderDialog}
sx={t => ({ borderRadius: t.radii.$md })}
>
<Card.Content sx={t => ({ textAlign: 'start', padding: t.sizes.$5 })}>
<Col sx={t => ({ gap: t.space.$4 })}>
<Col sx={t => ({ gap: t.space.$2 })}>
<Heading
id={titleId}
textVariant='h2'
localizationKey={localizationKeys('configureSSO.changeProviderDialog.title', {
provider: nextProvider,
})}
sx={t => ({ fontSize: t.fontSizes.$md })}
/>
<Text
as='p'
colorScheme='secondary'
localizationKey={localizationKeys('configureSSO.changeProviderDialog.subtitle', {
provider: nextProvider,
currentProvider,
})}
/>
</Col>

const nextProvider = t(nextProviderLabel);
const currentProvider = t(currentProviderLabel);
{card.error && (
<Alert
variant='danger'
title={card.error}
/>
)}

return (
<Card.Root
elementDescriptor={descriptors.configureSSOChangeProviderDialog}
sx={t => ({ borderRadius: t.radii.$md })}
>
<Card.Content sx={t => ({ textAlign: 'start', padding: t.sizes.$5 })}>
<Col sx={t => ({ gap: t.space.$4 })}>
<Col sx={t => ({ gap: t.space.$2 })}>
<Heading
textVariant='h2'
localizationKey={localizationKeys('configureSSO.changeProviderDialog.title', {
provider: nextProvider,
})}
sx={t => ({ fontSize: t.fontSizes.$md })}
/>
<Text
as='p'
colorScheme='secondary'
localizationKey={localizationKeys('configureSSO.changeProviderDialog.subtitle', {
provider: nextProvider,
currentProvider,
})}
/>
<Flex
justify='end'
sx={t => ({ gap: t.space.$3 })}
>
<Button
elementDescriptor={descriptors.configureSSOChangeProviderDialogCancelButton}
variant='ghost'
isDisabled={isSubmitting}
onClick={handleClose}
localizationKey={localizationKeys('configureSSO.changeProviderDialog.cancelButton')}
/>
<Button
elementDescriptor={descriptors.configureSSOChangeProviderDialogConfirmButton}
variant='solid'
isLoading={isSubmitting}
onClick={() => void handleConfirm()}
localizationKey={localizationKeys('configureSSO.changeProviderDialog.confirmButton')}
/>
</Flex>
</Col>

<Flex
justify='end'
sx={t => ({ gap: t.space.$3 })}
>
<Button
elementDescriptor={descriptors.configureSSOChangeProviderDialogCancelButton}
variant='ghost'
isDisabled={isSubmitting}
onClick={onClose}
localizationKey={localizationKeys('configureSSO.changeProviderDialog.cancelButton')}
/>
<Button
elementDescriptor={descriptors.configureSSOChangeProviderDialogConfirmButton}
variant='solid'
isLoading={isSubmitting}
onClick={onConfirm}
localizationKey={localizationKeys('configureSSO.changeProviderDialog.confirmButton')}
/>
</Flex>
</Col>
</Card.Content>
</Card.Root>
</Card.Content>
</Card.Root>
</Modal>
);
});
29 changes: 25 additions & 4 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSOWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,34 @@ export const ConfigureSSOWizard = ({ title, forceInitialStep, ...props }: Config

const steps = React.useMemo<WizardStepConfig[]>(
() => [
{ id: 'verify-domain', label: 'Domains' },
// `isComplete` is position-independent (unlike the stepper's positional
// default), so re-entering an active connection ticks every step wherever the
// user stands. Each mirrors its guard except `activate`, whose completion is
// its own terminal `isActive`; `configure` keys off real configuration (or
// active), NOT a bare `hasConnection`, so a just-created-but-unconfigured
// connection does not read as done.
{ id: 'verify-domain', label: 'Domains', isComplete: () => allDomainsVerified },
// `select-provider` now lives inside `configure` as its first sub-step, so
// reaching `configure` only requires verified domains (fresh start) or an
// existing connection (resume / change-provider).
{ id: 'configure', label: 'Connection', guard: () => allDomainsVerified || c.hasConnection },
{ id: 'test', label: 'Test', guard: () => c.hasMinimumConfiguration || c.isActive },
{ id: 'activate', label: 'Activate', guard: () => c.hasSuccessfulTestRun || c.isActive },
{
id: 'configure',
label: 'Connection',
guard: () => allDomainsVerified || c.hasConnection,
isComplete: () => c.hasMinimumConfiguration || c.isActive,
},
{
id: 'test',
label: 'Test',
guard: () => c.hasMinimumConfiguration || c.isActive,
isComplete: () => c.hasSuccessfulTestRun || c.isActive,
},
{
id: 'activate',
label: 'Activate',
guard: () => c.hasSuccessfulTestRun || c.isActive,
isComplete: () => c.isActive,
},
],
[c, allDomainsVerified],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,4 +267,58 @@ describe('ConfigureSSO wizard navigation (integration)', () => {
expect(getByText('Test')).toBeInTheDocument();
expect(getByText('Activate')).toBeInTheDocument();
});

// Completion is guard-driven, not positional: re-entering an ACTIVE connection
// shows every stepper step ticked even after navigating BACK to an earlier step.
// A completed bullet renders a checkmark regardless of whether it is also the
// current step — so for a fully active connection all four bullets show checkmarks
// and zero show a digit. Under the old positional logic the steps AFTER current
// would lose their tick and show their numbers again, so this asserts the fix directly.
it('re-entering an active connection keeps every step completed after navigating back', async () => {
const { wrapper, fixtures } = await createFixtures(withAdminOrgUser);

fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([
{ ...configuredConnection, id: 'ent_active', active: true } as any,
]);
fixtures.clerk.organization?.getEnterpriseConnectionTestRuns.mockResolvedValue({
data: [],
total_count: 0,
} as any);
mockVerifiedDomains(fixtures);

const { container, findByText, userEvent } = render(<ConfigureSSO />, { wrapper });

// Mounts on the activate step (active connection short-circuits to it).
await findByText(/sso connection is active/i);

const stepper = () => container.querySelector('.cl-configureSSOStepper') as HTMLElement;
const bulletDigitCount = () =>
Array.from(stepper().querySelectorAll('.cl-configureSSOStepperItemBullet')).filter(el =>
/\d/.test(el.textContent ?? ''),
).length;
const stepperLabels = () =>
Array.from(stepper().querySelectorAll('.cl-configureSSOStepperItemLabel')).map(el => el.textContent);
const stepperButton = (label: string) =>
Array.from(stepper().querySelectorAll<HTMLButtonElement>('.cl-configureSSOStepperItem')).find(
btn => btn.textContent?.trim() === label,
)!;

// The breadcrumb carries all four labels.
expect(stepperLabels()).toEqual(['Domains', 'Connection', 'Test', 'Activate']);

// On the terminal step all four steps are complete, so all four bullets show
// checkmarks — including the current 'Activate' step — and zero show a digit.
expect(bulletDigitCount()).toBe(0);

// Navigate BACK to the first step via the breadcrumb. Every step's guard still
// holds for an active connection, so 'Domains' is reachable.
await userEvent.click(stepperButton('Domains'));

// Positional completion would now un-tick Connection/Test/Activate (they sit
// AFTER current) and show their numbers. Guard-driven completion keeps them
// ticked. 'Domains' is also completed, so its bullet shows a checkmark too —
// zero bullets show a digit.
expect(bulletDigitCount()).toBe(0);
expect(stepperLabels()).toEqual(['Domains', 'Connection', 'Test', 'Activate']);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';

import { Box, descriptors, Flex, Icon, SimpleButton, Text, Span } from '@/customizables';
import { ChevronRight, Checkmark } from '@/icons';
import { Box, descriptors, Flex, Icon, SimpleButton, Span,Text } from '@/customizables';
import { Checkmark, ChevronRight } from '@/icons';

import type { StepperItemProps, StepperProps } from './types';

Expand Down Expand Up @@ -82,11 +82,11 @@ const Item = ({
: theme.colors.$colorMutedForeground,
})}
>
{isCompleted && !isCurrent ? (
{isCompleted ? (
<Icon
icon={Checkmark}
size='sm'
sx={theme => ({ color: theme.colors.$white })}
sx={theme => ({ color: isCurrent ? theme.colors.$colorBackground : theme.colors.$white })}
/>
) : (
<Text
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,25 @@ describe('useWizardMachine — first/last and reachability derivations', () => {
expect(byId.a).toBe(true);
expect(byId.b).toBe(false);
});

it('isComplete predicate overrides the positional default (position-independent completion)', () => {
// 'a' declares its work UNdone and 'c' declares it DONE — independent of where
// current sits. Seed on 'b' (the middle): positionally 'a' would read complete
// and 'c' incomplete, but the predicates flip both, proving `isComplete` wins.
const { result } = renderMachine({
config: cfg([
{ id: 'a', isComplete: () => false },
{ id: 'b', guard: () => true },
{ id: 'c', guard: () => true, isComplete: () => true },
]),
initialStepId: 'b',
});
expect(result.current.current).toBe('b');
const byId = Object.fromEntries(result.current.activeSteps.map(s => [s.id, s.isCompleted]));
expect(byId.a).toBe(false); // predicate overrides "before current" → not complete
expect(byId.b).toBe(false); // no predicate, positional: not before itself
expect(byId.c).toBe(true); // predicate overrides "after current" → complete
});
});

describe('useWizardMachine — deferred goNext (submit-then-advance race)', () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ export interface WizardStepConfig {
* always-reachable predecessor both rely on that.
*/
guard?: () => boolean;
/**
* Inline completion predicate: "is THIS step's work done right now?", decoupled
* from the current position. Drives the stepper's completed tick so a step reads
* as done whenever its own work is, regardless of where the user currently
* stands (re-entering an already-finished flow shows every step ticked).
* OMITTED ⇒ fall back to the POSITIONAL default (sits before current) — nested /
* per-provider wizards that declare no `isComplete` are unchanged.
*/
isComplete?: () => boolean;
}

/**
Expand All @@ -44,8 +53,10 @@ export interface WizardActiveStep {
id: string;
label?: LocalizationKey | string;
/**
* POSITIONAL: the step sits before the current step in declaration order.
* Drives the visual "completed" tick only — it is not guard-derived.
* Whether this step's work is done — drives the visual "completed" tick. Resolved
* from the step's own `isComplete` predicate when it declares one (position-
* independent: a finished step stays ticked even when the user navigates back),
* otherwise the POSITIONAL default (sits before current in declaration order).
*/
isCompleted: boolean;
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,10 @@ export const useWizardMachine = ({ config, parentWizard, initialStepId }: UseWiz
// Breadcrumb-facing active steps: every descriptor, in declaration order.
// Derived synchronously from the live descriptors — known before `current` is
// resolved, so there is no inconsistency window. Each item carries
// `isCompleted` (POSITIONAL: sits before current in declaration order) and
// `isReachable` (GUARD-DRIVEN: its entry guard holds now — the single source
// `isCompleted` (the step's own `isComplete` predicate when it declares one —
// position-INDEPENDENT, so a finished step stays ticked after a back-nav —
// otherwise the POSITIONAL fallback: sits before current in declaration order)
// and `isReachable` (GUARD-DRIVEN: its entry guard holds now — the single source
// the stepper binds `isDisabled = !isReachable` to and the same predicate
// `goToStep` checks). Whether an item appears in the breadcrumb is the header's
// call (it filters on `label`), not the machine's.
Expand All @@ -224,7 +226,9 @@ export const useWizardMachine = ({ config, parentWizard, initialStepId }: UseWiz
return descriptors.map((s, descriptorIndex) => ({
id: s.id,
label: s.label,
isCompleted: currentDescriptorIndex >= 0 && descriptorIndex < currentDescriptorIndex,
isCompleted: s.isComplete
? s.isComplete()
: currentDescriptorIndex >= 0 && descriptorIndex < currentDescriptorIndex,
isReachable: guardHolds(s),
}));
}, [config, current]);
Expand Down
Loading
Loading