diff --git a/packages/ui/COMPOSED_API_PLAN.md b/packages/ui/COMPOSED_API_PLAN.md new file mode 100644 index 00000000000..e3fdb1cfe7d --- /dev/null +++ b/packages/ui/COMPOSED_API_PLAN.md @@ -0,0 +1,753 @@ +# Composed Profile API — Design Plan + +## Context + +The `@clerk/ui/experimental` export provides composable profile subcomponents that render outside Clerk's portal infrastructure. The current API uses named components (`UserProfile.Account`, `UserProfile.Security`) that render full page components. We're replacing this with a `Page`/`Section` API that gives consumers full compositional control: omit sections, reorder them, and inject custom content between them. + +## API + +### Import + +```tsx +import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; +``` + +### Basic usage — full defaults + +```tsx + + + + + + +``` + +Each `Page` with no children renders the **full default page**: header, error alert, all built-in sections (respecting environment flags). + +**Rendered output for ``:** + +``` +┌──────────────────────────────────┐ +│ Account (h2) │ +│ │ +│ [Card.Alert - errors show here] │ +│ │ +│ ┌─ Profile Section ────────────┐ │ +│ │ Avatar, name, update button │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Username Section ───────────┐ │ +│ │ (if username enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Email Section ──────────────┐ │ +│ │ (if email enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Phone Section ──────────────┐ │ +│ │ (if phone enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Connected Accounts ─────────┐ │ +│ │ (if social providers) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Enterprise Accounts ────────┐ │ +│ │ (if enterprise SSO enabled) │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Web3 Wallets ──────────────┐ │ +│ │ (if web3 enabled) │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +### Section-level composition + +```tsx + + + + + + +``` + +When children are passed, the `Page` still renders the **header** and **Card.Alert** (error display), but children control the section layout below. No ProfileCard.Page padding wrapper. + +**Rendered output:** + +``` +┌──────────────────────────────────┐ +│ Account (h2) │ +│ │ +│ [Card.Alert - errors show here] │ +│ │ +│ ┌─ Profile Section ────────────┐ │ +│ │ Avatar, name, update button │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Email Section ──────────────┐ │ +│ │ Email list, add button │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +Header and error alert are always present. No phone, username, connected accounts, web3 sections — only what's declared. + +### Custom content injection + +```tsx + + + +
Verify your email to unlock features
+ + +
+
+``` + +**Rendered output:** + +``` +┌──────────────────────────────────┐ +│ Account (h2) │ +│ │ +│ [Card.Alert - errors show here] │ +│ │ +│ ┌─ Profile Section ────────────┐ │ +│ │ Avatar, name, update button │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌─ my-banner ──────────────────┐ │ +│ │ Verify your email to unlock │ │ +│ │ features │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌─ Email Section ──────────────┐ │ +│ │ Email list, add button │ │ +│ └──────────────────────────────┘ │ +│ ┌─ Phone Section ──────────────┐ │ +│ │ Phone list, add button │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +Header and Card.Alert always present. Children render in declaration order below. `Section` resolves to built-in UI. Everything else passes through as-is. + +### Custom pages + +```tsx + + + + + + +``` + +`Page` with `title` (no `id`) renders a custom page. Children are the page content. `title` and `id` are mutually exclusive. + +**Rendered output:** + +``` +Page 1: +┌──────────────────────────────────┐ +│ Account │ +│ [full default account page] │ +└──────────────────────────────────┘ + +Page 2: +┌──────────────────────────────────┐ +│ [MyPreferencesPanel renders] │ +└──────────────────────────────────┘ +``` + +### OrganizationProfile + +```tsx + + + + + + +``` + +Section composition on the general page: + +```tsx + + + + + + +``` + +--- + +## Behavior Rules + +### 1. No children = passthrough to existing component + +`` renders the existing `AccountPage` component directly. The Page adds nothing — `AccountPage` already provides its own header, `Card.Alert`, `CardStateProvider`, and all sections with environment guards. This is a zero-change passthrough. + +### 2. Any children = Page provides chrome + children control sections + +If you pass ANY children, the Page renders: + +- `CardStateProvider` (shared error state) +- `PageContext.Provider` (tells Section which page it's inside) +- Header (localized page title, styled as h2) +- `Card.Alert` (error display) +- Flex column with standard gap (`space.$8`) +- Children (Sections + custom content) + +The existing page component is NOT rendered. The Page component builds the chrome from scratch. + +### 3. Environment guards always respected + +`
` renders nothing (`null`) if `email_address` is not enabled in the Clerk dashboard. Guards are enforced in the `Section` wrapper, matching the logic currently in the parent page component. + +### 4. Loose section typing + +`Section` accepts any valid section ID from a flat union. If the ID doesn't match the parent page's type, it renders nothing (`null`). No runtime error, no DOM output. + +```tsx +// Renders nothing — 'password' isn't an account section + + + +``` + +### 5. Section resolution via PageContext + +`Page` provides a `PageContext` with the page ID. `Section` reads this context to look up the correct component from the section registry. This resolves ambiguity where the same section ID (e.g., `profile`) maps to different components depending on the page type. + +```tsx +// Inside : Section id="profile" → UserProfileSection +// Inside : Section id="profile" → OrganizationProfileSection +``` + +### 6. Atomic pages + +These pages don't support section composition — children are ignored: + +| Page ID | Reason | +| ---------- | ------------------------------------------------------------ | +| `members` | Single component with tabs, shared pagination, role fetching | +| `api-keys` | Single component, no discrete sections | + +### 7. Billing page — composable with managed navigation + +`` supports section composition AND manages sub-page navigation internally. The Page always provides the billing router (`useBillingRouter`). When the user is on the main view, sections render. When a section triggers navigation (e.g., "Switch plans"), the Page swaps the entire view to the sub-page (plans, statement detail, payment detail). Back navigation returns to the section view. + +**Default (no children) — passthrough to existing BillingPage with tabs:** + +``` + + +Renders the existing BillingPage (with tabs + sub-page navigation): +┌──────────────────────────────────────┐ +│ Billing (h2) │ +│ │ +│ [Subscriptions] [Statements] [Payments] +│ │ +│ ┌─ Current Plan ──────────────────┐ │ +│ │ Pro Plan - $20/mo │ │ +│ │ [Switch plans] │ │ +│ └─────────────────────────────────┘ │ +│ ┌─ Payment Methods ───────────────┐ │ +│ │ Visa •••• 4242 │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +**Custom sections — no tabs, sections stack vertically. Consumer can add their own tab UI if desired:** + +```tsx + +
+
+ +``` + +``` +Renders: +┌──────────────────────────────────────┐ +│ Billing (h2) │ +│ │ +│ ┌─ Subscriptions ─────────────────┐ │ +│ │ Pro Plan - $20/mo │ │ +│ │ [Switch plans] │ │ +│ └─────────────────────────────────┘ │ +│ ┌─ Payment Methods ───────────────┐ │ +│ │ Visa •••• 4242 │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +**Sub-page navigation — managed by the Page:** + +``` +User clicks "Switch plans" in subscriptions → +Section calls navigate('plans') → +useBillingRouter updates route to { page: 'plans' } → +Page swaps entire view to PlansPage: + +┌──────────────────────────────────────┐ +│ ← Plans (h2) │ +│ │ +│ ┌─ Pricing Table ─────────────────┐ │ +│ │ Free Pro Enterprise │ │ +│ │ $0/mo $20/mo $50/mo │ │ +│ └─────────────────────────────────┘ │ +└──────────────────────────────────────┘ + +User clicks back → +useBillingRouter resets to { page: 'billing' } → +Page swaps back to sections view +``` + +Same pattern for statement detail and payment detail — sections trigger navigation, Page manages the view swap. + +**Billing section IDs:** + +| Section ID | Component | Router dependency | +| ---------------- | --------------------- | ------------------------------------------------------------ | +| `subscriptions` | `SubscriptionsList` | `navigate('plans')` — triggers sub-page swap | +| `paymentMethods` | `PaymentMethods` | None — fully standalone | +| `statements` | `StatementsList` | `navigate('statement/${id}')` — triggers sub-page swap | +| `payments` | `PaymentAttemptsList` | `navigate('payment-attempt/${id}')` — triggers sub-page swap | + +### 8. Page-level CardState + +When Page has children, it provides a shared `CardStateProvider`. Sections that call `useCardState()` write errors to this shared state, displayed in the `Card.Alert` the Page renders. When Page has no children (passthrough), the existing page component manages its own `CardStateProvider`. + +### 9. Custom page headers + +Custom pages (``) render a header with the title string, styled identically to built-in page headers (h2 variant). Built-in page headers use localization keys. + +### 10. Appearance prop + +`Provider` accepts an `appearance` prop for visual customization, passed through to `AppearanceProvider`. Same as current implementation. + +--- + +## Section Registry + +### UserProfile — Account page sections + +| Section ID | Component | Environment guard | Props injected by wrapper | +| -------------------- | --------------------------- | ------------------------------------------- | -------------------------------------------- | +| `profile` | `UserProfileSection` | none | none | +| `username` | `UsernameSection` | `attributes.username?.enabled` | `isImmutable` | +| `emails` | `EmailsSection` | `attributes.email_address?.enabled` | `shouldAllowCreation`, `shouldAllowDeletion` | +| `phone` | `PhoneSection` | `attributes.phone_number?.enabled` | `shouldAllowCreation`, `shouldAllowDeletion` | +| `connectedAccounts` | `ConnectedAccountsSection` | social providers exist with `enabled: true` | `shouldAllowCreation` | +| `enterpriseAccounts` | `EnterpriseAccountsSection` | `enterpriseSSO.enabled` | none | +| `web3` | `Web3Section` | `attributes.web3_wallet?.enabled` | `shouldAllowCreation` | + +Props are computed from `useEnvironment()` and `useUserProfileContext()` inside the Section wrapper. + +### UserProfile — Security page sections + +| Section ID | Component | Environment guard | Props injected by wrapper | +| --------------- | ---------------------- | -------------------------------------------------------- | ------------------------- | +| `password` | `PasswordSection` | `instanceIsPasswordBased` | none | +| `passkeys` | `PasskeySection` | passkeys enabled AND `shouldAllowIdentificationCreation` | none | +| `mfa` | `MfaSection` | `getSecondFactors(attributes).length > 0` | none | +| `activeDevices` | `ActiveDevicesSection` | none (always rendered) | none | +| `delete` | `DeleteSection` | `user.deleteSelfEnabled` | none | + +### OrganizationProfile — General page sections + +| Section ID | Component | Environment guard | Wrapper extras | +| ---------- | ---------------------------- | ---------------------------------------------------------- | -------------------------------------------------------- | +| `profile` | `OrganizationProfileSection` | none | none | +| `domains` | `OrganizationDomainsSection` | `organizationSettings.domains.enabled` | Wrapped in `` | +| `leave` | `OrganizationLeaveSection` | none | none | +| `delete` | `OrganizationDeleteSection` | `org:sys_profile:delete` permission + `adminDeleteEnabled` | none (guards are internal) | + +--- + +## Type Definitions + +```ts +// --- Page IDs --- + +type UserProfilePageId = 'account' | 'security' | 'billing' | 'api-keys'; +type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'api-keys'; + +// --- Section IDs --- + +type UserProfileSectionId = + // Account + | 'profile' + | 'username' + | 'emails' + | 'phone' + | 'connectedAccounts' + | 'enterpriseAccounts' + | 'web3' + // Security + | 'password' + | 'passkeys' + | 'mfa' + | 'activeDevices' + | 'delete' + // Billing + | 'subscriptions' + | 'paymentMethods' + | 'statements' + | 'payments'; + +type OrganizationProfileSectionId = + // General + | 'profile' + | 'domains' + | 'leave' + | 'delete' + // Billing (same IDs as UserProfile) + | 'subscriptions' + | 'paymentMethods' + | 'statements' + | 'payments'; + +// --- Component Props --- + +type PageProps = + | { id: UserProfilePageId; title?: never; children?: React.ReactNode } + | { id?: never; title: string; children: React.ReactNode }; + +type SectionProps = { + id: UserProfileSectionId; // or OrganizationProfileSectionId for org +}; +``` + +--- + +## Implementation + +### `Section` wrapper (conceptual) + +Each Section wrapper handles: environment guard, prop computation, and delegation to the existing component. + +```tsx +// Simplified example for the 'emails' section +function EmailsSectionWrapper() { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + // Environment guard — matches AccountPage line 25 + if (!attributes.email_address?.enabled) { + return null; + } + + const isImmutable = immutableAttributes.has('email_address'); + + return ( + + ); +} +``` + +The actual `Section` component uses a registry to look up the right wrapper: + +```tsx +const userAccountSections: Record = { + profile: ProfileSectionWrapper, + username: UsernameSectionWrapper, + emails: EmailsSectionWrapper, + phone: PhoneSectionWrapper, + connectedAccounts: ConnectedAccountsSectionWrapper, + enterpriseAccounts: EnterpriseAccountsSectionWrapper, + web3: Web3SectionWrapper, +}; + +const userSecuritySections: Record = { + password: PasswordSectionWrapper, + passkeys: PasskeySectionWrapper, + mfa: MfaSectionWrapper, + activeDevices: ActiveDevicesSectionWrapper, + delete: DeleteSectionWrapper, +}; +``` + +### `Page` component (conceptual) + +```tsx +function UserProfilePage({ id, title, children }: PageProps) { + // Custom page — just render children + if (title) { + return <>{children}; + } + + // Atomic pages — ignore children, render full component + if (id === 'api-keys') { + return ; + } + if (id === 'members') { + return ; // (org only) + } + + // Billing — composable with managed sub-page navigation + if (id === 'billing') { + const { router, route } = useBillingRouter(); + + // Sub-page active — Page takes over the whole view + if (route.page !== 'billing') { + return ( + + + + ); + } + + // Main billing view — section composition + return ( + + + + + {children ?? } + + + ); + } + + // Composable pages (account, security, general) + if (children) { + // Page provides chrome; children control section layout + return ( + + + ({ gap: t.space.$8 })}> + + + {children} + + + + ); + } + + // No children — passthrough to existing page component + // AccountPage/SecurityPage/etc. already have their own header, alert, CardState + return ; +} +``` + +### `DefaultPageRenderer` — renders the full default page + +When no children are passed, this renders the existing page component as-is (AccountPage, SecurityPage, OrganizationGeneralPage). This is what we have today — zero behavioral change for the simple case. + +```tsx +function DefaultPageRenderer({ id }: { id: string }) { + switch (id) { + case 'account': + return ; + case 'security': + return ; + case 'general': + return ; + default: + return null; + } +} +``` + +--- + +## File Changes + +### New files + +| File | Purpose | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `src/composed/UserProfile/Page.tsx` | `UserProfile.Page` component with section registry, default rendering, children detection | +| `src/composed/UserProfile/Section.tsx` | `UserProfile.Section` component — looks up wrapper from registry, renders built-in section | +| `src/composed/UserProfile/sectionWrappers.tsx` | Section wrapper components that compute props + guards for each section | +| `src/composed/OrganizationProfile/Page.tsx` | `OrganizationProfile.Page` — same pattern | +| `src/composed/OrganizationProfile/Section.tsx` | `OrganizationProfile.Section` | +| `src/composed/OrganizationProfile/sectionWrappers.tsx` | Org section wrappers | + +### Modified files + +| File | Change | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `src/composed/UserProfile/index.tsx` | Replace `.Account`/`.Security`/`.Billing`/`.APIKeys` with `.Page` + `.Section` | +| `src/composed/OrganizationProfile/index.tsx` | Replace `.General`/`.Members`/`.Billing`/`.APIKeys` with `.Page` + `.Section` | +| `src/components/OrganizationProfile/OrganizationGeneralPage.tsx` | Export the four private section components | +| `playground/composed/src/App.tsx` | Update to use new Page/Section API | + +### Deleted files + +| File | Reason | +| ---------------------------------------------- | ------------------------------------------------------------------ | +| `src/composed/UserProfile/Account.tsx` | Replaced by `Page` + section wrappers | +| `src/composed/UserProfile/Security.tsx` | Replaced by `Page` + section wrappers | +| `src/composed/UserProfile/Billing.tsx` | Replaced by billing logic inside `Page` + billing section wrappers | +| `src/composed/OrganizationProfile/General.tsx` | Replaced by `Page` + section wrappers | +| `src/composed/OrganizationProfile/Members.tsx` | Replaced by `Page` (atomic, rendered directly) | +| `src/composed/OrganizationProfile/Billing.tsx` | Replaced by billing logic inside `Page` + billing section wrappers | + +### Kept as-is + +| File | Reason | +| ------------------------------------------------------------------ | ----------------------------------------------------- | +| `src/composed/UserProfile/APIKeys.tsx` | API Keys is atomic — `Page` delegates to this | +| `src/composed/OrganizationProfile/APIKeys.tsx` | Same | +| `src/composed/useBillingRouter.ts` | Still needed for billing sub-navigation inside `Page` | +| `src/composed/stubRouter.ts` | Still needed for non-billing pages | +| `src/composed/UserProfile/UserProfileProvider.tsx` | Provider unchanged | +| `src/composed/OrganizationProfile/OrganizationProfileProvider.tsx` | Provider unchanged | + +--- + +## Compound Export Shape + +```tsx +// src/composed/UserProfile/index.tsx +export const UserProfile = { + Provider: UserProfileProvider, + Page: UserProfilePage, + Section: UserProfileSection, +}; + +// src/composed/OrganizationProfile/index.tsx +export const OrganizationProfile = { + Provider: OrganizationProfileProvider, + Page: OrganizationProfilePage, + Section: OrganizationProfileSection, +}; +``` + +--- + +## Full Example — Everything Together + +```tsx +import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; + +function MyApp() { + return ( +
+ {/* Tab: Profile */} + + + + + + + + + + {/* Tab: Security — full defaults */} + + + + + {/* Tab: Organization */} + + + + + + + + +
+ ); +} +``` + +--- + +## Known Issues & Risks + +### 1. Guard logic duplication + +Section wrappers must replicate guard logic that currently lives in parent page components. Two sources of truth. + +**Examples:** + +- `DeleteSection` renders unconditionally — the guard `user.deleteSelfEnabled` lives only in `SecurityPage.tsx:25`. The section wrapper must add this guard. +- `PasskeySection` visibility depends on `shouldAllowIdentificationCreation` from `useUserProfileContext()` — computed in `SecurityPage.tsx:23`, not in the section itself. +- AccountPage computes `isEmailImmutable`, `isPhoneImmutable`, `isUsernameImmutable` from `useUserProfileContext().immutableAttributes` and passes derived props (`shouldAllowCreation`, `shouldAllowDeletion`) to child sections. + +**Risk:** If a guard changes in the page component (portal path), the section wrapper (composed path) drifts silently. No shared code, no compile-time check. + +**Mitigation options:** + +- Extract shared `useSectionConfig()` hooks that both the page component and the section wrapper call. +- Move guards into the section components themselves (bigger refactor, changes portal path behavior). + +### 2. CardState scoping is inconsistent across sections + +Sections fall into three categories, each with different error display behavior: + +| Category | Sections | Error behavior | +| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| **Self-contained** (own `withCardStateProvider` + own `Card.Alert`) | `ConnectedAccountsSection`, `Web3Section`, `EnterpriseAccountsSection` | Errors display inside the section. Page-level `Card.Alert` never sees them. | +| **Parent-dependent** (no provider, calls `useCardState()`) | `EmailsSection`, `PhoneSection`, `MfaSection` | Errors bubble to the nearest `CardStateProvider` — currently the page's. | +| **No card state** | `UserProfileSection`, `UsernameSection`, `PasswordSection`, `PasskeySection`, `ActiveDevicesSection`, `DeleteSection` | No error interaction at the section level. | + +**The problem:** When `Page` provides chrome (children mode), it wraps everything in a `CardStateProvider` + renders `Card.Alert`. This works for parent-dependent sections (emails, phone, mfa) — their errors show in the page alert. But self-contained sections (connected accounts, web3) have their own provider, so errors display in duplicate locations OR only inside the section. + +**Worse:** If a parent-dependent section like `EmailsSection` is rendered WITHOUT a parent `CardStateProvider` (e.g., directly under `Provider` without a `Page`), `useCardState()` will throw. + +**Decision needed:** Should we require all sections to be self-contained? Or enforce that sections only render inside a `Page`? + +### 3. Org section components are private + +All four section components in `OrganizationGeneralPage.tsx` are unexported module-private `const`s: + +- `OrganizationProfileSection` (line 88) +- `OrganizationDomainsSection` (line 137) +- `OrganizationLeaveSection` (line 186) +- `OrganizationDeleteSection` (line 232) + +We need to export them for the section registry. This changes the module's public API surface. + +### 4. Org leave/delete forms depend on navigation callback + +`ActionConfirmationPage.tsx:22` reads `useOrganizationProfileContext().navigateAfterLeaveOrganization` — a callback that navigates away after the user leaves or deletes an org. In the portal path, this navigates to a different route. In the composed path, there's no route to navigate to. + +**Decision needed:** What should happen after leaving/deleting an org in the composed path? Callback prop on `Provider`? No-op? The current `OrganizationProfileProvider` doesn't provide `navigateAfterLeaveOrganization`. + +### 5. `useUserProfileContext()` is expensive + +The hook (`contexts/components/UserProfile.ts`) calls `useSubscription()`, `useStatements()`, and computes `pages` (navbar routes) on every render. Every section wrapper that needs a simple boolean like `shouldAllowIdentificationCreation` triggers all of this. + +In the portal path this is fine — the page component calls it once. In the composed path with N independent section wrappers, it's called N times per render. + +**Mitigation:** Extract the guard-related values into a lighter hook (e.g., `useUserProfileGuards()`) that doesn't pull billing data or page routes. + +### 6. Billing sections only work inside billing Page + +Billing sections call `navigate('plans')`, `navigate('statement/${id}')`, etc. These are handled by `useBillingRouter` which only exists when `` is the parent. + +If someone puts `
` under ``: + +- `PageContext` says "account", so the section registry lookup would fail (subscriptions isn't an account section) → renders null. This is the designed behavior (Rule 4). + +But if someone puts billing sections under `` without children (passthrough mode), the existing BillingPage with tabs renders — section composition is ignored. Need to document this clearly. + +### 7. StrictMode compatibility + +We already found that `useSafeState` (used by `useLoadingStatus`) breaks in React 18 StrictMode (the `isMountedRef` is never reset after remount). We fixed it, but other hooks may have similar patterns. Each section that uses form submission, loading states, or action menus could be affected. The portal path doesn't use StrictMode, so these bugs only surface in the composed path. + +**The composed playground uses ``.** Any host app might too. We should audit `useSafeState` consumers for similar issues. + +--- + +## Verification + +1. **Default rendering**: `` renders identically to current `` +2. **Section omission**: `
` renders only the profile section +3. **Custom content**: Non-Section children render inline between sections +4. **Environment guards**: Sections hidden by dashboard config render nothing even when explicitly declared +5. **Atomic pages**: Billing tabs, sub-navigation (plans, statements, payments) all work +6. **Error handling**: Section errors surface via `useCardState()` (available when page provides CardStateProvider) +7. **Existing tests**: `pnpm turbo test --filter=@clerk/ui` — all portal-path tests pass unchanged +8. **Playground**: Update `playground/composed/` to exercise all patterns above diff --git a/packages/ui/package.json b/packages/ui/package.json index e6a695bb706..d97953e8ec4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -50,6 +50,11 @@ "import": "./dist/themes/experimental.js", "default": "./dist/themes/experimental.js" }, + "./experimental": { + "types": "./dist/experimental/index.d.ts", + "import": "./dist/experimental/index.js", + "default": "./dist/experimental/index.js" + }, "./themes/shadcn.css": "./dist/themes/shadcn.css", "./register": { "import": { diff --git a/packages/ui/src/components/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' })} > { { { ({ 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 = () => ( + + + + + +); diff --git a/packages/ui/src/composed/OrganizationProfile/Billing.tsx b/packages/ui/src/composed/OrganizationProfile/Billing.tsx new file mode 100644 index 00000000000..47fe8280d76 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Billing.tsx @@ -0,0 +1,53 @@ +import { lazy, Suspense } 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 = () => { + const { router, route } = useBillingRouter(); + + let content: React.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/General.tsx b/packages/ui/src/composed/OrganizationProfile/General.tsx new file mode 100644 index 00000000000..2242af2667b --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/General.tsx @@ -0,0 +1,3 @@ +import { OrganizationGeneralPage } from '../../components/OrganizationProfile/OrganizationGeneralPage'; + +export const General = () => ; diff --git a/packages/ui/src/composed/OrganizationProfile/Members.tsx b/packages/ui/src/composed/OrganizationProfile/Members.tsx new file mode 100644 index 00000000000..65141655bb0 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/Members.tsx @@ -0,0 +1,3 @@ +import { OrganizationMembers } from '../../components/OrganizationProfile/OrganizationMembers'; + +export const Members = () => ; diff --git a/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx new file mode 100644 index 00000000000..4c7722737bc --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/OrganizationProfileProvider.tsx @@ -0,0 +1,76 @@ +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 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 { ProfileCardPagePaddingProvider } from '../../elements/ProfileCard'; +import { stubRouter } from '../stubRouter'; + +const stubModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined), +}; + +type OrganizationProfileProviderProps = React.PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; +}>; + +export const OrganizationProfileProvider = (props: OrganizationProfileProviderProps) => { + 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; + + if (!isLoaded || !user || !organization || !environment) { + return null; + } + + const orgProfileCtxValue = { + componentName: 'OrganizationProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + customPages: [], + }; + + 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..364b926a3b5 --- /dev/null +++ b/packages/ui/src/composed/OrganizationProfile/index.tsx @@ -0,0 +1,13 @@ +import { APIKeys } from './APIKeys'; +import { Billing } from './Billing'; +import { General } from './General'; +import { Members } from './Members'; +import { OrganizationProfileProvider } from './OrganizationProfileProvider'; + +export const OrganizationProfile = { + Provider: OrganizationProfileProvider, + General, + Members, + Billing, + APIKeys, +}; diff --git a/packages/ui/src/composed/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx new file mode 100644 index 00000000000..68c2bc231a7 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx @@ -0,0 +1,17 @@ +import { lazy, Suspense } from 'react'; + +import { CardStateProvider } from '../../elements/contexts'; + +const APIKeysPage = lazy(() => + import('../../components/UserProfile/APIKeysPage').then(m => ({ + default: m.APIKeysPage, + })), +); + +export const APIKeys = () => ( + + + + + +); diff --git a/packages/ui/src/composed/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx new file mode 100644 index 00000000000..b0d9720d29f --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Account.tsx @@ -0,0 +1,3 @@ +import { AccountPage } from '../../components/UserProfile/AccountPage'; + +export const Account = () => ; diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx new file mode 100644 index 00000000000..1386a89f8e3 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Billing.tsx @@ -0,0 +1,53 @@ +import { lazy, Suspense } 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 = () => { + const { router, route } = useBillingRouter(); + + let content: React.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..53c5966110c --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Security.tsx @@ -0,0 +1,3 @@ +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; + +export const Security = () => ; diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx new file mode 100644 index 00000000000..a074a49ca04 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx @@ -0,0 +1,73 @@ +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 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 { ProfileCardPagePaddingProvider } from '../../elements/ProfileCard'; +import { stubRouter } from '../stubRouter'; + +const stubModuleManager: ModuleManager = { + import: () => Promise.resolve(undefined), +}; + +type UserProfileProviderProps = React.PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; +}>; + +export const UserProfileProvider = (props: UserProfileProviderProps) => { + const { children, appearance, additionalOAuthScopes } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + + if (!isLoaded || !user || !environment) { + return null; + } + + const userProfileCtxValue = { + componentName: 'UserProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + additionalOAuthScopes, + customPages: [], + }; + + return ( + + + + + + + + + + {children} + + + + + + + + + + ); +}; diff --git a/packages/ui/src/composed/UserProfile/index.tsx b/packages/ui/src/composed/UserProfile/index.tsx new file mode 100644 index 00000000000..a8f4d43bc11 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/index.tsx @@ -0,0 +1,13 @@ +import { Account } from './Account'; +import { APIKeys } from './APIKeys'; +import { Billing } from './Billing'; +import { Security } from './Security'; +import { UserProfileProvider } from './UserProfileProvider'; + +export const UserProfile = { + Provider: UserProfileProvider, + Account, + Security, + Billing, + APIKeys, +}; 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__/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__/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/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/stubRouter.ts b/packages/ui/src/composed/stubRouter.ts new file mode 100644 index 00000000000..b334c193a78 --- /dev/null +++ b/packages/ui/src/composed/stubRouter.ts @@ -0,0 +1,37 @@ +import type { RouteContextValue } from '../router/RouteContext'; + +const noop = () => {}; + +function isExternalUrl(to: string): boolean { + try { + return new URL(to).origin !== window.location.origin; + } catch { + return false; + } +} + +export const stubRouter: RouteContextValue = { + basePath: '', + startPath: '', + flowStartPath: '', + fullPath: '', + indexPath: '', + currentPath: '', + matches: () => false, + baseNavigate: async (toURL: URL) => { + if (toURL.origin !== window.location.origin) { + window.location.href = toURL.href; + } + }, + navigate: async (to: string) => { + if (isExternalUrl(to)) { + window.location.href = to; + } + }, + resolve: (to: string) => new URL(to, window.location.origin), + refresh: noop, + params: {}, + queryString: '', + queryParams: {}, + getMatchData: () => false, +}; 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/elements/ProfileCard/ProfileCardPage.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx index ecc0d016a49..1a599b22292 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardPage.tsx @@ -1,8 +1,12 @@ -import type { PropsWithChildren } from 'react'; +import { createContext, type PropsWithChildren, useContext } from 'react'; import { Col } from '../../customizables'; import { mqu } from '../../styledSystem'; +const ProfileCardPagePaddingContext = createContext(true); + +export const ProfileCardPagePaddingProvider = ProfileCardPagePaddingContext.Provider; + type ProfileCardPageProps = PropsWithChildren<{ /** * Whether to apply the standard per-page padding. @@ -17,21 +21,17 @@ 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) { +export const ProfileCardPage = ({ children, padding, bleeding = false }: ProfileCardPageProps) => { + const defaultPadding = useContext(ProfileCardPagePaddingContext); + const shouldPad = padding ?? defaultPadding; + if (!shouldPad && !bleeding) { return <>{children}; } return ( ({ - ...(padding && { + ...(shouldPad && { paddingTop: theme.space.$7, paddingBottom: theme.space.$7, paddingInlineStart: theme.space.$8, diff --git a/packages/ui/src/elements/ProfileCard/index.ts b/packages/ui/src/elements/ProfileCard/index.ts index 84df2ddd56e..abe54826f3a 100644 --- a/packages/ui/src/elements/ProfileCard/index.ts +++ b/packages/ui/src/elements/ProfileCard/index.ts @@ -1,7 +1,9 @@ import { ProfileCardContent } from './ProfileCardContent'; -import { ProfileCardPage } from './ProfileCardPage'; +import { ProfileCardPage, ProfileCardPagePaddingProvider } from './ProfileCardPage'; import { ProfileCardRoot } from './ProfileCardRoot'; +export { ProfileCardPagePaddingProvider }; + export const ProfileCard = { Root: ProfileCardRoot, Content: ProfileCardContent, diff --git a/packages/ui/src/experimental/index.ts b/packages/ui/src/experimental/index.ts new file mode 100644 index 00000000000..d28965f8ff4 --- /dev/null +++ b/packages/ui/src/experimental/index.ts @@ -0,0 +1 @@ +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/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, diff --git a/playground/composed/index.html b/playground/composed/index.html new file mode 100644 index 00000000000..2399dae9217 --- /dev/null +++ b/playground/composed/index.html @@ -0,0 +1,17 @@ + + + + + + Composed UserProfile Playground + + + +
+ + + diff --git a/playground/composed/package.json b/playground/composed/package.json new file mode 100644 index 00000000000..072b97a05b9 --- /dev/null +++ b/playground/composed/package.json @@ -0,0 +1,22 @@ +{ + "name": "playground-composed", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "@clerk/react": "workspace:*", + "@clerk/ui": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "5.8.3", + "vite": "^6.0.0" + } +} diff --git a/playground/composed/src/App.tsx b/playground/composed/src/App.tsx new file mode 100644 index 00000000000..3598749ef4a --- /dev/null +++ b/playground/composed/src/App.tsx @@ -0,0 +1,116 @@ +import { Show, SignInButton, UserButton } from '@clerk/react'; +import { UserProfile, OrganizationProfile } from '@clerk/ui/experimental'; +import { useState } from 'react'; + +type ProfileType = 'user' | 'organization'; +type UserTab = 'account' | 'security' | 'billing' | 'api-keys'; +type OrgTab = 'general' | 'members' | 'billing' | 'api-keys'; + +export function App() { + const [profileType, setProfileType] = useState('user'); + const [userTab, setUserTab] = useState('account'); + const [orgTab, setOrgTab] = useState('general'); + + return ( +
+ +
+

Experimental Composed Profiles

+ +
+ +
+ + +
+ + {profileType === 'user' && ( + + + {userTab === 'account' && } + {userTab === 'security' && } + {userTab === 'billing' && } + {userTab === 'api-keys' && } + + )} + + {profileType === 'organization' && ( + + + {orgTab === 'general' && } + {orgTab === 'members' && } + {orgTab === 'billing' && } + {orgTab === 'api-keys' && } + + )} + + } + > +

Experimental Composed Profiles Playground

+

Sign in to test the composed UserProfile and OrganizationProfile components.

+ +
+
+ ); +} + +function TabBar({ tabs, active, onChange }: { tabs: T[]; active: T; onChange: (tab: T) => void }) { + return ( +
+ {tabs.map(tab => ( + + ))} +
+ ); +} diff --git a/playground/composed/src/main.tsx b/playground/composed/src/main.tsx new file mode 100644 index 00000000000..7a7d6ea12d1 --- /dev/null +++ b/playground/composed/src/main.tsx @@ -0,0 +1,19 @@ +import { ClerkProvider } from '@clerk/react'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './App'; + +const publishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; + +if (!publishableKey) { + throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY in .env.local'); +} + +createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/playground/composed/src/vite-env.d.ts b/playground/composed/src/vite-env.d.ts new file mode 100644 index 00000000000..91af686e054 --- /dev/null +++ b/playground/composed/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_CLERK_PUBLISHABLE_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/playground/composed/tsconfig.json b/playground/composed/tsconfig.json new file mode 100644 index 00000000000..f003d97f308 --- /dev/null +++ b/playground/composed/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/playground/composed/vite.config.ts b/playground/composed/vite.config.ts new file mode 100644 index 00000000000..fabde1a8f5e --- /dev/null +++ b/playground/composed/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 785c5ca0b8b..958a76df061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1103,6 +1103,37 @@ importers: specifier: ^3.2.4 version: 3.2.4(typescript@5.8.3) + playground/composed: + dependencies: + '@clerk/react': + specifier: workspace:* + version: link:../../packages/react + '@clerk/ui': + specifier: workspace:* + version: link:../../packages/ui + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: 18.3.28 + version: 18.3.28 + '@types/react-dom': + specifier: 18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3)) + typescript: + specifier: 5.8.3 + version: 5.8.3 + vite: + specifier: 7.3.3 + version: 7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3) + packages: '@0no-co/graphql.web@1.2.0': @@ -20650,6 +20681,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@4.7.0(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue-jsx@5.1.5(vite@7.3.3(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.3))(vue@3.5.33(typescript@5.8.3))': dependencies: '@babel/core': 7.29.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c3cdc3b057d..708bbe1ba24 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - playground/* catalogs: peer-react: