From b733483d5a059495b8d6ea179bc80939e4caba79 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 13 Jul 2026 10:12:12 -0400 Subject: [PATCH 1/4] feat(shared): expose moduleManager via getter across build boundary --- packages/clerk-js/src/core/clerk.ts | 16 +++++++- .../src/__tests__/isomorphicClerk.test.ts | 25 ++++++++++++ packages/react/src/isomorphicClerk.ts | 13 +++++++ packages/shared/src/types/clerk.ts | 38 +++++++++++++++++-- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 9c0a8fd6413..d9d61e219dd 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -275,6 +275,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) { @@ -558,7 +572,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 19be83620d6..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; @@ -1401,7 +1433,7 @@ export type ClerkOptions = ClerkOptionsNavigation & */ supportEmail?: string; /** - * By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/sessions/POST/v1/client/sessions/%7Bsession_id%7D/touch){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior. + * By default, the [Clerk Frontend API `touch` endpoint](https://clerk.com/docs/reference/frontend-api/tag/Sessions#operation/touchSession){{ target: '_blank' }} is called during page focus to keep the last active session alive. This option allows you to disable this behavior. */ touchSession?: boolean; /** From 9a50394168f2399fbddc108ff3fd62f264510bfa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 13 Jul 2026 10:12:14 -0400 Subject: [PATCH 2/4] refactor(ui): shared profile UI infra (style-cache sharing, appearance overrides, Animated StrictMode fix, ProfilePagePanel) --- .../auto-animate-strictmode.test.tsx | 144 ++++++++++++++++++ .../src/customizables/elementDescriptors.ts | 3 +- packages/ui/src/elements/Animated.tsx | 29 +++- .../ui/src/elements/AppearanceOverrides.tsx | 16 ++ .../elements/ProfileCard/ProfileCardPage.tsx | 31 ++-- .../elements/ProfileCard/ProfilePagePanel.tsx | 40 +++++ .../__tests__/ProfilePagePanel.test.tsx | 92 +++++++++++ packages/ui/src/elements/ProfileCard/index.ts | 2 + packages/ui/src/hooks/useSafeState.ts | 1 + packages/ui/src/internal/appearance.ts | 3 +- packages/ui/src/internal/styleCacheStore.ts | 19 +++ packages/ui/src/primitives/Spinner.tsx | 1 + .../src/styledSystem/StyleCacheProvider.tsx | 31 +--- .../ui/src/styledSystem/createEmotionCache.ts | 41 +++++ 14 files changed, 400 insertions(+), 53 deletions(-) create mode 100644 packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx create mode 100644 packages/ui/src/elements/AppearanceOverrides.tsx create mode 100644 packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx create mode 100644 packages/ui/src/elements/ProfileCard/__tests__/ProfilePagePanel.test.tsx create mode 100644 packages/ui/src/internal/styleCacheStore.ts create mode 100644 packages/ui/src/styledSystem/createEmotionCache.ts 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/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 0e8f3dc9d92..a8bcde9bbd5 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -177,8 +177,6 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'formFieldInputShowPasswordIcon', 'formFieldInputCopyToClipboardButton', 'formFieldInputCopyToClipboardIcon', - 'searchInput', - 'searchInputClearButton', 'phoneInputBox', 'formInputGroup', @@ -465,6 +463,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'profileSectionPrimaryButton', 'profileSectionButtonGroup', 'profilePage', + 'profilePageContent', 'formattedPhoneNumber', 'formattedPhoneNumberFlag', diff --git a/packages/ui/src/elements/Animated.tsx b/packages/ui/src/elements/Animated.tsx index 93f1ab4129a..8a45acccc6e 100644 --- a/packages/ui/src/elements/Animated.tsx +++ b/packages/ui/src/elements/Animated.tsx @@ -1,14 +1,37 @@ -import { useAutoAnimate } from '@formkit/auto-animate/react'; -import { cloneElement, type PropsWithChildren } from 'react'; +import autoAnimate from '@formkit/auto-animate'; +import { cloneElement, type PropsWithChildren, useCallback, useRef } from 'react'; import { useAppearance } from '@/customizables'; type AnimatedProps = PropsWithChildren<{ asChild?: boolean }>; +type AutoAnimateController = ReturnType; + +function useSafeAutoAnimate(): [(node: HTMLElement | null) => void] { + const controllerRef = useRef(null); + const nodeRef = useRef(null); + + const ref = useCallback((node: HTMLElement | null) => { + if (node && node === nodeRef.current && controllerRef.current) { + return; + } + if (controllerRef.current) { + controllerRef.current.destroy?.(); + controllerRef.current = null; + } + nodeRef.current = node; + if (node instanceof HTMLElement && typeof node.animate === 'function') { + controllerRef.current = autoAnimate(node); + } + }, []); + + return [ref]; +} + export const Animated = (props: AnimatedProps) => { const { children, asChild } = props; const { animations } = useAppearance().parsedOptions; - const [parent] = useAutoAnimate(); + const [parent] = useSafeAutoAnimate(); if (asChild) { return cloneElement(children as any, { ref: animations ? parent : null }); diff --git a/packages/ui/src/elements/AppearanceOverrides.tsx b/packages/ui/src/elements/AppearanceOverrides.tsx new file mode 100644 index 00000000000..7941af09906 --- /dev/null +++ b/packages/ui/src/elements/AppearanceOverrides.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { AppearanceContext, useAppearance } from '../customizables'; +import type { Elements } from '../internal/appearance'; + +export const AppearanceOverrides = ({ elements, children }: { elements: Elements; children: React.ReactNode }) => { + const appearance = useAppearance(); + + const augmented = React.useMemo(() => { + // position 0 is the base theme; overrides slot in immediately above it + const [base, ...rest] = appearance.parsedElements; + return { ...appearance, parsedElements: [base, elements, ...rest] }; + }, [appearance, elements]); + + return {children}; +}; diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx index 0e821859137..a83388a4af3 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx @@ -1,15 +1,10 @@ import type { PropsWithChildren } from 'react'; -import { Col } from '../../customizables'; +import { Col, descriptors } from '../../customizables'; import type { ThemableCssProp } from '../../styledSystem'; import { mqu } from '../../styledSystem'; type ProfileCardPageProps = PropsWithChildren<{ - /** - * Whether to apply the standard per-page padding. - * @default true - */ - padding?: boolean; /** * Whether the page should bleed past the standard padding by applying matching * negative inline margins, so children render flush with the scroll-gutter / card border. @@ -18,7 +13,6 @@ type ProfileCardPageProps = PropsWithChildren<{ bleeding?: boolean; /** * Extra styles for the page wrapper — e.g. `flex: 1` to fill the scroll box height. - * Ignored when neither `padding` nor `bleeding` apply (no wrapper renders). */ sx?: ThemableCssProp; }>; @@ -29,24 +23,19 @@ type ProfileCardPageProps = PropsWithChildren<{ * Each routed page inside `UserProfile` / `OrganizationProfile` should wrap its content * in this component */ -export const ProfileCardPage = ({ children, padding = true, bleeding = false, sx }: ProfileCardPageProps) => { - if (!padding && !bleeding) { - return <>{children}; - } - +export const ProfileCardPage = ({ children, bleeding = false, sx }: ProfileCardPageProps) => { return ( ({ - ...(padding && { - paddingTop: theme.space.$7, - paddingBottom: theme.space.$7, - paddingInlineStart: theme.space.$8, - paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter on the parent - [mqu.sm]: { - padding: `${theme.space.$8} ${theme.space.$5}`, - }, - }), + paddingTop: theme.space.$7, + paddingBottom: theme.space.$7, + paddingInlineStart: theme.space.$8, + paddingInlineEnd: theme.space.$6, //smaller because of stable scrollbar gutter on the parent + [mqu.sm]: { + padding: `${theme.space.$8} ${theme.space.$5}`, + }, ...(bleeding && { marginInlineStart: `calc(${theme.space.$8} * -1)`, marginInlineEnd: `calc(${theme.space.$6} * -1)`, diff --git a/packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx b/packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx new file mode 100644 index 00000000000..71a2e770605 --- /dev/null +++ b/packages/ui/src/elements/ProfileCard/ProfilePagePanel.tsx @@ -0,0 +1,40 @@ +import type { ProfilePageId } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { Header } from '@/ui/elements/Header'; + +import type { LocalizationKey } from '../../customizables'; +import { Col, descriptors } from '../../customizables'; +import type { ThemableCssProp } from '../../styledSystem'; + +type ProfilePagePanelProps = PropsWithChildren<{ + pageId: ProfilePageId; + titleKey: LocalizationKey; + alertContent?: ReactNode; + outerSx?: ThemableCssProp; +}>; + +export const ProfilePagePanel = ({ children, pageId, titleKey, alertContent, outerSx }: ProfilePagePanelProps) => { + return ( + ({ gap: t.space.$8, isolation: 'isolate' }))} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {alertContent !== undefined && {alertContent}} + {children} + + + ); +}; diff --git a/packages/ui/src/elements/ProfileCard/__tests__/ProfilePagePanel.test.tsx b/packages/ui/src/elements/ProfileCard/__tests__/ProfilePagePanel.test.tsx new file mode 100644 index 00000000000..c3ff1ce4845 --- /dev/null +++ b/packages/ui/src/elements/ProfileCard/__tests__/ProfilePagePanel.test.tsx @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { clearFetchCache } from '../../../hooks'; +import { ProfileCard } from '../index'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('ProfilePagePanel', () => { + beforeEach(() => { + clearFetchCache(); + }); + + it('renders title heading', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
section content
+
, + { wrapper }, + ); + + expect(screen.getByRole('heading')).toBeInTheDocument(); + screen.getByText('section content'); + }); + + it('renders alert content when provided', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
content
+
, + { wrapper }, + ); + + screen.getByText('Something went wrong'); + }); + + it('does not render Card.Alert when alertContent is undefined', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render( + +
content
+
, + { wrapper }, + ); + + const alert = container.querySelector('[class*="alert"]'); + expect(alert).not.toBeInTheDocument(); + }); + + it('renders children', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + +
First
+
Second
+
, + { wrapper }, + ); + + expect(screen.getByTestId('child-1')).toBeInTheDocument(); + expect(screen.getByTestId('child-2')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/elements/ProfileCard/index.ts b/packages/ui/src/elements/ProfileCard/index.ts index 84df2ddd56e..54f26eae9fd 100644 --- a/packages/ui/src/elements/ProfileCard/index.ts +++ b/packages/ui/src/elements/ProfileCard/index.ts @@ -1,9 +1,11 @@ import { ProfileCardContent } from './ProfileCardContent'; import { ProfileCardPage } from './ProfileCardPage'; import { ProfileCardRoot } from './ProfileCardRoot'; +import { ProfilePagePanel } from './ProfilePagePanel'; export const ProfileCard = { Root: ProfileCardRoot, Content: ProfileCardContent, Page: ProfileCardPage, + PagePanel: ProfilePagePanel, }; diff --git a/packages/ui/src/hooks/useSafeState.ts b/packages/ui/src/hooks/useSafeState.ts index cba72ee5eec..7d3641738e9 100644 --- a/packages/ui/src/hooks/useSafeState.ts +++ b/packages/ui/src/hooks/useSafeState.ts @@ -13,6 +13,7 @@ export function useSafeState(initialState?: S | (() => S)) { const isMountedRef = React.useRef(true); React.useEffect(() => { + isMountedRef.current = true; return () => { isMountedRef.current = false; }; diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index bb64ee12425..b9706b9c8e1 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -306,8 +306,6 @@ export type ElementsConfig = { formFieldInputShowPasswordIcon: WithOptions; formFieldInputCopyToClipboardButton: WithOptions; formFieldInputCopyToClipboardIcon: WithOptions; - searchInput: WithOptions; - searchInputClearButton: WithOptions; phoneInputBox: WithOptions; formInputGroup: WithOptions; @@ -599,6 +597,7 @@ export type ElementsConfig = { profileSectionPrimaryButton: WithOptions; profileSectionButtonGroup: WithOptions; profilePage: WithOptions; + profilePageContent: WithOptions; // TODO: review formattedPhoneNumber: WithOptions; diff --git a/packages/ui/src/internal/styleCacheStore.ts b/packages/ui/src/internal/styleCacheStore.ts new file mode 100644 index 00000000000..e8a784edb77 --- /dev/null +++ b/packages/ui/src/internal/styleCacheStore.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line no-restricted-imports +import type { EmotionCache } from '@emotion/cache'; + +// Why: composed profile roots (UserProfile + OrganizationProfile) mount as +// separate React roots in the consumer tree, so N can be live per clerk instance. +// One `cl-internal` emotion cache per instance is the invariant (two live caches, +// same key = duplicate style inserts, broken emotion dedup/ordering). AIO gets this +// free from its single portal tree; composed reconstructs it by keying the cache +// on the clerk instance here so sibling roots reuse one cache instead of each making +// their own. +const store = new WeakMap(); + +export function getStyleCache(clerkInstance: object): EmotionCache | undefined { + return store.get(clerkInstance); +} + +export function setStyleCache(clerkInstance: object, cache: EmotionCache): void { + store.set(clerkInstance, cache); +} diff --git a/packages/ui/src/primitives/Spinner.tsx b/packages/ui/src/primitives/Spinner.tsx index a528596e348..5f0f5eed7b4 100644 --- a/packages/ui/src/primitives/Spinner.tsx +++ b/packages/ui/src/primitives/Spinner.tsx @@ -6,6 +6,7 @@ const { size, thickness, speed } = createCssVariables('speed', 'size', 'thicknes const { applyVariants, filterProps } = createVariants(theme => { return { base: { + boxSizing: 'border-box', display: 'inline-block', borderRadius: '99999px', borderTop: `${thickness} solid currentColor`, diff --git a/packages/ui/src/styledSystem/StyleCacheProvider.tsx b/packages/ui/src/styledSystem/StyleCacheProvider.tsx index 0863f32172b..d3cfbdec000 100644 --- a/packages/ui/src/styledSystem/StyleCacheProvider.tsx +++ b/packages/ui/src/styledSystem/StyleCacheProvider.tsx @@ -1,10 +1,8 @@ // eslint-disable-next-line no-restricted-imports -import createCache from '@emotion/cache'; -// eslint-disable-next-line no-restricted-imports -import { CacheProvider, type SerializedStyles } from '@emotion/react'; +import { CacheProvider } from '@emotion/react'; import React, { useMemo } from 'react'; -const el = document.querySelector('style#cl-style-insertion-point'); +import { createEmotionCache } from './createEmotionCache'; type StyleCacheProviderProps = React.PropsWithChildren<{ /** The nonce value for CSP (Content Security Policy). */ @@ -14,27 +12,10 @@ type StyleCacheProviderProps = React.PropsWithChildren<{ }>; export const StyleCacheProvider = (props: StyleCacheProviderProps) => { - const cache = useMemo(() => { - const emotionCache = createCache({ - key: 'cl-internal', - prepend: props.cssLayerName ? false : !el, - insertionPoint: el ? (el as HTMLElement) : undefined, - nonce: props.nonce, - }); - - if (props.cssLayerName) { - const prevInsert = emotionCache.insert.bind(emotionCache); - emotionCache.insert = (selector: string, serialized: SerializedStyles, sheet: any, shouldCache: boolean) => { - if (serialized && typeof serialized.styles === 'string' && !serialized.styles.startsWith('@layer')) { - const newSerialized = { ...serialized }; - newSerialized.styles = `@layer ${props.cssLayerName} {${serialized.styles}}`; - return prevInsert(selector, newSerialized, sheet, shouldCache); - } - return prevInsert(selector, serialized, sheet, shouldCache); - }; - } - return emotionCache; - }, [props.nonce, props.cssLayerName]); + const cache = useMemo( + () => createEmotionCache({ nonce: props.nonce, cssLayerName: props.cssLayerName }), + [props.nonce, props.cssLayerName], + ); return {props.children}; }; diff --git a/packages/ui/src/styledSystem/createEmotionCache.ts b/packages/ui/src/styledSystem/createEmotionCache.ts new file mode 100644 index 00000000000..b6660c0cbe2 --- /dev/null +++ b/packages/ui/src/styledSystem/createEmotionCache.ts @@ -0,0 +1,41 @@ +// eslint-disable-next-line no-restricted-imports +import createCache, { type EmotionCache } from '@emotion/cache'; +// eslint-disable-next-line no-restricted-imports +import type { SerializedStyles } from '@emotion/react'; + +type CreateEmotionCacheOptions = { + /** The nonce value for CSP (Content Security Policy). */ + nonce?: string; + /** The CSS layer name to wrap style insertions in. */ + cssLayerName?: string; +}; + +/** + * Creates the shared `cl-internal` emotion cache used by both the AIO + * `StyleCacheProvider` and the composed `ProfileProviderShell`. When + * `cssLayerName` is set, every insertion is wrapped in `@layer { ... }` + * so consumers can control cascade precedence relative to their own styles. + */ +export function createEmotionCache({ nonce, cssLayerName }: CreateEmotionCacheOptions): EmotionCache { + const el = typeof document !== 'undefined' ? document.querySelector('style#cl-style-insertion-point') : null; + const cache = createCache({ + key: 'cl-internal', + prepend: cssLayerName ? false : !el, + insertionPoint: el ? (el as HTMLElement) : undefined, + nonce, + }); + + if (cssLayerName) { + const prevInsert = cache.insert.bind(cache); + + cache.insert = (selector: string, serialized: SerializedStyles, sheet: any, shouldCache: boolean) => { + if (serialized && typeof serialized.styles === 'string' && !serialized.styles.startsWith('@layer')) { + const wrapped = { ...serialized, styles: `@layer ${cssLayerName} {${serialized.styles}}` }; + return prevInsert(selector, wrapped, sheet, shouldCache); + } + return prevInsert(selector, serialized, sheet, shouldCache); + }; + } + + return cache; +} From d8a864767dbb9c743108f031d8ef4af8cbe95454 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 15 Jul 2026 16:25:15 -0400 Subject: [PATCH 3/4] fix(ui): restore searchInput element descriptors dropped during rebase A rebase over #9098 dropped the searchInput and searchInputClearButton entries from ElementsConfig and APPEARANCE_KEYS, breaking @clerk/ui's type-check (SearchInput.tsx still references them) and, in turn, every downstream build including the swingset preview deploy. --- packages/ui/src/customizables/elementDescriptors.ts | 2 ++ packages/ui/src/internal/appearance.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index a8bcde9bbd5..06d73ebf8f2 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -177,6 +177,8 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'formFieldInputShowPasswordIcon', 'formFieldInputCopyToClipboardButton', 'formFieldInputCopyToClipboardIcon', + 'searchInput', + 'searchInputClearButton', 'phoneInputBox', 'formInputGroup', diff --git a/packages/ui/src/internal/appearance.ts b/packages/ui/src/internal/appearance.ts index b9706b9c8e1..aea1825c40a 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -306,6 +306,8 @@ export type ElementsConfig = { formFieldInputShowPasswordIcon: WithOptions; formFieldInputCopyToClipboardButton: WithOptions; formFieldInputCopyToClipboardIcon: WithOptions; + searchInput: WithOptions; + searchInputClearButton: WithOptions; phoneInputBox: WithOptions; formInputGroup: WithOptions; From 70d924ca74911f9b3ec0a27b2b965442ae1d03de Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 13 Jul 2026 10:12:16 -0400 Subject: [PATCH 4/4] refactor(ui): extract reusable Section components from profile pages --- .../components/ConfigureSSO/ConfigureSSO.tsx | 2 +- .../OrganizationBillingPage.tsx | 2 +- .../OrganizationGeneralPage.tsx | 66 +++--- .../OrganizationMembers.tsx | 1 + .../components/UserProfile/APIKeysPage.tsx | 1 + .../components/UserProfile/AccountPage.tsx | 89 ++------ .../UserProfile/AccountSections.tsx | 90 ++++++++ .../components/UserProfile/BillingPage.tsx | 2 +- .../components/UserProfile/SecurityPage.tsx | 52 +---- .../UserProfile/SecuritySections.tsx | 43 ++++ .../src/components/UserProfile/Web3Form.tsx | 11 + .../__tests__/AccountSections.test.tsx | 209 ++++++++++++++++++ .../__tests__/SecuritySections.test.tsx | 114 ++++++++++ .../__tests__/Web3Section.test.tsx | 52 ++++- 14 files changed, 594 insertions(+), 140 deletions(-) create mode 100644 packages/ui/src/components/UserProfile/AccountSections.tsx create mode 100644 packages/ui/src/components/UserProfile/SecuritySections.tsx create mode 100644 packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx create mode 100644 packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx 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(); + }); + }); + }); });