diff --git a/.changeset/shaggy-terms-train.md b/.changeset/shaggy-terms-train.md
new file mode 100644
index 0000000000..909b9d7c80
--- /dev/null
+++ b/.changeset/shaggy-terms-train.md
@@ -0,0 +1,8 @@
+---
+'@clerk/localizations': patch
+'@clerk/clerk-js': patch
+'@clerk/types': patch
+---
+
+Introduces an invitation list within
++ Users can accept the invitation that is sent to them
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx
index f2dc5c28a2..b318712736 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcher.tsx
@@ -1,5 +1,5 @@
import { withOrganizationsEnabledGuard } from '../../common';
-import { withCoreUserGuard } from '../../contexts';
+import { useCoreOrganizationList, withCoreUserGuard } from '../../contexts';
import { Flow } from '../../customizables';
import { Popover, withCardStateProvider, withFloatingTree } from '../../elements';
import { usePopover } from '../../hooks';
@@ -12,6 +12,12 @@ const _OrganizationSwitcher = withFloatingTree(() => {
offset: 8,
});
+ useCoreOrganizationList({
+ userInvitations: {
+ infinite: true,
+ },
+ });
+
return (
unknown;
-};
+}
-export const OrganizationActionList = (props: OrganizationActionListProps) => {
- const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props;
- const { organizationList } = useCoreOrganizationList();
- const { organization: currentOrg } = useCoreOrganization();
+const CreateOrganizationButton = ({
+ onCreateOrganizationClick,
+}: Pick) => {
const user = useCoreUser();
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { username, primaryEmailAddress, primaryPhoneNumber, ...userWithoutIdentifiers } = user;
- const { hidePersonal } = useOrganizationSwitcherContext();
- const otherOrgs = (organizationList || []).map(e => e.organization).filter(o => o.id !== currentOrg?.id);
+ if (!user.createOrganizationEnabled) {
+ return null;
+ }
- const createOrganizationButton = (
+ return (
{
onClick={onCreateOrganizationClick}
/>
);
+};
+
+export const OrganizationActionList = (props: OrganizationActionListProps) => {
+ const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props;
return (
- ({
- maxHeight: `calc(4 * ${t.sizes.$12})`,
- overflowY: 'auto',
- ...common.unstyledScrollbar(t),
- })}
- >
- {currentOrg && !hidePersonal && (
-
- ({ margin: `0 calc(${t.space.$3}/2)` })}
- title={localizationKeys('organizationSwitcher.personalWorkspace')}
- />
-
- )}
- {otherOrgs.map(organization => (
- onOrganizationClick(organization)}
- >
- ({ margin: `0 calc(${t.space.$3}/2)` })}
- organization={organization}
- size='sm'
- />
-
- ))}
-
- {user.createOrganizationEnabled && createOrganizationButton}
+
+
+
);
};
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationList.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationList.tsx
new file mode 100644
index 0000000000..966640ffe5
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserInvitationList.tsx
@@ -0,0 +1,155 @@
+import type { UserOrganizationInvitationResource } from '@clerk/types';
+
+import { useCoreOrganizationList } from '../../contexts';
+import { Box, Button, descriptors, Flex, localizationKeys, Spinner, Text } from '../../customizables';
+import { OrganizationPreview, useCardState, withCardStateProvider } from '../../elements';
+import { useInView } from '../../hooks';
+import { common } from '../../styledSystem';
+import { handleError } from '../../utils';
+
+export const UserInvitationList = () => {
+ const { ref } = useInView({
+ threshold: 0,
+ onChange: inView => {
+ if (inView) {
+ userInvitations.fetchNext?.();
+ }
+ },
+ });
+
+ const { userInvitations } = useCoreOrganizationList({
+ userInvitations: {
+ infinite: true,
+ },
+ });
+
+ if ((userInvitations.count ?? 0) === 0) {
+ return null;
+ }
+
+ return (
+
+ ({
+ minHeight: 'unset',
+ height: t.space.$12,
+ padding: `${t.space.$3} ${t.space.$6}`,
+ display: 'flex',
+ alignItems: 'center',
+ })}
+ // Handle plurals
+ localizationKey={localizationKeys(
+ (userInvitations.count ?? 0) > 1
+ ? 'organizationSwitcher.invitationCountLabel_many'
+ : 'organizationSwitcher.invitationCountLabel_single',
+ {
+ count: userInvitations.count,
+ },
+ )}
+ />
+ ({
+ maxHeight: `calc(4 * ${t.sizes.$12})`,
+ overflowY: 'auto',
+ ...common.unstyledScrollbar(t),
+ })}
+ >
+ {userInvitations?.data?.map(inv => {
+ return (
+
+ );
+ })}
+
+ {(userInvitations.hasNextPage || userInvitations.isFetching) && (
+ ({
+ width: '100%',
+ height: t.space.$12,
+ position: 'relative',
+ })}
+ >
+
+
+
+
+ )}
+
+
+ );
+};
+
+const AcceptRejectInvitationButtons = (props: UserOrganizationInvitationResource) => {
+ const card = useCardState();
+ const { userInvitations } = useCoreOrganizationList({
+ userInvitations: {
+ infinite: true,
+ },
+ });
+
+ const mutateSwrState = () => {
+ (userInvitations as any)?.unstable__mutate?.();
+ };
+
+ const handleAccept = () => {
+ return card
+ .runAsync(props.accept())
+ .then(mutateSwrState)
+ .catch(err => handleError(err, [], card.setError));
+ };
+
+ return (
+ <>
+
+ >
+ );
+};
+
+const InvitationPreview = withCardStateProvider((props: UserOrganizationInvitationResource) => {
+ return (
+ ({
+ minHeight: 'unset',
+ height: t.space.$12,
+ justifyContent: 'space-between',
+ padding: `0 ${t.space.$6}`,
+ })}
+ >
+ ({ margin: `0 calc(${t.space.$3}/2)` })}
+ organization={props.publicOrganizationData}
+ size='sm'
+ />
+
+
+
+ );
+});
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserMembershipList.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserMembershipList.tsx
new file mode 100644
index 0000000000..d84b2f9291
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/UserMembershipList.tsx
@@ -0,0 +1,73 @@
+import type { OrganizationResource } from '@clerk/types';
+import React from 'react';
+
+import {
+ useCoreOrganization,
+ useCoreOrganizationList,
+ useCoreUser,
+ useOrganizationSwitcherContext,
+} from '../../contexts';
+import { Box, descriptors, localizationKeys } from '../../customizables';
+import { OrganizationPreview, PersonalWorkspacePreview, PreviewButton } from '../../elements';
+import { SwitchArrows } from '../../icons';
+import { common } from '../../styledSystem';
+
+export type UserMembershipListProps = {
+ onPersonalWorkspaceClick: React.MouseEventHandler;
+ onOrganizationClick: (org: OrganizationResource) => unknown;
+};
+export const UserMembershipList = (props: UserMembershipListProps) => {
+ const { onPersonalWorkspaceClick, onOrganizationClick } = props;
+
+ const { hidePersonal } = useOrganizationSwitcherContext();
+ const { organization: currentOrg } = useCoreOrganization();
+ const { organizationList } = useCoreOrganizationList();
+ const user = useCoreUser();
+
+ const otherOrgs = (organizationList || []).map(e => e.organization).filter(o => o.id !== currentOrg?.id);
+
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { username, primaryEmailAddress, primaryPhoneNumber, ...userWithoutIdentifiers } = user;
+
+ return (
+ ({
+ maxHeight: `calc(4 * ${t.sizes.$12})`,
+ overflowY: 'auto',
+ ...common.unstyledScrollbar(t),
+ })}
+ >
+ {currentOrg && !hidePersonal && (
+
+ ({ margin: `0 calc(${t.space.$3}/2)` })}
+ title={localizationKeys('organizationSwitcher.personalWorkspace')}
+ />
+
+ )}
+ {otherOrgs.map(organization => (
+ onOrganizationClick(organization)}
+ >
+ ({ margin: `0 calc(${t.space.$3}/2)` })}
+ organization={organization}
+ size='sm'
+ />
+
+ ))}
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
index 2f6e403e55..9bee2e7d50 100644
--- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx
@@ -5,6 +5,7 @@ import React from 'react';
import { render } from '../../../../testUtils';
import { bindCreateFixtures } from '../../../utils/test/createFixtures';
import { OrganizationSwitcher } from '../OrganizationSwitcher';
+import { createFakeUserOrganizationInvitations } from './utlis';
const { createFixtures } = bindCreateFixtures('OrganizationSwitcher');
@@ -131,6 +132,49 @@ describe('OrganizationSwitcher', () => {
expect(queryByRole('button', { name: 'Create Organization' })).not.toBeInTheDocument();
});
+ it('displays a list of user invitations', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.dev'],
+ organization_memberships: [{ name: 'Org1', role: 'basic_member' }],
+ create_organization_enabled: false,
+ });
+ });
+ fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce(
+ Promise.resolve({
+ data: [
+ createFakeUserOrganizationInvitations({
+ id: '1',
+ emailAddress: 'one@clerk.com',
+ publicOrganizationData: {
+ name: 'OrgOne',
+ },
+ }),
+ createFakeUserOrganizationInvitations({
+ id: '2',
+ emailAddress: 'two@clerk.com',
+ publicOrganizationData: { name: 'OrgTwo' },
+ }),
+ ],
+ total_count: 11,
+ }),
+ );
+ const { queryByText, userEvent, getByRole } = render(, {
+ wrapper,
+ });
+
+ await userEvent.click(getByRole('button'));
+
+ expect(fixtures.clerk.user?.getOrganizationInvitations).toHaveBeenCalledWith({
+ initialPage: 1,
+ pageSize: 10,
+ status: 'pending',
+ });
+ expect(queryByText('OrgOne')).toBeInTheDocument();
+ expect(queryByText('OrgTwo')).toBeInTheDocument();
+ });
+
it("switches between active organizations when one is clicked'", async () => {
const { wrapper, props, fixtures } = await createFixtures(f => {
f.withOrganizations();
diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/utlis.ts b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/utlis.ts
new file mode 100644
index 0000000000..ab25210178
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/utlis.ts
@@ -0,0 +1,36 @@
+import { MembershipRole, OrganizationInvitationStatus, UserOrganizationInvitationResource } from '@clerk/types';
+import { jest } from '@jest/globals';
+
+type FakeOrganizationParams = {
+ id: string;
+ createdAt?: Date;
+ emailAddress: string;
+ role?: MembershipRole;
+ status?: OrganizationInvitationStatus;
+ publicOrganizationData?: { hasImage?: boolean; id?: string; imageUrl?: string; name?: string; slug?: string };
+};
+
+export const createFakeUserOrganizationInvitations = (
+ params: FakeOrganizationParams,
+): UserOrganizationInvitationResource => {
+ return {
+ pathRoot: '',
+ emailAddress: params.emailAddress,
+ publicOrganizationData: {
+ hasImage: false,
+ id: '',
+ imageUrl: '',
+ name: '',
+ slug: '',
+ ...params.publicOrganizationData,
+ },
+ role: params.role || 'basic_member',
+ status: params.status || 'pending',
+ id: params.id,
+ createdAt: params?.createdAt || new Date(),
+ updatedAt: new Date(),
+ publicMetadata: {},
+ accept: jest.fn() as any,
+ reload: jest.fn() as any,
+ };
+};
diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
index a499a98e80..6e8c2ee4af 100644
--- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
+++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
@@ -127,8 +127,11 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'organizationSwitcherPopoverCard',
'organizationSwitcherPopoverMain',
'organizationSwitcherPopoverActions',
+ 'organizationSwitcherPopoverInvitationActions',
'organizationSwitcherPopoverActionButton',
'organizationSwitcherPreviewButton',
+ 'organizationSwitcherInvitationAcceptButton',
+ 'organizationSwitcherInvitationRejectButton',
'organizationSwitcherPopoverActionButtonIconBox',
'organizationSwitcherPopoverActionButtonIcon',
'organizationSwitcherPopoverActionButtonText',
diff --git a/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx b/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx
index 9346840ef2..7036e34df1 100644
--- a/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx
+++ b/packages/clerk-js/src/ui/elements/OrganizationPreview.tsx
@@ -1,4 +1,4 @@
-import type { OrganizationPreviewId, OrganizationResource, UserResource } from '@clerk/types';
+import type { OrganizationPreviewId, UserOrganizationInvitationResource, UserResource } from '@clerk/types';
import React from 'react';
import { descriptors, Flex, Text } from '../customizables';
@@ -7,7 +7,7 @@ import { roleLocalizationKey } from '../utils';
import { OrganizationAvatar } from './OrganizationAvatar';
export type OrganizationPreviewProps = Omit, 'elementId'> & {
- organization: OrganizationResource;
+ organization: UserOrganizationInvitationResource['publicOrganizationData'];
user?: UserResource;
size?: 'lg' | 'md' | 'sm';
avatarSx?: ThemableCssProp;
diff --git a/packages/clerk-js/src/ui/hooks/index.ts b/packages/clerk-js/src/ui/hooks/index.ts
index 451e54d5da..579be2932f 100644
--- a/packages/clerk-js/src/ui/hooks/index.ts
+++ b/packages/clerk-js/src/ui/hooks/index.ts
@@ -4,6 +4,7 @@ export * from './useWindowEventListener';
export * from './useMagicLink';
export * from './useClipboard';
export * from './useEnabledThirdPartyProviders';
+export * from './useInView';
export * from './useLoadingStatus';
export * from './usePassword';
export * from './usePasswordComplexity';
diff --git a/packages/clerk-js/src/ui/hooks/useInView.ts b/packages/clerk-js/src/ui/hooks/useInView.ts
new file mode 100644
index 0000000000..f9e9258f44
--- /dev/null
+++ b/packages/clerk-js/src/ui/hooks/useInView.ts
@@ -0,0 +1,63 @@
+import { useCallback, useRef, useState } from 'react';
+
+interface IntersectionOptions extends IntersectionObserverInit {
+ /** Only trigger the inView callback once */
+ triggerOnce?: boolean;
+ /** Call this function whenever the in view state changes */
+ onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void;
+}
+
+/**
+ * A custom React hook that provides the ability to track whether an element is in view
+ * based on the IntersectionObserver API.
+ *
+ * @param {IntersectionOptions} params - IntersectionObserver configuration options.
+ * @returns {{
+ * inView: boolean,
+ * ref: (element: HTMLElement | null) => void
+ * }} An object containing the current inView status and a ref function to attach to the target element.
+ */
+export const useInView = (params: IntersectionOptions) => {
+ const [inView, setInView] = useState(false);
+ const observerRef = useRef(null);
+ const thresholds = Array.isArray(params.threshold) ? params.threshold : [params.threshold || 0];
+ const internalOnChange = useRef();
+
+ internalOnChange.current = params.onChange;
+
+ const ref = useCallback((element: HTMLElement | null) => {
+ // Callback refs are called with null to clear the value, so we rely on that to cleanup the observer. (ref: https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback)
+ if (!element) {
+ if (observerRef.current) {
+ observerRef.current.disconnect();
+ }
+ return;
+ }
+
+ observerRef.current = new IntersectionObserver(
+ entries => {
+ entries.forEach(entry => {
+ const _inView = entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold);
+
+ setInView(_inView);
+
+ if (internalOnChange.current) {
+ internalOnChange.current(_inView, entry);
+ }
+ });
+ },
+ {
+ root: params.root,
+ rootMargin: params.rootMargin,
+ threshold: thresholds,
+ },
+ );
+
+ observerRef.current.observe(element);
+ }, []);
+
+ return {
+ inView,
+ ref,
+ };
+};
diff --git a/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts b/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts
index 03b842fe5a..95f9aec950 100644
--- a/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts
+++ b/packages/clerk-js/src/ui/hooks/useLoadingStatus.ts
@@ -2,10 +2,11 @@ import { useSafeState } from './useSafeState';
type Status = 'idle' | 'loading' | 'error';
-export const useLoadingStatus = () => {
+export const useLoadingStatus = (initialState?: { status: Status; metadata?: Metadata | undefined }) => {
const [state, setState] = useSafeState<{ status: Status; metadata?: Metadata | undefined }>({
status: 'idle',
metadata: undefined,
+ ...initialState,
});
return {
diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts
index 05178d905a..1e9dfd68d9 100644
--- a/packages/localizations/src/en-US.ts
+++ b/packages/localizations/src/en-US.ts
@@ -524,6 +524,9 @@ export const enUS: LocalizationResource = {
notSelected: 'No organization selected',
action__createOrganization: 'Create Organization',
action__manageOrganization: 'Manage Organization',
+ invitationCountLabel_single: '1 pending invitation to join:',
+ invitationCountLabel_many: '{{count}} pending invitations to join:',
+ invitationAccept: 'Join',
},
impersonationFab: {
title: 'Signed in as {{identifier}}',
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index 9768adf395..7615e514d1 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -267,12 +267,15 @@ export type ElementsConfig = {
organizationSwitcherPopoverCard: WithOptions;
organizationSwitcherPopoverMain: WithOptions;
organizationSwitcherPopoverActions: WithOptions;
+ organizationSwitcherPopoverInvitationActions: WithOptions;
organizationSwitcherPopoverActionButton: WithOptions<
'manageOrganization' | 'createOrganization' | 'switchOrganization',
never,
never
>;
organizationSwitcherPreviewButton: WithOptions;
+ organizationSwitcherInvitationAcceptButton: WithOptions;
+ organizationSwitcherInvitationRejectButton: WithOptions;
organizationSwitcherPopoverActionButtonIconBox: WithOptions<
'manageOrganization' | 'createOrganization',
never,
diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts
index f634f755d5..30b2ea24fb 100644
--- a/packages/types/src/json.ts
+++ b/packages/types/src/json.ts
@@ -370,7 +370,7 @@ export interface UserOrganizationInvitationJSON extends ClerkResourceJSON {
public_organization_data: {
id: string;
name: string;
- slug: string;
+ slug: string | null;
has_image: boolean;
image_url: string;
};
diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts
index 9e0ddc144b..6cedec8387 100644
--- a/packages/types/src/localization.ts
+++ b/packages/types/src/localization.ts
@@ -545,6 +545,9 @@ type _LocalizationResource = {
notSelected: LocalizationValue;
action__createOrganization: LocalizationValue;
action__manageOrganization: LocalizationValue;
+ invitationCountLabel_single: LocalizationValue;
+ invitationCountLabel_many: LocalizationValue;
+ invitationAccept: LocalizationValue;
};
impersonationFab: {
title: LocalizationValue;
diff --git a/packages/types/src/userOrganizationInvitation.ts b/packages/types/src/userOrganizationInvitation.ts
index 50e1a5c618..baff1f268d 100644
--- a/packages/types/src/userOrganizationInvitation.ts
+++ b/packages/types/src/userOrganizationInvitation.ts
@@ -25,7 +25,7 @@ export interface UserOrganizationInvitationResource extends ClerkResource {
imageUrl: string;
name: string;
id: string;
- slug: string;
+ slug: string | null;
};
publicMetadata: UserOrganizationInvitationPublicMetadata;
role: MembershipRole;