From d61f9672d854fe19694c70c765185425d396072c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 10 Jul 2026 16:52:53 -0400 Subject: [PATCH] feat(ui): expose composed profile API via @clerk/ui/experimental --- .changeset/profile-section-components.md | 5 + integration/tests/composed-components.test.ts | 456 ++++++++++++++++++ packages/ui/package.json | 5 + packages/ui/src/composed/index.ts | 2 + .../__tests__/flat-exports.test.ts | 61 +++ packages/ui/src/experimental/index.ts | 13 + packages/ui/tsdown.config.mts | 1 + 7 files changed, 543 insertions(+) create mode 100644 .changeset/profile-section-components.md create mode 100644 integration/tests/composed-components.test.ts create mode 100644 packages/ui/src/composed/index.ts create mode 100644 packages/ui/src/experimental/__tests__/flat-exports.test.ts create mode 100644 packages/ui/src/experimental/index.ts diff --git a/.changeset/profile-section-components.md b/.changeset/profile-section-components.md new file mode 100644 index 00000000000..de5db077940 --- /dev/null +++ b/.changeset/profile-section-components.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Expose composable `UserProfile` and `OrganizationProfile` subcomponents from `@clerk/ui/experimental` diff --git a/integration/tests/composed-components.test.ts b/integration/tests/composed-components.test.ts new file mode 100644 index 00000000000..d75b6928e04 --- /dev/null +++ b/integration/tests/composed-components.test.ts @@ -0,0 +1,456 @@ +import { type BrowserContext, expect, type Page, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import { PKGLAB } from '../presets/utils'; +import type { FakeOrganization, FakeUser } from '../testUtils'; +import { createTestUtils } from '../testUtils'; +import { stringPhoneNumber } from '../testUtils/phoneUtils'; + +/** + * E2E parity coverage for the experimental composed exports from `@clerk/ui/experimental`. + * + * The composed API (`UserProfileProvider`, `UserProfileAccountPanel`, `UserProfileEmailSection`, + * ...) lets a developer compose the profile UI from flat exports. Its section wrappers render the + * exact same underlying components as the standard `` (e.g. `UserProfileEmailSection` + * -> `AccountEmails` from `AccountSections`), so the trusted `user-profile.test.ts` flows apply + * unchanged. This suite reuses the shared `@clerk/testing` page-object step helpers (`u.po.userProfile.*`) + * and the same assertions as `user-profile.test.ts`, so if a composed export diverges from the + * standard behavior an existing, trusted assertion fails. + * + * `user-profile.test.ts` is intentionally left untouched while this API is experimental. The only + * differences handled locally here are the composed component's lack of chrome: + * - it does not render the `.cl-userProfile-root` wrapper, so the mount wait keys off a rendered + * affordance instead; + * - it has no Account/Security tabs — the page renders both panels, so security sections are + * already present (no `switchToSecurityTab()`). + */ + +// A page composed from the experimental section exports, mirroring the sections the standard +// shows across its Account and Security tabs. +const composedUserProfilePage = () => `'use client'; +import { + UserProfileProvider, + UserProfileAccountPanel, + UserProfileProfileSection, + UserProfileUsernameSection, + UserProfileEmailSection, + UserProfilePhoneSection, + UserProfileSecurityPanel, + UserProfilePasswordSection, + UserProfileMfaSection, + UserProfileDeleteSection, +} from '@clerk/ui/experimental'; + +export default function Page() { + return ( + + + + + + + + + + + + + + ); +}`; + +const provider = () => `'use client' +import { ClerkProvider } from "@clerk/nextjs"; + +export function Provider({ children }: { children: any }) { + return ( + + {children} + + ) +}`; + +const layout = () => `import './globals.css'; +import { Inter } from 'next/font/google'; +import { Provider } from './provider'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +}`; + +test.describe('composed UserProfile exports @generic', () => { + test.describe.configure({ mode: 'serial' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + app = await appConfigs.next.appRouter + .clone() + // The composed exports are bundled into the app (they run in the host React tree and read the + // loaded clerk instance via useClerk()), so the app depends on @clerk/ui directly. + .addDependency('@clerk/ui', PKGLAB) + .addFile('src/app/provider.tsx', provider) + .addFile('src/app/layout.tsx', layout) + .addFile('src/app/composed/user/page.tsx', composedUserProfilePage) + .commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + const m = createTestUtils({ app }); + fakeUser = m.services.users.createFakeUser({ + withUsername: true, + fictionalEmail: true, + withPhoneNumber: true, + }); + await m.services.users.createBapiUser({ + ...fakeUser, + username: undefined, + phoneNumber: undefined, + }); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + // Sign in as `user` and open the composed profile page. The composed provider renders null until + // the user + environment are loaded and emits no `.cl-userProfile-root`, so we wait on a rendered + // affordance (the profile section's "Update profile" action) rather than the standard root. + const signInAndVisitComposedUserProfile = async (page: Page, context: BrowserContext, user: FakeUser) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password }); + await u.po.expect.toBeSignedIn(); + await u.page.goToRelative('/composed/user'); + await u.page.getByText(/update profile/i).waitFor({ state: 'visible' }); + return u; + }; + + // The flows below reuse the shared `u.po.userProfile` step helpers and mirror the assertions in + // `user-profile.test.ts` exactly; only navigation is composed-specific. + + test('can update the username', async ({ page, context }) => { + const u = await signInAndVisitComposedUserProfile(page, context, fakeUser); + + await u.po.userProfile.clickSetUsername(); + await u.po.userProfile.waitForSectionCardOpened('username'); + await u.po.userProfile.typeUsername(fakeUser.username); + await u.page.getByText(/Save/i).click(); + await u.po.userProfile.waitForSectionCardClosed('username'); + + const username = await u.page.locator('.cl-profileSectionItem__username').innerText(); + expect(username).toContain(fakeUser.username); + }); + + test('can update first and last name', async ({ page, context }) => { + const u = await signInAndVisitComposedUserProfile(page, context, fakeUser); + + await u.po.userProfile.clickToUpdateProfile(); + await u.po.userProfile.waitForSectionCardOpened('profile'); + await u.po.userProfile.typeFirstName('John'); + await u.po.userProfile.typeLastName('Doe'); + await u.page.getByText(/Save/i).click(); + await u.po.userProfile.waitForSectionCardClosed('profile'); + + const fullName = await u.page.locator('.cl-profileSectionItem__profile').innerText(); + expect(fullName).toContain('John Doe'); + }); + + test('can add a new email address', async ({ page, context }) => { + const u = await signInAndVisitComposedUserProfile(page, context, fakeUser); + + await u.po.userProfile.clickAddEmailAddress(); + await u.po.userProfile.waitForSectionCardOpened('emailAddresses'); + const newFakeEmail = `new-${fakeUser.email}`; + await u.po.userProfile.typeEmailAddress(newFakeEmail); + await u.page.getByRole('button', { name: /^add$/i }).click(); + + await u.po.userProfile.enterTestOtpCode(); + + await expect( + u.page.locator('.cl-profileSectionItem__emailAddresses').filter({ hasText: newFakeEmail }), + ).toContainText(newFakeEmail); + }); + + test('can add a new phone number', async ({ page, context }) => { + const u = await signInAndVisitComposedUserProfile(page, context, fakeUser); + + await u.po.userProfile.clickAddPhoneNumber(); + await u.po.userProfile.waitForSectionCardOpened('phoneNumbers'); + await u.po.userProfile.typePhoneNumber(fakeUser.phoneNumber); + await u.page.getByRole('button', { name: /^add$/i }).click(); + + await u.po.userProfile.enterTestOtpCode(); + + const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber); + await expect(u.page.locator('.cl-profileSectionItem__phoneNumbers')).toContainText(formattedPhoneNumber); + }); + + test('can add mfa authentication with a phone number', async ({ page, context }) => { + const u = await signInAndVisitComposedUserProfile(page, context, fakeUser); + + // No Security tab to switch to — the composed security panel is already rendered on the page. + await u.page.getByText(/add two-step verification/i).click(); + await u.page.getByText(/sms code/i).click(); + + const formattedPhoneNumber = stringPhoneNumber(fakeUser.phoneNumber); + await u.page.getByRole('button', { name: formattedPhoneNumber }).click(); + + await u.page.getByText(/sms code verification enabled/i).waitFor({ state: 'visible' }); + }); + + test('can delete the account', async ({ page, context }) => { + const m = createTestUtils({ app }); + const delFakeUser = m.services.users.createFakeUser({ + withUsername: true, + fictionalEmail: true, + withPhoneNumber: true, + }); + await m.services.users.createBapiUser({ + ...delFakeUser, + username: undefined, + phoneNumber: undefined, + }); + + const u = await signInAndVisitComposedUserProfile(page, context, delFakeUser); + + // The delete section is rendered directly in the composed security panel (no tab to switch). + await u.page.getByRole('button', { name: /delete account/i }).click(); + await u.page.locator('input[name=deleteConfirmation]').fill('Delete account'); + await u.page.getByRole('button', { name: /delete account/i }).click(); + + await u.po.expect.toBeSignedOut(); + + const sessionCookieList = (await u.page.context().cookies()).filter(cookie => cookie.name.startsWith('__session')); + expect(sessionCookieList).toHaveLength(0); + }); +}); + +// The composed `OrganizationProfileProvider` renders null until there is an active organization +// (`useOrganization()`). Each test signs in a user with exactly one membership, and the page +// activates it via `setActive` before rendering the composed general sections. The section wrappers +// (`OrganizationProfileProfileSection` / `...LeaveSection` / `...DeleteSection`) render the same +// components as the standard `` general page. +const composedOrganizationProfilePage = () => `'use client'; +import { useEffect } from 'react'; +import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs'; +import { + OrganizationProfileProvider, + OrganizationProfileGeneralPanel, + OrganizationProfileProfileSection, + OrganizationProfileLeaveSection, + OrganizationProfileDeleteSection, +} from '@clerk/ui/experimental'; + +function EnsureActiveOrganization() { + const { setActive } = useClerk(); + const { organization } = useOrganization(); + const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true }); + + useEffect(() => { + if (!isLoaded || organization) return; + const first = userMemberships?.data?.[0]?.organization; + if (first && setActive) { + void setActive({ organization: first.id }); + } + }, [isLoaded, organization, userMemberships, setActive]); + + return null; +} + +export default function Page() { + return ( + <> + + + + + + + + + + ); +}`; + +// The composed `OrganizationProfileSecurityPanel` renders the whole standard security page +// (`OrganizationSecurityPage`) — the SSO overview plus its configuration wizard. Unlike the general +// tab it has no composable sub-sections, so the panel takes no children. It is the composed +// counterpart to the security route the standard `` renders. +const composedOrganizationSecurityPage = () => `'use client'; +import { useEffect } from 'react'; +import { useClerk, useOrganization, useOrganizationList } from '@clerk/nextjs'; +import { + OrganizationProfileProvider, + OrganizationProfileSecurityPanel, +} from '@clerk/ui/experimental'; + +function EnsureActiveOrganization() { + const { setActive } = useClerk(); + const { organization } = useOrganization(); + const { isLoaded, userMemberships } = useOrganizationList({ userMemberships: true }); + + useEffect(() => { + if (!isLoaded || organization) return; + const first = userMemberships?.data?.[0]?.organization; + if (first && setActive) { + void setActive({ organization: first.id }); + } + }, [isLoaded, organization, userMemberships, setActive]); + + return null; +} + +export default function Page() { + return ( + <> + + + + + + ); +}`; + +test.describe('composed OrganizationProfile exports @generic', () => { + test.describe.configure({ mode: 'serial' }); + let app: Application; + let fakeUser: FakeUser; + let fakeOrganization: FakeOrganization; + + test.beforeAll(async () => { + app = await appConfigs.next.appRouter + .clone() + .addDependency('@clerk/ui', PKGLAB) + .addFile('src/app/provider.tsx', provider) + .addFile('src/app/layout.tsx', layout) + .addFile('src/app/composed/organization/page.tsx', composedOrganizationProfilePage) + .addFile('src/app/composed/organization-security/page.tsx', composedOrganizationSecurityPage) + .commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + const m = createTestUtils({ app }); + fakeUser = m.services.users.createFakeUser({ fictionalEmail: true }); + const user = await m.services.users.createBapiUser(fakeUser); + fakeOrganization = await m.services.users.createFakeOrganization(user.id); + }); + + test.afterAll(async () => { + // The delete test removes its own organization; ignore if this one is already gone. + await fakeOrganization.delete().catch(() => {}); + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + // Sign in as `user` and open the composed organization profile page, waiting for the active org's + // "Update profile" affordance (the composed provider renders null until an org is active). + const signInAndVisitComposedOrganizationProfile = async (page: Page, context: BrowserContext, user: FakeUser) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password }); + await u.po.expect.toBeSignedIn(); + await u.page.goToRelative('/composed/organization'); + await u.page.getByText(/update profile/i).waitFor({ state: 'visible' }); + return u; + }; + + test('renders the composed organization general sections', async ({ page, context }) => { + const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser); + + // The active organization's name is surfaced in the profile section, and the danger section + // (delete/leave) renders for the admin. + await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(fakeOrganization.name); + await expect(u.page.getByRole('button', { name: /delete organization/i })).toBeVisible(); + }); + + test('can rename the organization', async ({ page, context }) => { + const u = await signInAndVisitComposedOrganizationProfile(page, context, fakeUser); + + const newName = `${fakeOrganization.name}-renamed`; + await u.page.getByText(/update profile/i).click(); + const nameInput = u.page.getByLabel('Name', { exact: true }); + await nameInput.fill(newName); + await u.page.getByText(/Save/i).click(); + + await expect(u.page.locator('.cl-profileSectionItem__organizationProfile')).toContainText(newName); + + // Assert the mutation actually reached the backend, not just the DOM. + const updated = await u.services.clerk.organizations.getOrganization({ + organizationId: fakeOrganization.organization.id, + }); + expect(updated.name).toBe(newName); + }); + + test('can delete the organization', async ({ page, context }) => { + const m = createTestUtils({ app }); + const delFakeUser = m.services.users.createFakeUser({ fictionalEmail: true }); + const delUser = await m.services.users.createBapiUser(delFakeUser); + const delOrg = await m.services.users.createFakeOrganization(delUser.id); + + try { + const u = await signInAndVisitComposedOrganizationProfile(page, context, delFakeUser); + + await u.page.getByRole('button', { name: /delete organization/i }).click(); + + // The confirmation card requires typing the organization name (its placeholder). + await expect(u.page.getByText(/are you sure you want to delete this organization/i)).toBeVisible(); + await u.page.getByPlaceholder(delOrg.name).fill(delOrg.name); + await u.page.getByRole('button', { name: /delete organization/i }).click(); + + // Unlike the modal component, the composed provider renders null the moment the org is gone + // (useOrganization() -> null), so there is no lingering success screen to assert on. The + // observable parity outcome is that the deletion reached the backend. + await expect + .poll( + async () => { + try { + await u.services.clerk.organizations.getOrganization({ organizationId: delOrg.organization.id }); + return true; + } catch { + return false; + } + }, + { timeout: 15_000 }, + ) + .toBe(false); + } finally { + await delFakeUser.deleteIfExists(); + } + }); + + test('renders the composed organization security page', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await u.page.goToRelative('/composed/organization-security'); + + // The composed security panel renders the same OrganizationSecurityPage the standard component + // shows on its security route: the "Security" page header plus the SSO overview section. This is + // the composed counterpart to the security tab (there are no composable sub-sections here). + await expect(u.page.getByRole('heading', { name: /^security$/i })).toBeVisible(); + await expect(u.page.getByText(/^SSO$/)).toBeVisible(); + await expect(u.page.getByRole('button', { name: /start configuration/i })).toBeVisible(); + }); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 1a3a78b3f30..ed5467208c3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -50,6 +50,11 @@ "import": "./dist/themes/experimental.js", "default": "./dist/themes/experimental.js" }, + "./experimental": { + "types": "./dist/experimental/index.d.ts", + "import": "./dist/experimental/index.js", + "default": "./dist/experimental/index.js" + }, "./themes/shadcn.css": "./dist/themes/shadcn.css", "./register": { "import": { diff --git a/packages/ui/src/composed/index.ts b/packages/ui/src/composed/index.ts new file mode 100644 index 00000000000..67c86cc8e56 --- /dev/null +++ b/packages/ui/src/composed/index.ts @@ -0,0 +1,2 @@ +export * from './UserProfile'; +export * from './OrganizationProfile'; diff --git a/packages/ui/src/experimental/__tests__/flat-exports.test.ts b/packages/ui/src/experimental/__tests__/flat-exports.test.ts new file mode 100644 index 00000000000..c0c82746273 --- /dev/null +++ b/packages/ui/src/experimental/__tests__/flat-exports.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import * as experimental from '../index'; + +/** + * The experimental entry must expose the composed profile API as flat named + * exports (not a namespace object like `UserProfile.Account`). Flat names are + * what let consumers render these inside a React Server Component tree without + * adding their own `'use client'` boundary — each named export of a `'use client'` + * module becomes its own client reference, whereas property access on a namespace + * object does not. + */ + +const EXPECTED_EXPORTS = [ + // Providers + 'UserProfileProvider', + 'OrganizationProfileProvider', + // UserProfile panels + 'UserProfileAccountPanel', + 'UserProfileSecurityPanel', + 'UserProfileBillingPanel', + 'UserProfileAPIKeysPanel', + // UserProfile account sections + 'UserProfileProfileSection', + 'UserProfileUsernameSection', + 'UserProfileEmailSection', + 'UserProfilePhoneSection', + 'UserProfileConnectedAccountsSection', + 'UserProfileEnterpriseAccountsSection', + 'UserProfileWeb3Section', + // UserProfile security sections + 'UserProfilePasswordSection', + 'UserProfilePasskeysSection', + 'UserProfileMfaSection', + 'UserProfileActiveDevicesSection', + 'UserProfileDeleteSection', + // OrganizationProfile panels + 'OrganizationProfileGeneralPanel', + 'OrganizationProfileMembersPanel', + 'OrganizationProfileBillingPanel', + 'OrganizationProfileAPIKeysPanel', + 'OrganizationProfileSecurityPanel', + // OrganizationProfile general sections + 'OrganizationProfileProfileSection', + 'OrganizationProfileDomainsSection', + 'OrganizationProfileLeaveSection', + 'OrganizationProfileDeleteSection', +] as const; + +describe('@clerk/ui/experimental flat exports', () => { + for (const name of EXPECTED_EXPORTS) { + it(`exports ${name} as a component`, () => { + expect(typeof (experimental as Record)[name]).toBe('function'); + }); + } + + it('does not export the compound namespace objects', () => { + expect((experimental as Record).UserProfile).toBeUndefined(); + expect((experimental as Record).OrganizationProfile).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/experimental/index.ts b/packages/ui/src/experimental/index.ts new file mode 100644 index 00000000000..7be81eb1f6a --- /dev/null +++ b/packages/ui/src/experimental/index.ts @@ -0,0 +1,13 @@ +// Public entrypoint for the composed profile API (`@clerk/ui/experimental`). +// +// Each component is a top-level named export (not a property on a namespace +// object) so React Server Components create a client reference for it. That lets +// consumers render these directly in a Server Component tree without adding their +// own `'use client'` boundary — the `'use client'` directive lives on each leaf +// file, and re-exporting forwards those references unchanged. A namespace object +// (`UserProfile.Account`) would expose only the object as a client reference and +// break property access across the RSC boundary. +// +// The export roster is maintained once, in the per-directory `index` files under +// `../composed`; this module just re-exports it. +export * from '../composed'; diff --git a/packages/ui/tsdown.config.mts b/packages/ui/tsdown.config.mts index fcf7e5e089e..f5b8a74e396 100644 --- a/packages/ui/tsdown.config.mts +++ b/packages/ui/tsdown.config.mts @@ -41,6 +41,7 @@ export default defineConfig(({ watch }) => { './src/internal/index.ts', './src/themes/index.ts', './src/themes/experimental.ts', + './src/experimental/index.ts', ], outDir: './dist', unbundle: true,