diff --git a/.changeset/user-button-controller.md b/.changeset/user-button-controller.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-button-controller.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-button-switcher.md b/.changeset/user-button-switcher.md new file mode 100644 index 00000000000..89dce880dbe --- /dev/null +++ b/.changeset/user-button-switcher.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add the `UserButton` Mosaic component: an account and organization switcher that combines multi-session account switching with organization selection, suggestions, and invitations behind a single popover. Exposes the all-in-one `UserButton` plus the composable `UserButtonRoot`, `UserButtonTrigger`, and `UserButtonPopup` parts, and its slots are themeable via `appearance.elements`. diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index d54119bbc35..273efb6e7af 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,9 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { + user: { + 'user-button': dynamic(() => import('../stories/user-button.mdx')), + }, organization: { 'organization-profile': dynamic(() => import('../stories/organization-profile.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 3b1e386414c..fbcce2def3c 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -105,6 +105,12 @@ import { } from '../stories/text.stories'; import { meta as tooltipMeta } from '../stories/tooltip.stories'; import { meta as useDataTableMeta } from '../stories/use-data-table.stories'; +import { + Default as UserButtonDefault, + meta as userButtonMeta, + MultipleSessions as UserButtonMultipleAccounts, + Personal as UserButtonPersonal, +} from '../stories/user-button.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -176,6 +182,13 @@ const itemModule: StoryModule = { Group: ItemGroup, }; +const userButtonModule: StoryModule = { + meta: userButtonMeta, + Default: UserButtonDefault, + Personal: UserButtonPersonal, + MultipleSessions: UserButtonMultipleAccounts, +}; + const headingModule: StoryModule = { meta: headingMeta, Default: HeadingDefault, @@ -216,6 +229,8 @@ const tooltipModule: StoryModule = { meta: tooltipMeta }; const useDataTableModule: StoryModule = { meta: useDataTableMeta }; export const registry: StoryModule[] = [ + // User + userButtonModule, // Organization organizationProfileModule, organizationProfileGeneralPanelModule, diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx new file mode 100644 index 00000000000..fbc2d8bbef3 --- /dev/null +++ b/packages/swingset/src/stories/user-button.mdx @@ -0,0 +1,117 @@ +import * as UserButtonStories from './user-button.stories'; + +# UserButton + +The account & organization switcher that sits behind the user avatar. The active account sits at the +top with its organizations (including any **suggested** workspaces you can Join), and **additional +accounts** are listed below. Hovering the active account reveals **Sign out**; clicking any additional +account switches to it. **Settings / Members** open the combined profile modal, and the surface also +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 +`UserButtonView` is presentational: the parts render from the props on `UserButtonRoot` (mock data +in the examples). The connected `UserButton` wraps it with `useUserButtonController()`, which reads +live Clerk resources and needs no props. + +## Example + + + +## Usage + +The all-in-one, presentational `UserButtonView` renders the trigger and popup from a single +prop-driven call (the connected, Clerk-backed `UserButton` wraps it and needs no props): + +```tsx +import { UserButtonView } from '@clerk/ui/mosaic/user-button/user-button.view'; + + setActive({ organization: id })} + onSelectPersonal={() => setActive({ organization: null })} + onSwitchSession={sessionId => setActive({ session: sessionId })} + onSignOutAll={() => signOut()} +/> +``` + +For layouts that need to drop the popup into their own trigger, compose the parts directly. The data +and callbacks live on `UserButtonRoot` and are read from context by the leaves, so they take no props: + +```tsx +import { + UserButtonRoot, + UserButtonTrigger, + UserButtonPopup, +} from '@clerk/ui/mosaic/user-button/user-button.view'; + + + + + +``` + +The exports are flat (not `UserButton.Trigger`) so each part can declare its own `'use client'` +boundary without forcing the consumer's file to become a client component. + +## States & scenarios + +### Personal (no organizations) + +When the active account has no organizations, the header collapses to a personal layout — +**Manage account / Sign out** — and the organization list is omitted. The trigger still shows the +account avatar and name. + + + +### Personal & workspace + +One account has workspaces (organizations); an additional account is a personal account (no orgs). +Switching to it flips the surface to the personal layout. + + + +## Parts + +| Part | Description | +| ---------------------- | ---------------------------------------------------------------------------- | +| `UserButtonRoot` | Owns the data + callbacks and forwards popover open state to `Popover.Root`. | +| `UserButtonTrigger` | The sidebar trigger: active workspace avatar, name, plan badge, selector. | +| `UserButtonPopup` | The popover surface: header, workspace list, additional accounts, footer. | + +## Styling + +The component is themed with a Mosaic slot recipe (`userButtonRecipe`). Override any slot through +`appearance.elements` — e.g. `{ 'user-button-popup': { borderRadius: 24 } }`. + +| Slot | Description | +| ------------------------------ | ------------------------------------------------------- | +| `user-button-trigger` | The trigger button | +| `user-button-popup` | The popover surface | +| `user-button-header` | Active workspace header | +| `user-button-action` | Header action buttons (Settings / Members) | +| `user-button-group` | A divided section (workspace list, additional accounts) | +| `user-button-item` | A selectable workspace / account row | +| `user-button-avatar` | Org (square) / account (circle) avatar | +| `user-button-inline-button` | Row actions (Join / Accept) | +| `user-button-hover-action` | The hover-revealed Sign out on the active account | +| `user-button-footer` | Sign out of all accounts + branding | diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx new file mode 100644 index 00000000000..0741ed4a2b1 --- /dev/null +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -0,0 +1,113 @@ +/** @jsxImportSource @emotion/react */ +import { type UserButtonProps, UserButtonView } from '@clerk/ui/mosaic/user-button/user-button.view'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './user-button.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserButton', + source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx', +}; + +// The view is presentational, so the fixtures drive every state. All callbacks are wired as +// no-ops purely so each affordance renders (an unhandled action hides its control). +const handlers = { + onSelectOrganization: () => {}, + onSelectPersonal: () => {}, + onAcceptSuggestion: () => {}, + onAcceptInvitation: () => {}, + onSwitchSession: () => {}, + onSignOutSession: () => {}, + onSignOutAll: () => {}, + onManageOrganization: () => {}, + onManageMembers: () => {}, + onManageAccount: () => {}, + onCreateOrganization: () => {}, + onAddAccount: () => {}, + onUpgrade: () => {}, +} satisfies Partial; + +const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' }; + +export function Default(_args: Record) { + return ( + + ); +} + +export function Personal(_args: Record) { + return ( + + ); +} + +export function MultipleSessions(_args: Record) { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 057dbe0237a..07bc31191a1 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -73,6 +73,20 @@ const Ellipsis = glyph( />, ); +const ChevronUp = glyph( + , +); + +const ChevronUpDown = glyph( + , +); + const Plus = glyph( , ); +const Cog = glyph( + , +); + +const Users = glyph( + , +); + + /** Runtime name → glyph map. `Icon`'s `name` prop is typed from these keys. */ export const iconRegistry = { 'chevron-right': ChevronRight, 'chevron-left': ChevronLeft, 'chevron-down': ChevronDown, + 'chevron-up': ChevronUp, + 'chevron-up-down': ChevronUpDown, check: Check, close: Close, ellipsis: Ellipsis, plus: Plus, 'log-out': LogOut, + cog: Cog, + users: Users, } satisfies Record; export type IconName = keyof typeof iconRegistry; diff --git a/packages/ui/src/mosaic/primitives/popover.tsx b/packages/ui/src/mosaic/primitives/popover.tsx new file mode 100644 index 00000000000..e6277b6ffc9 --- /dev/null +++ b/packages/ui/src/mosaic/primitives/popover.tsx @@ -0,0 +1,28 @@ +import type { PopoverPortalProps, PopoverProps } from '@clerk/headless/popover'; +import { Popover as HeadlessPopover } from '@clerk/headless/popover'; +import type { FunctionComponent } from 'react'; + +import { withMosaicSlot } from './withMosaicSlot'; + +/** + * The headless popover parts bridged into mosaic. Each styleable part is wrapped + * with `withMosaicSlot`, which forwards its ref and accepts the per-slot props a + * recipe produces (`css`, `data-cl-slot`, state attrs) — the bridged type is + * inferred, so there is nothing to hand-annotate per part. + * + * The structural parts (`Root`, `Portal`) render no element of their own and + * pass through unchanged; they are cast to their public component types so the + * inferred `Popover` type stays portable (otherwise it references internal + * `@clerk/headless` declaration paths). + */ +export const Popover = { + Root: HeadlessPopover.Root as FunctionComponent, + Trigger: withMosaicSlot(HeadlessPopover.Trigger), + Portal: HeadlessPopover.Portal as FunctionComponent, + Positioner: withMosaicSlot(HeadlessPopover.Positioner), + Popup: withMosaicSlot(HeadlessPopover.Popup), + Arrow: withMosaicSlot(HeadlessPopover.Arrow), + Title: withMosaicSlot(HeadlessPopover.Title), + Description: withMosaicSlot(HeadlessPopover.Description), + Close: withMosaicSlot(HeadlessPopover.Close), +}; 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..6be4951ee0b --- /dev/null +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx @@ -0,0 +1,383 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +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; +} + +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 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 = useUserButtonController(); + if (c.status !== 'ready') { + return {c.status}; + } + return ( +
+ {c.status} + {c.activeSession.name} + {c.activeSession.email} + {c.activeSession.sessionId} + {c.activeSession.userId} + {String(c.activeOrganizationId)} + {String(c.hasOrganizations)} + {c.additionalSessions.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('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'); + 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 additionalSessions 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/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx new file mode 100644 index 00000000000..5a37b70c317 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-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 { + UserButtonSession, + UserButtonCallbacks, + UserButtonData, + UserButtonInvitation, + UserButtonMembership, + UserButtonSuggestion, +} from './user-button.view'; + +export type UserButtonController = + | { status: 'loading' } + | { status: 'hidden' } + | (UserButtonData & UserButtonCallbacks & { status: 'ready' }); + +const MANAGE_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, + userId: user.id, + name: displayName(user), + email: user.primaryEmailAddress?.emailAddress ?? '', + imageUrl: user.imageUrl, + }; +} + +export function useUserButtonController(): UserButtonController { + 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: UserButtonMembership[] = 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: 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, + })); + + const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => { + const sessionUser = s.user; + if (!sessionUser || sessionUser.id === user.id) { + return []; + } + return [toSession(s.id, sessionUser)]; + }); + + return { + status: 'ready', + activeSession: toSession(session.id, user), + activeOrganizationId, + hasOrganizations: (userMemberships.count ?? 0) > 0, + memberships, + suggestions, + invitations, + additionalSessions, + onSelectOrganization: organizationId => + clerk.setActive({ organization: organizationId, redirectUrl: displayConfig?.afterCreateOrganizationUrl }), + onSelectPersonal: () => clerk.setActive({ organization: null }), + onSwitchSession: 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/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx new file mode 100644 index 00000000000..086fe655b79 --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useState } from 'react'; + +import { useUserButtonController } from './user-button.controller'; +import { UserButtonView } from './user-button.view'; + +/** + * The connected UserButton: reads live Clerk data through `useUserButtonController` and renders + * the presentational `UserButtonView`. 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 UserButton() { + const controller = useUserButtonController(); + 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/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx new file mode 100644 index 00000000000..08ca2e564ab --- /dev/null +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -0,0 +1,915 @@ +'use client'; + +import type { PopoverProps } from '@clerk/headless/popover'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { useMosaicTheme } from '../MosaicProvider'; +import { Icon } from '../components/icon'; +import type { IconName } from '../icons/registry'; +import { Popover } from '../primitives/popover'; +import { defineSlotRecipe, useRecipe } from '../slot-recipe'; + +// Prototype accent (Clerk brand purple) for the plan badge and the Upgrade link. The neutral +// Mosaic palette has no accent token yet; swap these for a token once one lands. +const ACCENT = '#6c47ff'; +const ACCENT_SOFT = 'color-mix(in oklab, #6c47ff 14%, transparent)'; + +// ─── Data contract ────────────────────────────────────────────────────────── +// Session-backed, discriminated resource rows. Intended to be 1:1 with a future +// `useUserButtonController()` output so the controller is a drop-in follow-up. + +export interface UserButtonSession { + sessionId: string; + userId: string; + name: string; + email: string; + imageUrl?: string; +} + +export interface UserButtonMembership { + kind: 'membership'; + organizationId: string; + name: string; + imageUrl?: string; + membersCount?: number; + planLabel?: string; + upgradeable?: boolean; + membershipRequestCount?: number; +} + +export interface UserButtonSuggestion { + kind: 'suggestion'; + id: string; + organizationId: string; + name: string; + imageUrl?: string; + status: 'pending' | 'accepted'; +} + +export interface UserButtonInvitation { + kind: 'invitation'; + id: string; + organizationId: string; + organizationName: string; + imageUrl?: string; +} + +export interface UserButtonData { + status: 'loading' | 'ready'; + activeSession: UserButtonSession; + /** `null` => the personal workspace is active. */ + activeOrganizationId: string | null; + /** Explicit; do not derive from `memberships.length`. */ + hasOrganizations: boolean; + memberships: UserButtonMembership[]; + suggestions: UserButtonSuggestion[]; + invitations: UserButtonInvitation[]; + additionalSessions: UserButtonSession[]; +} + +/** All optional. An unhandled action hides (or de-activates) the affordance it drives. */ +export interface UserButtonCallbacks { + onSelectOrganization?: (organizationId: string) => void; + onSelectPersonal?: () => void; + onAcceptSuggestion?: (suggestionId: string) => void; + onAcceptInvitation?: (invitationId: string) => void; + onSwitchSession?: (sessionId: string) => void; + onSignOutSession?: (sessionId: string) => void; + onSignOutAll?: () => void; + onManageOrganization?: () => void; + onManageMembers?: () => void; + onManageAccount?: () => void; + onCreateOrganization?: () => void; + onAddAccount?: () => void; + onUpgrade?: () => void; +} + +type UserButtonContextValue = UserButtonData & UserButtonCallbacks; + +// ─── Recipe ─────────────────────────────────────────────────────────────────── + +export const userButtonRecipe = defineSlotRecipe(theme => ({ + slots: { + trigger: { slot: 'user-button-trigger' }, + triggerName: { slot: 'user-button-trigger-name' }, + triggerBadge: { slot: 'user-button-trigger-badge' }, + popup: { slot: 'user-button-popup' }, + header: { slot: 'user-button-header' }, + headerName: { slot: 'user-button-header-name' }, + headerActions: { slot: 'user-button-header-actions' }, + action: { slot: 'user-button-action' }, + group: { slot: 'user-button-group' }, + groupLabel: { slot: 'user-button-group-label' }, + item: { slot: 'user-button-item' }, + select: { slot: 'user-button-select' }, + name: { slot: 'user-button-name' }, + secondary: { slot: 'user-button-secondary' }, + upgrade: { slot: 'user-button-upgrade' }, + suggestedBadge: { slot: 'user-button-suggested-badge' }, + inlineButton: { slot: 'user-button-inline-button' }, + hoverAction: { slot: 'user-button-hover-action' }, + add: { slot: 'user-button-add' }, + addIcon: { slot: 'user-button-add-icon' }, + footer: { slot: 'user-button-footer' }, + signOutAll: { slot: 'user-button-sign-out-all' }, + branding: { slot: 'user-button-branding' }, + avatar: { slot: 'user-button-avatar' }, + }, + base: { + trigger: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + width: '100%', + minWidth: 0, + padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`, + borderRadius: theme.rounded.md, + background: 'none', + border: 'none', + cursor: 'pointer', + textAlign: 'start', + color: theme.color.cardForeground, + ...theme.text('sm'), + _hover: { backgroundColor: theme.color.muted }, + }, + triggerName: { + fontWeight: theme.font.medium, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + triggerBadge: { + flexShrink: 0, + ...theme.text('xs'), + fontWeight: theme.font.medium, + padding: `${theme.spacing(0.5)} ${theme.spacing(1.5)}`, + borderRadius: theme.rounded.full, + backgroundColor: ACCENT_SOFT, + color: ACCENT, + whiteSpace: 'nowrap', + }, + popup: { + width: '20rem', + maxWidth: 'calc(100vw - 2rem)', + backgroundColor: theme.color.card, + color: theme.color.cardForeground, + border: `1px solid ${theme.color.border}`, + borderRadius: theme.rounded.lg, + boxShadow: '0 10px 30px rgba(0, 0, 0, 0.12)', + overflow: 'hidden', + ...theme.text('sm'), + }, + header: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(3), + padding: theme.spacing(4), + }, + headerName: { + ...theme.text('sm'), + fontWeight: theme.font.semibold, + color: theme.color.cardForeground, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + headerActions: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: theme.spacing(2), + }, + action: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing(2), + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + border: `1px solid ${theme.color.border}`, + background: theme.color.card, + color: theme.color.cardForeground, + ...theme.text('sm'), + fontWeight: theme.font.medium, + cursor: 'pointer', + _hover: { backgroundColor: theme.color.muted }, + }, + group: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + padding: theme.spacing(1.5), + borderTop: `1px solid ${theme.color.border}`, + }, + groupLabel: { + padding: `${theme.spacing(1.5)} ${theme.spacing(2)} ${theme.spacing(1)}`, + ...theme.text('xs'), + fontWeight: theme.font.medium, + color: theme.color.mutedForeground, + }, + item: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(2), + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + position: 'relative', + _hover: { backgroundColor: theme.color.muted }, + // Hover-reveal sign out: hidden but focusable (kept in tab order); revealed on row hover + // or keyboard focus-within — never a bare `opacity: 0` that would strand the focused button. + '& [data-cl-slot="user-button-hover-action"]': { opacity: 0, pointerEvents: 'none' }, + '&:hover [data-cl-slot="user-button-hover-action"], &:focus-within [data-cl-slot="user-button-hover-action"]': + { opacity: 1, pointerEvents: 'auto' }, + }, + select: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(3), + flex: 1, + minWidth: 0, + background: 'none', + border: 'none', + padding: 0, + margin: 0, + font: 'inherit', + color: 'inherit', + textAlign: 'start', + cursor: 'pointer', + }, + name: { + ...theme.text('sm'), + fontWeight: theme.font.medium, + color: theme.color.cardForeground, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + secondary: { + ...theme.text('xs'), + color: theme.color.mutedForeground, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + upgrade: { + background: 'none', + border: 'none', + padding: 0, + font: 'inherit', + cursor: 'pointer', + color: ACCENT, + fontWeight: theme.font.medium, + }, + suggestedBadge: { + flexShrink: 0, + ...theme.text('xs'), + color: theme.color.mutedForeground, + padding: `${theme.spacing(0.5)} ${theme.spacing(1.5)}`, + borderRadius: theme.rounded.sm, + backgroundColor: theme.color.muted, + whiteSpace: 'nowrap', + }, + inlineButton: { + flexShrink: 0, + ...theme.text('xs'), + fontWeight: theme.font.medium, + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + borderRadius: theme.rounded.md, + border: `1px solid ${theme.color.border}`, + background: theme.color.card, + color: theme.color.cardForeground, + cursor: 'pointer', + _hover: { backgroundColor: theme.color.muted }, + }, + hoverAction: { + flexShrink: 0, + ...theme.text('xs'), + fontWeight: theme.font.medium, + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + borderRadius: theme.rounded.md, + border: `1px solid ${theme.color.border}`, + background: theme.color.card, + color: theme.color.cardForeground, + cursor: 'pointer', + transition: 'opacity 120ms ease', + _hover: { backgroundColor: theme.color.muted }, + }, + add: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(3), + width: '100%', + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + background: 'none', + border: 'none', + cursor: 'pointer', + textAlign: 'start', + ...theme.text('sm'), + color: theme.color.mutedForeground, + _hover: { backgroundColor: theme.color.muted }, + }, + addIcon: { + display: 'grid', + placeItems: 'center', + flexShrink: 0, + width: theme.spacing(9), + height: theme.spacing(9), + borderRadius: theme.rounded.full, + backgroundColor: theme.color.muted, + color: theme.color.mutedForeground, + }, + footer: { + display: 'flex', + flexDirection: 'column', + borderTop: `1px solid ${theme.color.border}`, + padding: theme.spacing(1.5), + }, + signOutAll: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(3), + width: '100%', + padding: theme.spacing(2), + borderRadius: theme.rounded.md, + background: 'none', + border: 'none', + cursor: 'pointer', + textAlign: 'start', + ...theme.text('sm'), + color: theme.color.mutedForeground, + _hover: { backgroundColor: theme.color.muted }, + }, + branding: { + marginTop: theme.spacing(1), + padding: theme.spacing(2), + borderTop: `1px solid ${theme.color.border}`, + textAlign: 'center', + ...theme.text('xs'), + color: theme.color.mutedForeground, + }, + avatar: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + overflow: 'hidden', + fontWeight: theme.font.medium, + textTransform: 'uppercase', + lineHeight: 1, + '& > img': { width: '100%', height: '100%', objectFit: 'cover' }, + }, + }, + variants: { + shape: { + square: { + avatar: { + borderRadius: theme.rounded.md, + backgroundColor: theme.color.primary, + color: theme.color.primaryForeground, + }, + }, + circle: { + avatar: { + borderRadius: theme.rounded.full, + backgroundColor: theme.color.muted, + color: theme.color.mutedForeground, + }, + }, + }, + size: { + sm: { avatar: { width: theme.spacing(5), height: theme.spacing(5), ...theme.text('xs') } }, + md: { avatar: { width: theme.spacing(9), height: theme.spacing(9), ...theme.text('sm') } }, + }, + }, + defaultVariants: { shape: 'circle', size: 'md' }, +})); + +declare module '../registry' { + interface MosaicSlotRegistry { + 'user-button-trigger': true; + 'user-button-trigger-name': true; + 'user-button-trigger-badge': true; + 'user-button-popup': true; + 'user-button-header': true; + 'user-button-header-name': true; + 'user-button-header-actions': true; + 'user-button-action': true; + 'user-button-group': true; + 'user-button-group-label': true; + 'user-button-item': true; + 'user-button-select': true; + 'user-button-name': true; + 'user-button-secondary': true; + 'user-button-upgrade': true; + 'user-button-suggested-badge': true; + 'user-button-inline-button': true; + 'user-button-hover-action': true; + 'user-button-add': true; + 'user-button-add-icon': true; + 'user-button-footer': true; + 'user-button-sign-out-all': true; + 'user-button-branding': true; + 'user-button-avatar': true; + } +} + +// ─── Context ──────────────────────────────────────────────────────────────── + +const UserButtonContext = React.createContext(null); + +function useUserButtonContext(): UserButtonContextValue { + const ctx = React.useContext(UserButtonContext); + if (!ctx) { + throw new Error('UserButton compound components must be used within '); + } + return ctx; +} + +function activeMembership(data: UserButtonData): UserButtonMembership | undefined { + if (data.activeOrganizationId === null) { + return undefined; + } + return data.memberships.find(m => m.organizationId === data.activeOrganizationId); +} + +function membershipSubtitle(org: UserButtonMembership): string { + const parts: string[] = []; + if (org.membersCount !== undefined) { + parts.push(`${org.membersCount} members`); + } + if (org.planLabel) { + parts.push(org.planLabel); + } + return parts.join(' · '); +} + +// ─── Presentational leaves ──────────────────────────────────────────────────── + +type AvatarShape = 'square' | 'circle'; + +function Avatar({ + name, + imageUrl, + shape, + size, +}: { + name: string; + imageUrl?: string; + shape: AvatarShape; + size: 'sm' | 'md'; +}) { + const { avatar } = useRecipe(userButtonRecipe, { variants: { shape, size } }); + return ( + + {imageUrl ? ( + + ) : ( + name.trim().charAt(0) + )} + + ); +} + +interface RowProps { + name: string; + secondary?: string; + shape: AvatarShape; + imageUrl?: string; + onSelect?: () => void; + active?: boolean; + badge?: ReactNode; + trailing?: ReactNode; + hoverAction?: ReactNode; +} + +function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, trailing, hoverAction }: RowProps) { + const theme = useMosaicTheme(); + const { item, select, name: nameSlot, secondary: secondarySlot } = useRecipe(userButtonRecipe); + const inner = ( + <> + + + + {name} + {badge} + + {secondary ? {secondary} : null} + + + ); + return ( +
+ {onSelect ? ( + + ) : ( +
+ {inner} +
+ )} + {active ? ( + + ) : null} + {trailing} + {hoverAction} +
+ ); +} + +function SuggestedBadge() { + const { suggestedBadge } = useRecipe(userButtonRecipe); + return Suggested; +} + +function InlineButton({ label, onClick }: { label: string; onClick: () => void }) { + const { inlineButton } = useRecipe(userButtonRecipe); + return ( + + ); +} + +function HoverAction({ onClick }: { onClick: () => void }) { + const { hoverAction } = useRecipe(userButtonRecipe); + return ( + + ); +} + +function AddRow({ label, onClick }: { label: string; onClick: () => void }) { + const theme = useMosaicTheme(); + const { add, addIcon } = useRecipe(userButtonRecipe); + return ( + + ); +} + +// ─── Sections ───────────────────────────────────────────────────────────────── + +interface HeaderAction { + icon: IconName; + label: string; + onClick: () => void; +} + +function Header() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { header, headerName, headerActions, action, secondary, upgrade } = useRecipe(userButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeSession.name; + const image = isOrg ? org.imageUrl : data.activeSession.imageUrl; + + const actions: HeaderAction[] = []; + if (isOrg) { + if (data.onManageOrganization) { + actions.push({ icon: 'cog', label: 'Settings', onClick: data.onManageOrganization }); + } + if (data.onManageMembers) { + actions.push({ icon: 'users', label: 'Members', onClick: data.onManageMembers }); + } + } else { + if (data.onManageAccount) { + actions.push({ icon: 'cog', label: 'Manage account', onClick: data.onManageAccount }); + } + const signOut = data.onSignOutSession; + if (signOut) { + actions.push({ icon: 'log-out', label: 'Sign out', onClick: () => signOut(data.activeSession.sessionId) }); + } + } + + const showUpgrade = isOrg && org.upgradeable === true && data.onUpgrade !== undefined; + const subtitle = isOrg ? membershipSubtitle(org) : data.activeSession.email; + + return ( +
+
+ + + {label} + + {subtitle} + {showUpgrade ? ( + <> + {subtitle ? ' · ' : null} + + + ) : null} + + +
+ {actions.length > 0 ? ( +
+ {actions.map(a => ( + + ))} +
+ ) : null} +
+ ); +} + +function WorkspaceList() { + const data = useUserButtonContext(); + const { group } = useRecipe(userButtonRecipe); + const selectOrg = data.onSelectOrganization; + const acceptSuggestion = data.onAcceptSuggestion; + const acceptInvitation = data.onAcceptInvitation; + const signOutSession = data.onSignOutSession; + + return ( +
+ signOutSession(data.activeSession.sessionId)} /> : undefined + } + /> + {data.memberships.map(m => ( + selectOrg(m.organizationId) : undefined} + active={m.organizationId === data.activeOrganizationId} + /> + ))} + {data.suggestions.map(s => ( + } + trailing={ + acceptSuggestion ? ( + acceptSuggestion(s.id)} + /> + ) : undefined + } + /> + ))} + {data.invitations.map(i => ( + acceptInvitation(i.id)} + /> + ) : undefined + } + /> + ))} + {data.onCreateOrganization ? ( + + ) : null} +
+ ); +} + +function SessionsSection() { + const data = useUserButtonContext(); + const { group, groupLabel } = useRecipe(userButtonRecipe); + const switchSession = data.onSwitchSession; + + if (data.additionalSessions.length === 0 && !data.onAddAccount) { + return null; + } + + return ( +
+ {data.additionalSessions.length > 0 ?
Additional accounts
: null} + {data.additionalSessions.map(a => ( + switchSession(a.sessionId) : undefined} + /> + ))} + {data.onAddAccount ? ( + + ) : null} +
+ ); +} + +function Footer() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { footer, signOutAll, branding } = useRecipe(userButtonRecipe); + return ( +
+ {data.onSignOutAll ? ( + + ) : null} +
Secured by Clerk
+
+ ); +} + +// ─── Public parts ─────────────────────────────────────────────────────────── + +export interface UserButtonRootProps extends UserButtonData, UserButtonCallbacks { + children: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: PopoverProps['placement']; + sideOffset?: number; +} + +/** + * Owns the account/organization data + callbacks and forwards the popover's open state straight to + * the headless `Popover.Root` — it does not keep a second controllable-state copy. Leaves consume + * the data through context. + */ +export function UserButtonRoot(props: UserButtonRootProps) { + const { children, open, defaultOpen, onOpenChange, placement, sideOffset, ...data } = props; + return ( + + {children} + + ); +} + +/** The sidebar trigger: active workspace avatar + name (+ plan badge for orgs) and a selector icon. */ +export function UserButtonTrigger() { + const theme = useMosaicTheme(); + const data = useUserButtonContext(); + const { trigger, triggerName, triggerBadge } = useRecipe(userButtonRecipe); + const org = activeMembership(data); + const isOrg = org !== undefined; + const label = isOrg ? org.name : data.activeSession.name; + const image = isOrg ? org.imageUrl : data.activeSession.imageUrl; + + return ( + ( + + )} + /> + ); +} + +/** The popover surface: header, workspace list, additional accounts, and footer. */ +export function UserButtonPopup() { + const data = useUserButtonContext(); + const { popup } = useRecipe(userButtonRecipe); + return ( + + + +
+ {data.hasOrganizations ? : null} + +