diff --git a/.changeset/invite-members-button.md b/.changeset/invite-members-button.md
new file mode 100644
index 00000000000..e2bac665dcc
--- /dev/null
+++ b/.changeset/invite-members-button.md
@@ -0,0 +1,21 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/shared': minor
+'@clerk/react': minor
+'@clerk/nextjs': minor
+'@clerk/ui': minor
+---
+
+Add ``, a control component that opens the organization invite-members form in a modal when clicked, working like ``.
+
+Wrap your own button (or omit children for a default one). The button requires an active organization and should be rendered for members who can manage memberships (`org:sys_memberships:manage`). Opening it without an active organization or that permission is a no-op in production, and throws a descriptive error in development.
+
+```tsx
+import { InviteMembersButton } from '@clerk/nextjs';
+
+
+
+;
+```
+
+This also adds `Clerk.openInviteMembers()` and `Clerk.closeInviteMembers()` for opening and closing the modal programmatically.
diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts
index 2e661a84de5..682f9b2f53e 100644
--- a/packages/clerk-js/sandbox/app.ts
+++ b/packages/clerk-js/sandbox/app.ts
@@ -472,6 +472,7 @@ void (async () => {
mountOpenButton(app, 'Open Sign In', p => Clerk?.openSignIn(p), componentControls.signIn.getProps() ?? {}),
'/open-sign-up': () =>
mountOpenButton(app, 'Open Sign Up', p => Clerk?.openSignUp(p), componentControls.signUp.getProps() ?? {}),
+ '/open-invite-members': () => mountOpenButton(app, 'Open Invite Members', p => Clerk?.openInviteMembers(p), {}),
};
for (const [path, { mount, component, defaultProps }] of Object.entries(mountableRoutes)) {
diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html
index 4a26ca93991..f59c231dceb 100644
--- a/packages/clerk-js/sandbox/template.html
+++ b/packages/clerk-js/sandbox/template.html
@@ -308,6 +308,10 @@
label="Configure SSO"
component=""
>
+
ui.ensureMounted()).then(controls => controls.closeModal('organizationProfile'));
};
+ public openInviteMembers = (props?: InviteMembersModalProps): void => {
+ const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({
+ for: 'organizations',
+ caller: 'InviteMembers',
+ onClose: () => {
+ throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('InviteMembers'), {
+ code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE,
+ });
+ },
+ });
+
+ if (!isOrganizationsEnabled) {
+ return;
+ }
+
+ if (noOrganizationExists(this)) {
+ if (this.#instanceType === 'development') {
+ throw new ClerkRuntimeError(warnings.createCannotRenderComponentWhenOrgDoesNotExist('InviteMembers'), {
+ code: CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE,
+ });
+ }
+ return;
+ }
+
+ if (!this.session?.checkAuthorization({ permission: 'org:sys_memberships:manage' })) {
+ if (this.#instanceType === 'development') {
+ throw new ClerkRuntimeError(
+ warnings.createCannotRenderComponentWhenPermissionIsMissing('InviteMembers', 'org:sys_memberships:manage'),
+ { code: CANNOT_RENDER_PERMISSION_MISSING_ERROR_CODE },
+ );
+ }
+ return;
+ }
+
+ this.assertComponentsReady(this.#clerkUI);
+ void this.#clerkUI
+ .then(ui => ui.ensureMounted())
+ .then(controls => controls.openModal('inviteMembers', props || {}));
+
+ this.telemetry?.record(eventPrebuiltComponentOpened('InviteMembers', props));
+ };
+
+ public closeInviteMembers = (): void => {
+ void this.#clerkUI?.then(ui => ui.ensureMounted()).then(controls => controls.closeModal('inviteMembers'));
+ };
+
public openCreateOrganization = (props?: CreateOrganizationProps): void => {
const { isEnabled: isOrganizationsEnabled } = this.__internal_attemptToEnableEnvironmentSetting({
for: 'organizations',
diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx
index f6f65fad650..1c2982bd933 100644
--- a/packages/nextjs/src/client-boundary/uiComponents.tsx
+++ b/packages/nextjs/src/client-boundary/uiComponents.tsx
@@ -16,6 +16,7 @@ export {
CreateOrganization,
GoogleOneTap,
HandleSSOCallback,
+ InviteMembersButton,
OAuthConsent,
OrganizationList,
OrganizationSwitcher,
diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts
index 283a7935cfc..f824d89e0f2 100644
--- a/packages/nextjs/src/index.ts
+++ b/packages/nextjs/src/index.ts
@@ -25,6 +25,7 @@ export {
APIKeys,
CreateOrganization,
GoogleOneTap,
+ InviteMembersButton,
OAuthConsent,
OrganizationList,
OrganizationProfile,
diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap
index 27525d4ce63..fb0e0ea41c9 100644
--- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -26,6 +26,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
+ "InviteMembersButton",
"OAuthConsent",
"OrganizationList",
"OrganizationProfile",
diff --git a/packages/react/src/components/InviteMembersButton.tsx b/packages/react/src/components/InviteMembersButton.tsx
new file mode 100644
index 00000000000..8b5ec520832
--- /dev/null
+++ b/packages/react/src/components/InviteMembersButton.tsx
@@ -0,0 +1,52 @@
+import type { InviteMembersButtonProps } from '@clerk/shared/types';
+import React from 'react';
+
+import type { WithClerkProp } from '../types';
+import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils';
+import { withClerk } from './withClerk';
+
+/**
+ * A button component that opens a modal containing the organization invite-members form when
+ * clicked. Wrap your own button, or omit children to render a default one.
+ *
+ * Requires an active organization, and should be rendered for members who can manage memberships
+ * (the `org:sys_memberships:manage` permission). Guard with `` or `useAuth()` if unsure.
+ * Clicking it when there is no active organization or the current user lacks that permission is a
+ * no-op in production, and throws a descriptive error in development.
+ *
+ * @example
+ * ```tsx
+ * import { InviteMembersButton } from '@clerk/react';
+ *
+ * function InviteButton() {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export const InviteMembersButton = withClerk(
+ ({ clerk, children, ...props }: WithClerkProp>) => {
+ const { appearance, getContainer, component, ...rest } = props;
+
+ children = normalizeWithDefaultValue(children, 'Invite members');
+ const child = assertSingleChild(children)('InviteMembersButton');
+
+ const clickHandler = () => {
+ return clerk.openInviteMembers({ appearance, getContainer });
+ };
+
+ const wrappedChildClickHandler: React.MouseEventHandler = async e => {
+ if (child && typeof child === 'object' && 'props' in child) {
+ await safeExecute(child.props.onClick)(e);
+ }
+ return clickHandler();
+ };
+
+ const childProps = { ...rest, onClick: wrappedChildClickHandler };
+ return React.cloneElement(child as React.ReactElement, childProps);
+ },
+ { component: 'InviteMembersButton', renderWhileLoading: true },
+);
diff --git a/packages/react/src/components/__tests__/InviteMembersButton.test.tsx b/packages/react/src/components/__tests__/InviteMembersButton.test.tsx
new file mode 100644
index 00000000000..ff0e3fb2a7a
--- /dev/null
+++ b/packages/react/src/components/__tests__/InviteMembersButton.test.tsx
@@ -0,0 +1,109 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { userEvent } from '@testing-library/user-event';
+import React from 'react';
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { InviteMembersButton } from '../InviteMembersButton';
+
+const mockOpenInviteMembers = vi.fn();
+const originalError = console.error;
+
+const mockClerk = {
+ openInviteMembers: mockOpenInviteMembers,
+} as any;
+
+vi.mock('../withClerk', () => {
+ return {
+ withClerk: (Component: any) => (props: any) => {
+ return (
+
+ );
+ },
+ };
+});
+
+describe('', () => {
+ beforeAll(() => {
+ console.error = vi.fn();
+ });
+
+ afterAll(() => {
+ console.error = originalError;
+ });
+
+ beforeEach(() => {
+ mockOpenInviteMembers.mockReset();
+ });
+
+ it('calls clerk.openInviteMembers when clicked', async () => {
+ render();
+ const btn = screen.getByText('Invite members');
+
+ await userEvent.click(btn);
+ await waitFor(() => {
+ expect(mockOpenInviteMembers).toHaveBeenCalled();
+ });
+ });
+
+ it('forwards appearance to clerk.openInviteMembers', async () => {
+ const appearance = { elements: { rootBox: 'test' } };
+ render();
+ const btn = screen.getByText('Invite members');
+
+ await userEvent.click(btn);
+ await waitFor(() => {
+ expect(mockOpenInviteMembers).toHaveBeenCalledWith(expect.objectContaining({ appearance }));
+ });
+ });
+
+ it('renders passed button and calls both click handlers', async () => {
+ const handler = vi.fn();
+ render(
+
+
+ ,
+ );
+ const btn = screen.getByText('custom button');
+
+ await userEvent.click(btn);
+ await waitFor(() => {
+ expect(handler).toHaveBeenCalled();
+ expect(mockOpenInviteMembers).toHaveBeenCalled();
+ });
+ });
+
+ it('uses text passed as children', async () => {
+ render(text);
+ screen.getByText('text');
+ });
+
+ it('throws if multiple children provided', async () => {
+ expect(() => {
+ render(
+
+
+
+ ,
+ );
+ }).toThrow();
+ });
+
+ it('does not pass appearance prop to child element', () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ const button = container.querySelector('button');
+ expect(button?.hasAttribute('appearance')).toBe(false);
+ });
+});
diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts
index 0cec6374f29..8baf38bf307 100644
--- a/packages/react/src/components/index.ts
+++ b/packages/react/src/components/index.ts
@@ -35,6 +35,7 @@ export {
export type { ShowProps } from './controlComponents';
+export { InviteMembersButton } from './InviteMembersButton';
export { SignInButton } from './SignInButton';
export { SignInWithMetamaskButton } from './SignInWithMetamaskButton';
export { SignOutButton } from './SignOutButton';
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index 15f805e2f9b..816e6709d60 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -33,6 +33,7 @@ import type {
GoogleOneTapProps,
HandleEmailLinkVerificationParams,
HandleOAuthCallbackParams,
+ InviteMembersModalProps,
JoinWaitlistParams,
ListenerCallback,
ListenerOptions,
@@ -144,6 +145,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
private preopenSignUp?: null | SignUpProps = null;
private preopenUserProfile?: null | UserProfileProps = null;
private preopenOrganizationProfile?: null | OrganizationProfileProps = null;
+ private preopenInviteMembers?: null | InviteMembersModalProps = null;
private preopenCreateOrganization?: null | CreateOrganizationProps = null;
private preOpenWaitlist?: null | WaitlistProps = null;
private premountSignInNodes = new Map();
@@ -713,6 +715,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
clerkjs.openOrganizationProfile(this.preopenOrganizationProfile);
}
+ if (this.preopenInviteMembers !== null) {
+ clerkjs.openInviteMembers(this.preopenInviteMembers);
+ }
+
if (this.preopenCreateOrganization !== null) {
clerkjs.openCreateOrganization(this.preopenCreateOrganization);
}
@@ -1064,6 +1070,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};
+ openInviteMembers = (props?: InviteMembersModalProps) => {
+ if (this.clerkjs && this.loaded) {
+ this.clerkjs.openInviteMembers(props);
+ } else {
+ this.preopenInviteMembers = props;
+ }
+ };
+
+ closeInviteMembers = () => {
+ if (this.clerkjs && this.loaded) {
+ this.clerkjs.closeInviteMembers();
+ } else {
+ this.preopenInviteMembers = null;
+ }
+ };
+
openCreateOrganization = (props?: CreateOrganizationProps) => {
if (this.clerkjs && this.loaded) {
this.clerkjs.openCreateOrganization(props);
diff --git a/packages/react/src/utils/childrenUtils.tsx b/packages/react/src/utils/childrenUtils.tsx
index 73b26c8c4af..7a6be1f0e58 100644
--- a/packages/react/src/utils/childrenUtils.tsx
+++ b/packages/react/src/utils/childrenUtils.tsx
@@ -13,7 +13,8 @@ export const assertSingleChild =
| 'SignInWithMetamaskButton'
| 'CheckoutButton'
| 'SubscriptionDetailsButton'
- | 'PlanDetailsButton',
+ | 'PlanDetailsButton'
+ | 'InviteMembersButton',
) => {
try {
return React.Children.only(children);
diff --git a/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts b/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts
index ce9fcd298f3..92f2750cdff 100644
--- a/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts
+++ b/packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts
@@ -20,4 +20,25 @@ describe('warnings', () => {
expect(warnings.cannotOpenSignInOrSignUp).toContain('This notice only appears in development');
});
});
+
+ describe('createCannotRenderComponentWhenPermissionIsMissing', () => {
+ const message = warnings.createCannotRenderComponentWhenPermissionIsMissing(
+ 'InviteMembers',
+ 'org:sys_memberships:manage',
+ );
+
+ it('names the component and the missing permission', () => {
+ expect(message).toContain('');
+ expect(message).toContain('org:sys_memberships:manage');
+ });
+
+ it('explains it is a no-op and how to gate the component', () => {
+ expect(message).toContain('this is no-op');
+ expect(message).toContain(' {
+ expect(message).toContain('This notice only appears in development');
+ });
+ });
});
diff --git a/packages/shared/src/internal/clerk-js/warnings.ts b/packages/shared/src/internal/clerk-js/warnings.ts
index 385f0363c07..4081f830339 100644
--- a/packages/shared/src/internal/clerk-js/warnings.ts
+++ b/packages/shared/src/internal/clerk-js/warnings.ts
@@ -7,6 +7,7 @@ const formatWarning = (msg: string) => {
const createMessageForDisabledOrganizations = (
componentName:
| 'OrganizationProfile'
+ | 'InviteMembers'
| 'OrganizationSwitcher'
| 'OrganizationList'
| 'CreateOrganization'
@@ -18,12 +19,20 @@ const createMessageForDisabledOrganizations = (
);
};
-const createCannotRenderComponentWhenOrgDoesNotExist = (componentName: 'OrganizationProfile' | 'ConfigureSSO') => {
+const createCannotRenderComponentWhenOrgDoesNotExist = (
+ componentName: 'OrganizationProfile' | 'InviteMembers' | 'ConfigureSSO',
+) => {
return formatWarning(
`<${componentName}/> cannot render unless an organization is active. Since no organization is currently active, this is no-op.`,
);
};
+const createCannotRenderComponentWhenPermissionIsMissing = (componentName: 'InviteMembers', permission: string) => {
+ return formatWarning(
+ `<${componentName}/> cannot render unless the current user has the \`${permission}\` permission. Since the current user is missing this permission, this is no-op. Render it only for members who can manage memberships, for example by wrapping it in .`,
+ );
+};
+
const createMessageForDisabledBilling = (componentName: 'PricingTable' | 'Checkout' | 'PlanDetails') => {
return formatWarning(
`The <${componentName}/> component cannot be rendered when billing is disabled. Visit 'https://dashboard.clerk.com/last-active?path=billing/settings' to follow the necessary steps to enable billing. Since billing is disabled, this is no-op.`,
@@ -54,6 +63,7 @@ const warnings = {
cannotRenderComponentWhenUserDoesNotExist:
' cannot render unless a user is signed in. Since no user is signed in, this is no-op.',
createCannotRenderComponentWhenOrgDoesNotExist,
+ createCannotRenderComponentWhenPermissionIsMissing,
cannotRenderAnyOrganizationComponent: createMessageForDisabledOrganizations,
cannotRenderAnyBillingComponent: createMessageForDisabledBilling,
cannotOpenUserProfile:
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index 19be83620d6..14cc735b84f 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -513,6 +513,18 @@ export interface Clerk {
*/
closeOrganizationProfile: () => void;
+ /**
+ * Opens a modal containing the organization invite-members form.
+ *
+ * @param props - Optional props that will be passed to the invite-members modal.
+ */
+ openInviteMembers: (props?: InviteMembersModalProps) => void;
+
+ /**
+ * Closes the invite-members modal.
+ */
+ closeInviteMembers: () => void;
+
/**
* Opens the Clerk CreateOrganization modal.
*
@@ -1821,6 +1833,7 @@ export type __internal_EnableOrganizationsPromptProps = {
caller:
| 'OrganizationSwitcher'
| 'OrganizationProfile'
+ | 'InviteMembers'
| 'OrganizationList'
| 'useOrganizationList'
| 'useOrganization';
@@ -1831,6 +1844,7 @@ export type __internal_AttemptToEnableEnvironmentSettingParams = {
caller:
| 'OrganizationSwitcher'
| 'OrganizationProfile'
+ | 'InviteMembers'
| 'OrganizationList'
| 'CreateOrganization'
| 'TaskChooseOrganization'
@@ -2037,6 +2051,23 @@ export type OrganizationProfileModalProps = WithoutRouting HTMLElement | null;
};
+/** @generateWithEmptyComment */
+export type InviteMembersProps = {
+ /**
+ * Customization options to fully match the Clerk components to your own brand. These options serve as overrides and will be merged with the global `appearance` configuration (if one is provided). See the [`Appearance`](https://clerk.com/docs/guides/customizing-clerk/appearance-prop/overview) docs for more information.
+ */
+ appearance?: OrganizationProfileProps['appearance'];
+};
+
+export type InviteMembersModalProps = InviteMembersProps & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
+};
+
/** @generateWithEmptyComment */
export type CreateOrganizationProps = RoutingOptions & {
/**
@@ -2679,6 +2710,11 @@ export type SignUpButtonProps = (SignUpButtonPropsModal | ButtonPropsRedirect) &
| 'oauthFlow'
>;
+/**
+ * The invite-members form is only available as a modal, so there is no `mode` prop.
+ */
+export type InviteMembersButtonProps = InviteMembersProps;
+
/** @generateWithEmptyComment */
export type TaskChooseOrganizationProps = {
/**
diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap
index 4a5318392ee..ee11ac03e0f 100644
--- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -32,6 +32,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
+ "InviteMembersButton",
"OAuthConsent",
"OrganizationList",
"OrganizationProfile",
diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json
index 08f942932d0..8c08f84446f 100644
--- a/packages/ui/bundlewatch.config.json
+++ b/packages/ui/bundlewatch.config.json
@@ -2,7 +2,7 @@
"files": [
{ "path": "./dist/ui.browser.js", "maxSize": "44KB" },
{ "path": "./dist/ui.legacy.browser.js", "maxSize": "84KB" },
- { "path": "./dist/ui.shared.browser.js", "maxSize": "40KB" },
+ { "path": "./dist/ui.shared.browser.js", "maxSize": "42KB" },
{ "path": "./dist/framework*.js", "maxSize": "44KB" },
{ "path": "./dist/vendors*.js", "maxSize": "73KB" },
{ "path": "./dist/ui-common*.js", "maxSize": "130KB" },
diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx
index 9b290c5ca9a..ef830af901b 100644
--- a/packages/ui/src/Components.tsx
+++ b/packages/ui/src/Components.tsx
@@ -11,15 +11,16 @@ import type {
CreateOrganizationModalProps,
EnvironmentResource,
GoogleOneTapProps,
+ InviteMembersModalProps,
OrganizationProfileModalProps,
- SignInProps,
SignInModalProps,
- SignUpProps,
+ SignInProps,
SignUpModalProps,
+ SignUpProps,
UserProfileModalProps,
UserProfileProps,
- WaitlistProps,
WaitlistModalProps,
+ WaitlistProps,
} from '@clerk/shared/types';
import { createDeferredPromise } from '@clerk/shared/utils';
import React, { Suspense, useCallback, useRef, useSyncExternalStore } from 'react';
@@ -34,6 +35,7 @@ import {
CreateOrganizationModal,
EnableOrganizationsPrompt,
ImpersonationFab,
+ InviteMembersModal,
KeylessPrompt,
OrganizationProfileModal,
preloadComponent,
@@ -90,6 +92,7 @@ export type ComponentControls = {
| 'signUp'
| 'userProfile'
| 'organizationProfile'
+ | 'inviteMembers'
| 'createOrganization'
| 'userVerification'
| 'waitlist'
@@ -105,9 +108,11 @@ export type ComponentControls = {
? __internal_UserVerificationProps
: T extends 'waitlist'
? WaitlistProps
- : T extends 'enableOrganizationsPrompt'
- ? __internal_EnableOrganizationsPromptProps
- : UserProfileProps,
+ : T extends 'inviteMembers'
+ ? InviteMembersModalProps
+ : T extends 'enableOrganizationsPrompt'
+ ? __internal_EnableOrganizationsPromptProps
+ : UserProfileProps,
) => void;
closeModal: (
modal:
@@ -116,6 +121,7 @@ export type ComponentControls = {
| 'signUp'
| 'userProfile'
| 'organizationProfile'
+ | 'inviteMembers'
| 'createOrganization'
| 'userVerification'
| 'waitlist'
@@ -170,6 +176,7 @@ interface ComponentsState {
userProfileModal: null | UserProfileModalProps;
userVerificationModal: null | __internal_UserVerificationProps;
organizationProfileModal: null | OrganizationProfileModalProps;
+ inviteMembersModal: null | InviteMembersModalProps;
createOrganizationModal: null | CreateOrganizationModalProps;
enableOrganizationsPromptModal: null | __internal_EnableOrganizationsPromptProps;
blankCaptchaModal: null;
@@ -300,6 +307,7 @@ const Components = (props: ComponentsProps) => {
userProfileModal: null,
userVerificationModal: null,
organizationProfileModal: null,
+ inviteMembersModal: null,
createOrganizationModal: null,
enableOrganizationsPromptModal: null,
organizationSwitcherPrefetch: false,
@@ -327,6 +335,7 @@ const Components = (props: ComponentsProps) => {
userProfileModal,
userVerificationModal,
organizationProfileModal,
+ inviteMembersModal,
createOrganizationModal,
waitlistModal,
blankCaptchaModal,
@@ -604,6 +613,23 @@ const Components = (props: ComponentsProps) => {
);
+ const mountedInviteMembersModal = (
+ componentsControls.closeModal('inviteMembers')}
+ onExternalNavigate={() => componentsControls.closeModal('inviteMembers')}
+ startPath={buildVirtualRouterUrl({ base: '/inviteMembers', path: urlStateParam?.path })}
+ getContainer={inviteMembersModal?.getContainer ?? (() => null)}
+ componentName={'InviteMembersModal'}
+ modalContainerSx={{ alignItems: 'center' }}
+ >
+
+
+ );
+
const mountedCreateOrganizationModal = (
{
{userProfileModal && mountedUserProfileModal}
{userVerificationModal && mountedUserVerificationModal}
{organizationProfileModal && mountedOrganizationProfileModal}
+ {inviteMembersModal && mountedInviteMembersModal}
{createOrganizationModal && mountedCreateOrganizationModal}
{waitlistModal && mountedWaitlistModal}
{blankCaptchaModal && mountedBlankCaptchaModal}
diff --git a/packages/ui/src/components/InviteMembers/InviteMembers.tsx b/packages/ui/src/components/InviteMembers/InviteMembers.tsx
new file mode 100644
index 00000000000..3c4cb663358
--- /dev/null
+++ b/packages/ui/src/components/InviteMembers/InviteMembers.tsx
@@ -0,0 +1,66 @@
+import { useOrganization } from '@clerk/shared/react';
+import type { InviteMembersModalProps } from '@clerk/shared/types';
+
+import { Protect, useWizard, Wizard } from '@/common';
+import { SubscriberTypeContext } from '@/contexts';
+import { localizationKeys } from '@/customizables';
+import { Card } from '@/elements/Card';
+import { useCardState, withCardStateProvider } from '@/elements/contexts';
+import { FormContainer } from '@/elements/FormContainer';
+import { Route } from '@/router';
+
+import { InviteMembersForm } from '../OrganizationProfile/InviteMembersForm';
+import { InvitationsSentMessage } from '../OrganizationProfile/InviteMembersScreen';
+
+const InviteMembersModalInner = withCardStateProvider(() => {
+ const { organization } = useOrganization();
+ const card = useCardState();
+ const wizard = useWizard({ onNextStep: () => card.setError(undefined) });
+ const title = localizationKeys('organizationProfile.invitePage.title');
+ const subtitle = localizationKeys('organizationProfile.invitePage.subtitle');
+
+ if (!organization) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+export const InviteMembersModal = (_props: InviteMembersModalProps): JSX.Element => {
+ return (
+
+
+
+ {/*TODO: Used by InvisibleRootBox, can we simplify? */}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/InviteMembers/__tests__/InviteMembersModal.test.tsx b/packages/ui/src/components/InviteMembers/__tests__/InviteMembersModal.test.tsx
new file mode 100644
index 00000000000..b5b80f11fd7
--- /dev/null
+++ b/packages/ui/src/components/InviteMembers/__tests__/InviteMembersModal.test.tsx
@@ -0,0 +1,59 @@
+import { waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render } from '@/test/utils';
+import { VirtualRouter } from '@/ui/router';
+
+import { clearFetchCache } from '../../../hooks';
+import { InviteMembersModal } from '..';
+
+const { createFixtures } = bindCreateFixtures('OrganizationProfile');
+
+describe('InviteMembersModal', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ it('renders the invite-members form for a user who can manage memberships', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ organization_memberships: [{ name: 'Org1', role: 'admin' }],
+ });
+ });
+
+ fixtures.clerk.organization?.getInvitations.mockRejectedValue(null);
+ fixtures.clerk.organization?.getRoles.mockRejectedValue(null);
+
+ const { findByText, getByText } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(async () => expect(await findByText('Invite new members')).toBeInTheDocument());
+ getByText('Enter or paste one or more email addresses, separated by spaces or commas.');
+ });
+
+ it('renders nothing for a user without the manage-memberships permission', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ organization_memberships: [{ name: 'Org1', role: 'member', permissions: [] }],
+ });
+ });
+
+ const { queryByText } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => expect(queryByText('Invite new members')).not.toBeInTheDocument());
+ });
+});
diff --git a/packages/ui/src/components/InviteMembers/index.tsx b/packages/ui/src/components/InviteMembers/index.tsx
new file mode 100644
index 00000000000..2c550f6eb28
--- /dev/null
+++ b/packages/ui/src/components/InviteMembers/index.tsx
@@ -0,0 +1 @@
+export * from './InviteMembers';
diff --git a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx
index 0574dc883e9..efc812e0aab 100644
--- a/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx
+++ b/packages/ui/src/components/OrganizationProfile/InviteMembersForm.tsx
@@ -33,6 +33,11 @@ type InviteMembersFormProps = {
onReset?: () => void;
primaryButtonLabel?: LocalizationKey;
resetButtonLabel?: LocalizationKey;
+ /**
+ * The standalone invite-members modal is dismissed with the modal's close button rather than a
+ * cancel action, so it hides the reset button and drops the leading margin on the controls row.
+ */
+ hideResetButton?: boolean;
};
type InviteMembersParams = {
@@ -41,7 +46,7 @@ type InviteMembersParams = {
};
export const InviteMembersForm = (props: InviteMembersFormProps) => {
- const { onSuccess, onReset, resetButtonLabel } = props;
+ const { onSuccess, onReset, resetButtonLabel, hideResetButton } = props;
const clerk = useClerk();
const { organization, invitations } = useOrganization({
invitations: {
@@ -300,7 +305,7 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
align='center'
justify='between'
sx={t => ({
- marginTop: t.space.$2,
+ marginTop: hideResetButton ? t.space.$none : t.space.$2,
gap: t.space.$4,
flexWrap: 'wrap',
[mqu.sm]: { justifyContent: 'center' },
@@ -317,11 +322,13 @@ export const InviteMembersForm = (props: InviteMembersFormProps) => {
: localizationKeys('organizationProfile.invitePage.formButtonPrimary__continue')
}
/>
-
+ {!hideResetButton && (
+
+ )}
diff --git a/packages/ui/src/lazyModules/components.ts b/packages/ui/src/lazyModules/components.ts
index 3c8e62f7e39..a6564663a43 100644
--- a/packages/ui/src/lazyModules/components.ts
+++ b/packages/ui/src/lazyModules/components.ts
@@ -9,6 +9,7 @@ const componentImportPaths = {
CreateOrganization: () => import(/* webpackChunkName: "createorganization" */ './../components/CreateOrganization'),
OrganizationProfile: () =>
import(/* webpackChunkName: "organizationprofile" */ './../components/OrganizationProfile'),
+ InviteMembers: () => import(/* webpackChunkName: "invitemembers" */ './../components/InviteMembers'),
OrganizationSwitcher: () =>
import(/* webpackChunkName: "organizationswitcher" */ './../components/OrganizationSwitcher'),
OrganizationList: () => import(/* webpackChunkName: "organizationlist" */ './../components/OrganizationList'),
@@ -90,6 +91,10 @@ export const OrganizationProfileModal = lazy(() =>
componentImportPaths.OrganizationProfile().then(module => ({ default: module.OrganizationProfileModal })),
);
+export const InviteMembersModal = lazy(() =>
+ componentImportPaths.InviteMembers().then(module => ({ default: module.InviteMembersModal })),
+);
+
export const OrganizationSwitcher = lazy(() =>
componentImportPaths.OrganizationSwitcher().then(module => ({ default: module.OrganizationSwitcher })),
);
@@ -175,6 +180,7 @@ export const ClerkComponents = {
SignUpModal,
UserProfileModal,
OrganizationProfileModal,
+ InviteMembersModal,
CreateOrganizationModal,
UserVerificationModal,
GoogleOneTap,