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/.changeset/shared-moduleManager-registry.md b/.changeset/shared-moduleManager-registry.md new file mode 100644 index 00000000000..a4ea2f1843e --- /dev/null +++ b/.changeset/shared-moduleManager-registry.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/react': patch +--- + +Internal plumbing to support `@clerk/ui` composed `UserProfile` / `OrganizationProfile` subcomponents. 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/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 81714df9bc6..7498dd70973 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -274,6 +274,20 @@ export class Clerk implements ClerkInterface { #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; #publicEventBus = createClerkEventBus(); + #moduleManager = new ModuleManager(); + + /** + * Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone + * from the CDN with its own inlined @clerk/shared, so plain property access + * is the only channel that reliably crosses that boundary. This getter is + * how IsomorphicClerk forwards the manager to consumers that import + * @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui). + * + * @internal + */ + get __internal_moduleManager(): ModuleManager { + return this.#moduleManager; + } get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined { if (!this.#queryClient) { @@ -557,7 +571,7 @@ export class Clerk implements ClerkInterface { () => this, () => this.environment, this.#options, - new ModuleManager(), + this.#moduleManager, ), ); } diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index df2c5920dc0..b2081acd6a6 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -48,6 +48,31 @@ describe('isomorphicClerk', () => { }).not.toThrow(); }); + // Regression: composed/subcomponent UserProfile reads moduleManager via + // `useClerk().__internal_moduleManager`. `useClerk()` returns the + // IsomorphicClerk wrapper, so its getter must chain through to the loaded + // clerk-js's own `__internal_moduleManager`. This plain property access is + // the cross-bundle channel: clerk-js ships standalone from the CDN with its + // own inlined @clerk/shared, so module-scoped state cannot bridge the two. + // + // Without this chain, every dynamic-imported feature (Coinbase Wallet, Base, + // Stripe, zxcvbn) falls back to a rejecting manager. + it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => { + const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' }); + const mm = { import: vi.fn(() => Promise.resolve(undefined)) }; + + // Before clerk-js loads, the getter is undefined so readers fall back. + expect(isomorphicClerk.__internal_moduleManager).toBeUndefined(); + + const innerClerk: any = { + addListener: vi.fn(), + __internal_moduleManager: mm, + }; + (isomorphicClerk as any).replayInterceptedInvocations(innerClerk); + + expect(isomorphicClerk.__internal_moduleManager).toBe(mm); + }); + it('updates props asynchronously after clerkjs has loaded', async () => { const propsHistory: any[] = []; const dummyClerkJS = { diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 15f805e2f9b..e017ab2ddcd 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser'; import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript'; +import type { ModuleManager } from '@clerk/shared/moduleManager'; import type { __internal_AttemptToEnableEnvironmentSettingParams, __internal_AttemptToEnableEnvironmentSettingResult, @@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { windowNavigate(to, { allowedProtocols }); }; + /** + * Proxies to the inner Clerk instance's ModuleManager. Returns `undefined` + * before clerk-js has loaded; composed UI components read this getter + * (via `useClerk()`) to resolve dynamic-imported modules and fall back to a + * rejecting manager while it is `undefined`. + * + * @internal + */ + public get __internal_moduleManager(): ModuleManager | undefined { + return this.clerkjs?.__internal_moduleManager; + } + constructor(options: IsomorphicClerkOptions) { this.#publishableKey = options?.publishableKey; this.#proxyUrl = options?.proxyUrl; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 31fe0610746..6803dac6015 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1,4 +1,6 @@ -import type { ClerkGlobalHookError } from '../errors/globalHookError'; +import type { ClerkGlobalHookError } from '@/errors/globalHookError'; + +import type { ModuleManager } from '../moduleManager'; import type { ClerkUIConstructor } from '../ui/types'; import type { APIKeysNamespace } from './apiKeys'; import type { @@ -139,11 +141,13 @@ export type SDKMetadata = { /** * A callback function that is called when Clerk resources change. + * * @inline */ export type ListenerCallback = (emission: Resources) => void; /** * Optional configuration for the `addListener()` method. + * * @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`. * @inline */ @@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: { /** * A callback that runs after sign out completes. - * @inline */ + * + @inline */ export type SignOutCallback = () => void | Promise; /** @@ -298,6 +303,20 @@ export interface Clerk { */ __internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void; + /** + * Internal handle to the bundled ModuleManager. Exposed so framework SDK + * wrappers (e.g. IsomorphicClerk) can forward it to composed UI components + * that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn). + * Plain property access crosses the bundle boundary that other channels + * cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is + * invisible to consumers loading @clerk/shared from node_modules. It is + * `undefined` on a wrapper whose inner clerk-js has not loaded yet, so + * readers must handle the absent case. + * + * @internal + */ + __internal_moduleManager: ModuleManager | undefined; + frontendApi: string; /** Your Clerk [Publishable Key](!publishable-key). */ @@ -317,6 +336,7 @@ export interface Clerk { /** * Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser. + * * @inline */ isStandardBrowser: boolean | undefined; @@ -350,6 +370,7 @@ export interface Clerk { * `effect()` that can be used to subscribe to changes from Signals. * * @hidden + * * @experimental This experimental API is subject to change. */ __internal_state: State; @@ -398,6 +419,7 @@ export interface Clerk { /** * Closes the Clerk Checkout drawer. + * * @hidden */ __internal_closeCheckout: () => void; @@ -412,6 +434,7 @@ export interface Clerk { /** * Closes the Clerk PlanDetails drawer. + * * @hidden */ __internal_closePlanDetails: () => void; @@ -426,6 +449,7 @@ export interface Clerk { /** * Closes the Clerk SubscriptionDetails drawer. + * * @hidden */ __internal_closeSubscriptionDetails: () => void; @@ -440,12 +464,14 @@ export interface Clerk { /** * Closes the Clerk user verification modal. + * * @hidden */ __internal_closeReverification: () => void; /** * Attempts to enable a environment setting from a development instance, prompting if disabled. + * * @hidden */ __internal_attemptToEnableEnvironmentSetting: ( @@ -454,12 +480,14 @@ export interface Clerk { /** * Opens the Clerk Enable Organizations prompt for development instance + * * @hidden */ __internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void; /** * Closes the Clerk Enable Organizations modal. + * * @hidden */ __internal_closeEnableOrganizationsPrompt: () => void; @@ -939,12 +967,14 @@ export interface Clerk { /** * Returns the configured `afterSignInUrl` of the instance. + * * @param params - Optional query parameters to append to the URL. */ buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string; /** * Returns the configured `afterSignUpUrl` of the instance. + * * @param params - Optional query parameters to append to the URL. */ buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string; @@ -1100,6 +1130,7 @@ export interface Clerk { /** * Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email. + * * @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution. * @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows. */ @@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation & localization?: LocalizationResource; /** * Indicates whether Clerk should poll against Clerk's backend every 5 minutes. + * * @default true */ polling?: boolean; 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/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 5b9aa8764a0..f247f215933 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { ); }); -const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { +export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { const { isLoading, enterpriseConnection, diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx index f47d28b8aad..2470257420d 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { export const OrganizationGeneralPage = () => { return ( - ({ gap: t.space.$8 })} + - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - - - - - - - - + + + + + + + ); }; -const OrganizationProfileSection = () => { +/** + * Renders the organization profile section (name, logo) with inline edit when the user has + * `org:sys_profile:manage`. + * + * @returns The profile section, or `null` when no organization is active. + */ +export const OrganizationProfileSection = (): JSX.Element | null => { const { organization } = useOrganization(); if (!organization) { @@ -134,7 +127,13 @@ const OrganizationProfileSection = () => { ); }; -const OrganizationDomainsSection = () => { +/** + * Renders the verified-domains section. + * + * @returns The domains section, or `null` when domains are disabled, no organization is active, or + * there are no domains and the user cannot add any. + */ +export const OrganizationDomainsSection = (): JSX.Element | null => { const { organizationSettings } = useEnvironment(); const { organization, domains } = useOrganization({ domains: { infinite: true } }); const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' }); @@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => { ); }; -const OrganizationLeaveSection = () => { +/** + * Renders the "leave organization" action in the danger section. + * + * @returns The leave-organization section, or `null` when no organization is active. + */ +export const OrganizationLeaveSection = (): JSX.Element | null => { const { organization } = useOrganization(); if (!organization) { @@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => { ); }; -const OrganizationDeleteSection = () => { +/** + * Renders the "delete organization" action in the danger section. + * + * @returns The delete-organization section, or `null` when no organization is active, the user + * lacks `org:sys_profile:delete`, or admin delete is disabled. + */ +export const OrganizationDeleteSection = (): JSX.Element | null => { const { organization } = useOrganization(); const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' }); diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx index 0b103b559e0..9109a5e2c1e 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => { { { - const { attributes, social, enterpriseSSO } = useEnvironment().userSettings; const card = useCardState(); - const { user } = useUser(); - const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); - - const showUsername = isAttributeAvailable(attributes.username); - const showEmail = isAttributeAvailable(attributes.email_address); - const showPhone = isAttributeAvailable(attributes.phone_number); - const showConnectedAccounts = social && Object.values(social).filter(p => p.enabled).length > 0; - const showEnterpriseAccounts = user && enterpriseSSO.enabled; - const showWeb3 = attributes.web3_wallet?.enabled; - - const isEmailImmutable = immutableAttributes.has('email_address'); - const isPhoneImmutable = immutableAttributes.has('phone_number'); - const isUsernameImmutable = immutableAttributes.has('username'); return ( - ({ gap: t.space.$8, color: t.colors.$colorForeground })} + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - - {card.error} - - - {showUsername && } - {showEmail && ( - - )} - {showPhone && ( - - )} - {showConnectedAccounts && ( - - )} - - {/*TODO-STEP-UP: Verify that these work as expected*/} - {showEnterpriseAccounts && } - {showWeb3 && } - - + + + + + + + + ); }); diff --git a/packages/ui/src/components/UserProfile/AccountSections.tsx b/packages/ui/src/components/UserProfile/AccountSections.tsx new file mode 100644 index 00000000000..f7ceb3c5742 --- /dev/null +++ b/packages/ui/src/components/UserProfile/AccountSections.tsx @@ -0,0 +1,90 @@ +import { useUser } from '@clerk/shared/react'; +import type { ReactNode } from 'react'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { ConnectedAccountsSection } from './ConnectedAccountsSection'; +import { EmailsSection } from './EmailsSection'; +import { EnterpriseAccountsSection } from './EnterpriseAccountsSection'; +import { PhoneSection } from './PhoneSection'; +import { UsernameSection } from './UsernameSection'; +import { isAttributeAvailable } from './utils'; +import { Web3Section } from './Web3Section'; + +export function AccountUsername(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { immutableAttributes } = useUserProfileContext(); + + if (!isAttributeAvailable(attributes.username)) { + return null; + } + + const isImmutable = immutableAttributes.has('username'); + return ; +} + +export function AccountEmails(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!isAttributeAvailable(attributes.email_address)) { + return null; + } + + const isImmutable = immutableAttributes.has('email_address'); + return ( + + ); +} + +export function AccountPhone(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!isAttributeAvailable(attributes.phone_number)) { + return null; + } + + const isImmutable = immutableAttributes.has('phone_number'); + return ( + + ); +} + +export function AccountConnectedAccounts(): ReactNode { + const { social } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!social || Object.values(social).filter(p => p.enabled).length === 0) { + return null; + } + + return ; +} + +export function AccountEnterpriseAccounts(): ReactNode { + const { enterpriseSSO } = useEnvironment().userSettings; + const { user } = useUser(); + + if (!user || !enterpriseSSO.enabled) { + return null; + } + + return ; +} + +export function AccountWeb3(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.web3_wallet?.enabled) { + return null; + } + + return ; +} diff --git a/packages/ui/src/components/UserProfile/BillingPage.tsx b/packages/ui/src/components/UserProfile/BillingPage.tsx index 3d555676142..e48beb9b6c4 100644 --- a/packages/ui/src/components/UserProfile/BillingPage.tsx +++ b/packages/ui/src/components/UserProfile/BillingPage.tsx @@ -27,7 +27,7 @@ const BillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { - const { attributes, instanceIsPasswordBased } = useEnvironment().userSettings; const card = useCardState(); - const { user } = useUser(); - const { shouldAllowIdentificationCreation } = useUserProfileContext(); - const showPassword = instanceIsPasswordBased; - const showPasskey = attributes.passkey?.enabled && shouldAllowIdentificationCreation; - const showMfa = getSecondFactors(attributes).length > 0; - const showDelete = user?.deleteSelfEnabled; return ( - ({ gap: t.space.$8 })} + - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - {card.error} - {showPassword && } - {showPasskey && } - {showMfa && } - - {showDelete && } - - + + + + + + ); }); diff --git a/packages/ui/src/components/UserProfile/SecuritySections.tsx b/packages/ui/src/components/UserProfile/SecuritySections.tsx new file mode 100644 index 00000000000..a1638f8dd7a --- /dev/null +++ b/packages/ui/src/components/UserProfile/SecuritySections.tsx @@ -0,0 +1,43 @@ +import { useUser } from '@clerk/shared/react'; +import type { ReactNode } from 'react'; + +import { getSecondFactors } from '@/ui/utils/mfa'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { MfaSection } from './MfaSection'; +import { PasskeySection } from './PasskeySection'; +import { PasswordSection } from './PasswordSection'; +import { DeleteSection } from './DeleteSection'; + +export function SecurityPassword(): ReactNode { + const { instanceIsPasswordBased } = useEnvironment().userSettings; + + if (!instanceIsPasswordBased) return null; + + return ; +} + +export function SecurityPasskeys(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.passkey?.enabled || !shouldAllowIdentificationCreation) return null; + + return ; +} + +export function SecurityMfa(): ReactNode { + const { attributes } = useEnvironment().userSettings; + + if (getSecondFactors(attributes).length === 0) return null; + + return ; +} + +export function SecurityDelete(): ReactNode { + const { user } = useUser(); + + if (!user?.deleteSelfEnabled) return null; + + return ; +} diff --git a/packages/ui/src/components/UserProfile/Web3Form.tsx b/packages/ui/src/components/UserProfile/Web3Form.tsx index 19c4b320c7a..39cb0e54f0a 100644 --- a/packages/ui/src/components/UserProfile/Web3Form.tsx +++ b/packages/ui/src/components/UserProfile/Web3Form.tsx @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useReverification, useUser } from '@clerk/shared/react'; import type { Web3Provider, Web3Strategy } from '@clerk/shared/types'; @@ -56,6 +57,16 @@ export const AddWeb3WalletActionMenu = () => { throw new Error('user is not defined'); } + // `getWeb3Identifier` returns '' when the wallet provider (or its dynamic + // import) is unavailable. Posting an empty string yields an unhelpful 422 + // from FAPI (`form_param_nil` on `web3_wallet`); surface the missing- + // provider state locally instead. + if (!identifier) { + throw new ClerkRuntimeError('A Web3 Wallet extension cannot be found. Please install one to continue.', { + code: 'web3_missing_identifier', + }); + } + let web3Wallet = await createWeb3Wallet(identifier); web3Wallet = await web3Wallet?.prepareVerification({ strategy }); const message = web3Wallet?.verification.message as string; diff --git a/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx new file mode 100644 index 00000000000..8e039000601 --- /dev/null +++ b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { clearFetchCache } from '../../../hooks'; +import { + AccountConnectedAccounts, + AccountEmails, + AccountEnterpriseAccounts, + AccountPhone, + AccountUsername, + AccountWeb3, +} from '../AccountSections'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('AccountSections — self-gating visibility', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('AccountUsername', () => { + it('renders when username is enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUsername(); + f.withUser({ email_addresses: ['test@clerk.com'], username: 'testuser' }); + }); + + render(, { wrapper }); + screen.getByText('testuser'); + }); + + it('returns null when username is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountEmails', () => { + it('renders email list when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['a@clerk.com', 'b@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText('a@clerk.com'); + screen.getByText('b@clerk.com'); + }); + + it('returns null when email is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountPhone', () => { + it('renders phone list when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] }); + }); + + render( + + + , + { wrapper }, + ); + expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0); + }); + + it('returns null when phone is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountConnectedAccounts', () => { + it('renders when social providers are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withUser({ + email_addresses: ['test@clerk.com'], + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + }); + }); + + render(, { wrapper }); + screen.getByText(/connected accounts/i); + }); + + it('returns null when no social providers are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountEnterpriseAccounts', () => { + it('renders when enterprise SSO is enabled and the user has an active enterprise account', async () => { + const { wrapper } = await createFixtures(f => { + f.withEnterpriseSso(); + f.withUser({ + email_addresses: ['test@clerk.com'], + enterprise_accounts: [ + { + object: 'enterprise_account', + active: true, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'enterprise_sso', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + }); + }); + + render(, { wrapper }); + screen.getByText(/enterprise accounts/i); + }); + + it('returns null when enterprise SSO is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountWeb3', () => { + it('renders when web3_wallet is enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render(, { wrapper }); + screen.getByText(/web3 wallets/i); + }); + + it('returns null when web3_wallet is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx new file mode 100644 index 00000000000..707cf50b8f2 --- /dev/null +++ b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { clearFetchCache } from '../../../hooks'; +import { SecurityPassword, SecurityPasskeys, SecurityMfa, SecurityDelete } from '../SecuritySections'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('SecuritySections — self-gating visibility', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('SecurityPassword', () => { + it('renders when instance is password-based', async () => { + const { wrapper } = await createFixtures(f => { + f.withPassword(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText(/^password/i); + }); + + it('returns null when instance is not password-based', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityPasskeys', () => { + it('renders when passkeys are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withPasskey(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText(/^passkeys/i); + }); + + it('returns null when passkeys are disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityMfa', () => { + it('renders when second factors are available', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber({ used_for_second_factor: true, second_factors: ['phone_code'] }); + f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] }); + }); + + render( + + + , + { wrapper }, + ); + expect(screen.getAllByText(/two-step verification/i).length).toBeGreaterThan(0); + }); + + it('returns null when no second factors are available', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityDelete', () => { + it('renders when delete_self_enabled is true', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true }); + }); + + render(, { wrapper }); + expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0); + }); + + it('returns null when delete_self_enabled is false', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx b/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx index 322ae3935d3..e972492ea2f 100644 --- a/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx @@ -1,10 +1,25 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { render } from '@/test/utils'; +import { render, waitFor } from '@/test/utils'; import { Web3Section } from '../Web3Section'; +vi.mock('@clerk/shared/internal/clerk-js/web3', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createWeb3: vi.fn(() => ({ + // Mirrors the empty-string fallback the shared helper returns when the + // wallet provider isn't resolvable (e.g. dynamic `@coinbase/wallet-sdk` + // import returned undefined because no module manager propagated to the + // composed/subcomponent UserProfile). + getWeb3Identifier: vi.fn(() => Promise.resolve('')), + generateWeb3Signature: vi.fn(() => Promise.resolve('')), + })), + }; +}); + const { createFixtures } = bindCreateFixtures('UserProfile'); const withMetamaskWallet = createFixtures.config(f => { @@ -130,4 +145,37 @@ describe('Web3Section', () => { // Admin wallet with just the address getByText('0xabcd...abcd'); }); + + // Regression: composed/subcomponent UserProfile mounts can fall back to a + // no-op ModuleManager when IsomorphicClerk doesn't propagate the wrapper's + // moduleManager (see packages/react isomorphicClerk test). When that + // happens, `createWeb3(...).getWeb3Identifier` returns '' instead of a + // wallet address — and we used to POST `{ web3_wallet: '' }` straight to + // FAPI, surfacing as a confusing 422 form_param_nil error. Guard the empty + // identifier locally so the failure mode is a clear UI message. + describe('AddWeb3WalletActionMenu — empty identifier guard', () => { + const withCoinbaseEnabled = createFixtures.config(f => { + f.withWeb3Wallet({ + first_factors: ['web3_coinbase_wallet_signature'], + verifications: ['web3_coinbase_wallet_signature'], + }); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + it('does not POST /me/web3_wallets when getWeb3Identifier returns ""', async () => { + const { wrapper, fixtures } = await createFixtures(withCoinbaseEnabled); + const createWeb3Wallet = vi.fn(); + fixtures.clerk.user!.createWeb3Wallet = createWeb3Wallet as any; + + const { getByRole, userEvent, findByText } = render(, { wrapper }); + + await userEvent.click(getByRole('button', { name: /Connect wallet/i })); + await userEvent.click(getByRole('menuitem', { name: /Coinbase Wallet/i })); + + await findByText(/Web3 Wallet extension cannot be found/i); + await waitFor(() => { + expect(createWeb3Wallet).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/ui/src/composed/APIKeysSection.tsx b/packages/ui/src/composed/APIKeysSection.tsx new file mode 100644 index 00000000000..3e1369dcef8 --- /dev/null +++ b/packages/ui/src/composed/APIKeysSection.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { Suspense, type ComponentType, type ReactNode } from 'react'; + +import { CardStateProvider } from '../elements/contexts'; + +export function APIKeysSection({ page: Page }: { page: ComponentType }): ReactNode { + return ( + + + + + + ); +} diff --git a/packages/ui/src/composed/BillingSection.tsx b/packages/ui/src/composed/BillingSection.tsx new file mode 100644 index 00000000000..f6c30a1139e --- /dev/null +++ b/packages/ui/src/composed/BillingSection.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { Suspense, type ComponentType, type ReactNode } from 'react'; + +import { RouteContext } from '../router/RouteContext'; +import { useBillingRouter } from './useBillingRouter'; + +type BillingSectionProps = { + billing: ComponentType; + plans: ComponentType; + statement: ComponentType; + paymentAttempt: ComponentType; +}; + +export function BillingSection({ + billing: Billing, + plans: Plans, + statement: Statement, + paymentAttempt: PaymentAttempt, +}: BillingSectionProps): ReactNode { + const { router, route } = useBillingRouter(); + + let content: ReactNode; + switch (route.page) { + case 'plans': + content = ; + break; + case 'statement': + content = ; + break; + case 'payment-attempt': + content = ; + break; + default: + content = ; + } + + return ( + + {content} + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx new file mode 100644 index 00000000000..2dd72af55cc --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/APIKeys.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { APIKeysSection } from '../APIKeysSection'; + +const OrganizationAPIKeysPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationAPIKeysPage').then(m => ({ + default: m.OrganizationAPIKeysPage, + })), +); + +export const OrganizationProfileAPIKeysPanel = (): ReactNode => ; diff --git a/packages/ui/src/composed/OrganizationProfile/Billing.tsx b/packages/ui/src/composed/OrganizationProfile/Billing.tsx new file mode 100644 index 00000000000..aac613207ae --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Billing.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { BillingSection } from '../BillingSection'; + +const OrganizationBillingPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationBillingPage').then(m => ({ + default: m.OrganizationBillingPage, + })), +); + +const OrganizationPlansPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationPlansPage').then(m => ({ + default: m.OrganizationPlansPage, + })), +); + +const OrganizationStatementPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationStatementPage').then(m => ({ + default: m.OrganizationStatementPage, + })), +); + +const OrganizationPaymentAttemptPage = lazy(() => + import('../../components/OrganizationProfile/OrganizationPaymentAttemptPage').then(m => ({ + default: m.OrganizationPaymentAttemptPage, + })), +); + +export const OrganizationProfileBillingPanel = (): ReactNode => ( + +); diff --git a/packages/ui/src/composed/OrganizationProfile/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx new file mode 100644 index 00000000000..fdf8bbeacdf --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/General.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { localizationKeys } from '../../customizables'; +import { PageContext } from '../PageContext'; + +function GeneralComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + + {children} + + + ); +} + +export function OrganizationProfileGeneralPanel({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + // The section confirmation forms (leave/delete) call useCardState(), so children must be wrapped + // in a CardStateProvider — mirroring the UserProfile Account/Security composed panels. + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx new file mode 100644 index 00000000000..5372a3efbaf --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralDeleteOrganization.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { OrganizationDeleteSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const OrganizationProfileDeleteSection = createSection( + 'OrganizationProfileDeleteSection', + OrganizationDeleteSection, +); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx new file mode 100644 index 00000000000..d48f2053227 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralLeaveOrganization.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { OrganizationLeaveSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const OrganizationProfileLeaveSection = createSection( + 'OrganizationProfileLeaveSection', + OrganizationLeaveSection, +); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx new file mode 100644 index 00000000000..b58b137287d --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralOrganizationProfile.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { OrganizationProfileSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { createSection } from '../createSection'; + +export const OrganizationProfileProfileSection = createSection( + 'OrganizationProfileProfileSection', + OrganizationProfileSection, +); diff --git a/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx new file mode 100644 index 00000000000..06f0984b0c4 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/GeneralVerifiedDomains.tsx @@ -0,0 +1,18 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { Protect } from '../../common'; +import { OrganizationDomainsSection } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { useRequirePage } from '../useRequirePage'; + +export function OrganizationProfileDomainsSection(): ReactNode { + if (!useRequirePage('OrganizationProfileDomainsSection')) { + return null; + } + return ( + + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/Members.tsx b/packages/ui/src/composed/OrganizationProfile/Members.tsx new file mode 100644 index 00000000000..8fc817c76fd --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Members.tsx @@ -0,0 +1,7 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import { OrganizationMembers } from '../../components/OrganizationProfile/OrganizationMembers'; + +export const OrganizationProfileMembersPanel = (): ReactNode => ; diff --git a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx new file mode 100644 index 00000000000..1cfdd5d17ff --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OrganizationProfileProps } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import type { Appearance } from '@/ui/internal/appearance'; + +import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; +import { SubscriberTypeContext } from '../../contexts/components/SubscriberType'; +import { fallbackModuleManager, ProfileProviderShell } from '../ProfileProviderShell'; + +type OrganizationProfileProviderProps = PropsWithChildren<{ + appearance?: Appearance; + afterLeaveOrganizationUrl?: OrganizationProfileProps['afterLeaveOrganizationUrl']; + apiKeysProps?: OrganizationProfileProps['apiKeysProps']; +}>; + +export const OrganizationProfileProvider = (props: OrganizationProfileProviderProps): ReactNode => { + const { children, appearance, afterLeaveOrganizationUrl, apiKeysProps } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const { organization } = useOrganization(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager; + + if (!isLoaded || !user || !organization || !environment) { + return null; + } + + const orgProfileCtxValue = { + componentName: 'OrganizationProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + afterLeaveOrganizationUrl, + apiKeysProps, + customPages: [], + }; + + return ( + + + {children} + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/Security.tsx b/packages/ui/src/composed/OrganizationProfile/Security.tsx new file mode 100644 index 00000000000..76020ef963b --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Security.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { type ReactNode, useRef } from 'react'; + +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { OrganizationSecurityPage } from '../../components/OrganizationProfile/OrganizationSecurityPage'; + +// The security tab has no composable sub-sections — it is an SSO overview plus a configuration +// wizard driven by internal view state. So the composed panel renders the whole standard security +// page (`OrganizationSecurityPage`), mirroring what `` shows on its security +// route. The confirmation/wizard forms call useCardState(), so children must sit under a +// CardStateProvider, matching how the standard component wraps the page. +export const OrganizationProfileSecurityPanel = (): ReactNode => { + const contentRef = useRef(null); + + return ( + + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/index.tsx b/packages/ui/src/composed/OrganizationProfile/index.tsx new file mode 100644 index 00000000000..fdbad295a9e --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/index.tsx @@ -0,0 +1,10 @@ +export { OrganizationProfileProvider } from './OrganizationProfileProvider'; +export { OrganizationProfileGeneralPanel } from './General'; +export { OrganizationProfileMembersPanel } from './Members'; +export { OrganizationProfileBillingPanel } from './Billing'; +export { OrganizationProfileAPIKeysPanel } from './APIKeys'; +export { OrganizationProfileSecurityPanel } from './Security'; +export { OrganizationProfileProfileSection } from './GeneralOrganizationProfile'; +export { OrganizationProfileDomainsSection } from './GeneralVerifiedDomains'; +export { OrganizationProfileLeaveSection } from './GeneralLeaveOrganization'; +export { OrganizationProfileDeleteSection } from './GeneralDeleteOrganization'; diff --git a/packages/ui/src/composed/PageContext.tsx b/packages/ui/src/composed/PageContext.tsx new file mode 100644 index 00000000000..b16f319d803 --- /dev/null +++ b/packages/ui/src/composed/PageContext.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +type PageId = 'account' | 'security' | 'general'; + +export const PageContext = createContext(null); diff --git a/packages/ui/src/composed/ProfileProviderShell.tsx b/packages/ui/src/composed/ProfileProviderShell.tsx new file mode 100644 index 00000000000..d1abf54b29c --- /dev/null +++ b/packages/ui/src/composed/ProfileProviderShell.tsx @@ -0,0 +1,150 @@ +'use client'; + +// Composed UserProfile / OrganizationProfile mount outside the clerk-js portal +// tree, so this shell rebuilds the providers normally split between +// `LazyProviders` and `LazyComponentRenderer` / `LazyModalRenderer` in +// `packages/ui/src/lazyModules/providers.tsx`. `ClerkContextProvider` is +// intentionally omitted — the consumer's `` supplies `clerk` via +// `useClerk()`. The emotion cache is keyed per clerk instance in +// `styleCacheStore` so sibling composed roots don't duplicate style insertions. + +import { ClerkRuntimeError } from '@clerk/shared/error'; +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import type { EnvironmentResource, LoadedClerk } from '@clerk/shared/types'; +// eslint-disable-next-line no-restricted-imports +import { CacheProvider } from '@emotion/react'; +import type { PropsWithChildren, ReactNode } from 'react'; +import { useMemo } from 'react'; + +import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import type { Appearance, Elements } from '@/ui/internal/appearance'; +import { getStyleCache, setStyleCache } from '@/ui/internal/styleCacheStore'; +import { RouteContext } from '@/ui/router/RouteContext'; +import { InternalThemeProvider } from '@/ui/styledSystem'; +import { createEmotionCache } from '@/ui/styledSystem/createEmotionCache'; +import { extractCssLayerNameFromAppearance } from '@/ui/utils/extractCssLayerNameFromAppearance'; + +import { EnvironmentProvider } from '../contexts/EnvironmentContext'; +import { ModuleManagerProvider } from '../contexts/ModuleManagerContext'; +import { OptionsProvider } from '../contexts/OptionsContext'; +import { AppearanceOverrides } from '../elements/AppearanceOverrides'; +import { createComposedRouter } from './stubRouter'; + +// Used when `clerk.__internal_moduleManager` is `undefined`. In a correctly wired app clerk-js +// exposes its ModuleManager through that getter, so reaching this means the loaded clerk-js is too +// old to expose it (an older clerk-js also predates composed profiles entirely). Fail loudly on the +// first dynamic import (Web3, billing, password strength) instead of silently resolving `undefined` +// and surfacing later as an opaque access on the missing module. +export const fallbackModuleManager: ModuleManager = { + import: () => + Promise.reject( + new ClerkRuntimeError( + 'Composed profile components could not resolve a Clerk module manager: this Clerk instance does not expose one. This usually means the loaded @clerk/clerk-js is too old to support composed profiles.', + { code: 'composed_module_manager_unavailable' }, + ), + ), +}; + +const composedOverrides: Elements = { + profilePageContent: { padding: 0 }, +}; + +type ProfileProviderShellProps = PropsWithChildren<{ + clerk: LoadedClerk; + environment: EnvironmentResource; + moduleManager: ModuleManager; + appearanceKey: 'userProfile' | 'organizationProfile'; + flow: 'userProfile' | 'organizationProfile'; + globalAppearance: Appearance | undefined; + appearance?: Appearance; +}>; + +type SharedStyleCacheProviderProps = PropsWithChildren<{ + clerk: LoadedClerk; + nonce?: string; + cssLayerName?: string; +}>; + +// One emotion cache per clerk instance, so sibling composed roots share inserts. +function SharedStyleCacheProvider({ clerk, nonce, cssLayerName, children }: SharedStyleCacheProviderProps): ReactNode { + const cache = useMemo(() => { + const existing = getStyleCache(clerk); + if (existing) { + return existing; + } + const next = createEmotionCache({ nonce, cssLayerName }); + setStyleCache(clerk, next); + return next; + }, [clerk, nonce, cssLayerName]); + + return {children}; +} + +export function ProfileProviderShell({ + children, + clerk, + environment, + moduleManager, + appearanceKey, + flow, + globalAppearance, + appearance, +}: ProfileProviderShellProps): ReactNode { + // currentPath is left empty: composed has no Clerk-internal navigation. Each + // section owns its own CardStateProvider, so errors clear on section unmount + // (single-section mounting). Side-by-side sections keep independent error + // state — a consumer URL change wouldn't be a meaningful signal to clear + // either, so observing it would only cause spurious clears. + const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]); + // Match the portal path's normalization (Components.tsx:209) so a cssLayerName + // nested inside appearance.theme gets hoisted to top-level for @layer wrapping. + const normalizedGlobalAppearance = useMemo( + () => extractCssLayerNameFromAppearance(globalAppearance), + [globalAppearance], + ); + const options = useMemo( + () => ({ + localization: clerk.__internal_getOption('localization'), + supportEmail: clerk.__internal_getOption('supportEmail'), + }), + [clerk], + ); + + return ( + + {/* parsed appearance for cl-* styled components */} + + {/* flow= for Flow.Root/Part data-clerk-* selectors */} + + {/* Emotion ThemeProvider over parsed theme */} + + {/* dynamic-import bridge (Web3) */} + + {/* threads localization + supportEmail from the consumer's */} + + {/* read by useEnvironment() across MFA/account sections */} + + {/* router stub: navigate→clerk.navigate, matches/refresh no-op */} + + {/* zero out profilePageContent padding when embedded */} + {children} + + + + + + + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx new file mode 100644 index 00000000000..e627775110e --- /dev/null +++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { APIKeysSection } from '../APIKeysSection'; + +const APIKeysPage = lazy(() => + import('../../components/UserProfile/APIKeysPage').then(m => ({ + default: m.APIKeysPage, + })), +); + +export const UserProfileAPIKeysPanel = (): ReactNode => ; diff --git a/packages/ui/src/composed/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx new file mode 100644 index 00000000000..5672ae47c38 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Account.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { AccountPage } from '../../components/UserProfile/AccountPage'; +import { localizationKeys } from '../../customizables'; +import { PageContext } from '../PageContext'; + +function AccountComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} + > + {children} + + + ); +} + +export function UserProfileAccountPanel({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx new file mode 100644 index 00000000000..280352a05ef --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountConnectedAccounts as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileConnectedAccountsSection = createSection('UserProfileConnectedAccountsSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountEmails.tsx b/packages/ui/src/composed/UserProfile/AccountEmails.tsx new file mode 100644 index 00000000000..f4aab0d638b --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountEmails.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountEmails as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileEmailSection = createSection('UserProfileEmailSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx new file mode 100644 index 00000000000..e0f6e527b9b --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountEnterpriseAccounts as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileEnterpriseAccountsSection = createSection('UserProfileEnterpriseAccountsSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountPhone.tsx b/packages/ui/src/composed/UserProfile/AccountPhone.tsx new file mode 100644 index 00000000000..b24c5eaa551 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountPhone.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountPhone as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfilePhoneSection = createSection('UserProfilePhoneSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountProfile.tsx b/packages/ui/src/composed/UserProfile/AccountProfile.tsx new file mode 100644 index 00000000000..0e5ee1d5499 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountProfile.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { UserProfileSection } from '../../components/UserProfile/UserProfileSection'; +import { createSection } from '../createSection'; + +export const UserProfileProfileSection = createSection('UserProfileProfileSection', UserProfileSection); diff --git a/packages/ui/src/composed/UserProfile/AccountUsername.tsx b/packages/ui/src/composed/UserProfile/AccountUsername.tsx new file mode 100644 index 00000000000..8976d221197 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountUsername.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountUsername as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileUsernameSection = createSection('UserProfileUsernameSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountWeb3.tsx b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx new file mode 100644 index 00000000000..44b1df38425 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountWeb3 as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileWeb3Section = createSection('UserProfileWeb3Section', Section); diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx new file mode 100644 index 00000000000..a8ad004e6f2 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Billing.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { BillingSection } from '../BillingSection'; + +const BillingPage = lazy(() => + import('../../components/UserProfile/BillingPage').then(m => ({ + default: m.BillingPage, + })), +); + +const PlansPage = lazy(() => + import('../../components/UserProfile/PlansPage').then(m => ({ + default: m.PlansPage, + })), +); + +const StatementPage = lazy(() => + import('../../components/Statements').then(m => ({ + default: m.StatementPage, + })), +); + +const PaymentAttemptPage = lazy(() => + import('../../components/PaymentAttempts').then(m => ({ + default: m.PaymentAttemptPage, + })), +); + +export const UserProfileBillingPanel = (): ReactNode => ( + +); diff --git a/packages/ui/src/composed/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx new file mode 100644 index 00000000000..451e545467c --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Security.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; +import { localizationKeys } from '../../customizables'; +import { PageContext } from '../PageContext'; + +function SecurityComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + + {children} + + + ); +} + +export function UserProfileSecurityPanel({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx new file mode 100644 index 00000000000..0f25cfad6ac --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { ActiveDevicesSection } from '../../components/UserProfile/ActiveDevicesSection'; +import { createSection } from '../createSection'; + +export const UserProfileActiveDevicesSection = createSection('UserProfileActiveDevicesSection', ActiveDevicesSection); diff --git a/packages/ui/src/composed/UserProfile/SecurityDelete.tsx b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx new file mode 100644 index 00000000000..9e116c958de --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityDelete as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfileDeleteSection = createSection('UserProfileDeleteSection', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityMfa.tsx b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx new file mode 100644 index 00000000000..ad5c61564d6 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityMfa as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfileMfaSection = createSection('UserProfileMfaSection', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx new file mode 100644 index 00000000000..b0cf767a6d1 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityPasskeys as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfilePasskeysSection = createSection('UserProfilePasskeysSection', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityPassword.tsx b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx new file mode 100644 index 00000000000..3a4ad201999 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityPassword as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfilePasswordSection = createSection('UserProfilePasswordSection', Section); diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx new file mode 100644 index 00000000000..a7b1e6f9311 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useClerk, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OAuthProvider, OAuthScope, UserProfileProps } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import type { Appearance } from '@/ui/internal/appearance'; + +import { UserProfileContext } from '../../contexts/components/UserProfile'; +import { fallbackModuleManager, ProfileProviderShell } from '../ProfileProviderShell'; + +type UserProfileProviderProps = PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; + apiKeysProps?: UserProfileProps['apiKeysProps']; +}>; + +export const UserProfileProvider = (props: UserProfileProviderProps): ReactNode => { + const { children, appearance, additionalOAuthScopes, apiKeysProps } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager; + + if (!isLoaded || !user || !environment) { + return null; + } + + const userProfileCtxValue = { + componentName: 'UserProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + additionalOAuthScopes, + apiKeysProps, + customPages: [], + }; + + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/composed/UserProfile/index.tsx b/packages/ui/src/composed/UserProfile/index.tsx new file mode 100644 index 00000000000..7de69d4d962 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/index.tsx @@ -0,0 +1,17 @@ +export { UserProfileProvider } from './UserProfileProvider'; +export { UserProfileAccountPanel } from './Account'; +export { UserProfileSecurityPanel } from './Security'; +export { UserProfileBillingPanel } from './Billing'; +export { UserProfileAPIKeysPanel } from './APIKeys'; +export { UserProfileProfileSection } from './AccountProfile'; +export { UserProfileUsernameSection } from './AccountUsername'; +export { UserProfileEmailSection } from './AccountEmails'; +export { UserProfilePhoneSection } from './AccountPhone'; +export { UserProfileConnectedAccountsSection } from './AccountConnectedAccounts'; +export { UserProfileEnterpriseAccountsSection } from './AccountEnterpriseAccounts'; +export { UserProfileWeb3Section } from './AccountWeb3'; +export { UserProfilePasswordSection } from './SecurityPassword'; +export { UserProfilePasskeysSection } from './SecurityPasskeys'; +export { UserProfileMfaSection } from './SecurityMfa'; +export { UserProfileActiveDevicesSection } from './SecurityActiveDevices'; +export { UserProfileDeleteSection } from './SecurityDelete'; diff --git a/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx new file mode 100644 index 00000000000..38133f9e5a6 --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfile.test.tsx @@ -0,0 +1,47 @@ +import type { ClerkPaginatedResponse, OrganizationMembershipResource } from '@clerk/shared/types'; +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +describe('Experimental OrganizationProfile', () => { + describe('General page', () => { + it('renders the organization general page', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [], + total_count: 0, + }), + ); + + render(, { wrapper }); + screen.getByText('General'); + }); + + it('shows organization name', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [], + total_count: 0, + }), + ); + + render(, { wrapper }); + screen.getByText('TestOrg'); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx new file mode 100644 index 00000000000..eedfbdf96ad --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { OrganizationProfileGeneralPanel } from '../OrganizationProfile/General'; +import { OrganizationProfileDeleteSection } from '../OrganizationProfile/GeneralDeleteOrganization'; +import { OrganizationProfileLeaveSection } from '../OrganizationProfile/GeneralLeaveOrganization'; +import { OrganizationProfileProfileSection } from '../OrganizationProfile/GeneralOrganizationProfile'; +import { OrganizationProfileDomainsSection } from '../OrganizationProfile/GeneralVerifiedDomains'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +describe('OrganizationProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('General — section composition mode', () => { + it('renders only declared sections', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationDomains(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + const { queryByText } = render( + + + , + { wrapper }, + ); + + screen.getByText('TestOrg'); + expect(queryByText(/verified domains/i)).not.toBeInTheDocument(); + }); + + it('renders header', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + screen.getByText('General'); + }); + + it('OrganizationProfileDomainsSection renders when domains enabled and user has permission', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withOrganizationDomains(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/verified domains/i)); + }); + + it('OrganizationProfileDeleteSection renders null when adminDeleteEnabled is false', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + screen.getByText('TestOrg'); + expect(queryByText(/delete organization/i)).not.toBeInTheDocument(); + }); + + it('OrganizationProfileLeaveSection renders leave button', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + expect(screen.getAllByText(/leave organization/i).length).toBeGreaterThan(0); + }); + + it('renders custom content between sections', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + +
Custom org content
+ +
, + { wrapper }, + ); + + expect(screen.getByTestId('custom-banner')).toBeInTheDocument(); + screen.getByText('Custom org content'); + }); + }); + + describe('General — section outside page', () => { + it('useRequirePage throws when rendered outside a page component', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + expect(() => render(, { wrapper })).toThrow( + ' must be rendered inside a page component', + ); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/UserProfile.test.tsx b/packages/ui/src/composed/__tests__/UserProfile.test.tsx new file mode 100644 index 00000000000..ba9d832903d --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfile.test.tsx @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { AccountPage } from '../../components/UserProfile/AccountPage'; +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; +import { clearFetchCache } from '../../hooks'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('Experimental UserProfile', () => { + beforeEach(() => { + clearFetchCache(); + }); + + // The page components (AccountPage/SecurityPage) are thin wrappers that render + // the composed panels. The section-visibility matrix is asserted once at the + // component level in AccountSections/SecuritySections; here we only cover what + // the page level adds — the enterprise-SSO additional-identification guard — + // plus a single mount smoke per page. + describe('Account page', () => { + it('hides add buttons when enterprise SSO disables additional identifications', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + email_addresses: ['test@clerk.com'], + enterprise_accounts: [ + { + active: true, + enterprise_connection: { + disable_additional_identifications: true, + }, + } as any, + ], + }); + f.withEnterpriseSso(); + }); + + const { queryByRole } = render(, { wrapper }); + expect(queryByRole('button', { name: /add email address/i })).not.toBeInTheDocument(); + }); + }); + + describe('Security page', () => { + it('renders active devices section', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/active devices/i)); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx new file mode 100644 index 00000000000..1fcb41b7502 --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx @@ -0,0 +1,320 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { UserProfileAccountPanel } from '../UserProfile/Account'; +import { UserProfileConnectedAccountsSection } from '../UserProfile/AccountConnectedAccounts'; +import { UserProfileEmailSection } from '../UserProfile/AccountEmails'; +import { UserProfileEnterpriseAccountsSection } from '../UserProfile/AccountEnterpriseAccounts'; +import { UserProfilePhoneSection } from '../UserProfile/AccountPhone'; +import { UserProfileProfileSection } from '../UserProfile/AccountProfile'; +import { UserProfileUsernameSection } from '../UserProfile/AccountUsername'; +import { UserProfileWeb3Section } from '../UserProfile/AccountWeb3'; +import { UserProfileSecurityPanel } from '../UserProfile/Security'; +import { UserProfileActiveDevicesSection } from '../UserProfile/SecurityActiveDevices'; +import { UserProfileDeleteSection } from '../UserProfile/SecurityDelete'; +import { UserProfilePasswordSection } from '../UserProfile/SecurityPassword'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('UserProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('Account — inline form flow', () => { + it('inline form flow: update profile opens form', async () => { + const { wrapper } = await createFixtures(f => { + f.withName(); + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + const { getByRole, getByLabelText, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: /update profile/i })); + await waitFor(() => getByLabelText(/first name/i)); + expect(getByLabelText(/first name/i)).toBeInTheDocument(); + }); + }); + + describe('Account — section composition mode', () => { + it('renders only declared sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + phone_numbers: ['+11111111111'], + }); + }); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + screen.getByText('Test User'); + expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0); + expect(queryByText(/phone number/i)).not.toBeInTheDocument(); + }); + + it('renders header', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('Profile details'); + }); + + it('renders custom content between sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + render( + + +
Custom content
+ +
, + { wrapper }, + ); + + expect(screen.getByTestId('custom-banner')).toBeInTheDocument(); + screen.getByText('Custom content'); + }); + + it('environment guard: disabled email renders null', async () => { + const { wrapper } = await createFixtures(f => { + // Email NOT enabled + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + screen.getByText('Test User'); + expect(queryByText(/email address/i)).not.toBeInTheDocument(); + }); + }); + + describe('Account — individual sections', () => { + it('UserProfileProfileSection renders user name', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Jane', last_name: 'Doe' }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('Jane Doe'); + }); + + it('UserProfileUsernameSection renders when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUsername(); + f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('jdoe'); + }); + + it('UserProfileUsernameSection renders null when disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' }); + }); + + const { container } = render( + + + , + { wrapper }, + ); + + expect(container.querySelector('[class*="profileSection"]')).not.toBeInTheDocument(); + }); + + it('UserProfileEmailSection renders email list', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['primary@clerk.com', 'secondary@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('primary@clerk.com'); + screen.getByText('secondary@clerk.com'); + }); + + it('UserProfilePhoneSection renders phone list when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] }); + }); + + render( + + + , + { wrapper }, + ); + + expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0); + }); + + it('UserProfileConnectedAccountsSection renders when social providers enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withUser({ + email_addresses: ['test@clerk.com'], + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText(/connected accounts/i); + }); + + it('UserProfileEnterpriseAccountsSection renders null when enterprise SSO disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { queryByText } = render( + + + , + { wrapper }, + ); + + expect(queryByText(/enterprise accounts/i)).not.toBeInTheDocument(); + }); + + it('UserProfileWeb3Section renders when web3_wallet enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText(/web3 wallets/i); + }); + }); + + describe('Account — section outside page', () => { + it('useRequirePage throws when rendered outside a page component', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + expect(() => render(, { wrapper })).toThrow( + ' must be rendered inside a page component', + ); + }); + }); + + describe('Security — section composition mode', () => { + it('renders only declared sections', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withPassword(); + f.withPasskey(); + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/^password/i)); + screen.getByText(/active devices/i); + expect(queryByText(/^passkeys/i)).not.toBeInTheDocument(); + expect(queryByText(/delete account/i)).not.toBeInTheDocument(); + }); + + it('UserProfileActiveDevicesSection renders without guard', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/active devices/i)); + }); + + it('UserProfileDeleteSection respects user flag', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/active devices/i)); + expect(queryByText(/delete account/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/action-animation.test.tsx b/packages/ui/src/composed/__tests__/action-animation.test.tsx new file mode 100644 index 00000000000..6b46dfe868f --- /dev/null +++ b/packages/ui/src/composed/__tests__/action-animation.test.tsx @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('@formkit/auto-animate/react'); +vi.unmock('@formkit/auto-animate'); + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { UserProfileAccountPanel } from '../UserProfile/Account'; +import { UserProfileEmailSection } from '../UserProfile/AccountEmails'; +import { UserProfileProfileSection } from '../UserProfile/AccountProfile'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +function findAddAnimationCall(calls: any[]) { + return calls.find(call => { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) { + return false; + } + return keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ); + }); +} + +describe('Action open animation', () => { + beforeEach(() => { + clearFetchCache(); + vi.mocked(Element.prototype.animate).mockClear(); + }); + + it('calls el.animate with add keyframes when "Add email" action opens', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + }); + }); + + const { userEvent } = render(, { wrapper }); + vi.mocked(Element.prototype.animate).mockClear(); + + await userEvent.click(screen.getByRole('button', { name: /add email address/i })); + await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); + + it('composed sections: "Add email" triggers add animation', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + }); + }); + + const { userEvent } = render( + + + + , + { wrapper }, + ); + + vi.mocked(Element.prototype.animate).mockClear(); + await userEvent.click(screen.getByRole('button', { name: /add email address/i })); + await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); +}); diff --git a/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx new file mode 100644 index 00000000000..b16319f537b --- /dev/null +++ b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx @@ -0,0 +1,144 @@ +import { StrictMode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The global vitest setup mocks @formkit/auto-animate to a no-op stub. Restore +// the real module for this file (and for Animated.tsx's transitive import) so +// the production useSafeAutoAnimate wiring actually runs. +vi.mock('@formkit/auto-animate', async importOriginal => await importOriginal()); +vi.mock('@formkit/auto-animate/react', async importOriginal => await importOriginal()); + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { Animated } from '../../elements/Animated'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +/** + * Exercises the production wrapper (which uses useSafeAutoAnimate) + * under React StrictMode's mount → cleanup → remount cycle. StrictMode + * double-invokes effects and re-runs ref callbacks; without the + * destroy-previous-controller guard in useSafeAutoAnimate, a second + * MutationObserver would linger on the same node and its remain() animation + * would cancel the entrance (add) animation. We assert the user-facing outcome + * (add fires, no remain) and that exactly one MutationObserver stays active on + * the animated element after the cycle. + */ + +function classifyAnimateCalls(calls: any[]) { + const adds: any[] = []; + const remains: any[] = []; + for (const call of calls) { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) { + continue; + } + if ( + keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ) + ) { + adds.push(call); + } + if (keyframes.some((kf: any) => typeof kf.transform === 'string' && kf.transform.includes('translate'))) { + remains.push(call); + } + } + return { adds, remains }; +} + +function AnimatedList({ showChild }: { showChild: boolean }) { + return ( + +
always here
+ {showChild ?
new child
: null} +
+ ); +} + +const flush = () => new Promise(resolve => setTimeout(resolve, 50)); + +describe('Animated under React StrictMode', () => { + let animateSpy: ReturnType; + + beforeEach(() => { + animateSpy = vi + .spyOn(Element.prototype, 'animate') + .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any); + }); + + afterEach(() => { + animateSpy.mockRestore(); + }); + + it('fires the add animation (and no remain) when a child is added', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { rerender } = render( + + + , + { wrapper }, + ); + + await flush(); + animateSpy.mockClear(); + + rerender( + + + , + ); + + await flush(); + + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + // remain() would only fire if a lingering second observer from the + // StrictMode remount re-processed the mutation — the guard prevents it. + expect(remains.length).toBe(0); + }); + + it('keeps exactly one active MutationObserver on the element after the StrictMode cycle', async () => { + const activeChildListObservers = new Set(); + const targets = new WeakMap(); + const origObserve = MutationObserver.prototype.observe; + const origDisconnect = MutationObserver.prototype.disconnect; + + MutationObserver.prototype.observe = function (target: Node, options?: MutationObserverInit) { + if (options?.childList) { + activeChildListObservers.add(this); + targets.set(this, target); + } + return origObserve.call(this, target, options); + }; + MutationObserver.prototype.disconnect = function () { + activeChildListObservers.delete(this); + return origDisconnect.call(this); + }; + + try { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + + await flush(); + + const el = screen.getByText('always here').parentElement; + const activeOnEl = [...activeChildListObservers].filter(mo => targets.get(mo) === el).length; + expect(activeOnEl).toBe(1); + } finally { + MutationObserver.prototype.observe = origObserve; + MutationObserver.prototype.disconnect = origDisconnect; + } + }); +}); diff --git a/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx new file mode 100644 index 00000000000..5bdb1b27820 --- /dev/null +++ b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx @@ -0,0 +1,569 @@ +import { act } from '@testing-library/react'; +import { useContext } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; +import { useModuleManager, useOptions } from '@/ui/contexts'; +import { useAppearance } from '@/ui/customizables/AppearanceContext'; +import { useRouter } from '@/ui/router'; + +import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; +import { UserProfileContext } from '../../contexts/components/UserProfile'; +import { clearFetchCache } from '../../hooks'; +import { OrganizationProfileProvider } from '../OrganizationProfile/OrganizationProfileProvider'; +import { fallbackModuleManager } from '../ProfileProviderShell'; +import { UserProfileSecurityPanel } from '../UserProfile/Security'; +import { UserProfilePasswordSection } from '../UserProfile/SecurityPassword'; +import { UserProfileProvider } from '../UserProfile/UserProfileProvider'; + +function patchEnvironment(clerk: any, env: any) { + Object.defineProperty(clerk, '__internal_environment', { value: env, configurable: true }); +} + +function ModuleManagerProbe() { + const mm = useModuleManager(); + return ( +
+ ); +} + +function RouterProbe() { + const router = useRouter(); + return ( +
+ ); +} + +describe('UserProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it("provides the clerk instance's moduleManager to children", async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('mm-probe'); + expect(probe.dataset.hasMm).toBe('true'); + }); + + it('falls back to fallbackModuleManager when the clerk instance exposes no moduleManager', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + // Simulate a clerk-js too old to expose the getter (returns undefined). + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: undefined, + configurable: true, + }); + + function FallbackProbe() { + const mm = useModuleManager(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId('mm-fallback-probe').dataset.isFallback).toBe('true'); + }); + + it('provides a router that delegates to clerk.navigate', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('router-probe'); + expect(probe.dataset.hasNavigate).toBe('true'); + expect(probe.dataset.hasBaseNavigate).toBe('true'); + }); + + // The shell does NOT track the host app URL. Composed has no Clerk-internal + // navigation between sections (consumer remounts ), so there's + // no meaningful currentPath to snapshot — and observing the consumer's URL + // would just trigger spurious navigation-keyed effects. + describe('router.currentPath is decoupled from the host URL', () => { + let originalPath: string; + + beforeEach(() => { + originalPath = window.location.pathname; + }); + + afterEach(() => { + window.history.replaceState(null, '', originalPath); + }); + + function CurrentPathProbe() { + const router = useRouter(); + return ( +
+ ); + } + + it('does not snapshot window.location.pathname into router.currentPath', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + window.history.replaceState(null, '', '/page-a'); + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId('path-probe').dataset.current).toBe(''); + }); + + it('does not update router.currentPath on popstate', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + render( + + + , + { wrapper }, + ); + + act(() => { + window.history.pushState(null, '', '/page-b'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + expect(screen.getByTestId('path-probe').dataset.current).toBe(''); + }); + }); + + // The end-to-end proof that resolution actually feeds a dynamic import: render + // the real composed password section, enable zxcvbn strength, type a password, + // and assert the resolved manager's `import` fires for the zxcvbn module. This + // exercises the whole chain UserProfileProvider -> ProfileProviderShell -> + // ModuleManagerProvider -> useModuleManager -> usePassword -> loadZxcvbn. + // (This is the deterministic in-process analog of a Playwright flow, which + // cannot run this path without a backend instance that has `show_zxcvbn` on.) + it('feeds the resolved moduleManager into the composed password strength import', async () => { + // Never settles, so the un-caught `.then` in createValidatePassword does not + // surface as an unhandled rejection; we only care that `import` was invoked. + const importSpy = vi.fn(() => new Promise(() => {})); + + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + f.withPassword(); + f.withPasswordComplexity({ show_zxcvbn: true }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: { import: importSpy }, + configurable: true, + }); + + const { userEvent, getByRole, getByLabelText } = render( + + + + + , + { wrapper }, + ); + + await userEvent.click(getByRole('button', { name: /set password/i })); + await userEvent.type(getByLabelText(/new password/i), 'weak'); + + await waitFor(() => { + expect(importSpy).toHaveBeenCalledWith('@zxcvbn-ts/core'); + }); + }); + + it('returns null when user is not loaded', async () => { + const { wrapper, fixtures } = await createFixtures(); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const { container } = render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('cascades globalAppearance from ClerkProvider into composed theme', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + // Simulate ClerkProvider setting appearance with colorPrimary + fixtures.clerk.__internal_getOption = vi.fn((key: string) => { + if (key === 'appearance') { + return { variables: { colorPrimary: '#ff0000' } }; + } + return undefined; + }); + + function AppearanceProbe() { + const { parsedInternalTheme } = useAppearance(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('appearance-probe'); + // #ff0000 = hsla(0, 100%, 50%, 1) — the global appearance should cascade + expect(probe.dataset.colorPrimary).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('threads localization from ClerkProvider into composed OptionsProvider', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const expectedLocalization = { locale: 'fr-FR', signIn: { start: { title: 'Bienvenue' } } }; + const expectedSupportEmail = 'help@clerk.dev'; + fixtures.clerk.__internal_getOption = vi.fn((key: string) => { + if (key === 'localization') { + return expectedLocalization; + } + if (key === 'supportEmail') { + return expectedSupportEmail; + } + return undefined; + }); + + function OptionsProbe() { + const options = useOptions(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('options-probe'); + expect(probe.dataset.locale).toBe('fr-FR'); + expect(probe.dataset.supportEmail).toBe(expectedSupportEmail); + }); + + it('returns null when environment is missing', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, undefined); + + render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('forwards apiKeysProps into the UserProfile context', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + function ApiKeysProbe() { + const ctx = useContext(UserProfileContext); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(JSON.parse(screen.getByTestId('apikeys-probe').dataset.apiKeys || 'null')).toEqual({ perPage: 5 }); + }); +}); + +describe('OrganizationProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it("provides the clerk instance's moduleManager to children", async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('mm-probe'); + expect(probe.dataset.hasMm).toBe('true'); + }); + + it('falls back to fallbackModuleManager when the clerk instance exposes no moduleManager', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: undefined, + configurable: true, + }); + + function FallbackProbe() { + const mm = useModuleManager(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId('org-mm-fallback-probe').dataset.isFallback).toBe('true'); + }); + + it('returns null when organization is not loaded', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const { container } = render( + +
+ , + { wrapper }, + ); + + expect(screen.queryByTestId('should-not-render')).not.toBeInTheDocument(); + }); + + it('forwards afterLeaveOrganizationUrl and apiKeysProps into the OrganizationProfile context', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + function OrgProbe() { + const ctx = useContext(OrganizationProfileContext); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + const probe = screen.getByTestId('org-probe'); + expect(probe.dataset.afterLeave).toBe('/bye'); + expect(JSON.parse(probe.dataset.apiKeys || 'null')).toEqual({ showDescription: true }); + }); +}); + +// The fallback exists to fail loudly instead of silently resolving `undefined`. +// This is the contract the whole getter refactor protects: when a too-old +// clerk-js exposes no manager, the first dynamic import (Web3, billing, +// password strength) must reject with a stable, diagnosable code rather than +// degrade to an opaque access on a missing module. +describe('fallbackModuleManager', () => { + it('rejects dynamic imports with a composed_module_manager_unavailable code', async () => { + await expect(fallbackModuleManager.import('@zxcvbn-ts/core')).rejects.toMatchObject({ + code: 'composed_module_manager_unavailable', + }); + }); +}); + +// Both providers were changed identically to resolve the manager through the +// live `clerk.__internal_moduleManager` getter, which crosses bundle boundaries +// regardless of how many @clerk/shared copies are installed. A registry keyed by +// module-scoped state would silently return the wrong manager (or undefined) when +// the read-side @clerk/shared is a different physical copy than the write-side +// one. Exposing a getter-only manager (distinct from anything the real Clerk +// instance registered) and surfacing it proves the read channel is the getter, +// not a shared registry — pinned here for both providers. +describe('moduleManager getter resolution', () => { + beforeEach(() => { + clearFetchCache(); + }); + + const cases = [ + { + name: 'UserProfileProvider', + component: 'UserProfile' as const, + Provider: UserProfileProvider, + testId: 'mm-getter-probe', + setup: (f: any) => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }, + }, + { + name: 'OrganizationProfileProvider', + component: 'OrganizationProfile' as const, + Provider: OrganizationProfileProvider, + testId: 'org-mm-getter-probe', + setup: (f: any) => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }, + }, + ]; + + it.each(cases)( + '$name resolves the moduleManager from clerk.__internal_moduleManager (getter), not a shared registry', + async ({ component, Provider, testId, setup }) => { + const { createFixtures } = bindCreateFixtures(component); + const getterImport = vi.fn(() => Promise.resolve(undefined)); + const getterModuleManager = { import: getterImport }; + + const { wrapper, fixtures } = await createFixtures(setup); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains?.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + Object.defineProperty(fixtures.clerk, '__internal_moduleManager', { + value: getterModuleManager, + configurable: true, + }); + + function ModuleManagerIdentityProbe() { + const mm = useModuleManager(); + return ( +
+ ); + } + + render( + + + , + { wrapper }, + ); + + expect(screen.getByTestId(testId).dataset.fromGetter).toBe('true'); + }, + ); +}); diff --git a/packages/ui/src/composed/__tests__/stub-limitations.test.ts b/packages/ui/src/composed/__tests__/stub-limitations.test.ts new file mode 100644 index 00000000000..fffd9d61236 --- /dev/null +++ b/packages/ui/src/composed/__tests__/stub-limitations.test.ts @@ -0,0 +1,131 @@ +import { renderHook, act } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createComposedRouter, stubRouter } from '../stubRouter'; +import { useBillingRouter } from '../useBillingRouter'; + +describe('createComposedRouter', () => { + it('navigate delegates to clerkNavigate for same-origin paths', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.navigate('/dashboard'); + + expect(clerkNavigate).toHaveBeenCalledWith('/dashboard'); + }); + + it('navigate delegates to clerkNavigate for relative paths', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.navigate('../'); + + expect(clerkNavigate).toHaveBeenCalledWith('../'); + }); + + it('navigate delegates to clerkNavigate for external URLs', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.navigate('https://external.example.com/callback'); + + expect(clerkNavigate).toHaveBeenCalledWith('https://external.example.com/callback'); + }); + + it('baseNavigate delegates to clerkNavigate with URL href', async () => { + const clerkNavigate = vi.fn().mockResolvedValue(undefined); + const router = createComposedRouter(clerkNavigate); + + await router.baseNavigate(new URL('https://example.com/path')); + + expect(clerkNavigate).toHaveBeenCalledWith('https://example.com/path'); + }); + + it('resolve produces URLs relative to current location', () => { + const router = createComposedRouter(vi.fn()); + + const resolved = router.resolve('/some-path'); + expect(resolved.pathname).toBe('/some-path'); + }); +}); + +describe('createComposedRouter — AIO-only APIs throw in dev', () => { + it('matches() throws', () => { + const router = createComposedRouter(vi.fn()); + expect(() => router.matches('/foo')).toThrow(/not supported inside composed sections/); + }); + + it('refresh() throws', () => { + const router = createComposedRouter(vi.fn()); + expect(() => router.refresh()).toThrow(/not supported inside composed sections/); + }); + + it('getMatchData() throws', () => { + const router = createComposedRouter(vi.fn()); + expect(() => router.getMatchData('/foo')).toThrow(/not supported inside composed sections/); + }); +}); + +describe('stubRouter fallback', () => { + it('is created with window.location.assign as navigator', () => { + // stubRouter is a pre-built instance that delegates to window.location.assign. + // We can't spy on window.location.assign in jsdom, but we verify it's a valid router. + expect(stubRouter.navigate).toBeDefined(); + expect(stubRouter.baseNavigate).toBeDefined(); + }); +}); + +describe('useBillingRouter — in-memory by design', () => { + // Composed billing routing is purely React state — the consumer owns the + // page URL. Trade-off: back/forward, refresh, and deep-links do not preserve + // sub-route or tab state. These tests pin that decision. + + let originalHash: string; + + afterEach(() => { + window.location.hash = originalHash ?? ''; + }); + + it('navigate() does not touch window.location.hash', async () => { + originalHash = window.location.hash; + const { result } = renderHook(() => useBillingRouter()); + + await act(async () => { + await result.current.router.navigate('plans'); + }); + + expect(window.location.hash).toBe(originalHash); + expect(result.current.route.page).toBe('plans'); + }); + + it('navigate() does not push a history entry', async () => { + const before = window.history.length; + const { result } = renderHook(() => useBillingRouter()); + + await act(async () => { + await result.current.router.navigate('statement/abc'); + }); + + expect(window.history.length).toBe(before); + }); +}); + +describe('createComposedRouter — SSR safety', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resolve() does not throw when window is undefined', () => { + vi.stubGlobal('window', undefined); + + const router = createComposedRouter(vi.fn()); + expect(() => router.resolve('/some-path')).not.toThrow(); + expect(router.resolve('/some-path').pathname).toBe('/some-path'); + }); + + it('stubRouter.navigate is a no-op (does not throw) when window is undefined', async () => { + vi.stubGlobal('window', undefined); + + await expect(stubRouter.navigate('/foo')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx b/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx new file mode 100644 index 00000000000..e2fd8e396df --- /dev/null +++ b/packages/ui/src/composed/__tests__/style-cache-sharing.test.tsx @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; +import { getStyleCache } from '@/ui/internal/styleCacheStore'; + +import { clearFetchCache } from '../../hooks'; +import { OrganizationProfileProvider } from '../OrganizationProfile/OrganizationProfileProvider'; +import { UserProfileProvider } from '../UserProfile/UserProfileProvider'; + +function patchEnvironment(clerk: any, env: any) { + Object.defineProperty(clerk, '__internal_environment', { value: env, configurable: true }); +} + +describe('composed style cache sharing', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + it('sibling UserProfile + OrganizationProfile roots share one emotion cache per clerk instance', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + first_name: 'Test', + last_name: 'User', + organization_memberships: [{ name: 'TestOrg' }], + }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + fixtures.clerk.organization?.getDomains.mockReturnValue(Promise.resolve({ data: [], total_count: 0 })); + + render( + <> + +
+ + +
+ + , + { wrapper }, + ); + + const cache = getStyleCache(fixtures.clerk); + expect(cache).toBeDefined(); + expect(cache?.key).toBe('cl-internal'); + + // Second sibling must reuse the cache the first sibling set on the WeakMap, + // rather than creating a new one. The WeakMap is the sharing channel. + const cacheAgain = getStyleCache(fixtures.clerk); + expect(cacheAgain).toBe(cache); + }); + + it('distinct clerk instances get distinct caches', async () => { + const first = await createFixtures(f => { + f.withUser({ email_addresses: ['a@clerk.com'] }); + }); + patchEnvironment(first.fixtures.clerk, first.fixtures.environment); + + const second = await createFixtures(f => { + f.withUser({ email_addresses: ['b@clerk.com'] }); + }); + patchEnvironment(second.fixtures.clerk, second.fixtures.environment); + + render( + +
+ , + { wrapper: first.wrapper }, + ); + + render( + +
+ , + { wrapper: second.wrapper }, + ); + + const cacheA = getStyleCache(first.fixtures.clerk); + const cacheB = getStyleCache(second.fixtures.clerk); + + expect(cacheA).toBeDefined(); + expect(cacheB).toBeDefined(); + expect(cacheA).not.toBe(cacheB); + }); + + it('first mount initializes the cache with the clerk-provided nonce', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + const expectedNonce = 'test-nonce-abc123'; + const originalGetOption = fixtures.clerk.__internal_getOption.bind(fixtures.clerk); + fixtures.clerk.__internal_getOption = (key: string) => { + if (key === 'nonce') { + return expectedNonce; + } + return originalGetOption(key); + }; + + render( + +
+ , + { wrapper }, + ); + + const cache = getStyleCache(fixtures.clerk); + expect(cache?.sheet.nonce).toBe(expectedNonce); + }); + + it('wraps emitted CSS in @layer when appearance.cssLayerName is configured', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + patchEnvironment(fixtures.clerk, fixtures.environment); + + fixtures.clerk.__internal_getOption = vi.fn((key: string) => { + if (key === 'appearance') { + return { cssLayerName: 'app-clerk' }; + } + return undefined; + }); + + render( + +
+ , + { wrapper }, + ); + + const cache = getStyleCache(fixtures.clerk); + expect(cache).toBeDefined(); + if (!cache) { + return; + } + + // Insert a style and read what actually landed in the cache's