diff --git a/.changeset/wicked-seahorses-juggle.md b/.changeset/wicked-seahorses-juggle.md new file mode 100644 index 0000000000..92aed29cd2 --- /dev/null +++ b/.changeset/wicked-seahorses-juggle.md @@ -0,0 +1,6 @@ +--- +"@clerk/clerk-js": patch +"@clerk/types": patch +--- + +Introduce ability to set an active organization by slug diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 3397196167..3178012b22 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -335,18 +335,56 @@ describe('Clerk singleton', () => { return Promise.resolve(); }); - await sut.setActive({ organization: { id: 'org-id' } as Organization, beforeEmit: beforeEmitMock }); + await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); await waitFor(() => { expect(executionOrder).toEqual(['session.touch', 'set cookie', 'before emit']); expect(mockSession.touch).toHaveBeenCalled(); expect(mockSession.getToken).toHaveBeenCalled(); - expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org-id'); + expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); expect(beforeEmitMock).toBeCalledWith(mockSession); expect(sut.session).toMatchObject(mockSession); }); }); + it('sets active organization by slug', async () => { + const mockSession2 = { + id: '1', + status: 'active', + user: { + organizationMemberships: [ + { + id: 'orgmem_id', + organization: { + id: 'org_id', + slug: 'some-org-slug', + }, + }, + ], + }, + touch: jest.fn(), + getToken: jest.fn(), + }; + mockClientFetch.mockReturnValue(Promise.resolve({ activeSessions: [mockSession2] })); + const sut = new Clerk(productionPublishableKey); + await sut.load(); + + mockSession2.touch.mockImplementationOnce(() => { + sut.session = mockSession2 as any; + return Promise.resolve(); + }); + mockSession2.getToken.mockImplementation(() => 'mocked-token'); + + await sut.setActive({ organization: 'some-org-slug' }); + + await waitFor(() => { + expect(mockSession2.touch).toHaveBeenCalled(); + expect(mockSession2.getToken).toHaveBeenCalled(); + expect((mockSession2 as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); + expect(sut.session).toMatchObject(mockSession2); + }); + }); + mockNativeRuntime(() => { it('calls session.touch in a non-standard browser', async () => { mockClientFetch.mockReturnValue(Promise.resolve({ activeSessions: [mockSession] })); @@ -365,11 +403,11 @@ describe('Clerk singleton', () => { return Promise.resolve(); }); - await sut.setActive({ organization: { id: 'org-id' } as Organization, beforeEmit: beforeEmitMock }); + await sut.setActive({ organization: { id: 'org_id' } as Organization, beforeEmit: beforeEmitMock }); expect(executionOrder).toEqual(['session.touch', 'before emit']); expect(mockSession.touch).toHaveBeenCalled(); - expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org-id'); + expect((mockSession as any as ActiveSessionResource)?.lastActiveOrganizationId).toEqual('org_id'); expect(mockSession.getToken).toBeCalled(); expect(beforeEmitMock).toBeCalledWith(mockSession); expect(sut.session).toMatchObject(mockSession); @@ -1892,12 +1930,12 @@ describe('Clerk singleton', () => { BaseResource._fetch = jest.fn().mockResolvedValue({}); const sut = new Clerk(developmentPublishableKey); - await sut.getOrganization('some-org-id'); + await sut.getOrganization('org_id'); // @ts-expect-error - Mocking a protected method expect(BaseResource._fetch).toHaveBeenCalledWith({ method: 'GET', - path: '/organizations/some-org-id', + path: '/organizations/org_id', }); }); }); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 71ceb63e98..3668f245ff 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -75,6 +75,7 @@ import { inBrowser, isDevAccountPortalOrigin, isError, + isOrganizationId, isRedirectForFAPIInitiatedFlow, noOrganizationExists, noUserExists, @@ -707,9 +708,18 @@ export class Clerk implements ClerkInterface { // However, if the `organization` parameter is not given (i.e. `undefined`), we want // to keep the organization id that the session had. const shouldSwitchOrganization = organization !== undefined; + if (newSession && shouldSwitchOrganization) { - const organizationId = typeof organization === 'string' ? organization : organization?.id; - newSession.lastActiveOrganizationId = organizationId || null; + const organizationIdOrSlug = typeof organization === 'string' ? organization : organization?.id; + + if (isOrganizationId(organizationIdOrSlug)) { + newSession.lastActiveOrganizationId = organizationIdOrSlug || null; + } else { + const matchingOrganization = newSession.user.organizationMemberships.find( + mem => mem.organization.slug === organizationIdOrSlug, + ); + newSession.lastActiveOrganizationId = matchingOrganization?.organization.id || null; + } } // If this.session exists, then signOut was triggered by the current tab diff --git a/packages/clerk-js/src/utils/__tests__/organization.test.ts b/packages/clerk-js/src/utils/__tests__/organization.test.ts new file mode 100644 index 0000000000..4262f07337 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/organization.test.ts @@ -0,0 +1,20 @@ +import { isOrganizationId } from '../organization'; + +describe('isOrganizationId(string)', () => { + it('should return true for strings starting with `org_`', () => { + expect(isOrganizationId('org_123')).toBe(true); + expect(isOrganizationId('org_abc')).toBe(true); + }); + + it('should return false for strings not starting with `org_`', () => { + expect(isOrganizationId('user_123')).toBe(false); + expect(isOrganizationId('123org_')).toBe(false); + expect(isOrganizationId('ORG_123')).toBe(false); + }); + + it('should handle falsy values', () => { + expect(isOrganizationId(undefined)).toBe(false); + expect(isOrganizationId(null)).toBe(false); + expect(isOrganizationId('')).toBe(false); + }); +}); diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index 2defd829ff..cdffa71c21 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -25,3 +25,4 @@ export * from './queryStateParams'; export * from './normalizeRoutingOptions'; export * from './image'; export * from './completeSignUpFlow'; +export * from './organization'; diff --git a/packages/clerk-js/src/utils/organization.ts b/packages/clerk-js/src/utils/organization.ts new file mode 100644 index 0000000000..5106fb93d9 --- /dev/null +++ b/packages/clerk-js/src/utils/organization.ts @@ -0,0 +1,8 @@ +/** + * Checks and assumes a string is an organization ID if it starts with 'org_', specifically for + * disambiguating with slugs. `_` is a disallowed character in slug names, so slugs cannot + * start with `org_`. + */ +export function isOrganizationId(id: string | null | undefined): boolean { + return typeof id === 'string' && id.startsWith('org_'); +} diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 62ce64ccb4..61858a26df 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -681,7 +681,7 @@ export type SetActiveParams = { session?: ActiveSessionResource | string | null; /** - * The organization resource or organization id (string version) to be set as active in the current session. + * The organization resource or organization ID/slug (string version) to be set as active in the current session. * If `null`, the currently active organization is removed as active. */ organization?: OrganizationResource | string | null;