diff --git a/.changeset/wild-members-gather.md b/.changeset/wild-members-gather.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wild-members-gather.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 15993226c7a..35bdb3e9da1 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -14,6 +14,7 @@ const docModules: Record> = { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), 'organization-profile-api-keys-panel': dynamic(() => import('../stories/organization-profile-api-keys-panel.mdx')), + 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), 'organization-profile-profile-section': dynamic( () => import('../stories/organization-profile-profile-section.mdx'), ), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index f14dce7483f..dee445b26f0 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -58,6 +58,10 @@ import { Default as OrganizationProfileLeaveSectionDefault, meta as organizationProfileLeaveSectionMeta, } from '../stories/organization-profile-leave-section.stories'; +import { + Default as OrganizationProfileMembersPanelDefault, + meta as organizationProfileMembersPanelMeta, +} from '../stories/organization-profile-members-panel.stories'; import { Default as OrganizationProfileProfileSectionDefault, meta as organizationProfileProfileSectionMeta, @@ -104,6 +108,10 @@ const organizationProfileApiKeysPanelModule: StoryModule = { meta: organizationProfileApiKeysPanelMeta, Default: OrganizationProfileApiKeysPanelDefault, }; +const organizationProfileMembersPanelModule: StoryModule = { + meta: organizationProfileMembersPanelMeta, + Default: OrganizationProfileMembersPanelDefault, +}; const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; @@ -155,6 +163,7 @@ export const registry: StoryModule[] = [ organizationProfileModule, organizationProfileGeneralPanelModule, organizationProfileApiKeysPanelModule, + organizationProfileMembersPanelModule, organizationProfileProfileSectionModule, organizationProfileDomainsSectionModule, organizationProfileLeaveSectionModule, diff --git a/packages/swingset/src/stories/organization-profile-members-panel.mdx b/packages/swingset/src/stories/organization-profile-members-panel.mdx new file mode 100644 index 00000000000..10bca05d65f --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.mdx @@ -0,0 +1,17 @@ +import * as OrganizationProfileMembersPanelStories from './organization-profile-members-panel.stories'; + +# Organization Profile Members Panel + +The Members tab panel of the Organization Profile — lists an organization's active members and owns the search and remove flows. It gates on `org:sys_memberships:read` to view and `org:sys_memberships:manage` to remove a member. + + diff --git a/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx new file mode 100644 index 00000000000..8191d386541 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx @@ -0,0 +1,73 @@ +/** @jsxImportSource @emotion/react */ +import { useMachine } from '@clerk/ui/mosaic/machine/useMachine'; +import type { MemberRow } from '@clerk/ui/mosaic/organization/organization-profile-members-panel.controller'; +import { organizationProfileMembersPanelMachine } from '@clerk/ui/mosaic/organization/organization-profile-members-panel.machine'; +import { OrganizationProfileMembersPanelView } from '@clerk/ui/mosaic/organization/organization-profile-members-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Organization', + title: 'OrganizationProfileMembersPanel', + source: 'packages/ui/src/mosaic/organization/organization-profile-members-panel.tsx', +}; + +const DEMO_MEMBERS: Omit[] = [ + { + id: 'mem_1', + name: 'Ada Lovelace', + identifier: 'ada@example.com', + roleLabel: 'Admin', + joinedAt: '1/12/2024', + isCurrentUser: true, + isBanned: false, + }, + { + id: 'mem_2', + name: 'Alan Turing', + identifier: 'alan@example.com', + roleLabel: 'Member', + joinedAt: '3/4/2024', + isCurrentUser: false, + isBanned: false, + }, + { + id: 'mem_3', + name: 'Grace Hopper', + identifier: 'grace@example.com', + roleLabel: 'Member', + joinedAt: '6/9/2024', + isCurrentUser: false, + isBanned: true, + }, +]; + +export function Default() { + const [snapshot, send] = useMachine(organizationProfileMembersPanelMachine); + const [page, setPage] = useState(1); + + const rows: MemberRow[] = DEMO_MEMBERS.map(member => ({ + ...member, + onRemove: () => + send({ + type: 'REMOVE_MEMBER', + membershipId: member.id, + // Simulate the network round-trip so the row shows its "Removing…" state. + run: () => new Promise(resolve => setTimeout(resolve, 700)), + }), + })); + + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile.stories.tsx b/packages/swingset/src/stories/organization-profile.stories.tsx index 275e283e9b4..0d389ec1ab2 100644 --- a/packages/swingset/src/stories/organization-profile.stories.tsx +++ b/packages/swingset/src/stories/organization-profile.stories.tsx @@ -5,6 +5,7 @@ import type { StoryMeta } from '@/lib/types'; import { Default as OrganizationProfileApiKeysPanelDemo } from './organization-profile-api-keys-panel.stories'; import { Default as OrganizationProfileGeneralPanelDemo } from './organization-profile-general-panel.stories'; +import { Default as OrganizationProfileMembersPanelDemo } from './organization-profile-members-panel.stories'; export const meta: StoryMeta = { group: 'Organization', @@ -16,6 +17,7 @@ export function Default() { return ( } + members={} apiKeys={} /> ); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.controller.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.controller.test.tsx new file mode 100644 index 00000000000..aceabfd186d --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.controller.test.tsx @@ -0,0 +1,213 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../machines/__tests__/test-utils'; +import { useOrganizationProfileMembersPanelController } from '../organization-profile-members-panel.controller'; + +const READ = 'org:sys_memberships:read'; +const MANAGE = 'org:sys_memberships:manage'; + +let permissions: string[]; +let user: { id: string } | null; +let isSessionLoaded: boolean; +let isOrganizationLoaded: boolean; +let organization: { id: string } | null; +let memberships: { + data: Array>; + count: number; + page: number; + pageCount: number; + isLoading: boolean; + fetchPage: ReturnType; + revalidate: ReturnType; +} | null; +let destroy: ReturnType; +let revalidate: ReturnType; +let fetchPage: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useOrganization: () => ({ isLoaded: isOrganizationLoaded, organization, memberships }), + useSession: () => ({ + isLoaded: isSessionLoaded, + session: isSessionLoaded + ? { + id: 'sess_1', + checkAuthorization: ({ permission }: { permission: string }) => permissions.includes(permission), + } + : undefined, + }), + useUser: () => ({ user }), + }; +}); + +beforeEach(() => { + permissions = [READ, MANAGE]; + user = { id: 'user_self' }; + isSessionLoaded = true; + isOrganizationLoaded = true; + organization = { id: 'org_1' }; + destroy = vi.fn().mockResolvedValue(undefined); + revalidate = vi.fn().mockResolvedValue(undefined); + fetchPage = vi.fn(); + memberships = { + data: [ + { + id: 'mem_1', + publicUserData: { userId: 'user_self', firstName: 'Alice', lastName: 'Doe', identifier: 'alice@example.com' }, + role: 'org:admin', + roleName: 'Admin', + createdAt: new Date('2024-01-01T00:00:00Z'), + destroy, + }, + { + id: 'mem_2', + publicUserData: { + userId: 'user_2', + firstName: 'Bob', + lastName: null, + identifier: 'bob@example.com', + banned: true, + }, + role: 'org:member', + roleName: 'Member', + createdAt: new Date('2024-02-01T00:00:00Z'), + destroy, + }, + ], + count: 2, + page: 1, + pageCount: 1, + isLoading: false, + fetchPage, + revalidate, + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness() { + const controller = useOrganizationProfileMembersPanelController(); + if (controller.status !== 'ready') { + return {controller.status}; + } + return ( +
+ {controller.snapshot.value} + {controller.snapshot.context.error ?? ''} + {controller.snapshot.context.query} + {String(controller.canManage)} + + {controller.rows.map(r => `${r.name}|${r.roleLabel}|${r.isCurrentUser}|${r.isBanned}`).join(';')} + + + + + {controller.rows.map(r => ( + + ))} +
+ ); +} + +describe('useOrganizationProfileMembersPanelController', () => { + it('is loading until the session is loaded', () => { + isSessionLoaded = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is loading until the organization is loaded', () => { + isOrganizationLoaded = false; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('loading'); + }); + + it('is hidden when the user cannot read memberships', () => { + permissions = []; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('is hidden when there is no active organization', () => { + organization = null; + render(); + expect(screen.getByTestId('state')).toHaveTextContent('hidden'); + }); + + it('derives plain rows from the memberships resource', () => { + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(screen.getByTestId('rows')).toHaveTextContent('Alice Doe|Admin|true|false;Bob|Member|false|true'); + }); + + it('reports canManage from the manage permission', () => { + permissions = [READ]; + render(); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(screen.getByTestId('canManage')).toHaveTextContent('false'); + }); + + it('commits the search query only on submit', () => { + render(); + + fireEvent.click(screen.getByText('ChangeSearch')); + expect(screen.getByTestId('query')).toHaveTextContent(''); + + fireEvent.click(screen.getByText('SubmitSearch')); + expect(screen.getByTestId('query')).toHaveTextContent('z'); + }); + + it('drives removal through destroy + revalidate', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + + fireEvent.click(screen.getByText('Remove mem_2')); + expect(screen.getByTestId('state')).toHaveTextContent('removing'); + expect(destroy).toHaveBeenCalledTimes(1); + + await act(async () => { + gate.resolve(); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(revalidate).toHaveBeenCalledTimes(1); + }); + + it('surfaces a removal error on the snapshot', async () => { + const gate = deferred(); + destroy.mockReturnValue(gate.promise); + + render(); + fireEvent.click(screen.getByText('Remove mem_2')); + + await act(async () => { + gate.reject(new Error('cannot remove last admin')); + }); + + expect(screen.getByTestId('state')).toHaveTextContent('ready'); + expect(screen.getByTestId('error')).toHaveTextContent('cannot remove last admin'); + expect(revalidate).not.toHaveBeenCalled(); + }); + + it('fetches a new page via the memberships resource', () => { + render(); + + fireEvent.click(screen.getByText('Page2')); + + expect(fetchPage).toHaveBeenCalledWith(2); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.machine.test.ts b/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.machine.test.ts new file mode 100644 index 00000000000..16c6705b9be --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.machine.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { organizationProfileMembersPanelMachine } from '../organization-profile-members-panel.machine'; + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); + +describe('organizationProfileMembersPanelMachine', () => { + it('holds the search value uncommitted until SEARCH_SUBMIT', () => { + const actor = createActor(organizationProfileMembersPanelMachine); + actor.start(); + + actor.send({ type: 'SEARCH_CHANGE', value: 'alice' }); + expect(actor.getSnapshot().context.search).toBe('alice'); + // The committed query the controller keys off is unchanged until submit. + expect(actor.getSnapshot().context.query).toBe(''); + + actor.send({ type: 'SEARCH_SUBMIT' }); + expect(actor.getSnapshot().context.query).toBe('alice'); + }); + + it('invokes the injected remove effect and returns to ready on success', async () => { + const run = vi.fn(() => Promise.resolve()); + const actor = createActor(organizationProfileMembersPanelMachine); + actor.start(); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_1', run }); + + expect(actor.getSnapshot().value).toBe('removing'); + expect(actor.getSnapshot().context.pendingMembershipId).toBe('mem_1'); + expect(run).toHaveBeenCalledTimes(1); + + await tick(); + + expect(actor.getSnapshot().value).toBe('ready'); + expect(actor.getSnapshot().context.pendingMembershipId).toBeNull(); + expect(actor.getSnapshot().context.error).toBeNull(); + }); + + it('returns to ready with an error message when removal fails', async () => { + const run = vi.fn(() => Promise.reject(new Error('cannot remove'))); + const actor = createActor(organizationProfileMembersPanelMachine); + actor.start(); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_1', run }); + await tick(); + + expect(actor.getSnapshot().value).toBe('ready'); + expect(actor.getSnapshot().context.pendingMembershipId).toBeNull(); + expect(actor.getSnapshot().context.error).toBe('cannot remove'); + }); + + it('clears a prior error when a new removal starts', async () => { + const actor = createActor(organizationProfileMembersPanelMachine); + actor.start(); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_1', run: () => Promise.reject(new Error('boom')) }); + await tick(); + expect(actor.getSnapshot().context.error).toBe('boom'); + + actor.send({ type: 'REMOVE_MEMBER', membershipId: 'mem_2', run: () => Promise.resolve() }); + expect(actor.getSnapshot().context.error).toBeNull(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.view.test.tsx b/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.view.test.tsx new file mode 100644 index 00000000000..60bafd0d557 --- /dev/null +++ b/packages/ui/src/mosaic/organization/__tests__/organization-profile-members-panel.view.test.tsx @@ -0,0 +1,164 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Snapshot } from '../../machine/types'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { MemberRow } from '../organization-profile-members-panel.controller'; +import type { OrganizationProfileMembersPanelContext } from '../organization-profile-members-panel.machine'; +import { OrganizationProfileMembersPanelView } from '../organization-profile-members-panel.view'; + +function snapshot( + overrides: Partial = {}, +): Snapshot { + return { + value: 'ready', + status: 'active', + context: { + search: '', + query: '', + pendingMembershipId: null, + removeMember: async () => {}, + error: null, + ...overrides, + }, + }; +} + +function row(overrides: Partial = {}): MemberRow { + return { + id: 'mem_1', + name: 'Alice Doe', + identifier: 'alice@example.com', + roleLabel: 'Admin', + joinedAt: '1/1/2024', + isCurrentUser: false, + isBanned: false, + onRemove: vi.fn(), + ...overrides, + }; +} + +function renderView(props: Partial[0]> = {}) { + const send = vi.fn(); + const onPageChange = vi.fn(); + render( + + + , + ); + return { send, onPageChange }; +} + +describe('OrganizationProfileMembersPanelView', () => { + it('renders a member row with name, identifier, role, and joined date', () => { + renderView(); + + expect(screen.getByText('Alice Doe')).toBeInTheDocument(); + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('1/1/2024')).toBeInTheDocument(); + }); + + it('emits SEARCH_CHANGE while typing without committing the query', () => { + const { send } = renderView(); + + fireEvent.change(screen.getByLabelText('Search members'), { target: { value: 'bob' } }); + + expect(send).toHaveBeenCalledWith({ type: 'SEARCH_CHANGE', value: 'bob' }); + expect(send).not.toHaveBeenCalledWith({ type: 'SEARCH_SUBMIT' }); + }); + + it('emits SEARCH_SUBMIT when the search form is submitted', () => { + const { send } = renderView(); + + const form = screen.getByLabelText('Search members').closest('form'); + expect(form).not.toBeNull(); + fireEvent.submit(form!); + + expect(send).toHaveBeenCalledWith({ type: 'SEARCH_SUBMIT' }); + }); + + it('marks the current user with a badge and disables their remove action', () => { + renderView({ rows: [row({ isCurrentUser: true })] }); + + expect(screen.getByText('You')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled(); + }); + + it('marks banned members with a badge', () => { + renderView({ rows: [row({ isBanned: true })] }); + + expect(screen.getByText('Banned')).toBeInTheDocument(); + }); + + it('invokes the row remove handler when Remove is clicked', () => { + const onRemove = vi.fn(); + renderView({ rows: [row({ onRemove })] }); + + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('hides the remove action entirely when the user cannot manage members', () => { + renderView({ canManage: false }); + + expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument(); + expect(screen.queryByText('Actions')).not.toBeInTheDocument(); + }); + + it('disables and relabels the row currently being removed', () => { + renderView({ + snapshot: { ...snapshot({ pendingMembershipId: 'mem_1' }), value: 'removing' }, + rows: [row({ id: 'mem_1' })], + }); + + expect(screen.getByRole('button', { name: 'Removing…' })).toBeDisabled(); + }); + + it('shows an empty state when there are no members and it is not loading', () => { + renderView({ rows: [] }); + + expect(screen.getByText('There are no members to display')).toBeInTheDocument(); + }); + + it('does not show the empty state while loading', () => { + renderView({ rows: [], isLoading: true }); + + expect(screen.queryByText('There are no members to display')).not.toBeInTheDocument(); + }); + + it('surfaces a machine error without Clerk fixtures', () => { + renderView({ snapshot: snapshot({ error: 'Removal failed' }) }); + + expect(screen.getByRole('alert')).toHaveTextContent('Removal failed'); + }); + + it('renders pagination and moves pages via onPageChange', () => { + const { onPageChange } = renderView({ page: 2, pageCount: 3 }); + + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + expect(onPageChange).toHaveBeenCalledWith(3); + + fireEvent.click(screen.getByRole('button', { name: 'Previous' })); + expect(onPageChange).toHaveBeenCalledWith(1); + }); + + it('hides pagination when there is a single page', () => { + renderView({ page: 1, pageCount: 1 }); + + expect(screen.queryByText(/Page 1 of/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization/organization-profile-members-panel.controller.tsx b/packages/ui/src/mosaic/organization/organization-profile-members-panel.controller.tsx new file mode 100644 index 00000000000..fa1cb11d61a --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-members-panel.controller.tsx @@ -0,0 +1,123 @@ +import { useOrganization, useSession, useUser } from '@clerk/shared/react'; + +import type { Snapshot } from '../machine/types'; +import { useMachine } from '../machine/useMachine'; +import type { + OrganizationProfileMembersPanelContext, + OrganizationProfileMembersPanelEvent, +} from './organization-profile-members-panel.machine'; +import { organizationProfileMembersPanelMachine } from './organization-profile-members-panel.machine'; + +/** A single active member, reduced to plain data the view can render without Clerk. */ +export interface MemberRow { + id: string; + /** Display name, falling back to the identifier when no name is set. */ + name: string; + /** Email / phone / username used to identify the user. */ + identifier: string; + /** Human-readable role label (`roleName`, falling back to the raw role key). */ + roleLabel: string; + /** Locale-formatted join date. */ + joinedAt: string; + /** Whether this row is the signed-in user (their own remove action is disabled). */ + isCurrentUser: boolean; + /** Whether the member is banned (surfaced as a badge). */ + isBanned: boolean; + /** Dispatches the removal for this member; wired by the controller so the view stays Clerk-free. */ + onRemove: () => void; +} + +type OrganizationProfileMembersPanelController = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + snapshot: Snapshot; + send: (event: OrganizationProfileMembersPanelEvent) => void; + rows: MemberRow[]; + /** Whether the user can manage memberships (gates the remove action). */ + canManage: boolean; + page: number; + pageCount: number; + /** First-page load with no data yet — the view shows a skeleton. */ + isLoading: boolean; + onPageChange: (page: number) => void; + }; + +function memberName(firstName: string | null, lastName: string | null, identifier: string): string { + const full = [firstName, lastName].filter(Boolean).join(' ').trim(); + return full || identifier; +} + +export function useOrganizationProfileMembersPanelController(): OrganizationProfileMembersPanelController { + const [snapshot, send] = useMachine(organizationProfileMembersPanelMachine); + const { isLoaded: isSessionLoaded, session } = useSession(); + const { user } = useUser(); + + // Permissions gate both what we fetch and what the view can do. Computing them + // from the session (which is available before the org fetch resolves) lets us + // skip requesting memberships the user isn't allowed to read. + const canRead = session?.checkAuthorization({ permission: 'org:sys_memberships:read' }) ?? false; + const canManage = session?.checkAuthorization({ permission: 'org:sys_memberships:manage' }) ?? false; + + const { + isLoaded: isOrganizationLoaded, + organization, + memberships, + } = useOrganization({ + memberships: canRead ? { keepPreviousData: true, query: snapshot.context.query || undefined } : undefined, + }); + + // Both the session and the organization must be resolved before we can decide + // between 'ready' and 'hidden' — treat either still loading as 'loading'. + if (!isSessionLoaded || !isOrganizationLoaded) { + return { status: 'loading' }; + } + + if (!organization || !canRead) { + return { status: 'hidden' }; + } + + const currentUserId = user?.id; + const rows: MemberRow[] = (memberships?.data ?? []).map(membership => { + const publicUserData = membership.publicUserData; + return { + id: membership.id, + name: memberName( + publicUserData?.firstName ?? null, + publicUserData?.lastName ?? null, + publicUserData?.identifier ?? '', + ), + identifier: publicUserData?.identifier ?? '', + roleLabel: membership.roleName || membership.role, + joinedAt: membership.createdAt.toLocaleDateString(), + isCurrentUser: !!currentUserId && publicUserData?.userId === currentUserId, + isBanned: !!publicUserData?.banned, + onRemove: () => + send({ + type: 'REMOVE_MEMBER', + membershipId: membership.id, + run: async () => { + await membership.destroy(); + // Refresh the list so the removed row disappears. Not awaited: a stale + // list must not make a successful removal look like it failed. + void memberships?.revalidate?.(); + }, + }), + }; + }); + + return { + status: 'ready', + snapshot, + send, + rows, + canManage, + page: memberships?.page ?? 1, + pageCount: memberships?.pageCount ?? 0, + isLoading: !!memberships?.isLoading && (memberships?.data?.length ?? 0) === 0, + onPageChange: (page: number) => { + void memberships?.fetchPage?.(page); + }, + }; +} diff --git a/packages/ui/src/mosaic/organization/organization-profile-members-panel.machine.ts b/packages/ui/src/mosaic/organization/organization-profile-members-panel.machine.ts new file mode 100644 index 00000000000..933c1ba41d6 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-members-panel.machine.ts @@ -0,0 +1,73 @@ +import { setup } from '../machine/setup'; + +export interface OrganizationProfileMembersPanelContext { + /** The current value of the search box (uncommitted). */ + search: string; + /** The committed query the controller keys the memberships fetch off of. */ + query: string; + /** The membership currently being removed, so the view can disable just that row. */ + pendingMembershipId: string | null; + /** Injected per-removal effect — destroys a membership and revalidates the list. */ + removeMember: () => Promise; + error: string | null; +} + +export type OrganizationProfileMembersPanelEvent = + | { type: 'SEARCH_CHANGE'; value: string } + | { type: 'SEARCH_SUBMIT' } + | { type: 'REMOVE_MEMBER'; membershipId: string; run: () => Promise }; + +const { createMachine, assign, fromPromise } = setup< + OrganizationProfileMembersPanelContext, + OrganizationProfileMembersPanelEvent +>(); + +export const organizationProfileMembersPanelMachine = createMachine({ + id: 'organizationProfileMembersPanel', + initial: 'ready', + context: { + search: '', + query: '', + pendingMembershipId: null, + removeMember: async () => {}, + error: null, + }, + states: { + ready: { + on: { + // Typing only updates the uncommitted search value; the fetch is unchanged + // until SEARCH_SUBMIT commits it into `query` (mirrors the legacy + // search/query split so keystrokes don't trigger a request each time). + SEARCH_CHANGE: { + actions: assign((_, event) => ({ search: event.value })), + }, + SEARCH_SUBMIT: { + actions: assign(context => ({ query: context.search })), + }, + REMOVE_MEMBER: { + target: 'removing', + actions: assign((_, event) => ({ + pendingMembershipId: event.membershipId, + removeMember: event.run, + error: null, + })), + }, + }, + }, + removing: { + invoke: fromPromise(context => context.removeMember(), { + onDone: { + target: 'ready', + actions: assign(() => ({ pendingMembershipId: null })), + }, + onError: { + target: 'ready', + actions: assign((_, event) => ({ + pendingMembershipId: null, + error: event.error instanceof Error ? event.error.message : 'Something went wrong. Please try again.', + })), + }, + }), + }, + }, +}); diff --git a/packages/ui/src/mosaic/organization/organization-profile-members-panel.tsx b/packages/ui/src/mosaic/organization/organization-profile-members-panel.tsx new file mode 100644 index 00000000000..ed0a1cabe69 --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-members-panel.tsx @@ -0,0 +1,31 @@ +import type { ReactElement } from 'react'; + +import { useOrganizationProfileMembersPanelController } from './organization-profile-members-panel.controller'; +import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view'; + +/** + * The organization members panel: a searchable, paginated member list showing each + * member's name, join date, and role, plus a Remove action for managers. Returns + * `null` while the controller is not `ready` (permission gating and initial data + * load); once ready it renders {@link OrganizationProfileMembersPanelView}. Self-gates + * on membership read/manage permissions and requires a `MosaicProvider` ancestor. + * Exposed on the compound namespace as `OrganizationProfile.MembersPanel`. + */ +export function OrganizationProfileMembersPanel(): ReactElement | null { + const controller = useOrganizationProfileMembersPanelController(); + if (controller.status !== 'ready') { + return null; + } + return ( + + ); +} diff --git a/packages/ui/src/mosaic/organization/organization-profile-members-panel.view.tsx b/packages/ui/src/mosaic/organization/organization-profile-members-panel.view.tsx new file mode 100644 index 00000000000..cd14a48adbe --- /dev/null +++ b/packages/ui/src/mosaic/organization/organization-profile-members-panel.view.tsx @@ -0,0 +1,320 @@ +import type { ReactElement, ReactNode } from 'react'; + +import { Box } from '../components/box'; +import { Button } from '../components/button'; +import { Input } from '../components/input'; +import { Skeleton } from '../components/skeleton'; +import { Text } from '../components/text'; +import type { Snapshot } from '../machine/types'; +import type { MemberRow } from './organization-profile-members-panel.controller'; +import type { + OrganizationProfileMembersPanelContext, + OrganizationProfileMembersPanelEvent, +} from './organization-profile-members-panel.machine'; + +export interface OrganizationProfileMembersPanelViewProps { + /** Current machine snapshot; drives the search field, error alert, and per-row removing state. */ + snapshot: Snapshot; + /** Dispatches events to the machine (search input/submit, member removal). */ + send: (event: OrganizationProfileMembersPanelEvent) => void; + /** The members to render, already mapped to a Clerk-free row model by the controller. */ + rows: MemberRow[]; + /** Whether the current user may remove members; shows the Actions column and Remove buttons. */ + canManage: boolean; + /** The 1-based current page number. */ + page: number; + /** Total number of pages; pagination controls render only when greater than 1. */ + pageCount: number; + /** Whether the list is loading; renders skeleton rows in place of members. */ + isLoading: boolean; + /** Called with the requested 1-based page when the user pages forward or back. */ + onPageChange: (page: number) => void; +} + +/** Small pill used for the "You" / "Banned" row markers. */ +function Badge({ + children, + intent = 'neutral', +}: { + children: ReactNode; + intent?: 'neutral' | 'destructive'; +}): ReactElement { + return ( + } + sx={t => ({ + display: 'inline-flex', + alignItems: 'center', + ...t.text('xs'), + fontWeight: t.font.medium, + paddingInline: t.spacing(1.5), + paddingBlock: t.spacing(0.5), + borderRadius: t.rounded.full, + color: intent === 'destructive' ? t.color.destructive : t.color.mutedForeground, + backgroundColor: intent === 'destructive' ? t.alpha('destructive', 12) : t.alpha('primary', 8), + })} + > + {children} + + ); +} + +function HeaderCell({ children, align = 'start' }: { children?: ReactNode; align?: 'start' | 'end' }): ReactElement { + return ( + } + sx={t => ({ + ...t.text('xs'), + fontWeight: t.font.medium, + color: t.color.mutedForeground, + textAlign: align, + paddingInline: t.spacing(3), + paddingBlock: t.spacing(2), + whiteSpace: 'nowrap', + })} + > + {children} + + ); +} + +function Cell({ children, align = 'start' }: { children?: ReactNode; align?: 'start' | 'end' }): ReactElement { + return ( + } + sx={t => ({ + ...t.text('sm'), + textAlign: align, + paddingInline: t.spacing(3), + paddingBlock: t.spacing(3), + verticalAlign: 'middle', + })} + > + {children} + + ); +} + +/** + * Renders the members panel UI from a machine snapshot and controller-derived props: + * a search form, the members table (Member / Joined / Role, plus Actions when + * `canManage`), skeleton rows while loading, an empty state, an error alert, and + * pagination. Rendering only — it sends events via `send` and never touches Clerk + * resources. Consumed by {@link OrganizationProfileMembersPanel}; also rendered + * directly in the swingset story with a stubbed machine. + */ +export function OrganizationProfileMembersPanelView({ + snapshot, + send, + rows, + canManage, + page, + pageCount, + isLoading, + onPageChange, +}: OrganizationProfileMembersPanelViewProps): ReactElement { + const isRemoving = snapshot.value === 'removing'; + const columnCount = canManage ? 4 : 3; + + return ( + + ( +
{ + event.preventDefault(); + send({ type: 'SEARCH_SUBMIT' }); + }} + /> + )} + sx={t => ({ + display: 'flex', + gap: t.spacing(2), + marginBlockEnd: t.spacing(4), + })} + > + send({ type: 'SEARCH_CHANGE', value: event.currentTarget.value })} + /> + + + + {snapshot.context.error ? ( + ({ marginBlockEnd: t.spacing(3) })} + > + {snapshot.context.error} + + ) : null} + + } + sx={{ + width: '100%', + borderCollapse: 'collapse', + }} + > + }> + } + sx={t => ({ borderBottom: `1px solid ${t.alpha('primary', 10)}` })} + > + Member + Joined + Role + {canManage ? Actions : null} + + + }> + {isLoading ? ( + Array.from({ length: 3 }).map((_, index) => ( + } + > + {Array.from({ length: columnCount }).map((__, cellIndex) => ( + + + + ))} + + )) + ) : rows.length === 0 ? ( + }> + ( + } + sx={t => ({ borderBottom: `1px solid ${t.alpha('primary', 6)}` })} + > + + ({ display: 'flex', flexDirection: 'column', gap: t.spacing(0.5) })}> + ({ display: 'flex', alignItems: 'center', gap: t.spacing(1.5) })}> + ({ fontWeight: t.font.medium })} + > + {row.name} + + {row.isCurrentUser ? You : null} + {row.isBanned ? Banned : null} + + {row.identifier && row.identifier !== row.name ? ( + + {row.identifier} + + ) : null} + + + + + {row.joinedAt} + + + + {row.roleLabel} + + {canManage ? ( + + + + ) : null} + + ); + }) + )} + + + + {pageCount > 1 ? ( + ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: t.spacing(3), + marginBlockStart: t.spacing(4), + })} + > + + Page {page} of {pageCount} + + + + + ) : null} + + ); +} diff --git a/packages/ui/src/mosaic/organization/organization-profile-view.tsx b/packages/ui/src/mosaic/organization/organization-profile-view.tsx index 77bbf153a79..a82002370a6 100644 --- a/packages/ui/src/mosaic/organization/organization-profile-view.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile-view.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { Box } from '../components/box'; import { Tabs } from '../components/tabs'; @@ -6,11 +6,19 @@ import { Tabs } from '../components/tabs'; interface OrganizationProfileViewProps { /** The General tab's panel content. */ general: ReactNode; + /** The Members tab's panel content. */ + members: ReactNode; /** The API keys tab's panel content. */ apiKeys: ReactNode; } -export function OrganizationProfileView({ general, apiKeys }: OrganizationProfileViewProps) { +/** + * The organization profile shell: a heading and a tabbed layout that hosts each + * panel's content in the General, Members, and API keys tabs. Layout only — the + * caller supplies each tab's panel via the `general`, `members`, and `apiKeys` + * slots, so this component holds no data or Clerk state itself. + */ +export function OrganizationProfileView({ general, members, apiKeys }: OrganizationProfileViewProps): ReactElement { return ( API keys {general} - -

} - sx={t => ({ - ...t.text('base'), - fontWeight: t.font.medium, - textAlign: 'center', - })} - > - Members content - - + {members} {apiKeys} diff --git a/packages/ui/src/mosaic/organization/organization-profile.tsx b/packages/ui/src/mosaic/organization/organization-profile.tsx index 8b4be270f13..f3ef7e71e61 100644 --- a/packages/ui/src/mosaic/organization/organization-profile.tsx +++ b/packages/ui/src/mosaic/organization/organization-profile.tsx @@ -6,6 +6,7 @@ import { OrganizationProfileDeleteSection } from './organization-profile-delete- import { OrganizationProfileDomainsSection } from './organization-profile-domains-section'; import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel'; import { OrganizationProfileLeaveSection } from './organization-profile-leave-section'; +import { OrganizationProfileMembersPanel } from './organization-profile-members-panel'; import { OrganizationProfileProfileSection } from './organization-profile-profile-section'; import { OrganizationProfileView } from './organization-profile-view'; @@ -17,6 +18,7 @@ function OrganizationProfileRoot(): ReactElement | null { return ( } + members={} apiKeys={} /> ); @@ -38,4 +40,5 @@ export const OrganizationProfile = Object.assign(OrganizationProfileRoot, { LeaveSection: OrganizationProfileLeaveSection, DeleteSection: OrganizationProfileDeleteSection, ApiKeysPanel: OrganizationProfileApiKeysPanel, + MembersPanel: OrganizationProfileMembersPanel, });

+ )} + sx={t => ({ + ...t.text('sm'), + color: t.color.mutedForeground, + textAlign: 'center', + paddingBlock: t.spacing(8), + })} + > + There are no members to display + + + ) : ( + rows.map(row => { + const isRemovingRow = isRemoving && snapshot.context.pendingMembershipId === row.id; + return ( +