diff --git a/.changeset/mosaic-invite-members-button.md b/.changeset/mosaic-invite-members-button.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-invite-members-button.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/integration/tests/passkeys.test.ts b/integration/tests/passkeys.test.ts
index f079940e8f2..77968ed22a5 100644
--- a/integration/tests/passkeys.test.ts
+++ b/integration/tests/passkeys.test.ts
@@ -18,12 +18,15 @@ type SavedCredential = {
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP';
// clerk-js gates WebAuthn behind isValidBrowser(), which bails when
-// navigator.webdriver is true, so mask it before installing the virtual authenticator.
-const installVirtualAuthenticator = async (context: BrowserContext) => {
- await context.addInitScript(() => {
- Object.defineProperty(Object.getPrototypeOf(navigator), 'webdriver', { get: () => false });
- });
+// navigator.webdriver is true, so mask it before Clerk loads.
+const installVirtualAuthenticator = async (context: BrowserContext, { autofill = false } = {}) => {
await context.credentials.install();
+ await context.addInitScript(autofill => {
+ Object.defineProperty(Object.getPrototypeOf(navigator), 'webdriver', { get: () => false });
+ if (!autofill) {
+ PublicKeyCredential.isConditionalMediationAvailable = () => Promise.resolve(false);
+ }
+ }, autofill);
};
let app: Application;
@@ -76,7 +79,7 @@ test.describe('passkeys @generic', () => {
test('signs in with passkey via conditional UI autofill', async ({ page, context }) => {
await context.credentials.create(savedCredential.rpId, savedCredential);
- await installVirtualAuthenticator(context);
+ await installVirtualAuthenticator(context, { autofill: true });
const u = await createTestUtils({ app, page, context });
await u.po.signIn.goTo();
@@ -95,8 +98,6 @@ test.describe('passkeys @generic', () => {
await u.po.signIn.continue();
await expect(u.page.getByText('Use your passkey')).toBeVisible();
- // Seed only now: the start page's conditional-UI autofill is answered
- // instantly by the virtual authenticator and would preempt this flow.
await context.credentials.create(savedCredential.rpId, savedCredential);
await u.po.signIn.continue();
@@ -111,7 +112,6 @@ test.describe('passkeys @generic', () => {
const usePasskeyLink = u.page.getByRole('link', { name: /use passkey instead/i });
await expect(usePasskeyLink).toBeVisible();
- // Seed after load for the same autofill-preemption reason as above.
await context.credentials.create(savedCredential.rpId, savedCredential);
await usePasskeyLink.click();
@@ -180,8 +180,6 @@ test.describe('passkeys as a second factor @generic', () => {
// Passkey outranks the enrolled authenticator app once the backend advertises it.
await expect(u.page.getByText('Use your passkey')).toBeVisible();
- // Seed only now: the start page's conditional-UI autofill would otherwise
- // answer with this credential and verify it as the first factor instead.
await context.credentials.create(savedCredential.rpId, savedCredential);
await u.po.signIn.continue();
diff --git a/packages/mosaic/src/features/create-organization/create-organization.model.ts b/packages/mosaic/src/features/create-organization/create-organization.model.ts
index f714fef8207..e42d5d7c622 100644
--- a/packages/mosaic/src/features/create-organization/create-organization.model.ts
+++ b/packages/mosaic/src/features/create-organization/create-organization.model.ts
@@ -5,7 +5,7 @@ import type { CreateOrganizationProps, OrganizationResource } from '@clerk/share
import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
import { useMosaicRouter } from '../../hooks/use-mosaic-router';
import { save } from '../../utils/errors';
-import { rejectedEmailAddresses } from '../organization-profile/organization-profile-members-panel/invitations-table-tab.model';
+import { rejectedEmailAddresses } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.model';
import { resolveAfterUrl } from '../switcher/switcher.model';
import type {
CreatedOrganization,
diff --git a/packages/mosaic/src/features/invite-members-button/__tests__/invite-members-button.feature.test.tsx b/packages/mosaic/src/features/invite-members-button/__tests__/invite-members-button.feature.test.tsx
new file mode 100644
index 00000000000..7fab7efc948
--- /dev/null
+++ b/packages/mosaic/src/features/invite-members-button/__tests__/invite-members-button.feature.test.tsx
@@ -0,0 +1,214 @@
+import type { RoleJSON } from '@clerk/shared/types';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEnvironment,
+ fapiMembership,
+ fapiOrganization,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import type { InviteMembersButtonProps } from '../invite-members-button';
+import { InviteMembersButton } from '../invite-members-button';
+
+const organization = fapiOrganization({ id: 'org_invite_button', name: 'Acme' });
+
+const role = (key: string, name: string): RoleJSON => ({
+ object: 'role',
+ id: `role_${key}`,
+ key,
+ name,
+ description: '',
+ permissions: [],
+ created_at: 0,
+ updated_at: 0,
+});
+
+const MANAGE = ['org:sys_memberships:read', 'org:sys_memberships:manage'];
+
+function serve({
+ permissions = MANAGE,
+ withOrganization = true,
+ defaultRole = 'org:member',
+}: { permissions?: string[]; withOrganization?: boolean; defaultRole?: string | null } = {}) {
+ const membership = fapiMembership(organization, { permissions });
+ const user = fapiUser({ id: 'user_invite_button', organization_memberships: [membership] });
+ return serveFapi({
+ environment: fapiEnvironment({
+ organization_settings: {
+ ...fapiEnvironment().organization_settings,
+ domains: { enabled: false, enrollment_modes: [], default_role: defaultRole },
+ },
+ }),
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_invite_button',
+ user,
+ last_active_organization_id: withOrganization ? organization.id : null,
+ }),
+ ]),
+ memberships: [membership],
+ roles: [role('org:admin', 'Admin'), role('org:member', 'Member')],
+ });
+}
+
+function tree(props: InviteMembersButtonProps = {}) {
+ return (
+
+
+
+ );
+}
+
+const host = () => screen.getByTestId('host');
+
+async function openDialog() {
+ const user = userEvent.setup();
+ const button = await screen.findByRole('button', { name: 'Invite members' });
+ await waitFor(() => expect(button).toBeEnabled());
+ await user.click(button);
+ const dialog = within(await screen.findByRole('dialog', { name: 'Invite members' }));
+ return { user, button, dialog };
+}
+
+describe('InviteMembersButton', () => {
+ it('renders its fallback while Clerk is still loading', async () => {
+ serve();
+ const client = holdRequests('get', '/v1/client');
+ const rendering = renderWithClerk(tree({ fallback: }));
+
+ await waitFor(() => expect(client.requests).toHaveLength(1));
+ expect(screen.getByTestId('fallback')).toBeInTheDocument();
+
+ client.release();
+ await rendering;
+ expect(screen.queryByTestId('fallback')).toBeNull();
+ expect(await screen.findByRole('button', { name: 'Invite members' })).toBeVisible();
+ });
+
+ it('renders nothing without an active organization', async () => {
+ serve({ withOrganization: false });
+ await renderWithClerk(tree({ fallback: }));
+
+ expect(host()).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing without the manage-memberships permission', async () => {
+ serve({ permissions: ['org:sys_memberships:read'] });
+ const roles = holdRequests('get', '/v1/organizations/:organizationId/roles');
+ await renderWithClerk(tree());
+
+ expect(host()).toBeEmptyDOMElement();
+ expect(roles.requests).toHaveLength(0);
+ roles.release();
+ });
+
+ it('does not load invitations', async () => {
+ serve();
+ const invitations = holdRequests('get', '/v1/organizations/:organizationId/invitations');
+ await renderWithClerk(tree());
+ await openDialog();
+
+ expect(invitations.requests).toHaveLength(0);
+ invitations.release();
+ });
+
+ it('stays unavailable until roles load', async () => {
+ serve();
+ const roles = holdRequests('get', '/v1/organizations/:organizationId/roles');
+ await renderWithClerk(tree());
+ const user = userEvent.setup();
+
+ await waitFor(() => expect(roles.requests).toHaveLength(1));
+ const button = screen.getByRole('button', { name: 'Invite members' });
+ expect(button).toBeDisabled();
+ expect(button).toHaveAttribute('data-disabled');
+ await user.click(button);
+ expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull();
+
+ roles.release();
+ await waitFor(() => expect(button).toBeEnabled());
+ });
+
+ it('invites members with the default role and returns focus to the button', async () => {
+ const fapi = serve();
+ await renderWithClerk(tree());
+ const { user, button, dialog } = await openDialog();
+
+ expect(dialog.getByRole('combobox', { name: /Role/ })).toHaveTextContent('Member');
+ await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example.com,linus@example.com{Enter}');
+
+ const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk');
+ await user.click(dialog.getByRole('button', { name: 'Send invites' }));
+ await waitFor(() => expect(bulk.requests).toHaveLength(1));
+ const body = new URLSearchParams(await bulk.requests[0]?.text());
+ expect(body.getAll('email_address')).toEqual(['grace@example.com', 'linus@example.com']);
+ expect(body.get('role')).toBe('org:member');
+ bulk.release();
+
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'Invitations sent' })).toBeVisible());
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull());
+ expect(fapi.organizationInvitations.map(item => [item.email_address, item.role])).toEqual([
+ ['grace@example.com', 'org:member'],
+ ['linus@example.com', 'org:member'],
+ ]);
+ await waitFor(() => expect(button).toHaveFocus());
+ });
+
+ it('opens empty again after a cancelled invite', async () => {
+ serve();
+ await renderWithClerk(tree());
+ const { user, dialog } = await openDialog();
+
+ await user.type(dialog.getByRole('textbox', { name: 'Email' }), 'grace@example.com{Enter}');
+ await user.click(dialog.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull());
+
+ const reopened = await openDialog();
+ expect(reopened.dialog.queryByText('grace@example.com')).toBeNull();
+ });
+
+ it('renders an unstyled native button', async () => {
+ serve();
+ await renderWithClerk(tree());
+
+ const button = await screen.findByRole('button', { name: 'Invite members' });
+ expect(button.tagName).toBe('BUTTON');
+ expect(button).toHaveAttribute('type', 'button');
+ expect(button).not.toHaveAttribute('class');
+ });
+
+ it('takes its label from children and its element from render', async () => {
+ serve();
+ await renderWithClerk(
+ tree({
+ render: ,
+ nativeButton: false,
+ className: 'mine',
+ children: 'Add teammates',
+ }),
+ );
+
+ const trigger = await screen.findByTestId('trigger');
+ expect(trigger).toHaveAccessibleName('Add teammates');
+ expect(trigger).toHaveAttribute('role', 'button');
+ expect(trigger).toHaveClass('mine');
+ await waitFor(() => expect(trigger).not.toHaveAttribute('data-disabled'));
+ await userEvent.setup().click(trigger);
+ expect(await screen.findByRole('dialog', { name: 'Invite members' })).toBeVisible();
+ });
+
+ it('runs its own onClick and opens the dialog', async () => {
+ serve();
+ const onClick = vi.fn();
+ await renderWithClerk(tree({ onClick }));
+ await openDialog();
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/mosaic/src/features/invite-members-button/invite-members-button.messages.ts b/packages/mosaic/src/features/invite-members-button/invite-members-button.messages.ts
new file mode 100644
index 00000000000..d008c0471b2
--- /dev/null
+++ b/packages/mosaic/src/features/invite-members-button/invite-members-button.messages.ts
@@ -0,0 +1,3 @@
+export const inviteMembersButtonMessages = {
+ label: 'Invite members',
+};
diff --git a/packages/mosaic/src/features/invite-members-button/invite-members-button.model.ts b/packages/mosaic/src/features/invite-members-button/invite-members-button.model.ts
new file mode 100644
index 00000000000..8bbaff1e828
--- /dev/null
+++ b/packages/mosaic/src/features/invite-members-button/invite-members-button.model.ts
@@ -0,0 +1,34 @@
+import { useOrganization, useSession } from '@clerk/shared/react';
+
+import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
+import { ORGANIZATION_PERMISSIONS } from '../../utils/permissions';
+import { useInviteMembersModel } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.model';
+import { useMembersPanelRolesModel } from '../organization-profile/organization-profile-members-panel/organization-profile-members-panel.model';
+
+export type InviteMembersButtonAccessModel =
+ | { status: 'loading' }
+ | { status: 'hidden' }
+ | { status: 'ready'; organizationId: string; sessionId: string };
+
+export function useInviteMembersButtonAccessModel(): InviteMembersButtonAccessModel {
+ const { isLoaded, organization } = useOrganization();
+ const { isLoaded: isSessionLoaded, session } = useSession();
+ const environment = useMosaicEnvironment();
+
+ if (!isLoaded || !isSessionLoaded || !environment) {
+ return { status: 'loading' };
+ }
+
+ if (!organization || !session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsManage })) {
+ return { status: 'hidden' };
+ }
+
+ return { status: 'ready', organizationId: organization.id, sessionId: session.id };
+}
+
+export function useInviteMembersButtonModel() {
+ const { roles, defaultRole } = useMembersPanelRolesModel(true);
+ const invite = useInviteMembersModel();
+
+ return { roles, defaultRole, invite };
+}
diff --git a/packages/mosaic/src/features/invite-members-button/invite-members-button.tsx b/packages/mosaic/src/features/invite-members-button/invite-members-button.tsx
new file mode 100644
index 00000000000..dd46a29997a
--- /dev/null
+++ b/packages/mosaic/src/features/invite-members-button/invite-members-button.tsx
@@ -0,0 +1,76 @@
+'use client';
+
+import type { ReactElement, ReactNode } from 'react';
+
+import { useInviteMembersController } from '../organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller';
+import { useInviteMembersButtonAccessModel, useInviteMembersButtonModel } from './invite-members-button.model';
+import type { InviteMembersButtonElementProps } from './invite-members-button.view';
+import { InviteMembersButtonView } from './invite-members-button.view';
+
+/** Everything `` takes: the element it renders, and what stands in while Clerk loads. */
+export interface InviteMembersButtonProps extends InviteMembersButtonElementProps {
+ /**
+ * Fallback while loading.
+ *
+ * Note that the component renders nothing when there is no active organization or the user cannot
+ * manage its members, so using this where either can be true can result in Fallback->Nothing.
+ */
+ fallback?: ReactNode;
+}
+
+/**
+ * A headless button that opens the invite-members dialog for the active organization. It ships no
+ * styles: it renders a plain `