From 41a6a61d2db6d7eb5fff1610fb22b46135399ac4 Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Mon, 2 Nov 2026 13:43:23 -0500 Subject: [PATCH 1/3] feat(mosaic): add headless invite members button --- .changeset/mosaic-invite-members-button.md | 2 + .../invite-members-button.feature.test.tsx | 214 ++++++++++++++++++ .../invite-members-button.messages.ts | 3 + .../invite-members-button.model.ts | 34 +++ .../invite-members-button.tsx | 76 +++++++ .../invite-members-button.view.tsx | 39 ++++ .../invitations-table-tab.model.ts | 56 +---- ...ation-profile-invite-members.controller.ts | 2 +- ...ganization-profile-invite-members.model.ts | 61 +++++ packages/mosaic/src/localization/registry.ts | 2 + .../live/misc/invite-members-button/page.tsx | 44 ++++ .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/live-navigation.ts | 1 + packages/swingset/src/lib/registry.ts | 14 ++ .../src/stories/invite-members-button.mdx | 82 +++++++ .../stories/invite-members-button.stories.tsx | 55 +++++ 16 files changed, 635 insertions(+), 51 deletions(-) create mode 100644 .changeset/mosaic-invite-members-button.md create mode 100644 packages/mosaic/src/features/invite-members-button/__tests__/invite-members-button.feature.test.tsx create mode 100644 packages/mosaic/src/features/invite-members-button/invite-members-button.messages.ts create mode 100644 packages/mosaic/src/features/invite-members-button/invite-members-button.model.ts create mode 100644 packages/mosaic/src/features/invite-members-button/invite-members-button.tsx create mode 100644 packages/mosaic/src/features/invite-members-button/invite-members-button.view.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.model.ts create mode 100644 packages/swingset/src/app/(clerk)/live/misc/invite-members-button/page.tsx create mode 100644 packages/swingset/src/stories/invite-members-button.mdx create mode 100644 packages/swingset/src/stories/invite-members-button.stories.tsx 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/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: Loading })); + + 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: Loading })); + + 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 ` + {inviteDialog ? : null} + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts index d83ec7de682..f5b9ff9b2df 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.model.ts @@ -1,22 +1,13 @@ -import { ClerkRuntimeError, isClerkAPIResponseError } from '@clerk/shared/error'; -import { useClerk, useOrganization, useSession } from '@clerk/shared/react'; -import type { ClerkAPIError, OrganizationInvitationResource } from '@clerk/shared/types'; +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useOrganization, useSession } from '@clerk/shared/react'; +import type { OrganizationInvitationResource } from '@clerk/shared/types'; -import { save } from '../../../utils/errors'; import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions'; -import type { MembersRoles } from './members-table-tab.types'; +import { useInviteMembersModel } from './organization-profile-invite-members.model'; const PAGE_SIZE = 10; -export interface InviteMembersInput { - emailAddresses: string[]; - role: string; - roles: MembersRoles; - onRejected: (emailAddresses: string[]) => void; -} - export function useInvitationsTableModel() { - const clerk = useClerk(); const { session } = useSession(); const canManage = session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsManage }) ?? false; const { organization, invitations } = useOrganization({ @@ -25,6 +16,7 @@ export function useInvitationsTableModel() { const rows = (invitations?.data ?? []) .filter(invitation => invitation.organizationId === organization?.id && invitation.status === 'pending') .map(toInvitation); + const invite = useInviteMembersModel({ onInvited: () => invitations?.revalidate?.() }); return { rows, @@ -52,46 +44,10 @@ export function useInvitationsTableModel() { } } : undefined, - invite: canManage - ? ({ emailAddresses, role, roles, onRejected }: InviteMembersInput) => - save(async () => { - const organizationId = organization?.id; - const sessionId = session?.id; - if (!organization || !roles.roles.some(option => option.key === role)) { - throw new ClerkRuntimeError('This role is unavailable.', { code: 'role_unavailable' }); - } - // TODO: Add session reverification for invitations; surface API errors until then. - try { - await organization.inviteMembers({ emailAddresses, role }); - } catch (cause) { - onRejected(rejectedEmailAddresses(cause, emailAddresses)); - throw cause; - } - if (clerk.organization?.id !== organizationId || clerk.session?.id !== sessionId) { - throw new ClerkRuntimeError('The active organization changed.', { code: 'organization_changed' }); - } - await invitations?.revalidate?.(); - }) - : undefined, + invite, }; } -function rejectedEmailAddresses(cause: unknown, submitted: string[]) { - if (!isClerkAPIResponseError(cause)) { - return []; - } - const isRejected = (email: string) => - cause.errors.some( - (error: ClerkAPIError) => - [...(error.meta?.emailAddresses ?? []), ...(error.meta?.identifiers ?? [])].some( - value => value.toLowerCase() === email.toLowerCase(), - ) || - (error.code === 'already_a_member_in_organization' && - (error.longMessage ?? '').toLowerCase().startsWith(`${email.toLowerCase()} `)), - ); - return submitted.filter(isRejected); -} - function toInvitation(invitation: OrganizationInvitationResource) { const { id, emailAddress, createdAt, role, roleName } = invitation; return { id, emailAddress, createdAt, role, roleName }; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts index 61996130575..c787a395be1 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.controller.ts @@ -4,9 +4,9 @@ import { useRef, useState } from 'react'; import { useForm } from '../../../components/form'; import { useToastManager } from '../../../components/toast'; import { plural, useLocale, useMessages } from '../../../localization'; -import type { InviteMembersInput } from './invitations-table-tab.model'; import type { MembersRoles } from './members-table-tab.types'; import type { OrganizationProfileInviteMembersDialogProps } from './organization-profile-invite-members.dialog'; +import type { InviteMembersInput } from './organization-profile-invite-members.model'; interface InviteMembersValues { emailAddresses: string[]; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.model.ts new file mode 100644 index 00000000000..7eab383d1b8 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/organization-profile-invite-members.model.ts @@ -0,0 +1,61 @@ +import { ClerkRuntimeError, isClerkAPIResponseError } from '@clerk/shared/error'; +import { useClerk, useOrganization, useSession } from '@clerk/shared/react'; +import type { ClerkAPIError } from '@clerk/shared/types'; + +import { save } from '../../../utils/errors'; +import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions'; +import type { MembersRoles } from './members-table-tab.types'; + +export interface InviteMembersInput { + emailAddresses: string[]; + role: string; + roles: MembersRoles; + onRejected: (emailAddresses: string[]) => void; +} + +export function useInviteMembersModel({ onInvited }: { onInvited?: () => unknown } = {}) { + const clerk = useClerk(); + const { session } = useSession(); + const { organization } = useOrganization(); + const canManage = session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.membershipsManage }) ?? false; + + if (!canManage) { + return undefined; + } + + return ({ emailAddresses, role, roles, onRejected }: InviteMembersInput) => + save(async () => { + const organizationId = organization?.id; + const sessionId = session?.id; + if (!organization || !roles.roles.some(option => option.key === role)) { + throw new ClerkRuntimeError('This role is unavailable.', { code: 'role_unavailable' }); + } + // TODO: Add session reverification for invitations; surface API errors until then. + try { + await organization.inviteMembers({ emailAddresses, role }); + } catch (cause) { + onRejected(rejectedEmailAddresses(cause, emailAddresses)); + throw cause; + } + if (clerk.organization?.id !== organizationId || clerk.session?.id !== sessionId) { + throw new ClerkRuntimeError('The active organization changed.', { code: 'organization_changed' }); + } + await onInvited?.(); + }); +} + +function rejectedEmailAddresses(cause: unknown, submitted: string[]) { + if (!isClerkAPIResponseError(cause)) { + return []; + } + const isRejected = (email: string) => + cause.errors.some( + (error: ClerkAPIError) => + [...(error.meta?.emailAddresses ?? []), ...(error.meta?.identifiers ?? [])].some( + value => value.toLowerCase() === email.toLowerCase(), + ) || + (error.code === 'already_a_member_in_organization' && + (error.longMessage ?? '').toLowerCase().startsWith(`${email.toLowerCase()} `)), + ); + return submitted.filter(isRejected); +} diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 420df1eba8e..2269c691056 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -1,6 +1,7 @@ import { formMessages } from '../components/form/form.messages'; import { profileMessages } from '../components/profile/profile.messages'; import { apiKeysTableMessages } from '../features/api-keys/api-keys-table.messages'; +import { inviteMembersButtonMessages } from '../features/invite-members-button/invite-members-button.messages'; import { organizationProfileMessages } from '../features/organization-profile/organization-profile.messages'; import { organizationProfileDangerSectionMessages } from '../features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages'; import { invitationsTableTabMessages } from '../features/organization-profile/organization-profile-members-panel/invitations-table-tab.messages'; @@ -68,6 +69,7 @@ export const mosaicMessages = { }, danger: organizationProfileDangerSectionMessages, }, + inviteMembersButton: inviteMembersButtonMessages, apiKeys: apiKeysTableMessages, reverification: reverificationMessages, profile: profileMessages, diff --git a/packages/swingset/src/app/(clerk)/live/misc/invite-members-button/page.tsx b/packages/swingset/src/app/(clerk)/live/misc/invite-members-button/page.tsx new file mode 100644 index 00000000000..80acf31fe4a --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/misc/invite-members-button/page.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { Button } from '@clerk/mosaic/components/button'; +import { InviteMembersButton } from '@clerk/mosaic/features/invite-members-button/invite-members-button'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useOrganization, useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +import { LivePageHeader } from '../../../live-page-header'; + +export default function InviteMembersButtonLivePage() { + const { isLoaded, isSignedIn } = useUser(); + const { organization } = useOrganization(); + + return ( + +
+ + Invite members to the active organization. The button renders only for members who can manage memberships. + + {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + and pick an active organization. +

+ ) : null} + {isLoaded && isSignedIn && !organization ? ( +

Pick an active organization to invite members to it.

+ ) : null} + {isLoaded && isSignedIn ? ( +
+ } /> +
+ ) : null} +
+
+ ); +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d93567e5f3c..79802dc342c 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -54,6 +54,7 @@ const docModules: Record> = { misc: { 'organization-switcher': dynamic(() => import('../stories/organization-switcher.mdx')), 'user-avatar': dynamic(() => import('../stories/user-avatar.mdx')), + 'invite-members-button': dynamic(() => import('../stories/invite-members-button.mdx')), 'api-keys-table': dynamic(() => import('../stories/api-keys-table.mdx')), reverification: dynamic(() => import('../stories/reverification.mdx')), }, diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index b4f662c4181..d92bc4ec07a 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -36,6 +36,7 @@ const livePages = new Set([ '/organization-profile/organization-profile-danger-section', '/user-profile/user-profile-passkeys-section', '/user-profile/user-profile-web3wallets-section', + '/misc/invite-members-button', '/misc/api-keys-table', '/misc/reverification', '/user-button/user-button', diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 92049024d8b..613ad59d8f0 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -132,6 +132,12 @@ import { meta as inputGroupMeta, Sizes as InputGroupSizes, } from '../stories/input-group.stories'; +import { + CustomElement as InviteMembersButtonCustomElement, + Default as InviteMembersButtonDefault, + meta as inviteMembersButtonMeta, + Unavailable as InviteMembersButtonUnavailable, +} from '../stories/invite-members-button.stories'; import { Default as ItemDefault, Group as ItemGroup, @@ -694,6 +700,13 @@ const userButtonModule: StoryModule = { User: UserButtonUser, }; +const inviteMembersButtonModule: StoryModule = { + meta: inviteMembersButtonMeta, + Default: InviteMembersButtonDefault, + CustomElement: InviteMembersButtonCustomElement, + Unavailable: InviteMembersButtonUnavailable, +}; + const userAvatarModule: StoryModule = { meta: userAvatarMeta, Default: UserAvatarDefault, @@ -1079,6 +1092,7 @@ export const registry: StoryModule[] = [ // Misc organizationSwitcherModule, userAvatarModule, + inviteMembersButtonModule, apiKeysTableModule, reverificationModule, // Blocks — flows assembled from components, wired by the caller's machine. diff --git a/packages/swingset/src/stories/invite-members-button.mdx b/packages/swingset/src/stories/invite-members-button.mdx new file mode 100644 index 00000000000..7bbe97b60e6 --- /dev/null +++ b/packages/swingset/src/stories/invite-members-button.mdx @@ -0,0 +1,82 @@ +import * as InviteMembersButtonStories from './invite-members-button.stories'; + +# InviteMembersButton + +A headless button that opens the invite-members dialog for the active organization. It ships no +styles: by default it renders a plain `