Skip to content

Commit 2e4a430

Browse files
authoredDec 20, 2023
feat(clerk-js,types): Support redirectUrl param in Clerk.signtOut() and Clerk#afterSignOutUrl property (#2412)
* feat(clerk-js,types): Support redirectUrl option in Clerk.signOut() * chore(clerk-js): Add test for afterSignOutUrl prop in UserButton * feat(clerk-js,types): Support afterSignOutUrl and related methods in Clerk instance
1 parent bad4de1 commit 2e4a430

File tree

8 files changed

+167
-56
lines changed

8 files changed

+167
-56
lines changed
 

‎.changeset/selfish-flies-care.md

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/clerk-react': minor
4+
'@clerk/types': minor
5+
---
6+
7+
Update `@clerk/clerk-js` and `@clerk/clerk-react` to support the following examples:
8+
9+
```typescript
10+
Clerk.signOut({ redirectUrl: '/' })
11+
12+
<SignOutButton redirectUrl='/' />
13+
// uses Clerk.signOut({ redirectUrl: '/' })
14+
<UserButton afterSignOutUrl='/after' />
15+
// uses Clerk.signOut({ redirectUrl: '/after' })
16+
<ClerkProvider afterSignOutUrl='/after' />
17+
// uses Clerk.signOut({ redirectUrl: '/after' })
18+
```

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

+29-3
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,10 @@ describe('Clerk singleton', () => {
443443
await sut.signOut();
444444
await waitFor(() => {
445445
expect(mockClientDestroy).toHaveBeenCalled();
446-
expect(sut.setActive).toHaveBeenCalledWith({ session: null });
446+
expect(sut.setActive).toHaveBeenCalledWith({
447+
session: null,
448+
beforeEmit: expect.any(Function),
449+
});
447450
});
448451
});
449452

@@ -463,7 +466,7 @@ describe('Clerk singleton', () => {
463466
await waitFor(() => {
464467
expect(mockClientDestroy).toHaveBeenCalled();
465468
expect(mockSession1.remove).not.toHaveBeenCalled();
466-
expect(sut.setActive).toHaveBeenCalledWith({ session: null });
469+
expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) });
467470
});
468471
});
469472

@@ -485,6 +488,7 @@ describe('Clerk singleton', () => {
485488
expect(mockClientDestroy).not.toHaveBeenCalled();
486489
expect(sut.setActive).not.toHaveBeenCalledWith({
487490
session: null,
491+
beforeEmit: expect.any(Function),
488492
});
489493
});
490494
});
@@ -505,7 +509,29 @@ describe('Clerk singleton', () => {
505509
await waitFor(() => {
506510
expect(mockSession1.remove).toHaveBeenCalled();
507511
expect(mockClientDestroy).not.toHaveBeenCalled();
508-
expect(sut.setActive).toHaveBeenCalledWith({ session: null });
512+
expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) });
513+
});
514+
});
515+
516+
it('removes and signs out the session and redirects to the provided redirectUrl ', async () => {
517+
mockClientFetch.mockReturnValue(
518+
Promise.resolve({
519+
activeSessions: [mockSession1, mockSession2],
520+
sessions: [mockSession1, mockSession2],
521+
destroy: mockClientDestroy,
522+
}),
523+
);
524+
525+
const sut = new Clerk(productionPublishableKey);
526+
sut.setActive = jest.fn(({ beforeEmit }) => beforeEmit());
527+
sut.navigate = jest.fn();
528+
await sut.load();
529+
await sut.signOut({ sessionId: '1', redirectUrl: '/after-sign-out' });
530+
await waitFor(() => {
531+
expect(mockSession1.remove).toHaveBeenCalled();
532+
expect(mockClientDestroy).not.toHaveBeenCalled();
533+
expect(sut.setActive).toHaveBeenCalledWith({ session: null, beforeEmit: expect.any(Function) });
534+
expect(sut.navigate).toHaveBeenCalledWith('/after-sign-out');
509535
});
510536
});
511537
});

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

+20-1
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ const defaultOptions: ClerkOptions = {
133133
signUpUrl: undefined,
134134
afterSignInUrl: undefined,
135135
afterSignUpUrl: undefined,
136+
afterSignOutUrl: undefined,
136137
};
137138

