diff --git a/.changeset/strange-owls-behave.md b/.changeset/strange-owls-behave.md
new file mode 100644
index 0000000000..c30fea5ef9
--- /dev/null
+++ b/.changeset/strange-owls-behave.md
@@ -0,0 +1,19 @@
+---
+'@clerk/clerk-js': patch
+'@clerk/types': patch
+---
+
+Construct urls based on context in
+- Deprecate `afterSwitchOrganizationUrl`
+- Introduce `afterSelectOrganizationUrl` & `afterSelectPersonalUrl`
+
+`afterSelectOrganizationUrl` accepts
+- Full URL -> 'https://clerk.com/'
+- relative path -> '/organizations'
+- relative path -> with param '/organizations/:id'
+- function that returns a string -> (org) => `/org/${org.slug}`
+`afterSelectPersonalUrl` accepts
+- Full URL -> 'https://clerk.com/'
+- relative path -> '/users'
+- relative path -> with param '/users/:username'
+- function that returns a string -> (user) => `/users/${user.id}`
diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx
index 812d66b196..85eba08e1f 100644
--- a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx
+++ b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx
@@ -1,3 +1,4 @@
+import type { OrganizationResource } from '@clerk/types';
import React from 'react';
import { QuestionMark } from '../../../ui/icons';
@@ -27,6 +28,7 @@ export const CreateOrganizationPage = withCardStateProvider(() => {
const { setActive, closeCreateOrganization } = useCoreClerk();
const { mode, navigateAfterCreateOrganization, skipInvitationScreen } = useCreateOrganizationContext();
const { organization } = useCoreOrganization();
+ const lastCreatedOrganizationRef = React.useRef(null);
const wizard = useWizard({ onNextStep: () => card.setError(undefined) });
@@ -61,6 +63,7 @@ export const CreateOrganizationPage = withCardStateProvider(() => {
await organization.setLogo({ file });
}
+ lastCreatedOrganizationRef.current = organization;
await setActive({ organization });
if (skipInvitationScreen ?? organization.maxAllowedMemberships === 1) {
@@ -74,7 +77,9 @@ export const CreateOrganizationPage = withCardStateProvider(() => {
};
const completeFlow = () => {
- void navigateAfterCreateOrganization();
+ // We are confident that lastCreatedOrganizationRef.current will never be null
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ void navigateAfterCreateOrganization(lastCreatedOrganizationRef.current!);
if (mode === 'modal') {
closeCreateOrganization();
}
diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx
index c03f587fef..b6ccde1118 100644
--- a/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx
+++ b/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx
@@ -158,4 +158,54 @@ describe('CreateOrganization', () => {
expect(queryByText(/Invite members/i)).not.toBeInTheDocument();
});
});
+
+ describe('navigation', () => {
+ it('constructs afterCreateOrganizationUrl from function', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.dev'],
+ });
+ });
+
+ const createdOrg = getCreatedOrg({
+ maxAllowedMemberships: 1,
+ });
+
+ fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg));
+
+ props.setProps({ afterCreateOrganizationUrl: org => `/org/${org.id}` });
+ const { getByRole, userEvent, getByLabelText } = render(, {
+ wrapper,
+ });
+ await userEvent.type(getByLabelText(/Organization name/i), 'new org');
+ await userEvent.click(getByRole('button', { name: /create organization/i }));
+
+ expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.id}`);
+ });
+
+ it('constructs afterCreateOrganizationUrl from `:slug` ', async () => {
+ const { wrapper, fixtures, props } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.dev'],
+ });
+ });
+
+ const createdOrg = getCreatedOrg({
+ maxAllowedMemberships: 1,
+ });
+
+ fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg));
+
+ props.setProps({ afterCreateOrganizationUrl: '/org/:slug' });
+ const { getByRole, userEvent, getByLabelText } = render(, {
+ wrapper,
+ });
+ await userEvent.type(getByLabelText(/Organization name/i), 'new org');
+ await userEvent.click(getByRole('button', { name: /create organization/i }));
+
+ expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.slug}`);
+ });
+ });
});
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
index 51820cf05e..ff3892c814 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
@@ -48,7 +48,8 @@ export const OrganizationSwitcherPopover = React.forwardRef {
- return card.runAsync(() => setActive({ organization, beforeEmit: navigateAfterSwitchOrganization })).then(close);
+ return card
+ .runAsync(() =>
+ setActive({
+ organization,
+ beforeEmit: () => navigateAfterSelectOrganization(organization),
+ }),
+ )
+ .then(close);
};
const handlePersonalWorkspaceClicked = () => {
return card
- .runAsync(() => setActive({ organization: null, beforeEmit: navigateAfterSwitchOrganization }))
+ .runAsync(() => setActive({ organization: null, beforeEmit: () => navigateAfterSelectPersonal(user) }))
.then(close);
};
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
index 7deeaf8934..2f6e403e55 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
@@ -131,6 +131,56 @@ describe('OrganizationSwitcher', () => {
expect(queryByRole('button', { name: 'Create Organization' })).not.toBeInTheDocument();
});
- it.todo('switches between active organizations when one is clicked');
+ it("switches between active organizations when one is clicked'", async () => {
+ const { wrapper, props, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.dev'],
+ organization_memberships: [
+ { name: 'Org1', role: 'basic_member' },
+ { name: 'Org2', role: 'admin' },
+ ],
+ create_organization_enabled: false,
+ });
+ });
+ fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());
+
+ props.setProps({ hidePersonal: true });
+ const { getByRole, getByText, userEvent } = render(, { wrapper });
+ await userEvent.click(getByRole('button'));
+ await userEvent.click(getByText('Org2'));
+
+ expect(fixtures.clerk.setActive).toHaveBeenCalledWith(
+ expect.objectContaining({
+ organization: expect.objectContaining({
+ name: 'Org2',
+ }),
+ }),
+ );
+ });
+
+ it("switches to personal workspace when clicked'", async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.dev'],
+ organization_memberships: [
+ { name: 'Org1', role: 'basic_member' },
+ { name: 'Org2', role: 'admin' },
+ ],
+ });
+ });
+
+ fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());
+ const { getByRole, getByText, userEvent } = render(, { wrapper });
+ await userEvent.click(getByRole('button'));
+ await userEvent.click(getByText(/Personal workspace/i));
+
+ expect(fixtures.clerk.setActive).toHaveBeenCalledWith(
+ expect.objectContaining({
+ organization: null,
+ }),
+ );
+ });
});
});
diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
index ebe1134114..624a455068 100644
--- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
+++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
@@ -1,6 +1,7 @@
+import type { OrganizationResource, UserResource } from '@clerk/types';
import React from 'react';
-import { buildAuthQueryString, buildURL, pickRedirectionProp } from '../../utils';
+import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils';
import { useCoreClerk, useEnvironment, useOptions } from '../contexts';
import type { ParsedQs } from '../router';
import { useRouter } from '../router';
@@ -15,6 +16,8 @@ import type {
UserProfileCtx,
} from '../types';
+const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ });
+
export const ComponentContext = React.createContext(null);
export type SignUpContextType = SignUpCtx & {
@@ -230,8 +233,50 @@ export const useOrganizationSwitcherContext = () => {
const navigateCreateOrganization = () => navigate(ctx.createOrganizationUrl || displayConfig.createOrganizationUrl);
const navigateOrganizationProfile = () =>
navigate(ctx.organizationProfileUrl || displayConfig.organizationProfileUrl);
- const navigateAfterSwitchOrganization = () =>
- ctx.afterSwitchOrganizationUrl ? navigate(ctx.afterSwitchOrganizationUrl) : Promise.resolve();
+
+ const navigateAfterSelectOrganizationOrPersonal = ({
+ organization,
+ user,
+ }: {
+ organization?: OrganizationResource;
+ user?: UserResource;
+ }) => {
+ if (typeof ctx.afterSelectPersonalUrl === 'function' && user) {
+ return navigate(ctx.afterSelectPersonalUrl(user));
+ }
+
+ if (typeof ctx.afterSelectOrganizationUrl === 'function' && organization) {
+ return navigate(ctx.afterSelectOrganizationUrl(organization));
+ }
+
+ if (ctx.afterSelectPersonalUrl && user) {
+ const parsedUrl = populateParamFromObject({
+ urlWithParam: ctx.afterSelectPersonalUrl as string,
+ entity: user,
+ });
+ return navigate(parsedUrl);
+ }
+
+ if (ctx.afterSelectOrganizationUrl && organization) {
+ const parsedUrl = populateParamFromObject({
+ urlWithParam: ctx.afterSelectOrganizationUrl as string,
+ entity: organization,
+ });
+ return navigate(parsedUrl);
+ }
+
+ // Continue to support afterSwitchOrganizationUrl
+ if (ctx.afterSwitchOrganizationUrl) {
+ return navigate(ctx.afterSwitchOrganizationUrl);
+ }
+
+ return Promise.resolve();
+ };
+
+ const navigateAfterSelectOrganization = (organization: OrganizationResource) =>
+ navigateAfterSelectOrganizationOrPersonal({ organization });
+
+ const navigateAfterSelectPersonal = (user: UserResource) => navigateAfterSelectOrganizationOrPersonal({ user });
return {
...ctx,
@@ -242,7 +287,8 @@ export const useOrganizationSwitcherContext = () => {
afterLeaveOrganizationUrl,
navigateOrganizationProfile,
navigateCreateOrganization,
- navigateAfterSwitchOrganization,
+ navigateAfterSelectOrganization,
+ navigateAfterSelectPersonal,
componentName,
};
};
@@ -275,8 +321,21 @@ export const useCreateOrganizationContext = () => {
throw new Error('Clerk: useCreateOrganizationContext called outside CreateOrganization.');
}
- const navigateAfterCreateOrganization = () =>
- navigate(ctx.afterCreateOrganizationUrl || displayConfig.afterCreateOrganizationUrl);
+ const navigateAfterCreateOrganization = (organization: OrganizationResource) => {
+ if (typeof ctx.afterCreateOrganizationUrl === 'function') {
+ return navigate(ctx.afterCreateOrganizationUrl(organization));
+ }
+
+ if (ctx.afterCreateOrganizationUrl) {
+ const parsedUrl = populateParamFromObject({
+ urlWithParam: ctx.afterCreateOrganizationUrl,
+ entity: organization,
+ });
+ return navigate(parsedUrl);
+ }
+
+ return navigate(displayConfig.afterCreateOrganizationUrl);
+ };
return {
...ctx,
diff --git a/packages/clerk-js/src/utils/__tests__/dynamicParamParser.test.ts b/packages/clerk-js/src/utils/__tests__/dynamicParamParser.test.ts
new file mode 100644
index 0000000000..3f9ceafd85
--- /dev/null
+++ b/packages/clerk-js/src/utils/__tests__/dynamicParamParser.test.ts
@@ -0,0 +1,29 @@
+import { describe } from '@jest/globals';
+
+import { createDynamicParamParser } from '../dynamicParamParser';
+
+const entity = {
+ foo: 'foo_string',
+ bar: 'bar_string',
+};
+
+describe('createDynamicParamParser', () => {
+ const testCases = [
+ [':foo', entity, 'foo_string'],
+ ['/:foo', entity, '/foo_string'],
+ ['/some/:bar/any', entity, '/some/bar_string/any'],
+ ['/:notValid', entity, '/:notValid'],
+ ] as const;
+
+ it.each(testCases)(
+ 'replaces the dynamic param with the value assigned to the key inside the object. Url=(%s), Object=(%s), result=(%s)',
+ (urlWithParam, obj, result) => {
+ expect(
+ createDynamicParamParser({ regex: /:(\w+)/ })({
+ urlWithParam,
+ entity: obj,
+ }),
+ ).toEqual(result);
+ },
+ );
+});
diff --git a/packages/clerk-js/src/utils/dynamicParamParser.ts b/packages/clerk-js/src/utils/dynamicParamParser.ts
new file mode 100644
index 0000000000..9f254312d9
--- /dev/null
+++ b/packages/clerk-js/src/utils/dynamicParamParser.ts
@@ -0,0 +1,14 @@
+export const createDynamicParamParser =
+ ({ regex }: { regex: RegExp }) =>
+ >({ urlWithParam, entity }: { urlWithParam: string; entity: T }) => {
+ const match = regex.exec(urlWithParam);
+
+ if (match) {
+ const key = match[1];
+ if (key in entity) {
+ const value = entity[key] as string;
+ return urlWithParam.replace(match[0], value);
+ }
+ }
+ return urlWithParam;
+ };
diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts
index c6c9dbc2de..5392e17056 100644
--- a/packages/clerk-js/src/utils/index.ts
+++ b/packages/clerk-js/src/utils/index.ts
@@ -1,6 +1,7 @@
export * from './beforeUnloadTracker';
export * from './componentGuards';
export * from './cookies';
+export * from './dynamicParamParser';
export * from './devBrowser';
export * from './email';
export * from './encoders';
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index 5445475b1b..155edd9c5a 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -661,7 +661,9 @@ export type CreateOrganizationProps = {
* Full URL or path to navigate after creating a new organization.
* @default undefined
*/
- afterCreateOrganizationUrl?: string;
+ afterCreateOrganizationUrl?:
+ | ((organization: OrganizationResource) => string)
+ | LooseExtractedParams>;
/**
* Hides the screen for sending invitations after an organization is created.
* @default undefined When left undefined Clerk will automatically hide the screen if
@@ -730,6 +732,12 @@ export type UserButtonProps = {
userProfileProps?: Pick;
};
+type PrimitiveKeys = {
+ [K in keyof T]: T[K] extends string | boolean | number | null ? K : never;
+}[keyof T];
+
+type LooseExtractedParams = `:${T}` | (string & NonNullable);
+
export type OrganizationSwitcherProps = {
/**
Controls the default state of the OrganizationSwitcher
@@ -746,13 +754,31 @@ export type OrganizationSwitcherProps = {
/**
* Full URL or path to navigate after a successful organization switch.
* @default undefined
+ * @deprecated use `afterSelectOrganizationUrl` or `afterSelectPersonalUrl`
*/
afterSwitchOrganizationUrl?: string;
/**
* Full URL or path to navigate after creating a new organization.
* @default undefined
*/
- afterCreateOrganizationUrl?: string;
+ afterCreateOrganizationUrl?:
+ | ((organization: OrganizationResource) => string)
+ | LooseExtractedParams>;
+ /**
+ * Full URL or path to navigate after a successful organization selection.
+ * Accepts a function that returns URL or path
+ * @default undefined`
+ */
+ afterSelectOrganizationUrl?:
+ | ((organization: OrganizationResource) => string)
+ | LooseExtractedParams>;
+
+ /**
+ * Full URL or path to navigate after a successful selection of personal workspace.
+ * Accepts a function that returns URL or path
+ * @default undefined`
+ */
+ afterSelectPersonalUrl?: ((user: UserResource) => string) | LooseExtractedParams>;
/**
* Full URL or path to navigate to after the user leaves the currently active organization.
* @default undefined