diff --git a/.changeset/selfish-flies-care.md b/.changeset/selfish-flies-care.md new file mode 100644 index 00000000000..a9302afac2a --- /dev/null +++ b/.changeset/selfish-flies-care.md @@ -0,0 +1,18 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Update `@clerk/clerk-js` and `@clerk/clerk-react` to support the following examples: + +```typescript +Clerk.signOut({ redirectUrl: '/' }) + + +// uses Clerk.signOut({ redirectUrl: '/' }) + +// uses Clerk.signOut({ redirectUrl: '/after' }) + +// uses Clerk.signOut({ redirectUrl: '/after' }) +``` \ No newline at end of file diff --git a/packages/clerk-js/src/core/clerk.test.ts b/packages/clerk-js/src/core/clerk.test.ts index 5f490e3537f..f0e3ea1a2a3 100644 --- a/packages/clerk-js/src/core/clerk.test.ts +++ b/packages/clerk-js/src/core/clerk.test.ts @@ -443,7 +443,10 @@ describe('Clerk singleton', () => { await sut.signOut(); await waitFor(() => { expect(mockClientDestroy).toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ session: null }); + expect(sut.setActive).toHaveBeenCalledWith({ + session: null, + beforeEmit: expect.any(Function), + }); }); }); @@ -463,7 +466,7 @@ describe('Clerk singleton', () => { await waitFor(() => { expect(mockClientDestroy).toHaveBeenCalled(); expect(mockSession1.remove).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ session: null }); + expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) }); }); }); @@ -485,6 +488,7 @@ describe('Clerk singleton', () => { expect(mockClientDestroy).not.toHaveBeenCalled(); expect(sut.setActive).not.toHaveBeenCalledWith({ session: null, + beforeEmit: expect.any(Function), }); }); }); @@ -505,7 +509,29 @@ describe('Clerk singleton', () => { await waitFor(() => { expect(mockSession1.remove).toHaveBeenCalled(); expect(mockClientDestroy).not.toHaveBeenCalled(); - expect(sut.setActive).toHaveBeenCalledWith({ session: null }); + expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) }); + }); + }); + + it('removes and signs out the session and redirects to the provided redirectUrl ', async () => { + mockClientFetch.mockReturnValue( + Promise.resolve({ + activeSessions: [mockSession1, mockSession2], + sessions: [mockSession1, mockSession2], + destroy: mockClientDestroy, + }), + ); + + const sut = new Clerk(productionPublishableKey); + sut.setActive = jest.fn(({ beforeEmit }) => beforeEmit()); + sut.navigate = jest.fn(); + await sut.load(); + await sut.signOut({ sessionId: '1', redirectUrl: '/after-sign-out' }); + await waitFor(() => { + expect(mockSession1.remove).toHaveBeenCalled(); + expect(mockClientDestroy).not.toHaveBeenCalled(); + expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) }); + expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out'); }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d988a494bd8..463b937d910 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -133,6 +133,7 @@ const defaultOptions: ClerkOptions = { signUpUrl: undefined, afterSignInUrl: undefined, afterSignUpUrl: undefined, + afterSignOutUrl: undefined, }; export class Clerk implements ClerkInterface { @@ -301,9 +302,12 @@ export class Clerk implements ClerkInterface { if (!this.client || this.client.sessions.length === 0) { return; } - const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined; const opts = callbackOrOptions && typeof callbackOrOptions === 'object' ? callbackOrOptions : options || {}; + const redirectUrl = opts?.redirectUrl || this.buildAfterSignOutUrl(); + const defaultCb = () => this.navigate(redirectUrl); + const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : defaultCb; + if (!opts.sessionId || this.client.activeSessions.length === 1) { await this.client.destroy(); return this.setActive({ @@ -758,6 +762,14 @@ export class Clerk implements ClerkInterface { return this.buildUrlWithAuth(this.#options.afterSignUpUrl); } + public buildAfterSignOutUrl(): string { + if (!this.#options.afterSignOutUrl) { + return '/'; + } + + return this.buildUrlWithAuth(this.#options.afterSignOutUrl); + } + public buildCreateOrganizationUrl(): string { if (!this.#environment || !this.#environment.displayConfig) { return ''; @@ -848,6 +860,13 @@ export class Clerk implements ClerkInterface { return; }; + public redirectToAfterSignOut = async (): Promise => { + if (inBrowser()) { + return this.navigate(this.buildAfterSignOutUrl()); + } + return; + }; + public handleEmailLinkVerification = async ( params: HandleEmailLinkVerificationParams, customNavigate?: (to: string) => Promise, diff --git a/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx index a0228519fa6..f9f2bd3142c 100644 --- a/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx +++ b/packages/clerk-js/src/ui/components/UserButton/__tests__/UserButton.test.tsx @@ -59,10 +59,34 @@ describe('UserButton', () => { email_addresses: ['test@clerk.com'], }); }); + + fixtures.clerk.signOut.mockImplementationOnce(callback => callback()); + + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user button' })); + await userEvent.click(getByText('Sign out')); + + expect(fixtures.router.navigate).toHaveBeenCalledWith('/'); + }); + + it('redirects to afterSignOutUrl when "Sign out" is clicked and afterSignOutUrl prop is passed', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withUser({ + first_name: 'First', + last_name: 'Last', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + + fixtures.clerk.signOut.mockImplementation(callback => callback()); + props.setProps({ afterSignOutUrl: '/after-sign-out' }); + const { getByText, getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button', { name: 'Open user button' })); await userEvent.click(getByText('Sign out')); - expect(fixtures.clerk.signOut).toHaveBeenCalled(); + + expect(fixtures.router.navigate).toHaveBeenCalledWith('/after-sign-out'); }); it.todo('navigates to sign in url when "Add account" is clicked'); diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 5b0d548996e..5cde066a9e8 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -232,7 +232,7 @@ export const useUserButtonContext = () => { const afterMultiSessionSingleSignOutUrl = ctx.afterMultiSessionSingleSignOutUrl || displayConfig.afterSignOutOneUrl; const navigateAfterMultiSessionSingleSignOut = () => clerk.redirectWithAuth(afterMultiSessionSingleSignOutUrl); - const afterSignOutUrl = ctx.afterSignOutUrl || '/'; + const afterSignOutUrl = ctx.afterSignOutUrl || clerk.buildAfterSignOutUrl(); const navigateAfterSignOut = () => navigate(afterSignOutUrl); const afterSwitchSessionUrl = ctx.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl; diff --git a/packages/react/src/components/SignOutButton.tsx b/packages/react/src/components/SignOutButton.tsx index f3ed4e611a3..19dd6f9bf7c 100644 --- a/packages/react/src/components/SignOutButton.tsx +++ b/packages/react/src/components/SignOutButton.tsx @@ -18,14 +18,7 @@ export const SignOutButton = withClerk( children = normalizeWithDefaultValue(children, 'Sign out'); const child = assertSingleChild(children)('SignOutButton'); - const navigateToRedirectUrl = () => { - return clerk.navigate(redirectUrl); - }; - - const clickHandler = () => { - return clerk.signOut(navigateToRedirectUrl, signOutOptions); - }; - + const clickHandler = () => clerk.signOut({ redirectUrl }); const wrappedChildClickHandler: React.MouseEventHandler = async e => { await safeExecute((child as any).props.onClick)(e); return clickHandler(); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index f4b7df167e5..8f50d8fd228 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -75,6 +75,7 @@ type IsomorphicLoadedClerk = Without< | 'buildOrganizationProfileUrl' | 'buildAfterSignUpUrl' | 'buildAfterSignInUrl' + | 'buildAfterSignOutUrl' | 'buildUrlWithAuth' | 'handleRedirectCallback' | 'handleUnauthenticated' @@ -115,6 +116,8 @@ type IsomorphicLoadedClerk = Without< buildAfterSignInUrl: () => string | void; // TODO: Align return type buildAfterSignUpUrl: () => string | void; + // TODO: Align return type + buildAfterSignOutUrl: () => string | void; // TODO: Align optional props mountUserButton: (node: HTMLDivElement, props: UserButtonProps) => void; mountOrganizationList: (node: HTMLDivElement, props: OrganizationListProps) => void; @@ -278,6 +281,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + buildAfterSignOutUrl = (): string | void => { + const callback = () => this.clerkjs?.buildAfterSignOutUrl() || ''; + if (this.clerkjs && this.#loaded) { + return callback(); + } else { + this.premountMethodCalls.set('buildAfterSignOutUrl', callback); + } + }; + buildUserProfileUrl = (): string | void => { const callback = () => this.clerkjs?.buildUserProfileUrl() || ''; if (this.clerkjs && this.#loaded) { @@ -835,6 +847,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + redirectToAfterSignOut = (): void => { + const callback = () => this.clerkjs?.redirectToAfterSignOut(); + if (this.clerkjs && this.#loaded) { + callback(); + } else { + this.premountMethodCalls.set('redirectToAfterSignOut', callback); + } + }; + redirectToOrganizationProfile = async (): Promise => { const callback = () => this.clerkjs?.redirectToOrganizationProfile(); if (this.clerkjs && this.#loaded) { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index bd5f4a4f4c2..59ec7047a65 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -39,6 +39,10 @@ export type SignOutOptions = { * multi-session applications. */ sessionId?: string; + /** + * Specify a redirect URL to navigate after sign out is complete. + */ + redirectUrl?: string; }; export interface SignOut { @@ -341,15 +345,20 @@ export interface Clerk { buildOrganizationProfileUrl(): string; /** - * Returns the configured afterSignIn url of the instance. + * Returns the configured afterSignInUrl of the instance. */ buildAfterSignInUrl(): string; /** - * Returns the configured afterSignIn url of the instance. + * Returns the configured afterSignInUrl of the instance. */ buildAfterSignUpUrl(): string; + /** + * Returns the configured afterSignOutUrl of the instance. + */ + buildAfterSignOutUrl(): string; + /** * * Redirects to the provided url after decorating it with the auth token for development instances. @@ -397,6 +406,11 @@ export interface Clerk { */ redirectToAfterSignUp: () => void; + /** + * Redirects to the configured afterSignOut URL. + */ + redirectToAfterSignOut: () => void; + /** * Completes an OAuth or SAML redirection flow started by * {@link Clerk.client.signIn.authenticateWithRedirect} or {@link Clerk.client.signUp.authenticateWithRedirect} @@ -435,17 +449,7 @@ export interface Clerk { handleUnauthenticated: () => Promise; } -export type HandleOAuthCallbackParams = { - /** - * Full URL or path to navigate after successful sign up. - */ - afterSignUpUrl?: string | null; - - /** - * Full URL or path to navigate after successful sign in. - */ - afterSignInUrl?: string | null; - +export type HandleOAuthCallbackParams = AfterActionURLs & { /** * Full URL or path to navigate after successful sign in * or sign up. @@ -513,35 +517,34 @@ type ClerkOptionsNavigation = ClerkOptionsNavigationFn & { routerDebug?: boolean; }; -export type ClerkOptions = ClerkOptionsNavigation & { - appearance?: Appearance; - localization?: LocalizationResource; - polling?: boolean; - selectInitialSession?: (client: ClientResource) => ActiveSessionResource | null; - /** Controls if ClerkJS will load with the standard browser setup using Clerk cookies */ - standardBrowser?: boolean; - /** Optional support email for display in authentication screens */ - supportEmail?: string; - touchSession?: boolean; - signInUrl?: string; - signUpUrl?: string; - afterSignInUrl?: string; - afterSignUpUrl?: string; - allowedRedirectOrigins?: Array; - isSatellite?: boolean | ((url: URL) => boolean); - - /** - * Telemetry options - */ - telemetry?: - | false - | { - disabled?: boolean; - debug?: boolean; - }; +export type ClerkOptions = ClerkOptionsNavigation & + AfterActionURLs & { + appearance?: Appearance; + localization?: LocalizationResource; + polling?: boolean; + selectInitialSession?: (client: ClientResource) => ActiveSessionResource | null; + /** Controls if ClerkJS will load with the standard browser setup using Clerk cookies */ + standardBrowser?: boolean; + /** Optional support email for display in authentication screens */ + supportEmail?: string; + touchSession?: boolean; + signInUrl?: string; + signUpUrl?: string; + allowedRedirectOrigins?: Array; + isSatellite?: boolean | ((url: URL) => boolean); - sdkMetadata?: SDKMetadata; -}; + /** + * Telemetry options + */ + telemetry?: + | false + | { + disabled?: boolean; + debug?: boolean; + }; + + sdkMetadata?: SDKMetadata; + }; export interface NavigateOptions { replace?: boolean; @@ -572,7 +575,7 @@ export type SignUpInitialValues = { username?: string; }; -export type RedirectOptions = { +type AfterActionURLs = { /** * Full URL or path to navigate after successful sign in. */ @@ -584,6 +587,13 @@ export type RedirectOptions = { */ afterSignUpUrl?: string | null; + /** + * Full URL or path to navigate after successful sign out. + */ + afterSignOutUrl?: string | null; +}; + +export type RedirectOptions = AfterActionURLs & { /** * Full URL or path to navigate after successful sign in, * or sign up.