Skip to content

Commit

Permalink
feat(clerk-js,types): Support redirectUrl param in `Clerk.signtOut(…
Browse files Browse the repository at this point in the history
…)` 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
  • Loading branch information
dimkl authored Dec 20, 2023
1 parent bad4de1 commit 2e4a430
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 56 deletions.
18 changes: 18 additions & 0 deletions .changeset/selfish-flies-care.md
Original file line number Diff line number Diff line change
@@ -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: '/' })

<SignOutButton redirectUrl='/' />
// uses Clerk.signOut({ redirectUrl: '/' })
<UserButton afterSignOutUrl='/after' />
// uses Clerk.signOut({ redirectUrl: '/after' })
<ClerkProvider afterSignOutUrl='/after' />
// uses Clerk.signOut({ redirectUrl: '/after' })
```
32 changes: 29 additions & 3 deletions packages/clerk-js/src/core/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
});
});

Expand All @@ -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) });
});
});

Expand All @@ -485,6 +488,7 @@ describe('Clerk singleton', () => {
expect(mockClientDestroy).not.toHaveBeenCalled();
expect(sut.setActive).not.toHaveBeenCalledWith({
session: null,
beforeEmit: expect.any(Function),
});
});
});
Expand All @@ -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');
});
});
});
Expand Down
21 changes: 20 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ const defaultOptions: ClerkOptions = {
signUpUrl: undefined,
afterSignInUrl: undefined,
afterSignUpUrl: undefined,
afterSignOutUrl: undefined,
};

export class Clerk implements ClerkInterface {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 '';
Expand Down Expand Up @@ -848,6 +860,13 @@ export class Clerk implements ClerkInterface {
return;
};

public redirectToAfterSignOut = async (): Promise<unknown> => {
if (inBrowser()) {
return this.navigate(this.buildAfterSignOutUrl());
}
return;
};

public handleEmailLinkVerification = async (
params: HandleEmailLinkVerificationParams,
customNavigate?: (to: string) => Promise<unknown>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,34 @@ describe('UserButton', () => {
email_addresses: ['test@clerk.com'],
});
});

fixtures.clerk.signOut.mockImplementationOnce(callback => callback());

const { getByText, getByRole, userEvent } = render(<UserButton />, { 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(<UserButton />, { 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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 1 addition & 8 deletions packages/react/src/components/SignOutButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
21 changes: 21 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type IsomorphicLoadedClerk = Without<
| 'buildOrganizationProfileUrl'
| 'buildAfterSignUpUrl'
| 'buildAfterSignInUrl'
| 'buildAfterSignOutUrl'
| 'buildUrlWithAuth'
| 'handleRedirectCallback'
| 'handleUnauthenticated'
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<unknown> => {
const callback = () => this.clerkjs?.redirectToOrganizationProfile();
if (this.clerkjs && this.#loaded) {
Expand Down
94 changes: 52 additions & 42 deletions packages/types/src/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -435,17 +449,7 @@ export interface Clerk {
handleUnauthenticated: () => Promise<unknown>;
}

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.
Expand Down Expand 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<string | RegExp>;
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<string | RegExp>;
isSatellite?: boolean | ((url: URL) => boolean);

sdkMetadata?: SDKMetadata;
};
/**
* Telemetry options
*/
telemetry?:
| false
| {
disabled?: boolean;
debug?: boolean;
};

sdkMetadata?: SDKMetadata;
};

export interface NavigateOptions {
replace?: boolean;
Expand Down Expand Up @@ -572,7 +575,7 @@ export type SignUpInitialValues = {
username?: string;
};

export type RedirectOptions = {
type AfterActionURLs = {
/**
* Full URL or path to navigate after successful sign in.
*/
Expand All @@ -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.
Expand Down

0 comments on commit 2e4a430

Please sign in to comment.