diff --git a/.changeset/exclusive-membership-switcher.md b/.changeset/exclusive-membership-switcher.md new file mode 100644 index 00000000000..78dfedc59ca --- /dev/null +++ b/.changeset/exclusive-membership-switcher.md @@ -0,0 +1,5 @@ +--- +"@clerk/ui": minor +--- + +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/ui/src/components/OrganizationList/OrganizationListPage.tsx b/packages/ui/src/components/OrganizationList/OrganizationListPage.tsx index db8937232d2..aff51abae10 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'; @@ -7,6 +8,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'; @@ -118,9 +120,14 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () const { ref, userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); const { hidePersonal } = useOrganizationListContext(); + const { user } = useUser(); + + const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; - const hasNextPage = userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; + const hasNextPage = hasExclusive + ? false + : userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; const onCreateOrganizationClick = () => { // Clear error originated from the list when switching to form @@ -133,6 +140,9 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () const userInvitationsData = userInvitations.data?.filter(a => !!a); const userSuggestionsData = userSuggestions.data?.filter(a => !!a); + 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 && + {!hasExclusive && + !userMemberships.hasNextPage && userInvitationsData?.map(inv => { return ( { return ( @@ -190,7 +201,7 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: () {(hasNextPage || isLoading) && } - + {!hasExclusive && } diff --git a/packages/ui/src/components/OrganizationList/UserMembershipList.tsx b/packages/ui/src/components/OrganizationList/UserMembershipList.tsx index a7dec64439b..6a9fd51b205 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,7 @@ export const PersonalAccountPreview = withCardStateProvider(() => { }); }; - if (hidePersonal) { + 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 78356967931..8a1e3a75c40 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,59 @@ 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, + }, + }); + (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 }); + + expect(await findByText('Exclusive Org')).toBeInTheDocument(); + 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 f215bfee375..aa6fff52a60 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,14 @@ 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); + const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); + + 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); + + const hidePersonalWorkspace = hidePersonal || hasExclusive; if (!user) { return null; @@ -59,7 +65,8 @@ export const UserMembershipList = (props: UserMembershipListProps) => { const { primaryEmailAddress, primaryPhoneNumber, primaryWeb3Wallet, username, ...userWithoutIdentifiers } = user; - const { isLoading, hasNextPage } = userMemberships; + const { isLoading } = userMemberships; + const hasNextPage = hasExclusive ? false : userMemberships.hasNextPage; return ( { ...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 && ( { 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, + }, + }); + (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')); + 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 972fb3682ed..de52dfe5800 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/ChooseOrganizationScreen.tsx @@ -26,6 +26,7 @@ import { Header } from '@/ui/elements/Header'; import { OrganizationPreview } from '@/ui/elements/OrganizationPreview'; import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView'; import { handleError } from '@/ui/utils/errorHandler'; +import { filterExclusiveMemberships } from '@/ui/utils/filterExclusiveMemberships'; type ChooseOrganizationScreenProps = { onCreateOrganizationClick: () => void; @@ -36,14 +37,21 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) = const { user } = useUser(); const { ref, userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); + const { hasExclusive } = filterExclusiveMemberships(user?.organizationMemberships ?? []); + const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; - const hasNextPage = userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; + 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); + 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 && + {!hasExclusive && + !userMemberships.hasNextPage && userInvitationsData?.map(inv => { return ( { return ( @@ -96,13 +105,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..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 @@ -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,88 @@ 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 }], + }); + }); + + 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, + }, + }); + (exclusiveMembership.organization as any).exclusiveMembership = true; + + 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 }); + + expect(await findByText('Exclusive Org')).toBeInTheDocument(); + 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..82be5eea832 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -107,7 +107,7 @@ 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..21333aa8eaf --- /dev/null +++ b/packages/ui/src/utils/__tests__/filterExclusiveMemberships.test.ts @@ -0,0 +1,59 @@ +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, + 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); + 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 new file mode 100644 index 00000000000..660383f4a94 --- /dev/null +++ b/packages/ui/src/utils/filterExclusiveMemberships.ts @@ -0,0 +1,24 @@ +import type { OrganizationMembershipResource } from '@clerk/shared/types'; + +type FilterExclusiveMembershipsResult> = { + memberships: T[]; + hasExclusive: boolean; +}; + +/** + * Filters a list of organization memberships for exclusive membership. + */ +export const filterExclusiveMemberships = >( + memberships: T[], +): FilterExclusiveMembershipsResult => { + const hasExclusive = memberships.some(membership => membership.organization.exclusiveMembership); + + if (!hasExclusive) { + return { memberships, hasExclusive: false }; + } + + return { + memberships: memberships.filter(membership => membership.organization.exclusiveMembership), + hasExclusive: true, + }; +};