diff --git a/.changeset/swingset-sidebar-organization.md b/.changeset/swingset-sidebar-organization.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/swingset-sidebar-organization.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-profile-billing-panel.md b/.changeset/user-profile-billing-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-billing-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/user-profile-security-panel.md b/.changeset/user-profile-security-panel.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-security-panel.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index ddd0765758f..32550e15031 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -55,17 +55,18 @@ Pick the archetype below by the component's **layer** (its `meta.group`), then f ### Layers -`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Use these exact group strings: +`meta.group` places an entry in one of these layers. Sidebar order follows the `registry` array; group order follows first appearance there. Within a group, an optional `meta.navigation.category` sub-groups entries under a small collapsible subheading (e.g. `User Profile` splits into `Panels` and `Sections`), collapsed by default unless it contains the active page; category order also follows first appearance in the registry, and uncategorized entries render with no subheading (list them before the categorized ones). Use these exact group strings: | Group | What lives here | Archetype | | ------------ | -------------------------------------------------------------- | --------- | -| `User` | Composed flow UI (e.g. `UserButton`) | C | +| `User Button` | Composed flow UI (e.g. `UserButton`) | C | +| `User Profile` | Composed flow UI (e.g. `UserProfileProfilePanel`) | C | | `Components` | Styled Mosaic components — simple, with a flat variant surface (`Button`, `Input`), or compound (`Card`, `Field`, `Menu`, `Popover`) | A | | `Primitives` | Headless `@clerk/headless` primitives (`Accordion`) | B | | `Styles` | Atomic styles that ship as StyleX atoms, not components (`Scroll Area`) | B (adapted) | | `Hooks` | Headless hooks (`useDataTable`) | B (adapted) | -`User` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). +`User Button` / `User Profile` → `Components` → `Primitives` runs high-level-composition → low-level-primitive. Composed layers are documented as compositions of lower layers (archetype C); leaf layers (Components, Primitives) get full prop/knob docs (archetypes A and B). `Styles` and `Hooks` are the non-component layers: there is no element to knob, so they follow archetype B's shape (Example → Usage → Parts → Styling) with `Props` replaced by whatever the export @@ -239,7 +240,7 @@ The story is `meta` (no `styles`) plus a single `Default` export that renders th **Document the default value for every prop in a dedicated Default column.** Every props table — auto and hand-written — has a **Default** column; the `Type` stays a plain union/enum and the default is named in its own column (the convention every component-doc site and TypeDoc's `@default` tag follow), never inlined into the type. The auto `` renders `Prop | Type | Default | Value` and fills Default from `meta.styles._defaultVariants` (the **Value** column is the live knob seeded with that default); hand-written tables render `Prop | Type | Default | Description` and fill it by hand. Name the default member (`'base'`, `'multiple'`, `'bottom-start'`); use `—` when there is no default (a controlled-only or required prop) and append `(required)` for required props; when the default is behavioral rather than a literal, state it in words (`inherits Root`, `falls back to value`). -### Archetype C — composed layer (`User`) +### Archetype C — composed layer (`User Button`, `User Profile`) These compose lower layers, so the docs lead with the composition rather than knobs. Required MDX: diff --git a/packages/swingset/src/components/Composition.tsx b/packages/swingset/src/components/Composition.tsx index 60ee46ad440..27dc0fd44bb 100644 --- a/packages/swingset/src/components/Composition.tsx +++ b/packages/swingset/src/components/Composition.tsx @@ -7,13 +7,13 @@ export interface CompositionPiece { name: string; /** Route to the piece's page in swingset (e.g. `/components/button`). */ href: string; - /** Which Mosaic layer the piece lives in (e.g. `User`, `Components`, `Primitives`). */ + /** Which Mosaic layer the piece lives in (e.g. `User Button`, `Components`, `Primitives`). */ layer: string; } // Mosaic layers, high → low. Drives the order the composition groups render in. // Matches the sidebar group names. -const LAYER_ORDER = ['User', 'Components', 'Styles', 'Primitives']; +const LAYER_ORDER = ['User Button', 'User Profile', 'Components', 'Styles', 'Primitives']; function layerRank(layer: string): number { const i = LAYER_ORDER.indexOf(layer); diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 34ad4f04993..3a52fbae376 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,10 +10,27 @@ import { ViewSource } from './ViewSource'; // MDX docs keyed by `group` slug → `component` slug. Group-aware so identically-named // entries (the headless `Dialog` primitive vs. the styled `Dialog` component) stay distinct. const docModules: Record> = { - user: { + 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), + }, + 'user-profile': { + 'user-page': dynamic(() => import('../stories/user-page.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), + 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), + 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), + 'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), + 'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')), + 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), + 'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')), + 'user-profile-active-devices-section': dynamic(() => import('../stories/user-profile-active-devices-section.mdx')), + 'user-profile-subscription-section': dynamic(() => import('../stories/user-profile-subscription-section.mdx')), + 'user-profile-payment-methods-section': dynamic( + () => import('../stories/user-profile-payment-methods-section.mdx'), + ), + 'user-profile-billing-history-section': dynamic( + () => import('../stories/user-profile-billing-history-section.mdx'), + ), 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), ), @@ -83,7 +100,9 @@ export function DocsViewer({ group, slug }: DocsViewerProps) { key={`${group}/${slug}`} meta={meta} > -
+
{meta?.source ? (
diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index eba4369ff05..00cc791f6c2 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -1,9 +1,11 @@ 'use client'; +import { ChevronRightIcon } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import * as React from 'react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Sidebar, SidebarContent, @@ -15,11 +17,116 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarRail, + SidebarSeparator, } from '@/components/ui/sidebar'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { getSidebarGroups } from '@/lib/registry'; const groups = getSidebarGroups(); +const COLLAPSED_BY_DEFAULT = new Set(['Primitives', 'Components', 'Styles', 'Hooks']); + +type SidebarEntry = ReturnType[number]['components'][number]; + +// Partitions a group's entries by `meta.navigation.category` into subheaded runs. Category and +// entry order both follow first appearance in the registry; uncategorized entries get no subheading. +function byCategory(components: SidebarEntry[]) { + const categories: { category: string; components: SidebarEntry[] }[] = []; + for (const component of components) { + const category = component.mod.meta.navigation?.category ?? ''; + const bucket = categories.find(c => c.category === category); + if (bucket) { + bucket.components.push(component); + } else { + categories.push({ category, components: [component] }); + } + } + return categories; +} + +function SidebarUsageItem({ usage, href, isActive }: { usage: string; href: string; isActive: boolean }) { + const labelRef = React.useRef(null); + const [isTruncated, setIsTruncated] = React.useState(false); + + React.useEffect(() => { + const label = labelRef.current; + if (!label) { + return; + } + const check = () => setIsTruncated(label.scrollWidth > label.clientWidth); + check(); + const observer = new ResizeObserver(check); + observer.observe(label); + return () => observer.disconnect(); + }, []); + + return ( + + + } + > + + {usage} + + + } + /> + + {usage} + + + + ); +} + +function SidebarEntryMenu({ + components, + groupSlug, + pathname, +}: { + components: SidebarEntry[]; + groupSlug: string; + pathname: string; +}) { + return ( + + {components.map(({ mod, componentSlug }) => { + const href = `/${groupSlug}/${componentSlug}`; + // How an entry is USED differs by layer, so the label follows the layer rather + // than a guess at the title: hooks are called, atomic styles are a set of + // exports with no single call form worth privileging, and everything else is a + // component rendered as JSX. + const usage = + mod.meta.group === 'Hooks' + ? `${mod.meta.title}()` + : mod.meta.group === 'Styles' + ? mod.meta.title + : `<${mod.meta.title} />`; + return ( + + ); + })} + + ); +} + export function AppSidebar({ ...props }: React.ComponentProps) { const pathname = usePathname(); @@ -59,45 +166,69 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {groups.map(({ group, groupSlug, components }) => ( - - - {group} - - - - {components.map(({ mod, componentSlug }) => { - const href = `/${groupSlug}/${componentSlug}`; - // How an entry is USED differs by layer, so the label follows the layer rather - // than a guess at the title: hooks are called, atomic styles are a set of - // exports with no single call form worth privileging, and everything else is a - // component rendered as JSX. - const usage = - mod.meta.group === 'Hooks' - ? `${mod.meta.title}()` - : mod.meta.group === 'Styles' - ? mod.meta.title - : `<${mod.meta.title} />`; - return ( - - } - > - - {usage} - - - - ); - })} - - - + + {group === 'Components' && } + + + } + > + {group} + + + + + {byCategory(components).map(({ category, components }) => + category ? ( + pathname === `/${groupSlug}/${componentSlug}`, + )} + className='group/category' + > + + + {category} + + + +
+ +
+
+
+ ) : ( + + ), + )} +
+
+
+
+
))}
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 5aab8aeb2cd..05d34dccb2d 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -101,10 +101,29 @@ import { Organizations as UserButtonOrganizations, User as UserButtonUser, } from '../stories/user-button.stories'; +import { Default as UserPageDefault, meta as userPageMeta } from '../stories/user-page.stories'; import { Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, } from '../stories/user-profile-account-section.stories'; +import { + Default as UserProfileActiveDevicesSectionDefault, + meta as userProfileActiveDevicesSectionMeta, +} from '../stories/user-profile-active-devices-section.stories'; +import { + Default as UserProfileApiKeysPanelDefault, + Empty as UserProfileApiKeysPanelEmpty, + meta as userProfileApiKeysPanelMeta, +} from '../stories/user-profile-api-keys-panel.stories'; +import { + Default as UserProfileBillingHistorySectionDefault, + Empty as UserProfileBillingHistorySectionEmpty, + meta as userProfileBillingHistorySectionMeta, +} from '../stories/user-profile-billing-history-section.stories'; +import { + Default as UserProfileBillingPanelDefault, + meta as userProfileBillingPanelMeta, +} from '../stories/user-profile-billing-panel.stories'; import { Default as UserProfileConnectedAccountsSectionDefault, meta as userProfileConnectedAccountsSectionMeta, @@ -113,10 +132,37 @@ import { Default as UserProfileDeleteSectionDefault, meta as userProfileDeleteSectionMeta, } from '../stories/user-profile-delete-section.stories'; +import { + Default as UserProfileMfaSectionDefault, + Empty as UserProfileMfaSectionEmpty, + meta as userProfileMfaSectionMeta, +} from '../stories/user-profile-mfa-section.stories'; +import { + Default as UserProfilePasskeysSectionDefault, + Empty as UserProfilePasskeysSectionEmpty, + meta as userProfilePasskeysSectionMeta, +} from '../stories/user-profile-passkeys-section.stories'; +import { + Default as UserProfilePasswordSectionDefault, + meta as userProfilePasswordSectionMeta, +} from '../stories/user-profile-password-section.stories'; +import { + Default as UserProfilePaymentMethodsSectionDefault, + Empty as UserProfilePaymentMethodsSectionEmpty, + meta as userProfilePaymentMethodsSectionMeta, +} from '../stories/user-profile-payment-methods-section.stories'; import { Default as UserProfileProfilePanelDefault, meta as userProfileProfilePanelMeta, } from '../stories/user-profile-profile-panel.stories'; +import { + Default as UserProfileSecurityPanelDefault, + meta as userProfileSecurityPanelMeta, +} from '../stories/user-profile-security-panel.stories'; +import { + Default as UserProfileSubscriptionSectionDefault, + meta as userProfileSubscriptionSectionMeta, +} from '../stories/user-profile-subscription-section.stories'; import { Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, @@ -235,6 +281,16 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const userProfileApiKeysPanelModule: StoryModule = { + meta: userProfileApiKeysPanelMeta, + Default: UserProfileApiKeysPanelDefault, + Empty: UserProfileApiKeysPanelEmpty, +}; +const userPageModule: StoryModule = { + meta: userPageMeta, + Default: UserPageDefault, +}; + const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, @@ -243,6 +299,46 @@ const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, Default: UserProfileProfilePanelDefault, }; +const userProfileSecurityPanelModule: StoryModule = { + meta: userProfileSecurityPanelMeta, + Default: UserProfileSecurityPanelDefault, +}; +const userProfileBillingPanelModule: StoryModule = { + meta: userProfileBillingPanelMeta, + Default: UserProfileBillingPanelDefault, +}; +const userProfileBillingHistorySectionModule: StoryModule = { + meta: userProfileBillingHistorySectionMeta, + Default: UserProfileBillingHistorySectionDefault, + Empty: UserProfileBillingHistorySectionEmpty, +}; +const userProfilePasswordSectionModule: StoryModule = { + meta: userProfilePasswordSectionMeta, + Default: UserProfilePasswordSectionDefault, +}; +const userProfilePasskeysSectionModule: StoryModule = { + meta: userProfilePasskeysSectionMeta, + Default: UserProfilePasskeysSectionDefault, + Empty: UserProfilePasskeysSectionEmpty, +}; +const userProfileMfaSectionModule: StoryModule = { + meta: userProfileMfaSectionMeta, + Default: UserProfileMfaSectionDefault, + Empty: UserProfileMfaSectionEmpty, +}; +const userProfileActiveDevicesSectionModule: StoryModule = { + meta: userProfileActiveDevicesSectionMeta, + Default: UserProfileActiveDevicesSectionDefault, +}; +const userProfileSubscriptionSectionModule: StoryModule = { + meta: userProfileSubscriptionSectionMeta, + Default: UserProfileSubscriptionSectionDefault, +}; +const userProfilePaymentMethodsSectionModule: StoryModule = { + meta: userProfilePaymentMethodsSectionMeta, + Default: UserProfilePaymentMethodsSectionDefault, + Empty: UserProfilePaymentMethodsSectionEmpty, +}; const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, @@ -257,10 +353,24 @@ const userProfileDeleteSectionModule: StoryModule = { }; export const registry: StoryModule[] = [ - // User + // User Button userButtonModule, + // User Profile + userPageModule, + // User Profile · Panels userProfileProfilePanelModule, + userProfileSecurityPanelModule, + userProfileBillingPanelModule, + userProfileApiKeysPanelModule, + // User Profile · Sections userProfileAccountSectionModule, + userProfilePasswordSectionModule, + userProfilePasskeysSectionModule, + userProfileMfaSectionModule, + userProfileActiveDevicesSectionModule, + userProfileSubscriptionSectionModule, + userProfilePaymentMethodsSectionModule, + userProfileBillingHistorySectionModule, userProfileConnectedAccountsSectionModule, userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, diff --git a/packages/swingset/src/lib/types.ts b/packages/swingset/src/lib/types.ts index e031a80e6cd..5ca91be71ab 100644 --- a/packages/swingset/src/lib/types.ts +++ b/packages/swingset/src/lib/types.ts @@ -37,12 +37,19 @@ export type KnobValues = Record; export interface StoryMeta { group: string; title: string; + /** Controls the documentation canvas width. Wide compositions still keep prose at a readable measure. */ + layout?: 'default' | 'wide'; /** * Optional human-friendly label shown in the sidebar. Falls back to `title` when * omitted. Use this when the desired sidebar text differs from the component name * (which still drives the slug and the `` tag). */ label?: string; + navigation?: { + family?: string; + category?: string; + order?: number; + }; /** * Path to the file that exports the documented component, relative to the monorepo * root (e.g. `packages/ui/src/mosaic/components/button.tsx`). Rendered as a "View diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index 06e1b30a79b..60f6fb2f9ed 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -17,8 +17,9 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-button.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Button', title: 'UserButton', + label: 'User button', source: 'packages/ui/src/mosaic/user-button/user-button.view.tsx', }; diff --git a/packages/swingset/src/stories/user-page.mdx b/packages/swingset/src/stories/user-page.mdx new file mode 100644 index 00000000000..8592ea9f463 --- /dev/null +++ b/packages/swingset/src/stories/user-page.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-page.stories'; + +# UserPage + +The complete User page. It owns the profile navigation and composes the Account, Security, Billing, +and API Keys panels without imposing a modal height or scroll container. + +<Story + name='Default' + storyModule={Stories} + composition={[ + { name: 'Profile panel', href: '/user-profile/user-profile-profile-panel', layer: 'User Profile' }, + { name: 'Security panel', href: '/user-profile/user-profile-security-panel', layer: 'User Profile' }, + { name: 'Billing panel', href: '/user-profile/user-profile-billing-panel', layer: 'User Profile' }, + { name: 'API keys panel', href: '/user-profile/user-profile-api-keys-panel', layer: 'User Profile' }, + ]} +/> diff --git a/packages/swingset/src/stories/user-page.stories.tsx b/packages/swingset/src/stories/user-page.stories.tsx new file mode 100644 index 00000000000..d07565d467a --- /dev/null +++ b/packages/swingset/src/stories/user-page.stories.tsx @@ -0,0 +1,250 @@ +import type { UserPageViewProps } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import type { UserProfileAPIKey } from '@clerk/ui/mosaic/user-profile/user-profile-api-keys-panel.view'; +import type { + UserProfilePaymentMethod, + UserProfileSubscription, +} from '@clerk/ui/mosaic/user-profile/user-profile-billing-panel.view'; +import type { UserProfileEmail, UserProfilePhone } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import type { + UserProfileDevice, + UserProfileMfaMethod, + UserProfilePasskey, +} from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import type { UserProfilePanelId } from '@clerk/ui/mosaic/user-profile/user-profile-sidebar'; +import { useMemo, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-page.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserPage', + label: 'User page', + layout: 'wide', + source: 'packages/ui/src/mosaic/user-profile/user-page.view.tsx', +}; + +const initialAPIKeys: UserProfileAPIKey[] = [ + { + id: 'primary', + name: 'Primary API Key', + expirationLabel: 'Expires Dec 31, 2027', + createdAtLabel: 'Jan 05, 2026', + lastUsedAtLabel: 'Dec 31, 2026', + }, + { + id: 'legacy', + name: 'Legacy API Key', + expirationLabel: 'Expired Jul 1, 2025', + createdAtLabel: 'Jul 1, 2024', + lastUsedAtLabel: 'Jul 1, 2025', + isExpired: true, + }, +]; + +export function Default() { + const [activePanel, setActivePanel] = useState<UserProfilePanelId>('account'); + const [emails, setEmails] = useState<UserProfileEmail[]>([ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + ]); + const [phones, setPhones] = useState<UserProfilePhone[]>([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([ + { + id: 'passkey', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ]); + const [mfaMethods, setMfaMethods] = useState<UserProfileMfaMethod[]>([ + { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup', type: 'backup-codes' }, + ]); + const [devices, setDevices] = useState<UserProfileDevice[]>([ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + ]); + const [subscription, setSubscription] = useState<UserProfileSubscription>({ + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }); + const [paymentMethods, setPaymentMethods] = useState<UserProfilePaymentMethod[]>([ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + ]); + const [historyPageSize, setHistoryPageSize] = useState(10); + const [apiKeys, setAPIKeys] = useState(initialAPIKeys); + const [apiKeysPageSize, setAPIKeysPageSize] = useState(10); + const [searchValue, setSearchValue] = useState(''); + const [selectedIds, setSelectedIds] = useState<string[]>([]); + const visibleAPIKeys = useMemo( + () => apiKeys.filter(apiKey => apiKey.name.toLowerCase().includes(searchValue.toLowerCase())), + [apiKeys, searchValue], + ); + + const panels: UserPageViewProps['panels'] = { + account: { + imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', + name: 'Preston Booth', + username: 'prestonxyz', + emails, + phones, + onAddEmail: () => + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]), + onAddPhone: () => + setPhones(current => [ + ...current, + { + id: `phone_${Date.now()}`, + value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, + isVerified: true, + }, + ]), + onDeleteAccount: () => undefined, + onEditProfilePicture: () => undefined, + onManageEmail: () => undefined, + onManagePhone: () => undefined, + onNameChange: () => undefined, + onRemoveEmail: id => setEmails(current => current.filter(email => email.id !== id)), + onRemovePhone: id => setPhones(current => current.filter(phone => phone.id !== id)), + onSetPrimaryEmail: id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))), + onSetPrimaryPhone: id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))), + onUsernameChange: () => undefined, + onVerifyEmail: id => + setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), + onVerifyPhone: id => + setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), + }, + security: { + hasPassword: true, + passkeys, + mfaMethods, + devices, + onAddMfaMethod: type => + setMfaMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }), + onAddPasskey: () => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]), + onChangePassword: () => undefined, + onDeleteAccount: () => undefined, + onManageDevice: () => undefined, + onManagePasskey: () => undefined, + onRegenerateBackupCodes: () => + setMfaMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ), + onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), + onRemovePasskey: id => setPasskeys(current => current.filter(passkey => passkey.id !== id)), + onSignOutAllOtherDevices: () => setDevices(current => current.filter(device => device.isCurrent)), + onSignOutDevice: id => setDevices(current => current.filter(device => device.id !== id)), + }, + billing: { + subscription, + paymentMethods, + historyItems: [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + ], + historyPagination: { page: 1, pageCount: 1, pageSize: historyPageSize }, + onAddPaymentMethod: () => + setPaymentMethods(current => [ + ...current, + { id: `card-${Date.now()}`, label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030' }, + ]), + onChangePlan: () => + setSubscription(current => + current.planName === 'Basic Plan' + ? { + planName: 'Pro Plan', + priceLabel: '$25 / Month', + totalDueLabel: '$25.00', + renewsAtLabel: 'Renews Aug 26', + } + : { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }, + ), + onMakeDefaultPaymentMethod: id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))), + onRemovePaymentMethod: id => + setPaymentMethods(current => current.filter(paymentMethod => paymentMethod.id !== id)), + onBillingHistoryPageSizeChange: setHistoryPageSize, + onViewInvoice: () => undefined, + }, + apiKeys: { + apiKeys: visibleAPIKeys, + pagination: { page: 1, pageCount: 1, pageSize: apiKeysPageSize }, + searchValue, + selectedIds, + onCreate: () => + setAPIKeys(current => [ + ...current, + { + id: `key-${Date.now()}`, + name: `API Key ${current.length + 1}`, + expirationLabel: 'Expires Never', + createdAtLabel: 'Just now', + lastUsedAtLabel: 'Never', + }, + ]), + onPageSizeChange: setAPIKeysPageSize, + onRevoke: id => { + setAPIKeys(current => current.filter(apiKey => apiKey.id !== id)); + setSelectedIds(current => current.filter(selectedId => selectedId !== id)); + }, + onSearchChange: setSearchValue, + onSelectionChange: setSelectedIds, + }, + }; + + return ( + <UserPageView + activePanel={activePanel} + panels={panels} + onPanelChange={setActivePanel} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 71494230b16..bba49d88af4 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,31 +1,59 @@ +import type { + UserProfileEmail, + UserProfilePhone, +} from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-account-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileAccountSection', + label: 'Account', + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx', }; export function Default() { + const [emails, setEmails] = useState<UserProfileEmail[]>([ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + ]); + const [phones, setPhones] = useState<UserProfilePhone[]>([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); + return ( <UserProfileAccountSectionView - emails={[ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, - ]} + emails={emails} imageUrl='https://avatars.githubusercontent.com/u/51144033?v=4' name='Preston Booth' - phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]} + phones={phones} username='prestonxyz' - onAddEmail={() => undefined} - onAddPhone={() => undefined} + onAddEmail={() => + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]) + } + onAddPhone={() => + setPhones(current => [ + ...current, + { + id: `phone_${Date.now()}`, + value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, + isVerified: true, + }, + ]) + } onEditProfilePicture={() => undefined} onManageEmail={() => undefined} onManagePhone={() => undefined} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onNameChange={() => undefined} onUsernameChange={() => undefined} /> diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.mdx b/packages/swingset/src/stories/user-profile-active-devices-section.mdx new file mode 100644 index 00000000000..6aa7f659b7e --- /dev/null +++ b/packages/swingset/src/stories/user-profile-active-devices-section.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-active-devices-section.stories'; + +# UserProfileActiveDevicesSection + +The current device and other active sessions composed with `Section`. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx new file mode 100644 index 00000000000..1c231a6e034 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-active-devices-section.stories.tsx @@ -0,0 +1,48 @@ +import type { UserProfileDevice } from '@clerk/ui/mosaic/user-profile/user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-active-devices-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-active-devices-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileActiveDevicesSection', + label: 'Active devices', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx', +}; + +export function Default() { + const [devices, setDevices] = useState<UserProfileDevice[]>([ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, + ]); + + return ( + <UserProfileActiveDevicesSectionView + devices={devices} + onManageDevice={() => undefined} + onSignOutAllOtherDevices={() => setDevices(current => current.filter(device => device.isCurrent))} + onSignOutDevice={id => setDevices(current => current.filter(device => device.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.mdx b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx new file mode 100644 index 00000000000..d9c1fa6494c --- /dev/null +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.mdx @@ -0,0 +1,21 @@ +import * as Stories from './user-profile-api-keys-panel.stories'; + +# UserProfileApiKeysPanel + +Search, selection, key metadata, row actions, and pagination composed without the surrounding navigation shell. + +<Story + name='Default' + storyModule={Stories} + composition={[ + { name: 'Input', href: '/components/input', layer: 'Components' }, + { name: 'Button', href: '/components/button', layer: 'Components' }, + { name: 'Badge', href: '/components/badge', layer: 'Components' }, + { name: 'Menu', href: '/components/menu', layer: 'Components' }, + ]} +/> + +<Story + name='Empty' + storyModule={Stories} +/> diff --git a/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx new file mode 100644 index 00000000000..b8421bbe5fd --- /dev/null +++ b/packages/swingset/src/stories/user-profile-api-keys-panel.stories.tsx @@ -0,0 +1,117 @@ +import type { UserProfileAPIKey } from '@clerk/ui/mosaic/user-profile/user-profile-api-keys-panel.view'; +import { UserProfileApiKeysPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-api-keys-panel.view'; +import { useMemo, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-api-keys-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileApiKeysPanel', + label: 'API keys panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx', +}; + +const initialAPIKeys: UserProfileAPIKey[] = [ + { + id: 'primary', + name: 'Primary API Key', + expirationLabel: 'Expires Dec 31, 2027', + createdAtLabel: 'Jan 05, 2026', + lastUsedAtLabel: 'Dec 31, 2026', + }, + { + id: 'backup', + name: 'Backup API Key', + expirationLabel: 'Expires Never', + createdAtLabel: 'Mar 22, 2022', + lastUsedAtLabel: 'Mar 22, 2022', + }, + { + id: 'analytics', + name: 'Analytics Key', + expirationLabel: 'Expires Never', + createdAtLabel: 'Feb 10, 2021', + lastUsedAtLabel: 'Feb 10, 2021', + }, + { + id: 'integration', + name: 'Integration Key', + expirationLabel: 'Expires Nov 5, 2026', + createdAtLabel: 'Nov 5, 2025', + lastUsedAtLabel: 'Nov 5, 2026', + isExpired: true, + }, + { + id: 'legacy', + name: 'Legacy API Key', + expirationLabel: 'Expired Jul 1, 2025', + createdAtLabel: 'Jul 1, 2024', + lastUsedAtLabel: 'Jul 1, 2025', + isExpired: true, + }, + { + id: 'development', + name: 'Dev Environment Key', + expirationLabel: 'Expired Sep 30, 2024', + createdAtLabel: 'Sep 30, 2022', + lastUsedAtLabel: 'Sep 30, 2024', + isExpired: true, + }, +]; + +export function Default() { + const [apiKeys, setAPIKeys] = useState(initialAPIKeys); + const [pageSize, setPageSize] = useState(10); + const [searchValue, setSearchValue] = useState(''); + const [selectedIds, setSelectedIds] = useState<string[]>([]); + const visibleAPIKeys = useMemo( + () => apiKeys.filter(apiKey => apiKey.name.toLowerCase().includes(searchValue.toLowerCase())), + [apiKeys, searchValue], + ); + + return ( + <UserProfileApiKeysPanelView + apiKeys={visibleAPIKeys} + pagination={{ page: 1, pageCount: 1, pageSize }} + searchValue={searchValue} + selectedIds={selectedIds} + onCreate={() => + setAPIKeys(current => [ + ...current, + { + id: `key-${Date.now()}`, + name: `API Key ${current.length + 1}`, + expirationLabel: 'Expires Never', + createdAtLabel: 'Just now', + lastUsedAtLabel: 'Never', + }, + ]) + } + onPageSizeChange={setPageSize} + onRevoke={id => { + setAPIKeys(current => current.filter(apiKey => apiKey.id !== id)); + setSelectedIds(current => current.filter(selectedId => selectedId !== id)); + }} + onSearchChange={setSearchValue} + onSelectionChange={setSelectedIds} + /> + ); +} + +export function Empty() { + const [searchValue, setSearchValue] = useState(''); + + return ( + <UserProfileApiKeysPanelView + apiKeys={[]} + searchValue={searchValue} + selectedIds={[]} + onCreate={() => {}} + onSearchChange={setSearchValue} + onSelectionChange={() => {}} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-billing-history-section.mdx b/packages/swingset/src/stories/user-profile-billing-history-section.mdx new file mode 100644 index 00000000000..d3269e1b413 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-history-section.mdx @@ -0,0 +1,20 @@ +import * as Stories from './user-profile-billing-history-section.stories'; + +# UserProfileBillingHistorySection + +Billing history rendered as a section-local semantic table. + +<Story + name='Default' + storyModule={Stories} + composition={[ + { name: 'Section', href: '/components/section', layer: 'Components' }, + { name: 'Badge', href: '/components/badge', layer: 'Components' }, + { name: 'Button', href: '/components/button', layer: 'Components' }, + ]} +/> + +<Story + name='Empty' + storyModule={Stories} +/> diff --git a/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx b/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx new file mode 100644 index 00000000000..aabb3f04b83 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-history-section.stories.tsx @@ -0,0 +1,56 @@ +import type { UserProfileBillingHistoryItem } from '@clerk/ui/mosaic/user-profile/user-profile-billing-history-section.view'; +import { UserProfileBillingHistorySectionView } from '@clerk/ui/mosaic/user-profile/user-profile-billing-history-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-billing-history-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileBillingHistorySection', + label: 'Billing history', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx', +}; + +const items: UserProfileBillingHistoryItem[] = [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202606_0644', + dateLabel: 'Jun 3, 2026', + invoiceLabel: 'stmt_202606_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202607_0644', + dateLabel: 'Jun 10, 2026', + invoiceLabel: 'stmt_202607_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, +]; + +export function Default() { + const [pageSize, setPageSize] = useState(10); + + return ( + <UserProfileBillingHistorySectionView + items={items} + pagination={{ page: 1, pageCount: 1, pageSize }} + onPageSizeChange={setPageSize} + onView={() => {}} + /> + ); +} + +export function Empty() { + return <UserProfileBillingHistorySectionView items={[]} />; +} diff --git a/packages/swingset/src/stories/user-profile-billing-panel.mdx b/packages/swingset/src/stories/user-profile-billing-panel.mdx new file mode 100644 index 00000000000..90b9ec8fd02 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-panel.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-billing-panel.stories'; + +# UserProfileBillingPanel + +Subscription, payment methods, and an inline billing history table composed without the surrounding navigation shell. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx new file mode 100644 index 00000000000..b048aa576f6 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-billing-panel.stories.tsx @@ -0,0 +1,118 @@ +import type { + UserProfileBillingHistoryItem, + UserProfilePaymentMethod, + UserProfileSubscription, +} from '@clerk/ui/mosaic/user-profile/user-profile-billing-panel.view'; +import { UserProfileBillingPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-billing-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-billing-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileBillingPanel', + label: 'Billing panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx', +}; + +const initialSubscription: UserProfileSubscription = { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', +}; + +const initialPaymentMethods: UserProfilePaymentMethod[] = [ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + { id: 'mastercard', label: 'Mastercard •••• 1212', expiryLabel: 'Expires 02/2029' }, +]; + +const historyItems: UserProfileBillingHistoryItem[] = [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202606_0644', + dateLabel: 'Jun 3, 2026', + invoiceLabel: 'stmt_202606_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202607_0644', + dateLabel: 'Jun 10, 2026', + invoiceLabel: 'stmt_202607_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202608_0644', + dateLabel: 'Jun 18, 2026', + invoiceLabel: 'stmt_202608_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202609_0644', + dateLabel: 'Jul 1, 2026', + invoiceLabel: 'stmt_202609_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202610_0644', + dateLabel: 'Jul 9, 2026', + invoiceLabel: 'stmt_202610_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'stmt_202611_0644', + dateLabel: 'Jul 23, 2026', + invoiceLabel: 'stmt_202611_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, +]; + +export function Default() { + const [subscription, setSubscription] = useState(initialSubscription); + const [paymentMethods, setPaymentMethods] = useState(initialPaymentMethods); + const [historyPageSize, setHistoryPageSize] = useState(10); + + return ( + <UserProfileBillingPanelView + historyItems={historyItems} + historyPagination={{ page: 1, pageCount: 1, pageSize: historyPageSize }} + paymentMethods={paymentMethods} + subscription={subscription} + onAddPaymentMethod={() => + setPaymentMethods(current => [ + ...current, + { id: `card-${Date.now()}`, label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030' }, + ]) + } + onChangePlan={() => + setSubscription({ + planName: 'Pro Plan', + priceLabel: '$25 / Month', + totalDueLabel: '$25.00', + renewsAtLabel: 'Renews Aug 26', + }) + } + onMakeDefaultPaymentMethod={id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))) + } + onRemovePaymentMethod={id => setPaymentMethods(current => current.filter(method => method.id !== id))} + onBillingHistoryPageSizeChange={setHistoryPageSize} + onViewInvoice={() => {}} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx index 189e78f123d..61a8f4d63af 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx @@ -5,8 +5,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-connected-accounts-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileConnectedAccountsSection', + label: 'Connected accounts', + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx index 9c316bf4ed8..cc18ac403eb 100644 --- a/packages/swingset/src/stories/user-profile-delete-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-delete-section.stories.tsx @@ -5,8 +5,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-delete-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileDeleteSection', + label: 'Danger zone', + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx', }; diff --git a/packages/swingset/src/stories/user-profile-mfa-section.mdx b/packages/swingset/src/stories/user-profile-mfa-section.mdx new file mode 100644 index 00000000000..c915eceedaa --- /dev/null +++ b/packages/swingset/src/stories/user-profile-mfa-section.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-profile-mfa-section.stories'; + +# UserProfileMfaSection + +Two-step verification methods composed with the shared Security list treatment. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> + +<Story + name='Empty' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx new file mode 100644 index 00000000000..0fd8382332d --- /dev/null +++ b/packages/swingset/src/stories/user-profile-mfa-section.stories.tsx @@ -0,0 +1,84 @@ +import type { UserProfileMfaMethod } from '@clerk/ui/mosaic/user-profile/user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-mfa-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-mfa-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileMfaSection', + label: '2-step verification', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx', +}; + +export function Default() { + const [methods, setMethods] = useState<UserProfileMfaMethod[]>([ + { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup', type: 'backup-codes' }, + ]); + + return ( + <UserProfileMfaSectionView + methods={methods} + sectionTitle='Authentication' + onAdd={type => + setMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }) + } + onRegenerateBackupCodes={() => + setMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ) + } + onRemove={id => setMethods(current => current.filter(method => method.id !== id))} + /> + ); +} + +export function Empty() { + const [methods, setMethods] = useState<UserProfileMfaMethod[]>([]); + + return ( + <UserProfileMfaSectionView + methods={methods} + sectionTitle='Authentication' + onAdd={type => + setMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }) + } + onRegenerateBackupCodes={() => + setMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ) + } + onRemove={id => setMethods(current => current.filter(method => method.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.mdx b/packages/swingset/src/stories/user-profile-passkeys-section.mdx new file mode 100644 index 00000000000..b4ba13cebb8 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-passkeys-section.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-profile-passkeys-section.stories'; + +# UserProfilePasskeysSection + +Passkey management composed with the shared Security list treatment. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> + +<Story + name='Empty' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx new file mode 100644 index 00000000000..fe476e55d54 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-passkeys-section.stories.tsx @@ -0,0 +1,59 @@ +import type { UserProfilePasskey } from '@clerk/ui/mosaic/user-profile/user-profile-passkeys-section.view'; +import { UserProfilePasskeysSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-passkeys-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-passkeys-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfilePasskeysSection', + label: 'Passkeys', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx', +}; + +export function Default() { + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([ + { + id: 'passkey', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ]); + + return ( + <UserProfilePasskeysSectionView + passkeys={passkeys} + sectionTitle='Authentication' + onAdd={() => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]) + } + onManage={() => undefined} + onRemove={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + /> + ); +} + +export function Empty() { + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([]); + + return ( + <UserProfilePasskeysSectionView + passkeys={passkeys} + sectionTitle='Authentication' + onAdd={() => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]) + } + onRemove={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-password-section.mdx b/packages/swingset/src/stories/user-profile-password-section.mdx new file mode 100644 index 00000000000..3f36536dac4 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-password-section.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-password-section.stories'; + +# UserProfilePasswordSection + +Password management composed with `Section`. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx new file mode 100644 index 00000000000..462112b0db4 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -0,0 +1,17 @@ +import { UserProfilePasswordSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-password-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-password-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfilePasswordSection', + label: 'Password', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx', +}; + +export function Default() { + return <UserProfilePasswordSectionView onChangePassword={() => undefined} />; +} diff --git a/packages/swingset/src/stories/user-profile-payment-methods-section.mdx b/packages/swingset/src/stories/user-profile-payment-methods-section.mdx new file mode 100644 index 00000000000..1f49f3646fe --- /dev/null +++ b/packages/swingset/src/stories/user-profile-payment-methods-section.mdx @@ -0,0 +1,17 @@ +import * as Stories from './user-profile-payment-methods-section.stories'; + +# UserProfilePaymentMethodsSection + +Saved payment methods with default and removal actions. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> + +<Story + name='Empty' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx b/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx new file mode 100644 index 00000000000..4a4148f1ac3 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-payment-methods-section.stories.tsx @@ -0,0 +1,55 @@ +import type { UserProfilePaymentMethod } from '@clerk/ui/mosaic/user-profile/user-profile-payment-methods-section.view'; +import { UserProfilePaymentMethodsSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-payment-methods-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-payment-methods-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfilePaymentMethodsSection', + label: 'Payment methods', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx', +}; + +export function Default() { + const [paymentMethods, setPaymentMethods] = useState<UserProfilePaymentMethod[]>([ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + { id: 'mastercard', label: 'Mastercard •••• 1212', expiryLabel: 'Expires 02/2029' }, + ]); + + return ( + <UserProfilePaymentMethodsSectionView + paymentMethods={paymentMethods} + onAdd={() => + setPaymentMethods(current => [ + ...current, + { id: `card-${Date.now()}`, label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030' }, + ]) + } + onMakeDefault={id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))) + } + onRemove={id => setPaymentMethods(current => current.filter(method => method.id !== id))} + /> + ); +} + +export function Empty() { + const [paymentMethods, setPaymentMethods] = useState<UserProfilePaymentMethod[]>([]); + + return ( + <UserProfilePaymentMethodsSectionView + paymentMethods={paymentMethods} + onAdd={() => + setPaymentMethods([{ id: 'visa', label: 'Visa •••• 4242', expiryLabel: 'Expires 08/2030', isDefault: true }]) + } + onMakeDefault={id => + setPaymentMethods(current => current.map(method => ({ ...method, isDefault: method.id === id }))) + } + onRemove={id => setPaymentMethods(current => current.filter(method => method.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index b69cbefa42c..7662754284e 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -1,4 +1,6 @@ +import type { UserProfileEmail, UserProfilePhone } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -8,18 +10,25 @@ const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; export { default as __source } from './user-profile-profile-panel.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileProfilePanel', + label: 'Profile panel', + navigation: { category: 'Panels' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', }; export function Default(_args: Record<string, unknown>) { + const [emails, setEmails] = useState<UserProfileEmail[]>([ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, + ]); + const [phones, setPhones] = useState<UserProfilePhone[]>([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); + return ( <UserProfileProfilePanelView - emails={[ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, - ]} + emails={emails} connectedAccounts={[ { id: 'google', @@ -48,16 +57,32 @@ export function Default(_args: Record<string, unknown>) { ]} imageUrl={profileImageUrl} name='Preston Booth' - phones={[{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]} + phones={phones} username='prestonxyz' - onAddEmail={() => undefined} - onAddPhone={() => undefined} + onAddEmail={() => + setEmails(current => [ + ...current, + { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, + ]) + } + onAddPhone={() => + setPhones(current => [ + ...current, + { + id: `phone_${Date.now()}`, + value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, + isVerified: true, + }, + ]) + } onConnectAccount={() => undefined} onDeleteAccount={() => undefined} onEditProfilePicture={() => undefined} + onManageEmail={() => undefined} + onManagePhone={() => undefined} onRemoveConnectedAccount={() => undefined} - onRemoveEmail={() => undefined} - onRemovePhone={() => undefined} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onConnectWeb3Wallet={() => undefined} onRemoveWeb3Wallet={() => undefined} onSetPrimaryWeb3Wallet={() => undefined} diff --git a/packages/swingset/src/stories/user-profile-security-panel.mdx b/packages/swingset/src/stories/user-profile-security-panel.mdx new file mode 100644 index 00000000000..ba87eecb59d --- /dev/null +++ b/packages/swingset/src/stories/user-profile-security-panel.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-security-panel.stories'; + +# UserProfileSecurityPanel + +Authentication methods, active devices, and the danger zone composed without the surrounding navigation shell. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-security-panel.stories.tsx b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx new file mode 100644 index 00000000000..e02a43d8489 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-security-panel.stories.tsx @@ -0,0 +1,99 @@ +import type { + UserProfileDevice, + UserProfileMfaMethod, + UserProfilePasskey, +} from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import { UserProfileSecurityPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-security-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileSecurityPanel', + label: 'Security panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx', +}; + +export function Default() { + const [passkeys, setPasskeys] = useState<UserProfilePasskey[]>([ + { + id: 'passkey', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ]); + const [mfaMethods, setMfaMethods] = useState<UserProfileMfaMethod[]>([ + { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup', type: 'backup-codes' }, + ]); + const [devices, setDevices] = useState<UserProfileDevice[]>([ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, + ]); + + return ( + <UserProfileSecurityPanelView + devices={devices} + hasPassword + mfaMethods={mfaMethods} + passkeys={passkeys} + onAddMfaMethod={type => + setMfaMethods(current => { + const timestamp = Date.now(); + return [ + ...current, + { + id: `${type}-${timestamp}`, + type, + description: type === 'sms' ? '+1 801-555-0100' : undefined, + }, + ...(current.some(method => method.type === 'backup-codes') + ? [] + : [{ id: `backup-${timestamp}`, type: 'backup-codes' as const }]), + ]; + }) + } + onAddPasskey={() => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]) + } + onChangePassword={() => undefined} + onDeleteAccount={() => undefined} + onManageDevice={() => undefined} + onManagePasskey={() => undefined} + onRegenerateBackupCodes={() => + setMfaMethods(current => + current.map(method => (method.type === 'backup-codes' ? { ...method, description: 'Just now' } : method)), + ) + } + onRemoveMfaMethod={id => setMfaMethods(current => current.filter(method => method.id !== id))} + onRemovePasskey={id => setPasskeys(current => current.filter(passkey => passkey.id !== id))} + onSignOutAllOtherDevices={() => setDevices(current => current.filter(device => device.isCurrent))} + onSignOutDevice={id => setDevices(current => current.filter(device => device.id !== id))} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-subscription-section.mdx b/packages/swingset/src/stories/user-profile-subscription-section.mdx new file mode 100644 index 00000000000..f8c99e74780 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-subscription-section.mdx @@ -0,0 +1,11 @@ +import * as Stories from './user-profile-subscription-section.stories'; + +# UserProfileSubscriptionSection + +Current plan, renewal date, total due, and plan-change action. + +<Story + name='Default' + storyModule={Stories} + composition={[{ name: 'Section', href: '/components/section', layer: 'Components' }]} +/> diff --git a/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx b/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx new file mode 100644 index 00000000000..5ef950b817e --- /dev/null +++ b/packages/swingset/src/stories/user-profile-subscription-section.stories.tsx @@ -0,0 +1,30 @@ +import { UserProfileSubscriptionSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-subscription-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './user-profile-subscription-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileSubscriptionSection', + label: 'Subscription', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx', +}; + +export function Default() { + const [isPro, setIsPro] = useState(false); + + return ( + <UserProfileSubscriptionSectionView + subscription={{ + planName: isPro ? 'Pro Plan' : 'Basic Plan', + priceLabel: isPro ? '$25 / Month' : '$12 / Month', + totalDueLabel: isPro ? '$25.00' : '$12.00', + renewsAtLabel: 'Renews Aug 26', + }} + onChangePlan={() => setIsPro(value => !value)} + /> + ); +} diff --git a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx index 21964f25d18..9b0bec9b6ab 100644 --- a/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-web3-wallets-section.stories.tsx @@ -5,8 +5,10 @@ import type { StoryMeta } from '@/lib/types'; export { default as __source } from './user-profile-web3-wallets-section.stories?raw'; export const meta: StoryMeta = { - group: 'User', + group: 'User Profile', title: 'UserProfileWeb3WalletsSection', + label: 'Web3 wallets', + navigation: { category: 'Sections' }, source: 'packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx', }; diff --git a/packages/ui/src/mosaic/components/icon/icon.test.tsx b/packages/ui/src/mosaic/components/icon/icon.test.tsx index dc9483eb2bd..574b726c327 100644 --- a/packages/ui/src/mosaic/components/icon/icon.test.tsx +++ b/packages/ui/src/mosaic/components/icon/icon.test.tsx @@ -19,6 +19,28 @@ describe('Mosaic Icon', () => { expect(svg?.querySelector('path')).not.toBeNull(); }); + it.each(['security-phone', 'security-lock-square', 'security-passkey'] as const)( + 'renders the %s glyph on its 18px canvas', + name => { + const { container } = wrap(<Icon name={name} />); + const svg = container.querySelector('svg'); + + expect(svg).toHaveAttribute('viewBox', '0 0 18 18'); + expect(svg?.querySelector('path')).toHaveAttribute('fill', 'currentColor'); + }, + ); + + it.each([ + ['device-phone', ['#646464', '#646464', '#343434', '#575757', '#171717', 'black']], + ['device-laptop', ['black', '#575757', 'black', '#444444', '#171717']], + ] as const)('preserves the supplied %s palette', (name, palette) => { + const { container } = wrap(<Icon name={name} />); + const paths = Array.from(container.querySelectorAll('path')); + + expect(container.querySelector('svg')).toHaveAttribute('viewBox', '0 0 18 18'); + expect(paths.map(path => path.getAttribute('fill'))).toEqual(palette); + }); + it('applies the default size when none is passed', () => { const { container } = wrap(<Icon name='chevron-right' />); expect(container.querySelector('svg')).toHaveAttribute('data-size', 'md'); diff --git a/packages/ui/src/mosaic/components/section/section.styles.ts b/packages/ui/src/mosaic/components/section/section.styles.ts index df51f9e5472..f324b95efb2 100644 --- a/packages/ui/src/mosaic/components/section/section.styles.ts +++ b/packages/ui/src/mosaic/components/section/section.styles.ts @@ -9,10 +9,11 @@ export const styles = stylex.create({ root: { display: 'flex', flexDirection: 'column', - rowGap: space['2'], + rowGap: space['3'], width: '100%', }, title: { + color: colorVars['--cl-color-neutral'], fontWeight: fontWeightVars['--cl-font-medium'], }, group: { @@ -36,32 +37,49 @@ export const styles = stylex.create({ flexDirection: 'column', paddingBlockEnd: { default: space['4'], - [stylex.when.descendant('[data-nested]', sectionItemsMarker)]: space['1'], + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, + }, + paddingBlockStart: { + default: space['4'], + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: space['3'], }, - paddingBlockStart: space['4'], rowGap: { default: space['2'], - [stylex.when.descendant('[data-nested]', sectionItemsMarker)]: space['3'], + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, + }, + minHeight: { + default: `calc(${space['18.5']} + 1px)`, + [stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0, }, - minHeight: `calc(${space['18.5']} + 1px)`, width: 'auto', }, items: { + backgroundColor: colorVars['--cl-color-border'], + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', display: 'flex', flexDirection: 'column', + marginBlockStart: space['3'], + rowGap: '1px', width: '100%', }, item: { + paddingBlock: { + default: null, + [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: space['4'], + }, alignItems: 'center', + backgroundColor: { + default: null, + [stylex.when.ancestor(':where(*)', sectionItemsMarker)]: colorVars['--cl-color-card'], + }, columnGap: space['3'], display: 'flex', flexWrap: 'nowrap', justifyContent: 'space-between', width: '100%', }, - nestedItem: { - paddingBlock: space['1'], - }, mediaBase: { alignItems: 'center', alignSelf: 'center', diff --git a/packages/ui/src/mosaic/components/section/section.test.tsx b/packages/ui/src/mosaic/components/section/section.test.tsx index 9a31513a612..f474cf35f8e 100644 --- a/packages/ui/src/mosaic/components/section/section.test.tsx +++ b/packages/ui/src/mosaic/components/section/section.test.tsx @@ -59,7 +59,7 @@ describe('Section', () => { <Section.Root> <Section.Title>Profile</Section.Title> <Section.Group> - <Section.Row> + <Section.Row data-testid='row'> <Section.Item> <Section.Content> <Section.Label>Email</Section.Label> @@ -83,9 +83,30 @@ describe('Section', () => { expect(screen.getByText('ada@example.com')).toBeInTheDocument(); expect(screen.getAllByText(/Edit|More/)).toHaveLength(2); expect(screen.getByTestId('items')).toHaveClass('cl-section-items'); - expect(screen.getByTestId('items')).toHaveAttribute('data-nested'); - expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested'); - expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested'); + expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant'); + expect(screen.getByTestId('items')).not.toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-item')).not.toHaveAttribute('data-nested'); + expect(screen.getByTestId('nested-content')).not.toHaveAttribute('data-nested'); + }); + + it('uses the item collection structure without public styling variants', () => { + render( + <Section.Root> + <Section.Group> + <Section.Row data-testid='row'> + <Section.Item>Email</Section.Item> + <Section.Items> + <Section.Item>one@example.com</Section.Item> + <Section.Item>two@example.com</Section.Item> + </Section.Items> + </Section.Row> + </Section.Group> + </Section.Root>, + ); + + expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant'); + expect(screen.getByText('one@example.com')).not.toHaveAttribute('data-nested'); + expect(screen.getByText('two@example.com')).not.toHaveAttribute('data-nested'); }); it('lets consumer props win and forwards refs and custom elements', () => { diff --git a/packages/ui/src/mosaic/components/section/section.tsx b/packages/ui/src/mosaic/components/section/section.tsx index 944166d2a8e..1e934c48840 100644 --- a/packages/ui/src/mosaic/components/section/section.tsx +++ b/packages/ui/src/mosaic/components/section/section.tsx @@ -32,7 +32,6 @@ const mediaSizes = { }; const SectionTitleContext = React.createContext<React.Dispatch<React.SetStateAction<string[]>> | null>(null); -const SectionItemsContext = React.createContext(false); const Root = React.forwardRef<HTMLElement, SectionRootProps>(function SectionRoot( { render, className, style, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...rest }, @@ -77,7 +76,7 @@ const Title = React.forwardRef<HTMLHeadingElement, SectionTitleProps>(function S ref={ref} id={id} render={render ?? (props => <h4 {...props} />)} - size='sm' + size='base' {...mergeStyleProps(themeProps('section-title'), stylex.props(styles.title), className, style)} {...rest} /> @@ -99,7 +98,7 @@ const Group = React.forwardRef<HTMLDivElement, SectionGroupProps>(function Secti }); }); -const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(function SectionRow( +const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function SectionItems( { render, className, style, ...rest }, ref, ) { @@ -108,51 +107,42 @@ const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(function SectionRo render, ref, props: { - ...mergeStyleProps(themeProps('section-row'), stylex.props(reset.base, styles.row), className, style), + ...mergeStyleProps( + themeProps('section-items'), + stylex.props(reset.base, styles.items, sectionItemsMarker), + className, + style, + ), ...rest, }, }); }); -const Items = React.forwardRef<HTMLDivElement, SectionItemsProps>(function SectionItems( +const Row = React.forwardRef<HTMLDivElement, SectionRowProps>(function SectionRow( { render, className, style, ...rest }, ref, ) { - const element = useRender({ + return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps( - themeProps('section-items', { nested: true }), - stylex.props(reset.base, styles.items, sectionItemsMarker), - className, - style, - ), + ...mergeStyleProps(themeProps('section-row'), stylex.props(reset.base, styles.row), className, style), ...rest, }, }); - - return <SectionItemsContext.Provider value>{element}</SectionItemsContext.Provider>; }); const Item = React.forwardRef<HTMLDivElement, SectionItemProps>(function SectionItem( { render, className, style, ...rest }, ref, ) { - const nested = React.useContext(SectionItemsContext); - return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps( - themeProps('section-item', { nested }), - stylex.props(reset.base, styles.item, nested && styles.nestedItem), - className, - style, - ), + ...mergeStyleProps(themeProps('section-item'), stylex.props(reset.base, styles.item), className, style), ...rest, }, }); @@ -182,19 +172,12 @@ const Content = React.forwardRef<HTMLDivElement, SectionContentProps>(function S { render, className, style, ...rest }, ref, ) { - const nested = React.useContext(SectionItemsContext); - return useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps( - themeProps('section-content', { nested }), - stylex.props(reset.base, styles.content), - className, - style, - ), + ...mergeStyleProps(themeProps('section-content'), stylex.props(reset.base, styles.content), className, style), ...rest, }, }); diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index a652c87c706..71f4e41bfb4 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -94,6 +94,161 @@ const Plus = glyph( />, ); +const Search = glyph( + <path + d='M10 10.0104C10.7722 9.24089 11.25 8.17625 11.25 7C11.25 4.65279 9.34721 2.75 7 2.75C4.65279 2.75 2.75 4.65279 2.75 7C2.75 9.34721 4.65279 11.25 7 11.25C8.17096 11.25 9.23132 10.7764 10 10.0104ZM10 10.0104L13.25 13.25' + {...strokeProps} + />, +); + +const CreditCard = glyph( + <path + d='M2.75 6.75V10.25C2.75 11.3546 3.64543 12.25 4.75 12.25H11.25C12.3546 12.25 13.25 11.3546 13.25 10.25V6.75M2.75 6.75V5.75C2.75 4.64543 3.64543 3.75 4.75 3.75H11.25C12.3546 3.75 13.25 4.64543 13.25 5.75V6.75M2.75 6.75H13.25M5.75 9.25H6.25' + {...strokeProps} + />, +); + +const UserCircle = glyph( + <path + d='M11.1786 12.1788C10.4001 11.3023 9.26453 10.75 8 10.75C6.73547 10.75 5.59993 11.3023 4.82141 12.1788M11.1786 12.1788C12.4375 11.2197 13.25 9.70474 13.25 8C13.25 5.10051 10.8995 2.75 8 2.75C5.10051 2.75 2.75 5.10051 2.75 8C2.75 9.70474 3.56251 11.2197 4.82141 12.1788M11.1786 12.1788C10.2963 12.8509 9.19476 13.25 8 13.25C6.80524 13.25 5.7037 12.8509 4.82141 12.1788M9.25 7C9.25 7.69036 8.69036 8.25 8 8.25C7.30964 8.25 6.75 7.69036 6.75 7C6.75 6.30964 7.30964 5.75 8 5.75C8.69036 5.75 9.25 6.30964 9.25 7Z' + {...strokeProps} + />, +); + +const ShieldCheck = glyph( + <path + d='M13.25 5.9L8 2.75L2.75 5.9C2.75 5.9 3 12 7.25 13.25M9.75 10.85L11.15 12.25L13.25 8.75' + {...strokeProps} + />, +); + +const Code = glyph( + <path + d='M5.25 5.75L2.75 8L5.25 10.25M10.75 5.75L13.25 8L10.75 10.25' + {...strokeProps} + />, +); + +const SecurityPasskey = glyph( + <> + <path + d='M8.43754 5.0625C8.43754 4.44118 7.93386 3.9375 7.31254 3.9375C6.69122 3.9375 6.18754 4.44118 6.18754 5.0625C6.18754 5.68382 6.69122 6.1875 7.31254 6.1875C7.93386 6.1875 8.43754 5.68382 8.43754 5.0625ZM10.125 5.0625C10.125 6.6158 8.86584 7.875 7.31254 7.875C5.75924 7.875 4.50004 6.6158 4.50004 5.0625C4.50004 3.5092 5.75924 2.25 7.31254 2.25C8.86584 2.25 10.125 3.5092 10.125 5.0625Z' + fill='currentColor' + /> + <path + d='M7.31254 9C7.93629 9 8.53595 9.12402 9.08573 9.33948C9.51942 9.5095 9.73343 9.99887 9.56364 10.4326C9.39361 10.8665 8.90326 11.0806 8.4694 10.9105C8.1027 10.7669 7.71165 10.6875 7.31254 10.6875C5.77377 10.6875 4.48879 11.6999 4.17155 13.0562L3.93974 14.0438C3.94311 14.0471 3.94803 14.0515 3.95512 14.0548C3.96327 14.0586 3.97581 14.0625 3.99467 14.0625H8.77812C9.24395 14.0627 9.62187 14.4404 9.62187 14.9062C9.62187 15.3721 9.24395 15.7498 8.77812 15.75H3.99467C2.9311 15.7498 2.03268 14.7896 2.29399 13.6725L2.52799 12.6716C3.03625 10.4987 5.04597 9 7.31254 9Z' + fill='currentColor' + /> + <path + d='M15.1645 12.4805H11.6368V13.7922C11.6368 13.9476 11.7627 14.0735 11.918 14.0735H14.8832C15.0385 14.0735 15.1645 13.9476 15.1645 13.7922V12.4805ZM14.2163 10.4832C14.2161 10.0331 13.8513 9.66823 13.4012 9.66797C12.9508 9.66797 12.5851 10.0329 12.5849 10.4832V10.793H14.2163V10.4832ZM15.9038 10.793H16.0082C16.4742 10.793 16.852 11.1707 16.852 11.6367V13.7922C16.852 14.8795 15.9705 15.761 14.8832 15.761H11.918C10.8307 15.761 9.94926 14.8795 9.94926 13.7922V11.6367C9.94926 11.1707 10.327 10.793 10.793 10.793H10.8974V10.4832C10.8976 9.10091 12.0189 7.98047 13.4012 7.98047C14.7832 7.98073 15.9036 9.10107 15.9038 10.4832V10.793Z' + fill='currentColor' + /> + </>, + '0 0 18 18', +); + +const SecurityPhone = glyph( + <> + <path + d='M6.04014 2.8125C5.54942 2.8125 5.0625 3.25236 5.0625 3.90841V14.0916C5.0625 14.7477 5.54941 15.1875 6.04014 15.1875H11.9599C12.4506 15.1875 12.9375 14.7477 12.9375 14.0916V3.90841C12.9375 3.25236 12.4506 2.8125 11.9599 2.8125H6.04014ZM3.375 3.90841C3.375 2.42196 4.51901 1.125 6.04014 1.125H11.9599C13.481 1.125 14.625 2.42197 14.625 3.90841V14.0916C14.625 15.5781 13.481 16.875 11.9599 16.875H6.04014C4.51902 16.875 3.375 15.5781 3.375 14.0916V3.90841Z' + fill='currentColor' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M7.875 12.6562C7.875 12.1903 8.25276 11.8125 8.71875 11.8125H9.28125C9.74724 11.8125 10.125 12.1903 10.125 12.6562C10.125 13.1222 9.74724 13.5 9.28125 13.5H8.71875C8.25276 13.5 7.875 13.1222 7.875 12.6562Z' + fill='currentColor' + fillRule='evenodd' + clipRule='evenodd' + /> + </>, + '0 0 18 18', +); + +const SecurityLockSquare = glyph( + <path + d='M2.25 5.34375C2.25 3.63512 3.63512 2.25 5.34375 2.25H12.6562C14.3649 2.25 15.75 3.63512 15.75 5.34375V5.90625C15.75 6.37224 15.3722 6.75 14.9062 6.75C14.4403 6.75 14.0625 6.37224 14.0625 5.90625V5.34375C14.0625 4.5671 13.4329 3.9375 12.6562 3.9375H5.34375C4.5671 3.9375 3.9375 4.5671 3.9375 5.34375V12.6562C3.9375 13.4329 4.5671 14.0625 5.34375 14.0625H5.90625C6.37224 14.0625 6.75 14.4403 6.75 14.9062C6.75 15.3722 6.37224 15.75 5.90625 15.75H5.34375C3.63512 15.75 2.25 14.3649 2.25 12.6562V5.34375ZM11.8125 8.4375C11.1912 8.4375 10.6875 8.94118 10.6875 9.5625V10.125H12.9375V9.5625C12.9375 8.94118 12.4338 8.4375 11.8125 8.4375ZM14.625 10.125V9.5625C14.625 8.0092 13.3658 6.75 11.8125 6.75C10.2592 6.75 9 8.0092 9 9.5625V10.125H8.71875C8.25276 10.125 7.875 10.5028 7.875 10.9688V13.7812C7.875 14.8686 8.75644 15.75 9.84375 15.75H13.7812C14.8686 15.75 15.75 14.8686 15.75 13.7812V10.9688C15.75 10.5028 15.3722 10.125 14.9062 10.125H14.625ZM14.0625 11.8125H9.5625V13.7812C9.5625 13.9366 9.68842 14.0625 9.84375 14.0625H13.7812C13.9366 14.0625 14.0625 13.9366 14.0625 13.7812V11.8125Z' + fill='currentColor' + fillRule='evenodd' + clipRule='evenodd' + />, + '0 0 18 18', +); + +const DevicePhone = glyph( + <> + <path + d='M4.6045 6.07587V5.87713H4.54673C4.53907 5.87337 4.5302 5.87275 4.52207 5.87541C4.51395 5.87807 4.50723 5.88379 4.5034 5.89132C4.49957 5.89885 4.49895 5.90757 4.50166 5.91556C4.50437 5.92355 4.51018 5.93015 4.51784 5.93391V7.18312C4.51784 7.18312 4.51785 7.23991 4.57561 7.23991V6.07587H4.6045Z' + fill='#646464' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M4.6045 4.3725V4.17376H4.54673C4.53907 4.17 4.5302 4.16938 4.52207 4.17204C4.51395 4.1747 4.50723 4.18043 4.5034 4.18796C4.49957 4.19549 4.49895 4.2042 4.50166 4.21219C4.50437 4.22018 4.51018 4.22678 4.51784 4.23054V5.47975C4.51784 5.47975 4.51785 5.53654 4.57561 5.53654V4.3725H4.6045Z' + fill='#646464' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M11.9117 0C12.9515 0 13.5002 0.596216 13.5002 1.6183V16.4953C13.5002 17.4606 12.836 18 11.8251 18H6.22201C5.1245 18 4.54686 17.3186 4.57574 16.4669V1.6183C4.57574 0.596216 5.15339 0 6.19314 0H11.9117Z' + fill='#343434' + /> + <path + d='M11.7958 0.168701C12.8644 0.168701 13.3554 0.679743 13.3554 1.70182V16.4652C13.3554 17.3738 12.72 17.8848 11.7669 17.8848H6.27935C5.35513 17.8848 4.71973 17.317 4.71973 16.4652V1.70182C4.71973 0.679743 5.2396 0.168701 6.30823 0.168701H11.7958Z' + fill='#575757' + stroke='#444444' + strokeWidth={0.140625} + /> + <path + d='M6.77197 0.481689C6.82974 0.481689 6.85862 0.510079 6.85862 0.595252V0.652036C6.85862 0.907557 7.08966 1.13469 7.32072 1.13469H10.7288C11.0176 1.13469 11.2198 0.907557 11.2198 0.652036V0.595252C11.2198 0.510079 11.2487 0.481689 11.3064 0.481689H12.1729C12.635 0.481689 13.0393 0.964338 13.0393 1.4186V16.5795C13.0393 17.0621 12.6061 17.5164 12.0574 17.5164H5.99216C5.38564 17.5164 5.03906 17.1473 5.03906 16.6079V1.4186C5.03906 0.964338 5.41452 0.481689 5.87663 0.481689H6.74308H6.77197Z' + fill='#171717' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M9.77424 0.7378C9.77873 0.726659 9.78079 0.714714 9.78027 0.702742C9.77974 0.690769 9.77664 0.679039 9.77119 0.668321C9.76574 0.657602 9.75805 0.648135 9.74862 0.640539C9.7392 0.632942 9.72823 0.627388 9.71647 0.624237C9.70509 0.620508 9.69309 0.619019 9.68113 0.619855C9.66916 0.620691 9.65748 0.623833 9.64675 0.629106C9.63602 0.634379 9.62646 0.641678 9.6186 0.650585C9.61074 0.659492 9.60475 0.669833 9.60095 0.681018C9.59716 0.692203 9.59564 0.704012 9.59649 0.715773C9.59734 0.727533 9.60054 0.739016 9.60591 0.749561C9.61127 0.760107 9.61869 0.76951 9.62775 0.777235C9.63681 0.78496 9.64734 0.790855 9.65872 0.794583C9.67005 0.799 9.6822 0.801015 9.69438 0.800499C9.70656 0.799982 9.71849 0.796945 9.72939 0.791585C9.7403 0.786226 9.74992 0.778666 9.75765 0.769398C9.76538 0.76013 9.77103 0.749362 9.77424 0.7378ZM8.30127 0.681018C8.30127 0.7378 8.30127 0.766193 8.35904 0.766193H9.1966C9.21432 0.760068 9.22953 0.748448 9.23995 0.733086C9.25037 0.717724 9.25543 0.699451 9.25437 0.681018C9.25023 0.667614 9.2428 0.655421 9.23272 0.645516C9.22264 0.635612 9.21024 0.628303 9.1966 0.624237H8.35904C8.3454 0.628303 8.33299 0.635612 8.32292 0.645516C8.31284 0.655421 8.30541 0.667614 8.30127 0.681018Z' + fill='black' + fillRule='evenodd' + clipRule='evenodd' + /> + </>, + '0 0 18 18', +); + +const DeviceLaptop = glyph( + <> + <path + d='M1.63696 4.19191C1.63696 3.86515 1.68002 3.7251 1.76614 3.58506C1.8738 3.46836 2.02453 3.375 2.36903 3.375H15.6537C15.9551 3.375 16.0843 3.44502 16.192 3.56172C16.2996 3.67842 16.3427 3.8418 16.3427 4.19191V13.7614C16.3427 14.0882 16.2996 14.2282 16.235 14.3216C16.1805 14.402 16.1091 14.4672 16.0267 14.5118C15.9444 14.5565 15.8534 14.5792 15.7614 14.5783H2.19677C2.02452 14.5783 1.85227 14.5083 1.76614 14.3449C1.68002 14.2516 1.63696 14.1115 1.63696 13.7614V4.19191Z' + fill='black' + /> + <path + d='M1.93797 14.2505H16.0408C16.1054 14.2505 16.17 14.2038 16.2131 14.1571C16.2561 14.1104 16.2561 14.0404 16.2561 13.8304V4.19083C16.2561 3.91075 16.2346 3.72403 16.127 3.63067C16.0193 3.51397 15.9116 3.46729 15.6533 3.46729H2.36859C2.08868 3.46729 1.93797 3.53731 1.83031 3.65401C1.74419 3.74737 1.72266 3.88741 1.72266 4.19083V13.8304C1.72266 14.0404 1.72265 14.1104 1.76571 14.1571C1.80877 14.2038 1.87337 14.2505 1.93797 14.2505Z' + fill='#575757' + /> + <path + d='M8.99922 3.88789C9.00895 3.89489 9.02025 3.8989 9.03192 3.89949C9.04358 3.90008 9.05518 3.89723 9.06547 3.89124C9.07576 3.88525 9.08436 3.87635 9.09036 3.86549C9.09635 3.85463 9.09952 3.8422 9.09952 3.82954C9.09952 3.81688 9.09635 3.80446 9.09036 3.79359C9.08436 3.78273 9.07576 3.77383 9.06547 3.76785C9.05518 3.76186 9.04358 3.75901 9.03192 3.7596C9.02025 3.76019 9.00895 3.76419 8.99922 3.77119C8.98949 3.76419 8.97819 3.76019 8.96653 3.7596C8.95486 3.75901 8.94327 3.76186 8.93298 3.76785C8.92269 3.77383 8.91408 3.78273 8.90809 3.79359C8.90209 3.80446 8.89893 3.81688 8.89893 3.82954C8.89893 3.8422 8.90209 3.85463 8.90809 3.86549C8.91408 3.87635 8.92269 3.88525 8.93298 3.89124C8.94327 3.89723 8.95486 3.90008 8.96653 3.89949C8.97819 3.8989 8.98949 3.89489 8.99922 3.88789Z' + fill='black' + stroke='black' + strokeWidth={0.16875} + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M0 14.344V14.2273H18V14.344C18 14.344 17.5909 14.484 17.1388 14.5307C16.8373 14.5541 16.3421 14.6241 15.2225 14.6241H2.86363C1.89473 14.6241 1.07655 14.5541 0.710524 14.5074C0.344495 14.4607 0 14.344 0 14.344Z' + fill='#444444' + fillRule='evenodd' + clipRule='evenodd' + /> + <path + d='M2.21704 4.12207H15.7816V13.3181H2.21704V4.12207Z' + fill='#171717' + fillRule='evenodd' + clipRule='evenodd' + /> + </>, + '0 0 18 18', +); + const ArrowRightTop = glyph( <path d='M6.35014 5.40727L10.8285 5.17157M10.8285 5.17157L10.5928 9.64991M10.8285 5.17157L5.17163 10.8284' @@ -157,12 +312,23 @@ export const iconRegistry = { 'chevron-up-down': ChevronUpDown, check: Check, close: Close, + code: Code, + 'credit-card': CreditCard, ellipsis: Ellipsis, pen: Pen, plus: Plus, + search: Search, + 'shield-check': ShieldCheck, 'log-out': LogOut, cog: Cog, + 'device-laptop': DeviceLaptop, + 'device-phone': DevicePhone, + 'security-authenticator': SecurityLockSquare, + 'security-lock-square': SecurityLockSquare, + 'security-passkey': SecurityPasskey, + 'security-phone': SecurityPhone, users: Users, + 'user-circle': UserCircle, } satisfies Record<string, IconComponent>; export type IconName = keyof typeof iconRegistry; diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx new file mode 100644 index 00000000000..a7f73cfe61c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserPageViewProps } from '../user-page.view'; +import { UserPageView } from '../user-page.view'; + +const panels: UserPageViewProps['panels'] = { + account: { name: 'Preston Booth', username: 'prestonxyz' }, + security: { hasPassword: true }, + billing: { + subscription: { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }, + paymentMethods: [], + historyItems: [], + }, + apiKeys: { + apiKeys: [], + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + }, +}; + +function renderView(overrides: Partial<UserPageViewProps> = {}) { + const props: UserPageViewProps = { + activePanel: 'account', + panels, + onPanelChange: vi.fn(), + ...overrides, + }; + + return { + ...render( + <MosaicProvider> + <UserPageView {...props} /> + </MosaicProvider>, + ), + props, + }; +} + +describe('UserPageView', () => { + it('renders the active panel and all available destinations', () => { + renderView(); + + expect(screen.getByRole('navigation', { name: 'User profile' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Account' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Security' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Billing' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'API Keys' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); + expect(screen.getByText('Secured by')).toBeInTheDocument(); + }); + + it('forwards panel changes', async () => { + const onPanelChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPanelChange }); + + await user.click(screen.getByRole('button', { name: 'Security' })); + + expect(onPanelChange).toHaveBeenCalledWith('security'); + expect(screen.queryByRole('button', { name: 'Close user profile' })).not.toBeInTheDocument(); + }); + + it('only exposes supplied optional panels', () => { + renderView({ panels: { account: panels.account } }); + + expect(screen.queryByRole('button', { name: 'Security' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Billing' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'API Keys' })).not.toBeInTheDocument(); + }); + + it('falls back to Account when the requested panel is unavailable', () => { + renderView({ activePanel: 'billing', panels: { account: panels.account } }); + + expect(screen.getByRole('button', { name: 'Account' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); + }); + + it('can omit Clerk branding', () => { + renderView({ renderBranding: false }); + + expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx new file mode 100644 index 00000000000..255eb8ea7fa --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-api-keys-panel.view.test.tsx @@ -0,0 +1,104 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileApiKeysPanelView } from '../user-profile-api-keys-panel.view'; + +const apiKeys = [ + { + id: 'primary', + name: 'Primary API Key', + expirationLabel: 'Expires Dec 31, 2027', + createdAtLabel: 'Jan 05, 2026', + lastUsedAtLabel: 'Dec 31, 2026', + }, + { + id: 'legacy', + name: 'Legacy API Key', + expirationLabel: 'Expired Jul 1, 2025', + createdAtLabel: 'Jul 1, 2024', + lastUsedAtLabel: 'Jul 1, 2025', + isExpired: true, + }, +]; + +function renderView(overrides: Partial<React.ComponentProps<typeof UserProfileApiKeysPanelView>> = {}) { + const props = { + apiKeys, + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + ...overrides, + }; + + return { + ...render( + <MosaicProvider> + <UserProfileApiKeysPanelView {...props} /> + </MosaicProvider>, + ), + props, + }; +} + +describe('UserProfileApiKeysPanelView', () => { + it('renders search, key metadata, and expired state', () => { + renderView(); + + expect(screen.getByRole('heading', { level: 3, name: 'API Keys' })).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search API keys' })).toBeInTheDocument(); + expect(screen.getByText('Primary API Key')).toBeInTheDocument(); + expect(screen.getByText('Expired')).toBeInTheDocument(); + }); + + it('forwards search, selection, creation, and revoke actions', async () => { + const onCreate = vi.fn(); + const onRevoke = vi.fn(); + const onSearchChange = vi.fn(); + const onSelectionChange = vi.fn(); + const user = userEvent.setup(); + + renderView({ onCreate, onRevoke, onSearchChange, onSelectionChange }); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search API keys' }), { target: { value: 'primary' } }); + await user.click(screen.getByRole('button', { name: 'Create API key' })); + await user.click(screen.getByRole('checkbox', { name: 'Select Primary API Key' })); + await user.click(screen.getByRole('checkbox', { name: 'Select all API keys' })); + await user.click(screen.getByRole('button', { name: 'Manage Primary API Key' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke' })); + + expect(onSearchChange).toHaveBeenCalledWith('primary'); + expect(onCreate).toHaveBeenCalledOnce(); + expect(onSelectionChange).toHaveBeenNthCalledWith(1, ['primary']); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, ['primary', 'legacy']); + expect(onRevoke).toHaveBeenCalledWith('primary'); + }); + + it('forwards page and results-per-page changes', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + const user = userEvent.setup(); + + renderView({ + pagination: { page: 2, pageCount: 3, pageSize: 10, pageSizeOptions: [10, 25] }, + onPageChange, + onPageSizeChange, + }); + + await user.click(screen.getByRole('button', { name: 'Previous API keys page' })); + await user.click(screen.getByRole('button', { name: 'Next API keys page' })); + await user.selectOptions(screen.getByRole('combobox', { name: 'Results per page' }), '25'); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1); + expect(onPageChange).toHaveBeenNthCalledWith(2, 3); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + }); + + it('renders an empty state', () => { + renderView({ apiKeys: [] }); + + expect(screen.getByText('No API keys found')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx new file mode 100644 index 00000000000..75ef66de7fe --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-billing-panel.view.test.tsx @@ -0,0 +1,125 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileBillingPanelView } from '../user-profile-billing-panel.view'; + +const subscription = { + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', +}; + +const paymentMethods = [ + { + id: 'visa', + label: 'Visa •••• 0644', + expiryLabel: 'Expires 02/2029', + isDefault: true, + }, + { + id: 'mastercard', + label: 'Mastercard •••• 1212', + expiryLabel: 'Expires 02/2029', + }, +]; + +const historyItems = [ + { + id: 'stmt_202605_0644', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_...us64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, +]; + +function renderView(overrides: Partial<React.ComponentProps<typeof UserProfileBillingPanelView>> = {}) { + return render( + <MosaicProvider> + <UserProfileBillingPanelView + historyItems={historyItems} + paymentMethods={paymentMethods} + subscription={subscription} + {...overrides} + /> + </MosaicProvider>, + ); +} + +describe('UserProfileBillingPanelView', () => { + it('composes subscription, payment methods, and billing history', () => { + renderView(); + + expect(screen.getByRole('heading', { level: 3, name: 'Billing' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Subscription' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Payment methods' })).toBeInTheDocument(); + expect(screen.getByText('Basic Plan')).toBeInTheDocument(); + expect(screen.getByText('$12.00')).toBeInTheDocument(); + expect(screen.getByText('Visa •••• 0644')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'History' })).toBeInTheDocument(); + expect(screen.getByText('May 26, 2026')).toBeInTheDocument(); + expect(screen.getByText('Paid')).toBeInTheDocument(); + }); + + it('forwards subscription and payment method actions', async () => { + const onChangePlan = vi.fn(); + const onAdd = vi.fn(); + const onMakeDefault = vi.fn(); + const onRemove = vi.fn(); + const onViewInvoice = vi.fn(); + const user = userEvent.setup(); + + renderView({ + onChangePlan, + onAddPaymentMethod: onAdd, + onMakeDefaultPaymentMethod: onMakeDefault, + onRemovePaymentMethod: onRemove, + onViewInvoice, + }); + + await user.click(screen.getByRole('button', { name: 'Change plan' })); + await user.click(screen.getByRole('button', { name: 'Add payment method' })); + await user.click(screen.getByRole('button', { name: 'Manage Mastercard •••• 1212' })); + await user.click(screen.getByRole('menuitem', { name: 'Make default' })); + await user.click(screen.getByRole('button', { name: 'Manage Mastercard •••• 1212' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove payment method' })); + await user.click(screen.getByRole('button', { name: 'View' })); + + expect(onChangePlan).toHaveBeenCalledOnce(); + expect(onAdd).toHaveBeenCalledOnce(); + expect(onMakeDefault).toHaveBeenCalledWith('mastercard'); + expect(onRemove).toHaveBeenCalledWith('mastercard'); + expect(onViewInvoice).toHaveBeenCalledWith('stmt_202605_0644'); + }); + + it('keeps an empty payment method list actionable', () => { + renderView({ paymentMethods: [], onAddPaymentMethod: vi.fn() }); + + expect(screen.getByText('No payment methods added')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add payment method' })).toBeInTheDocument(); + }); + + it('forwards billing history pagination', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + const user = userEvent.setup(); + + renderView({ + historyPagination: { page: 2, pageCount: 3, pageSize: 10, pageSizeOptions: [10, 25] }, + onBillingHistoryPageChange: onPageChange, + onBillingHistoryPageSizeChange: onPageSizeChange, + }); + + await user.click(screen.getByRole('button', { name: 'Previous invoice page' })); + await user.click(screen.getByRole('button', { name: 'Next invoice page' })); + await user.selectOptions(screen.getByRole('combobox', { name: 'Results per page' }), '25'); + + expect(onPageChange).toHaveBeenNthCalledWith(1, 1); + expect(onPageChange).toHaveBeenNthCalledWith(2, 3); + expect(onPageSizeChange).toHaveBeenCalledWith(25); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index e8f831c4fd2..33a98752cd1 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -31,8 +31,7 @@ describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { renderView({ onEditProfilePicture: vi.fn(), onNameChange: vi.fn(), onUsernameChange: vi.fn() }); - expect(screen.queryByRole('heading', { name: 'Account' })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Account' })).toContainElement( document.querySelector('.cl-section-group'), ); @@ -44,14 +43,14 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('button', { name: 'Edit username' })).toBeInTheDocument(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); - expect(within(screen.getByRole('region', { name: 'Email' })).getByText('Primary')).toBeInTheDocument(); + expect(screen.getByText('item1@clerk.dev').closest('.cl-section-item')).toHaveTextContent('Primary'); expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); expect(screen.getByText('Profile picture')).toHaveClass('cl-section-label'); expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toHaveClass('cl-section-description'); expect(screen.getByText('Email')).toHaveClass('cl-section-label'); expect(screen.getByText('Phone')).toHaveClass('cl-section-label'); expect(screen.getByText('item1@clerk.dev').closest('.cl-section-description')).not.toBeNull(); - expect(screen.getByRole('button', { name: 'Edit profile picture' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); const profilePicture = screen.getByText('Profile picture').closest('.cl-section-item'); expect(profilePicture?.querySelector('.cl-section-media')).toHaveAttribute('data-size', 'lg'); expect(profilePicture?.querySelector('.cl-avatar')).toHaveAttribute('data-size', 'fit'); @@ -59,16 +58,78 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); }); - it('edits the profile picture when the avatar is clicked', async () => { + it('edits the profile picture when Upload is clicked', async () => { const onEditProfilePicture = vi.fn(); const user = userEvent.setup(); renderView({ onEditProfilePicture }); - await user.click(screen.getByRole('button', { name: 'Edit profile picture' })); + await user.click(screen.getByRole('button', { name: 'Upload' })); expect(onEditProfilePicture).toHaveBeenCalledOnce(); }); + it('breaks out both contact types when either has multiple entries', () => { + renderView({ onAddEmail: vi.fn(), onAddPhone: vi.fn() }); + + const accountSection = screen.getByRole('region', { name: 'Account' }); + const emailSection = screen.getByRole('region', { name: 'Email' }); + const phoneSection = screen.getByRole('region', { name: 'Phone' }); + + expect(accountSection).not.toContainElement(emailSection); + expect(accountSection).not.toContainElement(phoneSection); + expect(emailSection).toHaveTextContent('item1@clerk.dev'); + expect(emailSection).toHaveTextContent('item2@clerk.dev'); + expect(phoneSection).toHaveTextContent('+1 801-888-8181'); + expect(within(emailSection).getByRole('button', { name: 'Add email' })).toHaveTextContent('Add'); + expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toHaveTextContent('Add'); + }); + + it('keeps both contact types inside Account when neither has multiple entries', () => { + renderView({ + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], + onManageEmail: vi.fn(), + onManagePhone: vi.fn(), + }); + + const accountSection = screen.getByRole('region', { name: 'Account' }); + + expect(accountSection).toHaveTextContent('item1@clerk.dev'); + expect(accountSection).toHaveTextContent('+1 801-888-8181'); + expect(within(accountSection).getByRole('button', { name: 'Update email' })).toBeInTheDocument(); + expect(within(accountSection).getByRole('button', { name: 'Update phone number' })).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Phone' })).not.toBeInTheDocument(); + }); + + it('forwards inline contact update and add actions', async () => { + const onAddEmail = vi.fn(); + const onManagePhone = vi.fn(); + const user = userEvent.setup(); + renderView({ + emails: [], + onAddEmail, + onManagePhone, + }); + + expect(screen.getByText('No email addresses added')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Add email' })); + await user.click(screen.getByRole('button', { name: 'Update phone number' })); + + expect(onAddEmail).toHaveBeenCalledOnce(); + expect(onManagePhone).toHaveBeenCalledWith('phone_1'); + }); + + it('renders an actionable empty state when no phone number exists', () => { + renderView({ phones: [], onAddPhone: vi.fn() }); + + const phoneSection = screen.getByRole('region', { name: 'Phone' }); + const emptyState = within(phoneSection).getByText('No phone numbers added'); + + expect(emptyState.closest('.cl-section-items')).not.toBeNull(); + expect(emptyState.closest('.cl-section-item')).not.toContainElement(within(phoneSection).getByText('Phone')); + expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument(); + }); + it('renders connected accounts and the danger zone when provided', async () => { const onConnectAccount = vi.fn(); const onManageConnectedAccount = vi.fn(); @@ -76,7 +137,7 @@ describe('UserProfileProfilePanelView', () => { const user = userEvent.setup(); renderView({ connectedAccounts: [ - { id: 'google', provider: 'Google', identifier: 'test@google.com' }, + { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' }, { id: 'apple', provider: 'Apple', connected: false }, ], onConnectAccount, @@ -85,9 +146,12 @@ describe('UserProfileProfilePanelView', () => { }); expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); + expect( + screen.getByRole('region', { name: 'Connected accounts' }).querySelector('.cl-section-media[data-size="lg"] img'), + ).toHaveAttribute('src', 'https://example.com/google.svg'); expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument(); - expect(screen.getByText('Permanently delete this profile and all its data. This cannot be undone.')).toHaveClass( + expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass( 'cl-section-description', ); await user.click(screen.getByRole('button', { name: 'Manage Google' })); @@ -112,6 +176,7 @@ describe('UserProfileProfilePanelView', () => { id: 'primary', address: '0x1234567890abcdef1234567890abcdef12345678', provider: 'MetaMask', + iconUrl: 'https://example.com/metamask.svg', isPrimary: true, isVerified: true, }, @@ -134,6 +199,9 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument(); expect(screen.getByText('MetaMask')).toBeInTheDocument(); + expect( + screen.getByRole('region', { name: 'Web3 wallets' }).querySelector('.cl-section-media[data-size="lg"] img'), + ).toHaveAttribute('src', 'https://example.com/metamask.svg'); expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); expect(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByText('Primary')).toBeInTheDocument(); @@ -185,7 +253,7 @@ describe('UserProfileProfilePanelView', () => { const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Edit name' })); - await user.click(within(screen.getByRole('region', { name: 'Email' })).getByRole('button', { name: 'Add email' })); + await user.click(screen.getByRole('button', { name: 'Add email' })); await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); expect(onManageEmail).not.toHaveBeenCalled(); await user.click(screen.getByRole('menuitem', { name: 'Manage' })); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx new file mode 100644 index 00000000000..5892b8c8cd8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -0,0 +1,190 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; +import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; + +const props: UserProfileSecurityPanelViewProps = { + hasPassword: true, + passkeys: [ + { + id: 'passkey_1', + name: 'Passkey', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ], + mfaMethods: [ + { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, + { id: 'totp_1', type: 'authenticator' }, + { id: 'backup_1', type: 'backup-codes' }, + ], + devices: [ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, + ], +}; + +function renderView(overrides: Partial<UserProfileSecurityPanelViewProps> = {}) { + return render( + <MosaicProvider> + <UserProfileSecurityPanelView + {...props} + {...overrides} + /> + </MosaicProvider>, + ); +} + +describe('UserProfileSecurityPanelView', () => { + it('composes authentication, active devices, and the danger zone', () => { + renderView({ onDeleteAccount: vi.fn() }); + + expect(screen.getByRole('heading', { level: 3, name: 'Security' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Authentication' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Active devices' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Password')).toHaveClass('cl-section-label'); + expect(screen.getByText('Passkeys')).toHaveClass('cl-section-label'); + expect(screen.getByText('2-step verification')).toHaveClass('cl-section-label'); + expect(screen.getByRole('region', { name: 'Passkeys' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: '2-step verification' })).toBeInTheDocument(); + expect(screen.getByText('This device')).toBeInTheDocument(); + expect(screen.getByText('2 other devices')).toBeInTheDocument(); + expect( + screen.getByText('Permanently delete this account and all its data. This cannot be undone.'), + ).toBeInTheDocument(); + }); + + it('forwards security actions', async () => { + const onChangePassword = vi.fn(); + const onAddPasskey = vi.fn(); + const onManagePasskey = vi.fn(); + const onRemovePasskey = vi.fn(); + const onAddMfaMethod = vi.fn(); + const onSignOutDevice = vi.fn(); + const onSignOutAllOtherDevices = vi.fn(); + const onDeleteAccount = vi.fn(); + const user = userEvent.setup(); + + renderView({ + mfaMethods: [ + { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup_1', type: 'backup-codes' }, + ], + onChangePassword, + onAddPasskey, + onManagePasskey, + onRemovePasskey, + onAddMfaMethod, + onSignOutDevice, + onSignOutAllOtherDevices, + onDeleteAccount, + }); + + await user.click(screen.getByRole('button', { name: 'Change password' })); + await user.click(screen.getByRole('button', { name: 'Add passkey' })); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + expect(screen.queryByRole('menuitem', { name: 'SMS verification' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Authenticator app' })); + await user.click(screen.getByRole('button', { name: 'Sign out of all devices' })); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + + await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); + await user.click(screen.getByRole('menuitem', { name: 'Rename' })); + await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); + + const otherDevices = screen.getByRole('region', { name: 'Other devices' }); + await user.click(within(otherDevices).getByRole('button', { name: 'Manage Safari on iOS' })); + await user.click(screen.getByRole('menuitem', { name: 'Sign out' })); + + expect(onChangePassword).toHaveBeenCalledOnce(); + expect(onAddPasskey).toHaveBeenCalledOnce(); + expect(onManagePasskey).toHaveBeenCalledWith('passkey_1'); + expect(onRemovePasskey).toHaveBeenCalledWith('passkey_1'); + expect(onAddMfaMethod).toHaveBeenCalledWith('authenticator'); + expect(onSignOutDevice).toHaveBeenCalledWith('mobile'); + expect(onSignOutAllOtherDevices).toHaveBeenCalledOnce(); + expect(onDeleteAccount).toHaveBeenCalledOnce(); + }); + + it('keeps supported empty authentication methods actionable', () => { + renderView({ + hasPassword: false, + passkeys: [], + mfaMethods: [], + devices: [], + onAddPasskey: vi.fn(), + onAddMfaMethod: vi.fn(), + }); + + expect(screen.getByText('No passkeys added')).toBeInTheDocument(); + expect(screen.getByText('No verification methods added')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add passkey' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add verification method' })).toBeInTheDocument(); + expect(screen.getByText('No current device available')).toBeInTheDocument(); + expect(screen.queryByText('Password')).not.toBeInTheDocument(); + }); + + it('does not render actions for the current device', () => { + renderView({ + onManageDevice: vi.fn(), + onSignOutDevice: vi.fn(), + }); + + expect(screen.queryByRole('button', { name: 'Manage Safari on macOS' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage Safari on iOS' })).toBeInTheDocument(); + }); + + it('only shows backup codes with another verification method and only allows regeneration', async () => { + const onRegenerateBackupCodes = vi.fn(); + const onRemoveMfaMethod = vi.fn(); + const backupCodes = { id: 'backup_1', type: 'backup-codes' as const }; + const backupOnlyView = renderView({ + mfaMethods: [backupCodes], + onRegenerateBackupCodes, + onRemoveMfaMethod, + }); + + expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + backupOnlyView.unmount(); + + const user = userEvent.setup(); + renderView({ + mfaMethods: [{ id: 'sms_1', type: 'sms' }, backupCodes], + onRegenerateBackupCodes, + onRemoveMfaMethod, + }); + + expect(screen.getByText('Backup codes')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); + expect(screen.queryByRole('menuitem', { name: 'Manage' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Remove method' })); + await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); + expect(screen.queryByRole('menuitem', { name: 'Remove method' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); + + expect(onRemoveMfaMethod).toHaveBeenCalledWith('sms_1'); + expect(onRegenerateBackupCodes).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-page.view.tsx b/packages/ui/src/mosaic/user-profile/user-page.view.tsx new file mode 100644 index 00000000000..907eaf111c4 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-page.view.tsx @@ -0,0 +1,92 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './user-profile.styles'; +import type { UserProfileApiKeysPanelViewProps } from './user-profile-api-keys-panel.view'; +import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view'; +import type { UserProfileBillingPanelViewProps } from './user-profile-billing-panel.view'; +import { UserProfileBillingPanelView } from './user-profile-billing-panel.view'; +import type { UserProfileProfilePanelViewProps } from './user-profile-profile-panel.view'; +import { UserProfileProfilePanelView } from './user-profile-profile-panel.view'; +import type { UserProfileSecurityPanelViewProps } from './user-profile-security-panel.view'; +import { UserProfileSecurityPanelView } from './user-profile-security-panel.view'; +import type { UserProfilePanelId } from './user-profile-sidebar'; +import { UserProfileSidebar } from './user-profile-sidebar'; + +export interface UserPagePanels { + account: UserProfileProfilePanelViewProps; + security?: UserProfileSecurityPanelViewProps; + billing?: UserProfileBillingPanelViewProps; + apiKeys?: UserProfileApiKeysPanelViewProps; +} + +export interface UserPageViewProps { + activePanel: UserProfilePanelId; + panels: UserPagePanels; + onPanelChange: (panel: UserProfilePanelId) => void; + renderBranding?: boolean; +} + +function getAvailablePanels(panels: UserPagePanels): UserProfilePanelId[] { + return [ + 'account', + ...(panels.security ? (['security'] as const) : []), + ...(panels.billing ? (['billing'] as const) : []), + ...(panels.apiKeys ? (['api-keys'] as const) : []), + ]; +} + +function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPagePanels }): ReactElement { + switch (panel) { + case 'security': + return panels.security ? ( + <UserProfileSecurityPanelView {...panels.security} /> + ) : ( + <UserProfileProfilePanelView {...panels.account} /> + ); + case 'billing': + return panels.billing ? ( + <UserProfileBillingPanelView {...panels.billing} /> + ) : ( + <UserProfileProfilePanelView {...panels.account} /> + ); + case 'api-keys': + return panels.apiKeys ? ( + <UserProfileApiKeysPanelView {...panels.apiKeys} /> + ) : ( + <UserProfileProfilePanelView {...panels.account} /> + ); + case 'account': + return <UserProfileProfilePanelView {...panels.account} />; + } +} + +export function UserPageView({ + activePanel, + panels, + onPanelChange, + renderBranding = true, +}: UserPageViewProps): ReactElement { + const availablePanels = getAvailablePanels(panels); + const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'account'; + + return ( + <div {...mergeStyleProps(themeProps('user-page'), stylex.props(styles.root))}> + <UserProfileSidebar + activePanel={resolvedPanel} + panels={availablePanels} + renderBranding={renderBranding} + onPanelChange={onPanelChange} + /> + <main {...stylex.props(styles.main)}> + <div {...stylex.props(styles.content)}> + <Panel + panel={resolvedPanel} + panels={panels} + /> + </div> + </main> + </div> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 34243e4e9fc..25202fce18d 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -74,83 +74,103 @@ export function UserProfileAccountSectionView({ .toUpperCase(); const updateName = onNameChange ? () => onNameChange(name) : undefined; const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined; + const shouldBreakOutContacts = emails.length > 1 || phones.length > 1; return ( - <Section.Root aria-label='Account'> - <Section.Group> - <Section.Row> - <Section.Item> - <Section.Media size='lg'> - <Avatar.Root - size='fit' - render={ - onEditProfilePicture ? ( - <button - type='button' - aria-label='Edit profile picture' - onClick={onEditProfilePicture} - /> - ) : undefined - } - > - <Avatar.Image - alt={name} - src={imageUrl} - /> - <Avatar.Fallback>{initials}</Avatar.Fallback> - {onEditProfilePicture ? ( - <Avatar.Icon> - <Icon name='pen' /> - </Avatar.Icon> - ) : null} - </Avatar.Root> - </Section.Media> - <Section.Content> - <Section.Label>Profile picture</Section.Label> - <Section.Description>Recommend size 1:1, up to 10MB.</Section.Description> - </Section.Content> - </Section.Item> - </Section.Row> - <Section.Row> - <Section.Item> - <Section.Content> - <Section.Label>Name</Section.Label> - <Section.Description>{name}</Section.Description> - </Section.Content> - {updateName ? ( - <Section.Actions> - <Button - color='neutral' - size='sm' - variant='outline' - onClick={updateName} - > - Edit name - </Button> - </Section.Actions> - ) : null} - </Section.Item> - </Section.Row> - <Section.Row> - <Section.Item> - <Section.Content> - <Section.Label>Username</Section.Label> - <Section.Description>{username}</Section.Description> - </Section.Content> - {updateUsername ? ( - <Section.Actions> - <Button - color='neutral' - size='sm' - variant='outline' - onClick={updateUsername} - > - Edit username - </Button> - </Section.Actions> - ) : null} - </Section.Item> - </Section.Row> + <div {...stylex.props(styles.sections)}> + <Section.Root aria-label='Account'> + <Section.Title>Profile</Section.Title> + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Media size='lg'> + <Avatar.Root size='fit'> + <Avatar.Image + alt={name} + src={imageUrl} + /> + <Avatar.Fallback>{initials}</Avatar.Fallback> + </Avatar.Root> + </Section.Media> + <Section.Content> + <Section.Label>Profile picture</Section.Label> + <Section.Description>Recommend size 1:1, up to 10MB.</Section.Description> + </Section.Content> + {onEditProfilePicture ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onEditProfilePicture} + > + Upload + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Name</Section.Label> + <Section.Description>{name}</Section.Description> + </Section.Content> + {updateName ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={updateName} + > + Edit name + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Username</Section.Label> + <Section.Description>{username}</Section.Description> + </Section.Content> + {updateUsername ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={updateUsername} + > + Edit username + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + {!shouldBreakOutContacts ? ( + <SingleContactRow + items={emails} + kind='email' + label='Email' + onAdd={onAddEmail} + onManage={onManageEmail} + /> + ) : null} + {!shouldBreakOutContacts ? ( + <SingleContactRow + items={phones} + kind='phone' + label='Phone' + onAdd={onAddPhone} + onManage={onManagePhone} + /> + ) : null} + </Section.Group> + </Section.Root> + {shouldBreakOutContacts ? ( <ContactSection items={emails} kind='email' @@ -161,6 +181,8 @@ export function UserProfileAccountSectionView({ onSetPrimary={onSetPrimaryEmail} onVerify={onVerifyEmail} /> + ) : null} + {shouldBreakOutContacts ? ( <ContactSection items={phones} kind='phone' @@ -171,21 +193,12 @@ export function UserProfileAccountSectionView({ onSetPrimary={onSetPrimaryPhone} onVerify={onVerifyPhone} /> - </Section.Group> - </Section.Root> + ) : null} + </div> ); } -function ContactSection({ - kind, - label, - items, - onAdd, - onManage, - onVerify, - onSetPrimary, - onRemove, -}: { +interface ContactSectionProps { kind: 'email' | 'phone'; label: string; items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; @@ -194,80 +207,142 @@ function ContactSection({ onVerify?: (id: string) => void; onSetPrimary?: (id: string) => void; onRemove?: (id: string) => void; -}) { - const labelId = `user-profile-profile-panel-${label.toLowerCase()}`; +} +function ContactSection(props: ContactSectionProps) { return ( - <Section.Row - render={props => ( - <section - {...props} - aria-labelledby={labelId} - /> - )} - > + <Section.Root aria-label={props.label}> + <Section.Group> + <ContactRow {...props} /> + </Section.Group> + </Section.Root> + ); +} + +function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { + const item = items[0]; + const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; + const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; + const actionLabel = item + ? kind === 'email' + ? 'Update email' + : 'Update phone number' + : kind === 'email' + ? 'Add email' + : 'Add phone number'; + + return ( + <Section.Row> <Section.Item> <Section.Content> - <Section.Label id={labelId}>{label}</Section.Label> + <Section.Label>{label}</Section.Label> + {item ? ( + <Section.Description {...stylex.props(styles.contactValue)}> + <span>{item.value}</span> + {item.isDefault ? <Badge color='neutral'>Primary</Badge> : null} + </Section.Description> + ) : ( + <Section.Description>{emptyDescription}</Section.Description> + )} + </Section.Content> + {onClick ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onClick} + > + {actionLabel} + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + ); +} + +function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimary, onRemove }: ContactSectionProps) { + const emptyDescription = kind === 'email' ? 'No email addresses added' : 'No phone numbers added'; + + return ( + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>{label}</Section.Label> </Section.Content> {onAdd ? ( <Section.Actions> <Button + aria-label={kind === 'email' ? 'Add email' : 'Add phone number'} color='neutral' size='sm' variant='outline' onClick={onAdd} > - {kind === 'email' ? 'Add email' : 'Add phone number'} + <Icon + name='plus' + placement='inline-start' + size='sm' + /> + Add </Button> </Section.Actions> ) : null} </Section.Item> <Section.Items> - {items.map(item => { - const actions: UserProfileMenuAction[] = []; - const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + {items.length === 0 ? ( + <Section.Item> + <Section.Content> + <Section.Description>{emptyDescription}</Section.Description> + </Section.Content> + </Section.Item> + ) : ( + items.map(item => { + const actions: UserProfileMenuAction[] = []; + const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); - if (item.isVerified === false && onVerify) { - actions.push({ - label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', - onClick: () => onVerify(item.id), - }); - } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { - actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); - } + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? 'Complete verification' : kind === 'email' ? 'Verify' : 'Verify phone number', + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: 'Set as primary', onClick: () => onSetPrimary(item.id) }); + } - if (onRemove && item.canRemove !== false) { - actions.push({ - label: kind === 'email' ? 'Remove email' : 'Remove phone number', - color: 'negative', - onClick: () => onRemove(item.id), - }); - } + if (onRemove && item.canRemove !== false) { + actions.push({ + label: kind === 'email' ? 'Remove email' : 'Remove phone number', + color: 'negative', + onClick: () => onRemove(item.id), + }); + } - if (!hasExplicitActions && onManage) { - actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); - } + if (!hasExplicitActions && onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(item.id) }); + } - return ( - <Section.Item key={item.id}> - <Section.Content> - <Section.Description {...stylex.props(styles.contactValue)}> - <span>{item.value}</span> - {item.isDefault ? <Badge color='neutral'>Primary</Badge> : null} - </Section.Description> - </Section.Content> - {actions.length > 0 ? ( - <Section.Actions> - <UserProfileActionMenu - actions={actions} - label={`Manage ${item.value}`} - /> - </Section.Actions> - ) : null} - </Section.Item> - ); - })} + return ( + <Section.Item key={item.id}> + <Section.Content> + <Section.Description {...stylex.props(styles.contactValue)}> + <span>{item.value}</span> + {item.isDefault ? <Badge color='neutral'>Primary</Badge> : null} + </Section.Description> + </Section.Content> + {actions.length > 0 ? ( + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${item.value}`} + /> + </Section.Actions> + ) : null} + </Section.Item> + ); + }) + )} </Section.Items> </Section.Row> ); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx new file mode 100644 index 00000000000..176bcaa3ca8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-active-devices-section.view.tsx @@ -0,0 +1,136 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../components/button'; +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileSecurityIcon } from './user-profile-security-icon'; +import { styles } from './user-profile-security-panel.styles'; + +export interface UserProfileDevice { + id: string; + name: string; + description?: string; + type: 'desktop' | 'mobile'; + isCurrent?: boolean; +} + +export interface UserProfileActiveDevicesSectionViewProps { + devices: UserProfileDevice[]; + onManageDevice?: (id: string) => void; + onSignOutDevice?: (id: string) => void; + onSignOutAllOtherDevices?: () => void; +} + +export function UserProfileActiveDevicesSectionView({ + devices, + onManageDevice, + onSignOutDevice, + onSignOutAllOtherDevices, +}: UserProfileActiveDevicesSectionViewProps) { + const currentDevices = devices.filter(device => device.isCurrent); + const otherDevices = devices.filter(device => !device.isCurrent); + + return ( + <div {...stylex.props(styles.sectionCards)}> + <Section.Root> + <Section.Title>Active devices</Section.Title> + <Section.Group> + {currentDevices.length > 0 ? ( + currentDevices.map(device => ( + <Section.Row key={device.id}> + <DeviceItem device={device} /> + </Section.Row> + )) + ) : ( + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Description>No current device available</Section.Description> + </Section.Content> + </Section.Item> + </Section.Row> + )} + </Section.Group> + </Section.Root> + {otherDevices.length > 0 ? ( + <Section.Root aria-label='Other devices'> + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label> + {otherDevices.length} other {otherDevices.length === 1 ? 'device' : 'devices'} + </Section.Label> + </Section.Content> + {onSignOutAllOtherDevices ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onSignOutAllOtherDevices} + > + Sign out of all devices + </Button> + </Section.Actions> + ) : null} + </Section.Item> + <Section.Items> + {otherDevices.map(device => ( + <DeviceItem + key={device.id} + device={device} + onManage={onManageDevice} + onSignOut={onSignOutDevice} + /> + ))} + </Section.Items> + </Section.Row> + </Section.Group> + </Section.Root> + ) : null} + </div> + ); +} + +function DeviceItem({ + device, + onManage, + onSignOut, +}: { + device: UserProfileDevice; + onManage?: (id: string) => void; + onSignOut?: (id: string) => void; +}) { + const actions: UserProfileMenuAction[] = []; + + if (onManage) { + actions.push({ label: 'Manage', onClick: () => onManage(device.id) }); + } + if (onSignOut) { + actions.push({ label: 'Sign out', color: 'negative', onClick: () => onSignOut(device.id) }); + } + + return ( + <Section.Item> + <UserProfileSecurityIcon name={device.type} /> + <Section.Content> + <Section.Label>{device.name}</Section.Label> + {device.isCurrent || device.description ? ( + <Section.Description {...stylex.props(styles.descriptionLine)}> + {device.isCurrent ? <span {...stylex.props(styles.currentDevice)}>This device</span> : null} + {device.isCurrent && device.description ? <span>·</span> : null} + {device.description ? <span>{device.description}</span> : null} + </Section.Description> + ) : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${device.name}`} + /> + </Section.Actions> + </Section.Item> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.styles.ts new file mode 100644 index 00000000000..1aa2b4aebd3 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.styles.ts @@ -0,0 +1,150 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + actionCell: { + textAlign: 'end', + width: space['12'], + }, + cell: { + paddingBlock: space['3'], + paddingInline: space['4'], + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + verticalAlign: 'middle', + }, + checkbox: { + accentColor: colorVars['--cl-color-primary'], + cursor: 'pointer', + height: space['4'], + width: space['4'], + }, + checkboxCell: { + paddingInlineEnd: space['1'], + paddingInlineStart: space['4'], + textAlign: 'center', + width: space['8'], + }, + emptyCell: { + paddingBlock: space['8'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textAlign: 'center', + }, + header: { + backgroundColor: colorVars['--cl-color-border-faded'], + }, + headerCell: { + paddingBlock: space['2.5'], + paddingInline: space['4'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + textAlign: 'start', + }, + keyDescription: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + marginBlockStart: space['0.5'], + }, + keyName: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + nameColumn: { + width: '38%', + }, + pageSizeLabel: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-neutral-faded'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pageSizeSelect: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['1'], + paddingInline: space['2'], + backgroundColor: colorVars['--cl-color-card'], + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pagination: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, + paginationControls: { + gap: space['1'], + alignItems: 'center', + display: 'flex', + }, + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + row: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + search: { + paddingInlineStart: space['8'], + }, + searchIcon: { + color: colorVars['--cl-color-neutral-faded'], + insetInlineStart: space['3'], + pointerEvents: 'none', + position: 'absolute', + transform: 'translateY(-50%)', + top: '50%', + }, + searchWrapper: { + position: 'relative', + width: '17rem', + }, + table: { + borderCollapse: 'collapse', + tableLayout: 'fixed', + width: '100%', + }, + tableScroller: { + overflowX: 'auto', + width: '100%', + }, + tableShell: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + width: '100%', + }, + toolbar: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx new file mode 100644 index 00000000000..57c45c2c9e2 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-api-keys-panel.view.tsx @@ -0,0 +1,257 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Heading } from '../components/heading'; +import { Icon } from '../components/icon'; +import { Input } from '../components/input'; +import { Menu } from '../components/menu'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './user-profile-api-keys-panel.styles'; + +export interface UserProfileAPIKey { + id: string; + name: string; + expirationLabel: string; + createdAtLabel: string; + lastUsedAtLabel: string; + isExpired?: boolean; +} + +export interface UserProfileAPIKeysPagination { + page: number; + pageCount: number; + pageSize: number; + pageSizeOptions?: readonly number[]; +} + +export interface UserProfileApiKeysPanelViewProps { + apiKeys: UserProfileAPIKey[]; + pagination?: UserProfileAPIKeysPagination; + searchValue: string; + selectedIds: readonly string[]; + onCreate?: () => void; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onRevoke?: (id: string) => void; + onSearchChange: (value: string) => void; + onSelectionChange: (ids: string[]) => void; +} + +export function UserProfileApiKeysPanelView({ + apiKeys, + pagination, + searchValue, + selectedIds, + onCreate, + onPageChange, + onPageSizeChange, + onRevoke, + onSearchChange, + onSelectionChange, +}: UserProfileApiKeysPanelViewProps): ReactElement { + const allSelected = apiKeys.length > 0 && apiKeys.every(apiKey => selectedIds.includes(apiKey.id)); + + const toggleAll = () => { + onSelectionChange(allSelected ? [] : apiKeys.map(apiKey => apiKey.id)); + }; + + const toggleOne = (id: string) => { + onSelectionChange( + selectedIds.includes(id) ? selectedIds.filter(selectedId => selectedId !== id) : [...selectedIds, id], + ); + }; + + return ( + <div {...mergeStyleProps(themeProps('user-profile-api-keys-panel'), stylex.props(styles.root))}> + <Heading + render={props => <h3 {...props} />} + size='2xl' + > + API Keys + </Heading> + <div {...stylex.props(styles.toolbar)}> + <div {...stylex.props(styles.searchWrapper)}> + <Icon + aria-hidden + name='search' + size='sm' + {...stylex.props(styles.searchIcon)} + /> + <Input + aria-label='Search API keys' + autoComplete='off' + placeholder='Search' + size='sm' + type='search' + value={searchValue} + {...stylex.props(styles.search)} + onChange={event => onSearchChange(event.currentTarget.value)} + /> + </div> + {onCreate ? <Button onClick={onCreate}>Create API key</Button> : null} + </div> + {/* TODO: Replace this inline implementation with the Mosaic Table component. */} + <div {...stylex.props(styles.tableShell)}> + <div {...stylex.props(styles.tableScroller)}> + <table {...stylex.props(styles.table)}> + <thead {...stylex.props(styles.header)}> + <tr> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.checkboxCell)} + > + {/* TODO: Replace these inline selection controls with the Mosaic Checkbox component. */} + <input + aria-label='Select all API keys' + checked={allSelected} + type='checkbox' + {...stylex.props(styles.checkbox)} + onChange={toggleAll} + /> + </th> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.nameColumn)} + > + Name + </th> + <th + scope='col' + {...stylex.props(styles.headerCell)} + > + Created + </th> + <th + scope='col' + {...stylex.props(styles.headerCell)} + > + Last used + </th> + <th + aria-label='Actions' + scope='col' + {...stylex.props(styles.headerCell, styles.actionCell)} + /> + </tr> + </thead> + <tbody> + {apiKeys.length > 0 ? ( + apiKeys.map(apiKey => ( + <tr + key={apiKey.id} + {...stylex.props(styles.row)} + > + <td {...stylex.props(styles.cell, styles.checkboxCell)}> + <input + aria-label={`Select ${apiKey.name}`} + checked={selectedIds.includes(apiKey.id)} + type='checkbox' + {...stylex.props(styles.checkbox)} + onChange={() => toggleOne(apiKey.id)} + /> + </td> + <td {...stylex.props(styles.cell)}> + <div {...stylex.props(styles.keyName)}> + <span>{apiKey.name}</span> + {apiKey.isExpired ? <Badge color='warning'>Expired</Badge> : null} + </div> + <div {...stylex.props(styles.keyDescription)}>{apiKey.expirationLabel}</div> + </td> + <td {...stylex.props(styles.cell)}>{apiKey.createdAtLabel}</td> + <td {...stylex.props(styles.cell)}>{apiKey.lastUsedAtLabel}</td> + <td {...stylex.props(styles.cell, styles.actionCell)}> + {onRevoke ? ( + <Menu.Root placement='bottom-end'> + <Menu.Trigger aria-label={`Manage ${apiKey.name}`} /> + <Menu.Content> + <Menu.Item + color='negative' + label='Revoke' + onClick={() => onRevoke(apiKey.id)} + /> + </Menu.Content> + </Menu.Root> + ) : null} + </td> + </tr> + )) + ) : ( + <tr {...stylex.props(styles.row)}> + <td + colSpan={5} + {...stylex.props(styles.emptyCell)} + > + No API keys found + </td> + </tr> + )} + </tbody> + </table> + </div> + </div> + {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. + <div {...stylex.props(styles.pagination)}> + <div {...stylex.props(styles.paginationControls)}> + <Button + aria-label='Previous API keys page' + color='neutral' + disabled={pagination.page <= 1} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page - 1)} + > + <Icon name='chevron-left' /> + </Button> + <Button + aria-current='page' + aria-label={`API keys page ${pagination.page}`} + color='neutral' + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + > + {pagination.page} + </Button> + <Button + aria-label='Next API keys page' + color='neutral' + disabled={pagination.page >= pagination.pageCount} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page + 1)} + > + <Icon name='chevron-right' /> + </Button> + </div> + <label {...stylex.props(styles.pageSizeLabel)}> + <span>Results per page</span> + {/* TODO: Replace this inline implementation with the Mosaic Select component. */} + <select + aria-label='Results per page' + value={pagination.pageSize} + {...stylex.props(styles.pageSizeSelect)} + onChange={event => onPageSizeChange?.(Number(event.currentTarget.value))} + > + {(pagination.pageSizeOptions ?? [10, 25, 50]).map(pageSize => ( + <option + key={pageSize} + value={pageSize} + > + {pageSize} + </option> + ))} + </select> + </label> + </div> + ) : null} + </div> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.styles.ts new file mode 100644 index 00000000000..ed30ce9b878 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.styles.ts @@ -0,0 +1,121 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + actionCell: { + textAlign: 'end', + }, + amountCell: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + cell: { + paddingBlock: space['3'], + paddingInline: space['4'], + verticalAlign: 'middle', + }, + emptyCell: { + paddingBlock: space['6'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + textAlign: 'center', + }, + header: { + backgroundColor: colorVars['--cl-color-border-faded'], + }, + headerCell: { + paddingBlock: space['2.5'], + paddingInline: space['4'], + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + textAlign: 'start', + }, + invoiceColumn: { + width: '34%', + }, + invoiceId: { + overflow: 'hidden', + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + marginBlockStart: space['0.5'], + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + invoiceLabel: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + pageSizeLabel: { + gap: space['2'], + alignItems: 'center', + color: colorVars['--cl-color-neutral-faded'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pageSizeSelect: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['1'], + paddingInline: space['2'], + backgroundColor: colorVars['--cl-color-card'], + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + pagination: { + gap: space['2'], + paddingBlock: space['2'], + paddingInline: space['3'], + alignItems: 'center', + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + display: 'flex', + justifyContent: 'space-between', + }, + paginationControls: { + gap: space['1'], + alignItems: 'center', + display: 'flex', + }, + row: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + shell: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + width: '100%', + }, + statusColumn: { + width: '20%', + }, + table: { + borderCollapse: 'collapse', + tableLayout: 'fixed', + width: '100%', + }, + tableScroller: { + overflowX: 'auto', + width: '100%', + }, + viewColumn: { + width: '14%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx new file mode 100644 index 00000000000..ad6a0d28cd7 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-history-section.view.tsx @@ -0,0 +1,181 @@ +import * as stylex from '@stylexjs/stylex'; + +import type { BadgeProps } from '../components/badge'; +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { styles } from './user-profile-billing-history-section.styles'; + +export interface UserProfileBillingHistoryItem { + id: string; + dateLabel: string; + invoiceLabel: string; + amountLabel: string; + statusLabel: string; + statusColor?: BadgeProps['color']; +} + +export interface UserProfileBillingHistoryPagination { + page: number; + pageCount: number; + pageSize: number; + pageSizeOptions?: readonly number[]; +} + +export interface UserProfileBillingHistorySectionViewProps { + items: UserProfileBillingHistoryItem[]; + pagination?: UserProfileBillingHistoryPagination; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onView?: (id: string) => void; +} + +export function UserProfileBillingHistorySectionView({ + items, + pagination, + onPageChange, + onPageSizeChange, + onView, +}: UserProfileBillingHistorySectionViewProps) { + return ( + <Section.Root aria-label='Billing history'> + <Section.Title>History</Section.Title> + <div {...stylex.props(styles.shell)}> + {/* TODO: Replace this inline implementation with the Mosaic Table component. */} + <div {...stylex.props(styles.tableScroller)}> + <table {...stylex.props(styles.table)}> + <thead {...stylex.props(styles.header)}> + <tr> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.invoiceColumn)} + > + Invoice + </th> + <th + scope='col' + {...stylex.props(styles.headerCell)} + > + Amount + </th> + <th + scope='col' + {...stylex.props(styles.headerCell, styles.statusColumn)} + > + Status + </th> + <th + aria-label='Actions' + scope='col' + {...stylex.props(styles.headerCell, styles.viewColumn)} + /> + </tr> + </thead> + <tbody> + {items.length > 0 ? ( + items.map(item => ( + <tr + key={item.id} + {...stylex.props(styles.row)} + > + <td {...stylex.props(styles.cell)}> + <div {...stylex.props(styles.invoiceLabel)}>{item.dateLabel}</div> + <div {...stylex.props(styles.invoiceId)}>{item.invoiceLabel}</div> + </td> + <td {...stylex.props(styles.cell, styles.amountCell)}>{item.amountLabel}</td> + <td {...stylex.props(styles.cell)}> + <Badge color={item.statusColor ?? 'positive'}>{item.statusLabel}</Badge> + </td> + <td {...stylex.props(styles.cell, styles.actionCell)}> + {onView ? ( + <Button + color='neutral' + size='sm' + variant='link' + onClick={() => onView(item.id)} + > + View + </Button> + ) : null} + </td> + </tr> + )) + ) : ( + <tr {...stylex.props(styles.row)}> + <td + colSpan={4} + {...stylex.props(styles.emptyCell)} + > + No invoices yet + </td> + </tr> + )} + </tbody> + </table> + </div> + {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. + <div {...stylex.props(styles.pagination)}> + <div {...stylex.props(styles.paginationControls)}> + <Button + aria-label='Previous invoice page' + color='neutral' + disabled={pagination.page <= 1} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page - 1)} + > + <Icon name='chevron-left' /> + </Button> + <Button + aria-current='page' + aria-label={`Invoice page ${pagination.page}`} + color='neutral' + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + > + {pagination.page} + </Button> + <Button + aria-label='Next invoice page' + color='neutral' + disabled={pagination.page >= pagination.pageCount} + shape='square' + size='sm' + touchTarget={false} + variant='ghost' + onClick={() => onPageChange?.(pagination.page + 1)} + > + <Icon name='chevron-right' /> + </Button> + </div> + <label {...stylex.props(styles.pageSizeLabel)}> + <span>Results per page</span> + {/* TODO: Replace this inline implementation with the Mosaic Select component. */} + <select + aria-label='Results per page' + value={pagination.pageSize} + {...stylex.props(styles.pageSizeSelect)} + onChange={event => onPageSizeChange?.(Number(event.currentTarget.value))} + > + {(pagination.pageSizeOptions ?? [10, 25, 50]).map(pageSize => ( + <option + key={pageSize} + value={pageSize} + > + {pageSize} + </option> + ))} + </select> + </label> + </div> + ) : null} + </div> + </Section.Root> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.styles.ts new file mode 100644 index 00000000000..13c1602d4aa --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.styles.ts @@ -0,0 +1,24 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + amount: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-base-size'], + fontWeight: fontWeightVars['--cl-font-semibold'], + lineHeight: typeScaleVars['--cl-text-base-leading'], + }, + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + sections: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx new file mode 100644 index 00000000000..766e88d1bdc --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-billing-panel.view.tsx @@ -0,0 +1,80 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import type { + UserProfileBillingHistoryItem, + UserProfileBillingHistoryPagination, +} from './user-profile-billing-history-section.view'; +import { UserProfileBillingHistorySectionView } from './user-profile-billing-history-section.view'; +import { styles } from './user-profile-billing-panel.styles'; +import type { UserProfilePaymentMethod } from './user-profile-payment-methods-section.view'; +import { UserProfilePaymentMethodsSectionView } from './user-profile-payment-methods-section.view'; +import type { UserProfileSubscription } from './user-profile-subscription-section.view'; +import { UserProfileSubscriptionSectionView } from './user-profile-subscription-section.view'; + +export type { + UserProfileBillingHistoryItem, + UserProfileBillingHistoryPagination, + UserProfilePaymentMethod, + UserProfileSubscription, +}; + +export interface UserProfileBillingPanelViewProps { + subscription: UserProfileSubscription; + paymentMethods: UserProfilePaymentMethod[]; + historyItems: UserProfileBillingHistoryItem[]; + historyPagination?: UserProfileBillingHistoryPagination; + onChangePlan?: () => void; + onAddPaymentMethod?: () => void; + onMakeDefaultPaymentMethod?: (id: string) => void; + onRemovePaymentMethod?: (id: string) => void; + onBillingHistoryPageChange?: (page: number) => void; + onBillingHistoryPageSizeChange?: (pageSize: number) => void; + onViewInvoice?: (id: string) => void; +} + +export function UserProfileBillingPanelView({ + subscription, + paymentMethods, + historyItems, + historyPagination, + onChangePlan, + onAddPaymentMethod, + onMakeDefaultPaymentMethod, + onRemovePaymentMethod, + onBillingHistoryPageChange, + onBillingHistoryPageSizeChange, + onViewInvoice, +}: UserProfileBillingPanelViewProps): ReactElement { + return ( + <div {...mergeStyleProps(themeProps('user-profile-billing-panel'), stylex.props(styles.root))}> + <Heading + render={props => <h3 {...props} />} + size='2xl' + > + Billing + </Heading> + <div {...stylex.props(styles.sections)}> + <UserProfileSubscriptionSectionView + subscription={subscription} + onChangePlan={onChangePlan} + /> + <UserProfilePaymentMethodsSectionView + paymentMethods={paymentMethods} + onAdd={onAddPaymentMethod} + onMakeDefault={onMakeDefaultPaymentMethod} + onRemove={onRemovePaymentMethod} + /> + <UserProfileBillingHistorySectionView + items={historyItems} + pagination={historyPagination} + onPageChange={onBillingHistoryPageChange} + onPageSizeChange={onBillingHistoryPageSizeChange} + onView={onViewInvoice} + /> + </div> + </div> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx index f91bbbecfc0..94b4760ed65 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-connected-accounts-section.view.tsx @@ -1,11 +1,9 @@ -import * as stylex from '@stylexjs/stylex'; - import { Button } from '../components/button'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; -import { styles } from './user-profile-profile-panel.styles'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; export interface UserProfileConnectedAccount { id: string; @@ -45,18 +43,7 @@ export function UserProfileConnectedAccountsSectionView({ return ( <Section.Row key={account.id}> <Section.Item> - {account.iconUrl ? ( - <Section.Media - size='xl' - {...stylex.props(styles.providerMedia)} - > - <img - alt='' - src={account.iconUrl} - {...stylex.props(styles.providerIcon)} - /> - </Section.Media> - ) : null} + {account.iconUrl ? <UserProfileProviderIcon iconUrl={account.iconUrl} /> : null} <Section.Content> <Section.Label>{account.provider}</Section.Label> {account.identifier ? <Section.Description>{account.identifier}</Section.Description> : null} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx index ba2e5cb5b6b..8ace3e6e9c6 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-delete-section.view.tsx @@ -15,7 +15,7 @@ export function UserProfileDeleteSectionView({ onDelete }: UserProfileDeleteSect <Section.Content> <Section.Label>Delete account</Section.Label> <Section.Description> - Permanently delete this profile and all its data. This cannot be undone. + Permanently delete this account and all its data. This cannot be undone. </Section.Description> </Section.Content> <Section.Actions> diff --git a/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx new file mode 100644 index 00000000000..b298e1f4001 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-mfa-section.view.tsx @@ -0,0 +1,120 @@ +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Menu } from '../components/menu'; +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileSecurityIcon } from './user-profile-security-icon'; +import { UserProfileSecurityList } from './user-profile-security-list'; + +export interface UserProfileMfaMethod { + id: string; + type: 'sms' | 'authenticator' | 'backup-codes'; + label?: string; + description?: string; +} + +export type UserProfileMfaAddableMethod = Extract<UserProfileMfaMethod['type'], 'sms' | 'authenticator'>; + +export interface UserProfileMfaSectionViewProps { + methods: UserProfileMfaMethod[]; + sectionTitle?: string; + onAdd?: (type: UserProfileMfaAddableMethod) => void; + onRegenerateBackupCodes?: () => void; + onRemove?: (id: string) => void; +} + +const labels: Record<UserProfileMfaMethod['type'], string> = { + sms: 'SMS verification', + authenticator: 'Authenticator app', + 'backup-codes': 'Backup codes', +}; + +const addableMethods: UserProfileMfaAddableMethod[] = ['sms', 'authenticator']; + +export function UserProfileMfaSectionView({ + methods, + sectionTitle, + onAdd, + onRegenerateBackupCodes, + onRemove, +}: UserProfileMfaSectionViewProps) { + const availableMethods = addableMethods.filter(type => !methods.some(method => method.type === type)); + const hasConfiguredMethod = methods.some(method => method.type === 'sms' || method.type === 'authenticator'); + const visibleMethods = methods.filter(method => method.type !== 'backup-codes' || hasConfiguredMethod); + + return ( + <UserProfileSecurityList + addControl={ + onAdd && availableMethods.length > 0 ? ( + <Menu.Root placement='bottom-end'> + <Menu.Trigger + aria-label='Add verification method' + render={props => ( + <Button + color='neutral' + size='sm' + variant='outline' + {...props} + /> + )} + > + <Icon + name='plus' + placement='inline-start' + size='sm' + /> + Add + </Menu.Trigger> + <Menu.Content> + {availableMethods.map(type => ( + <Menu.Item + key={type} + label={labels[type]} + onClick={() => onAdd(type)} + /> + ))} + </Menu.Content> + </Menu.Root> + ) : null + } + addLabel='Add verification method' + emptyLabel='No verification methods added' + hasItems={visibleMethods.length > 0} + label='2-step verification' + sectionTitle={sectionTitle} + > + {visibleMethods.map(method => { + const label = method.label ?? labels[method.type]; + const actions: UserProfileMenuAction[] = []; + + if (method.type === 'backup-codes') { + if (onRegenerateBackupCodes) { + actions.push({ + label: 'Regenerate', + onClick: onRegenerateBackupCodes, + }); + } + } else if (onRemove) { + actions.push({ label: 'Remove method', color: 'negative', onClick: () => onRemove(method.id) }); + } + + return ( + <Section.Item key={method.id}> + <UserProfileSecurityIcon name={method.type} /> + <Section.Content> + <Section.Label>{label}</Section.Label> + {method.description ? <Section.Description>{method.description}</Section.Description> : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${label}`} + /> + </Section.Actions> + </Section.Item> + ); + })} + </UserProfileSecurityList> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx new file mode 100644 index 00000000000..dfbb07689bc --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-passkeys-section.view.tsx @@ -0,0 +1,70 @@ +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileSecurityIcon } from './user-profile-security-icon'; +import { UserProfileSecurityList } from './user-profile-security-list'; + +export interface UserProfilePasskey { + id: string; + name: string; + createdAtLabel?: string; + lastUsedAtLabel?: string; +} + +export interface UserProfilePasskeysSectionViewProps { + passkeys: UserProfilePasskey[]; + sectionTitle?: string; + onAdd?: () => void; + onManage?: (id: string) => void; + onRemove?: (id: string) => void; +} + +export function UserProfilePasskeysSectionView({ + passkeys, + sectionTitle, + onAdd, + onManage, + onRemove, +}: UserProfilePasskeysSectionViewProps) { + return ( + <UserProfileSecurityList + addLabel='Add passkey' + emptyLabel='No passkeys added' + hasItems={passkeys.length > 0} + label='Passkeys' + sectionTitle={sectionTitle} + onAdd={onAdd} + > + {passkeys.map(passkey => { + const actions: UserProfileMenuAction[] = []; + + if (onManage) { + actions.push({ label: 'Rename', onClick: () => onManage(passkey.id) }); + } + if (onRemove) { + actions.push({ label: 'Remove passkey', color: 'negative', onClick: () => onRemove(passkey.id) }); + } + + return ( + <Section.Item key={passkey.id}> + <UserProfileSecurityIcon name='passkey' /> + <Section.Content> + <Section.Label>{passkey.name}</Section.Label> + {passkey.createdAtLabel || passkey.lastUsedAtLabel ? ( + <Section.Description> + {[passkey.createdAtLabel, passkey.lastUsedAtLabel].filter(Boolean).join(' · ')} + </Section.Description> + ) : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${passkey.name}`} + /> + </Section.Actions> + </Section.Item> + ); + })} + </UserProfileSecurityList> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx new file mode 100644 index 00000000000..9a48fb65176 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-password-section.view.tsx @@ -0,0 +1,40 @@ +import { Button } from '../components/button'; +import { Section } from '../components/section'; + +export interface UserProfilePasswordSectionViewProps { + sectionTitle?: string; + onChangePassword?: () => void; +} + +export function UserProfilePasswordSectionView({ + sectionTitle = 'Authentication', + onChangePassword, +}: UserProfilePasswordSectionViewProps) { + return ( + <Section.Root aria-label={sectionTitle ? undefined : 'Password'}> + {sectionTitle ? <Section.Title>{sectionTitle}</Section.Title> : null} + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Password</Section.Label> + <Section.Description>••••••••••••••••••</Section.Description> + </Section.Content> + {onChangePassword ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onChangePassword} + > + Change password + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx new file mode 100644 index 00000000000..64eadcc3558 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-payment-methods-section.view.tsx @@ -0,0 +1,116 @@ +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import type { UserProfileMenuAction } from './user-profile-action-menu'; +import { UserProfileActionMenu } from './user-profile-action-menu'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; + +export interface UserProfilePaymentMethod { + id: string; + label: string; + expiryLabel?: string; + isDefault?: boolean; + isRemovable?: boolean; +} + +export interface UserProfilePaymentMethodsSectionViewProps { + paymentMethods: UserProfilePaymentMethod[]; + onAdd?: () => void; + onMakeDefault?: (id: string) => void; + onRemove?: (id: string) => void; +} + +export function UserProfilePaymentMethodsSectionView({ + paymentMethods, + onAdd, + onMakeDefault, + onRemove, +}: UserProfilePaymentMethodsSectionViewProps) { + return ( + <Section.Root aria-label='Payment methods'> + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Payment methods</Section.Label> + </Section.Content> + {onAdd ? ( + <Section.Actions> + <Button + aria-label='Add payment method' + color='neutral' + size='sm' + variant='outline' + onClick={onAdd} + > + <Icon + name='plus' + placement='inline-start' + size='sm' + /> + Add + </Button> + </Section.Actions> + ) : null} + </Section.Item> + <Section.Items> + {paymentMethods.length > 0 ? ( + paymentMethods.map(paymentMethod => ( + <PaymentMethodItem + key={paymentMethod.id} + paymentMethod={paymentMethod} + onMakeDefault={onMakeDefault} + onRemove={onRemove} + /> + )) + ) : ( + <Section.Item> + <Section.Content> + <Section.Description>No payment methods added</Section.Description> + </Section.Content> + </Section.Item> + )} + </Section.Items> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} + +function PaymentMethodItem({ + paymentMethod, + onMakeDefault, + onRemove, +}: { + paymentMethod: UserProfilePaymentMethod; + onMakeDefault?: (id: string) => void; + onRemove?: (id: string) => void; +}) { + const actions: UserProfileMenuAction[] = []; + + if (!paymentMethod.isDefault && onMakeDefault) { + actions.push({ label: 'Make default', onClick: () => onMakeDefault(paymentMethod.id) }); + } + if (paymentMethod.isRemovable !== false && onRemove) { + actions.push({ label: 'Remove payment method', color: 'negative', onClick: () => onRemove(paymentMethod.id) }); + } + + return ( + <Section.Item> + <UserProfileProviderIcon name='credit-card' /> + <Section.Content> + <Section.Label> + {paymentMethod.label} {paymentMethod.isDefault ? <Badge color='neutral'>Default</Badge> : null} + </Section.Label> + {paymentMethod.expiryLabel ? <Section.Description>{paymentMethod.expiryLabel}</Section.Description> : null} + </Section.Content> + <Section.Actions> + <UserProfileActionMenu + actions={actions} + label={`Manage ${paymentMethod.label}`} + /> + </Section.Actions> + </Section.Item> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 2f6a734164c..47fb979d2fc 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../tokens.stylex'; +import { colorVars, radiusVars, space } from '../tokens.stylex'; export const styles = stylex.create({ contactValue: { @@ -9,18 +9,18 @@ export const styles = stylex.create({ display: 'flex', minWidth: 0, }, - providerMedia: { - borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', - borderRadius: 'var(--cl-radius-lg)', - borderStyle: 'solid', - borderWidth: '1px', - backgroundColor: 'var(--cl-color-background)', - }, providerIcon: { display: 'block', height: space['5'], width: space['5'], }, + providerMedia: { + borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', + borderRadius: radiusVars['--cl-radius-lg'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: colorVars['--cl-color-background'], + }, root: { gap: space['4'], display: 'flex', @@ -30,5 +30,6 @@ export const styles = stylex.create({ gap: space['8'], display: 'flex', flexDirection: 'column', + width: '100%', }, }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 79139febcdf..42947b075ea 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -67,7 +67,7 @@ export function UserProfileProfilePanelView({ render={props => <h3 {...props} />} size='2xl' > - Profile + Account </Heading> <div {...stylex.props(styles.sections)}> <UserProfileAccountSectionView diff --git a/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx b/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx new file mode 100644 index 00000000000..a200fe914b9 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-provider-icon.tsx @@ -0,0 +1,32 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import type { IconName } from '../icons/registry'; +import { styles } from './user-profile-profile-panel.styles'; + +type UserProfileProviderIconProps = { iconUrl: string; name?: never } | { iconUrl?: never; name: IconName }; + +// TODO: Replace this temporary user-profile wrapper with IconFrame. +export function UserProfileProviderIcon(props: UserProfileProviderIconProps) { + return ( + <Section.Media + size='lg' + {...stylex.props(styles.providerMedia)} + > + {'iconUrl' in props ? ( + <img + alt='' + src={props.iconUrl} + {...stylex.props(styles.providerIcon)} + /> + ) : ( + <Icon + aria-hidden + name={props.name} + {...stylex.props(styles.providerIcon)} + /> + )} + </Section.Media> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-icon.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-icon.tsx new file mode 100644 index 00000000000..cbdae19132b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-icon.tsx @@ -0,0 +1,34 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { mergeStyleProps } from '../props'; +import { space } from '../tokens.stylex'; +import { styles } from './user-profile-security-panel.styles'; + +export type UserProfileSecurityIconName = 'authenticator' | 'backup-codes' | 'desktop' | 'mobile' | 'passkey' | 'sms'; + +const icons = { + authenticator: 'security-lock-square', + 'backup-codes': 'security-phone', + desktop: 'device-laptop', + mobile: 'device-phone', + passkey: 'security-passkey', + sms: 'security-phone', +} as const; + +export function UserProfileSecurityIcon({ name }: { name: UserProfileSecurityIconName }) { + return ( + <Section.Media + size='lg' + {...mergeStyleProps(stylex.props(styles.media), { style: { height: space['9'], width: space['9'] } })} + > + <Icon + aria-hidden + name={icons[name]} + size='lg' + {...stylex.props(styles.icon)} + /> + </Section.Media> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx new file mode 100644 index 00000000000..aefc2b7b70f --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-list.tsx @@ -0,0 +1,71 @@ +import type { ReactNode } from 'react'; + +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; + +export function UserProfileSecurityList({ + sectionTitle, + label, + addLabel, + emptyLabel, + hasItems, + onAdd, + addControl, + children, +}: { + sectionTitle?: string; + label: string; + addLabel: string; + emptyLabel: string; + hasItems: boolean; + onAdd?: () => void; + addControl?: ReactNode; + children: ReactNode; +}) { + return ( + <Section.Root aria-label={sectionTitle ? undefined : label}> + {sectionTitle ? <Section.Title>{sectionTitle}</Section.Title> : null} + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>{label}</Section.Label> + </Section.Content> + {addControl ? ( + <Section.Actions>{addControl}</Section.Actions> + ) : onAdd ? ( + <Section.Actions> + <Button + aria-label={addLabel} + color='neutral' + size='sm' + variant='outline' + onClick={onAdd} + > + <Icon + name='plus' + placement='inline-start' + size='sm' + /> + Add + </Button> + </Section.Actions> + ) : null} + </Section.Item> + <Section.Items> + {hasItems ? ( + children + ) : ( + <Section.Item> + <Section.Content> + <Section.Description>{emptyLabel}</Section.Description> + </Section.Content> + </Section.Item> + )} + </Section.Items> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.styles.ts new file mode 100644 index 00000000000..6d4ba165d7d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.styles.ts @@ -0,0 +1,44 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + currentDevice: { + color: colorVars['--cl-color-positive'], + }, + descriptionLine: { + columnGap: space['1'], + display: 'flex', + flexWrap: 'wrap', + }, + icon: { + color: colorVars['--cl-color-neutral-faded'], + display: 'block', + height: space['4.5'], + width: space['4.5'], + }, + media: { + borderColor: colorVars['--cl-color-border-faded'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + backgroundColor: colorVars['--cl-color-background'], + }, + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + }, + sectionCards: { + gap: space['3'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + sections: { + gap: space['10'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx new file mode 100644 index 00000000000..2ed2b1deadf --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-security-panel.view.tsx @@ -0,0 +1,99 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import type { + UserProfileActiveDevicesSectionViewProps, + UserProfileDevice, +} from './user-profile-active-devices-section.view'; +import { UserProfileActiveDevicesSectionView } from './user-profile-active-devices-section.view'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; +import type { UserProfileMfaAddableMethod, UserProfileMfaMethod } from './user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from './user-profile-mfa-section.view'; +import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; +import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; +import { UserProfilePasswordSectionView } from './user-profile-password-section.view'; +import { styles } from './user-profile-security-panel.styles'; + +export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; + +export interface UserProfileSecurityPanelViewProps extends Omit<UserProfileActiveDevicesSectionViewProps, 'devices'> { + hasPassword?: boolean; + passkeys?: UserProfilePasskey[]; + mfaMethods?: UserProfileMfaMethod[]; + devices?: UserProfileDevice[]; + onChangePassword?: () => void; + onAddPasskey?: () => void; + onManagePasskey?: (id: string) => void; + onRemovePasskey?: (id: string) => void; + onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; + onRegenerateBackupCodes?: () => void; + onRemoveMfaMethod?: (id: string) => void; + onDeleteAccount?: () => void; +} + +export function UserProfileSecurityPanelView({ + hasPassword = false, + passkeys, + mfaMethods, + devices, + onChangePassword, + onAddPasskey, + onManagePasskey, + onRemovePasskey, + onAddMfaMethod, + onRegenerateBackupCodes, + onRemoveMfaMethod, + onManageDevice, + onSignOutDevice, + onSignOutAllOtherDevices, + onDeleteAccount, +}: UserProfileSecurityPanelViewProps): ReactElement { + const hasAuthentication = hasPassword || passkeys !== undefined || mfaMethods !== undefined; + + return ( + <div {...mergeStyleProps(themeProps('user-profile-security-panel'), stylex.props(styles.root))}> + <Heading + render={props => <h3 {...props} />} + size='2xl' + > + Security + </Heading> + <div {...stylex.props(styles.sections)}> + {hasAuthentication ? ( + <div {...stylex.props(styles.sectionCards)}> + {hasPassword ? <UserProfilePasswordSectionView onChangePassword={onChangePassword} /> : null} + {passkeys !== undefined ? ( + <UserProfilePasskeysSectionView + passkeys={passkeys} + sectionTitle={hasPassword ? undefined : 'Authentication'} + onAdd={onAddPasskey} + onManage={onManagePasskey} + onRemove={onRemovePasskey} + /> + ) : null} + {mfaMethods !== undefined ? ( + <UserProfileMfaSectionView + methods={mfaMethods} + sectionTitle={!hasPassword && passkeys === undefined ? 'Authentication' : undefined} + onAdd={onAddMfaMethod} + onRegenerateBackupCodes={onRegenerateBackupCodes} + onRemove={onRemoveMfaMethod} + /> + ) : null} + </div> + ) : null} + {devices ? ( + <UserProfileActiveDevicesSectionView + devices={devices} + onManageDevice={onManageDevice} + onSignOutAllOtherDevices={onSignOutAllOtherDevices} + onSignOutDevice={onSignOutDevice} + /> + ) : null} + {onDeleteAccount ? <UserProfileDeleteSectionView onDelete={onDeleteAccount} /> : null} + </div> + </div> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx new file mode 100644 index 00000000000..a04d06e9f3c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx @@ -0,0 +1,77 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { ClerkLogo } from '../components/clerk-logo'; +import { Icon } from '../components/icon'; +import { reset } from '../components/reset.styles'; +import type { IconName } from '../icons/registry'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './user-profile.styles'; + +export type UserProfilePanelId = 'account' | 'security' | 'billing' | 'api-keys'; + +const destinations: Record<UserProfilePanelId, { label: string; icon: IconName }> = { + account: { label: 'Account', icon: 'user-circle' }, + security: { label: 'Security', icon: 'shield-check' }, + billing: { label: 'Billing', icon: 'credit-card' }, + 'api-keys': { label: 'API Keys', icon: 'code' }, +}; + +export interface UserProfileSidebarProps { + activePanel: UserProfilePanelId; + panels: readonly UserProfilePanelId[]; + onPanelChange: (panel: UserProfilePanelId) => void; + renderBranding?: boolean; +} + +export function UserProfileSidebar({ + activePanel, + panels, + onPanelChange, + renderBranding = true, +}: UserProfileSidebarProps): ReactElement { + return ( + <aside {...mergeStyleProps(themeProps('user-profile-sidebar'), stylex.props(reset.base, styles.sidebar))}> + <nav + aria-label='User profile' + {...stylex.props(reset.base, styles.navigation)} + > + {panels.map(panel => { + const destination = destinations[panel]; + const active = panel === activePanel; + + return ( + <button + key={panel} + aria-current={active ? 'page' : undefined} + type='button' + {...stylex.props(reset.base, styles.navigationItem, active && styles.navigationItemActive)} + onClick={() => onPanelChange(panel)} + > + <Icon + aria-hidden + name={destination.icon} + size='sm' + /> + <span>{destination.label}</span> + </button> + ); + })} + </nav> + {renderBranding ? ( + <div {...stylex.props(reset.base, styles.branding)}> + <span>Secured by</span> + <a + aria-label='Clerk' + href='https://go.clerk.com/components' + rel='noopener noreferrer' + target='_blank' + {...stylex.props(reset.base, styles.brandingLink)} + > + <ClerkLogo height={12} /> + </a> + </div> + ) : null} + </aside> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx new file mode 100644 index 00000000000..188d7d213e7 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-subscription-section.view.tsx @@ -0,0 +1,61 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../components/button'; +import { Section } from '../components/section'; +import { styles } from './user-profile-billing-panel.styles'; + +export interface UserProfileSubscription { + planName: string; + priceLabel: string; + totalDueLabel: string; + renewsAtLabel: string; +} + +export interface UserProfileSubscriptionSectionViewProps { + subscription: UserProfileSubscription; + onChangePlan?: () => void; +} + +export function UserProfileSubscriptionSectionView({ + subscription, + onChangePlan, +}: UserProfileSubscriptionSectionViewProps) { + return ( + <Section.Root> + <Section.Title>Subscription</Section.Title> + <Section.Group> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>{subscription.planName}</Section.Label> + <Section.Description>{subscription.priceLabel}</Section.Description> + </Section.Content> + {onChangePlan ? ( + <Section.Actions> + <Button + color='neutral' + size='sm' + variant='outline' + onClick={onChangePlan} + > + Change plan + </Button> + </Section.Actions> + ) : null} + </Section.Item> + </Section.Row> + <Section.Row> + <Section.Item> + <Section.Content> + <Section.Label>Total due</Section.Label> + <Section.Description>{subscription.renewsAtLabel}</Section.Description> + </Section.Content> + <Section.Actions> + <span {...stylex.props(styles.amount)}>{subscription.totalDueLabel}</span> + </Section.Actions> + </Section.Item> + </Section.Row> + </Section.Group> + </Section.Root> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx index 82c9b68df22..0ccf9bfd4da 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-web3-wallets-section.view.tsx @@ -7,6 +7,7 @@ import { Section } from '../components/section'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { styles } from './user-profile-profile-panel.styles'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; export interface UserProfileWeb3Wallet { id: string; @@ -74,18 +75,7 @@ export function UserProfileWeb3WalletsSectionView({ return ( <Section.Row key={wallet.id}> <Section.Item> - {wallet.iconUrl ? ( - <Section.Media - size='xl' - {...stylex.props(styles.providerMedia)} - > - <img - alt='' - src={wallet.iconUrl} - {...stylex.props(styles.providerIcon)} - /> - </Section.Media> - ) : null} + {wallet.iconUrl ? <UserProfileProviderIcon iconUrl={wallet.iconUrl} /> : null} <Section.Content> <Section.Label> <span {...stylex.props(styles.contactValue)}> diff --git a/packages/ui/src/mosaic/user-profile/user-profile.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile.styles.ts new file mode 100644 index 00000000000..58ea21c83f8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile.styles.ts @@ -0,0 +1,129 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + root: { + borderRadius: radiusVars['--cl-radius-xl'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + color: colorVars['--cl-color-card-foreground'], + display: 'grid', + gridTemplateColumns: { + default: `calc(${space['40']} + ${space['15']}) minmax(0, 1fr)`, + '@media (max-width: 47.99rem)': 'minmax(0, 1fr)', + }, + gridTemplateRows: 'auto', + maxWidth: '66rem', + minHeight: 0, + width: '100%', + }, + sidebar: { + padding: space['4'], + borderBlockEndColor: { + default: 'transparent', + '@media (max-width: 47.99rem)': colorVars['--cl-color-border'], + }, + borderBlockEndStyle: 'solid', + borderBlockEndWidth: { + default: '0px', + '@media (max-width: 47.99rem)': '1px', + }, + borderInlineEndColor: colorVars['--cl-color-border'], + borderInlineEndStyle: 'solid', + borderInlineEndWidth: { + default: '1px', + '@media (max-width: 47.99rem)': '0px', + }, + display: 'flex', + flexDirection: { + default: 'column', + '@media (max-width: 47.99rem)': 'row', + }, + minHeight: 0, + minWidth: 0, + }, + navigation: { + gap: space['1'], + display: 'flex', + flexDirection: { + default: 'column', + '@media (max-width: 47.99rem)': 'row', + }, + minWidth: 0, + overflowX: { + default: 'visible', + '@media (max-width: 47.99rem)': 'auto', + }, + }, + navigationItem: { + borderColor: 'transparent', + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '0px', + gap: space['2'], + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, + }, + paddingBlock: space['2'], + paddingInline: space['2.5'], + alignItems: 'center', + backgroundColor: { + default: 'transparent', + ':hover': colorVars['--cl-color-border-faded'], + }, + color: colorVars['--cl-color-neutral-faded'], + cursor: 'pointer', + display: 'flex', + flexShrink: 0, + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + outlineOffset: '2px', + textAlign: 'start', + whiteSpace: 'nowrap', + width: { + default: '100%', + '@media (max-width: 47.99rem)': 'auto', + }, + }, + navigationItemActive: { + backgroundColor: colorVars['--cl-color-border-faded'], + color: colorVars['--cl-color-card-foreground'], + }, + branding: { + gap: space['1'], + alignItems: 'center', + color: colorVars['--cl-color-neutral-faded'], + display: { + default: 'flex', + '@media (max-width: 47.99rem)': 'none', + }, + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + marginBlockStart: 'auto', + }, + brandingLink: { + borderRadius: radiusVars['--cl-radius-sm'], + outline: { + default: 'none', + ':focus-visible': `2px solid ${colorVars['--cl-color-primary']}`, + }, + alignItems: 'center', + color: 'inherit', + display: 'inline-flex', + outlineOffset: '2px', + height: space['4'], + }, + main: { + minWidth: 0, + }, + content: { + paddingBlock: space['16'], + paddingInline: space['16'], + }, +});