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,