138139
export class Clerk implements ClerkInterface {
@@ -301,9 +302,12 @@ export class Clerk implements ClerkInterface {
301302
if (!this.client || this.client.sessions.length === 0) {
302303
return;
303304
}
304-
const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : undefined;
305305
const opts = callbackOrOptions && typeof callbackOrOptions === 'object' ? callbackOrOptions : options || {};
306306

307+
const redirectUrl = opts?.redirectUrl || this.buildAfterSignOutUrl();
308+
const defaultCb = () => this.navigate(redirectUrl);
309+
const cb = typeof callbackOrOptions === 'function' ? callbackOrOptions : defaultCb;
310+
307311
if (!opts.sessionId || this.client.activeSessions.length === 1) {
308312
await this.client.destroy();
309313
return this.setActive({
@@ -758,6 +762,14 @@ export class Clerk implements ClerkInterface {
758762
return this.buildUrlWithAuth(this.#options.afterSignUpUrl);
759763
}
760764

765+
public buildAfterSignOutUrl(): string {
766+
if (!this.#options.afterSignOutUrl) {
767+
return '/';
768+
}
769+
770+
return this.buildUrlWithAuth(this.#options.afterSignOutUrl);
771+
}
772+
761773
public buildCreateOrganizationUrl(): string {
762774
if (!this.#environment || !this.#environment.displayConfig) {
763775
return '';
@@ -848,6 +860,13 @@ export class Clerk implements ClerkInterface {
848860
return;
849861
};
850862

863+
public redirectToAfterSignOut = async (): Promise<unknown> => {
864+
if (inBrowser()) {
865+
return this.navigate(this.buildAfterSignOutUrl());
866+
}
867+
return;
868+
};
869+
851870
public handleEmailLinkVerification = async (
852871
params: HandleEmailLinkVerificationParams,
853872
customNavigate?: (to: string) => Promise<unknown>,

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

+25-1
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,34 @@ describe('UserButton', () => {
5959
email_addresses: ['test@clerk.com'],
6060
});
6161
});
62+
63+
fixtures.clerk.signOut.mockImplementationOnce(callback => callback());
64+
65+
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
66+
await userEvent.click(getByRole('button', { name: 'Open user button' }));
67+
await userEvent.click(getByText('Sign out'));
68+
69+
expect(fixtures.router.navigate).toHaveBeenCalledWith('/');
70+
});
71+
72+
it('redirects to afterSignOutUrl when "Sign out" is clicked and afterSignOutUrl prop is passed', async () => {
73+
const { wrapper, fixtures, props } = await createFixtures(f => {
74+
f.withUser({
75+
first_name: 'First',
76+
last_name: 'Last',
77+
username: 'username1',
78+
email_addresses: ['test@clerk.com'],
79+
});
80+
});
81+
82+
fixtures.clerk.signOut.mockImplementation(callback => callback());
83+
props.setProps({ afterSignOutUrl: '/after-sign-out' });
84+
6285
const { getByText, getByRole, userEvent } = render(<UserButton />, { wrapper });
6386
await userEvent.click(getByRole('button', { name: 'Open user button' }));
6487
await userEvent.click(getByText('Sign out'));
65-
expect(fixtures.clerk.signOut).toHaveBeenCalled();
88+
89+
expect(fixtures.router.navigate).toHaveBeenCalledWith('/after-sign-out');
6690
});
6791

6892
it.todo('navigates to sign in url when "Add account" is clicked');

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export const useUserButtonContext = () => {
232232
const afterMultiSessionSingleSignOutUrl = ctx.afterMultiSessionSingleSignOutUrl || displayConfig.afterSignOutOneUrl;
233233
const navigateAfterMultiSessionSingleSignOut = () => clerk.redirectWithAuth(afterMultiSessionSingleSignOutUrl);
234234

235-
const afterSignOutUrl = ctx.afterSignOutUrl || '/';
235+
const afterSignOutUrl = ctx.afterSignOutUrl || clerk.buildAfterSignOutUrl();
236236
const navigateAfterSignOut = () => navigate(afterSignOutUrl);
237237

238238
const afterSwitchSessionUrl = ctx.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl;

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

+1-8
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,7 @@ export const SignOutButton = withClerk(
1818
children = normalizeWithDefaultValue(children, 'Sign out');
1919
const child = assertSingleChild(children)('SignOutButton');
2020

21-
const navigateToRedirectUrl = () => {
22-
return clerk.navigate(redirectUrl);
23-
};
24-
25-
const clickHandler = () => {
26-
return clerk.signOut(navigateToRedirectUrl, signOutOptions);
27-
};
28-
21+
const clickHandler = () => clerk.signOut({ redirectUrl });
2922
const wrappedChildClickHandler: React.MouseEventHandler = async e => {
3023
await safeExecute((child as any).props.onClick)(e);
3124
return clickHandler();

‎packages/react/src/isomorphicClerk.ts

+21
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ type IsomorphicLoadedClerk = Without<
7575
| 'buildOrganizationProfileUrl'
7676
| 'buildAfterSignUpUrl'
7777
| 'buildAfterSignInUrl'
78+
| 'buildAfterSignOutUrl'
7879
| 'buildUrlWithAuth'
7980
| 'handleRedirectCallback'
8081
| 'handleUnauthenticated'
@@ -115,6 +116,8 @@ type IsomorphicLoadedClerk = Without<
115116
buildAfterSignInUrl: () => string | void;
116117
// TODO: Align return type
117118
buildAfterSignUpUrl: () => string | void;
119+
// TODO: Align return type
120+
buildAfterSignOutUrl: () => string | void;
118121
// TODO: Align optional props
119122
mountUserButton: (node: HTMLDivElement, props: UserButtonProps) => void;
120123
mountOrganizationList: (node: HTMLDivElement, props: OrganizationListProps) => void;
@@ -278,6 +281,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
278281
}
279282
};
280283

284+
buildAfterSignOutUrl = (): string | void => {
285+
const callback = () => this.clerkjs?.buildAfterSignOutUrl() || '';
286+
if (this.clerkjs && this.#loaded) {
287+
return callback();
288+
} else {
289+
this.premountMethodCalls.set('buildAfterSignOutUrl', callback);
290+
}
291+
};
292+
281293
buildUserProfileUrl = (): string | void => {
282294
const callback = () => this.clerkjs?.buildUserProfileUrl() || '';
283295
if (this.clerkjs && this.#loaded) {
@@ -835,6 +847,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
835847
}
836848
};
837849

850+
redirectToAfterSignOut = (): void => {
851+
const callback = () => this.clerkjs?.redirectToAfterSignOut();
852+
if (this.clerkjs && this.#loaded) {
853+
callback();
854+
} else {
855+
this.premountMethodCalls.set('redirectToAfterSignOut', callback);
856+
}
857+
};
858+
838859
redirectToOrganizationProfile = async (): Promise<unknown> => {
839860
const callback = () => this.clerkjs?.redirectToOrganizationProfile();
840861
if (this.clerkjs && this.#loaded) {

‎packages/types/src/clerk.ts

+52-42
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export type SignOutOptions = {
3939
* multi-session applications.
4040
*/
4141
sessionId?: string;
42+
/**
43+
* Specify a redirect URL to navigate after sign out is complete.
44+
*/
45+
redirectUrl?: string;
4246
};
4347

4448
export interface SignOut {
@@ -341,15 +345,20 @@ export interface Clerk {
341345
buildOrganizationProfileUrl(): string;
342346

343347
/**
344-
* Returns the configured afterSignIn url of the instance.
348+
* Returns the configured afterSignInUrl of the instance.
345349
*/
346350
buildAfterSignInUrl(): string;
347351

348352
/**
349-
* Returns the configured afterSignIn url of the instance.
353+
* Returns the configured afterSignInUrl of the instance.
350354
*/
351355
buildAfterSignUpUrl(): string;
352356

357+
/**
358+
* Returns the configured afterSignOutUrl of the instance.
359+
*/
360+
buildAfterSignOutUrl(): string;
361+
353362
/**
354363
*
355364
* Redirects to the provided url after decorating it with the auth token for development instances.
@@ -397,6 +406,11 @@ export interface Clerk {
397406
*/
398407
redirectToAfterSignUp: () => void;
399408

409+
/**
410+
* Redirects to the configured afterSignOut URL.
411+
*/
412+
redirectToAfterSignOut: () => void;
413+
400414
/**
401415
* Completes an OAuth or SAML redirection flow started by
402416
* {@link Clerk.client.signIn.authenticateWithRedirect} or {@link Clerk.client.signUp.authenticateWithRedirect}
@@ -435,17 +449,7 @@ export interface Clerk {
435449
handleUnauthenticated: () => Promise<unknown>;
436450
}
437451

438-
export type HandleOAuthCallbackParams = {
439-
/**
440-
* Full URL or path to navigate after successful sign up.
441-
*/
442-
afterSignUpUrl?: string | null;
443-
444-
/**
445-
* Full URL or path to navigate after successful sign in.
446-
*/
447-
afterSignInUrl?: string | null;
448-
452+
export type HandleOAuthCallbackParams = AfterActionURLs & {
449453
/**
450454
* Full URL or path to navigate after successful sign in
451455
* or sign up.
@@ -513,35 +517,34 @@ type ClerkOptionsNavigation = ClerkOptionsNavigationFn & {
513517
routerDebug?: boolean;
514518
};
515519

516-
export type ClerkOptions = ClerkOptionsNavigation & {
517-
appearance?: Appearance;
518-
localization?: LocalizationResource;
519-
polling?: boolean;
520-
selectInitialSession?: (client: ClientResource) => ActiveSessionResource | null;
521-
/** Controls if ClerkJS will load with the standard browser setup using Clerk cookies */
522-
standardBrowser?: boolean;
523-
/** Optional support email for display in authentication screens */
524-
supportEmail?: string;
525-
touchSession?: boolean;
526-
signInUrl?: string;
527-
signUpUrl?: string;
528-
afterSignInUrl?: string;
529-
afterSignUpUrl?: string;
530-
allowedRedirectOrigins?: Array<string | RegExp>;
531-
isSatellite?: boolean | ((url: URL) => boolean);
532-
533-
/**
534-
* Telemetry options
535-
*/
536-
telemetry?:
537-
| false
538-
| {
539-
disabled?: boolean;
540-
debug?: boolean;
541-
};
520+
export type ClerkOptions = ClerkOptionsNavigation &
521+
AfterActionURLs & {
522+
appearance?: Appearance;
523+
localization?: LocalizationResource;
524+
polling?: boolean;
525+
selectInitialSession?: (client: ClientResource) => ActiveSessionResource | null;
526+
/** Controls if ClerkJS will load with the standard browser setup using Clerk cookies */
527+
standardBrowser?: boolean;
528+
/** Optional support email for display in authentication screens */
529+
supportEmail?: string;
530+
touchSession?: boolean;
531+
signInUrl?: string;
532+
signUpUrl?: string;
533+
allowedRedirectOrigins?: Array<string | RegExp>;
534+
isSatellite?: boolean | ((url: URL) => boolean);
542535

543-
sdkMetadata?: SDKMetadata;
544-
};
536+
/**
537+
* Telemetry options
538+
*/
539+
telemetry?:
540+
| false
541+
| {
542+
disabled?: boolean;
543+
debug?: boolean;
544+
};
545+
546+
sdkMetadata?: SDKMetadata;
547+
};
545548

546549
export interface NavigateOptions {
547550
replace?: boolean;
@@ -572,7 +575,7 @@ export type SignUpInitialValues = {
572575
username?: string;
573576
};
574577

575-
export type RedirectOptions = {
578+
type AfterActionURLs = {
576579
/**
577580
* Full URL or path to navigate after successful sign in.
578581
*/
@@ -584,6 +587,13 @@ export type RedirectOptions = {
584587
*/
585588
afterSignUpUrl?: string | null;
586589

590+
/**
591+
* Full URL or path to navigate after successful sign out.
592+
*/
593+
afterSignOutUrl?: string | null;
594+
};
595+
596+
export type RedirectOptions = AfterActionURLs & {
587597
/**
588598
* Full URL or path to navigate after successful sign in,
589599
* or sign up.

0 commit comments

Comments
 (0)
Please sign in to comment.