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 new file mode 100644 index 00000000000..f49af448b4c --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -0,0 +1,419 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { UserButtonControllerOptions } from '../user-button.controller'; +import { useUserButtonController } from '../user-button.controller'; + +interface FakeUser { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + primaryEmailAddress: { emailAddress: string } | null; + imageUrl: string; +} + +interface FakeSession { + id: string; + user: FakeUser; +} + +interface FakeList { + data: unknown[]; + count: number; + hasNextPage: 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 } | null; +let userMemberships: FakeList; +let userInvitations: FakeList; +let userSuggestions: FakeList; +let signedInSessions: FakeSession[]; +let pagingRef: (element: HTMLElement | null) => void; +let singleSessionMode: boolean; + +let setActive: ReturnType; +let signOut: ReturnType; +let navigate: ReturnType; +let checkAuthorization: ReturnType; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ isLoaded: isSessionLoaded, session }), + useOrganization: () => ({ isLoaded: isOrgLoaded, organization }), + useClerk: () => ({ + navigate, + setActive, + signOut, + buildUserProfileUrl: () => '/user-profile', + buildOrganizationProfileUrl: () => '/org-profile', + buildCreateOrganizationUrl: () => '/create-org', + buildSignInUrl: () => '/sign-in', + buildAfterSignOutUrl: () => '/after-sign-out', + buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out', + client: { signedInSessions }, + __internal_environment: { + displayConfig: { afterSwitchSessionUrl: '/after-switch' }, + authConfig: { singleSessionMode }, + }, + }), + }; +}); + +// The controller reads its three paginated lists through the shared in-view helper, so the fetch +// boundary is stubbed there rather than at `useOrganizationList`. +vi.mock('../../../hooks/useOrganizationListInView', () => ({ + useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }), +})); + +function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = '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): FakeList { + return { data, count, hasNextPage, 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', + }; + session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) }; + organization = { id: 'org_1' }; + 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; + 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', + }, + }, + ]; + setActive = vi.fn().mockResolvedValue(undefined); + signOut = vi.fn().mockResolvedValue(undefined); + navigate = vi.fn().mockResolvedValue(undefined); +}); + +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.email} + {c.activeSession.sessionId} + {String(c.activeOrganizationId)} + {String(c.hasOrganizations)} + {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))} + {JSON.stringify(c.memberships)} + {JSON.stringify(c.suggestions)} + {JSON.stringify(c.invitations)} + + + + + + + + + + + +
+ ); +} + +function memberships() { + return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]'); +} + +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'); + }); + + 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-email')).toHaveTextContent('alice@example.com'); + 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('reflects the active organization id, and null in personal mode', () => { + const { rerender } = render(); + expect(screen.getByTestId('active-org')).toHaveTextContent('org_1'); + + organization = null; + rerender(); + expect(screen.getByTestId('active-org')).toHaveTextContent('null'); + }); + + 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'); + }); + + 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', + }); + + const invitations = JSON.parse(screen.getByTestId('invitations').textContent ?? '[]'); + expect(invitations[0]).toMatchObject({ + kind: 'invitation', + id: 'inv_1', + organizationId: 'org_3', + organizationName: 'Gamma', + }); + }); + + 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' }); + }); + + 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' }); + }); + + 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'); + }); + + it('navigates for manage, invite, create, and add-account actions using clerk build URLs', () => { + render(); + + fireEvent.click(screen.getByText('manage-account')); + expect(navigate).toHaveBeenCalledWith('/user-profile'); + + fireEvent.click(screen.getByText('manage-org')); + expect(navigate).toHaveBeenCalledWith('/org-profile'); + + fireEvent.click(screen.getByText('invite-members')); + expect(navigate).toHaveBeenCalledWith('/org-profile'); + + fireEvent.click(screen.getByText('create-org')); + expect(navigate).toHaveBeenCalledWith('/create-org'); + + fireEvent.click(screen.getByText('add-account')); + expect(navigate).toHaveBeenCalledWith('/sign-in'); + }); + + it('accepts invitations and suggestions, then revalidates the collection', async () => { + render(); + + 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); + + 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); + }); +}); diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx new file mode 100644 index 00000000000..8258a890336 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx @@ -0,0 +1,185 @@ +import { useClerk, useOrganization, 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 { + UserButtonCallbacks, + UserButtonData, + UserButtonInvitation, + UserButtonMembership, + UserButtonSession, + UserButtonSuggestion, +} from './user-button.view'; + +// The container awaits these one-shot actions to drive busy state, so the controller exposes their +// promise; navigation callbacks stay fire-and-forget (`() => void`) and reach the view's DOM handlers. +interface UserButtonAsyncCallbacks { + onSelectOrganization?: (organizationId: string) => 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 & { status: 'ready' }); + +// Mirrors the `` `afterSelectOrganizationUrl` prop: a full URL/path, a `:token` +// path template resolved against the organization, or a builder function. +type AfterSelectUrl = ((entity: T) => string) | string; + +export interface UserButtonControllerOptions { + afterSelectOrganizationUrl?: AfterSelectUrl; +} + +function resolveAfterSelectUrl( + config: AfterSelectUrl | undefined, + entity: OrganizationResource, +): string | undefined { + if (typeof config === 'function') { + return config(entity); + } + if (config) { + return populateParamFromObject({ urlWithParam: config, entity }); + } + return undefined; +} + +const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; + +function displayName(user: UserResource): string { + const full = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + if (full) { + return full; + } + if (user.username) { + return user.username; + } + return user.primaryEmailAddress?.emailAddress ?? ''; +} + +function toSession(sessionId: string, user: UserResource): UserButtonSession { + return { + sessionId, + name: displayName(user), + email: user.primaryEmailAddress?.emailAddress ?? '', + 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(); + const environment = useMosaicEnvironment(); + const displayConfig = environment?.displayConfig; + const singleSessionMode = environment?.authConfig?.singleSessionMode ?? false; + + if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded) { + return { status: 'loading' }; + } + + if (!user || !session) { + return { status: 'hidden' }; + } + + 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 => ({ + kind: 'membership', + organizationId: m.organization.id, + name: m.organization.name, + imageUrl: m.organization.imageUrl || undefined, + membersCount: m.organization.membersCount, + })); + + 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, + })); + + const invitations: UserButtonInvitation[] = invitationData.map(i => ({ + kind: 'invitation', + id: i.id, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + })); + + // Organization requests are scoped to the session that makes them, so another account's + // workspaces are unknowable until it is the active one. Sessions are all we can hand over. + const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { + const sessionUser = s.user; + if (!sessionUser || s.id === session.id) { + return []; + } + return [toSession(s.id, sessionUser)]; + }); + + return { + status: 'ready', + activeSession: toSession(session.id, user), + activeOrganizationId: organization?.id ?? null, + hasOrganizations: (userMemberships.count ?? 0) > 0, + memberships, + suggestions, + invitations, + additionalSessions, + paging: { + ref, + hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage), + }, + onSelectOrganization: organizationId => { + const selected = membershipData.find(m => m.organization.id === organizationId)?.organization; + return clerk.setActive({ + organization: organizationId, + redirectUrl: selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined, + }); + }, + onSwitchSession: sessionId => + clerk.setActive({ session: sessionId, redirectUrl: displayConfig?.afterSwitchSessionUrl }), + onSignOutSession: sessionId => + clerk.signOut({ + sessionId, + // Other accounts stay signed in, so route to the single-session-out URL; otherwise this is + // a full sign out. + redirectUrl: + additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(), + }), + // Single-session apps cannot hold a second account, so adding one and signing out of "all + // accounts" are meaningless there; the per-account sign out on the active row remains. + onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), + onManageAccount: () => void router.navigate(clerk.buildUserProfileUrl()), + onManageOrganization: () => void router.navigate(clerk.buildOrganizationProfileUrl()), + onInviteMembers: canInviteMembers ? () => void router.navigate(clerk.buildOrganizationProfileUrl()) : undefined, + onCreateOrganization: () => void router.navigate(clerk.buildCreateOrganizationUrl()), + 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?.()); + }, + onAcceptInvitation: invitationId => { + const invitation = invitationData.find(i => i.id === invitationId); + return Promise.resolve(invitation?.accept()).finally(() => void userInvitations.revalidate?.()); + }, + }; +} diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx new file mode 100644 index 00000000000..c7d479b4cde --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; + +import { useSpinDelay } from '../hooks/useSpinDelay'; +import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller'; +import type { UserButtonRootProps, UserButtonTriggerProps } from './user-button.view'; +import { userButtonBusyKeys, UserButtonTriggerSkeleton, UserButtonView } from './user-button.view'; + +export type UserButtonProps = UserButtonControllerOptions & + UserButtonTriggerProps & + Pick; + +/** + * The connected UserButton: reads live Clerk data through `useUserButtonController` and renders + * the presentational `UserButtonView`. Owns the popover open state and the single in-flight action: + * it marks the clicked affordance busy (spinner + disables the rest), closes the popover only when + * the action resolves, and clears busy state (leaving the popover open) if it rejects. Actions that + * open another surface (manage/create navigations) leave the popover as-is. + */ +export function UserButton({ renderTriggerLabel, renderPlanBadge, modePriority, ...options }: UserButtonProps = {}) { + const controller = useUserButtonController(options); + const [open, setOpen] = useState(false); + const [pendingKey, setPendingKey] = useState(null); + + // Hold the spinner off for quick actions and steady it once shown. Re-entry is still guarded on + // the immediate `pendingKey`; only the view's feedback is delayed. + const displayPendingKey = useSpinDelay(pendingKey); + + if (controller.status === 'loading') { + return ; + } + + if (controller.status !== 'ready') { + return null; + } + + const close = () => setOpen(false); + + // Wraps a one-shot callback: block re-entry while busy, key the in-flight action for the view, and + // always clear busy so a rejection cannot leave the UI hanging. Only an action that ends the + // interaction closes the surface; the rest resolve into a popover that re-renders around the + // result, so you can see what you just did. + 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 ( + + ); +}