Skip to content

Commit ebcac3f

Browse files
committed
Add boilerplate for wizard steps
1 parent 49e435e commit ebcac3f

13 files changed

Lines changed: 919 additions & 2 deletions

File tree

packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useOrganization } from '@clerk/shared/react/index';
12
import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types';
23
import React from 'react';
34

@@ -7,8 +8,11 @@ import { ApplicationLogo } from '@/elements/ApplicationLogo';
78
import { withCardStateProvider } from '@/elements/contexts';
89
import { NavBar, NavbarContextProvider } from '@/elements/Navbar';
910
import { ProfileCard } from '@/elements/ProfileCard';
11+
import { Wizard } from '@/elements/Wizard';
1012
import { Route, Switch } from '@/router';
11-
import { useOrganization } from '@clerk/shared/react/index';
13+
14+
import { ConfigureSSOFlowProvider, useConfigureSSOFlow } from './ConfigureSSOContext';
15+
import { CONFIGURE_SSO_STEPS } from './constants';
1216

1317
const ConfigureSSOInternal = () => {
1418
return (
@@ -63,12 +67,46 @@ const AuthenticatedContent = withCoreUserGuard(() => {
6367
routes={[]}
6468
contentRef={contentRef}
6569
/>
66-
<ProfileCard.Content contentRef={contentRef} />
70+
<ConfigureSSOFlowProvider>
71+
<ConfigureSSOWizardPanel contentRef={contentRef} />
72+
</ConfigureSSOFlowProvider>
6773
</NavbarContextProvider>
6874
</ProfileCard.Root>
6975
);
7076
});
7177

78+
const ConfigureSSOWizardPanel = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
79+
const flowData = useConfigureSSOFlow();
80+
81+
return (
82+
<Col
83+
ref={contentRef}
84+
sx={t => ({
85+
backgroundColor: t.colors.$colorBackground,
86+
borderRadius: t.radii.$lg,
87+
width: '100%',
88+
flex: 1,
89+
minHeight: 0,
90+
borderWidth: t.borderWidths.$normal,
91+
borderStyle: t.borderStyles.$solid,
92+
borderColor: t.colors.$borderAlpha150,
93+
marginBlock: '-1px',
94+
marginInlineEnd: '-1px',
95+
overflow: 'hidden',
96+
})}
97+
>
98+
<Wizard.Root
99+
steps={CONFIGURE_SSO_STEPS}
100+
data={flowData}
101+
>
102+
<Wizard.Header />
103+
<Wizard.Content />
104+
<Wizard.Footer />
105+
</Wizard.Root>
106+
</Col>
107+
);
108+
};
109+
72110
const OrganizationSubtitle = () => {
73111
const { organization } = useOrganization();
74112
return (
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useUser } from '@clerk/shared/react/index';
2+
import React from 'react';
3+
4+
/**
5+
* Shared form state for the ConfigureSSO wizard. Lives outside the
6+
* Wizard's own context so that:
7+
* - it persists across step navigations (each step is its own
8+
* `<Route>`, mounted/unmounted on navigation)
9+
* - `shouldSkip` predicates on `WizardStep` can read it as plain data
10+
* via `<Wizard.Root data={ssoCtx} />`.
11+
*/
12+
export interface ConfigureSSOData {
13+
email: string;
14+
/**
15+
* Domain id returned by the API after the email is submitted.
16+
* Empty until the first step succeeds.
17+
*/
18+
domainId: string;
19+
/**
20+
* `true` if the domain returned by the API is already verified at the
21+
* time the user submits their email — the "Verify domain" step is
22+
* skipped in that case.
23+
*/
24+
domainAlreadyVerified: boolean;
25+
}
26+
27+
export interface ConfigureSSOContextValue extends ConfigureSSOData {
28+
setEmail: (email: string) => void;
29+
setDomainId: (id: string) => void;
30+
}
31+
32+
const ConfigureSSOFlowContext = React.createContext<ConfigureSSOContextValue | null>(null);
33+
ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext';
34+
35+
export const ConfigureSSOFlowProvider = ({ children }: { children: React.ReactNode }): JSX.Element => {
36+
const [domainId, setDomainId] = React.useState('');
37+
38+
const { user } = useUser();
39+
40+
// user is guaranteed to be defined because we're using the withCoreUserGuard HOC
41+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
42+
const [email, setEmail] = React.useState(user!.primaryEmailAddress?.emailAddress ?? '');
43+
const domainAlreadyVerified = user?.primaryEmailAddress?.verification.status === 'verified';
44+
45+
const value = React.useMemo<ConfigureSSOContextValue>(
46+
() => ({
47+
email,
48+
domainId,
49+
domainAlreadyVerified,
50+
setEmail,
51+
setDomainId,
52+
}),
53+
[email, domainId, domainAlreadyVerified],
54+
);
55+
56+
return <ConfigureSSOFlowContext.Provider value={value}>{children}</ConfigureSSOFlowContext.Provider>;
57+
};
58+
59+
export const useConfigureSSOFlow = (): ConfigureSSOContextValue => {
60+
const ctx = React.useContext(ConfigureSSOFlowContext);
61+
if (!ctx) {
62+
throw new Error('useConfigureSSOFlow called outside <ConfigureSSOFlowProvider>.');
63+
}
64+
return ctx;
65+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { WizardStep } from '@/elements/Wizard';
2+
3+
import type { ConfigureSSOData } from './ConfigureSSOContext';
4+
import { Configure, ProvideEmail, TestStep, VerifyDomain } from './steps';
5+
6+
export const CONFIGURE_SSO_STEPS: ReadonlyArray<WizardStep<ConfigureSSOData>> = [
7+
{
8+
id: 'provide-email',
9+
path: 'provide-email',
10+
label: 'Provide email',
11+
Component: ProvideEmail,
12+
// Skip this step when the user already has an email address
13+
shouldSkip: data => data.email !== '',
14+
},
15+
{
16+
id: 'verify-domain',
17+
path: 'verify-domain',
18+
label: 'Verify domain',
19+
Component: VerifyDomain,
20+
isOptional: true,
21+
// Skip this step when the primary email address domain is already verified
22+
shouldSkip: data => data.domainAlreadyVerified,
23+
},
24+
{
25+
id: 'configure',
26+
path: 'configure',
27+
label: 'Configure',
28+
Component: Configure,
29+
},
30+
{
31+
id: 'test',
32+
path: 'test',
33+
label: 'Test',
34+
Component: TestStep,
35+
},
36+
];
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Text } from '@/customizables';
2+
import { useRegisterContinueAction, useWizard } from '@/elements/Wizard';
3+
4+
import { StepLayout } from './StepLayout';
5+
6+
export const Configure = (): JSX.Element => {
7+
const { goNext } = useWizard();
8+
9+
useRegisterContinueAction({
10+
handler: () => goNext(),
11+
});
12+
13+
return (
14+
<StepLayout
15+
title='Configure your IdP'
16+
subtitle='Paste the metadata from your identity provider into the fields below.'
17+
>
18+
<Text as='p'>Configuration form goes here.</Text>
19+
</StepLayout>
20+
);
21+
};
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { Col, Flex, Heading, Icon, Input, Text } from '@/customizables';
2+
import { useRegisterContinueAction, useWizard } from '@/elements/Wizard';
3+
import { Email } from '@/icons';
4+
5+
import { useConfigureSSOFlow } from '../ConfigureSSOContext';
6+
import { StepLayout } from './StepLayout';
7+
8+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
9+
10+
// TODO -> Conditionally render this step based on the user's email address
11+
// If the user already has an email address, skip this step
12+
// If the instance doesn't support email addresses, skip this step
13+
// If the user doesn't have an email address, render this step
14+
export const ProvideEmail = (): JSX.Element => {
15+
const { email, setEmail } = useConfigureSSOFlow();
16+
const { goNext } = useWizard();
17+
18+
const isValid = EMAIL_RE.test(email.trim());
19+
20+
useRegisterContinueAction({
21+
handler: () => {
22+
if (!isValid) {
23+
return;
24+
}
25+
26+
// TODO -> Call API to add email address to user
27+
28+
return goNext();
29+
},
30+
isDisabled: !isValid,
31+
});
32+
33+
return (
34+
<StepLayout
35+
title='Configure SSO'
36+
subtitle='Create a new enterprise application in your Okta Dashboard'
37+
>
38+
<Flex
39+
direction='col'
40+
align='center'
41+
justify='center'
42+
sx={theme => ({
43+
flex: 1,
44+
gap: theme.space.$4,
45+
paddingBlock: theme.space.$8,
46+
})}
47+
>
48+
<Icon
49+
icon={Email}
50+
size='lg'
51+
sx={theme => ({ color: theme.colors.$colorMutedForeground })}
52+
/>
53+
<Col sx={theme => ({ gap: theme.space.$1, alignItems: 'center', textAlign: 'center' })}>
54+
<Heading textVariant='h3'>We need your email</Heading>
55+
<Text
56+
as='p'
57+
variant='body'
58+
sx={theme => ({ color: theme.colors.$colorMutedForeground })}
59+
>
60+
In order to start we will need your email address
61+
</Text>
62+
</Col>
63+
<Input
64+
type='email'
65+
placeholder='Paste URL here…'
66+
value={email}
67+
onChange={e => setEmail(e.currentTarget.value)}
68+
sx={theme => ({ maxWidth: theme.sizes.$60, width: '100%' })}
69+
/>
70+
</Flex>
71+
</StepLayout>
72+
);
73+
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react';
2+
3+
import { Col, Flex, Heading, Text } from '@/customizables';
4+
import { Wizard } from '@/elements/Wizard';
5+
6+
interface StepLayoutProps {
7+
title: React.ReactNode;
8+
subtitle?: React.ReactNode;
9+
/**
10+
* If true, renders the "Step X / Y" badge on the title row.
11+
* Defaults to true.
12+
*/
13+
showStepIndicator?: boolean;
14+
children: React.ReactNode;
15+
}
16+
17+
/**
18+
* Renders the title row (with the Wizard's Step X/Y badge) on top, a divider, and the step body
19+
* underneath. Each individual step file owns the body content.
20+
*/
21+
export const StepLayout = ({ title, subtitle, showStepIndicator = true, children }: StepLayoutProps): JSX.Element => {
22+
return (
23+
<Col
24+
sx={{
25+
flex: 1,
26+
minHeight: 0,
27+
}}
28+
>
29+
<Flex
30+
align='center'
31+
justify='between'
32+
sx={theme => ({
33+
gap: theme.space.$4,
34+
padding: `${theme.space.$5} ${theme.space.$6}`,
35+
borderBottomWidth: theme.borderWidths.$normal,
36+
borderBottomStyle: theme.borderStyles.$solid,
37+
borderBottomColor: theme.colors.$borderAlpha100,
38+
})}
39+
>
40+
<Col sx={theme => ({ gap: theme.space.$1, minWidth: 0 })}>
41+
<Heading
42+
textVariant='h3'
43+
sx={theme => ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })}
44+
>
45+
{title}
46+
</Heading>
47+
{subtitle ? (
48+
<Text
49+
as='p'
50+
variant='body'
51+
sx={theme => ({ color: theme.colors.$colorMutedForeground })}
52+
>
53+
{subtitle}
54+
</Text>
55+
) : null}
56+
</Col>
57+
{showStepIndicator ? <Wizard.StepIndicator /> : null}
58+
</Flex>
59+
<Col
60+
sx={theme => ({
61+
flex: 1,
62+
padding: theme.space.$6,
63+
overflowY: 'auto',
64+
})}
65+
>
66+
{children}
67+
</Col>
68+
</Col>
69+
);
70+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Col, Text } from '@/customizables';
2+
3+
import { StepLayout } from './StepLayout';
4+
5+
export const TestConfigurationStep = (): JSX.Element => {
6+
return (
7+
<StepLayout
8+
title='Test your connection'
9+
subtitle='Make sure everything is wired up before you finish.'
10+
>
11+
<Col
12+
sx={theme => ({
13+
gap: theme.space.$4,
14+
maxWidth: theme.sizes.$160,
15+
marginInline: 'auto',
16+
paddingBlock: theme.space.$8,
17+
})}
18+
>
19+
<Text
20+
as='p'
21+
variant='body'
22+
sx={theme => ({ color: theme.colors.$colorMutedForeground })}
23+
>
24+
Test step UI goes here. The shared &ldquo;Continue&rdquo; button is hidden on the last step; use a step-local
25+
primary action to finish.
26+
</Text>
27+
</Col>
28+
</StepLayout>
29+
);
30+
};

0 commit comments

Comments
 (0)