diff --git a/.changeset/eighty-guests-change.md b/.changeset/eighty-guests-change.md new file mode 100644 index 00000000000..51a2432c1f9 --- /dev/null +++ b/.changeset/eighty-guests-change.md @@ -0,0 +1,9 @@ +--- +"@clerk/clerk-js": minor +"@clerk/types": minor +"@clerk/elements": patch +"@clerk/clerk-react": patch +--- + +- Introduce `redirectUrl` property on `setActive` as a replacement for `beforeEmit`. +- Deprecates `beforeEmit` property on `setActive`. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index bbea30c6217..25d5f8978a6 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -366,6 +366,65 @@ describe('Clerk singleton', () => { }); }); + it('redirects the user to the /v1/client/touch endpoint if the cookie_expires_at is less than 8 days away', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + activeSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now + isEligibleForTouch: () => true, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, redirectUrl: '/redirect-url-path' }); + const redirectUrl = new URL((sut.navigate as jest.Mock).mock.calls[0]); + expect(redirectUrl.pathname).toEqual('/v1/client/touch'); + expect(redirectUrl.searchParams.get('redirect_url')).toEqual(`${mockWindowLocation.href}/redirect-url-path`); + }); + + it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is more than 8 days away', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + activeSessions: [mockSession], + cookieExpiresAt: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000), // 10 days from now + isEligibleForTouch: () => false, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, redirectUrl: '/redirect-url-path' }); + expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + }); + + it('does not redirect the user to the /v1/client/touch endpoint if the cookie_expires_at is not set', async () => { + mockSession.touch.mockReturnValue(Promise.resolve()); + mockClientFetch.mockReturnValue( + Promise.resolve({ + activeSessions: [mockSession], + cookieExpiresAt: null, + isEligibleForTouch: () => false, + buildTouchUrl: () => + `https://clerk.example.com/v1/client/touch?redirect_url=${mockWindowLocation.href}/redirect-url-path`, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.navigate = jest.fn(); + await sut.load(); + await sut.setActive({ session: mockSession as any as ActiveSessionResource, redirectUrl: '/redirect-url-path' }); + expect(sut.navigate).toHaveBeenCalledWith('/redirect-url-path'); + }); + mockNativeRuntime(() => { it('calls session.touch in a non-standard browser', async () => { mockClientFetch.mockReturnValue(Promise.resolve({ activeSessions: [mockSession] })); @@ -496,6 +555,7 @@ describe('Clerk singleton', () => { expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function), + redirectUrl: '/', }); }); }); @@ -518,7 +578,11 @@ describe('Clerk singleton', () => { expect(mockClientDestroy).not.toHaveBeenCalled(); expect(mockClientRemoveSessions).toHaveBeenCalled(); expect(mockSession1.remove).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) }); + expect(sut.setActive).toHaveBeenCalledWith({ + session: null, + beforeEmit: expect.any(Function), + redirectUrl: '/', + }); }); }); @@ -561,7 +625,11 @@ describe('Clerk singleton', () => { await waitFor(() => { expect(mockSession1.remove).toHaveBeenCalled(); expect(mockClientDestroy).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) }); + expect(sut.setActive).toHaveBeenCalledWith({ + session: null, + beforeEmit: expect.any(Function), + redirectUrl: '/', + }); }); }); @@ -582,7 +650,11 @@ describe('Clerk singleton', () => { await waitFor(() => { expect(mockSession1.remove).toHaveBeenCalled(); expect(mockClientDestroy).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) }); + expect(sut.setActive).toHaveBeenCalledWith({ + session: null, + beforeEmit: expect.any(Function), + redirectUrl: '/after-sign-out', + }); expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out'); }); }); @@ -1096,6 +1168,16 @@ describe('Clerk singleton', () => { }); it('redirects the user to the signInForceRedirectUrl if one was provided', async () => { + const sessionId = 'sess_1yDceUR8SIKtQ0gIOO8fNsW7nhe'; + const mockSession = { + id: sessionId, + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; mockEnvironmentFetch.mockReturnValue( Promise.resolve({ authConfig: {}, @@ -1110,6 +1192,7 @@ describe('Clerk singleton', () => { mockClientFetch.mockReturnValue( Promise.resolve({ + sessions: [mockSession], activeSessions: [], signIn: new SignIn(null), signUp: new SignUp({ @@ -1124,24 +1207,19 @@ describe('Clerk singleton', () => { long_message: "You're already signed in", message: "You're already signed in", meta: { - session_id: 'sess_1yDceUR8SIKtQ0gIOO8fNsW7nhe', + session_id: sessionId, }, }, }, }, } as any as SignUpJSON), + isEligibleForTouch: () => false, }), ); - const mockSetActive = jest.fn(async (setActiveOpts: any) => { - await setActiveOpts.beforeEmit(); - }); - const sut = new Clerk(productionPublishableKey); await sut.load(mockedLoadOptions); - sut.setActive = mockSetActive as any; - - sut.handleRedirectCallback({ + await sut.handleRedirectCallback({ signInForceRedirectUrl: '/custom-sign-in', }); @@ -1151,6 +1229,16 @@ describe('Clerk singleton', () => { }); it('gives priority to signInForceRedirectUrl if signInForceRedirectUrl and signInFallbackRedirectUrl were provided ', async () => { + const sessionId = 'sess_1yDceUR8SIKtQ0gIOO8fNsW7nhe'; + const mockSession = { + id: sessionId, + remove: jest.fn(), + status: 'active', + user: {}, + touch: jest.fn(() => Promise.resolve()), + getToken: jest.fn(), + lastActiveToken: { getRawString: () => 'mocked-token' }, + }; mockEnvironmentFetch.mockReturnValue( Promise.resolve({ authConfig: {}, @@ -1165,6 +1253,7 @@ describe('Clerk singleton', () => { mockClientFetch.mockReturnValue( Promise.resolve({ + sessions: [mockSession], activeSessions: [], signIn: new SignIn(null), signUp: new SignUp({ @@ -1179,24 +1268,20 @@ describe('Clerk singleton', () => { long_message: "You're already signed in", message: "You're already signed in", meta: { - session_id: 'sess_1yDceUR8SIKtQ0gIOO8fNsW7nhe', + session_id: sessionId, }, }, }, }, } as any as SignUpJSON), + isEligibleForTouch: () => false, }), ); - const mockSetActive = jest.fn(async (setActiveOpts: any) => { - await setActiveOpts.beforeEmit(); - }); - const sut = new Clerk(productionPublishableKey); await sut.load(mockedLoadOptions); - sut.setActive = mockSetActive as any; - sut.handleRedirectCallback({ + await sut.handleRedirectCallback({ signInForceRedirectUrl: '/custom-sign-in', signInFallbackRedirectUrl: '/redirect-to', } as any); @@ -1654,7 +1739,7 @@ describe('Clerk singleton', () => { await waitFor(() => { expect(mockSetActive).toHaveBeenCalledWith({ session: createdSessionId, - beforeEmit: expect.any(Function), + redirectUrl: redirectUrlComplete, }); }); }); @@ -1714,7 +1799,7 @@ describe('Clerk singleton', () => { await waitFor(() => { expect(mockSetActive).toHaveBeenCalledWith({ session: createdSessionId, - beforeEmit: expect.any(Function), + redirectUrl: redirectUrlComplete, }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 5ed5a1034b7..99c9c905965 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -1,6 +1,7 @@ import { addClerkPrefix, ClerkRuntimeError, + deprecated, handleValueOrFn, inBrowser as inClientSide, is4xxError, @@ -354,6 +355,7 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: null, beforeEmit: ignoreEventValue(cb), + redirectUrl, }); } @@ -364,6 +366,7 @@ export class Clerk implements ClerkInterface { return this.setActive({ session: null, beforeEmit: ignoreEventValue(cb), + redirectUrl, }); } }; @@ -746,7 +749,7 @@ export class Clerk implements ClerkInterface { /** * `setActive` can be used to set the active session and/or organization. */ - public setActive = async ({ session, organization, beforeEmit }: SetActiveParams): Promise => { + public setActive = async ({ session, organization, beforeEmit, redirectUrl }: SetActiveParams): Promise => { if (!this.client) { throw new Error('setActive is being called before the client is loaded. Wait for init.'); } @@ -831,10 +834,26 @@ export class Clerk implements ClerkInterface { if (beforeEmit) { beforeUnloadTracker?.startTracking(); this.#setTransitiveState(); + deprecated('beforeEmit', 'Use the `redirectUrl` property instead.'); await beforeEmit(newSession); beforeUnloadTracker?.stopTracking(); } + if (redirectUrl) { + beforeUnloadTracker?.startTracking(); + this.#setTransitiveState(); + + if (this.client.isEligibleForTouch()) { + const absoluteRedirectUrl = new URL(redirectUrl, window.location.href); + + await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl }))); + } else { + await this.navigate(redirectUrl); + } + + beforeUnloadTracker?.stopTracking(); + } + //3. Check if hard reloading (onbeforeunload). If not, set the user/session and emit if (beforeUnloadTracker?.isUnloading()) { return; @@ -1105,13 +1124,12 @@ export class Clerk implements ClerkInterface { const navigate = (to: string) => customNavigate && typeof customNavigate === 'function' ? customNavigate(to) : this.navigate(to); - const redirectComplete = params.redirectUrlComplete ? () => navigate(params.redirectUrlComplete as string) : noop; const redirectContinue = params.redirectUrl ? () => navigate(params.redirectUrl as string) : noop; if (shouldCompleteOnThisDevice) { return this.setActive({ session: newSessionId, - beforeEmit: redirectComplete, + redirectUrl: params.redirectUrlComplete, }); } else if (shouldContinueOnThisDevice) { return redirectContinue(); @@ -1206,8 +1224,6 @@ export class Clerk implements ClerkInterface { ); const redirectUrls = new RedirectUrls(this.#options, params); - const navigateAfterSignIn = makeNavigate(redirectUrls.getAfterSignInUrl()); - const navigateAfterSignUp = makeNavigate(redirectUrls.getAfterSignUpUrl()); const navigateToContinueSignUp = makeNavigate( params.continueSignUpUrl || @@ -1240,7 +1256,7 @@ export class Clerk implements ClerkInterface { if (si.status === 'complete') { return this.setActive({ session: si.sessionId, - beforeEmit: navigateAfterSignIn, + redirectUrl: redirectUrls.getAfterSignInUrl(), }); } @@ -1253,7 +1269,7 @@ export class Clerk implements ClerkInterface { case 'complete': return this.setActive({ session: res.createdSessionId, - beforeEmit: navigateAfterSignIn, + redirectUrl: redirectUrls.getAfterSignInUrl(), }); case 'needs_first_factor': return navigateToFactorOne(); @@ -1301,7 +1317,7 @@ export class Clerk implements ClerkInterface { case 'complete': return this.setActive({ session: res.createdSessionId, - beforeEmit: navigateAfterSignUp, + redirectUrl: redirectUrls.getAfterSignUpUrl(), }); case 'missing_requirements': return navigateToNextStepSignUp({ missingFields: res.missingFields }); @@ -1313,7 +1329,7 @@ export class Clerk implements ClerkInterface { if (su.status === 'complete') { return this.setActive({ session: su.sessionId, - beforeEmit: navigateAfterSignUp, + redirectUrl: redirectUrls.getAfterSignUpUrl(), }); } @@ -1337,7 +1353,7 @@ export class Clerk implements ClerkInterface { if (sessionId) { return this.setActive({ session: sessionId, - beforeEmit: navigateAfterSignIn, + redirectUrl: redirectUrls.getAfterSignInUrl(), }); } } @@ -1459,12 +1475,7 @@ export class Clerk implements ClerkInterface { if (signInOrSignUp.createdSessionId) { await this.setActive({ session: signInOrSignUp.createdSessionId, - beforeEmit: () => { - if (redirectUrl) { - return navigate(redirectUrl); - } - return Promise.resolve(); - }, + redirectUrl, }); } }; diff --git a/packages/clerk-js/src/core/resources/Client.ts b/packages/clerk-js/src/core/resources/Client.ts index 28f0ccc8cc4..8e4ac8ed962 100644 --- a/packages/clerk-js/src/core/resources/Client.ts +++ b/packages/clerk-js/src/core/resources/Client.ts @@ -13,6 +13,7 @@ export class Client extends BaseResource implements ClientResource { signUp: SignUpResource = new SignUp(); signIn: SignInResource = new SignIn(); lastActiveSessionId: string | null = null; + cookieExpiresAt: Date | null = null; createdAt: Date | null = null; updatedAt: Date | null = null; @@ -61,6 +62,7 @@ export class Client extends BaseResource implements ClientResource { this.signUp = new SignUp(null); this.signIn = new SignIn(null); this.lastActiveSessionId = null; + this.cookieExpiresAt = null; this.createdAt = null; this.updatedAt = null; }); @@ -76,6 +78,22 @@ export class Client extends BaseResource implements ClientResource { return this.sessions.forEach(s => s.clearCache()); } + // isEligibleForTouch returns true if the client cookie is due to expire in 8 days or less + isEligibleForTouch(): boolean { + return !!this.cookieExpiresAt && this.cookieExpiresAt.getTime() - Date.now() <= 8 * 24 * 60 * 60 * 1000; // 8 days + } + + buildTouchUrl({ redirectUrl }: { redirectUrl: URL }) { + return BaseResource.fapiClient + .buildUrl({ + method: 'GET', + path: '/client/touch', + pathPrefix: 'v1', + search: { redirect_url: redirectUrl.toString() }, + }) + .toString(); + } + fromJSON(data: ClientJSON | null): this { if (data) { this.id = data.id; @@ -83,6 +101,7 @@ export class Client extends BaseResource implements ClientResource { this.signUp = new SignUp(data.sign_up); this.signIn = new SignIn(data.sign_in); this.lastActiveSessionId = data.last_active_session_id; + this.cookieExpiresAt = data.cookie_expires_at ? unixEpochToDate(data.cookie_expires_at) : null; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); } diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 1fbb9436e91..ca64e70c060 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -48,8 +48,9 @@ export const OrganizationSwitcherPopover = React.forwardRef setActive({ organization, - beforeEmit: () => navigateAfterSelectOrganization(organization), + redirectUrl: afterSelectOrganizationUrl(organization), }), ) .then(close); @@ -80,7 +81,7 @@ export const OrganizationSwitcherPopover = React.forwardRef { return card - .runAsync(() => setActive({ organization: null, beforeEmit: () => navigateAfterSelectPersonal(user) })) + .runAsync(() => setActive({ organization: null, redirectUrl: afterSelectPersonalUrl(user) })) .then(close); }; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx index 78b095b27bc..df1a14e22fb 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInAccountSwitcher.tsx @@ -10,12 +10,13 @@ import { useMultisessionActions } from '../UserButton/useMultisessionActions'; const _SignInAccountSwitcher = () => { const card = useCardState(); const { userProfileUrl } = useEnvironment().displayConfig; - const { navigateAfterSignIn, path: signInPath } = useSignInContext(); + const { navigateAfterSignIn, afterSignInUrl, path: signInPath } = useSignInContext(); const { navigateAfterSignOut } = useSignOutContext(); const { handleSignOutAllClicked, handleSessionClicked, activeSessions, handleAddAccountClicked } = useMultisessionActions({ navigateAfterSignOut, navigateAfterSwitchSession: navigateAfterSignIn, + afterSignInUrl, userProfileUrl, signInUrl: signInPath, user: undefined, diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx index fb7f8bdd1e5..523d6657a12 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneCodeForm.tsx @@ -32,7 +32,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => const signIn = useCoreSignIn(); const card = useCardState(); const { navigate } = useRouter(); - const { navigateAfterSignIn } = useSignInContext(); + const { afterSignInUrl } = useSignInContext(); const { setActive } = useClerk(); const supportEmail = useSupportEmail(); const clerk = useClerk(); @@ -62,7 +62,7 @@ export const SignInFactorOneCodeForm = (props: SignInFactorOneCodeFormProps) => switch (res.status) { case 'complete': - return setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignIn }); + return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); case 'needs_second_factor': return navigate('../factor-two'); case 'needs_new_password': diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx index a8f9609733c..f9275a02837 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -27,7 +27,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const signInContext = useSignInContext(); const { signInUrl } = signInContext; const { navigate } = useRouter(); - const { navigateAfterSignIn } = useSignInContext(); + const { afterSignInUrl } = useSignInContext(); const { setActive } = useClerk(); const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signIn); const [showVerifyModal, setShowVerifyModal] = React.useState(false); @@ -73,7 +73,7 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard if (si.status === 'complete') { return setActive({ session: si.createdSessionId, - beforeEmit: navigateAfterSignIn, + redirectUrl: afterSignInUrl, }); } else if (si.status === 'needs_second_factor') { return navigate('../factor-two'); diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx index 72db6b6891a..9d79266f12a 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorOnePasswordCard.tsx @@ -49,7 +49,7 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) const card = useCardState(); const { setActive } = useClerk(); const signIn = useCoreSignIn(); - const { navigateAfterSignIn } = useSignInContext(); + const { afterSignInUrl } = useSignInContext(); const supportEmail = useSupportEmail(); const passwordControl = usePasswordControl(props); const { navigate } = useRouter(); @@ -68,7 +68,7 @@ export const SignInFactorOnePasswordCard = (props: SignInFactorOnePasswordProps) .then(res => { switch (res.status) { case 'complete': - return setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignIn }); + return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); case 'needs_second_factor': return navigate('../factor-two'); default: diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx index ac9ba597aa3..4b2f0fce6a8 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoBackupCodeCard.tsx @@ -19,7 +19,7 @@ type SignInFactorTwoBackupCodeCardProps = { export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCardProps) => { const { onShowAlternativeMethodsClicked } = props; const signIn = useCoreSignIn(); - const { navigateAfterSignIn } = useSignInContext(); + const { afterSignInUrl } = useSignInContext(); const { setActive } = useClerk(); const { navigate } = useRouter(); const supportEmail = useSupportEmail(); @@ -47,7 +47,7 @@ export const SignInFactorTwoBackupCodeCard = (props: SignInFactorTwoBackupCodeCa queryParams.set('createdSessionId', res.createdSessionId); return navigate(`../reset-password-success?${queryParams.toString()}`); } - return setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignIn }); + return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx index 3cdec7e9021..5002702237d 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInFactorTwoCodeForm.tsx @@ -31,7 +31,7 @@ type SignInFactorTwoCodeFormProps = SignInFactorTwoCodeCard & { export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => { const signIn = useCoreSignIn(); const card = useCardState(); - const { navigateAfterSignIn } = useSignInContext(); + const { afterSignInUrl } = useSignInContext(); const { setActive } = useClerk(); const { navigate } = useRouter(); const supportEmail = useSupportEmail(); @@ -77,7 +77,7 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => queryParams.set('createdSessionId', res.createdSessionId); return navigate(`../reset-password-success?${queryParams.toString()}`); } - return setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignIn }); + return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); default: return console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); } diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 97651aaf661..c7e45789166 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -65,7 +65,7 @@ export function _SignInStart(): JSX.Element { const signIn = useCoreSignIn(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { navigateAfterSignIn, signUpUrl } = ctx; + const { afterSignInUrl, signUpUrl } = ctx; const supportEmail = useSupportEmail(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), @@ -186,7 +186,7 @@ export function _SignInStart(): JSX.Element { removeClerkQueryParam('__clerk_ticket'); return clerk.setActive({ session: res.createdSessionId, - beforeEmit: navigateAfterSignIn, + redirectUrl: afterSignInUrl, }); default: { console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); @@ -289,7 +289,7 @@ export function _SignInStart(): JSX.Element { case 'complete': return clerk.setActive({ session: res.createdSessionId, - beforeEmit: navigateAfterSignIn, + redirectUrl: afterSignInUrl, }); default: { console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); @@ -328,7 +328,7 @@ export function _SignInStart(): JSX.Element { await signInWithFields(identifierField); } else if (alreadySignedInError) { const sid = alreadySignedInError.meta!.sessionId!; - await clerk.setActive({ session: sid, beforeEmit: navigateAfterSignIn }); + await clerk.setActive({ session: sid, redirectUrl: afterSignInUrl }); } else { handleError(e, [identifierField, instantPasswordField], card.setError); } diff --git a/packages/clerk-js/src/ui/components/SignIn/shared.ts b/packages/clerk-js/src/ui/components/SignIn/shared.ts index 95a27a25031..0770b6cef51 100644 --- a/packages/clerk-js/src/ui/components/SignIn/shared.ts +++ b/packages/clerk-js/src/ui/components/SignIn/shared.ts @@ -14,7 +14,7 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise // @ts-expect-error -- private method for the time being const { setActive, __internal_navigateWithError } = useClerk(); const supportEmail = useSupportEmail(); - const { navigateAfterSignIn } = useSignInContext(); + const { afterSignInUrl } = useSignInContext(); const { authenticateWithPasskey } = useCoreSignIn(); useEffect(() => { @@ -28,7 +28,7 @@ function useHandleAuthenticateWithPasskey(onSecondFactor: () => Promise const res = await authenticateWithPasskey(...args); switch (res.status) { case 'complete': - return setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignIn }); + return setActive({ session: res.createdSessionId, redirectUrl: afterSignInUrl }); case 'needs_second_factor': return onSecondFactor(); default: diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx index 7ac0c7188e7..15ba81e33ff 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx @@ -31,7 +31,7 @@ function _SignUpContinue() { const { navigate } = useRouter(); const { displayConfig, userSettings } = useEnvironment(); const { attributes } = userSettings; - const { navigateAfterSignUp, signInUrl, unsafeMetadata, initialValues = {} } = useSignUpContext(); + const { afterSignUpUrl, signInUrl, unsafeMetadata, initialValues = {} } = useSignUpContext(); const signUp = useCoreSignUp(); const isProgressiveSignUp = userSettings.signUp.progressive; const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( @@ -144,7 +144,7 @@ function _SignUpContinue() { signUp: res, verifyEmailPath: './verify-email-address', verifyPhonePath: './verify-phone-number', - handleComplete: () => clerk.setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignUp }), + handleComplete: () => clerk.setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), navigate, }), ) diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx index fbdd1d4eb2b..a5b701e4c93 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpEmailLinkCard.tsx @@ -17,7 +17,7 @@ export const SignUpEmailLinkCard = () => { const { t } = useLocalizations(); const signUp = useCoreSignUp(); const signUpContext = useSignUpContext(); - const { navigateAfterSignUp } = signUpContext; + const { afterSignUpUrl } = signUpContext; const card = useCardState(); const { displayConfig } = useEnvironment(); const { navigate } = useRouter(); @@ -54,7 +54,7 @@ export const SignUpEmailLinkCard = () => { signUp: su, verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', - handleComplete: () => setActive({ session: su.createdSessionId, beforeEmit: navigateAfterSignUp }), + handleComplete: () => setActive({ session: su.createdSessionId, redirectUrl: afterSignUpUrl }), navigate, }); } diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index 6a76ca18898..497329ad9ce 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -37,7 +37,7 @@ function _SignUpStart(): JSX.Element { const { attributes } = userSettings; const { setActive } = useClerk(); const ctx = useSignUpContext(); - const { navigateAfterSignUp, signInUrl, unsafeMetadata } = ctx; + const { afterSignUpUrl, signInUrl, unsafeMetadata } = ctx; const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), ); @@ -127,7 +127,7 @@ function _SignUpStart(): JSX.Element { handleComplete: () => { removeClerkQueryParam('__clerk_ticket'); removeClerkQueryParam('__clerk_invitation_token'); - return setActive({ session: signUp.createdSessionId, beforeEmit: navigateAfterSignUp }); + return setActive({ session: signUp.createdSessionId, redirectUrl: afterSignUpUrl }); }, navigate, }); @@ -227,7 +227,7 @@ function _SignUpStart(): JSX.Element { signUp: res, verifyEmailPath: 'verify-email-address', verifyPhonePath: 'verify-phone-number', - handleComplete: () => setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignUp }), + handleComplete: () => setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), navigate, redirectUrl, redirectUrlComplete, diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx index 8ef2f3d5ac7..358ff0fae1f 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpVerificationCodeForm.tsx @@ -19,7 +19,7 @@ type SignInFactorOneCodeFormProps = { }; export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) => { - const { navigateAfterSignUp } = useSignUpContext(); + const { afterSignUpUrl } = useSignUpContext(); const { setActive } = useClerk(); const { navigate } = useRouter(); @@ -36,7 +36,7 @@ export const SignUpVerificationCodeForm = (props: SignInFactorOneCodeFormProps) signUp: res, verifyEmailPath: '../verify-email-address', verifyPhonePath: '../verify-phone-number', - handleComplete: () => setActive({ session: res.createdSessionId, beforeEmit: navigateAfterSignUp }), + handleComplete: () => setActive({ session: res.createdSessionId, redirectUrl: afterSignUpUrl }), navigate, }); }) diff --git a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx index 6dfb5a06d0a..cb12f020812 100644 --- a/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/useMultisessionActions.tsx @@ -13,6 +13,7 @@ type UseMultisessionActionsParams = { navigateAfterSignOut?: () => any; navigateAfterMultiSessionSingleSignOut?: () => any; navigateAfterSwitchSession?: () => any; + afterSignInUrl?: string; userProfileUrl?: string; signInUrl?: string; } & Pick; @@ -68,7 +69,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { const handleSessionClicked = (session: ActiveSessionResource) => async () => { card.setLoading(); - return setActive({ session, beforeEmit: opts.navigateAfterSwitchSession }).finally(() => { + return setActive({ session, redirectUrl: opts.afterSignInUrl }).finally(() => { card.setIdle(); opts.actionCompleteCallback?.(); }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx index c83586766e8..75b3de7a288 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/DeleteUserForm.tsx @@ -12,7 +12,7 @@ type DeleteUserFormProps = FormProps; export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps) => { const { onReset } = props; const card = useCardState(); - const { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl } = useSignOutContext(); + const { afterSignOutUrl, afterMultiSessionSingleSignOutUrl } = useSignOutContext(); const { user } = useUser(); const { t } = useLocalizations(); const { otherSessions } = useMultipleSessions({ user }); @@ -41,11 +41,11 @@ export const DeleteUserForm = withCardStateProvider((props: DeleteUserFormProps) } await handleAssurance(user.delete); - const navigationCallback = - otherSessions.length === 0 ? navigateAfterSignOut : navigateAfterMultiSessionSingleSignOutUrl; + const redirectUrl = otherSessions.length === 0 ? afterSignOutUrl : afterMultiSessionSingleSignOutUrl; + return await setActive({ session: null, - beforeEmit: navigationCallback, + redirectUrl, }); } catch (e) { handleError(e, [], card.setError); diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index c9e0434b434..14a42d66829 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -194,6 +194,8 @@ export const useSignInContext = (): SignInContextType => { export type SignOutContextType = { navigateAfterSignOut: () => any; navigateAfterMultiSessionSingleSignOutUrl: () => any; + afterSignOutUrl: string; + afterMultiSessionSingleSignOutUrl: string; }; export const useSignOutContext = (): SignOutContextType => { @@ -203,7 +205,12 @@ export const useSignOutContext = (): SignOutContextType => { const navigateAfterSignOut = () => navigate(clerk.buildAfterSignOutUrl()); const navigateAfterMultiSessionSingleSignOutUrl = () => navigate(clerk.buildAfterMultiSessionSingleSignOutUrl()); - return { navigateAfterSignOut, navigateAfterMultiSessionSingleSignOutUrl }; + return { + navigateAfterSignOut, + navigateAfterMultiSessionSingleSignOutUrl, + afterSignOutUrl: clerk.buildAfterSignOutUrl(), + afterMultiSessionSingleSignOutUrl: clerk.buildAfterMultiSessionSingleSignOutUrl(), + }; }; type PagesType = { @@ -334,13 +341,32 @@ export const useOrganizationSwitcherContext = () => { }: { organization?: OrganizationResource; user?: UserResource; + }) => { + const redirectUrl = getAfterSelectOrganizationOrPersonalUrl({ + organization, + user, + }); + + if (redirectUrl) { + return navigate(redirectUrl); + } + + return Promise.resolve(); + }; + + const getAfterSelectOrganizationOrPersonalUrl = ({ + organization, + user, + }: { + organization?: OrganizationResource; + user?: UserResource; }) => { if (typeof ctx.afterSelectPersonalUrl === 'function' && user) { - return navigate(ctx.afterSelectPersonalUrl(user)); + return ctx.afterSelectPersonalUrl(user); } if (typeof ctx.afterSelectOrganizationUrl === 'function' && organization) { - return navigate(ctx.afterSelectOrganizationUrl(organization)); + return ctx.afterSelectOrganizationUrl(organization); } if (ctx.afterSelectPersonalUrl && user) { @@ -348,7 +374,7 @@ export const useOrganizationSwitcherContext = () => { urlWithParam: ctx.afterSelectPersonalUrl as string, entity: user, }); - return navigate(parsedUrl); + return parsedUrl; } if (ctx.afterSelectOrganizationUrl && organization) { @@ -356,10 +382,10 @@ export const useOrganizationSwitcherContext = () => { urlWithParam: ctx.afterSelectOrganizationUrl as string, entity: organization, }); - return navigate(parsedUrl); + return parsedUrl; } - return Promise.resolve(); + return; }; const navigateAfterSelectOrganization = (organization: OrganizationResource) => @@ -367,6 +393,10 @@ export const useOrganizationSwitcherContext = () => { const navigateAfterSelectPersonal = (user: UserResource) => navigateAfterSelectOrganizationOrPersonal({ user }); + const afterSelectOrganizationUrl = (organization: OrganizationResource) => + getAfterSelectOrganizationOrPersonalUrl({ organization }); + const afterSelectPersonalUrl = (user: UserResource) => getAfterSelectOrganizationOrPersonalUrl({ user }); + const organizationProfileMode = !!ctx.organizationProfileUrl && !ctx.organizationProfileMode ? 'navigation' : ctx.organizationProfileMode; @@ -386,6 +416,8 @@ export const useOrganizationSwitcherContext = () => { navigateCreateOrganization, navigateAfterSelectOrganization, navigateAfterSelectPersonal, + afterSelectOrganizationUrl, + afterSelectPersonalUrl, componentName, }; }; diff --git a/packages/clerk-js/src/ui/hooks/useSetSessionWithTimeout.ts b/packages/clerk-js/src/ui/hooks/useSetSessionWithTimeout.ts index 21eb6fd716e..832ff461637 100644 --- a/packages/clerk-js/src/ui/hooks/useSetSessionWithTimeout.ts +++ b/packages/clerk-js/src/ui/hooks/useSetSessionWithTimeout.ts @@ -7,7 +7,7 @@ import { useRouter } from '../router'; export const useSetSessionWithTimeout = (delay = 2000) => { const { queryString } = useRouter(); const { setActive } = useClerk(); - const { navigateAfterSignIn } = useSignInContext(); + const { afterSignInUrl } = useSignInContext(); useEffect(() => { let timeoutId: ReturnType; @@ -15,7 +15,7 @@ export const useSetSessionWithTimeout = (delay = 2000) => { const createdSessionId = queryParams.get('createdSessionId'); if (createdSessionId) { timeoutId = setTimeout(() => { - void setActive({ session: createdSessionId, beforeEmit: navigateAfterSignIn }); + void setActive({ session: createdSessionId, redirectUrl: afterSignInUrl }); }, delay); } @@ -24,5 +24,5 @@ export const useSetSessionWithTimeout = (delay = 2000) => { clearTimeout(timeoutId); } }; - }, [setActive, navigateAfterSignIn, queryString]); + }, [setActive, afterSignInUrl, queryString]); }; diff --git a/packages/elements/src/internals/machines/sign-in/router.machine.ts b/packages/elements/src/internals/machines/sign-in/router.machine.ts index e179330f4f2..18eac1068f3 100644 --- a/packages/elements/src/internals/machines/sign-in/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-in/router.machine.ts @@ -87,9 +87,10 @@ export const SignInRouterMachine = setup({ const session = id || createdSessionId || lastActiveSessionId || null; - const beforeEmit = () => - context.router?.push(context.router?.searchParams().get('redirect_url') || context.clerk.buildAfterSignInUrl()); - void context.clerk.setActive({ session, beforeEmit }); + void context.clerk.setActive({ + session, + redirectUrl: context.router?.searchParams().get('redirect_url') || context.clerk.buildAfterSignInUrl(), + }); enqueue.raise({ type: 'RESET' }, { delay: 2000 }); // Reset machine after 2s delay. }), diff --git a/packages/elements/src/internals/machines/sign-up/router.machine.ts b/packages/elements/src/internals/machines/sign-up/router.machine.ts index 9f18b4c7dcf..50bb02d76bd 100644 --- a/packages/elements/src/internals/machines/sign-up/router.machine.ts +++ b/packages/elements/src/internals/machines/sign-up/router.machine.ts @@ -79,9 +79,10 @@ export const SignUpRouterMachine = setup({ (params?.useLastActiveSession && context.clerk.client.lastActiveSessionId) || ((event as SignUpRouterNextEvent)?.resource || context.clerk.client.signUp).createdSessionId; - const beforeEmit = () => - context.router?.push(context.router?.searchParams().get('redirect_url') || context.clerk.buildAfterSignUpUrl()); - void context.clerk.setActive({ session, beforeEmit }); + void context.clerk.setActive({ + session, + redirectUrl: context.router?.searchParams().get('redirect_url') || context.clerk.buildAfterSignUpUrl(), + }); }, delayedReset: raise({ type: 'RESET' }, { delay: 3000 }), // Reset machine after 3s delay. setError: assign({ diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 3a94a521f9a..509e69550b0 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -625,9 +625,9 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { /** * `setActive` can be used to set the active session and/or organization. */ - setActive = ({ session, organization, beforeEmit }: SetActiveParams): Promise => { + setActive = ({ session, organization, beforeEmit, redirectUrl }: SetActiveParams): Promise => { if (this.clerkjs) { - return this.clerkjs.setActive({ session, organization, beforeEmit }); + return this.clerkjs.setActive({ session, organization, beforeEmit, redirectUrl }); } else { return Promise.reject(); } diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 841ddb1e255..d145d21d79f 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -801,10 +801,17 @@ export type SetActiveParams = { organization?: OrganizationResource | string | null; /** + * @deprecated use the redirectUrl parameter to redirect a user + * * Callback run just before the active session and/or organization is set to the passed object. * Can be used to hook up for pre-navigation actions. */ beforeEmit?: BeforeEmitCallback; + + /** + * The URL to redirect a user to just before the active session and/or organization is set to the passed object. + */ + redirectUrl?: string; }; export type SetActive = (params: SetActiveParams) => Promise; diff --git a/packages/types/src/client.ts b/packages/types/src/client.ts index 4326520dd0a..a3e06b8ce6a 100644 --- a/packages/types/src/client.ts +++ b/packages/types/src/client.ts @@ -13,7 +13,10 @@ export interface ClientResource extends ClerkResource { destroy: () => Promise; removeSessions: () => Promise; clearCache: () => void; + isEligibleForTouch: () => boolean; + buildTouchUrl: (params: { redirectUrl: URL }) => string; lastActiveSessionId: string | null; + cookieExpiresAt: Date | null; createdAt: Date | null; updatedAt: Date | null; } diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 01f1a8b0c22..4bbd03f9056 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -73,6 +73,7 @@ export interface ClientJSON extends ClerkResourceJSON { sign_up: SignUpJSON | null; sign_in: SignInJSON | null; last_active_session_id: string | null; + cookie_expires_at: number | null; created_at: number; updated_at: number; }