Skip to content
Merged
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-auto-activate-core2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': minor
---

Backport exclusive-membership auto-activation for Core 2. When the `choose-organization` session task fires for a member of an exclusive-membership organization, clerk-js now skips the org picker and auto-activates that organization. `Organization.exclusiveMembership` is now exposed on the Organization resource.
4 changes: 2 additions & 2 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "931KB" },
{ "path": "./dist/clerk.js", "maxSize": "934KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "87KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "129KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "132KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "68KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "123KB" },
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "126KB" },
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/Organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class Organization extends BaseResource implements OrganizationResource {
membersCount = 0;
pendingInvitationsCount = 0;
maxAllowedMemberships!: number;
exclusiveMembership = false;

constructor(data: OrganizationJSON | OrganizationJSONSnapshot) {
super();
Expand Down Expand Up @@ -305,6 +306,7 @@ export class Organization extends BaseResource implements OrganizationResource {
this.pendingInvitationsCount = data.pending_invitations_count || 0;
this.maxAllowedMemberships = data.max_allowed_memberships || 0;
this.adminDeleteEnabled = data.admin_delete_enabled || false;
this.exclusiveMembership = data.exclusive_membership || false;
this.createdAt = unixEpochToDate(data.created_at);
this.updatedAt = unixEpochToDate(data.updated_at);
return this;
Expand All @@ -323,6 +325,7 @@ export class Organization extends BaseResource implements OrganizationResource {
pending_invitations_count: this.pendingInvitationsCount,
max_allowed_memberships: this.maxAllowedMemberships,
admin_delete_enabled: this.adminDeleteEnabled,
exclusive_membership: this.exclusiveMembership,
created_at: this.createdAt.getTime(),
updated_at: this.updatedAt.getTime(),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,86 @@
import { useClerk, useOrganizationCreationDefaults, useSession, useUser } from '@clerk/shared/react';
import {
useClerk,
useOrganizationCreationDefaults,
useOrganizationList,
useSession,
useUser,
} from '@clerk/shared/react';
import type { OrganizationCreationDefaultsResource } from '@clerk/shared/types';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';

import { useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts';
import { useSessionTasksContext, useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
import { descriptors, Flex, Flow, localizationKeys, Spinner } from '@/ui/customizables';
import { Card } from '@/ui/elements/Card';
import { withCardStateProvider } from '@/ui/elements/contexts';
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
import { Header } from '@/ui/elements/Header';
import { useMultipleSessions } from '@/ui/hooks/useMultipleSessions';
import { useOrganizationListInView } from '@/ui/hooks/useOrganizationListInView';
import { handleError } from '@/ui/utils/errorHandler';

import { withTaskGuard } from '../shared';
import { ChooseOrganizationScreen } from './ChooseOrganizationScreen';
import { CreateOrganizationScreen } from './CreateOrganizationScreen';

const LoadingCardContent = () => (
<Flex
direction={'row'}
align={'center'}
justify={'center'}
sx={t => ({
height: '100%',
minHeight: t.sizes.$100,
})}
>
<Spinner
size={'lg'}
colorScheme={'primary'}
elementDescriptor={descriptors.spinner}
/>
</Flex>
);

const TaskChooseOrganizationInternal = () => {
const card = useCardState();
const { user } = useUser();
const { userMemberships, userSuggestions, userInvitations } = useOrganizationListInView();
const organizationCreationDefaults = useOrganizationCreationDefaults();
const { isLoaded: isOrganizationListLoaded, setActive } = useOrganizationList();
const { navigateOnSetActive } = useSessionTasksContext();
const { redirectUrlComplete } = useTaskChooseOrganizationContext();

const exclusiveOrganization = user?.organizationMemberships?.find(
membership => membership.organization.exclusiveMembership === true,
)?.organization;

const hasAutoActivated = useRef(false);
const [autoActivateFailed, setAutoActivateFailed] = useState(false);
const shouldAutoActivate = !!exclusiveOrganization && !autoActivateFailed;

// Exclusive members belong to a single org — skip the picker and activate it once the org list is ready.
// On failure, surface the error and fall back to the normal choose/create flows.
useEffect(() => {
if (!exclusiveOrganization || autoActivateFailed || !isOrganizationListLoaded || hasAutoActivated.current) {
return;
}

hasAutoActivated.current = true;

void (async () => {
try {
await setActive({
organization: exclusiveOrganization,
navigate: async ({ session }) => {
await navigateOnSetActive?.({ session, redirectUrlComplete });
},
});
} catch (err: any) {
handleError(err, [], card.setError);
setAutoActivateFailed(true);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [exclusiveOrganization, autoActivateFailed, isOrganizationListLoaded]);

const isLoading =
userMemberships?.isLoading ||
Expand All @@ -31,7 +94,7 @@ const TaskChooseOrganizationInternal = () => {
user?.organizationMemberships?.length === 0 &&
!hasExistingResources;

if (isOrganizationCreationDisabled) {
if (isOrganizationCreationDisabled && !shouldAutoActivate) {
return <OrganizationCreationDisabledScreen />;
}

Expand All @@ -40,22 +103,8 @@ const TaskChooseOrganizationInternal = () => {
<Flow.Part part='chooseOrganization'>
<Card.Root>
<Card.Content sx={t => ({ padding: `${t.space.$8} ${t.space.$none} ${t.space.$none}`, gap: t.space.$7 })}>
{isLoading ? (
<Flex
direction={'row'}
align={'center'}
justify={'center'}
sx={t => ({
height: '100%',
minHeight: t.sizes.$100,
})}
>
<Spinner
size={'lg'}
colorScheme={'primary'}
elementDescriptor={descriptors.spinner}
/>
</Flex>
{shouldAutoActivate || isLoading ? (
<LoadingCardContent />
) : (
<TaskChooseOrganizationFlows
initialFlow={hasExistingResources ? 'choose' : 'create'}
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/types/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ export interface OrganizationJSON extends ClerkResourceJSON {
pending_invitations_count: number;
admin_delete_enabled: boolean;
max_allowed_memberships: number;
exclusive_membership?: boolean;
}

export interface OrganizationMembershipJSON extends ClerkResourceJSON {
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/types/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export interface OrganizationResource extends ClerkResource, BillingPayerMethods
publicMetadata: OrganizationPublicMetadata;
adminDeleteEnabled: boolean;
maxAllowedMemberships: number;
/**
* Whether the Organization enforces exclusive membership, meaning members must have it set as their active Organization. Defaults to `false` for instances that have not adopted the feature.
*/
exclusiveMembership: boolean;
createdAt: Date;
updatedAt: Date;
update: (params: UpdateOrganizationParams) => Promise<OrganizationResource>;
Expand Down
Loading