From d42b712ea4769e9cb97212c15c653326bb9785ab Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Fri, 19 Jun 2026 09:59:23 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(clerk-js,ui):=20exclusive=20membership?= =?UTF-8?q?=20=E2=80=94=20show=20only=20exclusive=20org=20in=20switcher/li?= =?UTF-8?q?st/chooser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/exclusive-membership-switcher.md | 7 ++++ .../core/resources/OrganizationMembership.ts | 4 ++ .../__tests__/OrganizationMembership.test.ts | 2 + packages/shared/src/types/json.ts | 1 + .../src/types/organizationMembership.ts | 4 ++ .../OrganizationList/OrganizationListPage.tsx | 27 +++++++----- .../OrganizationList/UserMembershipList.tsx | 6 ++- .../UserMembershipList.tsx | 17 +++++--- .../ChooseOrganizationScreen.tsx | 25 +++++++---- .../src/utils/filterExclusiveMemberships.ts | 42 +++++++++++++++++++ 10 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 .changeset/exclusive-membership-switcher.md create mode 100644 packages/ui/src/utils/filterExclusiveMemberships.ts diff --git a/.changeset/exclusive-membership-switcher.md b/.changeset/exclusive-membership-switcher.md new file mode 100644 index 00000000000..6f190eb5b32 --- /dev/null +++ b/.changeset/exclusive-membership-switcher.md @@ -0,0 +1,7 @@ +--- +"@clerk/clerk-js": minor +"@clerk/shared": minor +"@clerk/ui": minor +--- + +Add `exclusiveMembership` to organization memberships and filter the organization switcher/list/chooser to show only exclusive org(s) when present. diff --git a/packages/clerk-js/src/core/resources/OrganizationMembership.ts b/packages/clerk-js/src/core/resources/OrganizationMembership.ts index a4ec4299114..a05c4824fa9 100644 --- a/packages/clerk-js/src/core/resources/OrganizationMembership.ts +++ b/packages/clerk-js/src/core/resources/OrganizationMembership.ts @@ -22,6 +22,7 @@ export class OrganizationMembership extends BaseResource implements Organization permissions: OrganizationPermissionKey[] = []; role!: OrganizationCustomRoleKey; roleName!: string; + exclusiveMembership!: boolean; createdAt!: Date; updatedAt!: Date; @@ -79,6 +80,8 @@ export class OrganizationMembership extends BaseResource implements Organization this.permissions = Array.isArray(data.permissions) ? [...data.permissions] : []; this.role = data.role; this.roleName = data.role_name; + // Default to `false` since the backend may not emit this field yet. + this.exclusiveMembership = data.exclusive_membership ?? false; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); return this; @@ -94,6 +97,7 @@ export class OrganizationMembership extends BaseResource implements Organization permissions: this.permissions, role: this.role, role_name: this.roleName, + exclusive_membership: this.exclusiveMembership, created_at: this.createdAt.getTime(), updated_at: this.updatedAt.getTime(), }; diff --git a/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts b/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts index 6ab590ab810..1b419fdbfd3 100644 --- a/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts @@ -11,6 +11,7 @@ describe('OrganizationMembership', () => { updated_at: 5678, role: 'admin', permissions: [], + exclusive_membership: true, organization: { id: 'test_org_id', image_url: @@ -46,6 +47,7 @@ describe('OrganizationMembership', () => { id: 'test_id', role: 'admin', roleName: undefined, + exclusiveMembership: true, createdAt: expect.any(Date), updatedAt: expect.any(Date), permissions: [], diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 765ca1d6208..2a92eed7edf 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -410,6 +410,7 @@ export interface OrganizationMembershipJSON extends ClerkResourceJSON { public_user_data?: PublicUserDataJSON; role: OrganizationCustomRoleKey; role_name: string; + exclusive_membership: boolean; created_at: number; updated_at: number; } diff --git a/packages/shared/src/types/organizationMembership.ts b/packages/shared/src/types/organizationMembership.ts index ca17721a71a..ba8583ad81c 100644 --- a/packages/shared/src/types/organizationMembership.ts +++ b/packages/shared/src/types/organizationMembership.ts @@ -69,6 +69,10 @@ export interface OrganizationMembershipResource extends ClerkResource { * The name of the [Role](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions) of the member in the Organization. */ roleName: string; + /** + * Whether this membership is to an Organization that enforces exclusive membership. When `true`, the user can only belong to this Organization and cannot access a personal account or other Organizations. + */ + exclusiveMembership: boolean; /** * The date when the membership was created. */ diff --git a/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx b/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx index db8937232d2..722bb23b2da 100644 --- a/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx +++ b/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx @@ -7,6 +7,7 @@ import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Header } from '@/ui/elements/Header'; import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView'; +import { filterExclusiveMemberships } from '@/ui/utils/filterExclusiveMemberships'; import { useEnvironment, useOrganizationListContext } from '../../contexts'; import { Box, Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; @@ -133,6 +134,13 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () const userInvitationsData = userInvitations.data?.filter(a => !!a); const userSuggestionsData = userSuggestions.data?.filter(a => !!a); + // `userMemberships` is paginated; the exclusive filter operates on the currently loaded + // memberships. Exclusive members are locked to ~1 organization, so pagination is a non-issue. + // Invitations/suggestions are intentionally left unfiltered: an exclusive user should not have + // any, and the backend blocks them from joining other organizations. + const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; + const { memberships: visibleMemberships, hasExclusive } = filterExclusiveMemberships(loadedMemberships); + return ( <> - - {(userMemberships.count || 0) > 0 && - userMemberships.data?.map(inv => { - return ( - - ); - })} + + {visibleMemberships.map(inv => { + return ( + + ); + })} {!userMemberships.hasNextPage && userInvitationsData?.map(inv => { diff --git a/packages/ui/src/components/OrganizationList/UserMembershipList.tsx b/packages/ui/src/components/OrganizationList/UserMembershipList.tsx index a7dec64439b..4f8788a5e2c 100644 --- a/packages/ui/src/components/OrganizationList/UserMembershipList.tsx +++ b/packages/ui/src/components/OrganizationList/UserMembershipList.tsx @@ -72,7 +72,7 @@ export const MembershipPreview = (props: { organization: OrganizationResource }) ); }; -export const PersonalAccountPreview = withCardStateProvider(() => { +export const PersonalAccountPreview = withCardStateProvider((props: { forceHide?: boolean }) => { const card = useCardState(); const { hidePersonal, navigateAfterSelectPersonal } = useOrganizationListContext(); const { isLoaded, setActive } = useOrganizationList(); @@ -97,7 +97,9 @@ export const PersonalAccountPreview = withCardStateProvider(() => { }); }; - if (hidePersonal) { + // `forceHide` is set when the user has an exclusive membership, in which case the personal + // account must always be hidden. + if (hidePersonal || props.forceHide) { return null; } diff --git a/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx b/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx index f215bfee375..fd5de29bfcb 100644 --- a/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { OrganizationPreview } from '@/ui/elements/OrganizationPreview'; import { PersonalWorkspacePreview } from '@/ui/elements/PersonalWorkspacePreview'; import { PreviewButton } from '@/ui/elements/PreviewButton'; +import { filterExclusiveMemberships } from '@/ui/utils/filterExclusiveMemberships'; import { InfiniteListSpinner } from '../../common'; import { useOrganizationSwitcherContext } from '../../contexts'; @@ -49,9 +50,15 @@ export const UserMembershipList = (props: UserMembershipListProps) => { const { ref, userMemberships } = useFetchMemberships(); const { user } = useUser(); - const otherOrgs = ((userMemberships.count || 0) > 0 ? userMemberships.data || [] : []) - .map(e => e.organization) - .filter(o => o.id !== currentOrg?.id); + // `userMemberships` is paginated; the exclusive filter operates on the currently loaded + // memberships. Exclusive members are locked to ~1 organization, so pagination is a non-issue. + const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; + const { memberships: visibleMemberships, hasExclusive } = filterExclusiveMemberships(loadedMemberships); + + const otherOrgs = visibleMemberships.map(e => e.organization).filter(o => o.id !== currentOrg?.id); + + // When the user has an exclusive membership, the personal workspace must always be hidden. + const hidePersonalWorkspace = hidePersonal || hasExclusive; if (!user) { return null; @@ -75,9 +82,9 @@ export const UserMembershipList = (props: UserMembershipListProps) => { ...common.unstyledScrollbar(t), })} role='group' - aria-label={hidePersonal ? 'List of all organization memberships' : 'List of all accounts'} + aria-label={hidePersonalWorkspace ? 'List of all organization memberships' : 'List of all accounts'} > - {currentOrg && !hidePersonal && ( + {currentOrg && !hidePersonalWorkspace && ( void; @@ -44,6 +45,13 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = const userInvitationsData = userInvitations.data?.filter(a => !!a); const userSuggestionsData = userSuggestions.data?.filter(a => !!a); + // `userMemberships` is paginated; the exclusive filter operates on the currently loaded + // memberships. Exclusive members are locked to ~1 organization, so pagination is a non-issue. + // Invitations/suggestions are intentionally left unfiltered: an exclusive user should not have + // any, and the backend blocks them from joining other organizations. + const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; + const { memberships: visibleMemberships } = filterExclusiveMemberships(loadedMemberships); + return ( <> - {(userMemberships.count || 0) > 0 && - userMemberships.data?.map(inv => { - return ( - - ); - })} + {visibleMemberships.map(inv => { + return ( + + ); + })} {!userMemberships.hasNextPage && userInvitationsData?.map(inv => { diff --git a/packages/ui/src/utils/filterExclusiveMemberships.ts b/packages/ui/src/utils/filterExclusiveMemberships.ts new file mode 100644 index 00000000000..bb7226db46b --- /dev/null +++ b/packages/ui/src/utils/filterExclusiveMemberships.ts @@ -0,0 +1,42 @@ +import type { OrganizationMembershipResource } from '@clerk/shared/types'; + +type FilterExclusiveMembershipsResult> = { + /** + * When at least one exclusive membership is present, only the exclusive memberships are returned. + * Otherwise the input list is returned unchanged. + */ + memberships: T[]; + /** + * `true` when the user has at least one exclusive membership. Callers should hide the personal + * account/workspace and any non-exclusive organizations when this is `true`. + */ + hasExclusive: boolean; +}; + +/** + * Filters a list of organization memberships for exclusive membership. + * + * When the user belongs to at least one organization that enforces exclusive membership + * (`exclusiveMembership === true`), only those exclusive memberships are returned and `hasExclusive` + * is `true` (the caller should also hide the personal account and any non-exclusive organizations). + * Otherwise the list is returned unchanged and `hasExclusive` is `false`. + * + * Note: memberships are paginated, so this operates on the currently loaded memberships. Exclusive + * members are locked to ~1 organization, so pagination is a non-issue for them. + */ +export const filterExclusiveMemberships = < + T extends Pick, +>( + memberships: T[], +): FilterExclusiveMembershipsResult => { + const hasExclusive = memberships.some(membership => membership.exclusiveMembership); + + if (!hasExclusive) { + return { memberships, hasExclusive: false }; + } + + return { + memberships: memberships.filter(membership => membership.exclusiveMembership), + hasExclusive: true, + }; +}; From d19de6624418a5896eeaa5cd0d0c6996f1396406 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Fri, 19 Jun 2026 10:40:16 -0300 Subject: [PATCH 2/4] style(ui): prettier format filterExclusiveMemberships --- packages/ui/src/utils/filterExclusiveMemberships.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ui/src/utils/filterExclusiveMemberships.ts b/packages/ui/src/utils/filterExclusiveMemberships.ts index bb7226db46b..92e1f1c3ddb 100644 --- a/packages/ui/src/utils/filterExclusiveMemberships.ts +++ b/packages/ui/src/utils/filterExclusiveMemberships.ts @@ -24,9 +24,7 @@ type FilterExclusiveMembershipsResult, ->( +export const filterExclusiveMemberships = >( memberships: T[], ): FilterExclusiveMembershipsResult => { const hasExclusive = memberships.some(membership => membership.exclusiveMembership); From 126434e5261d9aee65f6fd9f50be1b45e6b7cd0b Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 22 Jun 2026 13:47:23 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(ui):=20exclusive=20membership=20?= =?UTF-8?q?=E2=80=94=20read=20org-level=20flag,=20gate=20join/create=20sur?= =?UTF-8?q?faces,=20derive=20from=20full=20membership=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/exclusive-membership-switcher.md | 4 +- .../core/resources/OrganizationMembership.ts | 4 - .../__tests__/OrganizationMembership.test.ts | 2 - packages/shared/src/types/json.ts | 1 - .../src/types/organizationMembership.ts | 4 - .../OrganizationList/OrganizationListPage.tsx | 30 ++++-- .../__tests__/OrganizationList.test.tsx | 59 +++++++++++- .../UserMembershipList.tsx | 15 ++- .../__tests__/OrganizationSwitcher.test.tsx | 59 ++++++++++++ .../ChooseOrganizationScreen.tsx | 44 ++++++--- .../__tests__/TaskChooseOrganization.test.tsx | 95 ++++++++++++++++++- .../tasks/TaskChooseOrganization/index.tsx | 4 +- .../filterExclusiveMemberships.test.ts | 61 ++++++++++++ .../src/utils/filterExclusiveMemberships.ts | 18 ++-- 14 files changed, 348 insertions(+), 52 deletions(-) create mode 100644 packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts diff --git a/.changeset/exclusive-membership-switcher.md b/.changeset/exclusive-membership-switcher.md index 6f190eb5b32..78dfedc59ca 100644 --- a/.changeset/exclusive-membership-switcher.md +++ b/.changeset/exclusive-membership-switcher.md @@ -1,7 +1,5 @@ --- -"@clerk/clerk-js": minor -"@clerk/shared": minor "@clerk/ui": minor --- -Add `exclusiveMembership` to organization memberships and filter the organization switcher/list/chooser to show only exclusive org(s) when present. +Filter the organization switcher, organization list, and choose-organization task to show only the exclusive organization(s) when the user belongs to one. When an exclusive membership is present, the personal account/workspace, non-exclusive organizations, invitations, suggestions, and the create-organization action are hidden. Exclusivity is read from the existing organization-level `Organization.exclusiveMembership` flag, and the determination is made from the user's full membership set so it is not affected by membership pagination. diff --git a/packages/clerk-js/src/core/resources/OrganizationMembership.ts b/packages/clerk-js/src/core/resources/OrganizationMembership.ts index a05c4824fa9..a4ec4299114 100644 --- a/packages/clerk-js/src/core/resources/OrganizationMembership.ts +++ b/packages/clerk-js/src/core/resources/OrganizationMembership.ts @@ -22,7 +22,6 @@ export class OrganizationMembership extends BaseResource implements Organization permissions: OrganizationPermissionKey[] = []; role!: OrganizationCustomRoleKey; roleName!: string; - exclusiveMembership!: boolean; createdAt!: Date; updatedAt!: Date; @@ -80,8 +79,6 @@ export class OrganizationMembership extends BaseResource implements Organization this.permissions = Array.isArray(data.permissions) ? [...data.permissions] : []; this.role = data.role; this.roleName = data.role_name; - // Default to `false` since the backend may not emit this field yet. - this.exclusiveMembership = data.exclusive_membership ?? false; this.createdAt = unixEpochToDate(data.created_at); this.updatedAt = unixEpochToDate(data.updated_at); return this; @@ -97,7 +94,6 @@ export class OrganizationMembership extends BaseResource implements Organization permissions: this.permissions, role: this.role, role_name: this.roleName, - exclusive_membership: this.exclusiveMembership, created_at: this.createdAt.getTime(), updated_at: this.updatedAt.getTime(), }; diff --git a/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts b/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts index 1b419fdbfd3..6ab590ab810 100644 --- a/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/OrganizationMembership.test.ts @@ -11,7 +11,6 @@ describe('OrganizationMembership', () => { updated_at: 5678, role: 'admin', permissions: [], - exclusive_membership: true, organization: { id: 'test_org_id', image_url: @@ -47,7 +46,6 @@ describe('OrganizationMembership', () => { id: 'test_id', role: 'admin', roleName: undefined, - exclusiveMembership: true, createdAt: expect.any(Date), updatedAt: expect.any(Date), permissions: [], diff --git a/packages/shared/src/types/json.ts b/packages/shared/src/types/json.ts index 2a92eed7edf..765ca1d6208 100644 --- a/packages/shared/src/types/json.ts +++ b/packages/shared/src/types/json.ts @@ -410,7 +410,6 @@ export interface OrganizationMembershipJSON extends ClerkResourceJSON { public_user_data?: PublicUserDataJSON; role: OrganizationCustomRoleKey; role_name: string; - exclusive_membership: boolean; created_at: number; updated_at: number; } diff --git a/packages/shared/src/types/organizationMembership.ts b/packages/shared/src/types/organizationMembership.ts index ba8583ad81c..ca17721a71a 100644 --- a/packages/shared/src/types/organizationMembership.ts +++ b/packages/shared/src/types/organizationMembership.ts @@ -69,10 +69,6 @@ export interface OrganizationMembershipResource extends ClerkResource { * The name of the [Role](https://clerk.com/docs/guides/organizations/control-access/roles-and-permissions) of the member in the Organization. */ roleName: string; - /** - * Whether this membership is to an Organization that enforces exclusive membership. When `true`, the user can only belong to this Organization and cannot access a personal account or other Organizations. - */ - exclusiveMembership: boolean; /** * The date when the membership was created. */ diff --git a/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx b/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx index 722bb23b2da..5f032ccc091 100644 --- a/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx +++ b/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx @@ -1,3 +1,4 @@ +import { useUser } from '@clerk/shared/react'; import { useState } from 'react'; import { CreateOrganizationAction } from '@/common/CreateOrganizationAction'; @@ -119,9 +120,18 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () const { ref, userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); const { hidePersonal } = useOrganizationListContext(); + const { user } = useUser(); + + // Derive `hasExclusive` from the full, non-paginated membership set on the User resource so it never + // fails open when the exclusive membership is not on the currently loaded page of `userMemberships`. + const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; - const hasNextPage = userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; + // When the user has an exclusive membership, invitations/suggestions are hidden and we never load + // additional membership pages, so the spinner/observer must not keep paginating. + const hasNextPage = hasExclusive + ? false + : userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; const onCreateOrganizationClick = () => { // Clear error originated from the list when switching to form @@ -134,12 +144,10 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () const userInvitationsData = userInvitations.data?.filter(a => !!a); const userSuggestionsData = userSuggestions.data?.filter(a => !!a); - // `userMemberships` is paginated; the exclusive filter operates on the currently loaded - // memberships. Exclusive members are locked to ~1 organization, so pagination is a non-issue. - // Invitations/suggestions are intentionally left unfiltered: an exclusive user should not have - // any, and the backend blocks them from joining other organizations. + // The displayed list still filters the currently loaded page to exclusive-only when the user has an + // exclusive membership, so a partially-loaded page can never surface a non-exclusive organization. const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; - const { memberships: visibleMemberships, hasExclusive } = filterExclusiveMemberships(loadedMemberships); + const { memberships: visibleMemberships } = filterExclusiveMemberships(loadedMemberships); return ( <> @@ -174,7 +182,10 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () ); })} - {!userMemberships.hasNextPage && + {/* When the user has an exclusive membership they must not see ways to join or create + other organizations: invitations, suggestions, and the create button are all hidden. */} + {!hasExclusive && + !userMemberships.hasNextPage && userInvitationsData?.map(inv => { return ( { return ( @@ -197,7 +209,7 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () {(hasNextPage || isLoading) && } - + {!hasExclusive && } diff --git a/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx b/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx index 78356967931..b80a90d5cbe 100644 --- a/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx +++ b/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx @@ -3,12 +3,12 @@ import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, waitFor } from '@/test/utils'; -import { OrganizationList } from '../'; import { createFakeOrganization } from '../../CreateOrganization/__tests__/CreateOrganization.test'; import { createFakeUserOrganizationInvitation, createFakeUserOrganizationMembership, } from '../../OrganizationSwitcher/__tests__/test-utils'; +import { OrganizationList } from '../'; const { createFixtures } = bindCreateFixtures('OrganizationList'); @@ -188,6 +188,63 @@ describe('OrganizationList', () => { }); }); + describe('with an exclusive membership', () => { + it('shows only the exclusive org and hides personal/create/invitation surfaces', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + organization_memberships: [ + { id: '1', name: 'Exclusive Org', slug: 'exclusive', exclusive_membership: true }, + ], + }); + }); + + const exclusiveMembership = createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Exclusive Org', + slug: 'exclusive', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + pendingInvitationsCount: 0, + }, + }); + // The displayed-list filter reads `organization.exclusiveMembership`, which the fake org factory + // does not set, so flag it explicitly on the loaded page. + (exclusiveMembership.organization as any).exclusiveMembership = true; + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( + Promise.resolve({ + data: [exclusiveMembership], + total_count: 1, + }), + ); + + const invitation = createFakeUserOrganizationInvitation({ + id: '1', + emailAddress: 'one@clerk.com', + publicOrganizationData: { name: 'InvitedOrg' }, + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( + Promise.resolve({ data: [invitation], total_count: 1 }), + ); + + const { findByText, queryByText, queryByRole } = render(, { wrapper }); + + // The exclusive org is shown. + expect(await findByText('Exclusive Org')).toBeInTheDocument(); + // Personal account, invitations, and the create button are all hidden. + expect(queryByText('Personal account')).not.toBeInTheDocument(); + expect(queryByText('InvitedOrg')).not.toBeInTheDocument(); + expect(queryByRole('menuitem', { name: 'Create organization' })).not.toBeInTheDocument(); + }); + }); + describe('with force organization selection setting on environment', () => { it('does not show the personal account', async () => { const { wrapper } = await createFixtures(f => { diff --git a/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx b/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx index fd5de29bfcb..c3c456a785f 100644 --- a/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx @@ -50,10 +50,14 @@ export const UserMembershipList = (props: UserMembershipListProps) => { const { ref, userMemberships } = useFetchMemberships(); const { user } = useUser(); - // `userMemberships` is paginated; the exclusive filter operates on the currently loaded - // memberships. Exclusive members are locked to ~1 organization, so pagination is a non-issue. + // Derive `hasExclusive` from the full, non-paginated membership set on the User resource so it never + // fails open when the exclusive membership is not on the currently loaded page of `userMemberships`. + const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); + + // The displayed list still filters the currently loaded page to exclusive-only, so a partially-loaded + // page can never surface a non-exclusive organization. const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; - const { memberships: visibleMemberships, hasExclusive } = filterExclusiveMemberships(loadedMemberships); + const { memberships: visibleMemberships } = filterExclusiveMemberships(loadedMemberships); const otherOrgs = visibleMemberships.map(e => e.organization).filter(o => o.id !== currentOrg?.id); @@ -66,7 +70,10 @@ export const UserMembershipList = (props: UserMembershipListProps) => { const { primaryEmailAddress, primaryPhoneNumber, primaryWeb3Wallet, username, ...userWithoutIdentifiers } = user; - const { isLoading, hasNextPage } = userMemberships; + const { isLoading } = userMemberships; + // When the user has an exclusive membership we never load additional pages, so the infinite-scroll + // observer must not keep paginating (a later page could otherwise surface non-exclusive orgs). + const hasNextPage = hasExclusive ? false : userMemberships.hasNextPage; return ( { expect(getByText('Org2')).toBeInTheDocument(); }); + it('lists only the exclusive organization and hides the personal workspace', async () => { + const { wrapper, props, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [ + { name: 'Org1', id: '1', slug: 'org1', exclusive_membership: true }, + { name: 'Org2', id: '2', slug: 'org2' }, + ], + }); + }); + fixtures.clerk.organization?.getRoles.mockRejectedValue(null); + + const exclusiveMembership = createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + pendingInvitationsCount: 1, + }, + }); + // The displayed-list filter reads `organization.exclusiveMembership`, which the fake org factory + // does not set, so flag it explicitly on the loaded page. + (exclusiveMembership.organization as any).exclusiveMembership = true; + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + Promise.resolve({ + data: [ + exclusiveMembership, + createFakeUserOrganizationMembership({ + id: '2', + organization: { + id: '2', + name: 'Org2', + slug: 'org2', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 2, + }), + ); + + props.setProps({ hidePersonal: false }); + const { getAllByText, queryByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button')); + // Only the exclusive org is present; the non-exclusive org and personal workspace are hidden. + expect(getAllByText('Org1').length).toBeGreaterThan(0); + expect(queryByText('Org2')).not.toBeInTheDocument(); + expect(queryByText('Personal account')).not.toBeInTheDocument(); + }); + it('does allow creating organization if max allowed memberships is not reached', async () => { const { wrapper, props } = await createFixtures(f => { f.withOrganizations(); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx index 55c50f48074..a0f8f43effc 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx @@ -37,18 +37,26 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = const { user } = useUser(); const { ref, userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); + // Derive `hasExclusive` from the full, non-paginated membership set on the User resource so it never + // fails open when the exclusive membership is not on the currently loaded page of `userMemberships`. + // This screen also renders as the auto-activate failure fallback, so it cannot assume the backend + // blocks an exclusive user from joining/creating other organizations — it must enforce that here. + const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); + const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; - const hasNextPage = userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; + // When the user has an exclusive membership, invitations/suggestions are hidden and we never load + // additional membership pages, so the spinner/observer must not keep paginating. + const hasNextPage = hasExclusive + ? false + : userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; // Filter out falsy values that can occur when infinite loading resolves pages out of order // This happens when concurrent requests resolve in unexpected order, leaving undefined/null items in the data array const userInvitationsData = userInvitations.data?.filter(a => !!a); const userSuggestionsData = userSuggestions.data?.filter(a => !!a); - // `userMemberships` is paginated; the exclusive filter operates on the currently loaded - // memberships. Exclusive members are locked to ~1 organization, so pagination is a non-issue. - // Invitations/suggestions are intentionally left unfiltered: an exclusive user should not have - // any, and the backend blocks them from joining other organizations. + // The displayed list still filters the currently loaded page to exclusive-only, so a partially-loaded + // page can never surface a non-exclusive organization. const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; const { memberships: visibleMemberships } = filterExclusiveMemberships(loadedMemberships); @@ -80,7 +88,12 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = ); })} - {!userMemberships.hasNextPage && + {/* When the user has an exclusive membership they must not see ways to join or create + other organizations: invitations, suggestions, and the create button are all hidden. + This screen renders as the auto-activate failure fallback, so it enforces this directly + rather than relying on the backend to block the user. */} + {!hasExclusive && + !userMemberships.hasNextPage && userInvitationsData?.map(inv => { return ( { return ( @@ -103,13 +117,15 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = {(hasNextPage || isLoading) && } - { - // Clear error originated from the list when switching to form - card.setError(undefined); - props.onCreateOrganizationClick(); - }} - /> + {!hasExclusive && ( + { + // Clear error originated from the list when switching to form + card.setError(undefined); + props.onCreateOrganizationClick(); + }} + /> + )} diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx index 8af236fcf18..df640bdcbb6 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx @@ -1,3 +1,4 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; import type { OrganizationResource } from '@clerk/shared/src/types'; import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -11,8 +12,8 @@ import { } from '@/ui/components/OrganizationSwitcher/__tests__/test-utils'; import { clearFetchCache } from '@/ui/hooks/useFetch'; -import { TaskChooseOrganization } from '..'; import type { FakeOrganizationParams } from '../../../../CreateOrganization/__tests__/CreateOrganization.test'; +import { TaskChooseOrganization } from '..'; type FakeOrganizationParams = { id: string; @@ -499,4 +500,96 @@ describe('TaskChooseOrganization', () => { }); }); }); + + describe('with an exclusive membership', () => { + it('auto-activates the exclusive organization without rendering the picker', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + organization_memberships: [{ id: '1', name: 'Exclusive Org', slug: 'exclusive', exclusive_membership: true }], + }); + }); + + fixtures.clerk.setActive.mockReturnValue(Promise.resolve()); + + render(, { wrapper }); + + await waitFor(() => { + expect(fixtures.clerk.setActive).toHaveBeenCalled(); + }); + }); + + it('hides create/invitation/suggestion surfaces on the fallback screen when auto-activation fails', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + organization_memberships: [{ id: '1', name: 'Exclusive Org', slug: 'exclusive', exclusive_membership: true }], + }); + }); + + // Force the auto-activation to fail so the choose-organization fallback screen renders. A Clerk + // API error is used so the component's error handler surfaces it and falls back gracefully (an + // unknown error would be rethrown by `handleError`). + fixtures.clerk.setActive.mockRejectedValue( + new ClerkAPIResponseError('activation failed', { + data: [{ code: 'organization_not_found_or_unauthorized', message: 'activation failed' }], + status: 403, + }), + ); + + const exclusiveMembership = createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Exclusive Org', + slug: 'exclusive', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + pendingInvitationsCount: 0, + }, + }); + // The displayed-list filter reads `organization.exclusiveMembership`, which the fake org factory + // does not set, so flag it explicitly on the loaded page. + (exclusiveMembership.organization as any).exclusiveMembership = true; + + // A loaded membership page plus an outstanding suggestion: neither create nor suggestion may show. + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( + Promise.resolve({ + data: [exclusiveMembership], + total_count: 1, + }), + ); + + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationSuggestion({ + id: '2', + emailAddress: 'two@clerk.com', + publicOrganizationData: { name: 'OrgTwoSuggestion' }, + }), + ], + total_count: 1, + }), + ); + + const { findByText, queryByText, queryByRole } = render(, { wrapper }); + + // The exclusive organization is still listed on the fallback screen. + expect(await findByText('Exclusive Org')).toBeInTheDocument(); + // But none of the join/create surfaces are rendered. + expect(queryByText('Create new organization')).not.toBeInTheDocument(); + expect(queryByRole('textbox', { name: /name/i })).not.toBeInTheDocument(); + expect(queryByText('OrgTwoSuggestion')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx index 57cc61fc215..3a22353ebff 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -107,7 +107,9 @@ const TaskChooseOrganizationInternal = () => { ) : ( )} diff --git a/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts b/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts new file mode 100644 index 00000000000..3cd3a2e5ba1 --- /dev/null +++ b/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts @@ -0,0 +1,61 @@ +import type { OrganizationMembershipResource, OrganizationResource } from '@clerk/shared/types'; +import { describe, expect, test } from 'vitest'; + +import { filterExclusiveMemberships } from '../filterExclusiveMemberships'; + +type TestMembership = Pick & { id: string }; + +const createMembership = (id: string, exclusiveMembership: boolean): TestMembership => ({ + id, + // Only the fields the filter reads need to be realistic; the rest is irrelevant to the unit under test. + organization: { id: `org_${id}`, exclusiveMembership } as OrganizationResource, +}); + +describe('filterExclusiveMemberships', () => { + test('returns an empty list with hasExclusive false for an empty input', () => { + const result = filterExclusiveMemberships([]); + + expect(result.hasExclusive).toBe(false); + expect(result.memberships).toEqual([]); + }); + + test('returns the input unchanged with hasExclusive false when no membership is exclusive', () => { + const memberships = [createMembership('1', false), createMembership('2', false), createMembership('3', false)]; + + const result = filterExclusiveMemberships(memberships); + + expect(result.hasExclusive).toBe(false); + // The same array reference is returned unchanged when there is no exclusive membership. + expect(result.memberships).toBe(memberships); + }); + + test('returns only the exclusive membership when one exclusive is present among non-exclusive ones', () => { + const exclusive = createMembership('2', true); + const memberships = [createMembership('1', false), exclusive, createMembership('3', false)]; + + const result = filterExclusiveMemberships(memberships); + + expect(result.hasExclusive).toBe(true); + expect(result.memberships).toEqual([exclusive]); + }); + + test('returns all memberships when every membership is exclusive', () => { + const memberships = [createMembership('1', true), createMembership('2', true)]; + + const result = filterExclusiveMemberships(memberships); + + expect(result.hasExclusive).toBe(true); + expect(result.memberships).toEqual(memberships); + }); + + test('returns only the exclusive memberships when multiple exclusive are mixed with non-exclusive', () => { + const exclusiveA = createMembership('1', true); + const exclusiveB = createMembership('3', true); + const memberships = [exclusiveA, createMembership('2', false), exclusiveB, createMembership('4', false)]; + + const result = filterExclusiveMemberships(memberships); + + expect(result.hasExclusive).toBe(true); + expect(result.memberships).toEqual([exclusiveA, exclusiveB]); + }); +}); diff --git a/packages/ui/src/utils/filterExclusiveMemberships.ts b/packages/ui/src/utils/filterExclusiveMemberships.ts index 92e1f1c3ddb..fd27017717c 100644 --- a/packages/ui/src/utils/filterExclusiveMemberships.ts +++ b/packages/ui/src/utils/filterExclusiveMemberships.ts @@ -1,6 +1,6 @@ import type { OrganizationMembershipResource } from '@clerk/shared/types'; -type FilterExclusiveMembershipsResult> = { +type FilterExclusiveMembershipsResult> = { /** * When at least one exclusive membership is present, only the exclusive memberships are returned. * Otherwise the input list is returned unchanged. @@ -16,25 +16,27 @@ type FilterExclusiveMembershipsResult>( +export const filterExclusiveMemberships = >( memberships: T[], ): FilterExclusiveMembershipsResult => { - const hasExclusive = memberships.some(membership => membership.exclusiveMembership); + const hasExclusive = memberships.some(membership => membership.organization.exclusiveMembership); if (!hasExclusive) { return { memberships, hasExclusive: false }; } return { - memberships: memberships.filter(membership => membership.exclusiveMembership), + memberships: memberships.filter(membership => membership.organization.exclusiveMembership), hasExclusive: true, }; }; From c4e689c68039a136a5cbdd0639965f8e438c9d84 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Mon, 22 Jun 2026 14:01:56 -0300 Subject: [PATCH 4/4] chore(ui): drop explanatory comments to match codebase style --- .../OrganizationList/OrganizationListPage.tsx | 8 -------- .../OrganizationList/UserMembershipList.tsx | 2 -- .../__tests__/OrganizationList.test.tsx | 4 ---- .../UserMembershipList.tsx | 7 ------- .../__tests__/OrganizationSwitcher.test.tsx | 3 --- .../ChooseOrganizationScreen.tsx | 12 ------------ .../__tests__/TaskChooseOrganization.test.tsx | 8 -------- .../tasks/TaskChooseOrganization/index.tsx | 2 -- .../filterExclusiveMemberships.test.ts | 2 -- .../ui/src/utils/filterExclusiveMemberships.ts | 18 ------------------ 10 files changed, 66 deletions(-) diff --git a/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx b/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx index 5f032ccc091..aff51abae10 100644 --- a/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx +++ b/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx @@ -122,13 +122,9 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () const { hidePersonal } = useOrganizationListContext(); const { user } = useUser(); - // Derive `hasExclusive` from the full, non-paginated membership set on the User resource so it never - // fails open when the exclusive membership is not on the currently loaded page of `userMemberships`. const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; - // When the user has an exclusive membership, invitations/suggestions are hidden and we never load - // additional membership pages, so the spinner/observer must not keep paginating. const hasNextPage = hasExclusive ? false : userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; @@ -144,8 +140,6 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () const userInvitationsData = userInvitations.data?.filter(a => !!a); const userSuggestionsData = userSuggestions.data?.filter(a => !!a); - // The displayed list still filters the currently loaded page to exclusive-only when the user has an - // exclusive membership, so a partially-loaded page can never surface a non-exclusive organization. const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; const { memberships: visibleMemberships } = filterExclusiveMemberships(loadedMemberships); @@ -182,8 +176,6 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () ); })} - {/* When the user has an exclusive membership they must not see ways to join or create - other organizations: invitations, suggestions, and the create button are all hidden. */} {!hasExclusive && !userMemberships.hasNextPage && userInvitationsData?.map(inv => { diff --git a/packages/ui/src/components/OrganizationList/UserMembershipList.tsx b/packages/ui/src/components/OrganizationList/UserMembershipList.tsx index 4f8788a5e2c..6a9fd51b205 100644 --- a/packages/ui/src/components/OrganizationList/UserMembershipList.tsx +++ b/packages/ui/src/components/OrganizationList/UserMembershipList.tsx @@ -97,8 +97,6 @@ export const PersonalAccountPreview = withCardStateProvider((props: { forceHide? }); }; - // `forceHide` is set when the user has an exclusive membership, in which case the personal - // account must always be hidden. if (hidePersonal || props.forceHide) { return null; } diff --git a/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx b/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx index b80a90d5cbe..8a1e3a75c40 100644 --- a/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx +++ b/packages/ui/src/components/OrganizationList/__tests__/OrganizationList.test.tsx @@ -213,8 +213,6 @@ describe('OrganizationList', () => { pendingInvitationsCount: 0, }, }); - // The displayed-list filter reads `organization.exclusiveMembership`, which the fake org factory - // does not set, so flag it explicitly on the loaded page. (exclusiveMembership.organization as any).exclusiveMembership = true; fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( @@ -236,9 +234,7 @@ describe('OrganizationList', () => { const { findByText, queryByText, queryByRole } = render(, { wrapper }); - // The exclusive org is shown. expect(await findByText('Exclusive Org')).toBeInTheDocument(); - // Personal account, invitations, and the create button are all hidden. expect(queryByText('Personal account')).not.toBeInTheDocument(); expect(queryByText('InvitedOrg')).not.toBeInTheDocument(); expect(queryByRole('menuitem', { name: 'Create organization' })).not.toBeInTheDocument(); diff --git a/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx b/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx index c3c456a785f..aa6fff52a60 100644 --- a/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/UserMembershipList.tsx @@ -50,18 +50,13 @@ export const UserMembershipList = (props: UserMembershipListProps) => { const { ref, userMemberships } = useFetchMemberships(); const { user } = useUser(); - // Derive `hasExclusive` from the full, non-paginated membership set on the User resource so it never - // fails open when the exclusive membership is not on the currently loaded page of `userMemberships`. const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); - // The displayed list still filters the currently loaded page to exclusive-only, so a partially-loaded - // page can never surface a non-exclusive organization. const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; const { memberships: visibleMemberships } = filterExclusiveMemberships(loadedMemberships); const otherOrgs = visibleMemberships.map(e => e.organization).filter(o => o.id !== currentOrg?.id); - // When the user has an exclusive membership, the personal workspace must always be hidden. const hidePersonalWorkspace = hidePersonal || hasExclusive; if (!user) { @@ -71,8 +66,6 @@ export const UserMembershipList = (props: UserMembershipListProps) => { const { primaryEmailAddress, primaryPhoneNumber, primaryWeb3Wallet, username, ...userWithoutIdentifiers } = user; const { isLoading } = userMemberships; - // When the user has an exclusive membership we never load additional pages, so the infinite-scroll - // observer must not keep paginating (a later page could otherwise surface non-exclusive orgs). const hasNextPage = hasExclusive ? false : userMemberships.hasNextPage; return ( diff --git a/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx index cfede8f7a68..39b9e38870a 100644 --- a/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx @@ -317,8 +317,6 @@ describe('OrganizationSwitcher', () => { pendingInvitationsCount: 1, }, }); - // The displayed-list filter reads `organization.exclusiveMembership`, which the fake org factory - // does not set, so flag it explicitly on the loaded page. (exclusiveMembership.organization as any).exclusiveMembership = true; fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( @@ -345,7 +343,6 @@ describe('OrganizationSwitcher', () => { props.setProps({ hidePersonal: false }); const { getAllByText, queryByText, getByRole, userEvent } = render(, { wrapper }); await userEvent.click(getByRole('button')); - // Only the exclusive org is present; the non-exclusive org and personal workspace are hidden. expect(getAllByText('Org1').length).toBeGreaterThan(0); expect(queryByText('Org2')).not.toBeInTheDocument(); expect(queryByText('Personal account')).not.toBeInTheDocument(); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx index a0f8f43effc..de52dfe5800 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx @@ -37,15 +37,9 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = const { user } = useUser(); const { ref, userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); - // Derive `hasExclusive` from the full, non-paginated membership set on the User resource so it never - // fails open when the exclusive membership is not on the currently loaded page of `userMemberships`. - // This screen also renders as the auto-activate failure fallback, so it cannot assume the backend - // blocks an exclusive user from joining/creating other organizations — it must enforce that here. const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; - // When the user has an exclusive membership, invitations/suggestions are hidden and we never load - // additional membership pages, so the spinner/observer must not keep paginating. const hasNextPage = hasExclusive ? false : userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; @@ -55,8 +49,6 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = const userInvitationsData = userInvitations.data?.filter(a => !!a); const userSuggestionsData = userSuggestions.data?.filter(a => !!a); - // The displayed list still filters the currently loaded page to exclusive-only, so a partially-loaded - // page can never surface a non-exclusive organization. const loadedMemberships = (userMemberships.count || 0) > 0 ? userMemberships.data || [] : []; const { memberships: visibleMemberships } = filterExclusiveMemberships(loadedMemberships); @@ -88,10 +80,6 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = ); })} - {/* When the user has an exclusive membership they must not see ways to join or create - other organizations: invitations, suggestions, and the create button are all hidden. - This screen renders as the auto-activate failure fallback, so it enforces this directly - rather than relying on the backend to block the user. */} {!hasExclusive && !userMemberships.hasNextPage && userInvitationsData?.map(inv => { diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx index df640bdcbb6..ed3550f1162 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx @@ -535,9 +535,6 @@ describe('TaskChooseOrganization', () => { }); }); - // Force the auto-activation to fail so the choose-organization fallback screen renders. A Clerk - // API error is used so the component's error handler surfaces it and falls back gracefully (an - // unknown error would be rethrown by `handleError`). fixtures.clerk.setActive.mockRejectedValue( new ClerkAPIResponseError('activation failed', { data: [{ code: 'organization_not_found_or_unauthorized', message: 'activation failed' }], @@ -557,11 +554,8 @@ describe('TaskChooseOrganization', () => { pendingInvitationsCount: 0, }, }); - // The displayed-list filter reads `organization.exclusiveMembership`, which the fake org factory - // does not set, so flag it explicitly on the loaded page. (exclusiveMembership.organization as any).exclusiveMembership = true; - // A loaded membership page plus an outstanding suggestion: neither create nor suggestion may show. fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( Promise.resolve({ data: [exclusiveMembership], @@ -584,9 +578,7 @@ describe('TaskChooseOrganization', () => { const { findByText, queryByText, queryByRole } = render(, { wrapper }); - // The exclusive organization is still listed on the fallback screen. expect(await findByText('Exclusive Org')).toBeInTheDocument(); - // But none of the join/create surfaces are rendered. expect(queryByText('Create new organization')).not.toBeInTheDocument(); expect(queryByRole('textbox', { name: /name/i })).not.toBeInTheDocument(); expect(queryByText('OrgTwoSuggestion')).not.toBeInTheDocument(); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx index 3a22353ebff..82be5eea832 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -107,8 +107,6 @@ const TaskChooseOrganizationInternal = () => { ) : ( diff --git a/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts b/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts index 3cd3a2e5ba1..21333aa8eaf 100644 --- a/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts +++ b/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts @@ -7,7 +7,6 @@ type TestMembership = Pick & { i const createMembership = (id: string, exclusiveMembership: boolean): TestMembership => ({ id, - // Only the fields the filter reads need to be realistic; the rest is irrelevant to the unit under test. organization: { id: `org_${id}`, exclusiveMembership } as OrganizationResource, }); @@ -25,7 +24,6 @@ describe('filterExclusiveMemberships', () => { const result = filterExclusiveMemberships(memberships); expect(result.hasExclusive).toBe(false); - // The same array reference is returned unchanged when there is no exclusive membership. expect(result.memberships).toBe(memberships); }); diff --git a/packages/ui/src/utils/filterExclusiveMemberships.ts b/packages/ui/src/utils/filterExclusiveMemberships.ts index fd27017717c..660383f4a94 100644 --- a/packages/ui/src/utils/filterExclusiveMemberships.ts +++ b/packages/ui/src/utils/filterExclusiveMemberships.ts @@ -1,30 +1,12 @@ import type { OrganizationMembershipResource } from '@clerk/shared/types'; type FilterExclusiveMembershipsResult> = { - /** - * When at least one exclusive membership is present, only the exclusive memberships are returned. - * Otherwise the input list is returned unchanged. - */ memberships: T[]; - /** - * `true` when the user has at least one exclusive membership. Callers should hide the personal - * account/workspace and any non-exclusive organizations when this is `true`. - */ hasExclusive: boolean; }; /** * Filters a list of organization memberships for exclusive membership. - * - * Exclusivity is a property of the Organization (`organization.exclusiveMembership`), so a membership - * is considered exclusive when its Organization enforces exclusive membership. When the user belongs - * to at least one such Organization, only those exclusive memberships are returned and `hasExclusive` - * is `true` (the caller should also hide the personal account and any non-exclusive organizations). - * Otherwise the list is returned unchanged and `hasExclusive` is `false`. - * - * Callers should derive `hasExclusive` from the full, non-paginated membership set - * (e.g. `user.organizationMemberships`) so it never fails open when the exclusive membership is not on - * the currently loaded page. */ export const filterExclusiveMemberships = >( memberships: T[],