Skip to content

Commit 677a9b5

Browse files
dstaleywobsoriano
andauthored
feat(nextjs,react): Add HandleSSOCallback component (#7678)
Co-authored-by: Robert Soriano <sorianorobertc@gmail.com>
1 parent 332e020 commit 677a9b5

9 files changed

Lines changed: 561 additions & 0 deletions

File tree

.changeset/vast-loops-open.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/chrome-extension': minor
3+
'@clerk/nextjs': minor
4+
'@clerk/react': minor
5+
---
6+
7+
Add `HandleSSOCallback` component which handles the SSO callback during custom flows, including support for sign-in-or-up.

packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ exports[`public exports > should not include a breaking change 1`] = `
1111
"ClerkProvider",
1212
"CreateOrganization",
1313
"GoogleOneTap",
14+
"HandleSSOCallback",
1415
"OrganizationList",
1516
"OrganizationProfile",
1617
"OrganizationSwitcher",

packages/chrome-extension/src/react/re-exports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
ClerkLoaded,
77
ClerkLoading,
88
CreateOrganization,
9+
HandleSSOCallback,
910
OrganizationList,
1011
OrganizationProfile,
1112
OrganizationSwitcher,

packages/nextjs/src/client-boundary/uiComponents.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export {
2828
UserAvatar,
2929
UserButton,
3030
Waitlist,
31+
HandleSSOCallback,
3132
} from '@clerk/react';
3233

3334
// The assignment of UserProfile with BaseUserProfile props is used

packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
2525
"ClerkProvider",
2626
"CreateOrganization",
2727
"GoogleOneTap",
28+
"HandleSSOCallback",
2829
"OrganizationList",
2930
"OrganizationProfile",
3031
"OrganizationSwitcher",
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type { SetActiveNavigate } from '@clerk/shared/types';
2+
import React, { type ReactNode, useEffect, useRef } from 'react';
3+
4+
import { useClerk, useSignIn, useSignUp } from '../hooks';
5+
6+
export interface HandleSSOCallbackProps {
7+
/**
8+
* Called when the SSO callback is complete and a session has been created.
9+
*/
10+
navigateToApp: (...params: Parameters<SetActiveNavigate>) => void;
11+
/**
12+
* Called when a sign-in requires additional verification, or a sign-up is transfered to a sign-in that requires
13+
* additional verification.
14+
*/
15+
navigateToSignIn: () => void;
16+
/**
17+
* Called when a sign-in is transfered to a sign-up that requires additional verification.
18+
*/
19+
navigateToSignUp: () => void;
20+
}
21+
22+
/**
23+
* Use this component when building custom UI to handle the SSO callback and navigate to the appropriate page based on
24+
* the status of the sign-in or sign-up. By default, this component might render a captcha element to handle captchas
25+
* when required by the Clerk API.
26+
*
27+
* @example
28+
* ```tsx
29+
* import { HandleSSOCallback } from '@clerk/react';
30+
* import { useNavigate } from 'react-router';
31+
*
32+
* export default function Page() {
33+
* const navigate = useNavigate();
34+
*
35+
* return (
36+
* <HandleSSOCallback
37+
* navigateToApp={({ session, decorateUrl }) => {
38+
* if (session?.currentTask) {
39+
* const destination = decorateUrl(`/onboarding/${session?.currentTask.key}`);
40+
* if (destination.startsWith('http')) {
41+
* window.location.href = destination;
42+
* return;
43+
* }
44+
* navigate(destination);
45+
* return;
46+
* }
47+
*
48+
* const destination = decorateUrl('/dashboard');
49+
* if (destination.startsWith('http')) {
50+
* window.location.href = destination;
51+
* return;
52+
* }
53+
* navigate(destination);
54+
* }}
55+
* navigateToSignIn={() => {
56+
* navigate('/sign-in');
57+
* }}
58+
* navigateToSignUp={() => {
59+
* navigate('/sign-up');
60+
* }}
61+
* />
62+
* );
63+
* }
64+
* ```
65+
*/
66+
export function HandleSSOCallback(props: HandleSSOCallbackProps): ReactNode {
67+
const { navigateToApp, navigateToSignIn, navigateToSignUp } = props;
68+
const clerk = useClerk();
69+
const { signIn } = useSignIn();
70+
const { signUp } = useSignUp();
71+
const hasRun = useRef(false);
72+
73+
useEffect(() => {
74+
(async () => {
75+
if (!clerk.loaded || hasRun.current) {
76+
return;
77+
}
78+
// Prevent re-running this effect if the page is re-rendered during session activation (such as on Next.js).
79+
hasRun.current = true;
80+
81+
// If this was a sign-in, and it's complete, there's nothing else to do.
82+
// Note: We perform a cast here to prevent TypeScript from narrowing the type of signIn.status. TypeScript
83+
// doesn't understand that the status can be mutated during the execution of this function.
84+
if ((signIn.status as string) === 'complete') {
85+
await signIn.finalize({
86+
navigate: async (...params) => {
87+
navigateToApp(...params);
88+
},
89+
});
90+
return;
91+
}
92+
93+
// If the sign-up used an existing account, transfer it to a sign-in.
94+
if (signUp.isTransferable) {
95+
await signIn.create({ transfer: true });
96+
if (signIn.status === 'complete') {
97+
await signIn.finalize({
98+
navigate: async (...params) => {
99+
navigateToApp(...params);
100+
},
101+
});
102+
return;
103+
}
104+
// The sign-in requires additional verification, so we need to navigate to the sign-in page.
105+
return navigateToSignIn();
106+
}
107+
108+
if (
109+
signIn.status === 'needs_first_factor' &&
110+
!signIn.supportedFirstFactors?.every(f => f.strategy === 'enterprise_sso')
111+
) {
112+
// The sign-in requires the use of a configured first factor, so navigate to the sign-in page.
113+
return navigateToSignIn();
114+
}
115+
116+
// If the sign-in used an external account not associated with an existing user, create a sign-up.
117+
if (signIn.isTransferable) {
118+
await signUp.create({ transfer: true });
119+
if (signUp.status === 'complete') {
120+
await signUp.finalize({
121+
navigate: async (...params) => {
122+
navigateToApp(...params);
123+
},
124+
});
125+
return;
126+
}
127+
return navigateToSignUp();
128+
}
129+
130+
if (signUp.status === 'complete') {
131+
await signUp.finalize({
132+
navigate: async (...params) => {
133+
navigateToApp(...params);
134+
},
135+
});
136+
return;
137+
}
138+
139+
if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_new_password') {
140+
// The sign-in requires a MFA token or a new password, so navigate to the sign-in page.
141+
return navigateToSignIn();
142+
}
143+
144+
// The external account used to sign-in or sign-up was already associated with an existing user and active
145+
// session on this client, so activate the session and navigate to the application.
146+
if (signIn.existingSession || signUp.existingSession) {
147+
const sessionId = signIn.existingSession?.sessionId || signUp.existingSession?.sessionId;
148+
if (sessionId) {
149+
// Because we're activating a session that's not the result of a sign-in or sign-up, we need to use the
150+
// Clerk `setActive` API instead of the `finalize` API.
151+
await clerk.setActive({
152+
session: sessionId,
153+
navigate: async (...params) => {
154+
return navigateToApp(...params);
155+
},
156+
});
157+
return;
158+
}
159+
}
160+
})();
161+
}, [clerk, clerk.loaded, signIn, signUp]);
162+
163+
return (
164+
<div>
165+
{/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the
166+
captcha element. */}
167+
<div id='clerk-captcha' />
168+
</div>
169+
);
170+
}

0 commit comments

Comments
 (0)