diff --git a/.changeset/mosaic-user-profile-export.md b/.changeset/mosaic-user-profile-export.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-export.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx index 3656841dc26..8fc90b59a4a 100644 --- a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx @@ -4,7 +4,7 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic'; export default function Page() { return ( - + + ); diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx new file mode 100644 index 00000000000..3efd034696b --- /dev/null +++ b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { MosaicProvider, OrganizationProfile } from '@clerk/mosaic'; + +export default function Page() { + return ( + + custom-page-content

}]} + /> +
+ ); +} diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx index 401ff9bcefc..876c036cf59 100644 --- a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx @@ -4,7 +4,7 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic'; export default function Page() { return ( - + + custom-page-content

}]} /> +
+ ); +} diff --git a/integration/tests/mosaic/organization-profile.test.ts b/integration/tests/mosaic/organization-profile.test.ts new file mode 100644 index 00000000000..b4d0fecb7a3 --- /dev/null +++ b/integration/tests/mosaic/organization-profile.test.ts @@ -0,0 +1,82 @@ +import type { Organization } from '@clerk/backend'; +import type { BrowserContext, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic OrganizationProfile @mosaic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let organization: Organization; + const cleanups: Array<() => Promise> = []; + + test.beforeAll(async () => { + const u = await createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + cleanups.push(() => fakeUser.deleteIfExists()); + const user = await u.services.users.createBapiUser(fakeUser); + organization = await u.services.clerk.organizations.createOrganization({ + name: `Mosaic ${Date.now()}`, + createdBy: user.id, + }); + cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id)); + }); + + test.afterAll(async () => { + const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())); + await app.teardown(); + const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : [])); + if (failures.length > 0) { + throw new AggregateError(failures, 'Mosaic OrganizationProfile cleanup failed'); + } + }); + + const path = '/organization-profile'; + const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true }); + + async function signIn({ page, context }: { page: Page; context: BrowserContext }) { + const u = await createTestUtils({ app, page, context }); + await page.setViewportSize({ width: 1280, height: 800 }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.goToRelative(path); + await u.po.expect.toBeSignedIn(); + await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id); + await expect(tab(page, 'General')).toBeVisible(); + return u; + } + + test('renders nothing when signed out', async ({ page, context }) => { + const u = await createTestUtils({ app, page, context }); + await u.page.goToRelative(path); + await u.page.waitForClerkJsLoaded(); + + await expect(tab(page, 'General')).toHaveCount(0); + }); + + test('opens on the general page with the active organization', async ({ page, context }) => { + await signIn({ page, context }); + + await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(organization.name).first()).toBeVisible(); + }); + + test('switches to the members page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Members').click(); + + await expect(tab(page, 'Members')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(fakeUser.email)).toBeVisible(); + }); + + test('renders a custom page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Custom page').click(); + await expect(page.getByText('custom-page-content')).toBeVisible(); + }); +}); diff --git a/integration/tests/mosaic/organization-switcher.test.ts b/integration/tests/mosaic/organization-switcher.test.ts index 0b091106da6..c19fa9f8e9c 100644 --- a/integration/tests/mosaic/organization-switcher.test.ts +++ b/integration/tests/mosaic/organization-switcher.test.ts @@ -86,13 +86,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })( }); test('renders a custom page inside the OrganizationProfile modal', async ({ page, context }) => { + await page.setViewportSize({ width: 1280, height: 800 }); await signIn({ page, context }); await trigger(page).click(); await popup(page).getByRole('button', { name: 'Settings' }).click(); - const profile = page.locator('.cl-organizationProfile-root'); - await profile.getByText('Custom page').click(); + await expect(page.getByRole('tab', { name: 'General', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.cl-organizationProfile-root')).toHaveCount(0); + await page.getByRole('tab', { name: 'Custom page', exact: true }).click(); await expect(page.getByText('custom-page-content')).toBeVisible(); }); }, diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts index c4d5e6ed310..2f0f51065ef 100644 --- a/integration/tests/mosaic/user-button.test.ts +++ b/integration/tests/mosaic/user-button.test.ts @@ -49,7 +49,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User async function openProfileSettings(page: Page) { await popup(page).getByRole('button', { name: 'Settings' }).click(); const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' }); - await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor(); + const profile = page.getByRole('tab', { name: 'Account', exact: true }); + await profileSettings.or(profile).first().waitFor(); if (await profileSettings.isVisible()) { await profileSettings.click(); } @@ -101,14 +102,26 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User await expect(page.getByText('custom-link-target')).toBeVisible(); }); + test('opens the Mosaic UserProfile in a modal', async ({ page, context }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await signIn({ page, context }); + + await trigger(page).click(); + await openProfileSettings(page); + + await expect(page.getByRole('tab', { name: 'Account', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.cl-userProfile-root')).toHaveCount(0); + await expect(popup(page)).toBeHidden(); + }); + test('renders a custom page inside the UserProfile modal', async ({ page, context }) => { - const u = await signIn({ page, context }, '/custom'); + await page.setViewportSize({ width: 1280, height: 800 }); + await signIn({ page, context }, '/custom'); await trigger(page).click(); await openProfileSettings(page); - await u.po.userProfile.waitForUserProfileModal(); - await page.locator('.cl-userProfile-root').getByText('Custom page').click(); + await page.getByRole('tab', { name: 'Custom page', exact: true }).click(); await expect(page.getByText('custom-page-content')).toBeVisible(); }); }); diff --git a/integration/tests/mosaic/user-profile.test.ts b/integration/tests/mosaic/user-profile.test.ts new file mode 100644 index 00000000000..6d89555bf08 --- /dev/null +++ b/integration/tests/mosaic/user-profile.test.ts @@ -0,0 +1,82 @@ +import type { Organization } from '@clerk/backend'; +import type { BrowserContext, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic UserProfile @mosaic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let organization: Organization; + const cleanups: Array<() => Promise> = []; + + test.beforeAll(async () => { + const u = await createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + cleanups.push(() => fakeUser.deleteIfExists()); + const user = await u.services.users.createBapiUser(fakeUser); + organization = await u.services.clerk.organizations.createOrganization({ + name: `Mosaic ${Date.now()}`, + createdBy: user.id, + }); + cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id)); + }); + + test.afterAll(async () => { + const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())); + await app.teardown(); + const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : [])); + if (failures.length > 0) { + throw new AggregateError(failures, 'Mosaic UserProfile cleanup failed'); + } + }); + + const path = '/user-profile'; + const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true }); + + async function signIn({ page, context }: { page: Page; context: BrowserContext }) { + const u = await createTestUtils({ app, page, context }); + await page.setViewportSize({ width: 1280, height: 800 }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.goToRelative(path); + await u.po.expect.toBeSignedIn(); + await expect(tab(page, 'Account')).toBeVisible(); + return u; + } + + test('renders nothing when signed out', async ({ page, context }) => { + const u = await createTestUtils({ app, page, context }); + await u.page.goToRelative(path); + await u.page.waitForClerkJsLoaded(); + + await expect(tab(page, 'Account')).toHaveCount(0); + }); + + test('opens on the account page with the signed-in user', async ({ page, context }) => { + await signIn({ page, context }); + + await expect(tab(page, 'Account')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(fakeUser.email)).toBeVisible(); + }); + + test("lists the active organization's pages after the account's", async ({ page, context }) => { + await signIn({ page, context }); + + await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id); + + await tab(page, 'General').click(); + await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(organization.name).first()).toBeVisible(); + }); + + test('renders a custom page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Custom page').click(); + await expect(page.getByText('custom-page-content')).toBeVisible(); + }); +}); diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index 4ed0152569b..933e4dbffca 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -4,6 +4,7 @@ import { act, render } from '@testing-library/react'; import type { ReactElement } from 'react'; import { vi } from 'vitest'; +import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental'; import type { MosaicLocalization } from '../../localization'; import { MosaicProvider } from '../../mosaic-provider'; import { publishableKey } from './fake-fapi'; @@ -12,6 +13,7 @@ export async function renderWithClerk( ui: ReactElement, options?: Parameters[0], localization?: MosaicLocalization, + experimental?: MosaicExperimental, ) { const clerk = new Clerk(publishableKey()); const navigate = vi.fn((_to: string) => Promise.resolve()); @@ -21,7 +23,12 @@ export async function renderWithClerk( clerk={clerk} clerkStatus={clerk.status} > - {element} + + {element} + ); let current = ui; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx new file mode 100644 index 00000000000..b7ff553c1d7 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx @@ -0,0 +1,27 @@ +import { useOrganization } from '@clerk/shared/react'; +import React from 'react'; + +import { OrganizationProfile } from './organization-profile'; +import type { OrganizationProfileModalContentProps } from './organization-profile.modal.types'; + +export default function OrganizationProfileModalContent({ + options, + close, +}: OrganizationProfileModalContentProps): React.ReactElement { + const { isLoaded, organization } = useOrganization(); + const inactive = isLoaded && !organization; + + React.useEffect(() => { + if (inactive) { + close(); + } + }, [inactive, close]); + + const { page, ...props } = options ?? {}; + return ( + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts new file mode 100644 index 00000000000..c9b211967fc --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts @@ -0,0 +1,8 @@ +import { defineModalSurface } from '../../modals/define-modal-surface'; +import type { OrganizationProfileModalOptions } from './organization-profile.modal.types'; + +export const organizationProfileModal = defineModalSurface({ + id: 'organizationProfile', + variant: 'profile', + load: () => import('./organization-profile.modal-content'), +}); diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 278fd3d049e..36ba0bc99fa 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -1,7 +1,9 @@ import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { openDialog } from '../../../__tests__/feature/dialog'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, @@ -373,3 +375,38 @@ describe('OrganizationSwitcher custom trigger badges', () => { expect(trigger()).toHaveAccessibleName('Open organization menu for Other'); }); }); + +describe('OrganizationSwitcher with experimental.newProfiles', () => { + async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); + serveFapi(noOffers()); + return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true }); + } + + it('opens the OrganizationProfile, without the account pages, from Settings', async () => { + const { clerk } = await renderWithNewProfiles(); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile'); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull(); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => { + const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' }); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), { + role: 'alertdialog', + }); + await user.type(within(dialog).getByRole('textbox'), 'Acme'); + await user.click(within(dialog).getByRole('button', { name: 'Leave organization' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left')); + }); +}); diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index 413cc0bf287..26b51fd4ffd 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -12,9 +12,15 @@ import type { import { populateParamFromObject } from '@clerk/shared/url'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { useMosaicExperimental } from '../../hooks/use-mosaic-experimental'; import { useMosaicRouter } from '../../hooks/use-mosaic-router'; +import { useModal } from '../../modals/use-modal'; import { useGetPortalContainer } from '../../primitives/portal'; import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages'; +import { organizationProfileModal } from '../organization-profile/organization-profile.modal'; +import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types'; +import { userProfileModal } from '../user-profile/user-profile.modal'; +import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types'; import type { SwitcherBrandingProps, SwitcherCallbacks, @@ -111,6 +117,14 @@ export type OrganizationSwitcherModelOptions = OrganizationProfileMode & export interface SwitcherModalProps { userProfile?: Pick; organizationProfile?: Pick; + /** What Mosaic's own profiles take when `experimental.newProfiles` opens them instead. */ + newProfiles?: { + userProfile?: Pick< + UserProfileModalOptions, + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' + >; + organizationProfile?: Pick; + }; } function resolveAfterUrl( @@ -126,6 +140,10 @@ function resolveAfterUrl( return undefined; } +function opensModal(url: string | undefined, mode: 'navigation' | 'modal' | undefined): boolean { + return (mode ?? (url ? 'navigation' : 'modal')) === 'modal'; +} + /** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */ function openOrNavigate({ url, @@ -140,8 +158,7 @@ function openOrNavigate({ buildUrl: () => string; navigate: (to: string) => unknown; }): () => void { - const resolved = mode ?? (url ? 'navigation' : 'modal'); - return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal(); + return opensModal(url, mode) ? () => openModal() : () => void navigate(url ?? buildUrl()); } const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; @@ -173,8 +190,10 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession { } /** - * @param modals - Props forwarded to the profile modals. Custom pages arrive already bridged, since only - * the wrapper can render the portals behind them. + * @param modals - Props forwarded to the profile modals. Custom pages for Clerk's arrive already bridged, + * since only the wrapper can render the portals behind them. With `experimental.newProfiles`, a + * `combined` user profile also carries the organization's pages, so managing the organization opens it + * on them. */ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel { const { isLoaded: isUserLoaded, user } = useUser(); @@ -185,6 +204,9 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch }); const clerk = useClerk(); const router = useMosaicRouter(); + const userProfile = useModal(userProfileModal); + const organizationProfile = useModal(organizationProfileModal); + const { newProfiles } = useMosaicExperimental(); // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. const getContainer = useGetPortalContainer(); const environment = useMosaicEnvironment(); @@ -196,10 +218,20 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch enabled: Boolean(environment?.organizationSettings.enabled), }); + const afterLeaveOrganizationUrl = options?.afterLeaveOrganizationUrl; + const mosaicUserProfile = modals?.newProfiles?.userProfile; + const mosaicOrganizationProfile = { ...modals?.newProfiles?.organizationProfile, afterLeaveOrganizationUrl }; + const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer, ...modals?.userProfile }), + openModal: () => + newProfiles + ? userProfile.open( + { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile }, + getContainer?.(), + ) + : clerk.openUserProfile({ getContainer, ...modals?.userProfile }), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); @@ -207,16 +239,28 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const manageOrganization = openOrNavigate({ url: options?.organizationProfileUrl, mode: options?.organizationProfileMode, - openModal: () => - clerk.openOrganizationProfile({ - getContainer, - ...modals?.organizationProfile, - afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl, - }), + openModal: () => { + if (!newProfiles) { + clerk.openOrganizationProfile({ getContainer, ...modals?.organizationProfile, afterLeaveOrganizationUrl }); + } else if (mosaicUserProfile?.mode === 'combined') { + userProfile.open( + { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile, page: 'organization/general' }, + getContainer?.(), + ); + } else { + organizationProfile.open(mosaicOrganizationProfile, getContainer?.()); + } + }, buildUrl: () => clerk.buildOrganizationProfileUrl(), navigate: router.navigate, }); + const managesBothInOneProfile = + newProfiles && + mosaicUserProfile?.mode === 'combined' && + opensModal(options?.userProfileUrl, options?.userProfileMode) && + opensModal(options?.organizationProfileUrl, options?.organizationProfileMode); + const createOrganization = openOrNavigate({ url: options?.createOrganizationUrl, mode: options?.createOrganizationMode, @@ -348,7 +392,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch // 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, + onManageOrganization: managesBothInOneProfile ? undefined : 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. diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index aac782c9ad1..bd5694e8124 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -3,7 +3,7 @@ import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; -import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types'; +import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; @@ -106,6 +106,19 @@ export function Switcher( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. appearance: organizationProfileProps?.appearance, }, + newProfiles: { + userProfile: { + customPages: pagesOnly(userProfileProps?.customPages), + pageOrder: userProfileProps?.pageOrder, + additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, + apiKeysProps: userProfileProps?.apiKeysProps, + mode: mode === 'combined' ? 'combined' : 'user', + }, + organizationProfile: { + customPages: pagesOnly(organizationProfileProps?.customPages), + pageOrder: organizationProfileProps?.pageOrder, + }, + }, }); const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder }); @@ -137,3 +150,7 @@ export function Switcher( ); } + +function pagesOnly(items: CustomProfileItem[] | undefined): CustomProfilePage[] | undefined { + return items?.filter((item): item is CustomProfilePage => item.href === undefined); +} diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx new file mode 100644 index 00000000000..b41f67b573a --- /dev/null +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx @@ -0,0 +1,202 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; + +import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiInvitation, + fapiMembership, + fapiOrganization, + fapiSession, + fapiSuggestion, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { openMenu } from '../../../__tests__/feature/menu'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { PortalProvider } from '../../../primitives/portal'; +import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures'; +import type { UserButtonProps } from '../user-button'; +import { UserButton } from '../user-button'; + +const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 }); +const other = fapiOrganization({ id: 'org_9', name: 'Other' }); +const beta = fapiOrganization({ id: 'org_2', name: 'Beta' }); +const gamma = fapiOrganization({ id: 'org_3', name: 'Gamma' }); + +const aliceMemberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] }), fapiMembership(other)]; + +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + username: 'alice', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], + organization_memberships: aliceMemberships, +}); + +const bob = fapiUser({ + id: 'user_2', + first_name: 'Bob', + last_name: 'Jones', + email_addresses: [fapiEmailAddress({ id: 'idn_bob', email_address: 'bob@example.com' })], +}); + +const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' }); +const bobSession = fapiSession({ id: 'sess_2', user: bob }); + +function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { + return { + client: fapiClient([aliceSession, bobSession]), + memberships: aliceMemberships, + invitations: [fapiInvitation('inv_1', gamma)], + suggestions: [fapiSuggestion('sug_1', beta)], + ...overrides, + }; +} + +function tree(props: UserButtonProps = {}) { + return ( +
+ +
+ ); +} + +type User = ReturnType; + +const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); +const popup = () => screen.queryByRole('dialog', { name: 'Account' }); + +async function open() { + const user = userEvent.setup(); + await user.click(trigger()); + expect(popup()).toBeInTheDocument(); + return user; +} + +async function openSettings(user: User, label: string) { + await openMenu(user, within(requiredPopup()).getByRole('button', { name: 'Settings' })); + await user.click(screen.getByRole('menuitem', { name: label })); +} + +function requiredPopup() { + const surface = popup(); + if (!surface) { + throw new Error('expected the popover to be open'); + } + return surface; +} + +const terms = { label: 'Terms', path: 'terms', content:

Terms body

}; +describe('UserButton with experimental.newProfiles', () => { + const newProfiles = { newProfiles: true }; + + async function renderWithNewProfiles(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); + serveFapi(seed); + return renderWithClerk(tree(props), undefined, undefined, newProfiles); + } + + const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); + + it('opens the combined UserProfile straight from Settings, without a menu, and closes', async () => { + const { clerk, navigate } = await renderWithNewProfiles(); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile'); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(screen.queryByRole('menu')).toBeNull(); + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security', 'General', 'Members']); + expect(openUserProfile).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('opens the profile as a modal, so an OAuth connection returns to it', async () => { + await renderWithNewProfiles({}, withConnectedAccounts([])); + const request = holdRequests('post', '/v1/me/external_accounts'); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + await user.click(await screen.findByRole('button', { name: 'Connect GitHub' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + + const body = new URLSearchParams(await request.requests[0]?.text()); + const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); + expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ + componentName: 'UserProfile', + socialProvider: 'github', + }); + request.fail(); + }); + + it('opens the profile into the portal root the app provides', async () => { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); + const container = document.body.appendChild(document.createElement('div')); + onTestFinished(() => container.remove()); + serveFapi(signedIn()); + await renderWithClerk( + {tree()}, + undefined, + undefined, + newProfiles, + ); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument(); + }); + + it('renders custom pages in their order, and leaves out links', async () => { + await renderWithNewProfiles({ + userProfileProps: { + customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }], + pageOrder: ['terms', 'account'], + }, + }); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Terms body')).toBeVisible(); + expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']); + }); + + it("lists the organization's own pages in order in the combined profile", async () => { + await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']); + }); + + it('still asks which to manage when the organization profile routes to a page', async () => { + const { navigate } = await renderWithNewProfiles({ organizationProfileUrl: '/org' }); + const user = await open(); + + await openSettings(user, 'Manage organization'); + + expect(navigate).toHaveBeenCalledWith('/org'); + }); + + it("leaves the organization's pages out in user mode", async () => { + await renderWithNewProfiles({ mode: 'user' }); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security']); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx new file mode 100644 index 00000000000..c9d35d442de --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx @@ -0,0 +1,25 @@ +import { useUser } from '@clerk/shared/react'; +import React from 'react'; + +import { UserProfileSurface } from './user-profile'; +import type { UserProfileModalContentProps } from './user-profile.modal.types'; + +export default function UserProfileModalContent({ options, close }: UserProfileModalContentProps): React.ReactElement { + const { isLoaded, user } = useUser(); + const signedOut = isLoaded && !user; + + React.useEffect(() => { + if (signedOut) { + close(); + } + }, [signedOut, close]); + + const { page, ...props } = options ?? {}; + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.ts new file mode 100644 index 00000000000..636d8aa504a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal.ts @@ -0,0 +1,8 @@ +import { defineModalSurface } from '../../modals/define-modal-surface'; +import type { UserProfileModalOptions } from './user-profile.modal.types'; + +export const userProfileModal = defineModalSurface({ + id: 'userProfile', + variant: 'profile', + load: () => import('./user-profile.modal-content'), +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index 99e07acb914..d352d11380c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -120,7 +120,17 @@ export interface UserProfileProps * * ``` */ -export function UserProfile({ +export function UserProfile(props: UserProfileProps) { + return ( + + ); +} + +export function UserProfileSurface({ + presentation, mode = 'combined', organizationProfileProps, customPages, @@ -129,7 +139,7 @@ export function UserProfile({ additionalOAuthScopes, fallback, apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {}, -}: UserProfileProps) { +}: UserProfileProps & { presentation: NonNullable }) { const model = useUserProfileUserModel(); const builtInPages = useUserProfilePages(); @@ -156,7 +166,7 @@ export function UserProfile({ return ( {mode === 'user' ? ( diff --git a/packages/mosaic/src/hooks/use-mosaic-experimental.ts b/packages/mosaic/src/hooks/use-mosaic-experimental.ts new file mode 100644 index 00000000000..8acce7161c4 --- /dev/null +++ b/packages/mosaic/src/hooks/use-mosaic-experimental.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +export interface MosaicExperimental { + /** + * Opens Mosaic's own `UserProfile` and `OrganizationProfile` from `UserButton` and + * `OrganizationSwitcher`, instead of Clerk's profile modals. They skip `appearance` and the link + * entries of `customPages`. + * + * @default false + */ + newProfiles?: boolean; +} + +const MosaicExperimentalContext = createContext({}); + +export const MosaicExperimentalProvider = MosaicExperimentalContext.Provider; + +export function useMosaicExperimental(): MosaicExperimental { + return useContext(MosaicExperimentalContext); +} diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index b9690e71e22..ad03bffa48f 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -4,6 +4,10 @@ export type { UserButtonProps } from './features/user-button/user-button'; export { UserButton } from './features/user-button/user-button'; export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher'; export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher'; +export type { OrganizationProfileProps } from './features/organization-profile/organization-profile'; +export { OrganizationProfile } from './features/organization-profile/organization-profile'; +export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile'; +export { UserProfile } from './features/user-profile/user-profile'; export { MosaicProvider } from './mosaic-provider'; export type { PortalContainer, PortalProviderProps } from './primitives/portal'; export { PortalProvider } from './primitives/portal'; diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 7eb3f61e87f..3d7355893ba 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; +import { organizationProfileModal } from './features/organization-profile/organization-profile.modal'; +import { userProfileModal } from './features/user-profile/user-profile.modal'; +import type { MosaicExperimental } from './hooks/use-mosaic-experimental'; +import { MosaicExperimentalProvider } from './hooks/use-mosaic-experimental'; import { MosaicNowProvider } from './hooks/use-now'; import type { MosaicIconOverrides } from './icons/overrides'; import { MosaicIconsProvider } from './icons/overrides'; @@ -19,24 +23,36 @@ export interface MosaicProviderProps { icons?: MosaicIconOverrides; /** Locale and strings for everything Mosaic renders; see `MosaicLocalization`. */ localization?: MosaicLocalization; + /** Features still being tried out. Their names and behavior can change in any release. */ + experimental?: MosaicExperimental; } // Exported Mosaic components annotate a React return type on purpose. The package sets // `jsxImportSource: '@emotion/react'`, so an inferred return leaks Emotion's `JSX.Element` into the // published `.d.ts`, which React 19 consumers reject as a JSX element type. -export function MosaicProvider({ children, direction, icons, localization }: MosaicProviderProps): React.ReactElement { +export function MosaicProvider({ + children, + direction, + icons, + localization, + experimental, +}: MosaicProviderProps): React.ReactElement { const iconsValue = React.useMemo(() => icons ?? {}, [icons]); const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]); const [now] = React.useState(() => new Date()); + const newProfiles = experimental?.newProfiles; + const experimentalValue = React.useMemo(() => ({ newProfiles }), [newProfiles]); return ( - - {children} - + + + {children} + + @@ -44,4 +60,4 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos ); } -const modalSurfaces: ModalSurfaces = []; +const modalSurfaces: ModalSurfaces = [userProfileModal, organizationProfileModal]; diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index 4d9b79a4415..8bf6f87ada0 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -60,6 +60,11 @@ export default defineConfig({ { extends: true, publicDir: 'test/public', + server: { + warmup: { + clientFiles: ['./src/**/*.modal-content.tsx'], + }, + }, optimizeDeps: { include: [ '@clerk/clerk-js', diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx index dcd7d510253..37670fef95f 100644 --- a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx @@ -9,7 +9,7 @@ export default function UserButtonLivePage() { const { isLoaded, isSignedIn } = useUser(); return ( - +

User button

diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index 1e91a2c0bae..4c4eb01c34e 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -219,9 +219,10 @@ all accounts**. The foot keeps **Sign out**. ## Profile modal Mosaic's profile modals are mounted by `MosaicProvider`, so neither the opener nor the app renders a -dialog. The exported UserButton still opens the clerk-js profile. This example backs the modal with -fixture data, so it runs without a Clerk session. The profile is combined: the active organization's -pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls +dialog. With `experimental={{ newProfiles: true }}` on `MosaicProvider`, the exported UserButton opens +Mosaic's `UserProfile` the same way, against the signed-in user; without it, it opens Clerk's profile. +This example backs the modal with fixture data, so it runs without a Clerk session. The profile is +combined: the active organization's pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls `open({ page: 'organization/general' })`, and the app's own buttons call `open({ page })` or `open({ mode: 'user' })` on the same modal. Hovering one preloads the profile, so it opens without a wait.