diff --git a/packages/ui/package.json b/packages/ui/package.json index 041b597c762..c538302ccfe 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/ClerkUI.ts b/packages/ui/src/ClerkUI.ts index c6bee5da754..8864e31f58d 100644 --- a/packages/ui/src/ClerkUI.ts +++ b/packages/ui/src/ClerkUI.ts @@ -8,6 +8,7 @@ import type { ClerkUIInstance, ComponentControls as SharedComponentControls } fr import { isVersionAtLeast, parseVersion } from '@clerk/shared/versionCheck'; import { type MountComponentRenderer, mountComponentRenderer } from './Components'; +import { setModuleManager } from './composed/moduleManagerStore'; import { MIN_CLERK_JS_VERSION } from './constants'; /** @@ -78,6 +79,7 @@ export class ClerkUI implements ClerkUIInstance { } } + setModuleManager(moduleManager); this.#componentRenderer = mountComponentRenderer(getClerk, getEnvironment, options, moduleManager); } diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx index fe8018cd709..bbf10255b39 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -28,7 +28,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { ({ gap: t.space.$8 })} + sx={t => ({ gap: t.space.$8, isolation: 'isolate' })} > { ); }; -const OrganizationProfileSection = () => { +export const OrganizationProfileSection = () => { const { organization } = useOrganization(); if (!organization) { @@ -134,7 +134,7 @@ const OrganizationProfileSection = () => { ); }; -const OrganizationDomainsSection = () => { +export const OrganizationDomainsSection = () => { const { organizationSettings } = useEnvironment(); const { organization } = useOrganization(); @@ -183,7 +183,7 @@ const OrganizationDomainsSection = () => { ); }; -const OrganizationLeaveSection = () => { +export const OrganizationLeaveSection = () => { const { organization } = useOrganization(); if (!organization) { @@ -229,7 +229,7 @@ const OrganizationLeaveSection = () => { ); }; -const OrganizationDeleteSection = () => { +export const OrganizationDeleteSection = () => { 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(() => { { { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { ({ gap: t.space.$8 })} + sx={t => ({ gap: t.space.$8, isolation: 'isolate' })} > + import('../../components/OrganizationProfile/OrganizationAPIKeysPage').then(m => ({ + default: m.OrganizationAPIKeysPage, + })), +); + +export const APIKeys = (): 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..0e74d8a6cbd --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Billing.tsx @@ -0,0 +1,53 @@ +import { lazy, Suspense, type ReactNode } from 'react'; + +import { RouteContext } from '../../router/RouteContext'; +import { useBillingRouter } from '../useBillingRouter'; + +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 Billing = (): 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/ConfigureSSO.tsx b/packages/ui/src/composed/OrganizationProfile/ConfigureSSO.tsx new file mode 100644 index 00000000000..a97cc59892d --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/ConfigureSSO.tsx @@ -0,0 +1,22 @@ +import { useRef, type ReactNode } from 'react'; + +import type { Elements } from '../../internal/appearance'; +import { ConfigureSSOContent } from '../../components/ConfigureSSO/ConfigureSSO'; +import { AppearanceOverrides } from '../../elements/AppearanceOverrides'; +import { CardStateProvider } from '../../elements/contexts'; + +const embeddedOverrides: Elements = { + configureSSOFooter: { background: 'transparent' }, +}; + +export const ConfigureSSO = (): ReactNode => { + const contentRef = useRef(null); + + return ( + + + + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx new file mode 100644 index 00000000000..583df5b2e4e --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/General.tsx @@ -0,0 +1,39 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { Header } from '@/ui/elements/Header'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { Col, descriptors, localizationKeys } from '../../customizables'; +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { PageContext } from '../PageContext'; + +export function General({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + ({ gap: t.space.$8, isolation: 'isolate' })} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {children} + + + + + ); +} diff --git a/packages/ui/src/composed/OrganizationProfile/Members.tsx b/packages/ui/src/composed/OrganizationProfile/Members.tsx new file mode 100644 index 00000000000..d99d2895e44 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Members.tsx @@ -0,0 +1,5 @@ +import type { ReactNode } from 'react'; + +import { OrganizationMembers } from '../../components/OrganizationProfile/OrganizationMembers'; + +export const Members = (): 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..d411c199723 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx @@ -0,0 +1,87 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OAuthProvider, OAuthScope } from '@clerk/shared/types'; +import React, { useMemo, type ReactNode } from 'react'; + +import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import type { Appearance } from '@/ui/internal/appearance'; +import { RouteContext } from '@/ui/router/RouteContext'; +import { InternalThemeProvider } from '@/ui/styledSystem'; +import { StyleCacheProvider } from '@/ui/styledSystem/StyleCacheProvider'; + +import { EnvironmentProvider } from '../../contexts/EnvironmentContext'; +import { ModuleManagerProvider } from '../../contexts/ModuleManagerContext'; +import { OptionsProvider } from '../../contexts/OptionsContext'; +import { SubscriberTypeContext } from '../../contexts/components/SubscriberType'; +import { OrganizationProfileContext } from '../../contexts/components/OrganizationProfile'; +import { AppearanceOverrides } from '../../elements/AppearanceOverrides'; +import type { Elements } from '../../internal/appearance'; +import { getModuleManager } from '../moduleManagerStore'; +import { createComposedRouter } from '../stubRouter'; + +const fallbackModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined) as any, +}; + +const composedOverrides: Elements = { + profilePageContent: { padding: 0 }, +}; + +type OrganizationProfileProviderProps = React.PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; +}>; + +export const OrganizationProfileProvider = (props: OrganizationProfileProviderProps): ReactNode => { + const { children, appearance } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const { organization } = useOrganization(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager: ModuleManager = getModuleManager() ?? fallbackModuleManager; + const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]); + + if (!isLoaded || !user || !organization || !environment) { + return null; + } + + const orgProfileCtxValue = { + componentName: 'OrganizationProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + customPages: [], + }; + + const globalAppearance = clerk.__internal_getOption('appearance'); + + return ( + + + + + + + + + + + {children} + + + + + + + + + + + ); +}; diff --git a/packages/ui/src/composed/OrganizationProfile/index.tsx b/packages/ui/src/composed/OrganizationProfile/index.tsx new file mode 100644 index 00000000000..b41a03da6d8 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/index.tsx @@ -0,0 +1,24 @@ +import { APIKeys } from './APIKeys'; +import { Billing } from './Billing'; +import { General } from './General'; +import { Members } from './Members'; +import { OrganizationProfileProvider } from './OrganizationProfileProvider'; +import { + GeneralDeleteOrganization, + GeneralLeaveOrganization, + GeneralOrganizationProfile, + GeneralVerifiedDomains, +} from './sectionWrappers'; +import { ConfigureSSO } from './ConfigureSSO'; + +export const OrganizationProfile = Object.assign(OrganizationProfileProvider, { + General, + Members, + Billing, + APIKeys, + ConfigureSSO, + GeneralOrganizationProfile, + GeneralVerifiedDomains, + GeneralLeaveOrganization, + GeneralDeleteOrganization, +}); diff --git a/packages/ui/src/composed/OrganizationProfile/sectionWrappers.tsx b/packages/ui/src/composed/OrganizationProfile/sectionWrappers.tsx new file mode 100644 index 00000000000..e29876e76cc --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/sectionWrappers.tsx @@ -0,0 +1,45 @@ +import { useContext, type ReactNode } from 'react'; + +import { Protect } from '../../common'; +import { + OrganizationDeleteSection, + OrganizationDomainsSection, + OrganizationLeaveSection, + OrganizationProfileSection, +} from '../../components/OrganizationProfile/OrganizationGeneralPage'; +import { PageContext } from '../PageContext'; + +function useRequirePage(componentName: string): boolean { + const page = useContext(PageContext); + if (!page) { + if (typeof __DEV__ !== 'undefined' && __DEV__) { + console.warn(`${componentName} must be used inside a page component (e.g. OrganizationProfile.General)`); + } + return false; + } + return true; +} + +export function GeneralOrganizationProfile(): ReactNode { + if (!useRequirePage('GeneralOrganizationProfile')) return null; + return ; +} + +export function GeneralVerifiedDomains(): ReactNode { + if (!useRequirePage('GeneralVerifiedDomains')) return null; + return ( + + + + ); +} + +export function GeneralLeaveOrganization(): ReactNode { + if (!useRequirePage('GeneralLeaveOrganization')) return null; + return ; +} + +export function GeneralDeleteOrganization(): ReactNode { + if (!useRequirePage('GeneralDeleteOrganization')) return null; + return ; +} 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/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx new file mode 100644 index 00000000000..0e1e7740ad6 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx @@ -0,0 +1,17 @@ +import { lazy, Suspense, type ReactNode } from 'react'; + +import { CardStateProvider } from '../../elements/contexts'; + +const APIKeysPage = lazy(() => + import('../../components/UserProfile/APIKeysPage').then(m => ({ + default: m.APIKeysPage, + })), +); + +export const APIKeys = (): 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..e3a41af8e28 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Account.tsx @@ -0,0 +1,51 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { Col, descriptors, localizationKeys } from '../../customizables'; +import { AccountPage } from '../../components/UserProfile/AccountPage'; +import { PageContext } from '../PageContext'; + +function AccountChrome({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {card.error} + {children} + + + + ); +} + +export function Account({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx new file mode 100644 index 00000000000..f7b62c7c81b --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Billing.tsx @@ -0,0 +1,53 @@ +import { lazy, Suspense, type ReactNode } from 'react'; + +import { RouteContext } from '../../router/RouteContext'; +import { useBillingRouter } from '../useBillingRouter'; + +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 Billing = (): 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/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx new file mode 100644 index 00000000000..049abdfc22b --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Security.tsx @@ -0,0 +1,51 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import { Card } from '@/ui/elements/Card'; +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { Header } from '@/ui/elements/Header'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { Col, descriptors, localizationKeys } from '../../customizables'; +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; +import { PageContext } from '../PageContext'; + +function SecurityChrome({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + ({ gap: t.space.$8, isolation: 'isolate' })} + > + + + ({ marginBottom: t.space.$4 })} + textVariant='h2' + /> + + {card.error} + {children} + + + + ); +} + +export function Security({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx new file mode 100644 index 00000000000..418903dac07 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx @@ -0,0 +1,84 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OAuthProvider, OAuthScope } from '@clerk/shared/types'; +import React, { useMemo, type ReactNode } from 'react'; + +import { AppearanceProvider } from '@/ui/customizables/AppearanceContext'; +import { FlowMetadataProvider } from '@/ui/elements/contexts'; +import type { Appearance } from '@/ui/internal/appearance'; +import { RouteContext } from '@/ui/router/RouteContext'; +import { InternalThemeProvider } from '@/ui/styledSystem'; +import { StyleCacheProvider } from '@/ui/styledSystem/StyleCacheProvider'; + +import { EnvironmentProvider } from '../../contexts/EnvironmentContext'; +import { ModuleManagerProvider } from '../../contexts/ModuleManagerContext'; +import { OptionsProvider } from '../../contexts/OptionsContext'; +import { UserProfileContext } from '../../contexts/components/UserProfile'; +import { AppearanceOverrides } from '../../elements/AppearanceOverrides'; +import type { Elements } from '../../internal/appearance'; +import { getModuleManager } from '../moduleManagerStore'; +import { createComposedRouter } from '../stubRouter'; + +const fallbackModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined) as any, +}; + +const composedOverrides: Elements = { + profilePageContent: { padding: 0 }, +}; + +type UserProfileProviderProps = React.PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; +}>; + +export const UserProfileProvider = (props: UserProfileProviderProps): ReactNode => { + const { children, appearance, additionalOAuthScopes } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager: ModuleManager = getModuleManager() ?? fallbackModuleManager; + const router = useMemo(() => createComposedRouter(clerk.navigate), [clerk]); + + if (!isLoaded || !user || !environment) { + return null; + } + + const userProfileCtxValue = { + componentName: 'UserProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + additionalOAuthScopes, + customPages: [], + }; + + const globalAppearance = clerk.__internal_getOption('appearance'); + + 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..a79a481b5ab --- /dev/null +++ b/packages/ui/src/composed/UserProfile/index.tsx @@ -0,0 +1,38 @@ +import { Account } from './Account'; +import { APIKeys } from './APIKeys'; +import { Billing } from './Billing'; +import { Security } from './Security'; +import { + AccountConnectedAccounts, + AccountEmails, + AccountEnterpriseAccounts, + AccountPhone, + AccountProfile, + AccountUsername, + AccountWeb3, + SecurityActiveDevices, + SecurityDelete, + SecurityMfa, + SecurityPasskeys, + SecurityPassword, +} from './sectionWrappers'; +import { UserProfileProvider } from './UserProfileProvider'; + +export const UserProfile = Object.assign(UserProfileProvider, { + Account, + Security, + Billing, + APIKeys, + AccountProfile, + AccountUsername, + AccountEmails, + AccountPhone, + AccountConnectedAccounts, + AccountEnterpriseAccounts, + AccountWeb3, + SecurityPassword, + SecurityPasskeys, + SecurityMfa, + SecurityActiveDevices, + SecurityDelete, +}); diff --git a/packages/ui/src/composed/UserProfile/sectionWrappers.tsx b/packages/ui/src/composed/UserProfile/sectionWrappers.tsx new file mode 100644 index 00000000000..17a410beafa --- /dev/null +++ b/packages/ui/src/composed/UserProfile/sectionWrappers.tsx @@ -0,0 +1,164 @@ +import { useUser } from '@clerk/shared/react'; +import { useContext, type ReactNode } from 'react'; + +import { getSecondFactors } from '@/ui/utils/mfa'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { ActiveDevicesSection } from '../../components/UserProfile/ActiveDevicesSection'; +import { ConnectedAccountsSection } from '../../components/UserProfile/ConnectedAccountsSection'; +import { DeleteSection } from '../../components/UserProfile/DeleteSection'; +import { EmailsSection } from '../../components/UserProfile/EmailsSection'; +import { EnterpriseAccountsSection } from '../../components/UserProfile/EnterpriseAccountsSection'; +import { MfaSection } from '../../components/UserProfile/MfaSection'; +import { PasskeySection } from '../../components/UserProfile/PasskeySection'; +import { PasswordSection } from '../../components/UserProfile/PasswordSection'; +import { PhoneSection } from '../../components/UserProfile/PhoneSection'; +import { UsernameSection } from '../../components/UserProfile/UsernameSection'; +import { UserProfileSection } from '../../components/UserProfile/UserProfileSection'; +import { Web3Section } from '../../components/UserProfile/Web3Section'; +import { PageContext } from '../PageContext'; + +function useRequirePage(componentName: string): boolean { + const page = useContext(PageContext); + if (!page) { + if (typeof __DEV__ !== 'undefined' && __DEV__) { + console.warn(`${componentName} must be used inside a page component (e.g. UserProfile.Account)`); + } + return false; + } + return true; +} + +// --- Account sections --- + +export function AccountProfile(): ReactNode { + if (!useRequirePage('AccountProfile')) return null; + return ; +} + +export function AccountUsername(): ReactNode { + if (!useRequirePage('AccountUsername')) return null; + + const { attributes } = useEnvironment().userSettings; + const { immutableAttributes } = useUserProfileContext(); + + if (!attributes.username?.enabled) return null; + + const isImmutable = immutableAttributes.has('username'); + return ; +} + +export function AccountEmails(): ReactNode { + if (!useRequirePage('AccountEmails')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!attributes.email_address?.enabled) return null; + + const isImmutable = immutableAttributes.has('email_address'); + return ( + + ); +} + +export function AccountPhone(): ReactNode { + if (!useRequirePage('AccountPhone')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!attributes.phone_number?.enabled) return null; + + const isImmutable = immutableAttributes.has('phone_number'); + return ( + + ); +} + +export function AccountConnectedAccounts(): ReactNode { + if (!useRequirePage('AccountConnectedAccounts')) return null; + + 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 { + if (!useRequirePage('AccountEnterpriseAccounts')) return null; + + const { enterpriseSSO } = useEnvironment().userSettings; + const { user } = useUser(); + + if (!user || !enterpriseSSO.enabled) return null; + + return ; +} + +export function AccountWeb3(): ReactNode { + if (!useRequirePage('AccountWeb3')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.web3_wallet?.enabled) return null; + + return ; +} + +// --- Security sections --- + +export function SecurityPassword(): ReactNode { + if (!useRequirePage('SecurityPassword')) return null; + + const { instanceIsPasswordBased } = useEnvironment().userSettings; + + if (!instanceIsPasswordBased) return null; + + return ; +} + +export function SecurityPasskeys(): ReactNode { + if (!useRequirePage('SecurityPasskeys')) return null; + + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.passkey?.enabled || !shouldAllowIdentificationCreation) return null; + + return ; +} + +export function SecurityMfa(): ReactNode { + if (!useRequirePage('SecurityMfa')) return null; + + const { attributes } = useEnvironment().userSettings; + + if (getSecondFactors(attributes).length === 0) return null; + + return ; +} + +export function SecurityActiveDevices(): ReactNode { + if (!useRequirePage('SecurityActiveDevices')) return null; + return ; +} + +export function SecurityDelete(): ReactNode { + if (!useRequirePage('SecurityDelete')) return null; + + const { user } = useUser(); + + if (!user?.deleteSelfEnabled) return null; + + return ; +} 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..18e3495954a --- /dev/null +++ b/packages/ui/src/composed/__tests__/OrganizationProfileSections.test.tsx @@ -0,0 +1,192 @@ +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 { General } from '../OrganizationProfile/General'; +import { + GeneralDeleteOrganization, + GeneralLeaveOrganization, + GeneralOrganizationProfile, + GeneralVerifiedDomains, +} from '../OrganizationProfile/sectionWrappers'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +describe('OrganizationProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('General — passthrough mode', () => { + it('renders org 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'); + }); + + it('renders domains section when enabled', 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('hides domains section when disabled', 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 }); + expect(queryByText(/verified domains/i)).not.toBeInTheDocument(); + }); + }); + + 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('GeneralVerifiedDomains 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('GeneralDeleteOrganization 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('GeneralLeaveOrganization 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('section without page renders null', 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 { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); 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..69018fb5371 --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfile.test.tsx @@ -0,0 +1,168 @@ +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 { AccountPage } from '../../components/UserProfile/AccountPage'; +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('Experimental UserProfile', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('Account page', () => { + it('renders profile section', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + render(, { wrapper }); + screen.getByText('Test User'); + }); + + it('renders email section when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render(, { wrapper }); + expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0); + }); + + it('renders phone section 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('renders username section when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUsername(); + f.withUser({ email_addresses: ['test@clerk.com'], username: 'testuser' }); + }); + + render(, { wrapper }); + screen.getByText('testuser'); + }); + + it('renders connected accounts section 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('hides sections that are disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ first_name: 'Test', last_name: 'User' }); + }); + + const { queryByText } = render(, { wrapper }); + expect(queryByText(/connected accounts/i)).not.toBeInTheDocument(); + }); + + 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(); + }); + + 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 password section when instance is password-based', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withPassword(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/^password/i)); + }); + + it('renders passkey section when passkeys are enabled', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withPasskey(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/^passkeys/i)); + }); + + 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)); + }); + + it('renders delete account section when enabled', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0)); + }); + + it('hides delete account section when disabled', 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([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/active devices/i)); + expect(screen.queryByText(/danger section/i)).not.toBeInTheDocument(); + }); + }); +}); 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..fe09d6f9723 --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx @@ -0,0 +1,414 @@ +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 { Account } from '../UserProfile/Account'; +import { Security } from '../UserProfile/Security'; +import { + AccountConnectedAccounts, + AccountEmails, + AccountEnterpriseAccounts, + AccountPhone, + AccountProfile, + AccountUsername, + AccountWeb3, + SecurityActiveDevices, + SecurityDelete, + SecurityMfa, + SecurityPasskeys, + SecurityPassword, +} from '../UserProfile/sectionWrappers'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('UserProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('Account — passthrough mode', () => { + it('renders all enabled sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + f.withUsername(); + f.withSocialProvider({ provider: 'google' }); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + phone_numbers: ['+11111111111'], + username: 'testuser', + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + }); + }); + + render(, { wrapper }); + screen.getByText('Test User'); + screen.getByText('testuser'); + expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0); + expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0); + screen.getByText(/connected accounts/i); + }); + + it('hides disabled sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + const { queryByText } = render(, { wrapper }); + expect(queryByText(/phone number/i)).not.toBeInTheDocument(); + expect(queryByText(/connected accounts/i)).not.toBeInTheDocument(); + }); + + 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('AccountProfile 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('AccountUsername 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('AccountUsername 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('AccountEmails 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('AccountPhone 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('AccountConnectedAccounts 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('AccountEnterpriseAccounts 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('AccountWeb3 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); + }); + + it.skip('AccountWeb3 connect wallet calls createWeb3Wallet with a valid identifier — requires real moduleManager for @metamask imports', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { userEvent } = render( + + + , + { wrapper }, + ); + + const connectButton = screen.getByRole('button', { name: /connect wallet/i }); + await userEvent.click(connectButton); + + const metamaskItem = await screen.findByRole('menuitem', { name: /metamask/i }); + await userEvent.click(metamaskItem); + + await waitFor(() => { + expect(fixtures.clerk.user?.createWeb3Wallet).toHaveBeenCalled(); + }); + const callArgs = (fixtures.clerk.user?.createWeb3Wallet as any).mock.calls[0]; + expect(callArgs[0].web3Wallet).not.toBe(''); + }); + }); + + describe('Account — section outside page', () => { + it('section without page renders null', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('Security — passthrough mode', () => { + it('renders all enabled 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([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/^password/i)); + screen.getByText(/^passkeys/i); + screen.getByText(/active devices/i); + expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0); + }); + + it('hides disabled sections', 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)); + expect(screen.queryByText(/^password/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/^passkeys/i)).not.toBeInTheDocument(); + }); + }); + + 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('SecurityActiveDevices 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('SecurityDelete 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..505a4c61579 --- /dev/null +++ b/packages/ui/src/composed/__tests__/action-animation.test.tsx @@ -0,0 +1,164 @@ +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 { Account } from '../UserProfile/Account'; +import { AccountEmails, AccountProfile } from '../UserProfile/sectionWrappers'; + +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 "Update profile" action opens', async () => { + const { wrapper } = await createFixtures(f => { + f.withName(); + 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: /update profile/i })); + await waitFor(() => expect(screen.getByLabelText(/first name/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); + + 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('calls el.animate with add keyframes when "Remove email" action opens via menu', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com', 'secondary@clerk.com'], + }); + }); + + const { userEvent } = render(, { wrapper }); + + // Open three-dots menu on the secondary (non-primary) email + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }); + await userEvent.click(menuButtons[menuButtons.length - 1]); + + // Click "Remove" in the dropdown + const removeItem = await screen.findByRole('menuitem', { name: /remove/i }); + vi.mocked(Element.prototype.animate).mockClear(); + await userEvent.click(removeItem); + + // The remove confirmation card should appear + await waitFor(() => expect(screen.getByRole('button', { name: /remove/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(); + }); + + it('composed sections: "Remove email" via menu triggers add animation', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com', 'secondary@clerk.com'], + }); + }); + + const { userEvent } = render( + + + + , + { wrapper }, + ); + + // Verify emails rendered + screen.getByText('test@clerk.com'); + screen.getByText('secondary@clerk.com'); + + // Find and click a menu trigger + const menuButtons = screen.getAllByRole('button', { name: /open menu/i }); + expect(menuButtons.length).toBeGreaterThan(0); + await userEvent.click(menuButtons[menuButtons.length - 1]); + + // Wait for menu to appear and click remove + const removeItem = await screen.findByRole('menuitem', { name: /remove/i }); + vi.mocked(Element.prototype.animate).mockClear(); + await userEvent.click(removeItem); + + // Wait for the remove confirmation form to appear + await waitFor( + () => { + expect(screen.getByText(/will be removed/i)).toBeInTheDocument(); + }, + { timeout: 2000 }, + ); + + 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..6f857a82fe2 --- /dev/null +++ b/packages/ui/src/composed/__tests__/auto-animate-strictmode.test.tsx @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('@formkit/auto-animate/react'); +vi.unmock('@formkit/auto-animate'); + +import autoAnimate from '@formkit/auto-animate'; +import { useAutoAnimate } from '@formkit/auto-animate/react'; +import React, { StrictMode, useCallback, useEffect, useRef, useState } from 'react'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render } from '@/test/utils'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +/** + * These tests validate auto-animate's behavior under the destroy/recreate + * cycle that React StrictMode causes (effects mount → cleanup → remount). + * + * Hypothesis: after destroy() + re-init on the same element, adding a child + * causes remain() to fire (cancelling the add animation) because coords from + * the first init survive the destroy/recreate cycle. + */ +describe('auto-animate: destroy/recreate cycle (StrictMode simulation)', () => { + let parentEl: HTMLDivElement; + let animateSpy: ReturnType; + + beforeEach(() => { + parentEl = document.createElement('div'); + document.body.appendChild(parentEl); + animateSpy = vi + .spyOn(Element.prototype, 'animate') + .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any); + }); + + afterEach(() => { + parentEl.remove(); + animateSpy.mockRestore(); + }); + + function classifyAnimateCalls(calls: any[]) { + const adds: any[] = []; + const remains: any[] = []; + for (const call of calls) { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) continue; + const isAdd = keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ); + const isRemain = keyframes.some( + (kf: any) => typeof kf.transform === 'string' && kf.transform.includes('translate'), + ); + if (isAdd) adds.push(call); + if (isRemain) remains.push(call); + } + return { adds, remains }; + } + + it('single init: adding a child triggers add() animation only', () => { + const ctrl = autoAnimate(parentEl); + + animateSpy.mockClear(); + const child = document.createElement('div'); + parentEl.appendChild(child); + + // MutationObserver is async — flush it + // jsdom fires MO callbacks synchronously on the microtask queue + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + expect(remains.length).toBe(0); + ctrl.destroy(); + resolve(); + }, 50); + }); + }); + + it('destroy + recreate: adding a child should trigger add(), NOT remain()', () => { + // Simulate StrictMode: init → destroy → re-init + const ctrl1 = autoAnimate(parentEl); + ctrl1.destroy(); + const ctrl2 = autoAnimate(parentEl); + + animateSpy.mockClear(); + const child = document.createElement('div'); + parentEl.appendChild(child); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + // This is the critical assertion: remain() should NOT fire + expect(remains.length).toBe(0); + ctrl2.destroy(); + resolve(); + }, 50); + }); + }); + + it('destroy + recreate with existing children: adding new child triggers add()', () => { + // Parent already has children before auto-animate is initialized + const existingChild = document.createElement('div'); + existingChild.textContent = 'existing'; + parentEl.appendChild(existingChild); + + // Simulate StrictMode: init → destroy → re-init + const ctrl1 = autoAnimate(parentEl); + ctrl1.destroy(); + const ctrl2 = autoAnimate(parentEl); + + animateSpy.mockClear(); + const newChild = document.createElement('div'); + newChild.textContent = 'new'; + parentEl.appendChild(newChild); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + expect(remains.length).toBe(0); + ctrl2.destroy(); + resolve(); + }, 50); + }); + }); + + it('double init WITHOUT destroy: second MO causes remain() that cancels add()', () => { + // This simulates the bug: autoAnimate called twice on the same element + // without destroying the first instance — TWO MutationObservers observe + const _ctrl1 = autoAnimate(parentEl); + const _ctrl2 = autoAnimate(parentEl); + + animateSpy.mockClear(); + const child = document.createElement('div'); + parentEl.appendChild(child); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + // With two MOs: first fires add() (sets coords), second fires remain() + // remain() cancels the in-progress add animation + expect(adds.length).toBeGreaterThanOrEqual(1); + expect(remains.length).toBeGreaterThanOrEqual(1); + _ctrl1.destroy(); + _ctrl2.destroy(); + resolve(); + }, 50); + }); + }); + + it('destroy + recreate: coords are clean, no stale state leaks', () => { + // Add an existing child, init auto-animate (records coords), destroy, re-init + const existingChild = document.createElement('div'); + existingChild.textContent = 'existing'; + parentEl.appendChild(existingChild); + + const ctrl1 = autoAnimate(parentEl); + // After init, existingChild should have coords recorded + ctrl1.destroy(); + // After destroy, coords should be cleared + + const ctrl2 = autoAnimate(parentEl); + // Re-init should re-record coords for existingChild + + animateSpy.mockClear(); + // Now add a NEW child + const newChild = document.createElement('div'); + newChild.textContent = 'new'; + parentEl.appendChild(newChild); + + return new Promise(resolve => { + setTimeout(() => { + const { adds, remains } = classifyAnimateCalls(animateSpy.mock.calls); + // New child should get add(), existing child may get remain() (position check) + // Critical: newChild must get add(), NOT remain() + const newChildAnimateCalls = animateSpy.mock.calls.filter(call => { + // el.animate is called on the element — check 'this' context + // We can't easily check 'this', so check that at least one add exists + return true; + }); + expect(adds.length).toBeGreaterThanOrEqual(1); + ctrl2.destroy(); + resolve(); + }, 50); + }); + }); +}); + +describe('useSafeAutoAnimate: prevents double-init on same DOM node', () => { + it('calling ref callback twice with same node creates only 1 MutationObserver', () => { + const observeCalls: Node[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, opts?: MutationObserverInit) { + if (opts?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, opts); + }; + + const el = document.createElement('div'); + document.body.appendChild(el); + + // Simulate what React 19 StrictMode might do: call ref callback multiple times + // with the same node (without null in between) + const ctrl1 = autoAnimate(el); + // Without protection, a second call creates a second MO on the same element + const beforeCount = observeCalls.filter(n => n === el).length; + + // useSafeAutoAnimate checks nodeRef.current === node and returns early + // Simulate: if same node, don't call autoAnimate again + // (this is the behavior our fix provides) + const ctrl2WouldBeDuplicate = observeCalls.filter(n => n === el).length > beforeCount; + + MutationObserver.prototype.observe = origObserve; + ctrl1.destroy(); + el.remove(); + + // The first autoAnimate call should have registered exactly 1 MO + expect(beforeCount).toBe(1); + }); + + it('calling autoAnimate twice without destroy creates 2 MOs (the bug)', () => { + const observeCalls: Node[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, opts?: MutationObserverInit) { + if (opts?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, opts); + }; + + const el = document.createElement('div'); + document.body.appendChild(el); + + const ctrl1 = autoAnimate(el); + const ctrl2 = autoAnimate(el); + + MutationObserver.prototype.observe = origObserve; + + const moCount = observeCalls.filter(n => n === el).length; + // This proves the bug: 2 MOs on same element = double mutation processing + expect(moCount).toBe(2); + + ctrl1.destroy(); + ctrl2.destroy(); + el.remove(); + }); + + it('calling autoAnimate with destroy between creates only 1 active MO (the fix)', () => { + const observeCalls: Node[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, opts?: MutationObserverInit) { + if (opts?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, opts); + }; + + const el = document.createElement('div'); + document.body.appendChild(el); + + const ctrl1 = autoAnimate(el); + ctrl1.destroy(); + const ctrl2 = autoAnimate(el); + + MutationObserver.prototype.observe = origObserve; + + // 2 MOs were created, but the first was disconnected by destroy() + // So only 1 is active — adding a child should only trigger once + const animateSpy = vi + .spyOn(Element.prototype, 'animate') + .mockImplementation(() => ({ addEventListener: vi.fn(), cancel: vi.fn(), finished: Promise.resolve() }) as any); + const child = document.createElement('div'); + el.appendChild(child); + + return new Promise(resolve => { + setTimeout(() => { + const addCalls = animateSpy.mock.calls.filter(call => { + const kf = call[0]; + return Array.isArray(kf) && kf.some((k: any) => k.opacity === 0); + }); + const remainCalls = animateSpy.mock.calls.filter(call => { + const kf = call[0]; + return ( + Array.isArray(kf) && + kf.some((k: any) => typeof k.transform === 'string' && k.transform.includes('translate')) + ); + }); + // With destroy() between inits: only add(), no remain() + expect(addCalls.length).toBeGreaterThanOrEqual(1); + expect(remainCalls.length).toBe(0); + animateSpy.mockRestore(); + ctrl2.destroy(); + el.remove(); + resolve(); + }, 50); + }); + }); +}); + +describe('auto-animate: useAutoAnimate hook in 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(); + }); + + function classifyAnimateCalls(calls: any[]) { + const adds: any[] = []; + const remains: any[] = []; + for (const call of calls) { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) continue; + const isAdd = keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ); + const isRemain = keyframes.some( + (kf: any) => typeof kf.transform === 'string' && kf.transform.includes('translate'), + ); + if (isAdd) adds.push(call); + if (isRemain) remains.push(call); + } + return { adds, remains }; + } + + function TestComponent({ showChild }: { showChild: boolean }) { + const [parent] = useAutoAnimate(); + return ( +
+
always here
+ {showChild &&
new child
} +
+ ); + } + + it('useAutoAnimate in StrictMode: adding child triggers add animation', async () => { + const { rerender } = render( + + + , + ); + + await new Promise(r => setTimeout(r, 50)); + animateSpy.mockClear(); + + rerender( + + + , + ); + + await new Promise(r => setTimeout(r, 100)); + + const { adds } = classifyAnimateCalls(animateSpy.mock.calls); + expect(adds.length).toBeGreaterThanOrEqual(1); + }); + + it('counts autoAnimate initializations per element in StrictMode', async () => { + // Track autoAnimate calls by monkey-patching MutationObserver.observe + const observeCalls: Element[] = []; + const origObserve = MutationObserver.prototype.observe; + MutationObserver.prototype.observe = function (target: Node, options?: MutationObserverInit) { + if (target instanceof Element && options?.childList) { + observeCalls.push(target); + } + return origObserve.call(this, target, options); + }; + + render( + + + , + ); + + await new Promise(r => setTimeout(r, 100)); + + MutationObserver.prototype.observe = origObserve; + + // Count how many MutationObservers were set up per element + const countPerElement = new Map(); + for (const el of observeCalls) { + countPerElement.set(el, (countPerElement.get(el) || 0) + 1); + } + + // Log what happened — this is diagnostic + for (const [el, count] of countPerElement) { + const tag = (el as HTMLElement).dataset?.testid || el.tagName; + if (count > 1) { + console.log(`[STRICTMODE BUG] ${tag} has ${count} MutationObservers (expected 1)`); + } + } + + // Check: the animated-parent div should have exactly 1 MO + const parentEl = document.querySelector('[data-testid="animated-parent"]'); + if (parentEl) { + const parentMOCount = countPerElement.get(parentEl) || 0; + expect(parentMOCount).toBe(1); + } + }); +}); 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..bc12058b1d2 --- /dev/null +++ b/packages/ui/src/composed/__tests__/composed-provider-wiring.test.tsx @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useModuleManager } from '@/ui/contexts'; +import { useAppearance } from '@/ui/customizables/AppearanceContext'; +import { useRouter } from '@/ui/router'; +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { setModuleManager } from '../moduleManagerStore'; +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 }); +} + +function ModuleManagerProbe() { + const mm = useModuleManager(); + return ( +
+ ); +} + +function RouterProbe() { + const router = useRouter(); + return ( +
+ ); +} + +describe('UserProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + afterEach(() => { + setModuleManager(undefined as any); + }); + + it('provides the stored moduleManager to children', async () => { + const mockImport = vi.fn(() => Promise.resolve(undefined)); + setModuleManager({ import: mockImport }); + + 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 fallback moduleManager when store is empty', 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('provides a router that delegates to clerk.navigate', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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'); + }); + + it('returns null when user is not loaded', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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 () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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('returns null when environment is missing', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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(); + }); +}); + +describe('OrganizationProfileProvider wiring', () => { + const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + + beforeEach(() => { + clearFetchCache(); + }); + + afterEach(() => { + setModuleManager(undefined as any); + }); + + it('provides the stored moduleManager to children', async () => { + const mockImport = vi.fn(() => Promise.resolve(undefined)); + setModuleManager({ import: mockImport }); + + 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('returns null when organization is not loaded', async () => { + setModuleManager({ import: vi.fn(() => Promise.resolve(undefined)) }); + + 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(); + }); +}); diff --git a/packages/ui/src/composed/__tests__/context-parity.test.tsx b/packages/ui/src/composed/__tests__/context-parity.test.tsx new file mode 100644 index 00000000000..ae57b209c54 --- /dev/null +++ b/packages/ui/src/composed/__tests__/context-parity.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { useEnvironment } from '../../contexts/EnvironmentContext'; +import { useOptions } from '../../contexts/OptionsContext'; +import { useModuleManager } from '../../contexts/ModuleManagerContext'; +import { useFlowMetadata } from '../../elements/contexts'; +import { useRouter } from '../../router'; +import { useAppearance } from '../../customizables/AppearanceContext'; + +const ContextProbe = ({ testId }: { testId: string }) => { + const environment = useEnvironment(); + const options = useOptions(); + const moduleManager = useModuleManager(); + const flowMetadata = useFlowMetadata(); + const router = useRouter(); + const appearance = useAppearance(); + + return ( +
+ {environment ? 'ok' : 'missing'} + {options !== undefined ? 'ok' : 'missing'} + {moduleManager ? 'ok' : 'missing'} + {flowMetadata?.flow || 'missing'} + {router ? 'ok' : 'missing'} + {appearance ? 'ok' : 'missing'} +
+ ); +}; + +describe('Context parity between portal and experimental paths', () => { + describe('UserProfile context chain', () => { + const { createFixtures } = bindCreateFixtures('UserProfile'); + + it('all contexts are available in the portal path', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render(, { wrapper }); + + expect(screen.getByTestId('portal-env')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-options')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-module-manager')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-flow')).toHaveTextContent('UserProfile'); + expect(screen.getByTestId('portal-router')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-appearance')).toHaveTextContent('ok'); + }); + }); + + describe('OrganizationProfile context chain', () => { + const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + + it('all contexts are available in the portal path', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.com'], organization_memberships: [{ name: 'TestOrg' }] }); + }); + + render(, { wrapper }); + + expect(screen.getByTestId('portal-env')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-options')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-module-manager')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-flow')).toHaveTextContent('OrganizationProfile'); + expect(screen.getByTestId('portal-router')).toHaveTextContent('ok'); + expect(screen.getByTestId('portal-appearance')).toHaveTextContent('ok'); + }); + }); +}); 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..d685f605b29 --- /dev/null +++ b/packages/ui/src/composed/__tests__/stub-limitations.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createComposedRouter, stubRouter } from '../stubRouter'; + +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('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(); + }); +}); diff --git a/packages/ui/src/composed/index.ts b/packages/ui/src/composed/index.ts new file mode 100644 index 00000000000..bfbe324d413 --- /dev/null +++ b/packages/ui/src/composed/index.ts @@ -0,0 +1,2 @@ +export { UserProfile } from './UserProfile'; +export { OrganizationProfile } from './OrganizationProfile'; diff --git a/packages/ui/src/composed/moduleManagerStore.ts b/packages/ui/src/composed/moduleManagerStore.ts new file mode 100644 index 00000000000..94a5fa1421e --- /dev/null +++ b/packages/ui/src/composed/moduleManagerStore.ts @@ -0,0 +1,11 @@ +import type { ModuleManager } from '@clerk/shared/moduleManager'; + +let storedModuleManager: ModuleManager | undefined; + +export function setModuleManager(mm: ModuleManager): void { + storedModuleManager = mm; +} + +export function getModuleManager(): ModuleManager | undefined { + return storedModuleManager; +} diff --git a/packages/ui/src/composed/stubRouter.ts b/packages/ui/src/composed/stubRouter.ts new file mode 100644 index 00000000000..52f0245148d --- /dev/null +++ b/packages/ui/src/composed/stubRouter.ts @@ -0,0 +1,30 @@ +import type { RouteContextValue } from '../router/RouteContext'; + +export function createComposedRouter(clerkNavigate: (to: string) => Promise | void): RouteContextValue { + return { + basePath: '', + startPath: '', + flowStartPath: '', + fullPath: '', + indexPath: '', + currentPath: '', + matches: () => false, + navigate: async (to: string) => { + await clerkNavigate(to); + }, + baseNavigate: async (toURL: URL) => { + await clerkNavigate(toURL.href); + }, + resolve: (to: string) => new URL(to, window.location.href), + refresh: () => {}, + params: {}, + queryString: '', + queryParams: {}, + getMatchData: () => false, + }; +} + +export const stubRouter: RouteContextValue = createComposedRouter(to => { + window.location.assign(to); + return Promise.resolve(); +}); diff --git a/packages/ui/src/composed/useBillingRouter.ts b/packages/ui/src/composed/useBillingRouter.ts new file mode 100644 index 00000000000..59bc4714dd4 --- /dev/null +++ b/packages/ui/src/composed/useBillingRouter.ts @@ -0,0 +1,92 @@ +import { useState } from 'react'; + +import type { RouteContextValue } from '../router/RouteContext'; +import { stubRouter } from './stubRouter'; + +type BillingRoute = + | { page: 'billing' } + | { page: 'plans' } + | { page: 'statement'; statementId: string } + | { page: 'payment-attempt'; paymentAttemptId: string }; + +function resolveNavigation(_currentRoute: BillingRoute, to: string): BillingRoute { + let path = to; + while (path.startsWith('../')) { + path = path.slice(3); + } + + if (!path || path === '/') { + return { page: 'billing' }; + } + + if (path === 'plans') { + return { page: 'plans' }; + } + + const statementMatch = path.match(/^statement\/(.+)$/); + if (statementMatch) { + return { page: 'statement', statementId: statementMatch[1] }; + } + + const paymentMatch = path.match(/^payment-attempt\/(.+)$/); + if (paymentMatch) { + return { page: 'payment-attempt', paymentAttemptId: paymentMatch[1] }; + } + + return { page: 'billing' }; +} + +function pathFromRoute(route: BillingRoute): string { + switch (route.page) { + case 'plans': + return 'billing/plans'; + case 'statement': + return `billing/statement/${route.statementId}`; + case 'payment-attempt': + return `billing/payment-attempt/${route.paymentAttemptId}`; + default: + return 'billing'; + } +} + +function paramsFromRoute(route: BillingRoute): Record { + switch (route.page) { + case 'statement': + return { statementId: route.statementId }; + case 'payment-attempt': + return { paymentAttemptId: route.paymentAttemptId }; + default: + return {}; + } +} + +export function useBillingRouter(): { router: RouteContextValue; route: BillingRoute } { + const [route, setRoute] = useState({ page: 'billing' }); + const [queryParams, setQueryParams] = useState>({}); + + const router: RouteContextValue = { + ...stubRouter, + currentPath: pathFromRoute(route), + params: paramsFromRoute(route), + queryParams, + queryString: new URLSearchParams(queryParams).toString(), + navigate: async (to: string, options?: { searchParams?: URLSearchParams }) => { + try { + const url = new URL(to); + if (url.origin !== window.location.origin) { + window.location.href = to; + return; + } + } catch {} + const newRoute = resolveNavigation(route, to); + setRoute(newRoute); + if (options?.searchParams) { + setQueryParams(Object.fromEntries(options.searchParams.entries())); + } else if (newRoute.page !== route.page) { + setQueryParams({}); + } + }, + }; + + return { router, route }; +} diff --git a/packages/ui/src/customizables/elementDescriptors.ts b/packages/ui/src/customizables/elementDescriptors.ts index 688e0728bc5..81d1cab3e7d 100644 --- a/packages/ui/src/customizables/elementDescriptors.ts +++ b/packages/ui/src/customizables/elementDescriptors.ts @@ -453,6 +453,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..74b57fb5369 100644 --- a/packages/ui/src/elements/Animated.tsx +++ b/packages/ui/src/elements/Animated.tsx @@ -1,14 +1,44 @@ -import { useAutoAnimate } from '@formkit/auto-animate/react'; -import { cloneElement, type PropsWithChildren } from 'react'; +import autoAnimate from '@formkit/auto-animate'; +import { cloneElement, type PropsWithChildren, useCallback, useEffect, 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); + } + }, []); + + useEffect(() => { + return () => { + controllerRef.current?.destroy?.(); + controllerRef.current = null; + }; + }, []); + + 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..962e8d610a4 --- /dev/null +++ b/packages/ui/src/elements/AppearanceOverrides.tsx @@ -0,0 +1,15 @@ +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(() => { + const newParsedElements = [appearance.parsedElements[0], elements, ...appearance.parsedElements.slice(1)]; + return { ...appearance, parsedElements: newParsedElements }; + }, [appearance, elements]); + + return {children}; +}; diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx index ecc0d016a49..a0f05f4a5b5 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx @@ -1,14 +1,9 @@ import type { PropsWithChildren } from 'react'; -import { Col } from '../../customizables'; +import { Col, descriptors } from '../../customizables'; 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. @@ -17,29 +12,18 @@ type ProfileCardPageProps = PropsWithChildren<{ bleeding?: boolean; }>; -/** - * Per-page padding wrapper rendered inside `ProfileCardContent` - * - * Each routed page inside `UserProfile` / `OrganizationProfile` should wrap its content - * in this component - */ -export const ProfileCardPage = ({ children, padding = true, bleeding = false }: ProfileCardPageProps) => { - if (!padding && !bleeding) { - return <>{children}; - } - +export const ProfileCardPage = ({ children, bleeding = false }: 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, + [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/experimental/index.ts b/packages/ui/src/experimental/index.ts new file mode 100644 index 00000000000..5808af7d251 --- /dev/null +++ b/packages/ui/src/experimental/index.ts @@ -0,0 +1,3 @@ +'use client'; + +export { UserProfile, OrganizationProfile } from '../composed'; 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 daade5167bc..7e17b465571 100644 --- a/packages/ui/src/internal/appearance.ts +++ b/packages/ui/src/internal/appearance.ts @@ -587,6 +587,7 @@ export type ElementsConfig = { profileSectionPrimaryButton: WithOptions; profileSectionButtonGroup: WithOptions; profilePage: WithOptions; + profilePageContent: WithOptions; // TODO: review formattedPhoneNumber: WithOptions; 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 55ee736f5f4..e60a2af8d8a 100644 --- a/packages/ui/src/styledSystem/StyleCacheProvider.tsx +++ b/packages/ui/src/styledSystem/StyleCacheProvider.tsx @@ -4,8 +4,6 @@ import createCache from '@emotion/cache'; import { CacheProvider, type SerializedStyles } from '@emotion/react'; import React, { useMemo } from 'react'; -const el = document.querySelector('style#cl-style-insertion-point'); - type StyleCacheProviderProps = React.PropsWithChildren<{ /** Optional nonce value for CSP (Content Security Policy) */ nonce?: string; @@ -15,6 +13,7 @@ type StyleCacheProviderProps = React.PropsWithChildren<{ export const StyleCacheProvider = (props: StyleCacheProviderProps) => { const cache = useMemo(() => { + const el = typeof document !== 'undefined' ? document.querySelector('style#cl-style-insertion-point') : null; const emotionCache = createCache({ key: 'cl-internal', prepend: props.cssLayerName ? false : !el, diff --git a/packages/ui/tsdown.config.mts b/packages/ui/tsdown.config.mts index aba6fd3f133..ba2f1953525 100644 --- a/packages/ui/tsdown.config.mts +++ b/packages/ui/tsdown.config.mts @@ -39,6 +39,7 @@ export default defineConfig(({ watch }) => { './src/internal/index.ts', './src/themes/index.ts', './src/themes/experimental.ts', + './src/experimental/index.ts', ], outDir: './dist', unbundle: true,