diff --git a/.changeset/mosaic-user-button-mvc-layers.md b/.changeset/mosaic-user-button-mvc-layers.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-mvc-layers.md @@ -0,0 +1,2 @@ +--- +--- 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..10043907d75 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,75 @@ -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 { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { UserButtonControllerOptions } from '../user-button.controller'; -import { useUserButtonController } from '../user-button.controller'; +import { UserButton } 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 model: UserButtonModel; -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(), - }), - }; -}); +vi.mock('../user-button.model', () => ({ + useUserButtonModel: () => model, +})); -// 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 }), +// The controller's own job is which of the three model 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 acceptable( - id: string, - orgId: string, - orgName: string, - status: 'pending' | 'accepted' | 'revoked' | 'expired' = 'pending', -) { +function ready(): UserButtonModel { 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: [], }; } -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); - 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('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(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); - - isSessionLoaded = true; - isOrgLoaded = false; - rerender(); - expect(screen.getByTestId('status')).toHaveTextContent('loading'); +describe('UserButton', () => { + beforeEach(() => { + model = { status: '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('stands the fallback in while Clerk is still answering', () => { + render(} />); + expect(screen.getByTestId('fallback')).toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); }); - 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'); + // Signing out is an answer, not a wait. Holding the placeholder there would promise a button to + // someone who is never going to get one. + it('drops the fallback once nobody is signed in', () => { + model = { status: 'hidden' }; + render(} />); + expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); + expect(screen.queryByTestId('view')).not.toBeInTheDocument(); }); - it('is hidden when loaded but there is no active user', () => { - user = null; - render(); - expect(screen.getByTestId('status')).toHaveTextContent('hidden'); + it('renders the surface once the session is ready', () => { + model = ready(); + render(} />); + expect(screen.getByTestId('view')).toBeInTheDocument(); + expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); }); - 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 }); - }); - - // 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); + it('renders nothing while loading when no fallback is given', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); }); }); 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..49386033f68 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -0,0 +1,763 @@ +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 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: () => ({ 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: () => ({ 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(options: UserButtonModelOptions = {}) { + const c = useUserButtonModel(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)} + + + + + + + + + + + + +
+ ); +} + +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'); + + 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(); + 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 }); + }); + + // 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.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx deleted file mode 100644 index 2583acccf32..00000000000 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { UserButton } from '../user-button'; -import type { UserButtonController } from '../user-button.controller'; - -let controller: UserButtonController; - -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 -// 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, - hasOrganizations: false, - hidePersonal: false, - organizationsLoading: false, - memberships: [], - suggestions: [], - invitations: [], - additionalSessions: [], - }; -} - -describe('UserButton', () => { - beforeEach(() => { - controller = { status: 'loading' }; - }); - - it('stands the fallback in while Clerk is still answering', () => { - render(} />); - expect(screen.getByTestId('fallback')).toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - }); - - // Signing out is an answer, not a wait. Holding the placeholder there would promise a button to - // someone who is never going to get one. - it('drops the fallback once nobody is signed in', () => { - controller = { status: 'hidden' }; - render(} />); - expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); - expect(screen.queryByTestId('view')).not.toBeInTheDocument(); - }); - - it('renders the surface once the session is ready', () => { - controller = ready(); - render(} />); - expect(screen.getByTestId('view')).toBeInTheDocument(); - expect(screen.queryByTestId('fallback')).not.toBeInTheDocument(); - }); - - it('renders nothing while loading when no fallback is given', () => { - const { container } = render(); - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index def6553349f..11af8c2f55b 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -35,8 +35,8 @@ const gamma = { const beta = { kind: 'suggestion', id: 'sug_1', organizationId: 'org_4', name: 'Beta', status: 'pending' } as const; /** - * Every callback the connected container passes, so a test opts a surface *out* of an affordance - * rather than having to opt into it. `combined` is the container's own default. + * Every callback the connected controller passes, so a test opts a surface *out* of an affordance + * rather than having to opt into it. `combined` is the controller's own default. */ function renderView(props: Partial = {}) { return render( 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..ead5e904af0 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,161 @@ -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 { 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 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 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; +'use client'; + +import type { ReactElement, ReactNode } from 'react'; +import { useState } from 'react'; + +import { useSpinDelay } from '../hooks/useSpinDelay'; +import { type UserButtonModelOptions, useUserButtonModel } from './user-button.model'; +import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; +import type { UserButtonTriggerProps } from './user-button.view'; +import { userButtonBusyKeys, UserButtonView } from './user-button.view'; + +/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */ +export type UserButtonProps = UserButtonModelOptions & + UserButtonTriggerProps & + UserButtonMenuProps & + Pick & { /** - * Leaves the personal workspace out. An instance that forces organization selection withholds it - * either way, so this cannot opt back in. + * 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 + * an answer and not a wait. */ - hidePersonal?: boolean; + fallback?: ReactNode; }; -function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined { - if (typeof config === 'function') { - return config(entity); - } - if (config) { - return populateParamFromObject({ urlWithParam: config, entity }); +/** + * The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account, + * open the profile, and sign out. It reads the active session and organization from Clerk, so it takes + * no data. It renders `fallback` until Clerk answers, and nothing at all when nobody is signed in. + * + * Each action is a request: the row you click spins, the others stand down, and the menu stays open on + * the result. Only an action that navigates closes it. + * + * @example + * ```tsx + * import { UserButton } from '@clerk/ui/mosaic'; + * + * + * ``` + * + * @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. + * ```tsx + * + * ``` + * + * @example + * Passing a URL routes to a page of your own instead of opening Clerk's modal; that is the whole + * opt-in. `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param` + * template, a plain path, or a function. + * ```tsx + * + * ``` + * + * @example + * `fallback` holds the space while Clerk is still answering. Size it to the trigger to keep the row + * it sits in from moving. Nothing stands in once the answer is that nobody is signed in. + * ```tsx + * } /> + * ``` + * + * @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. + * ```tsx + * , href: 'https://example.com/docs' }, + * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, + * ]} + * menuItemOrder={['docs', 'support', 'addAccount', 'signOutAll']} + * /> + * ``` + */ +export function UserButton(props: UserButtonProps = {}): ReactElement | null { + const { renderTriggerLabel, renderTriggerBadge, modePriority, customMenuItems, menuItemOrder, fallback, ...options } = + props; + const model = useUserButtonModel(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); + + if (model.status === 'loading') { + return <>{fallback}; } - 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, - }); - - 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' }; + // Signed out is an answer, so the placeholder goes too rather than promising a button. + if (model.status === 'hidden') { + return null; } - 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 close = () => setOpen(false); - 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, + // 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, ); - // 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() })); + // 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; } - // `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?.(); - }); - }, - }; + 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 + } = model; + + return ( + + ); } 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..75b13f4f8ae --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -0,0 +1,288 @@ +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 { 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, + }; +} + +export function useUserButtonModel(options?: UserButtonModelOptions): UserButtonModel { + 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, + }); + + 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.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx deleted file mode 100644 index 554779a3187..00000000000 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ /dev/null @@ -1,161 +0,0 @@ -'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 type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; -import type { UserButtonTriggerProps } from './user-button.view'; -import { userButtonBusyKeys, UserButtonView } from './user-button.view'; - -/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */ -export type UserButtonProps = UserButtonControllerOptions & - UserButtonTriggerProps & - UserButtonMenuProps & - Pick & { - /** - * 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 - * an answer and not a wait. - */ - fallback?: ReactNode; - }; - -/** - * The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account, - * open the profile, and sign out. It reads the active session and organization from Clerk, so it takes - * no data. It renders `fallback` until Clerk answers, and nothing at all when nobody is signed in. - * - * Each action is a request: the row you click spins, the others stand down, and the menu stays open on - * the result. Only an action that navigates closes it. - * - * @example - * ```tsx - * import { UserButton } from '@clerk/ui/mosaic'; - * - * - * ``` - * - * @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. - * ```tsx - * - * ``` - * - * @example - * Passing a URL routes to a page of your own instead of opening Clerk's modal; that is the whole - * opt-in. `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param` - * template, a plain path, or a function. - * ```tsx - * - * ``` - * - * @example - * `fallback` holds the space while Clerk is still answering. Size it to the trigger to keep the row - * it sits in from moving. Nothing stands in once the answer is that nobody is signed in. - * ```tsx - * } /> - * ``` - * - * @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. - * ```tsx - * , href: 'https://example.com/docs' }, - * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, - * ]} - * menuItemOrder={['docs', 'support', 'addAccount', 'signOutAll']} - * /> - * ``` - */ -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); - - if (controller.status === 'loading') { - return <>{fallback}; - } - - // Signed out is an answer, so the placeholder goes too rather than promising a button. - if (controller.status === 'hidden') { - return null; - } - - 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; - - return ( - - ); -} 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..36b5224946e 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -38,12 +38,12 @@ 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'; /** - * Stable keys naming which affordance owns the single in-flight action. Shared by the connected - * container (which sets `pendingKey`) and the view (which matches against it). + * Stable keys naming which affordance owns the single in-flight action. Shared by the + * controller (which sets `pendingKey`) and the view (which matches against it). */ export const userButtonBusyKeys = { selectOrganization: (organizationId: string | null) => `select-org:${organizationId ?? 'personal'}`, @@ -601,7 +601,7 @@ function PendingRow({ busyKey, name, imageUrl, actionLabel, onAccept, note }: Pe // Every other affordance here swaps its icon for a spinner, but this one is a labelled // button, so the spinner goes inside it rather than taking the row's trailing edge — the // press and the thing that reports it stay the same element. `pendingKey` is already - // spin-delayed by the container, so this asks for no second delay of its own. + // spin-delayed by the controller, so this asks for no second delay of its own.