From ba22f922607aba7774cab2af6b8177fd12bc3481 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 16 Jul 2026 17:42:45 -0400 Subject: [PATCH] feat(ui): add AccountButton controller --- .changeset/account-button-controller.md | 2 + .../swingset/src/stories/account-button.mdx | 12 +- .../src/stories/account-button.stories.tsx | 8 +- .../account-button.controller.test.tsx | 383 ++++++++++++++++++ .../account/account-button.controller.tsx | 140 +++++++ .../ui/src/mosaic/account/account-button.tsx | 43 ++ .../mosaic/account/account-button.view.tsx | 7 +- 7 files changed, 584 insertions(+), 11 deletions(-) create mode 100644 .changeset/account-button-controller.md create mode 100644 packages/ui/src/mosaic/account/__tests__/account-button.controller.test.tsx create mode 100644 packages/ui/src/mosaic/account/account-button.controller.tsx create mode 100644 packages/ui/src/mosaic/account/account-button.tsx diff --git a/.changeset/account-button-controller.md b/.changeset/account-button-controller.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/account-button-controller.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/account-button.mdx b/packages/swingset/src/stories/account-button.mdx index 625a07f8138..2af66c1552d 100644 --- a/packages/swingset/src/stories/account-button.mdx +++ b/packages/swingset/src/stories/account-button.mdx @@ -10,8 +10,9 @@ exposes **Add organization**, **Add account**, and **Sign out of all accounts**. It is the styled Mosaic component composed from the headless `@clerk/headless` popover primitive plus slot recipes, and inherits the primitive's open/close behavior, focus management, and ARIA wiring. This -first pass is presentational: the parts render from the props on `AccountButtonRoot` (mock data in the -examples). A `useAccountButtonController()` that reads live Clerk resources is a drop-in follow-up. +`AccountButtonView` is presentational: the parts render from the props on `AccountButtonRoot` (mock data +in the examples). The connected `AccountButton` wraps it with `useAccountButtonController()`, which reads +live Clerk resources and needs no props. ## Example @@ -22,12 +23,13 @@ examples). A `useAccountButtonController()` that reads live Clerk resources is a ## Usage -The all-in-one `AccountButton` renders the trigger and popup from a single prop-driven call: +The all-in-one, presentational `AccountButtonView` renders the trigger and popup from a single +prop-driven call (the connected, Clerk-backed `AccountButton` wraps it and needs no props): ```tsx -import { AccountButton } from '@clerk/ui/mosaic/account/account-button.view'; +import { AccountButtonView } from '@clerk/ui/mosaic/account/account-button.view'; -) { return ( - ) { export function Personal(_args: Record) { return ( - ) { export function MultipleAccounts(_args: Record) { return ( - } | null; +let organization: { id: string } | null; +let membershipRequests: { count: number }; +let userMemberships: { data: unknown[]; count: number; revalidate: ReturnType }; +let userInvitations: { data: unknown[]; count: number; revalidate: ReturnType }; +let userSuggestions: { data: unknown[]; count: number; revalidate: ReturnType }; +let signedInSessions: FakeSession[]; + +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, membershipRequests }), + useOrganizationList: () => ({ userMemberships, userInvitations, userSuggestions }), + useClerk: () => ({ + navigate, + setActive, + signOut, + buildUserProfileUrl: () => '/user-profile', + buildOrganizationProfileUrl: () => '/org-profile', + buildCreateOrganizationUrl: () => '/create-org', + buildSignInUrl: () => '/sign-in', + client: { signedInSessions }, + __internal_environment: { + displayConfig: { + afterCreateOrganizationUrl: '/after-create', + afterSwitchSessionUrl: '/after-switch', + }, + }, + }), + }; +}); + +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 } }; +} + +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' }; + membershipRequests = { count: 4 }; + userMemberships = { + data: [membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], + count: 2, + revalidate: vi.fn().mockResolvedValue(undefined), + }; + userInvitations = { + data: [acceptable('inv_1', 'org_3', 'Gamma')], + count: 1, + revalidate: vi.fn().mockResolvedValue(undefined), + }; + userSuggestions = { + data: [acceptable('sug_1', 'org_2', 'Beta')], + count: 1, + revalidate: vi.fn().mockResolvedValue(undefined), + }; + 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() { + const c = useAccountButtonController(); + if (c.status !== 'ready') { + return {c.status}; + } + return ( +
+ {c.status} + {c.activeAccount.name} + {c.activeAccount.email} + {c.activeAccount.sessionId} + {c.activeAccount.userId} + {String(c.activeOrganizationId)} + {String(c.hasOrganizations)} + {c.additionalAccounts.map(a => a.userId).join(',')} + {JSON.stringify(c.memberships)} + {JSON.stringify(c.suggestions)} + {JSON.stringify(c.invitations)} + + + + + + + + + + + +
+ ); +} + +function memberships() { + return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]'); +} + +describe('useAccountButtonController', () => { + 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'); + expect(screen.getByTestId('active-user')).toHaveTextContent('user_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 = { data: [membership('org_1', 'Acme', 3)], count: 0, revalidate: vi.fn() }; + const { rerender } = render(); + expect(screen.getByTestId('has-orgs')).toHaveTextContent('false'); + + userMemberships = { data: [], count: 5, revalidate: vi.fn() }; + rerender(); + expect(screen.getByTestId('has-orgs')).toHaveTextContent('true'); + }); + + it('excludes the active user session from additionalAccounts and includes the others', () => { + render(); + expect(screen.getByTestId('additional')).toHaveTextContent('user_2'); + expect(screen.getByTestId('additional')).not.toHaveTextContent('user_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('populates membershipRequestCount only on the active org row and only with manage permission', () => { + const { rerender } = render(); + let rows = memberships(); + expect(rows[0]).toMatchObject({ organizationId: 'org_1', membershipRequestCount: 4 }); + expect(rows[1].membershipRequestCount).toBeUndefined(); + expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_memberships:manage' }); + + checkAuthorization.mockReturnValue(false); + rerender(); + rows = memberships(); + expect(rows[0].membershipRequestCount).toBeUndefined(); + expect(rows[1].membershipRequestCount).toBeUndefined(); + }); + + it('selects an organization via setActive with a redirect, and personal via a null organization', () => { + render(); + + fireEvent.click(screen.getByText('select-org')); + expect(setActive).toHaveBeenCalledWith( + expect.objectContaining({ organization: 'org_9', redirectUrl: '/after-create' }), + ); + + fireEvent.click(screen.getByText('select-personal')); + expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ organization: null })); + }); + + it('switches and signs out sessions via setActive and signOut', () => { + render(); + + fireEvent.click(screen.getByText('switch')); + expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' })); + + fireEvent.click(screen.getByText('sign-out-one')); + expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2' }); + + fireEvent.click(screen.getByText('sign-out-all')); + expect(signOut).toHaveBeenCalledWith(); + }); + + it('navigates for manage and create 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('manage-members')); + expect(navigate).toHaveBeenCalledWith('/org-profile'); + + fireEvent.click(screen.getByText('create-org')); + expect(navigate).toHaveBeenCalledWith('/create-org'); + }); + + 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/account/account-button.controller.tsx b/packages/ui/src/mosaic/account/account-button.controller.tsx new file mode 100644 index 00000000000..500d4f240f7 --- /dev/null +++ b/packages/ui/src/mosaic/account/account-button.controller.tsx @@ -0,0 +1,140 @@ +import { useClerk, useOrganization, useOrganizationList, useSession, useUser } from '@clerk/shared/react'; +import type { UserResource } from '@clerk/shared/types'; + +import { organizationListParams } from '../../components/OrganizationSwitcher/utils'; +import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment'; +import { useMosaicRouter } from '../hooks/useMosaicRouter'; +import type { + AccountButtonAccount, + AccountButtonCallbacks, + AccountButtonData, + AccountButtonInvitation, + AccountButtonMembership, + AccountButtonSuggestion, +} from './account-button.view'; + +export type AccountButtonController = + | { status: 'loading' } + | { status: 'hidden' } + | (AccountButtonData & AccountButtonCallbacks & { status: 'ready' }); + +const MANAGE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; + +function accountName(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 toAccount(sessionId: string, user: UserResource): AccountButtonAccount { + return { + sessionId, + userId: user.id, + name: accountName(user), + email: user.primaryEmailAddress?.emailAddress ?? '', + imageUrl: user.imageUrl, + }; +} + +export function useAccountButtonController(): AccountButtonController { + const { isLoaded: isUserLoaded, user } = useUser(); + const { isLoaded: isSessionLoaded, session } = useSession(); + + // The session resolves before the org fetch, so gate the membership-requests + // fetch on the manage permission to avoid requesting a count we cannot show. + const canManageMembers = session?.checkAuthorization({ permission: MANAGE_MEMBERS_PERMISSION }) ?? false; + const { + isLoaded: isOrgLoaded, + organization, + membershipRequests, + } = useOrganization({ membershipRequests: canManageMembers ? true : undefined }); + const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams); + + const clerk = useClerk(); + const router = useMosaicRouter(); + const displayConfig = useMosaicEnvironment()?.displayConfig; + + if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded) { + return { status: 'loading' }; + } + + if (!user || !session) { + return { status: 'hidden' }; + } + + const activeOrganizationId = organization?.id ?? null; + const membershipData = userMemberships.data ?? []; + const suggestionData = userSuggestions.data ?? []; + const invitationData = userInvitations.data ?? []; + + const memberships: AccountButtonMembership[] = membershipData.map(m => ({ + kind: 'membership', + organizationId: m.organization.id, + name: m.organization.name, + imageUrl: m.organization.imageUrl || undefined, + membersCount: m.organization.membersCount, + membershipRequestCount: + canManageMembers && m.organization.id === activeOrganizationId ? membershipRequests?.count : undefined, + })); + + const suggestions: AccountButtonSuggestion[] = 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: AccountButtonInvitation[] = invitationData.map(i => ({ + kind: 'invitation', + id: i.id, + organizationId: i.publicOrganizationData.id, + organizationName: i.publicOrganizationData.name, + imageUrl: i.publicOrganizationData.imageUrl || undefined, + })); + + const additionalAccounts: AccountButtonAccount[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { + const sessionUser = s.user; + if (!sessionUser || sessionUser.id === user.id) { + return []; + } + return [toAccount(s.id, sessionUser)]; + }); + + return { + status: 'ready', + activeAccount: toAccount(session.id, user), + activeOrganizationId, + hasOrganizations: (userMemberships.count ?? 0) > 0, + memberships, + suggestions, + invitations, + additionalAccounts, + onSelectOrganization: organizationId => + clerk.setActive({ organization: organizationId, redirectUrl: displayConfig?.afterCreateOrganizationUrl }), + onSelectPersonal: () => clerk.setActive({ organization: null }), + onSwitchAccount: sessionId => + clerk.setActive({ session: sessionId, redirectUrl: displayConfig?.afterSwitchSessionUrl }), + onSignOutSession: sessionId => clerk.signOut({ sessionId }), + onSignOutAll: () => clerk.signOut(), + onManageAccount: () => router.navigate(clerk.buildUserProfileUrl()), + onManageOrganization: () => router.navigate(clerk.buildOrganizationProfileUrl()), + onManageMembers: () => router.navigate(clerk.buildOrganizationProfileUrl()), + onCreateOrganization: () => router.navigate(clerk.buildCreateOrganizationUrl()), + onAddAccount: () => 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/account/account-button.tsx b/packages/ui/src/mosaic/account/account-button.tsx new file mode 100644 index 00000000000..79ffea4fcbe --- /dev/null +++ b/packages/ui/src/mosaic/account/account-button.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useState } from 'react'; + +import { useAccountButtonController } from './account-button.controller'; +import { AccountButtonView } from './account-button.view'; + +/** + * The connected AccountButton: reads live Clerk data through `useAccountButtonController` and renders + * the presentational `AccountButtonView`. Owns the popover open state and closes it after a successful + * one-shot action (select/switch/sign out/accept). Actions that open another surface + * (manage/create navigations) leave the popover as-is. + */ +export function AccountButton() { + const controller = useAccountButtonController(); + const [open, setOpen] = useState(false); + + if (controller.status !== 'ready') { + return null; + } + + const close = () => setOpen(false); + const closeOnSuccess = (fn?: (...args: Args) => void) => + fn ? (...args: Args) => void Promise.resolve(fn(...args)).finally(close) : undefined; + + const { status, ...data } = controller; + + return ( + + ); +} diff --git a/packages/ui/src/mosaic/account/account-button.view.tsx b/packages/ui/src/mosaic/account/account-button.view.tsx index e5d83a9a8f9..22a11c41943 100644 --- a/packages/ui/src/mosaic/account/account-button.view.tsx +++ b/packages/ui/src/mosaic/account/account-button.view.tsx @@ -895,8 +895,11 @@ export function AccountButtonPopup() { export type AccountButtonProps = Omit; -/** All-in-one: renders the trigger + popup from a single prop-driven call. The headline v1 API. */ -export function AccountButton(props: AccountButtonProps) { +/** + * Presentational all-in-one: renders the trigger + popup from a single prop-driven call. The + * connected, Clerk-backed `AccountButton` lives in `account-button.tsx` and wraps this view. + */ +export function AccountButtonView(props: AccountButtonProps) { return (