Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/exclusive-membership-switcher.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useUser } from '@clerk/shared/react';
import { useState } from 'react';

import { CreateOrganizationAction } from '@/common/CreateOrganizationAction';
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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 (
<>
<Header.Root
Expand All @@ -156,18 +166,18 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: ()
<Col elementDescriptor={descriptors.main}>
<PreviewListItems>
<Actions role='menu'>
<PersonalAccountPreview />
{(userMemberships.count || 0) > 0 &&
userMemberships.data?.map(inv => {
return (
<MembershipPreview
key={inv.id}
{...inv}
/>
);
})}
<PersonalAccountPreview forceHide={hasExclusive} />
{visibleMemberships.map(inv => {
return (
<MembershipPreview
key={inv.id}
{...inv}
/>
);
})}

{!userMemberships.hasNextPage &&
{!hasExclusive &&
!userMemberships.hasNextPage &&
userInvitationsData?.map(inv => {
return (
<InvitationPreview
Expand All @@ -177,7 +187,8 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: ()
);
})}

{!userMemberships.hasNextPage &&
{!hasExclusive &&
!userMemberships.hasNextPage &&
!userInvitations.hasNextPage &&
userSuggestionsData?.map(inv => {
return (
Expand All @@ -190,7 +201,7 @@ export const OrganizationListPageList = (props: { onCreateOrganizationClick: ()

{(hasNextPage || isLoading) && <OrganizationPreviewSpinner ref={ref} />}

<CreateOrganizationButton onCreateOrganizationClick={onCreateOrganizationClick} />
{!hasExclusive && <CreateOrganizationButton onCreateOrganizationClick={onCreateOrganizationClick} />}
</Actions>
</PreviewListItems>
</Col>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -97,7 +97,7 @@ export const PersonalAccountPreview = withCardStateProvider(() => {
});
};

if (hidePersonal) {
if (hidePersonal || props.forceHide) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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(<OrganizationList />, { 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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,17 +50,23 @@ 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;
}

const { primaryEmailAddress, primaryPhoneNumber, primaryWeb3Wallet, username, ...userWithoutIdentifiers } = user;

const { isLoading, hasNextPage } = userMemberships;
const { isLoading } = userMemberships;
const hasNextPage = hasExclusive ? false : userMemberships.hasNextPage;

return (
<Box
Expand All @@ -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 && (
<PreviewButton
elementDescriptor={descriptors.organizationSwitcherPreviewButton}
elementId={descriptors.organizationSwitcherPreviewButton.setId('personal')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,62 @@ describe('OrganizationSwitcher', () => {
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(<OrganizationSwitcher />, { 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<>
<Header.Root
Expand All @@ -63,17 +71,17 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) =
<Col elementDescriptor={descriptors.main}>
<OrganizationPreviewListItems elementDescriptor={descriptors.taskChooseOrganizationPreviewItems}>
<Actions role='menu'>
{(userMemberships.count || 0) > 0 &&
userMemberships.data?.map(inv => {
return (
<MembershipPreview
key={inv.id}
{...inv}
/>
);
})}
{visibleMemberships.map(inv => {
return (
<MembershipPreview
key={inv.id}
{...inv}
/>
);
})}

{!userMemberships.hasNextPage &&
{!hasExclusive &&
!userMemberships.hasNextPage &&
userInvitationsData?.map(inv => {
return (
<InvitationPreview
Expand All @@ -83,7 +91,8 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) =
);
})}

{!userMemberships.hasNextPage &&
{!hasExclusive &&
!userMemberships.hasNextPage &&
!userInvitations.hasNextPage &&
userSuggestionsData?.map(inv => {
return (
Expand All @@ -96,13 +105,15 @@ export const ChooseOrganizationScreen = (props: ChooseOrganizationScreenProps) =

{(hasNextPage || isLoading) && <OrganizationPreviewSpinner ref={ref} />}

<CreateOrganizationButton
onCreateOrganizationClick={() => {
// Clear error originated from the list when switching to form
card.setError(undefined);
props.onCreateOrganizationClick();
}}
/>
{!hasExclusive && (
<CreateOrganizationButton
onCreateOrganizationClick={() => {
// Clear error originated from the list when switching to form
card.setError(undefined);
props.onCreateOrganizationClick();
}}
/>
)}
</Actions>
</OrganizationPreviewListItems>
</Col>
Expand Down
Loading
Loading