diff --git a/.changeset/mosaic-user-button-integration.md b/.changeset/mosaic-user-button-integration.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-integration.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts b/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts index 68a178b90dd..f4452d21b4d 100644 --- a/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts +++ b/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts @@ -7,13 +7,16 @@ import { useClerk } from './useClerk'; * * @internal */ -export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') { +export function useAttemptToEnableOrganizations( + caller: 'useOrganization' | 'useOrganizationList', + { enabled = true }: { enabled?: boolean } = {}, +) { const clerk = useClerk(); const hasAttempted = useRef(false); useEffect(() => { // Guard to not run this effect twice on Clerk resource update - if (hasAttempted.current) { + if (!enabled || hasAttempted.current) { return; } @@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use for: 'organizations', caller, }); - }, [clerk, caller]); + }, [clerk, caller, enabled]); } diff --git a/packages/shared/src/react/hooks/useOrganization.tsx b/packages/shared/src/react/hooks/useOrganization.tsx index 619230df9b2..cec686047d0 100644 --- a/packages/shared/src/react/hooks/useOrganization.tsx +++ b/packages/shared/src/react/hooks/useOrganization.tsx @@ -61,6 +61,12 @@ export type UseOrganizationParams = { * */ invitations?: true | PaginatedHookConfig; + /** + * Skip the development prompt that offers to enable Organizations. + * + * @internal + */ + __internal_skipAttemptToEnableOrganizations?: boolean; }; /** @@ -275,10 +281,13 @@ export function useOrganization(params?: T): Us membershipRequests: membershipRequestsListParams, memberships: membersListParams, invitations: invitationsListParams, + __internal_skipAttemptToEnableOrganizations, } = params || {}; useAssertWrappedByClerkProvider('useOrganization'); - useAttemptToEnableOrganizations('useOrganization'); + useAttemptToEnableOrganizations('useOrganization', { + enabled: !__internal_skipAttemptToEnableOrganizations, + }); const organization = useOrganizationBase(); const session = useSessionBase(); diff --git a/packages/shared/src/react/hooks/useOrganizationList.tsx b/packages/shared/src/react/hooks/useOrganizationList.tsx index e50fab1e88e..0872adff464 100644 --- a/packages/shared/src/react/hooks/useOrganizationList.tsx +++ b/packages/shared/src/react/hooks/useOrganizationList.tsx @@ -253,7 +253,10 @@ export function useOrganizationList(params? const { userMemberships, userInvitations, userSuggestions } = params || {}; useAssertWrappedByClerkProvider('useOrganizationList'); - useAttemptToEnableOrganizations('useOrganizationList'); + // No list keys means this call is not using Organizations; the prompt is for the lists. + useAttemptToEnableOrganizations('useOrganizationList', { + enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined, + }); const userMembershipsSafeValues = useWithSafeValues(userMemberships, { initialPage: 1, diff --git a/packages/ui/src/hooks/useOrganizationListInView.ts b/packages/ui/src/hooks/useOrganizationListInView.ts index 6f3bd7f36d9..9de803e3db8 100644 --- a/packages/ui/src/hooks/useOrganizationListInView.ts +++ b/packages/ui/src/hooks/useOrganizationListInView.ts @@ -5,14 +5,18 @@ import { useInView } from './useInView'; /** * @internal + * + * `enabled` withholds the list params so the three requests do not start. Defaults on. */ -export const useOrganizationListInView = () => { - const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams); +export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => { + const { userMemberships, userInvitations, userSuggestions } = useOrganizationList( + enabled ? organizationListParams : undefined, + ); const { ref } = useInView({ threshold: 0, onChange: inView => { - if (!inView) { + if (!enabled || !inView) { return; } if (userMemberships.hasNextPage) { diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx index b8398f07523..d8403b86d16 100644 --- a/packages/ui/src/mosaic/components/button/submit-button.test.tsx +++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx @@ -302,19 +302,29 @@ describe('Mosaic SubmitButton spin delay', () => { expect(atoms(spinner()).length).toBeLessThan(hidden.length); }); - // A consumer who already knows the action is slow has nothing to gain by waiting. + // A consumer who already knows the action is slow has nothing to gain by waiting: there is no + // delay left to outlast, so the spinner shows in the render that starts the action rather than a + // timer's. it('lets the consumer opt out of the delay', () => { - render( + const { rerender } = render( Save , ); const hidden = atoms(spinner()); - advance(0); + rerender( + + Save + , + ); + expect(atoms(spinner()).length).toBeLessThan(hidden.length); }); diff --git a/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts index 66052c26835..3ac3f63839e 100644 --- a/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts +++ b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts @@ -76,6 +76,27 @@ describe('useSpinDelay', () => { expect(result.current).toBeNull(); }); + // Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's + // worth of render passes before the spinner appears. + it('surfaces the value in the same pass when there is no delay to wait out', async () => { + const { result, rerender } = render(null, { delay: 0, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + + expect(result.current).toBe('a'); + }); + + it('still holds a zero-delay value for minDuration', async () => { + const { result, rerender } = render(null, { delay: 0, minDuration: 200 }); + await act(() => rerender({ value: 'a' })); + await act(() => rerender({ value: null })); + + await advance(199); + expect(result.current).toBe('a'); + + await advance(1); + expect(result.current).toBeNull(); + }); + it('swaps to a new value immediately when one replaces another mid-show', async () => { const { result, rerender } = render(null, { delay: 500, minDuration: 200 }); await act(() => rerender({ value: 'a' })); diff --git a/packages/ui/src/mosaic/hooks/useSpinDelay.ts b/packages/ui/src/mosaic/hooks/useSpinDelay.ts index b847c0bc517..dac6bc682b7 100644 --- a/packages/ui/src/mosaic/hooks/useSpinDelay.ts +++ b/packages/ui/src/mosaic/hooks/useSpinDelay.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; export interface SpinDelayOptions { - /** Wait this long before showing the value, so quick actions never flash a spinner. */ + /** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */ delay?: number; /** Once shown, keep the value up at least this long, so the spinner never flickers off. */ minDuration?: number; @@ -25,11 +25,17 @@ export function useSpinDelay(value: T | null, options: SpinDelayOptions = {}) const shownAt = useRef(0); useEffect(() => { - // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. + // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no + // delay there is nothing to outlast, so it surfaces in this pass rather than a timer's. if (shown === null) { if (value === null) { return; } + if (delay <= 0) { + shownAt.current = Date.now(); + setShown(value); + return; + } const timer = setTimeout(() => { shownAt.current = Date.now(); setShown(value); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx index 1d728816eb5..fe94b7bf2dc 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -1,763 +1,285 @@ -import type * as SharedReact from '@clerk/shared/react'; -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; -import type { UserButtonControllerOptions } from '../user-button.controller'; +import { deferred, tick } from '../../machines/__tests__/test-utils'; +import type { UserButtonControllerOptions, UserButtonReadyModel } from '../user-button.controller'; import { useUserButtonController } from '../user-button.controller'; +import type { UserButtonModel } from '../user-button.model'; -interface FakeUser { - id: string; - firstName: string | null; - lastName: string | null; - username: string | null; - primaryEmailAddress: { emailAddress: string } | null; - primaryPhoneNumber?: { phoneNumber: string } | null; - primaryWeb3Wallet?: { web3Wallet: string } | null; - imageUrl: string; - organizationMemberships: unknown[]; - createOrganizationEnabled: boolean; -} - -interface FakeSession { - id: string; - user: FakeUser; -} - -interface FakeList { - data: unknown[]; - count: number; - hasNextPage: boolean; - isLoading: boolean; - revalidate: ReturnType; -} - -let isUserLoaded: boolean; -let isSessionLoaded: boolean; -let isOrgLoaded: boolean; -let user: FakeUser | null; -let session: { id: string; checkAuthorization: ReturnType } | null; -let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; -let userMemberships: FakeList; -let userInvitations: FakeList; -let userSuggestions: FakeList; -let signedInSessions: FakeSession[]; -let pagingRef: (element: HTMLElement | null) => void; -let singleSessionMode: boolean; -let branded: boolean; -let forceOrganizationSelection: boolean; -let organizationsEnabled: boolean; -// False stands for the window before clerk-js has hydrated it, which the controller has to sit out. -let environmentHydrated: boolean; - -// Built per read rather than once, so a test setting any of the flags above is answered by it. -function environment() { - return environmentHydrated - ? { - displayConfig: { afterSwitchSessionUrl: '/after-switch', branded }, - authConfig: { singleSessionMode }, - organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - } - : null; -} - -let setActive: ReturnType; -let signOut: ReturnType; -let navigate: ReturnType; -let openUserProfile: ReturnType; -let openOrganizationProfile: ReturnType; -let openCreateOrganization: ReturnType; -let openInviteMembers: ReturnType; -let checkAuthorization: ReturnType; -let getContainer: () => HTMLElement | null; - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ isLoaded: isSessionLoaded, session }), - useOrganization: () => ({ isLoaded: isOrgLoaded, organization }), - // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather - // than that some function did. - usePortalRoot: () => getContainer, - useClerk: () => ({ - navigate, - setActive, - signOut, - openUserProfile, - openOrganizationProfile, - openCreateOrganization, - openInviteMembers, - buildUserProfileUrl: () => '/user-profile', - buildOrganizationProfileUrl: () => '/org-profile', - buildCreateOrganizationUrl: () => '/create-org', - buildSignInUrl: () => '/sign-in', - buildAfterSignOutUrl: () => '/after-sign-out', - buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out', - client: { signedInSessions }, - __internal_environment: environment(), - }), - }; -}); - -// The controller reads its three paginated lists through the shared in-view helper, so the fetch -// boundary is stubbed there rather than at `useOrganizationList`. -vi.mock('../../../hooks/useOrganizationListInView', () => ({ - useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), -})); - -function acceptable( - id: string, - orgId: string, - orgName: string, - status: 'pending' | 'accepted' | 'revoked' | 'expired' = 'pending', -) { +function ready(overrides: Partial = {}): UserButtonReadyModel { return { - id, - status, - accept: vi.fn().mockResolvedValue(undefined), - publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' }, + status: 'ready', + organizationsEnabled: true, + renderBranding: true, + activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }, + activeOrganization: null, + hasOrganizations: false, + hidePersonal: false, + organizationsLoading: false, + memberships: [], + suggestions: [], + invitations: [], + additionalSessions: [], + ...overrides, }; } -function membership(orgId: string, name: string, membersCount: number) { - return { organization: { id: orgId, name, imageUrl: '', membersCount } }; -} - -function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { - return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) }; -} - -beforeEach(() => { - isUserLoaded = true; - isSessionLoaded = true; - isOrgLoaded = true; - user = { - id: 'user_1', - firstName: 'Alice', - lastName: 'Smith', - username: 'alice', - primaryEmailAddress: { emailAddress: 'alice@example.com' }, - imageUrl: 'https://img/alice', - organizationMemberships: [], - createOrganizationEnabled: true, - }; - session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; - organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 }; - userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); - userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); - userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); - pagingRef = vi.fn(); - singleSessionMode = false; - branded = true; - forceOrganizationSelection = false; - organizationsEnabled = true; - environmentHydrated = true; - signedInSessions = [ - { id: 'sess_1', user: user }, - { - id: 'sess_2', - user: { - id: 'user_2', - firstName: 'Bob', - lastName: 'Jones', - username: null, - primaryEmailAddress: { emailAddress: 'bob@example.com' }, - imageUrl: 'https://img/bob', - organizationMemberships: [], - createOrganizationEnabled: true, - }, - }, - ]; - setActive = vi.fn().mockResolvedValue(undefined); - signOut = vi.fn().mockResolvedValue(undefined); - navigate = vi.fn().mockResolvedValue(undefined); - openUserProfile = vi.fn(); - openOrganizationProfile = vi.fn(); - openCreateOrganization = vi.fn(); - openInviteMembers = vi.fn(); - getContainer = () => null; -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -function Harness(options: UserButtonControllerOptions = {}) { - const c = useUserButtonController(options); +function Harness({ model, ...options }: { model: UserButtonModel } & UserButtonControllerOptions) { + const c = useUserButtonController(model, options); if (c.status !== 'ready') { return {c.status}; } return (
{c.status} - {c.activeSession.name} - {c.activeSession.identifier} - {c.activeSession.sessionId} - {JSON.stringify(c.activeOrganization)} - {String(c.hasOrganizations)} - {String(c.organizationsEnabled)} - {String(c.renderBranding)} - {String(c.hidePersonal)} - {String(c.organizationsLoading)} - {c.additionalSessions.map(a => a.sessionId).join(',')} - {String(c.paging?.hasMore)} - {String(c.paging?.ref === pagingRef)} - {String(Boolean(c.onInviteMembers))} - {String(Boolean(c.onSignOutAll))} - {String(Boolean(c.onAddAccount))} - {String(Boolean(c.onCreateOrganization))} - {JSON.stringify(c.memberships)} - {JSON.stringify(c.suggestions)} - {JSON.stringify(c.invitations)} - + {c.mode ?? 'combined'} + {String(c.open)} + {c.pendingKey ?? ''} + {c.activeOrganization?.organizationId ?? ''} - - - - - - + {c.customMenuItems?.map(item => + item.href === undefined ? ( + + ) : ( + + {item.label} + + ), + )}
); } -function memberships() { - return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]'); -} - -function invitations() { - return JSON.parse(screen.getByTestId('invitations').textContent ?? '[]'); -} - -function activeOrganization() { - return JSON.parse(screen.getByTestId('active-org').textContent ?? 'null'); -} - describe('useUserButtonController', () => { - it('is loading until the user, session, and organization are all loaded', () => { - isUserLoaded = false; - const { rerender } = render(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - isUserLoaded = true; - isSessionLoaded = false; - rerender(); + it('passes loading and hidden through until the model is ready', () => { + const { rerender } = render(); expect(screen.getByTestId('status')).toHaveTextContent('loading'); - isSessionLoaded = true; - isOrgLoaded = false; - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - }); - - // Every instance-level answer the surface needs — organizations, single-session, forced - // selection — comes off the environment, and it hydrates on its own schedule. Reporting ready - // without it would mean guessing at all three and rearranging once it lands. - it('is loading until the environment has hydrated', () => { - environmentHydrated = false; - const { rerender } = render(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - environmentHydrated = true; - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('ready'); - }); - - it('reports whether the instance has organizations at all', () => { - render(); - expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('true'); - - cleanup(); - organizationsEnabled = false; - render(); - expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('false'); - }); - - it('is hidden when loaded but there is no active user', () => { - user = null; - render(); + rerender(); expect(screen.getByTestId('status')).toHaveTextContent('hidden'); - }); - it('maps the active account and prefers first+last > username > email for the name', () => { - const { rerender } = render(); + rerender(); expect(screen.getByTestId('status')).toHaveTextContent('ready'); - expect(screen.getByTestId('active-name')).toHaveTextContent('Alice Smith'); - expect(screen.getByTestId('active-session')).toHaveTextContent('sess_1'); - - user = { ...(user as FakeUser), firstName: null, lastName: null }; - rerender(); - expect(screen.getByTestId('active-name')).toHaveTextContent('alice'); - - user = { ...user, username: null }; - rerender(); - expect(screen.getByTestId('active-name')).toHaveTextContent('alice@example.com'); }); - it('identifies the active account by username, then email, then phone, then wallet', () => { - const { rerender } = render(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice'); - - user = { ...(user as FakeUser), username: null }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice@example.com'); - - user = { ...user, primaryEmailAddress: null, primaryPhoneNumber: { phoneNumber: '+15550100' } }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('+15550100'); + it('forces user mode when organizations are disabled, whatever mode was asked for', () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('user'); - user = { ...user, primaryPhoneNumber: null, primaryWeb3Wallet: { web3Wallet: '0xabc' } }; - rerender(); - expect(screen.getByTestId('active-identifier')).toHaveTextContent('0xabc'); + rerender( + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('organization'); }); - it('describes the active organization whole, and null in personal mode', () => { - const { rerender } = render(); - expect(activeOrganization()).toMatchObject({ - kind: 'membership', - organizationId: 'org_1', - name: 'Acme', - imageUrl: 'https://img/acme', - membersCount: 3, - }); + it('runs a model action through the machine and keys the affordance', async () => { + const onSelectOrganization = vi.fn(() => Promise.resolve()); + render(); - organization = null; - rerender(); - expect(activeOrganization()).toBeNull(); - }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('select-org')); - it('names the active organization from the organization itself, not the membership list', () => { - userMemberships = list([], 0, false, true); - render(); + expect(onSelectOrganization).toHaveBeenCalledWith('org_1'); + await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1')); - expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme' }); + await act(async () => { + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('false'); + expect(screen.getByTestId('pending')).toHaveTextContent(''); }); - it('reports the organization list as loading until every one of its three parts has landed', () => { - const { rerender } = render(); - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('false'); + it('closes immediately on a hand-off and leaves the model action to run', () => { + const onManageAccount = vi.fn(); + render(); - userSuggestions = list([], 0, false, true); - rerender(); - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); - }); - - it('derives hasOrganizations from the membership count, not the array length', () => { - userMemberships = list([membership('org_1', 'Acme', 3)], 0); - const { rerender } = render(); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('false'); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('manage-account')); - userMemberships = list([], 5); - rerender(); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); + expect(onManageAccount).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - // Waiting on the list would open a workspace section under every personal-only account, then - // take it away again. - it('answers hasOrganizations from the user resource before any list has loaded', () => { - userMemberships = list([], 0, false, true); - user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] }; - render(); + it('closes the popover before a custom menu action runs', () => { + const onClick = vi.fn(); + render( + , + ); - expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); - expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); - }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('Documentation')); - it('carries only sessions in additionalSessions, excluding the active one', () => { - render(); - expect(screen.getByTestId('additional')).toHaveTextContent('sess_2'); - expect(screen.getByTestId('additional')).not.toHaveTextContent('sess_1'); + expect(onClick).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - it('maps membership, suggestion, and invitation rows with the correct kind discriminants', () => { - render(); - - const rows = memberships(); - expect(rows[0]).toMatchObject({ kind: 'membership', organizationId: 'org_1', name: 'Acme', membersCount: 3 }); + it('starts closed and opens and closes', () => { + render(); + expect(screen.getByTestId('open')).toHaveTextContent('false'); - const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); - expect(suggestions[0]).toMatchObject({ - kind: 'suggestion', - id: 'sug_1', - organizationId: 'org_2', - name: 'Beta', - status: 'pending', - }); - - expect(invitations()[0]).toMatchObject({ - kind: 'invitation', - id: 'inv_1', - organizationId: 'org_3', - organizationName: 'Gamma', - status: 'pending', - }); - }); - - it('lists invitations still open to the account, dropping the revoked and expired ones', () => { - userInvitations = list( - [ - acceptable('inv_1', 'org_3', 'Gamma'), - acceptable('inv_2', 'org_4', 'Delta', 'accepted'), - acceptable('inv_3', 'org_5', 'Epsilon', 'revoked'), - acceptable('inv_4', 'org_6', 'Zeta', 'expired'), - ], - 4, - ); - render(); + fireEvent.click(screen.getByText('open')); + expect(screen.getByTestId('open')).toHaveTextContent('true'); - expect(invitations().map((i: { id: string }) => i.id)).toEqual(['inv_1', 'inv_2']); + fireEvent.click(screen.getByText('close')); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - it('reports more to page in when any of the three lists has a next page', () => { - const { rerender } = render(); - expect(screen.getByTestId('has-more')).toHaveTextContent('false'); - expect(screen.getByTestId('paging-ref')).toHaveTextContent('true'); + it('holds the popup open when an action fails, even one that would have closed it', async () => { + const onSelectOrganization = vi.fn(() => Promise.reject(new Error('cannot switch'))); + render(); - userSuggestions = list([], 0, true); - rerender(); - expect(screen.getByTestId('has-more')).toHaveTextContent('true'); - }); - - it('offers inviting members only with the manage-memberships permission', () => { - const { rerender } = render(); - expect(screen.getByTestId('can-invite')).toHaveTextContent('true'); - expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_memberships:manage' }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('select-org')); - checkAuthorization.mockReturnValue(false); - rerender(); - expect(screen.getByTestId('can-invite')).toHaveTextContent('false'); + await act(async () => { + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('true'); + expect(screen.getByTestId('pending')).toHaveTextContent(''); }); - it('selects an organization via setActive, with no redirect unless one is configured', () => { - const { rerender } = render(); + it('lets the row be clicked again after a failure', async () => { + const onSelectOrganization = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined); + render(); + fireEvent.click(screen.getByText('open')); fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); - - rerender(); - fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/orgs/org_9' }); + await act(async () => { + await tick(); + }); - rerender( `/o/${org.name}`} />); fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/Other' }); - }); - - // `null` is Clerk's own name for the personal workspace, and there is no organization for - // `afterSelectOrganizationUrl` to resolve against. - it('selects the personal workspace by clearing the active organization', () => { - render(); + expect(onSelectOrganization).toHaveBeenCalledTimes(2); - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined }); - }); - - it('redirects the personal workspace to the configured afterSelectPersonalUrl', () => { - const { rerender } = render(); - - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/user_1' }); - - rerender( `/u/${u.username}`} />); - fireEvent.click(screen.getByText('select-personal')); - expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/alice' }); + await act(async () => { + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - // The two are configured apart, so routing the personal workspace leaves the organizations alone. - it('keeps the personal redirect off the organizations', () => { - render(); + it('refuses a second action while one is in flight', async () => { + const pending = deferred(); + const onSelectOrganization = vi.fn(() => pending.promise); + const onSwitchSession = vi.fn(() => Promise.resolve()); + render(); + fireEvent.click(screen.getByText('open')); fireEvent.click(screen.getByText('select-org')); - expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); - }); - - // An instance that requires an organization has no personal workspace: clerk-js refuses - // `setActive({ organization: null })` outright there, so offering the switch would offer nothing. - it('reports no personal workspace where the instance forces an organization', () => { - const { rerender } = render(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('false'); - - forceOrganizationSelection = true; - rerender(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - }); - - // An app whose organizations are the whole product withholds it itself. The instance setting is - // the other way in, and neither one can be talked out of it by the other. - it('lets the app withhold the personal workspace on an instance that allows one', () => { - const { rerender } = render(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - - forceOrganizationSelection = true; - rerender(); - expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); - }); - - it('switches sessions and routes each sign out to the URL that matches what is left', () => { - const { rerender } = render(); + await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1')); fireEvent.click(screen.getByText('switch')); - expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' })); - - // Another account stays signed in, so this is a single sign out, not a full one. - fireEvent.click(screen.getByText('sign-out-one')); - expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-single-sign-out' }); - - fireEvent.click(screen.getByText('sign-out-all')); - expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); - - signedInSessions = signedInSessions.slice(0, 1); - rerender(); - fireEvent.click(screen.getByText('sign-out-one')); - expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-sign-out' }); - }); - - // The session switched to can land on a task of its own. A plain `redirectUrl` routes past it and - // strands the account, so the switch hands `setActive` a callback that answers both cases. - it('routes a switched session to its pending task, and to the after-switch URL when it has none', async () => { - render(); - fireEvent.click(screen.getByText('switch')); - - expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); - const navigateOnSetActive = setActive.mock.calls[0][0].navigate; - const decorateUrl = vi.fn((url: string) => url); + expect(onSwitchSession).not.toHaveBeenCalled(); await act(async () => { - await navigateOnSetActive({ session: { currentTask: { key: 'choose-organization' } }, decorateUrl }); + pending.resolve(undefined); + await tick(); }); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/sign-in')); - expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization')); - - await act(async () => { - await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); - }); - expect(navigate).toHaveBeenCalledWith('/after-switch'); - // `redirectUrl` was decorated for us; taking the callback takes the Safari ITP refresh with it. - expect(decorateUrl).toHaveBeenCalledWith('/after-switch'); - }); - - // An instance can restrict who may open an organization, and a user at their creation limit is - // restricted the same way. Offering the action anyway lands them on a page that turns them away. - it('drops create-organization for a user who cannot open one', () => { - const { rerender } = render(); - expect(screen.getByTestId('can-create-org')).toHaveTextContent('true'); - - user = { ...(user as FakeUser), createOrganizationEnabled: false }; - rerender(); - expect(screen.getByTestId('can-create-org')).toHaveTextContent('false'); - }); - - it('drops sign-out-all and add-account in single-session mode', () => { - singleSessionMode = true; - render(); - expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false'); - expect(screen.getByTestId('can-add-account')).toHaveTextContent('false'); }); - // An instance that has paid the branding off carries none of it, and the environment is the only - // place that answer lives. - it('carries the branding the instance is on, not the branding everyone gets', () => { - render(); - expect(screen.getByTestId('branded')).toHaveTextContent('true'); + it('refuses an action while the popup is closed', () => { + const onSelectOrganization = vi.fn(() => Promise.resolve()); + render(); - cleanup(); - branded = false; - render(); - expect(screen.getByTestId('branded')).toHaveTextContent('false'); - }); - - // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic - // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays. - it('opens the profile modals for manage-account and manage-org', () => { - render(); - - fireEvent.click(screen.getByText('manage-account')); - expect(openUserProfile).toHaveBeenCalled(); - - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalled(); + fireEvent.click(screen.getByText('select-org')); - expect(navigate).not.toHaveBeenCalled(); + expect(onSelectOrganization).not.toHaveBeenCalled(); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - // An app that mounts the button inside its own dialog or popover puts a portal root around it, and - // the modal has to land there too or it renders behind the surface that opened it. - it('opens the profile modals into the portal root the app configured', () => { - render(); + it('abandons an action dismissed mid-flight rather than reopening on its result', async () => { + const pending = deferred(); + const onSwitchSession = vi.fn(() => pending.promise); + render(); - fireEvent.click(screen.getByText('manage-account')); - expect(openUserProfile).toHaveBeenCalledWith({ getContainer }); - - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer }); - }); - - // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass - // alongside it. The two are resolved apart, so routing one profile leaves the other a modal. - it('navigates to a profile URL when one is given, and only for that profile', () => { - render(); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('switch')); + await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('switch:sess_2')); - fireEvent.click(screen.getByText('manage-account')); - expect(navigate).toHaveBeenCalledWith('/account'); - expect(openUserProfile).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('close')); + expect(screen.getByTestId('open')).toHaveTextContent('false'); - fireEvent.click(screen.getByText('manage-org')); - expect(openOrganizationProfile).toHaveBeenCalled(); + await act(async () => { + pending.resolve(undefined); + await tick(); + }); + expect(screen.getByTestId('open')).toHaveTextContent('false'); }); - it('navigates to an organization profile URL when one is given', () => { - render(); - - fireEvent.click(screen.getByText('manage-org')); + it('holds the surface on the model the action started from until it settles', async () => { + const pending = deferred(); + const onSwitchSession = vi.fn(() => pending.promise); + const { rerender } = render(); - expect(navigate).toHaveBeenCalledWith('/settings'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); - }); + fireEvent.click(screen.getByText('open')); + fireEvent.click(screen.getByText('switch')); - // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept, - // so passing both has to resolve the same as passing the URL alone. - it('accepts an explicit navigation mode alongside a URL', () => { - render( + rerender( , ); - fireEvent.click(screen.getByText('manage-org')); - - expect(navigate).toHaveBeenCalledWith('/settings'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); - }); - - // Invite opens its own modal rather than following manage-org: there is no invite page to route - // to, so an app that routes organization management to its own page still gets the form here. - it('opens the invite-members modal into the portal root, whatever manage-org is routed to', () => { - render(); - - fireEvent.click(screen.getByText('invite-members')); - - expect(openInviteMembers).toHaveBeenCalledWith({ getContainer }); - expect(navigate).not.toHaveBeenCalled(); - }); - - // Creating an organization resolves like the two profiles do: a modal unless a URL routes - // instead. Adding an account always leaves, since signing in cannot happen inside the popover. - it('opens the create-organization modal into the portal root, and navigates for add-account', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - expect(openCreateOrganization).toHaveBeenCalledWith({ getContainer }); - expect(navigate).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByText('add-account')); - expect(navigate).toHaveBeenCalledWith('/sign-in'); - }); - - it('navigates to a create-organization URL when one is given', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); - - expect(navigate).toHaveBeenCalledWith('/new-org'); - expect(openCreateOrganization).not.toHaveBeenCalled(); - }); - - // Without a URL there is nothing to navigate to but Clerk's own page, which is what an explicit - // `navigation` asks for. - it('falls back to the clerk create-organization URL for an explicit navigation mode', () => { - render(); - - fireEvent.click(screen.getByText('create-org')); + expect(screen.getByTestId('active-org')).toHaveTextContent(''); + expect(screen.getByTestId('open')).toHaveTextContent('true'); - expect(navigate).toHaveBeenCalledWith('/create-org'); - expect(openCreateOrganization).not.toHaveBeenCalled(); - }); - - it('accepts invitations and suggestions, then revalidates whatever the accept changed', async () => { - render(); - - // Accepting an invitation joins the organization, so the membership list is stale too. - const invitation = userInvitations.data[0] as ReturnType; await act(async () => { - fireEvent.click(screen.getByText('accept-invitation')); + pending.resolve(undefined); + await tick(); }); - expect(invitation.accept).toHaveBeenCalledTimes(1); - expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); - expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); - // A suggestion only files a request an admin has yet to approve, so nothing has been joined. - const suggestion = userSuggestions.data[0] as ReturnType; - await act(async () => { - fireEvent.click(screen.getByText('accept-suggestion')); - }); - expect(suggestion.accept).toHaveBeenCalledTimes(1); - expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); - expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('active-org')).toHaveTextContent('org_1'); + expect(screen.getByTestId('open')).toHaveTextContent('true'); }); }); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx new file mode 100644 index 00000000000..0e38e876e03 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -0,0 +1,592 @@ +import type * as SharedReact from '@clerk/shared/react'; +import type { CustomPage } from '@clerk/shared/types'; +import { act as reactAct, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserButtonProps } from '../user-button'; +import { UserButton } from '../user-button'; + +// End-to-end wiring test for the connected UserButton: it renders the real view through the real +// model and controller against a mocked Clerk, then drives the real popover DOM. Unlike the model +// test (model -> Clerk), this proves the layers compose, including what closes the popover: +// selecting a workspace closes on success in the machine, and anything that opens a modal or +// navigates closes before it hands off. + +interface FakeUser { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + primaryEmailAddress: { emailAddress: string } | null; + imageUrl: string; + organizationMemberships: unknown[]; + createOrganizationEnabled: boolean; +} + +interface FakeSession { + id: string; + user: FakeUser; +} + +interface FakeList { + data: unknown[]; + count: number; + hasNextPage: boolean; + isLoading: boolean; + revalidate: ReturnType; +} + +let isUserLoaded: boolean; +let isSessionLoaded: boolean; +let isOrgLoaded: boolean; +let user: FakeUser | null; +let session: { id: string; checkAuthorization: ReturnType } | null; +let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; +let userMemberships: FakeList; +let userInvitations: FakeList; +let userSuggestions: FakeList; +let signedInSessions: FakeSession[]; +let pagingRef: ReturnType; +let singleSessionMode: boolean; +let organizationsEnabled: boolean; + +let setActive: ReturnType; +let signOut: ReturnType; +let navigate: ReturnType; +let openUserProfile: ReturnType; +let openOrganizationProfile: ReturnType; +let openCreateOrganization: ReturnType; +let openInviteMembers: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + useOrganization: () => ({ isLoaded: isOrgLoaded, organization }), + useClerk: () => ({ + navigate, + setActive, + signOut, + openUserProfile, + openOrganizationProfile, + openCreateOrganization, + openInviteMembers, + buildUserProfileUrl: () => '/user-profile', + buildOrganizationProfileUrl: () => '/org-profile', + buildCreateOrganizationUrl: () => '/create-org', + buildSignInUrl: () => '/sign-in', + buildAfterSignOutUrl: () => '/after-sign-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out', + client: { signedInSessions }, + __internal_environment: { + displayConfig: { afterSwitchSessionUrl: '/after-switch' }, + authConfig: { singleSessionMode }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection: false }, + commerceSettings: { billing: { user: { enabled: false } } }, + apiKeysSettings: { user_api_keys_enabled: false }, + }, + }), + }; +}); + +// Stubbed at the same seam as the controller test: the in-view helper is the controller's whole +// fetch boundary, so `ref` doubles as the assertion that the paging sentinel mounted. +vi.mock('../../../hooks/useOrganizationListInView', () => ({ + useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), +})); + +function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = 'pending') { + return { + id, + status, + accept: vi.fn().mockResolvedValue(undefined), + publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' }, + }; +} + +function membership(orgId: string, name: string, membersCount: number) { + return { organization: { id: orgId, name, imageUrl: '', membersCount } }; +} + +function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { + return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) }; +} + +/** A promise whose settling is controlled by the test, to hold an async action in flight. */ +function createDeferred() { + let resolve: () => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + isUserLoaded = true; + isSessionLoaded = true; + isOrgLoaded = true; + user = { + id: 'user_1', + firstName: 'Alice', + lastName: 'Smith', + username: 'alice', + primaryEmailAddress: { emailAddress: 'alice@example.com' }, + imageUrl: 'https://img/alice', + organizationMemberships: [{ id: 'orgmem_1' }], + createOrganizationEnabled: true, + }; + session = { id: 'sess_1', checkAuthorization: vi.fn().mockReturnValue(true) }; + organization = { id: 'org_1', name: 'Acme', imageUrl: '', membersCount: 3 }; + userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); + userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); + userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); + pagingRef = vi.fn(); + singleSessionMode = false; + organizationsEnabled = true; + signedInSessions = [ + { id: 'sess_1', user }, + { + id: 'sess_2', + user: { + id: 'user_2', + firstName: 'Bob', + lastName: 'Jones', + username: null, + primaryEmailAddress: { emailAddress: 'bob@example.com' }, + imageUrl: 'https://img/bob', + organizationMemberships: [], + createOrganizationEnabled: true, + }, + }, + ]; + setActive = vi.fn().mockResolvedValue(undefined); + signOut = vi.fn().mockResolvedValue(undefined); + navigate = vi.fn().mockResolvedValue(undefined); + openUserProfile = vi.fn(); + openOrganizationProfile = vi.fn(); + openCreateOrganization = vi.fn(); + openInviteMembers = vi.fn(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function renderUserButton(props: UserButtonProps = {}) { + return render( + + {/* The button portals its popup out, so this host holds only what it renders in place. */} +
+ +
+
, + ); +} + +const host = () => screen.getByTestId('host'); +const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); +const popup = () => screen.queryByRole('dialog', { name: 'Account' }); +const spinner = () => popup()?.querySelector('.cl-spinner') ?? null; + +async function open() { + const act = userEvent.setup(); + await act.click(trigger()); + expect(popup()).toBeInTheDocument(); + return act; +} + +// Alice has a username, so that is what identifies her row; Bob has none and falls back to email. +const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice' }); + +/** Opens the `⋯` on the active account's row and clicks one of its actions. */ +async function accountAction(act: ReturnType, label: string) { + await act.click(accountMenu()); + await act.click(await screen.findByRole('menuitem', { name: label })); +} + +describe('UserButton (connected)', () => { + // Nothing stands in for the button before Clerk answers, in any mode: until it does, a signed-out + // visitor is indistinguishable from a session still resolving, so a placeholder here would be + // promising a button to people who never get one. + describe.each(['combined', 'organization', 'user'] as const)('in %s mode', mode => { + it('renders nothing while Clerk is still loading', () => { + isUserLoaded = false; + renderUserButton({ mode }); + expect(host()).toBeEmptyDOMElement(); + }); + + it('renders nothing when nobody is signed in', () => { + user = null; + renderUserButton({ mode }); + expect(host()).toBeEmptyDOMElement(); + }); + + // Organizations off at the instance is the same answer whatever mode asked for: the button is + // the account's. An org-only surface would otherwise render its own empty shell, since the + // clerk-js mount boundary that withholds `` never runs for this one. + it('leaves organizations out entirely when the instance has them disabled', async () => { + organizationsEnabled = false; + renderUserButton({ mode }); + + // The account heads the surface, rather than the organization that is active regardless. + expect(screen.getByRole('button', { name: 'Open account menu for Alice Smith' })).toBeInTheDocument(); + await open(); + + for (const name of ['Acme', 'Other', 'Beta', 'Gamma', 'Personal account']) { + expect(screen.queryByText(name)).toBeNull(); + } + expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + + // Everything the account itself carries is still on offer. + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument(); + }); + }); + + it('renders the trigger and keeps the popover closed until clicked', () => { + renderUserButton(); + expect(trigger()).toBeInTheDocument(); + expect(popup()).toBeNull(); + }); + + it('opens the popover on trigger click', async () => { + renderUserButton(); + await open(); + + expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument(); + expect(accountMenu()).toBeInTheDocument(); + }); + + it('selecting an organization calls setActive without a redirect by default and closes the popover', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('leaving the active organization for the personal workspace clears it', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Personal account' })); + + expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined }); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('drops the personal workspace where the app hides it, leaving the organizations', async () => { + renderUserButton({ hidePersonal: true }); + await open(); + + expect(screen.queryByText('Personal account')).toBeNull(); + expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument(); + }); + + // `mode` is the view's own prop; this only proves the connected component hands it down, since + // the account-only surface is otherwise indistinguishable from an account with no organizations. + it('forwards mode to the view, so an account-only surface lists no organizations', async () => { + renderUserButton({ mode: 'user' }); + await open(); + + expect(screen.queryByRole('button', { name: 'Other' })).toBeNull(); + expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument(); + }); + + it('switching to another account calls setActive with the session and stays open', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'bob@example.com' })); + + expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('signing out of the active account calls signOut with its session id', async () => { + renderUserButton(); + const act = await open(); + + await accountAction(act, 'Sign out'); + + // Another account stays signed in, so this is a single sign out, not a full one. + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1', redirectUrl: '/after-single-sign-out' }); + }); + + it('signing out of all accounts calls signOut with the after-sign-out url', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' })); + + expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); + }); + + it('accepting an invitation accepts it, revalidates, and stays open', async () => { + renderUserButton(); + const act = await open(); + const invitation = userInvitations.data[0] as ReturnType; + + await act.click(screen.getByRole('button', { name: 'Accept' })); + + await waitFor(() => expect(invitation.accept).toHaveBeenCalledTimes(1)); + expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('accepting a suggestion accepts it, revalidates, and stays open', async () => { + renderUserButton(); + const act = await open(); + const suggestion = userSuggestions.data[0] as ReturnType; + + await act.click(screen.getByRole('button', { name: 'Join' })); + + await waitFor(() => expect(suggestion.accept).toHaveBeenCalledTimes(1)); + expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('drops add-account and sign-out-of-all in single-session mode', async () => { + singleSessionMode = true; + signedInSessions = signedInSessions.slice(0, 1); + renderUserButton(); + const act = await open(); + + expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull(); + expect(screen.queryByLabelText('Account actions')).toBeNull(); + await act.click(accountMenu()); + expect(screen.queryByRole('menuitem', { name: 'Add account' })).toBeNull(); + }); + + it('managing the account opens the UserProfile modal and closes the popover', async () => { + renderUserButton(); + const act = await open(); + + await accountAction(act, 'Manage account'); + + expect(openUserProfile).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + // A custom action is the app's to run, and whatever it opens takes over from here, so the popover + // goes with it the way it does for managing an account. + it('running a custom menu item calls back and closes the popover', async () => { + const onClick = vi.fn(); + renderUserButton({ customMenuItems: [{ id: 'terms', label: 'Terms of service', onClick }] }); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Terms of service' })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(popup()).toBeNull()); + }); + + // The whole round trip for a custom page: the prop a consumer writes, through the bridge, out to + // the callbacks clerk-js is handed, and back into the element clerk-js renders for the page. The + // popover has closed by then, so this also covers the portals outliving what opened them. + it('renders a custom page into the element the opened profile hands back', async () => { + renderUserButton({ + userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, + }); + const act = await open(); + + await accountAction(act, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const { customPages } = openUserProfile.mock.calls[0][0]; + expect(customPages).toHaveLength(1); + expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + + // Stands in for clerk-js's `ExternalElementMounter`, which renders this `div` where the page goes. + const el = document.createElement('div'); + document.body.appendChild(el); + reactAct(() => { + customPages[0].mount(el); + }); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + + it('opens the profile with its pages in the order it was given', async () => { + renderUserButton({ + userProfileProps: { + customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], + pageOrder: ['account', 'terms'], + }, + }); + const act = await open(); + + await accountAction(act, 'Manage account'); + await waitFor(() => expect(popup()).toBeNull()); + + const { customPages } = openUserProfile.mock.calls[0][0]; + expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); + }); + + it('inviting members opens the InviteMembers modal and closes the popover', async () => { + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Invite' })); + + expect(openInviteMembers).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('creating an organization opens the modal and closes the popover', async () => { + renderUserButton(); + const act = await open(); + + await accountAction(act, 'Create organization'); + + expect(openCreateOrganization).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('creating an organization navigates instead when a URL routes it', async () => { + renderUserButton({ createOrganizationUrl: '/new-org' }); + const act = await open(); + + await accountAction(act, 'Create organization'); + + expect(navigate).toHaveBeenCalledWith('/new-org'); + expect(openCreateOrganization).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('leaves create-organization out of the account menu for a user who cannot open one', async () => { + user = { ...(user as FakeUser), createOrganizationEnabled: false }; + renderUserButton(); + const act = await open(); + await act.click(accountMenu()); + + expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Create organization' })).toBeNull(); + }); + + it('spins the clicked affordance and stands every other one down while an action is in flight', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + + // Every one of these is a network round trip, so there is nothing to debounce: the click gets + // its spinner in the same pass rather than after a delay window. + expect(spinner()).toBeInTheDocument(); + // A stood-down row stays a button, and `aria-disabled` rather than natively disabled so it + // keeps its place in the tab order. Dropping it to a static row would remount it, and with it + // the avatar it carries. + expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'bob@example.com' })).toHaveAttribute('aria-disabled', 'true'); + expect(popup()).toBeInTheDocument(); + + deferred.resolve(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + // `setActive` swaps the active organization mid-flight. See `frozen` in the machine. + it('holds the surface on the data it started with until the action settles', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + organization = { id: 'org_9', name: 'Other', imageUrl: '', membersCount: 1 }; + + // Any re-render now reads the swapped organization; the surface must not follow it. + await waitFor(() => expect(spinner()).toBeInTheDocument()); + const surface = popup(); + if (!surface) { + throw new Error('expected the popover to be open'); + } + // Still the organization the surface opened on: heading it and listed under it, unclickable. + expect(within(surface).getAllByText('Acme')).toHaveLength(2); + expect(screen.queryByRole('button', { name: 'Acme' })).toBeNull(); + + deferred.resolve(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('spins inside the join button while a suggestion is being joined', async () => { + const deferred = createDeferred(); + const suggestion = userSuggestions.data[0] as ReturnType; + suggestion.accept.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Join' })); + + const join = screen.getByRole('button', { name: 'Join' }); + expect(join).toHaveAttribute('aria-busy', 'true'); + expect(within(join).getByRole('progressbar')).toBeInTheDocument(); + + deferred.resolve(); + await waitFor(() => expect(spinner()).toBeNull()); + expect(popup()).toBeInTheDocument(); + }); + + it('keeps the popover open and clears busy state when an action rejects', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + expect(spinner()).toBeInTheDocument(); + + deferred.reject(new Error('setActive failed')); + + await waitFor(() => expect(spinner()).toBeNull(), { timeout: 2000 }); + expect(popup()).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); + }); + + // The spinner is held up for a minimum so it cannot flicker off. That hold is for a surface still + // on screen, so an action that closes the surface must not carry it: reopening inside the window + // would otherwise find the popup spinning over rows that are all stood down, for nothing. + it('reopens ready to use after an action that closed it', async () => { + const deferred = createDeferred(); + setActive.mockReturnValueOnce(deferred.promise); + renderUserButton(); + const act = await open(); + + await act.click(screen.getByRole('button', { name: 'Other' })); + expect(spinner()).toBeInTheDocument(); + + deferred.resolve(); + await waitFor(() => expect(popup()).toBeNull()); + + await act.click(trigger()); + + expect(spinner()).toBeNull(); + expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled(); + }); + + // The view decides whether to mount the sentinel at all; this is the wiring that carries the + // in-view ref from the paginated lists, through the controller, to it. + it('hands the paging sentinel to the in-view ref when a list has a next page', async () => { + userMemberships = list([membership('org_1', 'Acme', 3)], 1, true); + renderUserButton(); + await open(); + + expect(pagingRef).toHaveBeenCalledWith(expect.any(HTMLElement)); + }); +}); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx new file mode 100644 index 00000000000..c541932e966 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -0,0 +1,805 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { useOrganization } from '@clerk/shared/react'; +import type { CustomPage } from '@clerk/shared/types'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; +import type { UserButtonModelOptions } from '../user-button.model'; +import { useUserButtonModel } from '../user-button.model'; + +interface FakeUser { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + primaryEmailAddress: { emailAddress: string } | null; + primaryPhoneNumber?: { phoneNumber: string } | null; + primaryWeb3Wallet?: { web3Wallet: string } | null; + imageUrl: string; + organizationMemberships: unknown[]; + createOrganizationEnabled: boolean; +} + +interface FakeSession { + id: string; + user: FakeUser; +} + +interface FakeList { + data: unknown[]; + count: number; + hasNextPage: boolean; + isLoading: boolean; + revalidate: ReturnType; +} + +let isUserLoaded: boolean; +let isSessionLoaded: boolean; +let isOrgLoaded: boolean; +let user: FakeUser | null; +let session: { id: string; checkAuthorization: ReturnType } | null; +let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null; +let userMemberships: FakeList; +let userInvitations: FakeList; +let userSuggestions: FakeList; +let signedInSessions: FakeSession[]; +let pagingRef: (element: HTMLElement | null) => void; +let singleSessionMode: boolean; +let branded: boolean; +let forceOrganizationSelection: boolean; +let organizationsEnabled: boolean; +// False stands for the window before clerk-js has hydrated it, which the model has to sit out. +let environmentHydrated: boolean; + +// Built per read rather than once, so a test setting any of the flags above is answered by it. +function environment() { + return environmentHydrated + ? { + displayConfig: { afterSwitchSessionUrl: '/after-switch', branded }, + authConfig: { singleSessionMode }, + organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, + } + : null; +} + +let setActive: ReturnType; +let signOut: ReturnType; +let navigate: ReturnType; +let openUserProfile: ReturnType; +let openOrganizationProfile: ReturnType; +let openCreateOrganization: ReturnType; +let openInviteMembers: ReturnType; +let checkAuthorization: ReturnType; +let getContainer: () => HTMLElement | null; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization })), + // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather + // than that some function did. + usePortalRoot: () => getContainer, + useClerk: () => ({ + navigate, + setActive, + signOut, + openUserProfile, + openOrganizationProfile, + openCreateOrganization, + openInviteMembers, + buildUserProfileUrl: () => '/user-profile', + buildOrganizationProfileUrl: () => '/org-profile', + buildCreateOrganizationUrl: () => '/create-org', + buildSignInUrl: () => '/sign-in', + buildAfterSignOutUrl: () => '/after-sign-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out', + client: { signedInSessions }, + __internal_environment: environment(), + }), + }; +}); + +// The model reads its three paginated lists through the shared in-view helper, so the fetch +// boundary is stubbed there rather than at `useOrganizationList`. +vi.mock('../../../hooks/useOrganizationListInView', () => ({ + useOrganizationListInView: vi.fn(() => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef })), +})); + +function acceptable( + id: string, + orgId: string, + orgName: string, + status: 'pending' | 'accepted' | 'revoked' | 'expired' = 'pending', +) { + return { + id, + status, + accept: vi.fn().mockResolvedValue(undefined), + publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' }, + }; +} + +function membership(orgId: string, name: string, membersCount: number) { + return { organization: { id: orgId, name, imageUrl: '', membersCount } }; +} + +function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList { + return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) }; +} + +beforeEach(() => { + isUserLoaded = true; + isSessionLoaded = true; + isOrgLoaded = true; + user = { + id: 'user_1', + firstName: 'Alice', + lastName: 'Smith', + username: 'alice', + primaryEmailAddress: { emailAddress: 'alice@example.com' }, + imageUrl: 'https://img/alice', + organizationMemberships: [], + createOrganizationEnabled: true, + }; + session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; + organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 }; + userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2); + userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1); + userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1); + pagingRef = vi.fn(); + singleSessionMode = false; + branded = true; + forceOrganizationSelection = false; + organizationsEnabled = true; + environmentHydrated = true; + signedInSessions = [ + { id: 'sess_1', user: user }, + { + id: 'sess_2', + user: { + id: 'user_2', + firstName: 'Bob', + lastName: 'Jones', + username: null, + primaryEmailAddress: { emailAddress: 'bob@example.com' }, + imageUrl: 'https://img/bob', + organizationMemberships: [], + createOrganizationEnabled: true, + }, + }, + ]; + setActive = vi.fn().mockResolvedValue(undefined); + signOut = vi.fn().mockResolvedValue(undefined); + navigate = vi.fn().mockResolvedValue(undefined); + openUserProfile = vi.fn(); + openOrganizationProfile = vi.fn(); + openCreateOrganization = vi.fn(); + openInviteMembers = vi.fn(); + getContainer = () => null; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function Harness({ customPages, ...options }: UserButtonModelOptions & { customPages?: CustomPage[] } = {}) { + const c = useUserButtonModel(options, customPages); + if (c.status !== 'ready') { + return {c.status}; + } + return ( +
+ {c.status} + {c.activeSession.name} + {c.activeSession.identifier} + {c.activeSession.sessionId} + {JSON.stringify(c.activeOrganization)} + {String(c.hasOrganizations)} + {String(c.organizationsEnabled)} + {String(c.renderBranding)} + {String(c.hidePersonal)} + {String(c.organizationsLoading)} + {c.additionalSessions.map(a => a.sessionId).join(',')} + {String(c.paging?.hasMore)} + {String(c.paging?.ref === pagingRef)} + {String(Boolean(c.onInviteMembers))} + {String(Boolean(c.onSignOutAll))} + {String(Boolean(c.onAddAccount))} + {String(Boolean(c.onCreateOrganization))} + {JSON.stringify(c.memberships)} + {JSON.stringify(c.suggestions)} + {JSON.stringify(c.invitations)} + + + + + + + + + + + + +
+ ); +} + +function memberships() { + return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]'); +} + +function invitations() { + return JSON.parse(screen.getByTestId('invitations').textContent ?? '[]'); +} + +function activeOrganization() { + return JSON.parse(screen.getByTestId('active-org').textContent ?? 'null'); +} + +describe('useUserButtonModel', () => { + it('is loading until the user, session, and organization are all loaded', () => { + isUserLoaded = false; + const { rerender } = render(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + + isUserLoaded = true; + isSessionLoaded = false; + rerender(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + + isSessionLoaded = true; + isOrgLoaded = false; + rerender(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + }); + + // Every instance-level answer the surface needs — organizations, single-session, forced + // selection — comes off the environment, and it hydrates on its own schedule. Reporting ready + // without it would mean guessing at all three and rearranging once it lands. + it('is loading until the environment has hydrated', () => { + environmentHydrated = false; + const { rerender } = render(); + expect(screen.getByTestId('status')).toHaveTextContent('loading'); + + environmentHydrated = true; + rerender(); + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + }); + + it('reports whether the instance has organizations at all', () => { + render(); + expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('true'); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true }); + + cleanup(); + organizationsEnabled = false; + render(); + expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('false'); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false }); + }); + + it('does not fetch the organization lists until the environment says they are on', () => { + environmentHydrated = false; + const { rerender } = render(); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false }); + + environmentHydrated = true; + rerender(); + expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true }); + }); + + it('does not treat reading the active organization as a request to enable them', () => { + render(); + expect(useOrganization).toHaveBeenCalledWith({ + __internal_skipAttemptToEnableOrganizations: true, + }); + }); + + it('is hidden when loaded but there is no active user', () => { + user = null; + render(); + expect(screen.getByTestId('status')).toHaveTextContent('hidden'); + }); + + it('maps the active account and prefers first+last > username > email for the name', () => { + const { rerender } = render(); + expect(screen.getByTestId('status')).toHaveTextContent('ready'); + expect(screen.getByTestId('active-name')).toHaveTextContent('Alice Smith'); + expect(screen.getByTestId('active-session')).toHaveTextContent('sess_1'); + + user = { ...(user as FakeUser), firstName: null, lastName: null }; + rerender(); + expect(screen.getByTestId('active-name')).toHaveTextContent('alice'); + + user = { ...user, username: null }; + rerender(); + expect(screen.getByTestId('active-name')).toHaveTextContent('alice@example.com'); + }); + + it('identifies the active account by username, then email, then phone, then wallet', () => { + const { rerender } = render(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice'); + + user = { ...(user as FakeUser), username: null }; + rerender(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice@example.com'); + + user = { ...user, primaryEmailAddress: null, primaryPhoneNumber: { phoneNumber: '+15550100' } }; + rerender(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('+15550100'); + + user = { ...user, primaryPhoneNumber: null, primaryWeb3Wallet: { web3Wallet: '0xabc' } }; + rerender(); + expect(screen.getByTestId('active-identifier')).toHaveTextContent('0xabc'); + }); + + it('describes the active organization whole, and null in personal mode', () => { + const { rerender } = render(); + expect(activeOrganization()).toMatchObject({ + kind: 'membership', + organizationId: 'org_1', + name: 'Acme', + imageUrl: 'https://img/acme', + membersCount: 3, + }); + + organization = null; + rerender(); + expect(activeOrganization()).toBeNull(); + }); + + it('names the active organization from the organization itself, not the membership list', () => { + userMemberships = list([], 0, false, true); + render(); + + expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme' }); + }); + + it('reports the organization list as loading until every one of its three parts has landed', () => { + const { rerender } = render(); + expect(screen.getByTestId('orgs-loading')).toHaveTextContent('false'); + + userSuggestions = list([], 0, false, true); + rerender(); + expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); + }); + + it('derives hasOrganizations from the membership count, not the array length', () => { + userMemberships = list([membership('org_1', 'Acme', 3)], 0); + const { rerender } = render(); + expect(screen.getByTestId('has-orgs')).toHaveTextContent('false'); + + userMemberships = list([], 5); + rerender(); + expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); + }); + + // Waiting on the list would open a workspace section under every personal-only account, then + // take it away again. + it('answers hasOrganizations from the user resource before any list has loaded', () => { + userMemberships = list([], 0, false, true); + user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] }; + render(); + + expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true'); + expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); + }); + + it('carries only sessions in additionalSessions, excluding the active one', () => { + render(); + expect(screen.getByTestId('additional')).toHaveTextContent('sess_2'); + expect(screen.getByTestId('additional')).not.toHaveTextContent('sess_1'); + }); + + it('maps membership, suggestion, and invitation rows with the correct kind discriminants', () => { + render(); + + const rows = memberships(); + expect(rows[0]).toMatchObject({ kind: 'membership', organizationId: 'org_1', name: 'Acme', membersCount: 3 }); + + const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]'); + expect(suggestions[0]).toMatchObject({ + kind: 'suggestion', + id: 'sug_1', + organizationId: 'org_2', + name: 'Beta', + status: 'pending', + }); + + expect(invitations()[0]).toMatchObject({ + kind: 'invitation', + id: 'inv_1', + organizationId: 'org_3', + organizationName: 'Gamma', + status: 'pending', + }); + }); + + it('lists invitations still open to the account, dropping the revoked and expired ones', () => { + userInvitations = list( + [ + acceptable('inv_1', 'org_3', 'Gamma'), + acceptable('inv_2', 'org_4', 'Delta', 'accepted'), + acceptable('inv_3', 'org_5', 'Epsilon', 'revoked'), + acceptable('inv_4', 'org_6', 'Zeta', 'expired'), + ], + 4, + ); + render(); + + expect(invitations().map((i: { id: string }) => i.id)).toEqual(['inv_1', 'inv_2']); + }); + + it('reports more to page in when any of the three lists has a next page', () => { + const { rerender } = render(); + expect(screen.getByTestId('has-more')).toHaveTextContent('false'); + expect(screen.getByTestId('paging-ref')).toHaveTextContent('true'); + + userSuggestions = list([], 0, true); + rerender(); + expect(screen.getByTestId('has-more')).toHaveTextContent('true'); + }); + + it('offers inviting members only with the manage-memberships permission', () => { + const { rerender } = render(); + expect(screen.getByTestId('can-invite')).toHaveTextContent('true'); + expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_memberships:manage' }); + + checkAuthorization.mockReturnValue(false); + rerender(); + expect(screen.getByTestId('can-invite')).toHaveTextContent('false'); + }); + + it('selects an organization via setActive, with no redirect unless one is configured', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); + + rerender(); + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/orgs/org_9' }); + + rerender( `/o/${org.name}`} />); + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/Other' }); + }); + + // `null` is Clerk's own name for the personal workspace, and there is no organization for + // `afterSelectOrganizationUrl` to resolve against. + it('selects the personal workspace by clearing the active organization', () => { + render(); + + fireEvent.click(screen.getByText('select-personal')); + expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined }); + }); + + it('redirects the personal workspace to the configured afterSelectPersonalUrl', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByText('select-personal')); + expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/user_1' }); + + rerender( `/u/${u.username}`} />); + fireEvent.click(screen.getByText('select-personal')); + expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/alice' }); + }); + + // The two are configured apart, so routing the personal workspace leaves the organizations alone. + it('keeps the personal redirect off the organizations', () => { + render(); + + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined }); + }); + + // An instance that requires an organization has no personal workspace: clerk-js refuses + // `setActive({ organization: null })` outright there, so offering the switch would offer nothing. + it('reports no personal workspace where the instance forces an organization', () => { + const { rerender } = render(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('false'); + + forceOrganizationSelection = true; + rerender(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); + }); + + // An app whose organizations are the whole product withholds it itself. The instance setting is + // the other way in, and neither one can be talked out of it by the other. + it('lets the app withhold the personal workspace on an instance that allows one', () => { + const { rerender } = render(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); + + forceOrganizationSelection = true; + rerender(); + expect(screen.getByTestId('hide-personal')).toHaveTextContent('true'); + }); + + it('switches sessions and routes each sign out to the URL that matches what is left', () => { + const { rerender } = render(); + + fireEvent.click(screen.getByText('switch')); + expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' })); + + // Another account stays signed in, so this is a single sign out, not a full one. + fireEvent.click(screen.getByText('sign-out-one')); + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-single-sign-out' }); + + fireEvent.click(screen.getByText('sign-out-all')); + expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' }); + + signedInSessions = signedInSessions.slice(0, 1); + rerender(); + fireEvent.click(screen.getByText('sign-out-one')); + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-sign-out' }); + }); + + // The session switched to can land on a task of its own. A plain `redirectUrl` routes past it and + // strands the account, so the switch hands `setActive` a callback that answers both cases. + it('routes a switched session to its pending task, and to the after-switch URL when it has none', async () => { + render(); + fireEvent.click(screen.getByText('switch')); + + expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) }); + const navigateOnSetActive = setActive.mock.calls[0][0].navigate; + const decorateUrl = vi.fn((url: string) => url); + + await act(async () => { + await navigateOnSetActive({ session: { currentTask: { key: 'choose-organization' } }, decorateUrl }); + }); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/sign-in')); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization')); + + await act(async () => { + await navigateOnSetActive({ session: { currentTask: null }, decorateUrl }); + }); + expect(navigate).toHaveBeenCalledWith('/after-switch'); + // `redirectUrl` was decorated for us; taking the callback takes the Safari ITP refresh with it. + expect(decorateUrl).toHaveBeenCalledWith('/after-switch'); + }); + + // An instance can restrict who may open an organization, and a user at their creation limit is + // restricted the same way. Offering the action anyway lands them on a page that turns them away. + it('drops create-organization for a user who cannot open one', () => { + const { rerender } = render(); + expect(screen.getByTestId('can-create-org')).toHaveTextContent('true'); + + user = { ...(user as FakeUser), createOrganizationEnabled: false }; + rerender(); + expect(screen.getByTestId('can-create-org')).toHaveTextContent('false'); + }); + + it('drops sign-out-all and add-account in single-session mode', () => { + singleSessionMode = true; + render(); + expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false'); + expect(screen.getByTestId('can-add-account')).toHaveTextContent('false'); + }); + + // An instance that has paid the branding off carries none of it, and the environment is the only + // place that answer lives. + it('carries the branding the instance is on, not the branding everyone gets', () => { + render(); + expect(screen.getByTestId('branded')).toHaveTextContent('true'); + + cleanup(); + branded = false; + render(); + expect(screen.getByTestId('branded')).toHaveTextContent('false'); + }); + + // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic + // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays. + it('opens the profile modals for manage-account and manage-org', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(openUserProfile).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('manage-org')); + expect(openOrganizationProfile).toHaveBeenCalled(); + + expect(navigate).not.toHaveBeenCalled(); + }); + + // An app that mounts the button inside its own dialog or popover puts a portal root around it, and + // the modal has to land there too or it renders behind the surface that opened it. + it('opens the profile modals into the portal root the app configured', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(openUserProfile).toHaveBeenCalledWith({ getContainer }); + + fireEvent.click(screen.getByText('manage-org')); + expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer }); + }); + + // Custom pages are bridged into this DOM-callback form by the container, since it is the layer + // that can render their portals. All the model owes them is a ride to the modal. + it('hands the profile modal the custom pages it was given', () => { + const customPages = [ + { + label: 'Terms', + url: 'terms', + mount: vi.fn(), + unmount: vi.fn(), + mountIcon: vi.fn(), + unmountIcon: vi.fn(), + }, + ]; + render(); + + fireEvent.click(screen.getByText('manage-account')); + + expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages }); + }); + + // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass + // alongside it. The two are resolved apart, so routing one profile leaves the other a modal. + it('navigates to a profile URL when one is given, and only for that profile', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(navigate).toHaveBeenCalledWith('/account'); + expect(openUserProfile).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('manage-org')); + expect(openOrganizationProfile).toHaveBeenCalled(); + }); + + it('navigates to an organization profile URL when one is given', () => { + render(); + + fireEvent.click(screen.getByText('manage-org')); + + expect(navigate).toHaveBeenCalledWith('/settings'); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept, + // so passing both has to resolve the same as passing the URL alone. + it('accepts an explicit navigation mode alongside a URL', () => { + render( + , + ); + + fireEvent.click(screen.getByText('manage-org')); + + expect(navigate).toHaveBeenCalledWith('/settings'); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + // Invite opens its own modal rather than following manage-org: there is no invite page to route + // to, so an app that routes organization management to its own page still gets the form here. + it('opens the invite-members modal into the portal root, whatever manage-org is routed to', () => { + render(); + + fireEvent.click(screen.getByText('invite-members')); + + expect(openInviteMembers).toHaveBeenCalledWith({ getContainer }); + expect(navigate).not.toHaveBeenCalled(); + }); + + // Creating an organization resolves like the two profiles do: a modal unless a URL routes + // instead. Adding an account always leaves, since signing in cannot happen inside the popover. + it('opens the create-organization modal into the portal root, and navigates for add-account', () => { + render(); + + fireEvent.click(screen.getByText('create-org')); + expect(openCreateOrganization).toHaveBeenCalledWith({ getContainer }); + expect(navigate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText('add-account')); + expect(navigate).toHaveBeenCalledWith('/sign-in'); + }); + + it('navigates to a create-organization URL when one is given', () => { + render(); + + fireEvent.click(screen.getByText('create-org')); + + expect(navigate).toHaveBeenCalledWith('/new-org'); + expect(openCreateOrganization).not.toHaveBeenCalled(); + }); + + // Without a URL there is nothing to navigate to but Clerk's own page, which is what an explicit + // `navigation` asks for. + it('falls back to the clerk create-organization URL for an explicit navigation mode', () => { + render(); + + fireEvent.click(screen.getByText('create-org')); + + expect(navigate).toHaveBeenCalledWith('/create-org'); + expect(openCreateOrganization).not.toHaveBeenCalled(); + }); + + it('accepts invitations and suggestions, then revalidates whatever the accept changed', async () => { + render(); + + // Accepting an invitation joins the organization, so the membership list is stale too. + const invitation = userInvitations.data[0] as ReturnType; + await act(async () => { + fireEvent.click(screen.getByText('accept-invitation')); + }); + expect(invitation.accept).toHaveBeenCalledTimes(1); + expect(userInvitations.revalidate).toHaveBeenCalledTimes(1); + expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); + + // A suggestion only files a request an admin has yet to approve, so nothing has been joined. + const suggestion = userSuggestions.data[0] as ReturnType; + await act(async () => { + fireEvent.click(screen.getByText('accept-suggestion')); + }); + expect(suggestion.accept).toHaveBeenCalledTimes(1); + expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1); + expect(userMemberships.revalidate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx new file mode 100644 index 00000000000..6a7ae686a63 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx @@ -0,0 +1,208 @@ +import type { CustomPage } from '@clerk/shared/types'; +import { act, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { CustomPagesOptions, CustomProfileItem } from '../user-button.pages'; +import { useCustomPages } from '../user-button.pages'; + +// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to +// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the +// tests exercise the same handshake the real modal performs. +function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement { + const el = document.createElement('div'); + document.body.appendChild(el); + act(() => callback?.(el)); + return el; +} + +function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) { + act(() => callback?.(el)); + el.remove(); +} + +let emitted: CustomPage[] | undefined; + +function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) { + const { customPages, portals } = useCustomPages({ items, order, builtInPages }); + emitted = customPages; + return
{portals}
; +} + +const terms: CustomProfileItem = { + label: 'Terms', + path: 'terms', + icon: terms icon, + content:

Terms body

, +}; + +const docs: CustomProfileItem = { + label: 'Docs', + path: 'docs', + href: 'https://clerk.com/docs', + icon: docs icon, +}; + +beforeEach(() => { + emitted = undefined; +}); + +describe('useCustomPages', () => { + it('sends nothing when there are no custom pages', () => { + render(); + + expect(emitted).toBeUndefined(); + expect(screen.getByTestId('host')).toBeEmptyDOMElement(); + }); + + it('sends a page as its path and a link as its href', () => { + render(); + + expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']); + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what + // make an item a page. A link carrying them would be routed to instead of followed. + it('sends content callbacks for a page and none for a link', () => { + render(); + + const [page, link] = emitted ?? []; + expect(page.mount).toBeTypeOf('function'); + expect(page.unmount).toBeTypeOf('function'); + expect(link.mount).toBeUndefined(); + expect(link.unmount).toBeUndefined(); + }); + + // Without them clerk-js drops the page as invalid, so `icon` could not be optional. + it('sends the icon callbacks even for an item with no icon', () => { + render(Terms body

}]} />); + + const [page] = emitted ?? []; + expect(page.mountIcon).toBeTypeOf('function'); + expect(page.unmountIcon).toBeTypeOf('function'); + + const el = mountInto(page.mountIcon); + expect(el).toBeEmptyDOMElement(); + }); + + it('renders page content into the element clerk-js hands back', () => { + render(); + + const el = mountInto(emitted?.[0].mount); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + + it('renders an icon into its own element, apart from the content', () => { + render(); + + const content = mountInto(emitted?.[0].mount); + const icon = mountInto(emitted?.[0].mountIcon); + + expect(within(icon).getByText('terms icon')).toBeInTheDocument(); + expect(within(content).queryByText('terms icon')).toBeNull(); + }); + + it('keeps each page in the element that asked for it', () => { + const help: CustomProfileItem = { label: 'Help', path: 'help', content:

Help body

}; + render(); + + const first = mountInto(emitted?.[0].mount); + const second = mountInto(emitted?.[1].mount); + + expect(within(first).getByText('Terms body')).toBeInTheDocument(); + expect(within(second).getByText('Help body')).toBeInTheDocument(); + }); + + it('stops rendering content once clerk-js gives the element back', () => { + render(); + + const el = mountInto(emitted?.[0].mount); + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + + unmountFrom(emitted?.[0].unmount, el); + + expect(screen.queryByText('Terms body')).toBeNull(); + }); + + // The profile is opened once with the callbacks from that render, and never handed a later set. + // They have to keep working against the current content, or a page re-rendered while the profile + // is open goes stale. + it('renders updated content through the callbacks the profile was opened with', () => { + const { rerender } = render(); + const el = mountInto(emitted?.[0].mount); + + rerender(Revised terms

}]} />); + + expect(within(el).getByText('Revised terms')).toBeInTheDocument(); + }); + + describe('order', () => { + it('leaves the built-in pages alone when no order is given', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + it('sends the pages in the order it was given', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['security', 'Terms', 'account', 'Docs']); + }); + + // Anything more than the id and clerk-js reads it as a custom page. + it('sends a built-in page as its id alone', () => { + render(); + + expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]); + }); + + // Unsent built-ins jump to the front, so one left out of the order would not stay put. + it('sends the pages left out of the order after the ones in it', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']); + }); + + it('drops an id that belongs to no page', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']); + }); + + it('sends a page once even when the order names it twice', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['security', 'account']); + }); + + it('renders a reordered page into the element clerk-js hands back', () => { + render( + , + ); + + const el = mountInto(emitted?.[1].mount); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx index 2583acccf32..58af0503188 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx @@ -6,28 +6,37 @@ import type { UserButtonController } from '../user-button.controller'; let controller: UserButtonController; +vi.mock('../user-button.model', () => ({ + useUserButtonModel: () => ({ status: 'loading' }), +})); + vi.mock('../user-button.controller', () => ({ useUserButtonController: () => controller, })); -// The container's own job is which of the three controller states renders what, so the surface is +// The custom pages outlive the popup, so the wrapper renders their portals in every state. +vi.mock('../user-button.pages', () => ({ + useUserProfilePages: () => [], + useCustomPages: () => ({ + customPages: undefined, + portals: [ + , + ], + }), +})); + +// The wrapper's own job is which of the three controller states renders what, so the surface is // stubbed out and the view's own tests cover it. vi.mock('../user-button.view', () => ({ - userButtonBusyKeys: { - selectOrganization: () => 'select-organization', - switchSession: () => 'switch-session', - signOutSession: () => 'sign-out-session', - signOutAll: () => 'sign-out-all', - acceptSuggestion: () => 'accept-suggestion', - acceptInvitation: () => 'accept-invitation', - }, UserButtonView: () => , })); function ready(): UserButtonController { return { status: 'ready', - organizationsEnabled: true, renderBranding: true, activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' }, activeOrganization: null, @@ -68,8 +77,23 @@ describe('UserButton', () => { expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); }); - it('renders nothing while loading when no fallback is given', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + it('renders no fallback while loading when none is given', () => { + render(); + expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); + }); + + // The profile can be open in clerk-js's own root while the button itself has nothing to render. + it('keeps the custom page portals mounted in every state', () => { + const { rerender } = render(); + expect(screen.getByTestId('portal')).toBeInTheDocument(); + + controller = { status: 'hidden' }; + rerender(); + expect(screen.getByTestId('portal')).toBeInTheDocument(); + + controller = ready(); + rerender(); + expect(screen.getByTestId('portal')).toBeInTheDocument(); }); }); diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx index 024c92525df..947de532aed 100644 --- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -1,288 +1,206 @@ -import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; -import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; -import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; -import type { OrganizationResource, UserResource } from '@clerk/shared/types'; +import { useSpinDelay } from '../hooks/useSpinDelay'; +import { setup } from '../machine/setup'; +import { useMachine } from '../machine/useMachine'; +import type { UserButtonModel } from './user-button.model'; +import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; +import type { UserButtonProps as UserButtonViewProps, UserButtonTriggerProps } from './user-button.view'; +import { userButtonBusyKeys } from './user-button.view'; + +/** The model once Clerk has answered, which is the only shape an action can start from. */ +export type UserButtonReadyModel = Extract; + +interface UserButtonMachineContext { + /** Which action is currently pending. */ + pendingKey: string | null; + /** + * The model the action started from. `setActive` swaps the active organization while its + * promise is still in flight, so the live model would rearrange the popup mid-action. + * The view renders this instead until the action settles. + */ + frozen: UserButtonReadyModel | null; + /** Injected per-action effect — the model callback the clicked row runs. */ + run: () => Promise; + /** Whether succeeding ends the interaction, and the popup with it. */ + closeOnSuccess: boolean; +} -import { populateParamFromObject } from '../../contexts/utils'; -import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; -import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; -import { useMosaicRouter } from '../hooks/useMosaicRouter'; -import type { - UserButtonBrandingProps, - UserButtonCallbacks, - UserButtonData, - UserButtonInvitation, - UserButtonMembership, - UserButtonSession, - UserButtonSuggestion, -} from './user-button.types'; +type UserButtonMachineEvent = + | { type: 'OPEN' } + | { type: 'CLOSE' } + | { + type: 'RUN'; + key: string; + frozen: UserButtonReadyModel; + run: () => Promise; + closeOnSuccess: boolean; + }; + +const { createMachine, assign, fromPromise } = setup(); + +const settled = { pendingKey: null, frozen: null }; + +const userButtonMachine = createMachine({ + id: 'userButton', + initial: 'closed', + context: { + pendingKey: null, + frozen: null, + run: () => Promise.resolve(), + closeOnSuccess: false, + }, + states: { + closed: { + on: { OPEN: 'open' }, + }, + open: { + on: { + CLOSE: 'closed', + RUN: { + target: 'busy', + actions: assign((_, event) => ({ + pendingKey: event.key, + frozen: event.frozen, + run: event.run, + closeOnSuccess: event.closeOnSuccess, + })), + }, + }, + }, + // Reached only from `open`, so a busy popup that is not open is unrepresentable, and RUN going + // unhandled here is what stops a second action starting while one is in flight. Dismissing the + // popup abandons the action: the request finishes, but nothing is left for its result to land in. + busy: { + on: { CLOSE: { target: 'closed', actions: assign(() => settled) } }, + invoke: fromPromise(context => context.run(), { + onDone: [ + { target: 'closed', guard: context => context.closeOnSuccess, actions: assign(() => settled) }, + { target: 'open', actions: assign(() => settled) }, + ], + // The popup stays up on a failure so the row can be clicked again. Nothing reports what went + // wrong yet; the error surface is its own change, and carrying a message before one exists + // would mean shipping an untranslated string nobody reads. + onError: { target: 'open', actions: assign(() => settled) }, + }), + }, + }, +}); -// Promise-returning so the container can drive busy state. Navigation callbacks stay fire-and-forget. -interface UserButtonAsyncCallbacks { - onSelectOrganization?: (organizationId: string | null) => void | Promise; - onSwitchSession?: (sessionId: string) => void | Promise; - onSignOutSession?: (sessionId: string) => void | Promise; - onSignOutAll?: () => void | Promise; - onAcceptSuggestion?: (suggestionId: string) => void | Promise; - onAcceptInvitation?: (invitationId: string) => void | Promise; -} +export type UserButtonControllerOptions = Pick & UserButtonMenuProps; export type UserButtonController = | { status: 'loading' } | { status: 'hidden' } - | (UserButtonData & - Omit & - UserButtonAsyncCallbacks & - UserButtonBrandingProps & { - status: 'ready'; - /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ - organizationsEnabled: boolean; - }); - -// Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder. -type AfterSelectUrl = ((entity: T) => string) | string; - -/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ -type UserProfileMode = - | { userProfileUrl: string; userProfileMode?: 'navigation' } - | { userProfileUrl?: never; userProfileMode?: 'modal' }; - -type OrganizationProfileMode = - | { organizationProfileUrl: string; organizationProfileMode?: 'navigation' } - | { organizationProfileUrl?: never; organizationProfileMode?: 'modal' }; - -type CreateOrganizationMode = - | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' } - | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' }; - -export type UserButtonControllerOptions = UserProfileMode & - OrganizationProfileMode & - CreateOrganizationMode & { - afterSelectOrganizationUrl?: AfterSelectUrl; - /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */ - afterSelectPersonalUrl?: AfterSelectUrl; - /** - * Leaves the personal workspace out. An instance that forces organization selection withholds it - * either way, so this cannot opt back in. - */ - hidePersonal?: boolean; - }; - -function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined { - if (typeof config === 'function') { - return config(entity); - } - if (config) { - return populateParamFromObject({ urlWithParam: config, entity }); - } - return undefined; -} - -/** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */ -function openOrNavigate({ - url, - mode, - openModal, - buildUrl, - navigate, -}: { - url: string | undefined; - mode: 'navigation' | 'modal' | undefined; - openModal: () => void; - buildUrl: () => string; - navigate: (to: string) => unknown; -}): () => void { - const resolved = mode ?? (url ? 'navigation' : 'modal'); - return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal(); -} - -const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; - -function displayName(user: UserResource): string { - return getFullName(user) || getIdentifier(user); -} - -function toMembership(organization: OrganizationResource): UserButtonMembership { - return { - kind: 'membership', - organizationId: organization.id, - name: organization.name, - imageUrl: organization.imageUrl || undefined, - membersCount: organization.membersCount, - }; -} - -function toSession(sessionId: string, user: UserResource): UserButtonSession { - return { - sessionId, - name: displayName(user), - identifier: getIdentifier(user), - imageUrl: user.imageUrl, - }; -} - -export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController { - const { isLoaded: isUserLoaded, user } = useUser(); - const { isLoaded: isSessionLoaded, session } = useSession(); - const { isLoaded: isOrgLoaded, organization } = useOrganization(); - const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView(); - - const clerk = useClerk(); - const router = useMosaicRouter(); - // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. - const getContainer = usePortalRoot(); - const environment = useMosaicEnvironment(); - - const manageAccount = openOrNavigate({ - url: options?.userProfileUrl, - mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer }), - buildUrl: () => clerk.buildUserProfileUrl(), - navigate: router.navigate, + | ({ status: 'ready' } & Omit); + +/** + * The controller is the layer between the component (view) and the external world (model). + * It represents the local state and wraps model actions in order to handle pending states, + * keep the UI stable while an action is ongoing, close the popup on completed actions + * when appropriate, etc. + */ +export function useUserButtonController( + model: UserButtonModel, + options: UserButtonControllerOptions = {}, +): UserButtonController { + const { mode: requestedMode, modePriority, customMenuItems, menuItemOrder } = options; + // The popover's open state and the one action in flight are the same flow: an action that ends the + // interaction closes the surface, so they settle together or not at all. + const [{ value, context }, send] = useMachine(userButtonMachine); + + // Every action here is a network round trip, so we can start the + // pending state immediately, we use this for the minDuration + const displayPendingKey = useSpinDelay(context.pendingKey, { + delay: 0, + minDuration: context.closeOnSuccess ? 0 : undefined, }); - const manageOrganization = openOrNavigate({ - url: options?.organizationProfileUrl, - mode: options?.organizationProfileMode, - openModal: () => clerk.openOrganizationProfile({ getContainer }), - buildUrl: () => clerk.buildOrganizationProfileUrl(), - navigate: router.navigate, - }); - - const createOrganization = openOrNavigate({ - url: options?.createOrganizationUrl, - mode: options?.createOrganizationMode, - openModal: () => clerk.openCreateOrganization({ getContainer }), - buildUrl: () => clerk.buildCreateOrganizationUrl(), - navigate: router.navigate, - }); - - // These all affect layout, so wait for every one and avoid a reshuffle. - if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded || !environment) { - return { status: 'loading' }; + if (model.status !== 'ready') { + return { status: model.status }; } - if (!user || !session) { - return { status: 'hidden' }; - } - - const { displayConfig, authConfig, organizationSettings } = environment; - // clerk-js refuses `setActive({ organization: null })` when selection is forced, so there is no way back. - const { enabled: organizationsEnabled, forceOrganizationSelection } = organizationSettings; - const { singleSessionMode } = authConfig; - - const canInviteMembers = session.checkAuthorization({ permission: INVITE_MEMBERS_PERMISSION }) ?? false; - const membershipData = userMemberships.data ?? []; - const suggestionData = userSuggestions.data ?? []; - const invitationData = userInvitations.data ?? []; + const close = () => send({ type: 'CLOSE' }); - const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization)); - - const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({ - kind: 'suggestion', - id: s.id, - organizationId: s.publicOrganizationData.id, - name: s.publicOrganizationData.name, - imageUrl: s.publicOrganizationData.imageUrl || undefined, - status: s.status, - })); - - // Accepting is all a row offers, so a revoked or expired invitation has nothing to show. - const invitations: UserButtonInvitation[] = invitationData.flatMap(i => - i.status === 'pending' || i.status === 'accepted' - ? [ - { - kind: 'invitation', - id: i.id, - status: i.status, - organizationId: i.publicOrganizationData.id, - organizationName: i.publicOrganizationData.name, - imageUrl: i.publicOrganizationData.imageUrl || undefined, + const menuItems = customMenuItems?.map(item => + item.href === undefined + ? { + ...item, + onClick: () => { + // Always close the menu for custom actions + close(); + item.onClick(); }, - ] - : [], + } + : item, ); - // Organization requests are scoped to the active session, so another account's workspaces are unknowable. - const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { - const sessionUser = s.user; - if (!sessionUser || s.id === session.id) { - return []; - } - return [toSession(s.id, sessionUser)]; - }); - - const afterSelectUrl = (organizationId: string | null): string | undefined => { - if (!organizationId) { - return resolveAfterSelectUrl(options?.afterSelectPersonalUrl, user); - } - const selected = membershipData.find(m => m.organization.id === organizationId)?.organization; - return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined; - }; + // Wrapper to tie a callback into the machine + const runAction = ( + keyFor: (...args: Args) => string, + fn: ((...args: Args) => void | Promise) | undefined, + closeOnSuccess = false, + ) => + fn + ? (...args: Args) => + send({ + type: 'RUN', + key: keyFor(...args), + frozen: model, + run: async () => fn(...args), + closeOnSuccess, + }) + : undefined; + + // A callback that wraps a callback so it always closes the popup when done + const handOff = (fn: (() => void) | undefined) => + fn + ? () => { + close(); + fn(); + } + : undefined; + + // Rendering the model the action froze on holds the popup still while it runs; the result + // lands in one step when it settles. See `frozen` in the machine for why. + const { + status: _status, + organizationsEnabled, + onSelectOrganization, + onSwitchSession, + onSignOutSession, + onSignOutAll, + onAcceptSuggestion, + onAcceptInvitation, + onManageAccount, + onManageOrganization, + onInviteMembers, + onCreateOrganization, + onAddAccount, + ...data + } = context.frozen ?? model; + + // Force user mode if organizations are disabled + const mode = model.organizationsEnabled ? requestedMode : 'user'; return { status: 'ready', - organizationsEnabled, - renderBranding: displayConfig.branded, - activeSession: toSession(session.id, user), - activeOrganization: organization ? toMembership(organization) : null, - // The user resource settles this before the paginated list answers; the count covers a stale resource. - hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0, - hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), - // Only true before the first page lands, which is the one window where empty and pending look alike. - organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading, - memberships, - suggestions, - invitations, - additionalSessions, - paging: { - ref, - hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), - }, - onSelectOrganization: organizationId => - clerk.setActive({ organization: organizationId, redirectUrl: afterSelectUrl(organizationId) }), - // The session switched to can carry a task of its own, and a plain `redirectUrl` routes past it. - // App-level `taskUrls` outrank this callback, so it only answers for an app that set none. - onSwitchSession: sessionId => - clerk.setActive({ - session: sessionId, - navigate: async ({ session, decorateUrl }) => { - const task = session.currentTask; - if (task) { - await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() })); - return; - } - // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it. - await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl)); - }, - }), - onSignOutSession: sessionId => - clerk.signOut({ - sessionId, - // Other accounts stay signed in, so this is a single sign out rather than a full one. - redirectUrl: - additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(), - }), - // Single-session apps cannot hold a second account, so both actions are meaningless there. - onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), - onManageAccount: manageAccount, - onManageOrganization: manageOrganization, - // Invite has no page of its own to route to, so it opens its modal even when management is routed. - onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, - // Covers both restricted instances and users at their creation limit. - onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined, - onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()), - onAcceptSuggestion: suggestionId => { - const suggestion = suggestionData.find(s => s.id === suggestionId); - return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.()); - }, - // Accepting joins the organization, so memberships are stale too. A suggestion joins nothing. - onAcceptInvitation: invitationId => { - const invitation = invitationData.find(i => i.id === invitationId); - return Promise.resolve(invitation?.accept()).finally(() => { - void userInvitations.revalidate?.(); - void userMemberships.revalidate?.(); - }); - }, + ...data, + mode, + modePriority, + customMenuItems: menuItems, + menuItemOrder, + open: value !== 'closed', + onOpenChange: next => send(next ? { type: 'OPEN' } : { type: 'CLOSE' }), + pendingKey: displayPendingKey, + onSelectOrganization: runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true), + onSwitchSession: runAction(userButtonBusyKeys.switchSession, onSwitchSession), + onSignOutSession: runAction(userButtonBusyKeys.signOutSession, onSignOutSession), + onSignOutAll: runAction(userButtonBusyKeys.signOutAll, onSignOutAll), + onAcceptSuggestion: runAction(userButtonBusyKeys.acceptSuggestion, onAcceptSuggestion), + onAcceptInvitation: runAction(userButtonBusyKeys.acceptInvitation, onAcceptInvitation), + onManageAccount: handOff(onManageAccount), + onManageOrganization: handOff(onManageOrganization), + onInviteMembers: handOff(onInviteMembers), + onCreateOrganization: handOff(onCreateOrganization), + onAddAccount: handOff(onAddAccount), }; } diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx new file mode 100644 index 00000000000..4539add1422 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -0,0 +1,303 @@ +import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; +import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; +import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; +import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types'; + +import { populateParamFromObject } from '../../contexts/utils'; +import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; +import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; +import { useMosaicRouter } from '../hooks/useMosaicRouter'; +import type { + UserButtonBrandingProps, + UserButtonCallbacks, + UserButtonData, + UserButtonInvitation, + UserButtonMembership, + UserButtonSession, + UserButtonSuggestion, +} from './user-button.types'; + +// Promise-returning so the controller can drive busy state. Navigation callbacks stay fire-and-forget. +interface UserButtonAsyncCallbacks { + onSelectOrganization?: (organizationId: string | null) => void | Promise; + onSwitchSession?: (sessionId: string) => void | Promise; + onSignOutSession?: (sessionId: string) => void | Promise; + onSignOutAll?: () => void | Promise; + onAcceptSuggestion?: (suggestionId: string) => void | Promise; + onAcceptInvitation?: (invitationId: string) => void | Promise; +} + +export type UserButtonModel = + | { status: 'loading' } + | { status: 'hidden' } + | (UserButtonData & + Omit & + UserButtonAsyncCallbacks & + UserButtonBrandingProps & { + status: 'ready'; + /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */ + organizationsEnabled: boolean; + }); + +// Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder. +type AfterSelectUrl = ((entity: T) => string) | string; + +/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ +type UserProfileMode = + | { userProfileUrl: string; userProfileMode?: 'navigation' } + | { userProfileUrl?: never; userProfileMode?: 'modal' }; + +type OrganizationProfileMode = + | { organizationProfileUrl: string; organizationProfileMode?: 'navigation' } + | { organizationProfileUrl?: never; organizationProfileMode?: 'modal' }; + +type CreateOrganizationMode = + | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' } + | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' }; + +export type UserButtonModelOptions = UserProfileMode & + OrganizationProfileMode & + CreateOrganizationMode & { + afterSelectOrganizationUrl?: AfterSelectUrl; + /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */ + afterSelectPersonalUrl?: AfterSelectUrl; + /** + * Leaves the personal workspace out. An instance that forces organization selection withholds it + * either way, so this cannot opt back in. + */ + hidePersonal?: boolean; + }; + +function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined { + if (typeof config === 'function') { + return config(entity); + } + if (config) { + return populateParamFromObject({ urlWithParam: config, entity }); + } + return undefined; +} + +/** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */ +function openOrNavigate({ + url, + mode, + openModal, + buildUrl, + navigate, +}: { + url: string | undefined; + mode: 'navigation' | 'modal' | undefined; + openModal: () => void; + buildUrl: () => string; + navigate: (to: string) => unknown; +}): () => void { + const resolved = mode ?? (url ? 'navigation' : 'modal'); + return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal(); +} + +const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; + +function displayName(user: UserResource): string { + return getFullName(user) || getIdentifier(user); +} + +function toMembership(organization: OrganizationResource): UserButtonMembership { + return { + kind: 'membership', + organizationId: organization.id, + name: organization.name, + imageUrl: organization.imageUrl || undefined, + membersCount: organization.membersCount, + }; +} + +function toSession(sessionId: string, user: UserResource): UserButtonSession { + return { + sessionId, + name: displayName(user), + identifier: getIdentifier(user), + imageUrl: user.imageUrl, + }; +} + +/** + * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's + * DOM-callback form. The wrapper owns that conversion because it is the layer that can render + * the portals behind it, so they arrive here ready to forward and stay out of the public options. + */ +export function useUserButtonModel( + options?: UserButtonModelOptions, + userProfileCustomPages?: CustomPage[], +): UserButtonModel { + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + // The active org names the trigger. That is not a request to turn Organizations on. + const { isLoaded: isOrgLoaded, organization } = useOrganization({ + __internal_skipAttemptToEnableOrganizations: true, + }); + const clerk = useClerk(); + const router = useMosaicRouter(); + // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. + const getContainer = usePortalRoot(); + const environment = useMosaicEnvironment(); + // Don't fetch orgsLists until we know orgs are enabled. + // This wont delay rendering of the trigger, or even the popup shell, since the "ready" status + // does not depend on this. + const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView({ + enabled: Boolean(environment?.organizationSettings.enabled), + }); + + const manageAccount = openOrNavigate({ + url: options?.userProfileUrl, + mode: options?.userProfileMode, + openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }), + buildUrl: () => clerk.buildUserProfileUrl(), + navigate: router.navigate, + }); + + const manageOrganization = openOrNavigate({ + url: options?.organizationProfileUrl, + mode: options?.organizationProfileMode, + openModal: () => clerk.openOrganizationProfile({ getContainer }), + buildUrl: () => clerk.buildOrganizationProfileUrl(), + navigate: router.navigate, + }); + + const createOrganization = openOrNavigate({ + url: options?.createOrganizationUrl, + mode: options?.createOrganizationMode, + openModal: () => clerk.openCreateOrganization({ getContainer }), + buildUrl: () => clerk.buildCreateOrganizationUrl(), + navigate: router.navigate, + }); + + // These all affect layout, so wait for every one and avoid a reshuffle. + if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user || !session) { + return { status: 'hidden' }; + } + + const { displayConfig, authConfig, organizationSettings } = environment; + // clerk-js refuses `setActive({ organization: null })` when selection is forced, so there is no way back. + const { enabled: organizationsEnabled, forceOrganizationSelection } = organizationSettings; + const { singleSessionMode } = authConfig; + + const canInviteMembers = session.checkAuthorization({ permission: INVITE_MEMBERS_PERMISSION }) ?? false; + const membershipData = userMemberships.data ?? []; + const suggestionData = userSuggestions.data ?? []; + const invitationData = userInvitations.data ?? []; + + const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization)); + + const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({ + kind: 'suggestion', + id: s.id, + organizationId: s.publicOrganizationData.id, + name: s.publicOrganizationData.name, + imageUrl: s.publicOrganizationData.imageUrl || undefined, + status: s.status, + })); + + // Accepting is all a row offers, so a revoked or expired invitation has nothing to show. + const invitations: UserButtonInvitation[] = invitationData.flatMap(i => + i.status === 'pending' || i.status === 'accepted' + ? [ + { + kind: 'invitation', + id: i.id, + status: i.status, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + }, + ] + : [], + ); + + // Organization requests are scoped to the active session, so another account's workspaces are unknowable. + const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { + const sessionUser = s.user; + if (!sessionUser || s.id === session.id) { + return []; + } + return [toSession(s.id, sessionUser)]; + }); + + const afterSelectUrl = (organizationId: string | null): string | undefined => { + if (!organizationId) { + return resolveAfterSelectUrl(options?.afterSelectPersonalUrl, user); + } + const selected = membershipData.find(m => m.organization.id === organizationId)?.organization; + return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined; + }; + + return { + status: 'ready', + organizationsEnabled, + renderBranding: displayConfig.branded, + activeSession: toSession(session.id, user), + activeOrganization: organization ? toMembership(organization) : null, + // The user resource settles this before the paginated list answers; the count covers a stale resource. + hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0, + hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), + // Only true before the first page lands, which is the one window where empty and pending look alike. + organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading, + memberships, + suggestions, + invitations, + additionalSessions, + paging: { + ref, + hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), + }, + onSelectOrganization: organizationId => + clerk.setActive({ organization: organizationId, redirectUrl: afterSelectUrl(organizationId) }), + // The session switched to can carry a task of its own, and a plain `redirectUrl` routes past it. + // App-level `taskUrls` outrank this callback, so it only answers for an app that set none. + onSwitchSession: sessionId => + clerk.setActive({ + session: sessionId, + navigate: async ({ session, decorateUrl }) => { + const task = session.currentTask; + if (task) { + await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() })); + return; + } + // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it. + await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl)); + }, + }), + onSignOutSession: sessionId => + clerk.signOut({ + sessionId, + // Other accounts stay signed in, so this is a single sign out rather than a full one. + redirectUrl: + additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(), + }), + // Single-session apps cannot hold a second account, so both actions are meaningless there. + onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), + onManageAccount: manageAccount, + onManageOrganization: manageOrganization, + // Invite has no page of its own to route to, so it opens its modal even when management is routed. + onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, + // Covers both restricted instances and users at their creation limit. + onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined, + onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()), + onAcceptSuggestion: suggestionId => { + const suggestion = suggestionData.find(s => s.id === suggestionId); + return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.()); + }, + // Accepting joins the organization, so memberships are stale too. A suggestion joins nothing. + onAcceptInvitation: invitationId => { + const invitation = invitationData.find(i => i.id === invitationId); + return Promise.resolve(invitation?.accept()).finally(() => { + void userInvitations.revalidate?.(); + void userMemberships.revalidate?.(); + }); + }, + }; +} diff --git a/packages/ui/src/mosaic/user-button/user-button.pages.tsx b/packages/ui/src/mosaic/user-button/user-button.pages.tsx new file mode 100644 index 00000000000..070444d9d74 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.pages.tsx @@ -0,0 +1,160 @@ +import { + disabledUserAPIKeysFeature, + disabledUserBillingFeature, +} from '@clerk/shared/internal/clerk-js/componentGuards'; +import { useClerk } from '@clerk/shared/react'; +import type { CustomPage } from '@clerk/shared/types'; +import type { ReactNode } from 'react'; +import { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; +import { applyOrder } from './user-button.utils'; + +/** A page the UserProfile brings itself, named by the id its navigation knows it as. */ +export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys'; + +/** + * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned + * off. + * + * Ordering a custom page after a built-in one means naming every built-in that follows it, so the + * list has to match what the profile will actually show. It mirrors clerk-js rather than being read + * from it: the profile is not mounted yet at the point this is needed, and it decides its own pages + * from the same environment behind the same guards. + */ +export function useUserProfilePages(): UserProfilePageId[] { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + + const pages: UserProfilePageId[] = ['account', 'security']; + if (!disabledUserBillingFeature(clerk, environment)) { + pages.push('billing'); + } + if (!disabledUserAPIKeysFeature(clerk, environment)) { + pages.push('apiKeys'); + } + return pages; +} + +/** A page of your own inside the profile, reached from its navigation. */ +export interface CustomProfilePage { + /** Names the page in the profile's navigation. */ + label: string; + /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */ + path: string; + href?: never; + icon?: ReactNode; + /** Rendered as the page itself. */ + content: ReactNode; +} + +/** A row in the profile's navigation that leaves for somewhere else. */ +export interface CustomProfileLink { + /** Names the row in the profile's navigation. */ + label: string; + /** Identifies the row, for ordering. */ + path: string; + /** Where the row goes. */ + href: string; + icon?: ReactNode; + content?: never; +} + +export type CustomProfileItem = CustomProfilePage | CustomProfileLink; + +export interface CustomPagesOptions { + /** Pages and links of the consumer's own. */ + items: CustomProfileItem[] | undefined; + /** The order the profile's navigation should run in, by id. */ + order: readonly string[] | undefined; + /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */ + builtInPages: readonly string[]; +} + +export interface CustomPagesBridge { + /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */ + customPages: CustomPage[] | undefined; + /** Render these for as long as the profile can be open, or its pages come up blank. */ + portals: ReactNode[]; +} + +const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined; + +function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode { + const container = containers.get(id); + return container ? createPortal(node, container, id) : null; +} + +/** + * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes. + * + * The profile opens in clerk-js's own React root, which cannot render a node from the host app's + * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the + * page belongs and hands it over, and the host tree portals the content into it from here. The + * portals therefore have to stay mounted in the host tree the whole time the profile is open, which + * is why they come back out rather than being rendered here. + * + * This is the shape of the bridge only for as long as the profile renders outside the host tree. A + * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the + * props a consumer writes. + */ +export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge { + const [containers, setContainers] = useState>(new Map()); + + // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep + // working: the portal re-reads its container from state on every render of the host tree. + const bind = useCallback( + (id: string) => ({ + mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)), + unmount: () => + setContainers(prev => { + const next = new Map(prev); + next.delete(id); + return next; + }), + }), + [], + ); + + const byId = new Map((items ?? []).map(item => [item.path, item])); + // clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a + // built-in left out of the order has to be sent anyway to keep it behind the pages that were named. + // Without an order there is nothing to hold in place, so only the custom pages go out. + const ids = order?.length ? applyOrder(order, [...builtInPages, ...byId.keys()], id => id) : [...byId.keys()]; + + if (!ids.length) { + return { customPages: undefined, portals: [] }; + } + + const customPages = ids.map(id => { + const item = byId.get(id); + // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and + // it reads as a custom page instead. + if (!item) { + return { label: id }; + } + + // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an + // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put + // through them; without them, leaving `icon` off would silently cost you the page. + const icon = bind(`icon:${id}`); + const content = isLink(item) ? undefined : bind(`content:${id}`); + + return { + label: item.label, + // A page is routed to by its path; a link is followed to wherever it points. + url: isLink(item) ? item.href : item.path, + mountIcon: icon.mount, + unmountIcon: icon.unmount, + ...(content && { mount: content.mount, unmount: content.unmount }), + }; + }); + + const portals = (items ?? []).flatMap(item => [ + portalInto(containers, `icon:${item.path}`, item.icon), + ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]), + ]); + + return { customPages, portals }; +} diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 554779a3187..60f2094ceb3 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -1,19 +1,34 @@ 'use client'; import type { ReactElement, ReactNode } from 'react'; -import { useState } from 'react'; -import { useSpinDelay } from '../hooks/useSpinDelay'; -import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller'; +import { useUserButtonController } from './user-button.controller'; +import type { UserButtonModelOptions } from './user-button.model'; +import { useUserButtonModel } from './user-button.model'; +import type { CustomProfileItem, UserProfilePageId } from './user-button.pages'; +import { useCustomPages, useUserProfilePages } from './user-button.pages'; import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; import type { UserButtonTriggerProps } from './user-button.view'; -import { userButtonBusyKeys, UserButtonView } from './user-button.view'; +import { UserButtonView } from './user-button.view'; -/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */ -export type UserButtonProps = UserButtonControllerOptions & +/** Configures the UserProfile this button opens. */ +export interface UserButtonUserProfileProps { + /** Pages and links of your own, added to the profile's navigation. */ + customPages?: CustomProfileItem[]; + /** + * The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's + * `path`. Anything left out follows the pages named here. The first page is the one the profile + * opens on, so it cannot be a link. + */ + pageOrder?: (UserProfilePageId | (string & {}))[]; +} + +/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */ +export type UserButtonProps = UserButtonModelOptions & UserButtonTriggerProps & UserButtonMenuProps & - Pick & { + UserButtonModeProps & { + userProfileProps?: UserButtonUserProfileProps; /** * Stands in while Clerk is still answering, so the space the button will take is held rather * than appearing under whatever is beside it. Dropped once nobody is signed in, since that is @@ -38,9 +53,11 @@ export type UserButtonProps = UserButtonControllerOptions & * ``` * * @example - * `modePriority` picks which switcher the menu leads with — in its header, and in the trigger beside - * the avatar. The other one is still listed. + * `mode` narrows the menu to one switcher, and `modePriority` picks which one a combined menu leads + * with — in its header, and in the trigger beside the avatar. The other one is still listed. * ```tsx + * + * * * ``` * @@ -64,10 +81,14 @@ export type UserButtonProps = UserButtonControllerOptions & * ``` * * @example - * `customMenuItems` adds your own rows to the foot of the menu, each one either an `onClick` action - * or an `href` link, and `menuItemOrder` names the order the foot's rows run in. + * `customPages` adds your own pages to the profile this button opens; `customMenuItems` adds your + * own rows to the foot of the menu, each one either an `onClick` action or an `href` link. * ```tsx * , content: }], + * pageOrder: ['account', 'usage', 'security'], + * }} * customMenuItems={[ * { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }, * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, @@ -77,85 +98,53 @@ export type UserButtonProps = UserButtonControllerOptions & * ``` */ export function UserButton(props: UserButtonProps = {}): ReactElement | null { - const { renderTriggerLabel, renderTriggerBadge, modePriority, customMenuItems, menuItemOrder, fallback, ...options } = - props; - const controller = useUserButtonController(options); - const [open, setOpen] = useState(false); - const [pendingKey, setPendingKey] = useState(null); - - // Re-entry is guarded on the immediate `pendingKey`; only the view's feedback is delayed. - const displayPendingKey = useSpinDelay(pendingKey); + const { + renderTriggerLabel, + renderTriggerBadge, + mode, + modePriority, + userProfileProps, + customMenuItems, + menuItemOrder, + fallback, + ...options + } = props; + // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered + // from here. They have to outlive the popover that opened it, and the button's own data with it, + // which is why they hang off the wrapper rather than anything the popover renders. + const builtInPages = useUserProfilePages(); + const { customPages, portals } = useCustomPages({ + items: userProfileProps?.customPages, + order: userProfileProps?.pageOrder, + builtInPages, + }); + const model = useUserButtonModel(options, customPages); + const controller = useUserButtonController(model, { mode, modePriority, customMenuItems, menuItemOrder }); if (controller.status === 'loading') { - return <>{fallback}; + return ( + <> + {fallback} + {portals} + + ); } // Signed out is an answer, so the placeholder goes too rather than promising a button. if (controller.status === 'hidden') { - return null; + return <>{portals}; } - const close = () => setOpen(false); - - // Whatever the app's action opens takes over from here, so the popover goes with it. Links navigate away. - const menuItems = customMenuItems?.map(item => - item.href === undefined - ? { - ...item, - onClick: () => { - close(); - item.onClick(); - }, - } - : item, - ); - - // Only an action that ends the interaction closes the popover; the rest resolve into it. - const runAction = ( - keyFor: (...args: Args) => string, - fn: ((...args: Args) => void | Promise) | undefined, - closeOnSuccess = false, - ) => - fn - ? (...args: Args) => { - if (pendingKey) { - return; - } - setPendingKey(keyFor(...args)); - void Promise.resolve(fn(...args)) - .then(closeOnSuccess ? close : () => {}, () => {}) - .finally(() => setPendingKey(null)); - } - : undefined; - - const { - status: _status, - onSelectOrganization, - onSwitchSession, - onSignOutSession, - onSignOutAll, - onAcceptSuggestion, - onAcceptInvitation, - ...data - } = controller; + const { status: _status, ...viewController } = controller; return ( - + <> + + {portals} + ); } diff --git a/packages/ui/src/mosaic/user-button/user-button.types.ts b/packages/ui/src/mosaic/user-button/user-button.types.ts index 26f9e5f9d1f..e2ff5df3728 100644 --- a/packages/ui/src/mosaic/user-button/user-button.types.ts +++ b/packages/ui/src/mosaic/user-button/user-button.types.ts @@ -1,8 +1,8 @@ import type { ReactNode } from 'react'; // ─── Data contract ────────────────────────────────────────────────────────── -// Session-backed, discriminated resource rows. 1:1 with `useUserButtonController()`'s output, so the -// controller and the view agree on a shape neither one owns. +// Session-backed, discriminated resource rows. 1:1 with `useUserButtonModel()`'s output, so the +// model and the view agree on a shape neither one owns. export interface UserButtonSession { sessionId: string; diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index edfef936a06..323c937d2fd 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -38,7 +38,7 @@ import type { import { applyOrder } from './user-button.utils'; // The data contract, the mode flags, and the menu item shapes live in `user-button.types`; they are -// what the controller and the view agree on, so neither file owns them. +// what the model and the view agree on, so neither file owns them. export type * from './user-button.types'; /**