Skip to content
Open
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
21 changes: 21 additions & 0 deletions .changeset/invite-members-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/react': minor
'@clerk/nextjs': minor
'@clerk/ui': minor
---

Add `<InviteMembersButton />`, a control component that opens the organization invite-members form in a modal when clicked, working like `<SignInButton mode="modal">`.

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';

<InviteMembersButton>
<button>Invite members</button>
</InviteMembersButton>;
```

This also adds `Clerk.openInviteMembers()` and `Clerk.closeInviteMembers()` for opening and closing the modal programmatically.
1 change: 1 addition & 0 deletions packages/clerk-js/sandbox/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/sandbox/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,10 @@
label="Configure SSO"
component="<ConfigureSSO />"
></nav-link>
<nav-link
href="/open-invite-members"
label="Open invite members"
></nav-link>
</nav-group>
<nav-group label="Tasks">
<nav-link
Expand Down
48 changes: 48 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import type {
HandleEmailLinkVerificationParams,
HandleOAuthCallbackParams,
InstanceType,
InviteMembersModalProps,
JoinWaitlistParams,
ListenerCallback,
ListenerOptions,
Expand Down Expand Up @@ -208,6 +209,7 @@ const CANNOT_RENDER_BILLING_DISABLED_ERROR_CODE = 'cannot_render_billing_disable
const CANNOT_RENDER_USER_MISSING_ERROR_CODE = 'cannot_render_user_missing';
const CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE = 'cannot_render_organizations_disabled';
const CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE = 'cannot_render_organization_missing';
const CANNOT_RENDER_PERMISSION_MISSING_ERROR_CODE = 'cannot_render_permission_missing';
const CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE = 'cannot_render_single_session_enabled';
const CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE = 'cannot_render_api_keys_disabled';
const CANNOT_RENDER_API_KEYS_USER_DISABLED_ERROR_CODE = 'cannot_render_api_keys_user_disabled';
Expand Down Expand Up @@ -998,6 +1000,52 @@ export class Clerk implements ClerkInterface {
void this.#clerkUI?.then(ui => 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',
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/client-boundary/uiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
CreateOrganization,
GoogleOneTap,
HandleSSOCallback,
InviteMembersButton,
OAuthConsent,
OrganizationList,
OrganizationSwitcher,
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
APIKeys,
CreateOrganization,
GoogleOneTap,
InviteMembersButton,
OAuthConsent,
OrganizationList,
OrganizationProfile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"CreateOrganization",
"GoogleOneTap",
"HandleSSOCallback",
"InviteMembersButton",
"OAuthConsent",
"OrganizationList",
"OrganizationProfile",
Expand Down
52 changes: 52 additions & 0 deletions packages/react/src/components/InviteMembersButton.tsx
Original file line number Diff line number Diff line change
@@ -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 `<Show>` 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 (
* <InviteMembersButton>
* <button>Invite members</button>
* </InviteMembersButton>
* );
* }
* ```
*/
export const InviteMembersButton = withClerk(
({ clerk, children, ...props }: WithClerkProp<React.PropsWithChildren<InviteMembersButtonProps>>) => {
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<unknown>, childProps);
},
{ component: 'InviteMembersButton', renderWhileLoading: true },
);
109 changes: 109 additions & 0 deletions packages/react/src/components/__tests__/InviteMembersButton.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Comment on lines +11 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove untyped any from the test mocks.

The mocked Clerk object, component, and props use any, disabling compile-time validation of the new public component contract. Use typed mocks or unknown with narrowing.

As per coding guidelines, any is disallowed without justification.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/react/src/components/__tests__/InviteMembersButton.test.tsx` around
lines 11 - 17, Replace the untyped any usages in the mockClerk object and the
withClerk mock’s Component and props parameters with appropriate Clerk/component
prop types, or use unknown and narrow it before access. Preserve compile-time
validation of the public component contract while keeping the test mock behavior
unchanged.

Source: Coding guidelines

return (
<Component
{...props}
clerk={mockClerk}
/>
);
},
};
});

describe('<InviteMembersButton/>', () => {
beforeAll(() => {
console.error = vi.fn();
});

afterAll(() => {
console.error = originalError;
});

beforeEach(() => {
mockOpenInviteMembers.mockReset();
});

it('calls clerk.openInviteMembers when clicked', async () => {
render(<InviteMembersButton />);
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(<InviteMembersButton appearance={appearance} />);
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(
<InviteMembersButton>
<button
onClick={handler}
type='button'
>
custom button
</button>
</InviteMembersButton>,
);
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(<InviteMembersButton>text</InviteMembersButton>);
screen.getByText('text');
});

it('throws if multiple children provided', async () => {
expect(() => {
render(
<InviteMembersButton>
<button type='button'>1</button>
<button type='button'>2</button>
</InviteMembersButton>,
);
}).toThrow();
});

it('does not pass appearance prop to child element', () => {
const { container } = render(
<InviteMembersButton appearance={{ elements: { rootBox: 'test' } }}>
<button type='button'>Invite</button>
</InviteMembersButton>,
);

const button = container.querySelector('button');
expect(button?.hasAttribute('appearance')).toBe(false);
});
});
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
22 changes: 22 additions & 0 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
GoogleOneTapProps,
HandleEmailLinkVerificationParams,
HandleOAuthCallbackParams,
InviteMembersModalProps,
JoinWaitlistParams,
ListenerCallback,
ListenerOptions,
Expand Down Expand Up @@ -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<HTMLDivElement, SignInProps | undefined>();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

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);
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/utils/childrenUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export const assertSingleChild =
| 'SignInWithMetamaskButton'
| 'CheckoutButton'
| 'SubscriptionDetailsButton'
| 'PlanDetailsButton',
| 'PlanDetailsButton'
| 'InviteMembersButton',
) => {
try {
return React.Children.only(children);
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/internal/clerk-js/__tests__/warnings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<InviteMembers/>');
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('<Show when=');
});

it('includes the development notice', () => {
expect(message).toContain('This notice only appears in development');
});
});
});
Loading
Loading