Skip to content

Commit bab2e7e

Browse files
authoredApr 18, 2024··
feat(clerk-js,types,shared): Introduce force and fallback redirect urls (#3162)
1 parent 8913296 commit bab2e7e

34 files changed

+944
-588
lines changed
 

‎.changeset/friendly-masks-obey.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/shared': patch
4+
'@clerk/types': patch
5+
---
6+
7+
Introduce forceRedirectUrl and fallbackRedirectUrl

‎.github/workflows/release.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
playwright-enabled: true # Must be present to enable caching on branched workflows
5050

5151
- name: Build release
52-
run: npx turbo build $TURBO_ARGS --force
52+
run: npx turbo build $TURBO_ARGS --force --filter=!elements
5353

5454
- name: Create Release PR
5555
id: changesets

‎integration/playwright.config.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,10 @@ export default defineConfig({
4343
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
4444
dependencies: ['setup'],
4545
},
46-
{
47-
name: 'webkit',
48-
use: { ...devices['Desktop Safari'] },
49-
dependencies: ['setup'],
50-
},
46+
// {
47+
// name: 'webkit',
48+
// use: { ...devices['Desktop Safari'] },
49+
// dependencies: ['setup'],
50+
// },
5151
],
5252
});

‎integration/tests/navigation.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export default function Page() {
112112

113113
await u.po.signIn.getGoToSignUp().click();
114114
await u.po.signUp.waitForMounted();
115-
await u.page.waitForURL(`${app.serverUrl}/sign-up?redirect_url=${encodeURIComponent(app.serverUrl + '/')}`);
115+
await u.page.waitForURL(`${app.serverUrl}/sign-up`);
116116

117117
await page.goBack();
118118
await u.po.signIn.waitForMounted();

‎package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
]
1010
},
1111
"scripts": {
12-
"build": "FORCE_COLOR=1 turbo build --concurrency=${TURBO_CONCURRENCY:-80%}",
12+
"build": "FORCE_COLOR=1 turbo build --concurrency=${TURBO_CONCURRENCY:-80%} --filter=!@clerk/elements",
1313
"bundlewatch": "turbo bundlewatch",
1414
"changeset": "changeset",
1515
"changeset:empty": "npm run changeset -- --empty",
@@ -28,7 +28,7 @@
2828
"release:canary": "changeset publish --tag canary --no-git-tag",
2929
"release:snapshot": "changeset publish --tag snapshot --no-git-tag",
3030
"release:verdaccio": "if [ \"$(npm config get registry)\" = \"https://registry.npmjs.org/\" ]; then echo 'Error: Using default registry' && exit 1; else TURBO_CONCURRENCY=1 npm run build && changeset publish --no-git-tag; fi",
31-
"test": "FORCE_COLOR=1 turbo test --concurrency=${TURBO_CONCURRENCY:-80%}",
31+
"test": "FORCE_COLOR=1 turbo test --concurrency=${TURBO_CONCURRENCY:-80%} --filter=!@clerk/elements",
3232
"test:cache:clear": "FORCE_COLOR=1 turbo test:cache:clear --continue --concurrency=${TURBO_CONCURRENCY:-80%}",
3333
"test:integration:ap-flows": "npm run test:integration:base -- --grep @ap-flows",
3434
"test:integration:base": "DEBUG=1 npx playwright test --config integration/playwright.config.ts",

‎packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts

+42-21
Original file line numberDiff line numberDiff line change
@@ -122,20 +122,36 @@ describe('Clerk singleton - Redirects', () => {
122122
mockEnvironmentFetch.mockRestore();
123123
});
124124

125-
it('redirects to signInUrl', async () => {
126-
await clerkForProductionInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' });
125+
it('redirects to signInUrl for development instance', async () => {
127126
await clerkForDevelopmentInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' });
127+
expect(mockNavigate).toHaveBeenCalledWith(
128+
'/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F',
129+
undefined,
130+
);
131+
});
128132

129-
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F');
130-
expect(mockNavigate.mock.calls[1][0]).toBe('/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F');
133+
it('redirects to signInUrl for production instance', async () => {
134+
await clerkForProductionInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' });
135+
expect(mockNavigate).toHaveBeenCalledWith(
136+
'/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F',
137+
undefined,
138+
);
131139
});
132140

133-
it('redirects to signUpUrl', async () => {
134-
await clerkForProductionInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' });
141+
it('redirects to signUpUrl for development instance', async () => {
135142
await clerkForDevelopmentInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' });
143+
expect(mockNavigate).toHaveBeenCalledWith(
144+
'/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F',
145+
undefined,
146+
);
147+
});
136148

137-
expect(mockNavigate.mock.calls[0][0]).toBe('/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F');
138-
expect(mockNavigate.mock.calls[1][0]).toBe('/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F');
149+
it('redirects to signUpUrl for production instance', async () => {
150+
await clerkForProductionInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' });
151+
expect(mockNavigate).toHaveBeenCalledWith(
152+
'/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F',
153+
undefined,
154+
);
139155
});
140156

141157
it('redirects to userProfileUrl', async () => {
@@ -203,29 +219,34 @@ describe('Clerk singleton - Redirects', () => {
203219

204220
const host = 'http://another-test.host';
205221

206-
it('redirects to signInUrl', async () => {
207-
await clerkForProductionInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' });
222+
it('redirects to signInUrl for development instance', async () => {
208223
await clerkForDevelopmentInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' });
209-
210-
expect(mockHref).toHaveBeenNthCalledWith(1, `${host}/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`);
211-
212-
expect(mockHref).toHaveBeenNthCalledWith(
213-
2,
224+
expect(mockHref).toHaveBeenCalledTimes(1);
225+
expect(mockHref).toHaveBeenCalledWith(
214226
`${host}/sign-in?__clerk_db_jwt=deadbeef#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`,
215227
);
216228
});
217229

218-
it('redirects to signUpUrl', async () => {
219-
await clerkForProductionInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' });
220-
await clerkForDevelopmentInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' });
230+
it('redirects to signInUrl for production instance', async () => {
231+
await clerkForProductionInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' });
232+
expect(mockHref).toHaveBeenCalledTimes(1);
233+
expect(mockHref).toHaveBeenCalledWith(`${host}/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`);
234+
});
221235

222-
expect(mockHref).toHaveBeenNthCalledWith(1, `${host}/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`);
223-
expect(mockHref).toHaveBeenNthCalledWith(
224-
2,
236+
it('redirects to signUpUrl for development instance', async () => {
237+
await clerkForDevelopmentInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' });
238+
expect(mockHref).toHaveBeenCalledTimes(1);
239+
expect(mockHref).toHaveBeenCalledWith(
225240
`${host}/sign-up?__clerk_db_jwt=deadbeef#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`,
226241
);
227242
});
228243

244+
it('redirects to signUpUrl for production instance', async () => {
245+
await clerkForProductionInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' });
246+
expect(mockHref).toHaveBeenCalledTimes(1);
247+
expect(mockHref).toHaveBeenCalledWith(`${host}/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`);
248+
});
249+
229250
it('redirects to userProfileUrl', async () => {
230251
await clerkForProductionInstance.redirectToUserProfile();
231252
await clerkForDevelopmentInstance.redirectToUserProfile();

‎packages/clerk-js/src/core/__tests__/clerk.test.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,7 @@ describe('Clerk singleton', () => {
981981
});
982982
});
983983

984-
it('redirects the user to the afterSignInUrl if one was provided', async () => {
984+
it('redirects the user to the signInForceRedirectUrl if one was provided', async () => {
985985
mockEnvironmentFetch.mockReturnValue(
986986
Promise.resolve({
987987
authConfig: {},
@@ -1028,15 +1028,15 @@ describe('Clerk singleton', () => {
10281028
sut.setActive = mockSetActive as any;
10291029

10301030
sut.handleRedirectCallback({
1031-
redirectUrl: '/custom-sign-in',
1031+
signInForceRedirectUrl: '/custom-sign-in',
10321032
});
10331033

10341034
await waitFor(() => {
10351035
expect(mockNavigate.mock.calls[0][0]).toBe('/custom-sign-in');
10361036
});
10371037
});
10381038

1039-
it('gives priority to afterSignInUrl if afterSignInUrl and redirectUrl were provided ', async () => {
1039+
it('gives priority to signInForceRedirectUrl if signInForceRedirectUrl and signInFallbackRedirectUrl were provided ', async () => {
10401040
mockEnvironmentFetch.mockReturnValue(
10411041
Promise.resolve({
10421042
authConfig: {},
@@ -1083,8 +1083,8 @@ describe('Clerk singleton', () => {
10831083
sut.setActive = mockSetActive as any;
10841084

10851085
sut.handleRedirectCallback({
1086-
afterSignInUrl: '/custom-sign-in',
1087-
redirectUrl: '/redirect-to',
1086+
signInForceRedirectUrl: '/custom-sign-in',
1087+
signInFallbackRedirectUrl: '/redirect-to',
10881088
} as any);
10891089

10901090
await waitFor(() => {

‎packages/clerk-js/src/core/clerk.ts

+26-46
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ import type {
3737
OrganizationProfileProps,
3838
OrganizationResource,
3939
OrganizationSwitcherProps,
40-
RedirectOptions,
4140
Resources,
4241
SDKMetadata,
4342
SetActiveParams,
@@ -59,7 +58,6 @@ import type {
5958

6059
import type { MountComponentRenderer } from '../ui/Components';
6160
import {
62-
appendAsQueryParams,
6361
buildURL,
6462
completeSignUpFlow,
6563
createAllowedRedirectOrigins,
@@ -77,17 +75,16 @@ import {
7775
isRedirectForFAPIInitiatedFlow,
7876
noOrganizationExists,
7977
noUserExists,
80-
pickRedirectionProp,
8178
removeClerkQueryParam,
8279
requiresUserInput,
8380
sessionExistsAndSingleSessionModeEnabled,
8481
stripOrigin,
85-
stripSameOrigin,
86-
toURL,
8782
windowNavigate,
8883
} from '../utils';
84+
import { assertNoLegacyProp } from '../utils/assertNoLegacyProp';
8985
import { getClientUatCookie } from '../utils/cookies/clientUat';
9086
import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback';
87+
import { RedirectUrls } from '../utils/redirectUrls';
9188
import { CLERK_SATELLITE_URL, CLERK_SYNCED, ERROR_CODES } from './constants';
9289
import type { DevBrowser } from './devBrowser';
9390
import { createDevBrowser } from './devBrowser';
@@ -133,9 +130,11 @@ const defaultOptions: ClerkOptions = {
133130
isSatellite: false,
134131
signInUrl: undefined,
135132
signUpUrl: undefined,
136-
afterSignInUrl: undefined,
137-
afterSignUpUrl: undefined,
138133
afterSignOutUrl: undefined,
134+
signInFallbackRedirectUrl: undefined,
135+
signUpFallbackRedirectUrl: undefined,
136+
signInForceRedirectUrl: undefined,
137+
signUpForceRedirectUrl: undefined,
139138
};
140139

141140
export class Clerk implements ClerkInterface {
@@ -276,6 +275,8 @@ export class Clerk implements ClerkInterface {
276275
...options,
277276
};
278277

278+
assertNoLegacyProp(this.#options);
279+
279280
if (this.#options.sdkMetadata) {
280281
Clerk.sdkMetadata = this.#options.sdkMetadata;
281282
}
@@ -827,11 +828,17 @@ export class Clerk implements ClerkInterface {
827828
}
828829

829830
public buildSignInUrl(options?: SignInRedirectOptions): string {
830-
return this.#buildUrl('signInUrl', options);
831+
return this.#buildUrl('signInUrl', {
832+
...options?.initialValues,
833+
redirect_url: options?.redirectUrl || window.location.href,
834+
});
831835
}
832836

833837
public buildSignUpUrl(options?: SignUpRedirectOptions): string {
834-
return this.#buildUrl('signUpUrl', options);
838+
return this.#buildUrl('signUpUrl', {
839+
...options?.initialValues,
840+
redirect_url: options?.redirectUrl || window.location.href,
841+
});
835842
}
836843

837844
public buildUserProfileUrl(): string {
@@ -849,19 +856,11 @@ export class Clerk implements ClerkInterface {
849856
}
850857

851858
public buildAfterSignInUrl(): string {
852-
if (!this.#options.afterSignInUrl) {
853-
return '/';
854-
}
855-
856-
return this.buildUrlWithAuth(this.#options.afterSignInUrl);
859+
return this.buildUrlWithAuth(new RedirectUrls(this.#options).getAfterSignInUrl());
857860
}
858861

859862
public buildAfterSignUpUrl(): string {
860-
if (!this.#options.afterSignUpUrl) {
861-
return '/';
862-
}
863-
864-
return this.buildUrlWithAuth(this.#options.afterSignUpUrl);
863+
return this.buildUrlWithAuth(new RedirectUrls(this.#options).getAfterSignUpUrl());
865864
}
866865

867866
public buildAfterSignOutUrl(): string {
@@ -1062,9 +1061,9 @@ export class Clerk implements ClerkInterface {
10621061
buildURL({ base: displayConfig.signInUrl, hashPath: '/reset-password' }, { stringify: true }),
10631062
);
10641063

1065-
const navigateAfterSignIn = makeNavigate(params.afterSignInUrl || params.redirectUrl || '/');
1066-
1067-
const navigateAfterSignUp = makeNavigate(params.afterSignUpUrl || params.redirectUrl || '/');
1064+
const redirectUrls = new RedirectUrls(this.#options, params);
1065+
const navigateAfterSignIn = makeNavigate(redirectUrls.getAfterSignInUrl());
1066+
const navigateAfterSignUp = makeNavigate(redirectUrls.getAfterSignUpUrl());
10681067

10691068
const navigateToContinueSignUp = makeNavigate(
10701069
params.continueSignUpUrl ||
@@ -1090,7 +1089,6 @@ export class Clerk implements ClerkInterface {
10901089

10911090
const userExistsButNeedsToSignIn =
10921091
su.externalAccountStatus === 'transferable' && su.externalAccountErrorCode === 'external_account_exists';
1093-
10941092
if (userExistsButNeedsToSignIn) {
10951093
const res = await signIn.create({ transfer: true });
10961094
switch (res.status) {
@@ -1644,31 +1642,13 @@ export class Clerk implements ClerkInterface {
16441642
});
16451643
};
16461644

1647-
#buildUrl = (key: 'signInUrl' | 'signUpUrl', options?: SignInRedirectOptions | SignUpRedirectOptions): string => {
1648-
if (!this.loaded || !this.#environment || !this.#environment.displayConfig) {
1645+
#buildUrl = (key: 'signInUrl' | 'signUpUrl', params?: Record<string, string>): string => {
1646+
if (!key || !this.loaded || !this.#environment || !this.#environment.displayConfig) {
16491647
return '';
16501648
}
1651-
1652-
const signInOrUpUrl = pickRedirectionProp(
1653-
key,
1654-
{ options: this.#options, displayConfig: this.#environment.displayConfig },
1655-
false,
1656-
);
1657-
1658-
const urls: RedirectOptions = {
1659-
afterSignInUrl: pickRedirectionProp('afterSignInUrl', { ctx: options, options: this.#options }, false),
1660-
afterSignUpUrl: pickRedirectionProp('afterSignUpUrl', { ctx: options, options: this.#options }, false),
1661-
redirectUrl: options?.redirectUrl || window.location.href,
1662-
};
1663-
1664-
(Object.keys(urls) as Array<keyof typeof urls>).forEach(function (key) {
1665-
const url = urls[key];
1666-
if (url) {
1667-
urls[key] = stripSameOrigin(toURL(url), toURL(signInOrUpUrl));
1668-
}
1669-
});
1670-
1671-
return this.buildUrlWithAuth(appendAsQueryParams(signInOrUpUrl, { ...urls, ...options?.initialValues }));
1649+
const signInOrUpUrl = this.#options[key] || this.#environment.displayConfig[key];
1650+
const redirectUrls = new RedirectUrls(this.#options, params);
1651+
return this.buildUrlWithAuth(redirectUrls.appendPreservedPropsToUrl(signInOrUpUrl, params));
16721652
};
16731653

16741654
assertComponentsReady(controls: unknown): asserts controls is ReturnType<MountComponentRenderer> {

‎packages/clerk-js/src/core/constants.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
export const PRESERVED_QUERYSTRING_PARAMS = ['after_sign_in_url', 'after_sign_up_url', 'redirect_url'];
1+
// TODO: Do we still have a use for this or can we simply preserve all params?
2+
export const PRESERVED_QUERYSTRING_PARAMS = [
3+
'redirect_url',
4+
'sign_in_force_redirect_url',
5+
'sign_in_fallback_redirect_url',
6+
'sign_up_force_redirect_url',
7+
'sign_up_fallback_redirect_url',
8+
];
29

310
export const CLERK_MODAL_STATE = '__clerk_modal_state';
411
export const CLERK_SYNCED = '__clerk_synced';

‎packages/clerk-js/src/ui/components/SignIn/SignIn.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,8 @@ function SignInRoutes(): JSX.Element {
4444
<SignInSSOCallback
4545
signUpUrl={signInContext.signUpUrl}
4646
signInUrl={signInContext.signInUrl}
47-
afterSignInUrl={signInContext.afterSignInUrl}
48-
afterSignUpUrl={signInContext.afterSignUpUrl}
49-
redirectUrl={signInContext.redirectUrl}
47+
signInForceRedirectUrl={signInContext.afterSignInUrl}
48+
signUpForceRedirectUrl={signInContext.afterSignUpUrl}
5049
continueSignUpUrl={signInContext.signUpContinueUrl}
5150
firstFactorUrl={'../factor-one'}
5251
secondFactorUrl={'../factor-two'}
@@ -58,7 +57,7 @@ function SignInRoutes(): JSX.Element {
5857
</Route>
5958
<Route path='verify'>
6059
<SignInEmailLinkFlowComplete
61-
redirectUrlComplete={signInContext.afterSignInUrl || signInContext.redirectUrl || undefined}
60+
redirectUrlComplete={signInContext.afterSignInUrl}
6261
redirectUrl='../factor-two'
6362
/>
6463
</Route>

‎packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ describe('SignInStart', () => {
199199
expect(fixtures.signIn.create).toHaveBeenCalled();
200200
expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith({
201201
strategy: 'saml',
202-
redirectUrl: 'http://localhost/#/sso-callback?redirect_url=http%3A%2F%2Flocalhost%2F',
202+
redirectUrl: 'http://localhost/#/sso-callback',
203203
redirectUrlComplete: '/',
204204
});
205205
});

‎packages/clerk-js/src/ui/components/SignUp/SignUp.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ function SignUpRoutes(): JSX.Element {
4242
<SignUpSSOCallback
4343
signUpUrl={signUpContext.signUpUrl}
4444
signInUrl={signUpContext.signInUrl}
45-
afterSignUpUrl={signUpContext.afterSignUpUrl}
46-
afterSignInUrl={signUpContext.afterSignInUrl}
47-
redirectUrl={signUpContext.redirectUrl}
45+
signUpForceRedirectUrl={signUpContext.afterSignUpUrl}
46+
signInForceRedirectUrl={signUpContext.afterSignInUrl}
4847
secondFactorUrl={signUpContext.secondFactorUrl}
4948
continueSignUpUrl='../continue'
5049
verifyEmailAddressUrl='../verify-email-address'
@@ -53,7 +52,7 @@ function SignUpRoutes(): JSX.Element {
5352
</Route>
5453
<Route path='verify'>
5554
<SignUpEmailLinkFlowComplete
56-
redirectUrlComplete={signUpContext.afterSignUpUrl || signUpContext.redirectUrl || undefined}
55+
redirectUrlComplete={signUpContext.afterSignUpUrl}
5756
verifyEmailPath='../verify-email-address'
5857
verifyPhonePath='../verify-phone-number'
5958
/>

‎packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx

+48-69
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import type { OrganizationResource, UserResource } from '@clerk/types';
44
import React, { useMemo } from 'react';
55

66
import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_INITIAL_VALUE_KEYS } from '../../core/constants';
7-
import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils';
7+
import { buildURL, createDynamicParamParser } from '../../utils';
8+
import { assertNoLegacyProp } from '../../utils/assertNoLegacyProp';
9+
import { RedirectUrls } from '../../utils/redirectUrls';
810
import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../constants';
911
import { useEnvironment, useOptions } from '../contexts';
1012
import type { NavbarRoute } from '../elements';
@@ -48,6 +50,8 @@ export type SignUpContextType = SignUpCtx & {
4850
signUpUrl: string;
4951
secondFactorUrl: string;
5052
authQueryString: string | null;
53+
afterSignUpUrl: string;
54+
afterSignInUrl: string;
5155
};
5256

5357
export const useSignUpContext = (): SignUpContextType => {
@@ -58,54 +62,41 @@ export const useSignUpContext = (): SignUpContextType => {
5862
const options = useOptions();
5963
const clerk = useClerk();
6064

65+
assertNoLegacyProp(options);
66+
assertNoLegacyProp(ctx);
67+
6168
const initialValuesFromQueryParams = useMemo(
6269
() => getInitialValuesFromQueryParams(queryString, SIGN_UP_INITIAL_VALUE_KEYS),
6370
[],
6471
);
6572

73+
const redirectUrls = new RedirectUrls(
74+
options,
75+
{
76+
...ctx,
77+
signUpFallbackRedirectUrl: ctx.fallbackRedirectUrl,
78+
signUpForceRedirectUrl: ctx.forceRedirectUrl,
79+
},
80+
queryParams,
81+
);
82+
6683
if (componentName !== 'SignUp') {
6784
throw new Error('Clerk: useSignUpContext called outside of the mounted SignUp component.');
6885
}
6986

70-
const afterSignUpUrl = clerk.buildUrlWithAuth(
71-
pickRedirectionProp('afterSignUpUrl', {
72-
queryParams,
73-
ctx,
74-
options,
75-
}) || '/',
76-
);
77-
78-
const afterSignInUrl = clerk.buildUrlWithAuth(
79-
pickRedirectionProp('afterSignInUrl', {
80-
queryParams,
81-
ctx,
82-
options,
83-
}) || '/',
84-
);
87+
const afterSignUpUrl = clerk.buildUrlWithAuth(redirectUrls.getAfterSignUpUrl());
88+
const afterSignInUrl = clerk.buildUrlWithAuth(redirectUrls.getAfterSignInUrl());
8589

8690
const navigateAfterSignUp = () => navigate(afterSignUpUrl);
8791

88-
// Add query strings to the sign in URL
89-
const authQs = buildAuthQueryString({
90-
afterSignInUrl: afterSignInUrl,
91-
afterSignUpUrl: afterSignUpUrl,
92-
displayConfig: displayConfig,
93-
});
94-
9592
// The `ctx` object here refers to the SignUp component's props.
9693
// SignUp's own options won't have a `signUpUrl` property, so we have to get the value
9794
// from the `path` prop instead, when the routing is set to 'path'.
98-
let signUpUrl =
99-
(ctx.routing === 'path' ? ctx.path : undefined) ||
100-
pickRedirectionProp('signUpUrl', { options, displayConfig }, false);
101-
if (authQs && ctx.routing !== 'virtual') {
102-
signUpUrl += `#/?${authQs}`;
103-
}
95+
let signUpUrl = (ctx.routing === 'path' && ctx.path) || options.signUpUrl || displayConfig.signUpUrl;
96+
let signInUrl = ctx.signInUrl || options.signInUrl || displayConfig.signInUrl;
10497

105-
let signInUrl = pickRedirectionProp('signInUrl', { ctx, options, displayConfig }, false);
106-
if (authQs && ctx.routing !== 'virtual') {
107-
signInUrl += `#/?${authQs}`;
108-
}
98+
signUpUrl = redirectUrls.appendPreservedPropsToUrl(signUpUrl, queryParams);
99+
signInUrl = redirectUrls.appendPreservedPropsToUrl(signInUrl, queryParams);
109100

110101
// TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead.
111102
const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true });
@@ -121,7 +112,7 @@ export const useSignUpContext = (): SignUpContextType => {
121112
navigateAfterSignUp,
122113
queryParams,
123114
initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams },
124-
authQueryString: authQs,
115+
authQueryString: redirectUrls.toSearchParams().toString(),
125116
};
126117
};
127118

@@ -132,6 +123,8 @@ export type SignInContextType = SignInCtx & {
132123
signInUrl: string;
133124
signUpContinueUrl: string;
134125
authQueryString: string | null;
126+
afterSignUpUrl: string;
127+
afterSignInUrl: string;
135128
};
136129

137130
export const useSignInContext = (): SignInContextType => {
@@ -142,55 +135,41 @@ export const useSignInContext = (): SignInContextType => {
142135
const options = useOptions();
143136
const clerk = useClerk();
144137

138+
assertNoLegacyProp(options);
139+
assertNoLegacyProp(ctx);
140+
145141
const initialValuesFromQueryParams = useMemo(
146142
() => getInitialValuesFromQueryParams(queryString, SIGN_IN_INITIAL_VALUE_KEYS),
147143
[],
148144
);
149145

146+
const redirectUrls = new RedirectUrls(
147+
options,
148+
{
149+
...ctx,
150+
signInFallbackRedirectUrl: ctx.fallbackRedirectUrl,
151+
signInForceRedirectUrl: ctx.forceRedirectUrl,
152+
},
153+
queryParams,
154+
);
155+
150156
if (componentName !== 'SignIn') {
151157
throw new Error('Clerk: useSignInContext called outside of the mounted SignIn component.');
152158
}
153159

154-
const afterSignUpUrl = clerk.buildUrlWithAuth(
155-
pickRedirectionProp('afterSignUpUrl', {
156-
queryParams,
157-
ctx,
158-
options,
159-
}) || '/',
160-
);
161-
162-
const afterSignInUrl = clerk.buildUrlWithAuth(
163-
pickRedirectionProp('afterSignInUrl', {
164-
queryParams,
165-
ctx,
166-
options,
167-
}) || '/',
168-
);
160+
const afterSignInUrl = clerk.buildUrlWithAuth(redirectUrls.getAfterSignInUrl());
161+
const afterSignUpUrl = clerk.buildUrlWithAuth(redirectUrls.getAfterSignUpUrl());
169162

170163
const navigateAfterSignIn = () => navigate(afterSignInUrl);
171164

172-
// Add query strings to the sign in URL
173-
const authQs = buildAuthQueryString({
174-
afterSignInUrl: afterSignInUrl,
175-
afterSignUpUrl: afterSignUpUrl,
176-
displayConfig: displayConfig,
177-
});
178-
179-
let signUpUrl = pickRedirectionProp('signUpUrl', { ctx, options, displayConfig }, false);
180-
if (authQs && ctx.routing !== 'virtual') {
181-
signUpUrl += `#/?${authQs}`;
182-
}
183-
184165
// The `ctx` object here refers to the SignIn component's props.
185166
// SignIn's own options won't have a `signInUrl` property, so we have to get the value
186167
// from the `path` prop instead, when the routing is set to 'path'.
187-
let signInUrl =
188-
(ctx.routing === 'path' ? ctx.path : undefined) ||
189-
pickRedirectionProp('signInUrl', { options, displayConfig }, false);
190-
if (authQs && ctx.routing !== 'virtual') {
191-
signInUrl += `#/?${authQs}`;
192-
}
168+
let signInUrl = (ctx.routing === 'path' && ctx.path) || options.signInUrl || displayConfig.signInUrl;
169+
let signUpUrl = ctx.signUpUrl || options.signUpUrl || displayConfig.signUpUrl;
193170

171+
signInUrl = redirectUrls.appendPreservedPropsToUrl(signInUrl, queryParams);
172+
signUpUrl = redirectUrls.appendPreservedPropsToUrl(signUpUrl, queryParams);
194173
const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true });
195174

196175
return {
@@ -204,7 +183,7 @@ export const useSignInContext = (): SignInContextType => {
204183
signUpContinueUrl,
205184
queryParams,
206185
initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams },
207-
authQueryString: authQs,
186+
authQueryString: redirectUrls.toSearchParams().toString(),
208187
};
209188
};
210189

@@ -266,7 +245,7 @@ export const useUserButtonContext = () => {
266245
throw new Error('Clerk: useUserButtonContext called outside of the mounted UserButton component.');
267246
}
268247

269-
const signInUrl = pickRedirectionProp('signInUrl', { ctx, options, displayConfig }, false);
248+
const signInUrl = ctx.signInUrl || options.signInUrl || displayConfig.signInUrl;
270249
const userProfileUrl = ctx.userProfileUrl || displayConfig.userProfileUrl;
271250

272251
const afterSignOutUrl = ctx.afterSignOutUrl || clerk.buildAfterSignOutUrl();

‎packages/clerk-js/src/ui/portal/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import ReactDOM from 'react-dom';
55
import { PRESERVED_QUERYSTRING_PARAMS } from '../../core/constants';
66
import { clerkErrorPathRouterMissingPath } from '../../core/errors';
77
import { buildVirtualRouterUrl } from '../../utils';
8-
import { normalizeRoutingOptions } from '../../utils/authPropHelpers';
8+
import { normalizeRoutingOptions } from '../../utils/normalizeRoutingOptions';
99
import { ComponentContext } from '../contexts';
1010
import { HashRouter, PathRouter, VirtualRouter } from '../router';
1111
import type { AvailableComponentCtx } from '../types';

‎packages/clerk-js/src/utils/__tests__/authPropHelpers.test.ts

-137
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { snakeToCamel } from '@clerk/shared/underscore';
2+
import type { RedirectOptions } from '@clerk/types';
3+
4+
import { RedirectUrls } from '../redirectUrls';
5+
6+
const oldWindowLocation = window.location;
7+
8+
describe('redirectUrls', () => {
9+
let mockWindowLocation: Window['location'];
10+
11+
beforeEach(() => {
12+
mockWindowLocation = new URL('https://www.clerk.com') as any as Window['location'];
13+
Object.defineProperty(global.window, 'location', { value: mockWindowLocation });
14+
});
15+
16+
afterAll(() => {
17+
Object.defineProperty(global.window, 'location', {
18+
value: oldWindowLocation,
19+
});
20+
});
21+
22+
describe('parse input values', () => {
23+
it('parses options or props', () => {
24+
const options: [keyof RedirectOptions, string][] = [
25+
['signInForceRedirectUrl', 'sign-in-force-redirect-url'],
26+
['signInFallbackRedirectUrl', 'sign-in-fallback-redirect'],
27+
['signUpForceRedirectUrl', 'sign-up-force-redirect'],
28+
['signUpFallbackRedirectUrl', 'sign-up-fallback-redirect'],
29+
];
30+
31+
// test options (first param)
32+
for (const [k, v] of options) {
33+
const redirectUrls = new RedirectUrls({ [k]: v });
34+
// @ts-expect-error ignoring private modifier
35+
expect(redirectUrls.fromOptions[k]).toBe(`${mockWindowLocation.href}${v}`);
36+
}
37+
38+
// test props (second param)
39+
for (const [k, v] of options) {
40+
const redirectUrls = new RedirectUrls({}, { [k]: v });
41+
// @ts-expect-error ignoring private modifier
42+
expect(redirectUrls.fromProps[k]).toBe(`${mockWindowLocation.href}${v}`);
43+
}
44+
});
45+
46+
it('parses search params', () => {
47+
const params = [
48+
['sign_in_force_redirect_url', 'sign-in-force-redirect'],
49+
['sign_in_fallback_redirect_url', 'sign-in-fallback-redirect'],
50+
['sign_up_force_redirect_url', 'sign-up-force-redirect'],
51+
['sign_up_fallback_redirect_url', 'sign-up-fallback-redirect'],
52+
];
53+
54+
for (const [key, val] of params) {
55+
const redirectUrls = new RedirectUrls({}, {}, { [key]: val });
56+
// @ts-expect-error ignoring private modifier
57+
expect(redirectUrls.fromSearchParams[snakeToCamel(key)]).toBe(`${mockWindowLocation.href}${val}`);
58+
}
59+
});
60+
61+
it('filters origins that are not allowed', () => {
62+
const redirectUrls = new RedirectUrls(
63+
{
64+
allowedRedirectOrigins: ['https://www.clerk.com'],
65+
// This would take priority but its not allowed
66+
signInForceRedirectUrl: 'https://www.other.com/sign-in-force-redirect-url',
67+
},
68+
{
69+
// This will be used instead
70+
signInForceRedirectUrl: 'https://www.clerk.com/sign-in-force-redirect-url',
71+
},
72+
);
73+
74+
expect(redirectUrls.getAfterSignInUrl()).toBe('https://www.clerk.com/sign-in-force-redirect-url');
75+
});
76+
});
77+
78+
describe('get redirect urls', () => {
79+
it('prioritizes force urls among other urls in the same group', () => {
80+
const redirectUrls = new RedirectUrls({
81+
signInForceRedirectUrl: 'sign-in-force-redirect-url',
82+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
83+
signUpForceRedirectUrl: 'sign-up-force-redirect-url',
84+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
85+
});
86+
87+
expect(redirectUrls.getAfterSignInUrl()).toBe(`${mockWindowLocation.href}sign-in-force-redirect-url`);
88+
expect(redirectUrls.getAfterSignUpUrl()).toBe(`${mockWindowLocation.href}sign-up-force-redirect-url`);
89+
});
90+
91+
it('uses fallback urls if force do not exist', () => {
92+
const redirectUrls = new RedirectUrls({
93+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
94+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
95+
});
96+
expect(redirectUrls.getAfterSignInUrl()).toBe(`${mockWindowLocation.href}sign-in-fallback-redirect-url`);
97+
expect(redirectUrls.getAfterSignUpUrl()).toBe(`${mockWindowLocation.href}sign-up-fallback-redirect-url`);
98+
});
99+
100+
it('prioritizes props over options', () => {
101+
const redirectUrls = new RedirectUrls(
102+
{
103+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
104+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
105+
},
106+
{
107+
signInFallbackRedirectUrl: 'prop-sign-in-fallback-redirect-url',
108+
signUpFallbackRedirectUrl: 'prop-sign-up-fallback-redirect-url',
109+
},
110+
);
111+
expect(redirectUrls.getAfterSignInUrl()).toBe(`${mockWindowLocation.href}prop-sign-in-fallback-redirect-url`);
112+
expect(redirectUrls.getAfterSignUpUrl()).toBe(`${mockWindowLocation.href}prop-sign-up-fallback-redirect-url`);
113+
});
114+
115+
it('prioritizes force even if props take priority over options', () => {
116+
const redirectUrls = new RedirectUrls(
117+
{
118+
signInForceRedirectUrl: 'sign-in-fallback-redirect-url',
119+
signUpForceRedirectUrl: 'sign-up-fallback-redirect-url',
120+
},
121+
{
122+
signInFallbackRedirectUrl: 'prop-sign-in-fallback-redirect-url',
123+
signUpFallbackRedirectUrl: 'prop-sign-up-fallback-redirect-url',
124+
},
125+
);
126+
expect(redirectUrls.getAfterSignInUrl()).toBe(`${mockWindowLocation.href}sign-in-fallback-redirect-url`);
127+
expect(redirectUrls.getAfterSignUpUrl()).toBe(`${mockWindowLocation.href}sign-up-fallback-redirect-url`);
128+
});
129+
130+
it('prioritizes searchParams over all else', () => {
131+
const redirectUrls = new RedirectUrls(
132+
{
133+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
134+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
135+
},
136+
{
137+
signInFallbackRedirectUrl: 'prop-sign-in-fallback-redirect-url',
138+
signUpFallbackRedirectUrl: 'prop-sign-up-fallback-redirect-url',
139+
},
140+
{
141+
sign_in_fallback_redirect_url: 'search-param-sign-in-fallback-redirect-url',
142+
sign_up_fallback_redirect_url: 'search-param-sign-up-fallback-redirect-url',
143+
},
144+
);
145+
expect(redirectUrls.getAfterSignInUrl()).toBe(
146+
`${mockWindowLocation.href}search-param-sign-in-fallback-redirect-url`,
147+
);
148+
expect(redirectUrls.getAfterSignUpUrl()).toBe(
149+
`${mockWindowLocation.href}search-param-sign-up-fallback-redirect-url`,
150+
);
151+
});
152+
153+
it('prioritizes force even if searchParams exist', () => {
154+
const redirectUrls = new RedirectUrls(
155+
{
156+
signInForceRedirectUrl: 'sign-in-force-redirect-url',
157+
signUpForceRedirectUrl: 'sign-up-force-redirect-url',
158+
},
159+
{
160+
signInFallbackRedirectUrl: 'prop-sign-in-fallback-redirect-url',
161+
signUpFallbackRedirectUrl: 'prop-sign-up-fallback-redirect-url',
162+
},
163+
{
164+
sign_in_fallback_redirect_url: 'search-param-sign-in-fallback-redirect-url',
165+
sign_up_fallback_redirect_url: 'search-param-sign-up-fallback-redirect-url',
166+
},
167+
);
168+
expect(redirectUrls.getAfterSignInUrl()).toBe(`${mockWindowLocation.href}sign-in-force-redirect-url`);
169+
expect(redirectUrls.getAfterSignUpUrl()).toBe(`${mockWindowLocation.href}sign-up-force-redirect-url`);
170+
});
171+
172+
it('prioritizes redirect_url from searchParamsover fallback urls', () => {
173+
const redirectUrls = new RedirectUrls(
174+
{
175+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
176+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
177+
},
178+
{
179+
signInFallbackRedirectUrl: 'prop-sign-in-fallback-redirect-url',
180+
signUpFallbackRedirectUrl: 'prop-sign-up-fallback-redirect-url',
181+
},
182+
{
183+
redirect_url: 'search-param-redirect-url',
184+
},
185+
);
186+
expect(redirectUrls.getAfterSignInUrl()).toBe(`${mockWindowLocation.href}search-param-redirect-url`);
187+
expect(redirectUrls.getAfterSignUpUrl()).toBe(`${mockWindowLocation.href}search-param-redirect-url`);
188+
});
189+
});
190+
191+
describe('search params', () => {
192+
it('appends only the preserved props', () => {
193+
const redirectUrls = new RedirectUrls(
194+
{
195+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
196+
},
197+
{
198+
signInFallbackRedirectUrl: 'props-sign-in-fallback-redirect-url',
199+
signInForceRedirectUrl: 'props-sign-in-force-redirect-url',
200+
},
201+
{
202+
sign_up_fallback_redirect_url: 'search-param-sign-up-fallback-redirect-url',
203+
redirect_url: 'search-param-redirect-url',
204+
},
205+
);
206+
207+
const params = redirectUrls.toSearchParams();
208+
expect([...params.keys()].length).toBe(1);
209+
expect(params.get('redirect_url')).toBe(`${mockWindowLocation.href}search-param-redirect-url`);
210+
});
211+
});
212+
213+
describe('append to url', () => {
214+
it('does not append redirect urls from options to the url if the url is same origin', () => {
215+
const redirectUrls = new RedirectUrls({
216+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
217+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
218+
});
219+
220+
const url = redirectUrls.appendPreservedPropsToUrl('https://www.clerk.com');
221+
expect(url).toBe('https://www.clerk.com/');
222+
});
223+
224+
it('appends redirect urls from options to the url if the url is cross origin', () => {
225+
const redirectUrls = new RedirectUrls({}, {}, { redirect_url: '/search-param-redirect-url' });
226+
227+
const url = redirectUrls.appendPreservedPropsToUrl('https://www.example.com');
228+
expect(url).toContain('search-param-redirect-url');
229+
});
230+
231+
it('overrides the existing search params', () => {
232+
const redirectUrls = new RedirectUrls(
233+
{
234+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
235+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
236+
},
237+
{},
238+
{ redirect_url: '/search-param-redirect-url' },
239+
);
240+
241+
const url = redirectUrls.appendPreservedPropsToUrl('https://www.example.com?redirect_url=existing');
242+
expect(url).toBe(
243+
'https://www.example.com/?redirect_url=existing#/?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fsearch-param-redirect-url',
244+
);
245+
});
246+
247+
it('appends redirect urls from props to the url even if the url is same origin', () => {
248+
const redirectUrls = new RedirectUrls({}, {}, { redirect_url: '/search-param-redirect-url' });
249+
250+
const url = redirectUrls.appendPreservedPropsToUrl('https://www.clerk.com');
251+
expect(url).toContain('search-param-redirect-url');
252+
});
253+
254+
it('does not append redirect urls from props to the url if the url is same origin if they match the options urls', () => {
255+
const redirectUrls = new RedirectUrls(
256+
{
257+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
258+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
259+
},
260+
{
261+
signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url',
262+
signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url',
263+
},
264+
);
265+
266+
const url = redirectUrls.appendPreservedPropsToUrl('https://www.clerk.com');
267+
expect(url).toBe('https://www.clerk.com/');
268+
});
269+
});
270+
});

‎packages/clerk-js/src/utils/__tests__/url.test.ts

+47-3
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import {
1313
isDataUri,
1414
isDevAccountPortalOrigin,
1515
isRedirectForFAPIInitiatedFlow,
16+
isRelativeUrl,
1617
isValidUrl,
1718
mergeFragmentIntoUrl,
19+
relativeToAbsoluteUrl,
1820
requiresUserInput,
1921
trimLeadingSlash,
2022
trimTrailingSlash,
@@ -59,16 +61,42 @@ describe('isValidUrl(url)', () => {
5961
});
6062

6163
describe('isValidUrl(url,base)', () => {
64+
const cases: Array<[string, boolean]> = [
65+
['', false],
66+
['/', false],
67+
['/test', false],
68+
['/test?clerk=false', false],
69+
['/?clerk=false', false],
70+
['https://www.clerk.com/', true],
71+
['https://www.clerk.com/?test=clerk', true],
72+
['https://www.clerk.com', true],
73+
['https://clerk.com', true],
74+
['https://clerk.com#test', true],
75+
['www.clerk.com/', false],
76+
['www.clerk.com', false],
77+
['www.clerk', false],
78+
['clerk.com', false],
79+
['clerk.com?clerk=yes', false],
80+
['clerk.com#/?clerk=yes', false],
81+
];
82+
83+
test.each(cases)('.isValidUrl(%s,%s)', (a, expected) => {
84+
expect(isValidUrl(a)).toBe(expected);
85+
});
86+
});
87+
88+
describe('isRelativeUrl(url,base)', () => {
6289
const cases: Array<[string, boolean]> = [
6390
['', true],
6491
['/', true],
6592
['/test', true],
6693
['/test?clerk=true', true],
6794
['/?clerk=true', true],
95+
['https://www.clerk.com/', false],
6896
];
6997

70-
test.each(cases)('.isValidUrl(%s,%s)', (a, expected) => {
71-
expect(isValidUrl(a, { includeRelativeUrls: true })).toBe(expected);
98+
test.each(cases)('.isRelativeUrl(%s,%s)', (a, expected) => {
99+
expect(isRelativeUrl(a)).toBe(expected);
72100
});
73101
});
74102

@@ -363,6 +391,7 @@ describe('mergeFragmentIntoUrl(url | string)', () => {
363391
['https://test.test/foo?a=a&b=b#/bar?c=c', new URL('https://test.test/foo/bar?a=a&b=b&c=c')],
364392
['https://test.test?a=a#/?a=b', new URL('https://test.test?a=b')],
365393
['https://test.test/en-US/sign-in#/?a=b', new URL('https://test.test/en-US/sign-in?a=b')],
394+
['https://test.test/en-US/sign-in?a=c#/?a=b', new URL('https://test.test/en-US/sign-in?a=b')],
366395
];
367396

368397
test.each(testCases)('url=(%s), expected value=(%s)', (url, expectedParamValue) => {
@@ -457,7 +486,7 @@ describe('isAllowedRedirectOrigin', () => {
457486
afterAll(() => warnMock.mockRestore());
458487

459488
test.each(cases)('isAllowedRedirectOrigin("%s","%s") === %s', (url, allowedOrigins, expected) => {
460-
expect(isAllowedRedirectOrigin(url, allowedOrigins)).toEqual(expected);
489+
expect(isAllowedRedirectOrigin(allowedOrigins)(url)).toEqual(expected);
461490
expect(warnMock).toHaveBeenCalledTimes(Number(!expected)); // Number(boolean) evaluates to 0 or 1
462491
});
463492
});
@@ -491,3 +520,18 @@ describe('createAllowedRedirectOrigins', () => {
491520
expect(allowedRedirectOriginsValues).toEqual(['https://test.host', 'https://*.test.host']);
492521
});
493522
});
523+
524+
describe('relativeToAbsoluteUrl', () => {
525+
const cases: [string, string, string][] = [
526+
['https://www.clerk.com', '/test', 'https://www.clerk.com/test'],
527+
['https://www.clerk.com', 'test', 'https://www.clerk.com/test'],
528+
['https://www.clerk.com/', '/test', 'https://www.clerk.com/test'],
529+
['https://www.clerk.com/', 'test', 'https://www.clerk.com/test'],
530+
['https://www.clerk.com', 'https://www.clerk.com/test', 'https://www.clerk.com/test'],
531+
['https://www.clerk.com', 'https://www.google.com/test', 'https://www.google.com/test'],
532+
];
533+
534+
test.each(cases)('relativeToAbsoluteUrl(%s, %s) === %s', (origin, relative, expected) => {
535+
expect(relativeToAbsoluteUrl(relative, origin)).toEqual(expected);
536+
});
537+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function assertNoLegacyProp(props: Record<string, any>) {
2+
const legacyProps = ['redirectUrl', 'afterSignInUrl', 'afterSignUpUrl'];
3+
const legacyProp = Object.keys(props).find(key => legacyProps.includes(key));
4+
if (legacyProp) {
5+
throw new Error(
6+
`Clerk: The prop "${legacyProp}" is deprecated and should be removed. Use the "afterSignInUrl" and "afterSignUpUrl" props instead.`,
7+
);
8+
}
9+
}

‎packages/clerk-js/src/utils/authPropHelpers.ts

-133
This file was deleted.

‎packages/clerk-js/src/utils/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export * from './web3';
2323
export * from './windowNavigate';
2424
export * from './componentGuards';
2525
export * from './queryStateParams';
26-
export * from './authPropHelpers';
26+
export * from './normalizeRoutingOptions';
2727
export * from './image';
2828
export * from './captcha';
2929
export * from './completeSignUpFlow';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { RoutingOptions, RoutingStrategy } from '@clerk/types';
2+
3+
import { clerkInvalidRoutingStrategy } from '../core/errors';
4+
5+
export const normalizeRoutingOptions = ({
6+
routing,
7+
path,
8+
}: {
9+
routing?: RoutingStrategy;
10+
path?: string;
11+
}): RoutingOptions => {
12+
if (!!path && !routing) {
13+
return { routing: 'path', path };
14+
}
15+
16+
if (routing !== 'path' && !!path) {
17+
return clerkInvalidRoutingStrategy(routing);
18+
}
19+
20+
return { routing, path } as RoutingOptions;
21+
};
+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { applyFunctionToObj, filterProps, removeUndefined } from '@clerk/shared/object';
2+
import { camelToSnake } from '@clerk/shared/underscore';
3+
import type { ClerkOptions, RedirectOptions } from '@clerk/types';
4+
import type { ParsedQs } from 'qs';
5+
6+
import { buildURL, isAllowedRedirectOrigin, relativeToAbsoluteUrl } from './url';
7+
8+
export class RedirectUrls {
9+
private static keys: (keyof RedirectOptions)[] = [
10+
'signInForceRedirectUrl',
11+
'signInFallbackRedirectUrl',
12+
'signUpForceRedirectUrl',
13+
'signUpFallbackRedirectUrl',
14+
];
15+
16+
private static preserved = ['redirectUrl'];
17+
18+
private readonly options: ClerkOptions;
19+
private readonly fromOptions: RedirectOptions;
20+
private readonly fromProps: RedirectOptions;
21+
private readonly fromSearchParams: RedirectOptions & { redirectUrl?: string | null };
22+
23+
constructor(options: ClerkOptions, props: RedirectOptions = {}, searchParams: any = {}) {
24+
this.options = options;
25+
this.fromOptions = this.#parse(options || {});
26+
this.fromProps = this.#parse(props || {});
27+
this.fromSearchParams = this.#parseSearchParams(searchParams || {});
28+
}
29+
30+
getAfterSignInUrl() {
31+
return this.#getRedirectUrl('signIn');
32+
}
33+
34+
getAfterSignUpUrl() {
35+
return this.#getRedirectUrl('signUp');
36+
}
37+
38+
toSearchParams() {
39+
return this.#toSearchParams(this.#flattenPreservedProps());
40+
}
41+
42+
appendPreservedPropsToUrl(url: string, _otherParams: ParsedQs = {}) {
43+
const params = new URLSearchParams();
44+
const otherParams = Object.entries(_otherParams);
45+
const redirectParams = [...this.#toSearchParams(this.#flattenPreservedProps()).entries()];
46+
// merge with existing search params, if any
47+
// redirect params should always win
48+
[otherParams, redirectParams].flat().forEach(([key, value]) => {
49+
typeof value === 'string' && params.set(key, value);
50+
});
51+
52+
// TODO: A potential future improvement here is to remove the origin from the params we append
53+
// if `url` and the param share the same origin
54+
return buildURL({ base: url, hashSearch: params.toString() }, { stringify: true });
55+
}
56+
57+
#toSearchParams(obj: Record<string, string | undefined | null>): URLSearchParams {
58+
const camelCased = Object.fromEntries(Object.entries(obj).map(([key, value]) => [camelToSnake(key), value]));
59+
return new URLSearchParams(removeUndefined(camelCased) as Record<string, string>);
60+
}
61+
62+
#flattenPreservedProps() {
63+
return Object.fromEntries(
64+
Object.entries({ ...this.fromSearchParams }).filter(([key]) => RedirectUrls.preserved.includes(key)),
65+
);
66+
}
67+
68+
#getRedirectUrl(prefix: 'signIn' | 'signUp') {
69+
const forceKey = `${prefix}ForceRedirectUrl` as const;
70+
const fallbackKey = `${prefix}FallbackRedirectUrl` as const;
71+
let result;
72+
// Prioritize forceRedirectUrl
73+
result = this.fromSearchParams[forceKey] || this.fromProps[forceKey] || this.fromOptions[forceKey];
74+
// Try to get redirect_url that only allowed as a search param
75+
result ||= this.fromSearchParams.redirectUrl;
76+
// Otherwise, fallback to fallbackRedirectUrl
77+
result ||= this.fromSearchParams[fallbackKey] || this.fromProps[fallbackKey] || this.fromOptions[fallbackKey];
78+
return result || '/';
79+
}
80+
81+
#parse(obj: unknown) {
82+
const res = {} as RedirectOptions;
83+
RedirectUrls.keys.forEach(key => {
84+
// @ts-expect-error
85+
res[key] = obj[key];
86+
});
87+
return this.#toAbsoluteUrls(this.#filterOrigins(res));
88+
}
89+
90+
#parseSearchParams(obj: any) {
91+
const res = {} as typeof this.fromSearchParams;
92+
RedirectUrls.keys.forEach(key => {
93+
res[key] = obj[camelToSnake(key)];
94+
});
95+
res['redirectUrl'] = obj.redirect_url;
96+
return this.#toAbsoluteUrls(this.#filterOrigins(res));
97+
}
98+
99+
#toAbsoluteUrls(obj: RedirectOptions) {
100+
return applyFunctionToObj(obj, (url: string) => relativeToAbsoluteUrl(url, window.location.origin));
101+
}
102+
103+
#filterOrigins = (obj: RedirectOptions) => {
104+
return filterProps(obj, isAllowedRedirectOrigin(this.options?.allowedRedirectOrigins));
105+
};
106+
}

‎packages/clerk-js/src/utils/url.ts

+55-23
Original file line numberDiff line numberDiff line change
@@ -229,20 +229,38 @@ export function getSearchParameterFromHash({
229229
return dummyUrlForHash.searchParams.get(paramName);
230230
}
231231

232-
export function isValidUrl(val: unknown, opts?: { includeRelativeUrls?: boolean }): val is string {
233-
const { includeRelativeUrls = false } = opts || {};
234-
if (!val && !includeRelativeUrls) {
232+
export function isValidUrl(val: unknown): val is string {
233+
if (!val) {
235234
return false;
236235
}
237236

238237
try {
239-
new URL(val as string, includeRelativeUrls ? DUMMY_URL_BASE : undefined);
238+
new URL(val as string);
240239
return true;
241240
} catch (e) {
242241
return false;
243242
}
244243
}
245244

245+
export function relativeToAbsoluteUrl(url: string, origin: string | URL): string {
246+
if (isValidUrl(url)) {
247+
return url;
248+
}
249+
return new URL(url, origin).href;
250+
}
251+
252+
export function isRelativeUrl(val: unknown): val is string {
253+
if (val !== val && !val) {
254+
return false;
255+
}
256+
try {
257+
const temp = new URL(val as string, DUMMY_URL_BASE);
258+
return temp.origin === DUMMY_URL_BASE;
259+
} catch (e) {
260+
return false;
261+
}
262+
}
263+
246264
export function isDataUri(val?: string): val is string {
247265
if (!isValidUrl(val)) {
248266
return false;
@@ -328,28 +346,29 @@ export function requiresUserInput(redirectUrl: string): boolean {
328346
return frontendApiRedirectPathsWithUserInput.includes(url.pathname);
329347
}
330348

331-
export const isAllowedRedirectOrigin = (_url: string, allowedRedirectOrigins: Array<string | RegExp> | undefined) => {
332-
if (!allowedRedirectOrigins) {
333-
return true;
334-
}
349+
export const isAllowedRedirectOrigin =
350+
(allowedRedirectOrigins: Array<string | RegExp> | undefined) => (_url: string) => {
351+
if (!allowedRedirectOrigins) {
352+
return true;
353+
}
335354

336-
const url = new URL(_url, DUMMY_URL_BASE);
337-
const isRelativeUrl = url.origin === DUMMY_URL_BASE;
338-
if (isRelativeUrl) {
339-
return true;
340-
}
355+
const url = new URL(_url, DUMMY_URL_BASE);
356+
const isRelativeUrl = url.origin === DUMMY_URL_BASE;
357+
if (isRelativeUrl) {
358+
return true;
359+
}
341360

342-
const isAllowed = allowedRedirectOrigins
343-
.map(origin => (typeof origin === 'string' ? globs.toRegexp(trimTrailingSlash(origin)) : origin))
344-
.some(origin => origin.test(trimTrailingSlash(url.origin)));
361+
const isAllowed = allowedRedirectOrigins
362+
.map(origin => (typeof origin === 'string' ? globs.toRegexp(trimTrailingSlash(origin)) : origin))
363+
.some(origin => origin.test(trimTrailingSlash(url.origin)));
345364

346-
if (!isAllowed) {
347-
console.warn(
348-
`Clerk: Redirect URL ${url} is not on one of the allowedRedirectOrigins, falling back to the default redirect URL.`,
349-
);
350-
}
351-
return isAllowed;
352-
};
365+
if (!isAllowed) {
366+
console.warn(
367+
`Clerk: Redirect URL ${url} is not on one of the allowedRedirectOrigins, falling back to the default redirect URL.`,
368+
);
369+
}
370+
return isAllowed;
371+
};
353372

354373
export function createAllowedRedirectOrigins(
355374
allowedRedirectOrigins: Array<string | RegExp> | undefined,
@@ -369,3 +388,16 @@ export function createAllowedRedirectOrigins(
369388

370389
return origins;
371390
}
391+
392+
export const isCrossOrigin = (url: string | URL, origin: string | URL = window.location.origin): boolean => {
393+
try {
394+
if (isRelativeUrl(url)) {
395+
return false;
396+
}
397+
const urlOrigin = new URL(url).origin;
398+
const originOrigin = new URL(origin).origin;
399+
return urlOrigin !== originOrigin;
400+
} catch (e) {
401+
return false;
402+
}
403+
};

‎packages/nextjs/src/global.d.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@ declare global {
1515
NEXT_PUBLIC_CLERK_PROXY_URL: string | undefined;
1616
NEXT_PUBLIC_CLERK_SIGN_IN_URL: string | undefined;
1717
NEXT_PUBLIC_CLERK_SIGN_UP_URL: string | undefined;
18-
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL: string | undefined;
19-
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL: string | undefined;
18+
NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL: string | undefined;
19+
NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL: string | undefined;
20+
NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL: string | undefined;
21+
NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL: string | undefined;
2022
}
2123
}
2224
}

‎packages/nextjs/src/utils/mergeNextClerkPropsWithEnv.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ export const mergeNextClerkPropsWithEnv = (props: Omit<NextClerkProviderProps, '
1515
isSatellite: props.isSatellite || isTruthy(process.env.NEXT_PUBLIC_CLERK_IS_SATELLITE),
1616
signInUrl: props.signInUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL || '',
1717
signUpUrl: props.signUpUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL || '',
18-
afterSignInUrl: props.afterSignInUrl || process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL || '',
19-
afterSignUpUrl: props.afterSignUpUrl || process.env.NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL || '',
18+
signInForceRedirectUrl:
19+
props.signInForceRedirectUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL || '',
20+
signUpForceRedirectUrl:
21+
props.signUpForceRedirectUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL || '',
22+
signInFallbackRedirectUrl:
23+
props.signInFallbackRedirectUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL || '',
24+
signUpFallbackRedirectUrl:
25+
props.signUpFallbackRedirectUrl || process.env.NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL || '',
2026
telemetry: props.telemetry ?? {
2127
disabled: isTruthy(process.env.NEXT_PUBLIC_CLERK_TELEMETRY_DISABLED),
2228
debug: isTruthy(process.env.NEXT_PUBLIC_CLERK_TELEMETRY_DEBUG),

‎packages/react/src/components/SignInButton.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../ut
55
import { withClerk } from './withClerk';
66

77
export const SignInButton = withClerk(({ clerk, children, ...props }: WithClerkProp<SignInButtonProps>) => {
8-
const { afterSignInUrl, afterSignUpUrl, redirectUrl, mode, ...rest } = props;
8+
const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } =
9+
props;
910

1011
children = normalizeWithDefaultValue(children, 'Sign in');
1112
const child = assertSingleChild(children)('SignInButton');
1213

1314
const clickHandler = () => {
14-
const opts = { afterSignInUrl, afterSignUpUrl, redirectUrl };
15+
const opts = { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl };
1516
if (mode === 'modal') {
1617
return clerk.openSignIn(opts);
1718
}

‎packages/react/src/components/SignInWithMetamaskButton.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const SignInWithMetamaskButton = withClerk(
1515
// eslint-disable-next-line @typescript-eslint/require-await
1616
const clickHandler = async () => {
1717
async function authenticate() {
18-
await clerk.authenticateWithMetamask({ redirectUrl });
18+
await clerk.authenticateWithMetamask({ redirectUrl: redirectUrl || undefined });
1919
}
2020
void authenticate();
2121
};

‎packages/react/src/components/SignUpButton.tsx

+16-2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,27 @@ import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../ut
55
import { withClerk } from './withClerk';
66

77
export const SignUpButton = withClerk(({ clerk, children, ...props }: WithClerkProp<SignUpButtonProps>) => {
8-
const { afterSignInUrl, afterSignUpUrl, redirectUrl, mode, unsafeMetadata, ...rest } = props;
8+
const {
9+
fallbackRedirectUrl,
10+
forceRedirectUrl,
11+
signInFallbackRedirectUrl,
12+
signInForceRedirectUrl,
13+
mode,
14+
unsafeMetadata,
15+
...rest
16+
} = props;
917

1018
children = normalizeWithDefaultValue(children, 'Sign up');
1119
const child = assertSingleChild(children)('SignUpButton');
1220

1321
const clickHandler = () => {
14-
const opts = { afterSignInUrl, afterSignUpUrl, redirectUrl, unsafeMetadata };
22+
const opts = {
23+
fallbackRedirectUrl,
24+
forceRedirectUrl,
25+
signInFallbackRedirectUrl,
26+
signInForceRedirectUrl,
27+
unsafeMetadata,
28+
};
1529

1630
if (mode === 'modal') {
1731
return clerk.openSignUp(opts);

‎packages/react/src/components/__tests__/SignInButton.test.tsx

+9-9
Original file line numberDiff line numberDiff line change
@@ -46,34 +46,34 @@ describe('<SignInButton/>', () => {
4646
expect(mockRedirectToSignIn).toHaveBeenCalled();
4747
});
4848

49-
it('handles redirectUrl prop', async () => {
50-
render(<SignInButton redirectUrl={url} />);
49+
it('handles forceRedirectUrl prop', async () => {
50+
render(<SignInButton forceRedirectUrl={url} />);
5151

5252
const btn = screen.getByText('Sign in');
5353
await userEvent.click(btn);
5454

55-
expect(mockRedirectToSignIn).toHaveBeenCalledWith({ redirectUrl: url });
55+
expect(mockRedirectToSignIn).toHaveBeenCalledWith({ forceRedirectUrl: url });
5656
});
5757

58-
it('handles afterSignInUrl prop', async () => {
59-
render(<SignInButton afterSignInUrl={url} />);
58+
it('handles forceRedirectUrl prop', async () => {
59+
render(<SignInButton forceRedirectUrl={url} />);
6060

6161
const btn = screen.getByText('Sign in');
6262
await userEvent.click(btn);
6363

6464
expect(mockRedirectToSignIn).toHaveBeenCalledWith({
65-
afterSignInUrl: url,
65+
forceRedirectUrl: url,
6666
});
6767
});
6868

69-
it('handles afterSignUpUrl prop', async () => {
70-
render(<SignInButton afterSignUpUrl={url} />);
69+
it('handles signUpForceRedirectUrl prop', async () => {
70+
render(<SignInButton signUpForceRedirectUrl={url} />);
7171

7272
const btn = screen.getByText('Sign in');
7373
await userEvent.click(btn);
7474

7575
expect(mockRedirectToSignIn).toHaveBeenCalledWith({
76-
afterSignUpUrl: url,
76+
signUpForceRedirectUrl: url,
7777
});
7878
});
7979

‎packages/react/src/components/__tests__/SignUpButton.test.tsx

+9-9
Original file line numberDiff line numberDiff line change
@@ -48,33 +48,33 @@ describe('<SignUpButton/>', () => {
4848
});
4949
});
5050

51-
it('handles redirectUrl prop', async () => {
52-
render(<SignUpButton redirectUrl={url} />);
51+
it('handles forceRedirectUrl prop', async () => {
52+
render(<SignUpButton forceRedirectUrl={url} />);
5353
const btn = screen.getByText('Sign up');
5454
userEvent.click(btn);
5555
await waitFor(() => {
56-
expect(mockRedirectToSignUp).toHaveBeenCalledWith({ redirectUrl: url });
56+
expect(mockRedirectToSignUp).toHaveBeenCalledWith({ forceRedirectUrl: url });
5757
});
5858
});
5959

60-
it('handles afterSignUpUrl prop', async () => {
61-
render(<SignUpButton afterSignUpUrl={url} />);
60+
it('handles forceRedirectUrl prop', async () => {
61+
render(<SignUpButton forceRedirectUrl={url} />);
6262
const btn = screen.getByText('Sign up');
6363
userEvent.click(btn);
6464
await waitFor(() => {
6565
expect(mockRedirectToSignUp).toHaveBeenCalledWith({
66-
afterSignUpUrl: url,
66+
forceRedirectUrl: url,
6767
});
6868
});
6969
});
7070

71-
it('handles afterSignUpUrl prop', async () => {
72-
render(<SignUpButton afterSignUpUrl={url} />);
71+
it('handles forceRedirectUrl prop', async () => {
72+
render(<SignUpButton forceRedirectUrl={url} />);
7373
const btn = screen.getByText('Sign up');
7474
userEvent.click(btn);
7575
await waitFor(() => {
7676
expect(mockRedirectToSignUp).toHaveBeenCalledWith({
77-
afterSignUpUrl: url,
77+
forceRedirectUrl: url,
7878
});
7979
});
8080
});

‎packages/react/src/types.ts

+15-7
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import type {
66
InitialState,
77
LoadedClerk,
88
MultiDomainAndOrProxy,
9+
RedirectUrlProp,
910
SDKMetadata,
11+
SignInProps,
1012
SignInRedirectOptions,
13+
SignUpProps,
1114
SignUpRedirectOptions,
1215
Without,
1316
} from '@clerk/types';
@@ -74,20 +77,25 @@ export type ClerkProp =
7477
| null;
7578

7679
type ButtonProps = {
77-
afterSignInUrl?: string;
78-
afterSignUpUrl?: string;
79-
redirectUrl?: string;
8080
mode?: 'redirect' | 'modal';
8181
children?: React.ReactNode;
8282
};
8383

84-
export type SignInButtonProps = ButtonProps;
84+
export type SignInButtonProps = ButtonProps &
85+
Pick<
86+
SignInProps,
87+
'fallbackRedirectUrl' | 'forceRedirectUrl' | 'signUpForceRedirectUrl' | 'signUpFallbackRedirectUrl'
88+
>;
8589

86-
export interface SignUpButtonProps extends ButtonProps {
90+
export type SignUpButtonProps = {
8791
unsafeMetadata?: SignUpUnsafeMetadata;
88-
}
92+
} & ButtonProps &
93+
Pick<
94+
SignUpProps,
95+
'fallbackRedirectUrl' | 'forceRedirectUrl' | 'signInForceRedirectUrl' | 'signInFallbackRedirectUrl'
96+
>;
8997

90-
export type SignInWithMetamaskButtonProps = Pick<ButtonProps, 'redirectUrl' | 'children'>;
98+
export type SignInWithMetamaskButtonProps = ButtonProps & RedirectUrlProp;
9199

92100
export type RedirectToSignInProps = SignInRedirectOptions;
93101
export type RedirectToSignUpProps = SignUpRedirectOptions;

‎packages/shared/src/object.ts

+30
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,33 @@ export const without = <T extends object, P extends keyof T>(obj: T, ...props: P
55
}
66
return copy;
77
};
8+
9+
export const removeUndefined = <T extends object>(obj: T): Partial<T> => {
10+
return Object.entries(obj).reduce((acc, [key, value]) => {
11+
if (value !== undefined && value !== null) {
12+
acc[key as keyof T] = value;
13+
}
14+
return acc;
15+
}, {} as Partial<T>);
16+
};
17+
18+
export const applyFunctionToObj = <T extends Record<string, any>, R>(
19+
obj: T,
20+
fn: (val: any, key: string) => R,
21+
): Record<string, R> => {
22+
const result = {} as Record<string, R>;
23+
for (const key in obj) {
24+
result[key] = fn(obj[key], key);
25+
}
26+
return result;
27+
};
28+
29+
export const filterProps = <T extends Record<string, any>>(obj: T, filter: (a: any) => boolean): T => {
30+
const result = {} as T;
31+
for (const key in obj) {
32+
if (obj[key] && filter(obj[key])) {
33+
result[key] = obj[key];
34+
}
35+
}
36+
return result;
37+
};

‎packages/types/src/clerk.ts

+126-96
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ import type { LocalizationResource } from './localization';
1616
import type { OAuthProvider, OAuthScope } from './oauth';
1717
import type { OrganizationResource } from './organization';
1818
import type { OrganizationCustomRoleKey } from './organizationMembership';
19+
import type {
20+
AfterSignOutUrl,
21+
RedirectOptions,
22+
RedirectUrlProp,
23+
SignInFallbackRedirectUrl,
24+
SignInForceRedirectUrl,
25+
SignUpFallbackRedirectUrl,
26+
SignUpForceRedirectUrl,
27+
} from './redirects';
1928
import type { ActiveSessionResource } from './session';
2029
import type { UserResource } from './user';
2130
import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils';
@@ -466,57 +475,46 @@ export interface Clerk {
466475
handleUnauthenticated: () => Promise<unknown>;
467476
}
468477

469-
export type HandleOAuthCallbackParams = AfterActionURLs & {
470-
/**
471-
* Full URL or path where the SignIn component is mounted.
472-
*/
473-
signInUrl?: string;
474-
/**
475-
* Full URL or path where the SignUp component is mounted.
476-
*/
477-
signUpUrl?: string;
478-
/**
479-
* Full URL or path to navigate after successful sign in
480-
* or sign up.
481-
*
482-
* The same as setting afterSignInUrl and afterSignUpUrl
483-
* to the same value.
484-
*/
485-
redirectUrl?: string | null;
486-
487-
/**
488-
* Full URL or path to navigate during sign in,
489-
* if identifier verification is required.
490-
*/
491-
firstFactorUrl?: string;
492-
493-
/**
494-
* Full URL or path to navigate during sign in,
495-
* if 2FA is enabled.
496-
*/
497-
secondFactorUrl?: string;
498-
499-
/**
500-
* Full URL or path to navigate during sign in,
501-
* if the user is required to reset their password.
502-
*/
503-
resetPasswordUrl?: string;
504-
505-
/**
506-
* Full URL or path to navigate after an incomplete sign up.
507-
*/
508-
continueSignUpUrl?: string | null;
509-
510-
/**
511-
* Full URL or path to navigate after requesting email verification.
512-
*/
513-
verifyEmailAddressUrl?: string | null;
514-
515-
/**
516-
* Full URL or path to navigate after requesting phone verification.
517-
*/
518-
verifyPhoneNumberUrl?: string | null;
519-
};
478+
export type HandleOAuthCallbackParams = SignInForceRedirectUrl &
479+
SignInFallbackRedirectUrl &
480+
SignUpForceRedirectUrl &
481+
SignUpFallbackRedirectUrl & {
482+
/**
483+
* Full URL or path where the SignIn component is mounted.
484+
*/
485+
signInUrl?: string;
486+
/**
487+
* Full URL or path where the SignUp component is mounted.
488+
*/
489+
signUpUrl?: string;
490+
/**
491+
* Full URL or path to navigate during sign in,
492+
* if identifier verification is required.
493+
*/
494+
firstFactorUrl?: string;
495+
/**
496+
* Full URL or path to navigate during sign in,
497+
* if 2FA is enabled.
498+
*/
499+
secondFactorUrl?: string;
500+
/**
501+
* Full URL or path to navigate during sign in,
502+
* if the user is required to reset their password.
503+
*/
504+
resetPasswordUrl?: string;
505+
/**
506+
* Full URL or path to navigate after an incomplete sign up.
507+
*/
508+
continueSignUpUrl?: string | null;
509+
/**
510+
* Full URL or path to navigate after requesting email verification.
511+
*/
512+
verifyEmailAddressUrl?: string | null;
513+
/**
514+
* Full URL or path to navigate after requesting phone verification.
515+
*/
516+
verifyPhoneNumberUrl?: string | null;
517+
};
520518

521519
export type HandleSamlCallbackParams = HandleOAuthCallbackParams;
522520

@@ -541,7 +539,11 @@ type ClerkOptionsNavigation =
541539
};
542540

543541
export type ClerkOptions = ClerkOptionsNavigation &
544-
AfterActionURLs & {
542+
SignInForceRedirectUrl &
543+
SignInFallbackRedirectUrl &
544+
SignUpForceRedirectUrl &
545+
SignUpFallbackRedirectUrl &
546+
AfterSignOutUrl & {
545547
appearance?: Appearance;
546548
localization?: LocalizationResource;
547549
polling?: boolean;
@@ -555,7 +557,6 @@ export type ClerkOptions = ClerkOptionsNavigation &
555557
signUpUrl?: string;
556558
allowedRedirectOrigins?: Array<string | RegExp>;
557559
isSatellite?: boolean | ((url: URL) => boolean);
558-
559560
/**
560561
* Telemetry options
561562
*/
@@ -628,48 +629,21 @@ export type SignUpInitialValues = {
628629
username?: string;
629630
};
630631

631-
type AfterActionURLs = {
632-
/**
633-
* Full URL or path to navigate after successful sign in.
634-
*/
635-
afterSignInUrl?: string | null;
636-
637-
/**
638-
* Full URL or path to navigate after successful sign up.
639-
* Sets the afterSignUpUrl if the "Sign up" link is clicked.
640-
*/
641-
afterSignUpUrl?: string | null;
642-
643-
/**
644-
* Full URL or path to navigate after successful sign out.
645-
*/
646-
afterSignOutUrl?: string | null;
647-
};
648-
649-
export type RedirectOptions = AfterActionURLs & {
650-
/**
651-
* Full URL or path to navigate after successful sign in,
652-
* or sign up.
653-
*
654-
* The same as setting afterSignInUrl and afterSignUpUrl
655-
* to the same value.
656-
*/
657-
redirectUrl?: string | null;
658-
};
659-
660-
export type SignInRedirectOptions = RedirectOptions & {
661-
/**
662-
* Initial values that are used to prefill the sign in form.
663-
*/
664-
initialValues?: SignInInitialValues;
665-
};
632+
export type SignInRedirectOptions = RedirectOptions &
633+
RedirectUrlProp & {
634+
/**
635+
* Initial values that are used to prefill the sign in form.
636+
*/
637+
initialValues?: SignInInitialValues;
638+
};
666639

667-
export type SignUpRedirectOptions = RedirectOptions & {
668-
/**
669-
* Initial values that are used to prefill the sign up form.
670-
*/
671-
initialValues?: SignUpInitialValues;
672-
};
640+
export type SignUpRedirectOptions = RedirectOptions &
641+
RedirectUrlProp & {
642+
/**
643+
* Initial values that are used to prefill the sign up form.
644+
*/
645+
initialValues?: SignUpInitialValues;
646+
};
673647

674648
export type SetActiveParams = {
675649
/**
@@ -698,6 +672,34 @@ export type RoutingOptions =
698672
| { path?: never; routing?: Extract<RoutingStrategy, 'hash' | 'virtual'> };
699673

700674
export type SignInProps = RoutingOptions & {
675+
/**
676+
* Full URL or path to navigate after successful sign in.
677+
* This value has precedence over other redirect props, environment variables or search params.
678+
* Use this prop to override the redirect URL when needed.
679+
* @default undefined
680+
*/
681+
forceRedirectUrl?: string | null;
682+
/**
683+
* Full URL or path to navigate after successful sign in.
684+
* This value is used when no other redirect props, environment variables or search params are present.
685+
* @default undefined
686+
*/
687+
fallbackRedirectUrl?: string | null;
688+
/**
689+
* Full URL or path to navigate after successful sign up, triggered through the <SignIn/> component,
690+
* for example, when the user clicks the "Sign up" link or signs up using OAuth.
691+
* This value has precedence over other redirect props, environment variables or search params.
692+
* Use this prop to override the redirect URL when needed.
693+
* @default undefined
694+
*/
695+
signUpForceRedirectUrl?: string | null;
696+
/**
697+
* Full URL or path to navigate after successful sign up, triggered through the <SignIn/> component,
698+
* for example, when the user clicks the "Sign up" link or signs up using OAuth.
699+
* This value is used when no other redirect props, environment variables or search params are present.
700+
* @default undefined
701+
*/
702+
signUpFallbackRedirectUrl?: string | null;
701703
/**
702704
* Full URL or path to for the sign up process.
703705
* Used to fill the "Sign up" link in the SignUp component.
@@ -713,7 +715,7 @@ export type SignInProps = RoutingOptions & {
713715
* Initial values that are used to prefill the sign in form.
714716
*/
715717
initialValues?: SignInInitialValues;
716-
} & RedirectOptions;
718+
} & AfterSignOutUrl;
717719

718720
export type SignInModalProps = WithoutRouting<SignInProps>;
719721

@@ -723,6 +725,34 @@ export type OneTapProps = {
723725
};
724726

725727
export type SignUpProps = RoutingOptions & {
728+
/**
729+
* Full URL or path to navigate after successful sign up.
730+
* This value has precedence over other redirect props, environment variables or search params.
731+
* Use this prop to override the redirect URL when needed.
732+
* @default undefined
733+
*/
734+
forceRedirectUrl?: string | null;
735+
/**
736+
* Full URL or path to navigate after successful sign up.
737+
* This value is used when no other redirect props, environment variables or search params are present.
738+
* @default undefined
739+
*/
740+
fallbackRedirectUrl?: string | null;
741+
/**
742+
* Full URL or path to navigate after successful sign up, triggered through the <SignUp/> component,
743+
* for example, when the user clicks the "Sign in" link or signs up using OAuth.
744+
* This value has precedence over other redirect props, environment variables or search params.
745+
* Use this prop to override the redirect URL when needed.
746+
* @default undefined
747+
*/
748+
signInForceRedirectUrl?: string | null;
749+
/**
750+
* Full URL or path to navigate after successful sign up, triggered through the <SignUp/> component,
751+
* for example, when the user clicks the "Sign in" link or signs up using OAuth.
752+
* This value is used when no other redirect props, environment variables or search params are present.
753+
* @default undefined
754+
*/
755+
signInFallbackRedirectUrl?: string | null;
726756
/**
727757
* Full URL or path to for the sign in process.
728758
* Used to fill the "Sign in" link in the SignUp component.
@@ -743,7 +773,7 @@ export type SignUpProps = RoutingOptions & {
743773
* Initial values that are used to prefill the sign up form.
744774
*/
745775
initialValues?: SignUpInitialValues;
746-
} & RedirectOptions;
776+
} & AfterSignOutUrl;
747777

748778
export type SignUpModalProps = WithoutRouting<SignUpProps>;
749779

‎packages/types/src/redirects.ts

+61
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import type { OAuthStrategy, SamlStrategy } from './strategies';
22

3+
export type AfterSignOutUrl = {
4+
/**
5+
* Full URL or path to navigate after successful sign out.
6+
*/
7+
afterSignOutUrl?: string | null;
8+
};
9+
10+
/**
11+
* Redirect URLs for different actions.
12+
* Mainly used to be used to type internal Clerk functions.
13+
*/
14+
export type RedirectOptions = SignInForceRedirectUrl &
15+
SignInFallbackRedirectUrl &
16+
SignUpForceRedirectUrl &
17+
SignUpFallbackRedirectUrl;
18+
319
export type AuthenticateWithRedirectParams = {
420
/**
521
* Full URL or path to the route that will complete the OAuth or SAML flow.
@@ -34,3 +50,48 @@ export type AuthenticateWithRedirectParams = {
3450
*/
3551
emailAddress?: string;
3652
};
53+
54+
export type RedirectUrlProp = {
55+
/**
56+
* Full URL or path to navigate after a successful action.
57+
*/
58+
redirectUrl?: string | null;
59+
};
60+
61+
export type SignUpForceRedirectUrl = {
62+
/**
63+
* Full URL or path to navigate after successful sign up.
64+
* This value has precedence over other redirect props, environment variables or search params.
65+
* Use this prop to override the redirect URL when needed.
66+
* @default undefined
67+
*/
68+
signUpForceRedirectUrl?: string | null;
69+
};
70+
71+
export type SignUpFallbackRedirectUrl = {
72+
/**
73+
* Full URL or path to navigate after successful sign up.
74+
* This value is used when no other redirect props, environment variables or search params are present.
75+
* @default undefined
76+
*/
77+
signUpFallbackRedirectUrl?: string | null;
78+
};
79+
80+
export type SignInFallbackRedirectUrl = {
81+
/**
82+
* Full URL or path to navigate after successful sign in.
83+
* This value is used when no other redirect props, environment variables or search params are present.
84+
* @default undefined
85+
*/
86+
signInFallbackRedirectUrl?: string | null;
87+
};
88+
89+
export type SignInForceRedirectUrl = {
90+
/**
91+
* Full URL or path to navigate after successful sign in.
92+
* This value has precedence over other redirect props, environment variables or search params.
93+
* Use this prop to override the redirect URL when needed.
94+
* @default undefined
95+
*/
96+
signInForceRedirectUrl?: string | null;
97+
};

0 commit comments

Comments
 (0)
Please sign in to comment.