Skip to content

Commit fcd0ac8

Browse files
committed
Fix verify domain initial step
1 parent 4c0b4b0 commit fcd0ac8

4 files changed

Lines changed: 142 additions & 41 deletions

File tree

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { UseUserEnterpriseConnectionsReturn } from '@clerk/shared/react/index';
22
import { useSession, useUser } from '@clerk/shared/react/index';
3-
import type { EnterpriseConnectionResource, SignedInSessionResource, UserResource } from '@clerk/shared/types';
3+
import type {
4+
EmailAddressResource,
5+
EnterpriseConnectionResource,
6+
SignedInSessionResource,
7+
UserResource,
8+
} from '@clerk/shared/types';
49
import React, { type PropsWithChildren, useCallback } from 'react';
510

611
import { useCardState } from '@/elements/contexts';
@@ -35,7 +40,7 @@ export interface ConfigureSSOData {
3540
/**
3641
* Creates a new enterprise connection
3742
*/
38-
createEnterpriseConnection: (provider: ProviderType) => Promise<void>;
43+
createEnterpriseConnection: (provider: ProviderType, primaryEmailAddress?: EmailAddressResource) => Promise<void>;
3944
/**
4045
* Updates an existing enterprise connection
4146
*/
@@ -75,16 +80,16 @@ export const ConfigureSSOProvider = ({
7580
const [provider, setProvider] = React.useState<ProviderType | undefined>(
7681
enterpriseConnection?.provider as ProviderType,
7782
);
78-
const { user } = useUser();
7983
const { session } = useSession();
84+
const { user } = useUser();
8085
const card = useCardState();
8186

8287
const isDomainTakenByOtherOrg = checkDomainTakenByOtherOrg(user, session, enterpriseConnection);
8388
const initialStepId = deriveInitialStep(enterpriseConnection, { isDomainTakenByOtherOrg, hasSuccessfulTestRun });
8489

8590
const createEnterpriseConnection = useCallback(
86-
async (provider: ProviderType): Promise<void> => {
87-
const emailDomain = user?.primaryEmailAddress?.emailAddress.split('@')[1];
91+
async (provider: ProviderType, primaryEmailAddress?: EmailAddressResource): Promise<void> => {
92+
const emailDomain = primaryEmailAddress?.emailAddress.split('@')[1];
8893
const organizationId = session?.lastActiveOrganizationId ?? null;
8994

9095
if (!emailDomain) {
@@ -103,7 +108,7 @@ export const ConfigureSSOProvider = ({
103108
card.setIdle();
104109
}
105110
},
106-
[user, card, session, createEnterpriseConnectionApi],
111+
[card, session, createEnterpriseConnectionApi],
107112
);
108113

109114
const value = React.useMemo<ConfigureSSOData>(

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

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,14 @@ export const SelectProviderStep = (): JSX.Element => {
5757
const primaryEmailAddress = user?.primaryEmailAddress;
5858
const hasVerifiedPrimaryEmailAddress = primaryEmailAddress?.verification.status === 'verified';
5959

60-
// If the user doesn't have a primary email address, go direct to the provide email step
61-
if (!primaryEmailAddress) {
62-
void goToStep('provide-email');
63-
return;
64-
}
65-
66-
// If the user's primary email address is not verified, go to the verify email address step
67-
if (!hasVerifiedPrimaryEmailAddress) {
68-
void goToStep('verify-email-address');
60+
if (!primaryEmailAddress || !hasVerifiedPrimaryEmailAddress) {
61+
void goToStep('verify-domain');
6962
return;
7063
}
7164

7265
// Otherwise, set the provider and create the enterprise connection
7366
try {
74-
await createEnterpriseConnection(selected);
67+
await createEnterpriseConnection(selected, primaryEmailAddress);
7568
} catch (err) {
7669
handleError(err as Error, [], card.setError);
7770
return;

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

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useReverification, useSession, useUser } from '@clerk/shared/react';
22
import type { EmailAddressResource } from '@clerk/shared/types';
3-
import { useCallback, useEffect, useRef, useState } from 'react';
3+
import React, { useCallback, useEffect, useRef, useState } from 'react';
44

55
import {
66
Col,
@@ -42,6 +42,11 @@ export const VerifyDomainStep = (): JSX.Element => {
4242
);
4343

4444
const wasVerifiedOnMountRef = useRef(isVerified);
45+
const emailAddressRef = useRef<EmailAddressResource | undefined>(emailToVerify);
46+
const preExistingEmailIdRef = useRef<string | undefined>(emailToVerify?.id);
47+
const initialInnerStepIdRef = useRef<'provide-email' | 'verify-email-address'>(
48+
emailToVerify ? 'verify-email-address' : 'provide-email',
49+
);
4550

4651
if (isDomainTakenByOtherOrg) {
4752
const conflictingDomain = enterpriseConnection?.domains[0] as string;
@@ -122,7 +127,7 @@ export const VerifyDomainStep = (): JSX.Element => {
122127
elementDescriptor={descriptors.configureSSOStep}
123128
elementId={descriptors.configureSSOStep.setId('verify-domain')}
124129
>
125-
<Wizard>
130+
<Wizard initialStepId={initialInnerStepIdRef.current}>
126131
<Step.Header
127132
title={t(localizationKeys('configureSSO.verifyEmailDomainStep.title'))}
128133
description={t(localizationKeys('configureSSO.verifyEmailDomainStep.subtitle'))}
@@ -132,11 +137,14 @@ export const VerifyDomainStep = (): JSX.Element => {
132137

133138
<Step.Body>
134139
<Wizard.Step id='provide-email'>
135-
<ProvideEmailStep />
140+
<ProvideEmailStep
141+
emailAddressRef={emailAddressRef}
142+
preExistingEmailIdRef={preExistingEmailIdRef}
143+
/>
136144
</Wizard.Step>
137145

138146
<Wizard.Step id='verify-email-address'>
139-
<EnterVerificationCodeStep emailToVerify={emailToVerify} />
147+
<EnterVerificationCodeStep emailAddressRef={emailAddressRef} />
140148
</Wizard.Step>
141149
</Step.Body>
142150
</Wizard>
@@ -157,12 +165,21 @@ const InnerStepCounter = (): JSX.Element => {
157165

158166
const isEmail = (str: string) => /^\S+@\S+\.\S+$/.test(str);
159167

160-
export const ProvideEmailStep = (): JSX.Element => {
161-
const { goNext, goPrev, isFirstStep } = useWizard();
168+
type ProvideEmailStepProps = {
169+
emailAddressRef: React.MutableRefObject<EmailAddressResource | undefined>;
170+
preExistingEmailIdRef: React.MutableRefObject<string | undefined>;
171+
};
172+
173+
const normalizeEmail = (value: string): string => value.trim().toLowerCase();
174+
175+
export const ProvideEmailStep = ({ emailAddressRef, preExistingEmailIdRef }: ProvideEmailStepProps): JSX.Element => {
176+
const { goNext, goPrev } = useWizard();
162177
const { user } = useUser();
163178
const card = useCardState();
164179
const { t } = useLocalizations();
165-
const [email, setEmail] = useState('');
180+
// Pre-fill with whatever email the parent is currently tracking so navigating back from the
181+
// verify step shows the user what they previously submitted instead of an empty field.
182+
const [email, setEmail] = useState(() => emailAddressRef.current?.emailAddress ?? '');
166183
const createEmailAddress = useReverification((value: string) => user?.createEmailAddress({ email: value }));
167184

168185
const canSubmit = isEmail(email) && !card.isLoading;
@@ -171,18 +188,41 @@ export const ProvideEmailStep = (): JSX.Element => {
171188
return;
172189
}
173190

191+
const current = emailAddressRef.current;
192+
const submittedEmail = email.trim();
193+
194+
// Same email address as previously submitted, skip the flow
195+
if (current && normalizeEmail(current.emailAddress) === normalizeEmail(submittedEmail)) {
196+
await goNext();
197+
return;
198+
}
199+
174200
card.setError(undefined);
175201
card.setLoading();
176202

177203
try {
178-
await createEmailAddress(email);
204+
const created = await createEmailAddress(submittedEmail);
205+
const previous = current;
206+
emailAddressRef.current = created ?? undefined;
207+
208+
// Clean up the previous in-flight address so the user doesn't accumulate orphans on
209+
// their account
210+
if (previous && previous.id !== preExistingEmailIdRef.current && previous.id !== created?.id) {
211+
try {
212+
await previous.destroy();
213+
} catch {
214+
// A leftover unverified address is preferable to surfacing a cleanup
215+
// error after a successful create.
216+
}
217+
}
218+
179219
await goNext();
180220
} catch (err) {
181221
handleError(err as Error, [], card.setError);
182222
} finally {
183223
card.setIdle();
184224
}
185-
}, [canSubmit, email, createEmailAddress, card, goNext]);
225+
}, [canSubmit, email, createEmailAddress, card, goNext, emailAddressRef, preExistingEmailIdRef]);
186226

187227
return (
188228
<>
@@ -255,7 +295,7 @@ export const ProvideEmailStep = (): JSX.Element => {
255295
<Step.Footer>
256296
<Step.Footer.Previous
257297
onClick={() => goPrev()}
258-
isDisabled={isFirstStep}
298+
isDisabled
259299
/>
260300
<Step.Footer.Continue
261301
onClick={handleSubmit}
@@ -268,22 +308,26 @@ export const ProvideEmailStep = (): JSX.Element => {
268308
};
269309

270310
export const EnterVerificationCodeStep = ({
271-
emailToVerify,
311+
emailAddressRef,
272312
}: {
273-
emailToVerify?: EmailAddressResource;
313+
emailAddressRef: React.MutableRefObject<EmailAddressResource | undefined>;
274314
}): JSX.Element | null => {
275315
const { user } = useUser();
276316
const { provider, createEnterpriseConnection } = useConfigureSSO();
277317
const card = useCardState();
278-
const { goNext, goPrev, isFirstStep } = useWizard();
318+
const { goNext, goPrev } = useWizard();
319+
const primaryEmailAddress = user?.primaryEmailAddress;
279320

321+
const emailToVerify = emailAddressRef.current;
280322
const isVerified = emailToVerify?.verification.status === 'verified';
281323
const isPrimary = emailToVerify?.id === user?.primaryEmailAddressId;
282324

283325
const prepareEmailVerification = useReverification(() =>
284-
emailToVerify?.prepareVerification({ strategy: 'email_code' }),
326+
emailAddressRef.current?.prepareVerification({ strategy: 'email_code' }),
327+
);
328+
const attemptEmailVerification = useReverification((code: string) =>
329+
emailAddressRef.current?.attemptVerification({ code }),
285330
);
286-
const attemptEmailVerification = useReverification((code: string) => emailToVerify?.attemptVerification({ code }));
287331
const setPrimaryEmailAddress = useReverification((emailAddressId: string) =>
288332
user?.update({ primaryEmailAddressId: emailAddressId }),
289333
);
@@ -303,9 +347,10 @@ export const EnterVerificationCodeStep = ({
303347
void prepare();
304348
},
305349
onResolve: async () => {
306-
if (emailToVerify && !isPrimary) {
350+
const target = emailAddressRef.current;
351+
if (target && !isPrimary) {
307352
try {
308-
await setPrimaryEmailAddress(emailToVerify.id);
353+
await setPrimaryEmailAddress(target.id);
309354
} catch (err) {
310355
handleError(err as Error, [], card.setError);
311356
return;
@@ -318,7 +363,7 @@ export const EnterVerificationCodeStep = ({
318363
}
319364

320365
try {
321-
await createEnterpriseConnection(provider);
366+
await createEnterpriseConnection(provider, emailToVerify);
322367
} catch (err) {
323368
handleError(err as Error, [], card.setError);
324369
return;
@@ -328,10 +373,12 @@ export const EnterVerificationCodeStep = ({
328373
},
329374
});
330375

376+
// Send a code on mount, but only when the target address is not already verified
331377
useEffect(() => {
332378
if (emailToVerify && !isVerified) {
333379
void prepare();
334380
}
381+
// eslint-disable-next-line react-hooks/exhaustive-deps
335382
}, []);
336383

337384
if (!emailToVerify) {
@@ -374,7 +421,7 @@ export const EnterVerificationCodeStep = ({
374421
<Step.Footer>
375422
<Step.Footer.Previous
376423
onClick={() => goPrev()}
377-
isDisabled={isFirstStep}
424+
isDisabled={!!primaryEmailAddress}
378425
/>
379426
<Step.Footer.Continue
380427
onClick={() => goNext()}

packages/ui/src/components/ConfigureSSO/steps/__tests__/SelectProviderStep.test.tsx

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,26 @@ vi.mock('../../ConfigureSSOContext', () => ({
4040
}),
4141
}));
4242

43+
const userMockState = vi.hoisted(() => ({
44+
current: {
45+
primaryEmailAddress: {
46+
emailAddress: 'test@clerk.com',
47+
verification: { status: 'verified' as 'verified' | 'unverified' },
48+
},
49+
} as {
50+
primaryEmailAddress?: {
51+
emailAddress: string;
52+
verification: { status: 'verified' | 'unverified' };
53+
};
54+
} | null,
55+
}));
56+
4357
vi.mock('@clerk/shared/react/index', async importOriginal => {
4458
const actual = await importOriginal<typeof import('@clerk/shared/react/index')>();
4559
return {
4660
...actual,
4761
useUser: () => ({
48-
user: {
49-
primaryEmailAddress: {
50-
emailAddress: 'test@clerk.com',
51-
verification: { status: 'verified' },
52-
},
53-
},
62+
user: userMockState.current,
5463
isLoaded: true,
5564
isSignedIn: true,
5665
}),
@@ -75,6 +84,12 @@ const resetMocks = () => {
7584
setProvider.mockReset();
7685
createEnterpriseConnection.mockReset();
7786
createEnterpriseConnection.mockResolvedValue(undefined);
87+
userMockState.current = {
88+
primaryEmailAddress: {
89+
emailAddress: 'test@clerk.com',
90+
verification: { status: 'verified' },
91+
},
92+
};
7893
};
7994

8095
describe('SelectProviderStep', () => {
@@ -227,4 +242,45 @@ describe('SelectProviderStep', () => {
227242

228243
expect(screen.getByRole('button', { name: /Previous/i })).toBeDisabled();
229244
});
245+
246+
it('routes to verify-domain when the user has no primary email address', async () => {
247+
resetMocks();
248+
userMockState.current = {};
249+
250+
const { wrapper } = await createFixtures();
251+
const { userEvent } = renderStep(wrapper);
252+
253+
await userEvent.click(screen.getByRole('radio', { name: 'Okta Workforce' }));
254+
await userEvent.click(screen.getByRole('button', { name: /Continue/i }));
255+
256+
await waitFor(() => {
257+
expect(goToStep).toHaveBeenCalledWith('verify-domain');
258+
});
259+
260+
expect(setProvider).toHaveBeenCalledWith('saml_okta');
261+
expect(createEnterpriseConnection).not.toHaveBeenCalled();
262+
});
263+
264+
it('routes to verify-domain when the user has an unverified primary email address', async () => {
265+
resetMocks();
266+
userMockState.current = {
267+
primaryEmailAddress: {
268+
emailAddress: 'test@clerk.com',
269+
verification: { status: 'unverified' },
270+
},
271+
};
272+
273+
const { wrapper } = await createFixtures();
274+
const { userEvent } = renderStep(wrapper);
275+
276+
await userEvent.click(screen.getByRole('radio', { name: 'Okta Workforce' }));
277+
await userEvent.click(screen.getByRole('button', { name: /Continue/i }));
278+
279+
await waitFor(() => {
280+
expect(goToStep).toHaveBeenCalledWith('verify-domain');
281+
});
282+
283+
expect(setProvider).toHaveBeenCalledWith('saml_okta');
284+
expect(createEnterpriseConnection).not.toHaveBeenCalled();
285+
});
230286
});

0 commit comments

Comments
 (0)