From bdff2d055dc20a79ee8d8ea29193444889b08b52 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 09:56:19 -0600 Subject: [PATCH 01/25] feat(ui): add organization profile general panel --- packages/ui/src/mosaic/icons/registry.tsx | 16 +++ ...zation-profile-general-panel.view.test.tsx | 56 +++++++++ ...ganization-profile-danger-section.view.tsx | 69 +++++++++++ ...anization-profile-details-section.view.tsx | 115 ++++++++++++++++++ ...ganization-profile-general-panel.styles.ts | 30 +++++ ...rganization-profile-general-panel.view.tsx | 48 ++++++++ 6 files changed, 334 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-general-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-danger-section.view.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-details-section.view.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.styles.ts create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.view.tsx diff --git a/packages/ui/src/mosaic/icons/registry.tsx b/packages/ui/src/mosaic/icons/registry.tsx index 71f4e41bfb4..7cb1f8cb03e 100644 --- a/packages/ui/src/mosaic/icons/registry.tsx +++ b/packages/ui/src/mosaic/icons/registry.tsx @@ -286,6 +286,20 @@ const Users = glyph( />, ); +const Building = glyph( + , +); + +const Copy = glyph( + , +); + const AlertCircle = glyph( <> { + it('renders organization details and danger actions', async () => { + const onCopySlug = vi.fn(); + const onDeleteOrganization = vi.fn(); + const onLeaveOrganization = vi.fn(); + const onNameChange = vi.fn(); + const onUploadLogo = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + expect(screen.getByRole('heading', { level: 3, name: 'General' })).toBeInTheDocument(); + expect(screen.getByText('Organization details')).toBeInTheDocument(); + expect(screen.getByText('Danger zone')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Upload' })); + await user.click(screen.getByRole('button', { name: 'Edit name' })); + await user.click(screen.getByRole('button', { name: 'Copy' })); + await user.click(screen.getByRole('button', { name: 'Leave organization' })); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + + expect(onUploadLogo).toHaveBeenCalledOnce(); + expect(onNameChange).toHaveBeenCalledWith('Clerk'); + expect(onCopySlug).toHaveBeenCalledWith('clerkorganization-177654156132154'); + expect(onLeaveOrganization).toHaveBeenCalledOnce(); + expect(onDeleteOrganization).toHaveBeenCalledOnce(); + }); + + it('omits unavailable actions and the empty danger section', () => { + render( + , + ); + + expect(screen.queryByText('Danger zone')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-danger-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-danger-section.view.tsx new file mode 100644 index 00000000000..9dcfb47e08d --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-danger-section.view.tsx @@ -0,0 +1,69 @@ +import { Button } from '../components/button'; +import { Section } from '../components/section'; + +export interface OrganizationProfileDangerSectionViewProps { + onDeleteOrganization?: () => void; + onLeaveOrganization?: () => void; +} + +export function OrganizationProfileDangerSectionView({ + onDeleteOrganization, + onLeaveOrganization, +}: OrganizationProfileDangerSectionViewProps) { + if (!onDeleteOrganization && !onLeaveOrganization) { + return null; + } + + return ( + + Danger zone + + {onLeaveOrganization ? ( + + + + Leave organization + + You will lose access to this organization and its applications. + + + + + + + + ) : null} + {onDeleteOrganization ? ( + + + + Delete organization + + Permanently delete this workspace and all its data. This cannot be undone. All members will lose + access. + + + + + + + + ) : null} + + + ); +} diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-details-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-details-section.view.tsx new file mode 100644 index 00000000000..74b9868fdc4 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-details-section.view.tsx @@ -0,0 +1,115 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { styles } from './organization-profile-general-panel.styles'; + +export interface OrganizationProfileDetailsSectionViewProps { + logoUrl?: string; + name: string; + slug: string; + onCopySlug?: (slug: string) => void; + onNameChange?: (name: string) => void; + onUploadLogo?: () => void; +} + +export function OrganizationProfileDetailsSectionView({ + logoUrl, + name, + slug, + onCopySlug, + onNameChange, + onUploadLogo, +}: OrganizationProfileDetailsSectionViewProps) { + return ( + + Organization details + + + + + {logoUrl ? ( + {`${name} + ) : ( + + )} + + + Logo + Recommended size 1:1, up to 10MB. + + {onUploadLogo ? ( + + + + ) : null} + + + + + + Name + {name} + + {onNameChange ? ( + + + + ) : null} + + + + + + Slug + {slug} + + {onCopySlug ? ( + + + + ) : null} + + + + + ); +} diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.styles.ts b/packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.styles.ts new file mode 100644 index 00000000000..c9f5cf66b55 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.styles.ts @@ -0,0 +1,30 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + logo: { + borderRadius: radiusVars['--cl-radius-lg'], + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-card-foreground'], + color: colorVars['--cl-color-card'], + }, + logoImage: { + display: 'block', + objectFit: 'cover', + height: '100%', + width: '100%', + }, + root: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + sections: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.view.tsx new file mode 100644 index 00000000000..75c405b32c3 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.view.tsx @@ -0,0 +1,48 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import type { OrganizationProfileDangerSectionViewProps } from './organization-profile-danger-section.view'; +import { OrganizationProfileDangerSectionView } from './organization-profile-danger-section.view'; +import type { OrganizationProfileDetailsSectionViewProps } from './organization-profile-details-section.view'; +import { OrganizationProfileDetailsSectionView } from './organization-profile-details-section.view'; +import { styles } from './organization-profile-general-panel.styles'; + +export interface OrganizationProfileGeneralPanelViewProps + extends OrganizationProfileDetailsSectionViewProps, OrganizationProfileDangerSectionViewProps {} + +export function OrganizationProfileGeneralPanelView({ + logoUrl, + name, + slug, + onCopySlug, + onDeleteOrganization, + onLeaveOrganization, + onNameChange, + onUploadLogo, +}: OrganizationProfileGeneralPanelViewProps) { + return ( +
+

} + size='2xl' + > + General + +
+ + +
+

+ ); +} From 26ad553e4b740e7f35c1d05a814e6fc3db80f443 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 09:56:33 -0600 Subject: [PATCH 02/25] feat(ui): add organization profile page shell --- .../__tests__/organization-page.view.test.tsx | 61 +++++++++++++++ .../organization-page.view.tsx | 48 ++++++++++++ .../organization-profile-sidebar.tsx | 78 +++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 12 +++ 4 files changed, 199 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-page.view.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx new file mode 100644 index 00000000000..9a6decd17be --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import type { OrganizationPageViewProps } from '../organization-page.view'; +import { OrganizationPageView } from '../organization-page.view'; +import { OrganizationProfileGeneralPanelView } from '../organization-profile-general-panel.view'; + +const general = ( + +); + +function renderView(overrides: Partial = {}) { + const props: OrganizationPageViewProps = { + activePanel: 'general', + panels: { general, members:

Members

}, + onPanelChange: vi.fn(), + ...overrides, + }; + + return { ...render(), props }; +} + +describe('OrganizationPageView', () => { + it('renders the active panel and supplied destinations', () => { + renderView(); + + expect(screen.getByRole('navigation', { name: 'Organization profile' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'General' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Members' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Security' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'General' })).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: 'Members' })); + + expect(onPanelChange).toHaveBeenCalledWith('members'); + }); + + it('falls back to General when the requested panel is unavailable', () => { + renderView({ activePanel: 'billing', panels: { general } }); + + expect(screen.getByRole('button', { name: 'General' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('heading', { level: 3, name: 'General' })).toBeInTheDocument(); + }); + + it('can omit Clerk branding', () => { + renderView({ renderBranding: false }); + + expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx new file mode 100644 index 00000000000..7df45fa1806 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx @@ -0,0 +1,48 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { styles } from '../profile-page.styles'; +import { mergeStyleProps, themeProps } from '../props'; +import type { OrganizationProfilePanelId } from './organization-profile-sidebar'; +import { OrganizationProfileSidebar } from './organization-profile-sidebar'; + +export type OrganizationPagePanels = Partial> & { + general: ReactElement; +}; + +export interface OrganizationPageViewProps { + activePanel: OrganizationProfilePanelId; + panels: OrganizationPagePanels; + onPanelChange: (panel: OrganizationProfilePanelId) => void; + renderBranding?: boolean; +} + +const panelOrder: OrganizationProfilePanelId[] = ['general', 'members', 'security', 'billing', 'api-keys']; + +function getAvailablePanels(panels: OrganizationPagePanels): OrganizationProfilePanelId[] { + return panelOrder.filter(panel => Boolean(panels[panel])); +} + +export function OrganizationPageView({ + activePanel, + panels, + onPanelChange, + renderBranding = true, +}: OrganizationPageViewProps): ReactElement { + const availablePanels = getAvailablePanels(panels); + const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'general'; + + return ( +
+ +
+
{panels[resolvedPanel] ?? panels.general}
+
+
+ ); +} diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx new file mode 100644 index 00000000000..1e165857718 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx @@ -0,0 +1,78 @@ +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 { styles } from '../profile-page.styles'; +import { mergeStyleProps, themeProps } from '../props'; + +export type OrganizationProfilePanelId = 'general' | 'members' | 'security' | 'billing' | 'api-keys'; + +const destinations: Record = { + general: { label: 'General', icon: 'building' }, + members: { label: 'Members', icon: 'users' }, + security: { label: 'Security', icon: 'shield-check' }, + billing: { label: 'Billing', icon: 'credit-card' }, + 'api-keys': { label: 'API Keys', icon: 'code' }, +}; + +export interface OrganizationProfileSidebarProps { + activePanel: OrganizationProfilePanelId; + panels: readonly OrganizationProfilePanelId[]; + onPanelChange: (panel: OrganizationProfilePanelId) => void; + renderBranding?: boolean; +} + +export function OrganizationProfileSidebar({ + activePanel, + panels, + onPanelChange, + renderBranding = true, +}: OrganizationProfileSidebarProps): ReactElement { + return ( + + ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index e1899b7ca86..cfe79927cc5 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -104,6 +104,18 @@ export type { PopoverTriggerProps, } from '../components/popover'; +export { OrganizationPageView } from '../organization-profile/organization-page.view'; +export type { OrganizationPagePanels, OrganizationPageViewProps } from '../organization-profile/organization-page.view'; +export { OrganizationProfileDangerSectionView } from '../organization-profile/organization-profile-danger-section.view'; +export type { OrganizationProfileDangerSectionViewProps } from '../organization-profile/organization-profile-danger-section.view'; +export { OrganizationProfileDetailsSectionView } from '../organization-profile/organization-profile-details-section.view'; +export type { OrganizationProfileDetailsSectionViewProps } from '../organization-profile/organization-profile-details-section.view'; +export { OrganizationProfileGeneralPanelView } from '../organization-profile/organization-profile-general-panel.view'; +export type { OrganizationProfileGeneralPanelViewProps } from '../organization-profile/organization-profile-general-panel.view'; +export type { OrganizationProfilePanelId } from '../organization-profile/organization-profile-sidebar'; +export { UserPageView } from '../user-profile/user-page.view'; +export type { UserPagePanels, UserPageViewProps } from '../user-profile/user-page.view'; + import { colorVars, durationVars, From 10ea9db598c466e2bf2f052fb1581486d9bd53a5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 09:56:48 -0600 Subject: [PATCH 03/25] docs(swingset): add organization profile compositions --- .../swingset/src/components/DocsViewer.tsx | 8 +++ packages/swingset/src/lib/registry.ts | 37 +++++++++++++ .../src/stories/organization-page.mdx | 18 +++++++ .../src/stories/organization-page.stories.tsx | 52 +++++++++++++++++++ .../organization-profile-danger-section.mdx | 10 ++++ ...ization-profile-danger-section.stories.tsx | 22 ++++++++ .../organization-profile-details-section.mdx | 10 ++++ ...zation-profile-details-section.stories.tsx | 25 +++++++++ .../organization-profile-general-panel.mdx | 23 ++++++++ ...nization-profile-general-panel.stories.tsx | 30 +++++++++++ 10 files changed, 235 insertions(+) create mode 100644 packages/swingset/src/stories/organization-page.mdx create mode 100644 packages/swingset/src/stories/organization-page.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-danger-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-danger-section.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-details-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-details-section.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-general-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-general-panel.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3a52fbae376..38de15d256a 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,14 @@ 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> = { + 'organization-profile': { + 'organization-page': dynamic(() => import('../stories/organization-page.mdx')), + 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), + 'organization-profile-details-section': dynamic( + () => import('../stories/organization-profile-details-section.mdx'), + ), + 'organization-profile-danger-section': dynamic(() => import('../stories/organization-profile-danger-section.mdx')), + }, 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), }, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dbf534ce294..ed35de523ef 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -61,6 +61,19 @@ import { } from '../stories/item.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; +import { Default as OrganizationPageDefault, meta as organizationPageMeta } from '../stories/organization-page.stories'; +import { + Default as OrganizationProfileDangerSectionDefault, + meta as organizationProfileDangerSectionMeta, +} from '../stories/organization-profile-danger-section.stories'; +import { + Default as OrganizationProfileDetailsSectionDefault, + meta as organizationProfileDetailsSectionMeta, +} from '../stories/organization-profile-details-section.stories'; +import { + Default as OrganizationProfileGeneralPanelDefault, + meta as organizationProfileGeneralPanelMeta, +} from '../stories/organization-profile-general-panel.stories'; import { meta as otpMeta } from '../stories/otp.stories'; import { Alignment as PopoverComponentAlignment, @@ -282,6 +295,23 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const organizationPageModule: StoryModule = { + meta: organizationPageMeta, + Default: OrganizationPageDefault, +}; +const organizationProfileGeneralPanelModule: StoryModule = { + meta: organizationProfileGeneralPanelMeta, + Default: OrganizationProfileGeneralPanelDefault, +}; +const organizationProfileDetailsSectionModule: StoryModule = { + meta: organizationProfileDetailsSectionMeta, + Default: OrganizationProfileDetailsSectionDefault, +}; +const organizationProfileDangerSectionModule: StoryModule = { + meta: organizationProfileDangerSectionMeta, + Default: OrganizationProfileDangerSectionDefault, +}; + const userProfileApiKeysPanelModule: StoryModule = { meta: userProfileApiKeysPanelMeta, Default: UserProfileApiKeysPanelDefault, @@ -357,6 +387,13 @@ const userProfileDeleteSectionModule: StoryModule = { export const registry: StoryModule[] = [ // User Button userButtonModule, + // Organization Profile + organizationPageModule, + // Organization Profile · Panels + organizationProfileGeneralPanelModule, + // Organization Profile · Sections + organizationProfileDetailsSectionModule, + organizationProfileDangerSectionModule, // User Profile userPageModule, // User Profile · Panels diff --git a/packages/swingset/src/stories/organization-page.mdx b/packages/swingset/src/stories/organization-page.mdx new file mode 100644 index 00000000000..8a853f2203c --- /dev/null +++ b/packages/swingset/src/stories/organization-page.mdx @@ -0,0 +1,18 @@ +import * as Stories from './organization-page.stories'; + +# OrganizationPage + +The Organization profile shell from the General frame. It owns the organization navigation and composes +independent panels; the General panel is split into Organization details and Danger zone sections. + + diff --git a/packages/swingset/src/stories/organization-page.stories.tsx b/packages/swingset/src/stories/organization-page.stories.tsx new file mode 100644 index 00000000000..aa2adf4962e --- /dev/null +++ b/packages/swingset/src/stories/organization-page.stories.tsx @@ -0,0 +1,52 @@ +import type { OrganizationPagePanels } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; +import { OrganizationPageView } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; +import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-general-panel.view'; +import type { OrganizationProfilePanelId } from '@clerk/ui/mosaic/organization-profile/organization-profile-sidebar'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-page.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationPage', + label: 'Organization page', + layout: 'wide', + source: 'packages/ui/src/mosaic/organization-profile/organization-page.view.tsx', +}; + +function PlaceholderPanel({ title }: { title: string }) { + return

{title}

; +} + +export function Default() { + const [activePanel, setActivePanel] = useState('general'); + const [name, setName] = useState('Clerk'); + + const panels: OrganizationPagePanels = { + general: ( + undefined} + onDeleteOrganization={() => undefined} + onLeaveOrganization={() => undefined} + onNameChange={setName} + onUploadLogo={() => undefined} + /> + ), + members: , + security: , + billing: , + 'api-keys': , + }; + + return ( + + ); +} diff --git a/packages/swingset/src/stories/organization-profile-danger-section.mdx b/packages/swingset/src/stories/organization-profile-danger-section.mdx new file mode 100644 index 00000000000..5483a832ddb --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-danger-section.mdx @@ -0,0 +1,10 @@ +import * as Stories from './organization-profile-danger-section.stories'; + +# OrganizationProfileDangerSection + +Capability-driven leave and delete actions for the organization. + + diff --git a/packages/swingset/src/stories/organization-profile-danger-section.stories.tsx b/packages/swingset/src/stories/organization-profile-danger-section.stories.tsx new file mode 100644 index 00000000000..db4c545824e --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-danger-section.stories.tsx @@ -0,0 +1,22 @@ +import { OrganizationProfileDangerSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-danger-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-danger-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileDangerSection', + label: 'Danger zone', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-danger-section.view.tsx', +}; + +export function Default() { + return ( + undefined} + onLeaveOrganization={() => undefined} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-details-section.mdx b/packages/swingset/src/stories/organization-profile-details-section.mdx new file mode 100644 index 00000000000..dd1bd11b6fc --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-details-section.mdx @@ -0,0 +1,10 @@ +import * as Stories from './organization-profile-details-section.stories'; + +# OrganizationProfileDetailsSection + +Organization logo, name, and slug rows with capability-driven actions. + + diff --git a/packages/swingset/src/stories/organization-profile-details-section.stories.tsx b/packages/swingset/src/stories/organization-profile-details-section.stories.tsx new file mode 100644 index 00000000000..e8a7ed933cd --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-details-section.stories.tsx @@ -0,0 +1,25 @@ +import { OrganizationProfileDetailsSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-details-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-details-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileDetailsSection', + label: 'Organization details', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-details-section.view.tsx', +}; + +export function Default() { + return ( + undefined} + onNameChange={() => undefined} + onUploadLogo={() => undefined} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-general-panel.mdx b/packages/swingset/src/stories/organization-profile-general-panel.mdx new file mode 100644 index 00000000000..70397a56227 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general-panel.mdx @@ -0,0 +1,23 @@ +import * as Stories from './organization-profile-general-panel.stories'; + +# OrganizationProfileGeneralPanel + +The General organization panel. It composes the organization details and danger zone sections under the +panel heading. + + diff --git a/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx new file mode 100644 index 00000000000..32de41a8aa0 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-general-panel.stories.tsx @@ -0,0 +1,30 @@ +import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-general-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-general-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileGeneralPanel', + label: 'General panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-general-panel.view.tsx', +}; + +export function Default() { + const [name, setName] = useState('Clerk'); + + return ( + undefined} + onDeleteOrganization={() => undefined} + onLeaveOrganization={() => undefined} + onNameChange={setName} + onUploadLogo={() => undefined} + /> + ); +} From 6f306af8089b58cb09c59c1561809235ed562aaf Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 10:12:12 -0600 Subject: [PATCH 04/25] feat(ui): add organization members collection --- ...nization-profile-members-section.styles.ts | 151 ++++++++++ ...anization-profile-members-section.view.tsx | 280 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-members-section.styles.ts create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.styles.ts b/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.styles.ts new file mode 100644 index 00000000000..4757e7684c6 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.styles.ts @@ -0,0 +1,151 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; + +export const styles = stylex.create({ + actionCell: { + textAlign: 'end', + width: '11rem', + }, + avatar: { + flexShrink: 0, + }, + 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', + }, + member: { + gap: space['3'], + alignItems: 'center', + display: 'flex', + minWidth: 0, + }, + memberColumn: { + width: '43%', + }, + memberEmail: { + overflow: 'hidden', + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + memberIdentity: { + minWidth: 0, + }, + memberName: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + fontWeight: fontWeightVars['--cl-font-medium'], + }, + 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', + color: colorVars['--cl-color-neutral-faded'], + display: 'flex', + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + requestActions: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + }, + roleButton: { + paddingInline: 0, + }, + row: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + root: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + 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%', + }, +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx new file mode 100644 index 00000000000..b18b09889c2 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx @@ -0,0 +1,280 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Avatar } from '../components/avatar'; +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './organization-profile-members-section.styles'; + +export type OrganizationProfileMemberStatus = 'active' | 'invited' | 'request'; + +export interface OrganizationProfileMember { + id: string; + name: string; + emailAddress: string; + status: OrganizationProfileMemberStatus; + addedAtLabel?: string; + imageUrl?: string; + initials?: string; + roleLabel?: string; +} + +export interface OrganizationProfileMembersPagination { + page: number; + pageCount: number; + pageSize: number; + pageSizeOptions?: readonly number[]; +} + +export interface OrganizationProfileMembersSectionViewProps { + members: OrganizationProfileMember[]; + selectedIds: readonly string[]; + pagination?: OrganizationProfileMembersPagination; + onAcceptRequest?: (id: string) => void; + onDeclineRequest?: (id: string) => void; + onManageMember?: (id: string) => void; + onManageRole?: (id: string) => void; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onSelectionChange: (ids: string[]) => void; +} + +export function OrganizationProfileMembersSectionView({ + members, + selectedIds, + pagination, + onAcceptRequest, + onDeclineRequest, + onManageMember, + onManageRole, + onPageChange, + onPageSizeChange, + onSelectionChange, +}: OrganizationProfileMembersSectionViewProps): ReactElement { + const allSelected = members.length > 0 && members.every(member => selectedIds.includes(member.id)); + + const toggleAll = () => { + onSelectionChange(allSelected ? [] : members.map(member => member.id)); + }; + + const toggleOne = (id: string) => { + onSelectionChange( + selectedIds.includes(id) ? selectedIds.filter(selectedId => selectedId !== id) : [...selectedIds, id], + ); + }; + + return ( +
+ {/* TODO: Replace this inline implementation with the Mosaic Table component. */} +
+
+ + + + + + + + + + + {members.length > 0 ? ( + members.map(member => ( + + + + + + + + )) + ) : ( + + + + )} + +
+ {/* TODO: Replace these inline selection controls with the Mosaic Checkbox component. */} + + + Name + + Added + + Role + +
+ toggleOne(member.id)} + /> + +
+ + + {member.initials ?? member.name.slice(0, 1)} + +
+
+ {member.name} + {member.status === 'request' ? Request : null} + {member.status === 'invited' ? Invited : null} +
+
{member.emailAddress}
+
+
+
{member.addedAtLabel} + {member.status !== 'request' && member.roleLabel ? ( + onManageRole ? ( + + ) : ( + member.roleLabel + ) + ) : null} + + {member.status === 'request' ? ( +
+ {onDeclineRequest ? ( + + ) : null} + {onAcceptRequest ? ( + + ) : null} +
+ ) : onManageMember ? ( + + ) : null} +
+ No members found +
+
+
+ {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. +
+
+ + {`${pagination.page} of ${pagination.pageCount}`} + +
+ +
+ ) : null} +
+ ); +} From 1e3320f6d89dfc0ff0ad8ab13bd2de378d210f5b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 10:12:17 -0600 Subject: [PATCH 05/25] feat(ui): add organization members panel --- ...zation-profile-members-panel.view.test.tsx | 134 ++++++++++++++++++ ...ganization-profile-members-panel.styles.ts | 43 ++++++ ...rganization-profile-members-panel.view.tsx | 81 +++++++++++ packages/ui/src/mosaic/styles/index.ts | 9 ++ 4 files changed, 267 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.styles.ts create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx new file mode 100644 index 00000000000..f1adaa2631b --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx @@ -0,0 +1,134 @@ +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 { OrganizationProfileMembersPanelView } from '../organization-profile-members-panel.view'; + +const members = [ + { + id: 'request', + name: 'Preston Booth', + emailAddress: 'preston@clerk.dev', + status: 'request' as const, + }, + { + id: 'invited', + name: 'Bertram Gilfoyle', + emailAddress: 'gilfoyle@clerk.dev', + status: 'invited' as const, + addedAtLabel: '5/11/26', + roleLabel: 'Member', + }, + { + id: 'active', + name: 'Dinesh Chugtai', + emailAddress: 'dinesh@clerk.dev', + status: 'active' as const, + addedAtLabel: '5/11/26', + roleLabel: 'Member', + }, +]; + +function renderView(overrides: Partial> = {}) { + const props = { + members, + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + ...overrides, + }; + + return { + ...render( + + + , + ), + props, + }; +} + +describe('OrganizationProfileMembersPanelView', () => { + it('renders member states and metadata', () => { + renderView(); + + expect(screen.getByRole('heading', { level: 3, name: 'Members' })).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search members' })).toBeInTheDocument(); + expect(screen.getByText('Request')).toBeInTheDocument(); + expect(screen.getByText('Invited')).toBeInTheDocument(); + expect(screen.getByText('Dinesh Chugtai')).toBeInTheDocument(); + }); + + it('forwards toolbar, selection, request, role, and row actions', async () => { + const callbacks = { + onAcceptRequest: vi.fn(), + onDeclineRequest: vi.fn(), + onFilter: vi.fn(), + onInvite: vi.fn(), + onManageMember: vi.fn(), + onManageRole: vi.fn(), + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + }; + const user = userEvent.setup(); + + renderView(callbacks); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search members' }), { target: { value: 'preston' } }); + await user.click(screen.getByRole('button', { name: 'Filter members' })); + await user.click(screen.getByRole('button', { name: 'Invite' })); + await user.click(screen.getByRole('checkbox', { name: 'Select Preston Booth' })); + await user.click(screen.getByRole('checkbox', { name: 'Select all members' })); + await user.click(screen.getByRole('button', { name: 'Decline' })); + await user.click(screen.getByRole('button', { name: 'Accept' })); + await user.click(screen.getByRole('button', { name: 'Change role for Dinesh Chugtai' })); + await user.click(screen.getByRole('button', { name: 'Manage Bertram Gilfoyle' })); + await user.click(screen.getByRole('button', { name: 'Manage Dinesh Chugtai' })); + + expect(callbacks.onSearchChange).toHaveBeenCalledWith('preston'); + expect(callbacks.onFilter).toHaveBeenCalledOnce(); + expect(callbacks.onInvite).toHaveBeenCalledOnce(); + expect(callbacks.onSelectionChange).toHaveBeenNthCalledWith(1, ['request']); + expect(callbacks.onSelectionChange).toHaveBeenNthCalledWith(2, ['request', 'invited', 'active']); + expect(callbacks.onDeclineRequest).toHaveBeenCalledWith('request'); + expect(callbacks.onAcceptRequest).toHaveBeenCalledWith('request'); + expect(callbacks.onManageRole).toHaveBeenCalledWith('active'); + expect(callbacks.onManageMember).toHaveBeenNthCalledWith(1, 'invited'); + expect(callbacks.onManageMember).toHaveBeenNthCalledWith(2, 'active'); + }); + + it('forwards pagination changes and renders an empty state', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + const user = userEvent.setup(); + + const { rerender } = renderView({ + pagination: { page: 2, pageCount: 3, pageSize: 10, pageSizeOptions: [10, 25] }, + onPageChange, + onPageSizeChange, + }); + + await user.click(screen.getByRole('button', { name: 'Previous members page' })); + await user.click(screen.getByRole('button', { name: 'Next members 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); + + rerender( + + undefined} + onSelectionChange={() => undefined} + /> + , + ); + expect(screen.getByText('No members found')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.styles.ts b/packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.styles.ts new file mode 100644 index 00000000000..ab0252567da --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.styles.ts @@ -0,0 +1,43 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + filterButton: { + flexShrink: 0, + }, + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + search: { + paddingInlineStart: space['8'], + }, + searchGroup: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + minWidth: 0, + }, + searchIcon: { + color: colorVars['--cl-color-neutral-faded'], + insetInlineStart: space['3'], + pointerEvents: 'none', + position: 'absolute', + transform: 'translateY(-50%)', + top: '50%', + }, + searchWrapper: { + position: 'relative', + width: '17rem', + }, + toolbar: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.view.tsx new file mode 100644 index 00000000000..9b973f3a40d --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.view.tsx @@ -0,0 +1,81 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Button } from '../components/button'; +import { Heading } from '../components/heading'; +import { Icon } from '../components/icon'; +import { Input } from '../components/input'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './organization-profile-members-panel.styles'; +import type { OrganizationProfileMembersSectionViewProps } from './organization-profile-members-section.view'; +import { OrganizationProfileMembersSectionView } from './organization-profile-members-section.view'; + +export interface OrganizationProfileMembersPanelViewProps extends OrganizationProfileMembersSectionViewProps { + searchValue: string; + onFilter?: () => void; + onInvite?: () => void; + onSearchChange: (value: string) => void; +} + +export function OrganizationProfileMembersPanelView({ + searchValue, + onFilter, + onInvite, + onSearchChange, + ...sectionProps +}: OrganizationProfileMembersPanelViewProps): ReactElement { + return ( +
+

} + size='2xl' + > + Members + +
+
+
+ + onSearchChange(event.currentTarget.value)} + /> +
+ {onFilter ? ( + + ) : null} +
+ {onInvite ? ( + + ) : null} +
+ +

+ ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index cfe79927cc5..c4c68bcf80d 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -112,6 +112,15 @@ export { OrganizationProfileDetailsSectionView } from '../organization-profile/o export type { OrganizationProfileDetailsSectionViewProps } from '../organization-profile/organization-profile-details-section.view'; export { OrganizationProfileGeneralPanelView } from '../organization-profile/organization-profile-general-panel.view'; export type { OrganizationProfileGeneralPanelViewProps } from '../organization-profile/organization-profile-general-panel.view'; +export { OrganizationProfileMembersPanelView } from '../organization-profile/organization-profile-members-panel.view'; +export type { OrganizationProfileMembersPanelViewProps } from '../organization-profile/organization-profile-members-panel.view'; +export { OrganizationProfileMembersSectionView } from '../organization-profile/organization-profile-members-section.view'; +export type { + OrganizationProfileMember, + OrganizationProfileMembersPagination, + OrganizationProfileMembersSectionViewProps, + OrganizationProfileMemberStatus, +} from '../organization-profile/organization-profile-members-section.view'; export type { OrganizationProfilePanelId } from '../organization-profile/organization-profile-sidebar'; export { UserPageView } from '../user-profile/user-page.view'; export type { UserPagePanels, UserPageViewProps } from '../user-profile/user-page.view'; From f94d81e8bb734380d2a8eef866d5404a58c7501b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 10:12:23 -0600 Subject: [PATCH 06/25] docs(swingset): add organization members compositions --- .../swingset/src/components/DocsViewer.tsx | 4 + packages/swingset/src/lib/registry.ts | 20 +++++ .../src/stories/organization-page.mdx | 8 +- .../src/stories/organization-page.stories.tsx | 24 +++++- .../organization-profile-members-panel.mdx | 17 +++++ ...nization-profile-members-panel.stories.tsx | 40 ++++++++++ .../organization-profile-members-section.mdx | 16 ++++ ...zation-profile-members-section.stories.tsx | 75 +++++++++++++++++++ 8 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 packages/swingset/src/stories/organization-profile-members-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-members-panel.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-members-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-members-section.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 38de15d256a..bc33faf6504 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -13,10 +13,14 @@ const docModules: Record> = { 'organization-profile': { 'organization-page': dynamic(() => import('../stories/organization-page.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), + 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), 'organization-profile-details-section': dynamic( () => import('../stories/organization-profile-details-section.mdx'), ), 'organization-profile-danger-section': dynamic(() => import('../stories/organization-profile-danger-section.mdx')), + 'organization-profile-members-section': dynamic( + () => import('../stories/organization-profile-members-section.mdx'), + ), }, 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ed35de523ef..358c2f30b87 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -74,6 +74,15 @@ import { Default as OrganizationProfileGeneralPanelDefault, meta as organizationProfileGeneralPanelMeta, } from '../stories/organization-profile-general-panel.stories'; +import { + Default as OrganizationProfileMembersPanelDefault, + meta as organizationProfileMembersPanelMeta, +} from '../stories/organization-profile-members-panel.stories'; +import { + Default as OrganizationProfileMembersSectionDefault, + Empty as OrganizationProfileMembersSectionEmpty, + meta as organizationProfileMembersSectionMeta, +} from '../stories/organization-profile-members-section.stories'; import { meta as otpMeta } from '../stories/otp.stories'; import { Alignment as PopoverComponentAlignment, @@ -303,6 +312,10 @@ const organizationProfileGeneralPanelModule: StoryModule = { meta: organizationProfileGeneralPanelMeta, Default: OrganizationProfileGeneralPanelDefault, }; +const organizationProfileMembersPanelModule: StoryModule = { + meta: organizationProfileMembersPanelMeta, + Default: OrganizationProfileMembersPanelDefault, +}; const organizationProfileDetailsSectionModule: StoryModule = { meta: organizationProfileDetailsSectionMeta, Default: OrganizationProfileDetailsSectionDefault, @@ -311,6 +324,11 @@ const organizationProfileDangerSectionModule: StoryModule = { meta: organizationProfileDangerSectionMeta, Default: OrganizationProfileDangerSectionDefault, }; +const organizationProfileMembersSectionModule: StoryModule = { + meta: organizationProfileMembersSectionMeta, + Default: OrganizationProfileMembersSectionDefault, + Empty: OrganizationProfileMembersSectionEmpty, +}; const userProfileApiKeysPanelModule: StoryModule = { meta: userProfileApiKeysPanelMeta, @@ -391,9 +409,11 @@ export const registry: StoryModule[] = [ organizationPageModule, // Organization Profile · Panels organizationProfileGeneralPanelModule, + organizationProfileMembersPanelModule, // Organization Profile · Sections organizationProfileDetailsSectionModule, organizationProfileDangerSectionModule, + organizationProfileMembersSectionModule, // User Profile userPageModule, // User Profile · Panels diff --git a/packages/swingset/src/stories/organization-page.mdx b/packages/swingset/src/stories/organization-page.mdx index 8a853f2203c..534db1a5b8c 100644 --- a/packages/swingset/src/stories/organization-page.mdx +++ b/packages/swingset/src/stories/organization-page.mdx @@ -2,8 +2,7 @@ import * as Stories from './organization-page.stories'; # OrganizationPage -The Organization profile shell from the General frame. It owns the organization navigation and composes -independent panels; the General panel is split into Organization details and Danger zone sections. +The Organization profile shell. It owns the organization navigation and composes independent panels. diff --git a/packages/swingset/src/stories/organization-page.stories.tsx b/packages/swingset/src/stories/organization-page.stories.tsx index aa2adf4962e..78956d12085 100644 --- a/packages/swingset/src/stories/organization-page.stories.tsx +++ b/packages/swingset/src/stories/organization-page.stories.tsx @@ -1,11 +1,14 @@ import type { OrganizationPagePanels } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; import { OrganizationPageView } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-general-panel.view'; +import { OrganizationProfileMembersPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-panel.view'; import type { OrganizationProfilePanelId } from '@clerk/ui/mosaic/organization-profile/organization-profile-sidebar'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { members } from './organization-profile-members-section.stories'; + export { default as __source } from './organization-page.stories?raw'; export const meta: StoryMeta = { @@ -23,6 +26,8 @@ function PlaceholderPanel({ title }: { title: string }) { export function Default() { const [activePanel, setActivePanel] = useState('general'); const [name, setName] = useState('Clerk'); + const [memberSearch, setMemberSearch] = useState(''); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); const panels: OrganizationPagePanels = { general: ( @@ -36,7 +41,24 @@ export function Default() { onUploadLogo={() => undefined} /> ), - members: , + members: ( + undefined} + onDeclineRequest={() => undefined} + onFilter={() => undefined} + onInvite={() => undefined} + onManageMember={() => undefined} + onManageRole={() => undefined} + onPageChange={() => undefined} + onPageSizeChange={() => undefined} + onSearchChange={setMemberSearch} + onSelectionChange={setSelectedMemberIds} + /> + ), security: , billing: , 'api-keys': , diff --git a/packages/swingset/src/stories/organization-profile-members-panel.mdx b/packages/swingset/src/stories/organization-profile-members-panel.mdx new file mode 100644 index 00000000000..ce19f8f119d --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.mdx @@ -0,0 +1,17 @@ +import * as Stories from './organization-profile-members-panel.stories'; + +# OrganizationProfileMembersPanel + +The Members organization panel. It composes the search and invite toolbar with the member collection. + + diff --git a/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx new file mode 100644 index 00000000000..c6f430a792c --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-panel.stories.tsx @@ -0,0 +1,40 @@ +import { OrganizationProfileMembersPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { members } from './organization-profile-members-section.stories'; + +export { default as __source } from './organization-profile-members-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileMembersPanel', + label: 'Members panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.view.tsx', +}; + +export function Default() { + const [searchValue, setSearchValue] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + + return ( + undefined} + onDeclineRequest={() => undefined} + onFilter={() => undefined} + onInvite={() => undefined} + onManageMember={() => undefined} + onManageRole={() => undefined} + onPageChange={() => undefined} + onPageSizeChange={() => undefined} + onSearchChange={setSearchValue} + onSelectionChange={setSelectedIds} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-members-section.mdx b/packages/swingset/src/stories/organization-profile-members-section.mdx new file mode 100644 index 00000000000..3e7cb3f1f2e --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-section.mdx @@ -0,0 +1,16 @@ +import * as Stories from './organization-profile-members-section.stories'; + +# OrganizationProfileMembersSection + +The member collection used by the Organization Profile Members panel. It represents active members, pending invitations, +membership requests, selection, row actions, and pagination. + + + + diff --git a/packages/swingset/src/stories/organization-profile-members-section.stories.tsx b/packages/swingset/src/stories/organization-profile-members-section.stories.tsx new file mode 100644 index 00000000000..b0667df03d7 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-members-section.stories.tsx @@ -0,0 +1,75 @@ +import type { OrganizationProfileMember } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-section.view'; +import { OrganizationProfileMembersSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-section.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-members-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileMembersSection', + label: 'Members collection', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx', +}; + +export const members: OrganizationProfileMember[] = [ + { + id: 'request', + name: 'Preston Booth', + emailAddress: 'preston@clerk.dev', + status: 'request', + initials: 'PB', + }, + { + id: 'invited', + name: 'Bertram Gilfoyle', + emailAddress: 'gilfoyle@clerk.dev', + status: 'invited', + addedAtLabel: '5/11/26', + initials: 'BG', + roleLabel: 'Member', + }, + ...Array.from( + { length: 7 }, + (_, index): OrganizationProfileMember => ({ + id: `member-${index + 1}`, + name: 'Preston Booth', + emailAddress: 'preston@clerk.dev', + status: 'active', + addedAtLabel: '5/11/26', + initials: 'PB', + roleLabel: 'Member', + }), + ), +]; + +export function Default() { + const [selectedIds, setSelectedIds] = useState([]); + + return ( + undefined} + onDeclineRequest={() => undefined} + onManageMember={() => undefined} + onManageRole={() => undefined} + onPageChange={() => undefined} + onPageSizeChange={() => undefined} + onSelectionChange={setSelectedIds} + /> + ); +} + +export function Empty() { + return ( + undefined} + /> + ); +} From 1148f5bb9c99000a10db018d9a5c0c9e78676cd6 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:31:55 -0600 Subject: [PATCH 07/25] feat(ui): add organization security sections --- .../organization-profile-security.styles.ts | 31 +++++ .../organization-profile-sso-section.view.tsx | 106 +++++++++++++++++ ...-profile-verified-domains-section.view.tsx | 107 ++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-security.styles.ts create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-sso-section.view.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-verified-domains-section.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-security.styles.ts b/packages/ui/src/mosaic/organization-profile/organization-profile-security.styles.ts new file mode 100644 index 00000000000..88be5491624 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-security.styles.ts @@ -0,0 +1,31 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + icon: { + display: 'block', + height: space['5'], + width: space['5'], + }, + iconFrame: { + borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', + borderRadius: radiusVars['--cl-radius-lg'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-background'], + }, + root: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + sections: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-sso-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-sso-section.view.tsx new file mode 100644 index 00000000000..7aa060c998e --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-sso-section.view.tsx @@ -0,0 +1,106 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { styles } from './organization-profile-security.styles'; + +export interface OrganizationProfileSsoConnection { + id: string; + domain: string; + protocol: string; + iconUrl?: string; +} + +export interface OrganizationProfileSsoSectionViewProps { + connections: OrganizationProfileSsoConnection[]; + onAdd?: () => void; + onManage?: (id: string) => void; +} + +export function OrganizationProfileSsoSectionView({ + connections, + onAdd, + onManage, +}: OrganizationProfileSsoSectionViewProps): ReactElement { + return ( + + Authentication + + + + + SSO + + Require members with a matching email domain to sign in through your identity provider. + + + {onAdd ? ( + + + + ) : null} + + {connections.length > 0 ? ( + + {connections.map(connection => ( + + + {connection.iconUrl ? ( + + ) : ( + + )} + + + {connection.domain} + {connection.protocol} + + {onManage ? ( + + + + ) : null} + + ))} + + ) : null} + + + + ); +} diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-verified-domains-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-verified-domains-section.view.tsx new file mode 100644 index 00000000000..fd47f64bead --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-verified-domains-section.view.tsx @@ -0,0 +1,107 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { styles } from './organization-profile-security.styles'; + +export interface OrganizationProfileVerifiedDomain { + id: string; + name: string; + enrollmentModeLabel?: string; + iconUrl?: string; +} + +export interface OrganizationProfileVerifiedDomainsSectionViewProps { + domains: OrganizationProfileVerifiedDomain[]; + onAdd?: () => void; + onManage?: (id: string) => void; +} + +export function OrganizationProfileVerifiedDomainsSectionView({ + domains, + onAdd, + onManage, +}: OrganizationProfileVerifiedDomainsSectionViewProps): ReactElement { + return ( + + Access + + + + + Verified domains + + Allow users to join automatically or request access via verified email domain. + + + {onAdd ? ( + + + + ) : null} + + {domains.length > 0 ? ( + + {domains.map(domain => ( + + + {domain.iconUrl ? ( + + ) : ( + + )} + + + {domain.name} + + {domain.enrollmentModeLabel ? {domain.enrollmentModeLabel} : null} + {onManage ? ( + + + + ) : null} + + ))} + + ) : null} + + + + ); +} From 11e04eeed2ead2b8c9975c848fa5b30d1c0c1ebc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:31:59 -0600 Subject: [PATCH 08/25] feat(ui): add organization security panel --- ...ation-profile-security-panel.view.test.tsx | 72 +++++++++++++++++++ ...ganization-profile-security-panel.view.tsx | 35 +++++++++ packages/ui/src/mosaic/styles/index.ts | 12 ++++ 3 files changed, 119 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-security-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-security-panel.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-security-panel.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-security-panel.view.test.tsx new file mode 100644 index 00000000000..901e1d4e30c --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-security-panel.view.test.tsx @@ -0,0 +1,72 @@ +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 { OrganizationProfileSecurityPanelView } from '../organization-profile-security-panel.view'; + +function renderView(overrides: Partial> = {}) { + const props = { + sso: { + connections: [{ id: 'sso', domain: 'clerk.dev', protocol: 'SAML' }], + onAdd: vi.fn(), + onManage: vi.fn(), + }, + verifiedDomains: { + domains: [{ id: 'domain', name: 'clerk.dev', enrollmentModeLabel: 'Automatic invitations' }], + onAdd: vi.fn(), + onManage: vi.fn(), + }, + ...overrides, + }; + + return { + ...render( + + + , + ), + props, + }; +} + +describe('OrganizationProfileSecurityPanelView', () => { + it('composes authentication and access sections', () => { + renderView(); + + 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: 'Access' })).toBeInTheDocument(); + expect(screen.getByText('SSO')).toBeInTheDocument(); + expect(screen.getByText('Verified domains')).toBeInTheDocument(); + expect(screen.getByText('SAML')).toBeInTheDocument(); + expect(screen.getByText('Automatic invitations')).toBeInTheDocument(); + }); + + it('forwards add and manage actions to each section', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + const addButtons = screen.getAllByRole('button', { name: 'Add' }); + await user.click(addButtons[0]); + await user.click(screen.getByRole('button', { name: 'Manage SSO for clerk.dev' })); + await user.click(addButtons[1]); + await user.click(screen.getByRole('button', { name: 'Manage clerk.dev' })); + + expect(props.sso.onAdd).toHaveBeenCalledOnce(); + expect(props.sso.onManage).toHaveBeenCalledWith('sso'); + expect(props.verifiedDomains.onAdd).toHaveBeenCalledOnce(); + expect(props.verifiedDomains.onManage).toHaveBeenCalledWith('domain'); + }); + + it('keeps section headers when no connections or domains exist', () => { + renderView({ + sso: { connections: [] }, + verifiedDomains: { domains: [] }, + }); + + expect(screen.getByText('SSO')).toBeInTheDocument(); + expect(screen.getByText('Verified domains')).toBeInTheDocument(); + expect(screen.queryByText('SAML')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-security-panel.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-security-panel.view.tsx new file mode 100644 index 00000000000..160bc58f1ee --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-security-panel.view.tsx @@ -0,0 +1,35 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './organization-profile-security.styles'; +import type { OrganizationProfileSsoSectionViewProps } from './organization-profile-sso-section.view'; +import { OrganizationProfileSsoSectionView } from './organization-profile-sso-section.view'; +import type { OrganizationProfileVerifiedDomainsSectionViewProps } from './organization-profile-verified-domains-section.view'; +import { OrganizationProfileVerifiedDomainsSectionView } from './organization-profile-verified-domains-section.view'; + +export interface OrganizationProfileSecurityPanelViewProps { + sso: OrganizationProfileSsoSectionViewProps; + verifiedDomains: OrganizationProfileVerifiedDomainsSectionViewProps; +} + +export function OrganizationProfileSecurityPanelView({ + sso, + verifiedDomains, +}: OrganizationProfileSecurityPanelViewProps): ReactElement { + return ( +
+

} + size='2xl' + > + Security + +
+ + +
+

+ ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index c4c68bcf80d..2e739b95ae6 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -121,6 +121,18 @@ export type { OrganizationProfileMembersSectionViewProps, OrganizationProfileMemberStatus, } from '../organization-profile/organization-profile-members-section.view'; +export { OrganizationProfileSecurityPanelView } from '../organization-profile/organization-profile-security-panel.view'; +export type { OrganizationProfileSecurityPanelViewProps } from '../organization-profile/organization-profile-security-panel.view'; +export { OrganizationProfileSsoSectionView } from '../organization-profile/organization-profile-sso-section.view'; +export type { + OrganizationProfileSsoConnection, + OrganizationProfileSsoSectionViewProps, +} from '../organization-profile/organization-profile-sso-section.view'; +export { OrganizationProfileVerifiedDomainsSectionView } from '../organization-profile/organization-profile-verified-domains-section.view'; +export type { + OrganizationProfileVerifiedDomain, + OrganizationProfileVerifiedDomainsSectionViewProps, +} from '../organization-profile/organization-profile-verified-domains-section.view'; export type { OrganizationProfilePanelId } from '../organization-profile/organization-profile-sidebar'; export { UserPageView } from '../user-profile/user-page.view'; export type { UserPagePanels, UserPageViewProps } from '../user-profile/user-page.view'; From a9980f3c390756b0e69eb4b034f56e7050f58868 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:32:06 -0600 Subject: [PATCH 09/25] docs(swingset): add organization security compositions --- .../swingset/src/components/DocsViewer.tsx | 5 +++ packages/swingset/src/lib/registry.ts | 31 ++++++++++++++++++ .../src/stories/organization-page.mdx | 5 +++ .../src/stories/organization-page.stories.tsx | 16 +++++++++- .../organization-profile-security-panel.mdx | 22 +++++++++++++ ...ization-profile-security-panel.stories.tsx | 30 +++++++++++++++++ .../organization-profile-sso-section.mdx | 15 +++++++++ ...ganization-profile-sso-section.stories.tsx | 32 +++++++++++++++++++ ...ation-profile-verified-domains-section.mdx | 15 +++++++++ ...ofile-verified-domains-section.stories.tsx | 32 +++++++++++++++++++ 10 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 packages/swingset/src/stories/organization-profile-security-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-security-panel.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-sso-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-sso-section.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-verified-domains-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-verified-domains-section.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index bc33faf6504..b669c568f19 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -14,6 +14,7 @@ const docModules: Record> = { 'organization-page': dynamic(() => import('../stories/organization-page.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), + 'organization-profile-security-panel': dynamic(() => import('../stories/organization-profile-security-panel.mdx')), 'organization-profile-details-section': dynamic( () => import('../stories/organization-profile-details-section.mdx'), ), @@ -21,6 +22,10 @@ const docModules: Record> = { 'organization-profile-members-section': dynamic( () => import('../stories/organization-profile-members-section.mdx'), ), + 'organization-profile-sso-section': dynamic(() => import('../stories/organization-profile-sso-section.mdx')), + 'organization-profile-verified-domains-section': dynamic( + () => import('../stories/organization-profile-verified-domains-section.mdx'), + ), }, 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 358c2f30b87..9225aac3e8a 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -83,6 +83,20 @@ import { Empty as OrganizationProfileMembersSectionEmpty, meta as organizationProfileMembersSectionMeta, } from '../stories/organization-profile-members-section.stories'; +import { + Default as OrganizationProfileSecurityPanelDefault, + meta as organizationProfileSecurityPanelMeta, +} from '../stories/organization-profile-security-panel.stories'; +import { + Default as OrganizationProfileSsoSectionDefault, + Empty as OrganizationProfileSsoSectionEmpty, + meta as organizationProfileSsoSectionMeta, +} from '../stories/organization-profile-sso-section.stories'; +import { + Default as OrganizationProfileVerifiedDomainsSectionDefault, + Empty as OrganizationProfileVerifiedDomainsSectionEmpty, + meta as organizationProfileVerifiedDomainsSectionMeta, +} from '../stories/organization-profile-verified-domains-section.stories'; import { meta as otpMeta } from '../stories/otp.stories'; import { Alignment as PopoverComponentAlignment, @@ -316,6 +330,10 @@ const organizationProfileMembersPanelModule: StoryModule = { meta: organizationProfileMembersPanelMeta, Default: OrganizationProfileMembersPanelDefault, }; +const organizationProfileSecurityPanelModule: StoryModule = { + meta: organizationProfileSecurityPanelMeta, + Default: OrganizationProfileSecurityPanelDefault, +}; const organizationProfileDetailsSectionModule: StoryModule = { meta: organizationProfileDetailsSectionMeta, Default: OrganizationProfileDetailsSectionDefault, @@ -329,6 +347,16 @@ const organizationProfileMembersSectionModule: StoryModule = { Default: OrganizationProfileMembersSectionDefault, Empty: OrganizationProfileMembersSectionEmpty, }; +const organizationProfileSsoSectionModule: StoryModule = { + meta: organizationProfileSsoSectionMeta, + Default: OrganizationProfileSsoSectionDefault, + Empty: OrganizationProfileSsoSectionEmpty, +}; +const organizationProfileVerifiedDomainsSectionModule: StoryModule = { + meta: organizationProfileVerifiedDomainsSectionMeta, + Default: OrganizationProfileVerifiedDomainsSectionDefault, + Empty: OrganizationProfileVerifiedDomainsSectionEmpty, +}; const userProfileApiKeysPanelModule: StoryModule = { meta: userProfileApiKeysPanelMeta, @@ -410,10 +438,13 @@ export const registry: StoryModule[] = [ // Organization Profile · Panels organizationProfileGeneralPanelModule, organizationProfileMembersPanelModule, + organizationProfileSecurityPanelModule, // Organization Profile · Sections organizationProfileDetailsSectionModule, organizationProfileDangerSectionModule, organizationProfileMembersSectionModule, + organizationProfileSsoSectionModule, + organizationProfileVerifiedDomainsSectionModule, // User Profile userPageModule, // User Profile · Panels diff --git a/packages/swingset/src/stories/organization-page.mdx b/packages/swingset/src/stories/organization-page.mdx index 534db1a5b8c..2aaca4745fd 100644 --- a/packages/swingset/src/stories/organization-page.mdx +++ b/packages/swingset/src/stories/organization-page.mdx @@ -18,5 +18,10 @@ The Organization profile shell. It owns the organization navigation and composes href: '/organization-profile/organization-profile-members-panel', layer: 'Organization Profile', }, + { + name: 'Security panel', + href: '/organization-profile/organization-profile-security-panel', + layer: 'Organization Profile', + }, ]} /> diff --git a/packages/swingset/src/stories/organization-page.stories.tsx b/packages/swingset/src/stories/organization-page.stories.tsx index 78956d12085..ef8aae89b9f 100644 --- a/packages/swingset/src/stories/organization-page.stories.tsx +++ b/packages/swingset/src/stories/organization-page.stories.tsx @@ -2,6 +2,7 @@ import type { OrganizationPagePanels } from '@clerk/ui/mosaic/organization-profi import { OrganizationPageView } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-general-panel.view'; import { OrganizationProfileMembersPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-panel.view'; +import { OrganizationProfileSecurityPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-security-panel.view'; import type { OrganizationProfilePanelId } from '@clerk/ui/mosaic/organization-profile/organization-profile-sidebar'; import { useState } from 'react'; @@ -59,7 +60,20 @@ export function Default() { onSelectionChange={setSelectedMemberIds} /> ), - security: , + security: ( + undefined, + onManage: () => undefined, + }} + verifiedDomains={{ + domains: [{ id: 'domain', name: 'clerk.dev', enrollmentModeLabel: 'Automatic invitations' }], + onAdd: () => undefined, + onManage: () => undefined, + }} + /> + ), billing: , 'api-keys': , }; diff --git a/packages/swingset/src/stories/organization-profile-security-panel.mdx b/packages/swingset/src/stories/organization-profile-security-panel.mdx new file mode 100644 index 00000000000..6ab96afd477 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-security-panel.mdx @@ -0,0 +1,22 @@ +import * as Stories from './organization-profile-security-panel.stories'; + +# OrganizationProfileSecurityPanel + +The Security organization panel. It composes SSO authentication and verified-domain access settings. + + diff --git a/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx new file mode 100644 index 00000000000..ccaa072e8fc --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-security-panel.stories.tsx @@ -0,0 +1,30 @@ +import { OrganizationProfileSecurityPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-security-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-security-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileSecurityPanel', + label: 'Security panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-security-panel.view.tsx', +}; + +export function Default() { + return ( + undefined, + onManage: () => undefined, + }} + verifiedDomains={{ + domains: [{ id: 'domain', name: 'clerk.dev', enrollmentModeLabel: 'Automatic invitations' }], + onAdd: () => undefined, + onManage: () => undefined, + }} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-sso-section.mdx b/packages/swingset/src/stories/organization-profile-sso-section.mdx new file mode 100644 index 00000000000..1ec7efe946b --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-sso-section.mdx @@ -0,0 +1,15 @@ +import * as Stories from './organization-profile-sso-section.stories'; + +# OrganizationProfileSsoSection + +The Authentication section for configured organization SSO connections. + + + + diff --git a/packages/swingset/src/stories/organization-profile-sso-section.stories.tsx b/packages/swingset/src/stories/organization-profile-sso-section.stories.tsx new file mode 100644 index 00000000000..5f778ed7d2d --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-sso-section.stories.tsx @@ -0,0 +1,32 @@ +import { OrganizationProfileSsoSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-sso-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-sso-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileSsoSection', + label: 'SSO', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-sso-section.view.tsx', +}; + +export function Default() { + return ( + undefined} + onManage={() => undefined} + /> + ); +} + +export function Empty() { + return ( + undefined} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-verified-domains-section.mdx b/packages/swingset/src/stories/organization-profile-verified-domains-section.mdx new file mode 100644 index 00000000000..d739a18e22a --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-verified-domains-section.mdx @@ -0,0 +1,15 @@ +import * as Stories from './organization-profile-verified-domains-section.stories'; + +# OrganizationProfileVerifiedDomainsSection + +The Access section for verified organization domains and their enrollment policy. + + + + diff --git a/packages/swingset/src/stories/organization-profile-verified-domains-section.stories.tsx b/packages/swingset/src/stories/organization-profile-verified-domains-section.stories.tsx new file mode 100644 index 00000000000..82527c8b548 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-verified-domains-section.stories.tsx @@ -0,0 +1,32 @@ +import { OrganizationProfileVerifiedDomainsSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-verified-domains-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-verified-domains-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileVerifiedDomainsSection', + label: 'Verified domains', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-verified-domains-section.view.tsx', +}; + +export function Default() { + return ( + undefined} + onManage={() => undefined} + /> + ); +} + +export function Empty() { + return ( + undefined} + /> + ); +} From d1a03eecbf5a1dcf767e69ac420b76349d9f29df Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:47:43 -0600 Subject: [PATCH 10/25] feat(ui): add organization billing sections --- .../organization-profile-billing.styles.ts | 201 ++++++++++++++++++ ...nization-profile-invoices-section.view.tsx | 188 ++++++++++++++++ ...n-profile-payment-methods-section.view.tsx | 113 ++++++++++ ...tion-profile-subscription-section.view.tsx | 117 ++++++++++ packages/ui/src/mosaic/styles/index.ts | 17 ++ 5 files changed, 636 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-billing.styles.ts create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-invoices-section.view.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-payment-methods-section.view.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-subscription-section.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-billing.styles.ts b/packages/ui/src/mosaic/organization-profile/organization-profile-billing.styles.ts new file mode 100644 index 00000000000..9af4b429312 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-billing.styles.ts @@ -0,0 +1,201 @@ +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', + }, + icon: { + display: 'block', + height: space['5'], + width: space['5'], + }, + iconFrame: { + borderColor: 'light-dark(var(--cl-color-border-faded), var(--cl-color-background))', + borderRadius: radiusVars['--cl-radius-lg'], + borderStyle: 'solid', + borderWidth: '1px', + overflow: 'hidden', + backgroundColor: colorVars['--cl-color-background'], + }, + invoiceColumn: { + width: '40%', + }, + 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'], + }, + lineItem: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + }, + lineItemAmount: { + color: colorVars['--cl-color-card-foreground'], + fontSize: typeScaleVars['--cl-text-sm-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + }, + lineItems: { + gap: space['2'], + display: 'flex', + flexDirection: 'column', + marginBlockStart: space['4'], + }, + pageCount: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-xs-size'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + textAlign: 'center', + minWidth: space['10'], + }, + 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', + }, + planLabel: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + }, + root: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + row: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + seats: { + width: '100%', + }, + seatsActions: { + gap: space['3'], + alignItems: 'center', + display: 'flex', + }, + seatsHeader: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + }, + seatsUsage: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + lineHeight: typeScaleVars['--cl-text-sm-leading'], + whiteSpace: 'nowrap', + }, + sectionHeading: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + }, + sections: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + 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: '18%', + }, + table: { + borderCollapse: 'collapse', + tableLayout: 'fixed', + width: '100%', + }, + tableScroller: { + overflowX: 'auto', + width: '100%', + }, + viewColumn: { + width: '12%', + }, +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-invoices-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-invoices-section.view.tsx new file mode 100644 index 00000000000..a7290a796b6 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-invoices-section.view.tsx @@ -0,0 +1,188 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +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 './organization-profile-billing.styles'; + +export interface OrganizationProfileInvoice { + id: string; + dateLabel: string; + invoiceLabel: string; + amountLabel: string; + statusLabel: string; + statusColor?: BadgeProps['color']; +} + +export interface OrganizationProfileInvoicesPagination { + page: number; + pageCount: number; + pageSize: number; + pageSizeOptions?: readonly number[]; +} + +export interface OrganizationProfileInvoicesSectionViewProps { + invoices: OrganizationProfileInvoice[]; + pagination?: OrganizationProfileInvoicesPagination; + onDownloadAll?: () => void; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onView?: (id: string) => void; +} + +export function OrganizationProfileInvoicesSectionView({ + invoices, + pagination, + onDownloadAll, + onPageChange, + onPageSizeChange, + onView, +}: OrganizationProfileInvoicesSectionViewProps): ReactElement { + return ( + +
+ Invoices + {onDownloadAll ? ( + + ) : null} +
+
+ {/* TODO: Replace this inline implementation with the Mosaic Table component. */} +
+ + + + + + + + + + {invoices.length > 0 ? ( + invoices.map(invoice => ( + + + + + + + )) + ) : ( + + + + )} + +
+ Invoice + + Amount + + Status + +
+
{invoice.dateLabel}
+
{invoice.invoiceLabel}
+
{invoice.amountLabel} + {invoice.statusLabel} + + {onView ? ( + + ) : null} +
+ No invoices yet +
+
+ {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. +
+
+ + + {pagination.page} of {pagination.pageCount} + + +
+ +
+ ) : null} +
+
+ ); +} diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-payment-methods-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-payment-methods-section.view.tsx new file mode 100644 index 00000000000..e2cec167ad6 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-payment-methods-section.view.tsx @@ -0,0 +1,113 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { styles } from './organization-profile-billing.styles'; + +export interface OrganizationProfilePaymentMethod { + id: string; + label: string; + expiryLabel: string; + iconUrl?: string; + isDefault?: boolean; +} + +export interface OrganizationProfilePaymentMethodsSectionViewProps { + paymentMethods: OrganizationProfilePaymentMethod[]; + onAdd?: () => void; + onManage?: (id: string) => void; +} + +export function OrganizationProfilePaymentMethodsSectionView({ + paymentMethods, + onAdd, + onManage, +}: OrganizationProfilePaymentMethodsSectionViewProps): ReactElement { + return ( + + Payment + + + + + Payment methods + + {onAdd ? ( + + + + ) : null} + + + {paymentMethods.length > 0 ? ( + paymentMethods.map(paymentMethod => ( + + + {paymentMethod.iconUrl ? ( + + ) : ( + + )} + + + + {paymentMethod.label} {paymentMethod.isDefault ? Default : null} + + {paymentMethod.expiryLabel} + + {onManage ? ( + + + + ) : null} + + )) + ) : ( + + + No payment methods added + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-subscription-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-subscription-section.view.tsx new file mode 100644 index 00000000000..95cfae5f9ab --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-subscription-section.view.tsx @@ -0,0 +1,117 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { Section } from '../components/section'; +import { styles } from './organization-profile-billing.styles'; + +export interface OrganizationProfileSeatLineItem { + id: string; + label: string; + amountLabel: string; +} + +export interface OrganizationProfileSubscription { + planName: string; + statusLabel?: string; + priceLabel: string; + seatsUsed: number; + seatsTotal: number; + seatLineItems: OrganizationProfileSeatLineItem[]; +} + +export interface OrganizationProfileSubscriptionSectionViewProps { + subscription: OrganizationProfileSubscription; + onChangePlan?: () => void; + onManagePlan?: () => void; + onManageSeats?: () => void; +} + +export function OrganizationProfileSubscriptionSectionView({ + subscription, + onChangePlan, + onManagePlan, + onManageSeats, +}: OrganizationProfileSubscriptionSectionViewProps): ReactElement { + return ( + + Subscription + + + + + + {subscription.planName} + {subscription.statusLabel ? {subscription.statusLabel} : null} + + {subscription.priceLabel} + + {onChangePlan || onManagePlan ? ( + + {onChangePlan ? ( + + ) : null} + {onManagePlan ? ( + + ) : null} + + ) : null} + + + + +
+
+ Seats +
+ + {subscription.seatsUsed} of {subscription.seatsTotal} seats used + + {onManageSeats ? ( + + ) : null} +
+
+
+ {subscription.seatLineItems.map(item => ( +
+ {item.label} + {item.amountLabel} +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 2e739b95ae6..37233c77227 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -121,6 +121,17 @@ export type { OrganizationProfileMembersSectionViewProps, OrganizationProfileMemberStatus, } from '../organization-profile/organization-profile-members-section.view'; +export { OrganizationProfileInvoicesSectionView } from '../organization-profile/organization-profile-invoices-section.view'; +export type { + OrganizationProfileInvoice, + OrganizationProfileInvoicesPagination, + OrganizationProfileInvoicesSectionViewProps, +} from '../organization-profile/organization-profile-invoices-section.view'; +export { OrganizationProfilePaymentMethodsSectionView } from '../organization-profile/organization-profile-payment-methods-section.view'; +export type { + OrganizationProfilePaymentMethod, + OrganizationProfilePaymentMethodsSectionViewProps, +} from '../organization-profile/organization-profile-payment-methods-section.view'; export { OrganizationProfileSecurityPanelView } from '../organization-profile/organization-profile-security-panel.view'; export type { OrganizationProfileSecurityPanelViewProps } from '../organization-profile/organization-profile-security-panel.view'; export { OrganizationProfileSsoSectionView } from '../organization-profile/organization-profile-sso-section.view'; @@ -128,6 +139,12 @@ export type { OrganizationProfileSsoConnection, OrganizationProfileSsoSectionViewProps, } from '../organization-profile/organization-profile-sso-section.view'; +export { OrganizationProfileSubscriptionSectionView } from '../organization-profile/organization-profile-subscription-section.view'; +export type { + OrganizationProfileSeatLineItem, + OrganizationProfileSubscription, + OrganizationProfileSubscriptionSectionViewProps, +} from '../organization-profile/organization-profile-subscription-section.view'; export { OrganizationProfileVerifiedDomainsSectionView } from '../organization-profile/organization-profile-verified-domains-section.view'; export type { OrganizationProfileVerifiedDomain, From de0351d44853bae3dc444695fa8dd8f08ff6a05c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:47:59 -0600 Subject: [PATCH 11/25] feat(ui): add organization billing panel --- ...zation-profile-billing-panel.view.test.tsx | 118 ++++++++++++++++++ ...rganization-profile-billing-panel.view.tsx | 40 ++++++ packages/ui/src/mosaic/styles/index.ts | 2 + 3 files changed, 160 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-billing-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-billing-panel.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-billing-panel.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-billing-panel.view.test.tsx new file mode 100644 index 00000000000..5538e638846 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-billing-panel.view.test.tsx @@ -0,0 +1,118 @@ +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 { OrganizationProfileBillingPanelView } from '../organization-profile-billing-panel.view'; + +function renderView(overrides: Partial> = {}) { + const props = { + subscription: { + subscription: { + planName: 'Pro', + statusLabel: 'Active', + priceLabel: '$200 per month, billed monthly', + seatsUsed: 6, + seatsTotal: 10, + seatLineItems: [ + { id: 'included', label: '5 seats included with Pro', amountLabel: '$0.00' }, + { id: 'additional', label: '1 additional seat', amountLabel: '$19.00' }, + ], + }, + onChangePlan: vi.fn(), + onManagePlan: vi.fn(), + onManageSeats: vi.fn(), + }, + paymentMethods: { + paymentMethods: [ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + { id: 'mastercard', label: 'Mastercard •••• 1212', expiryLabel: 'Expires 02/2029' }, + ], + onAdd: vi.fn(), + onManage: vi.fn(), + }, + invoices: { + invoices: [ + { + id: 'invoice', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_0s64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + ], + pagination: { page: 1, pageCount: 2, pageSize: 10, pageSizeOptions: [10, 25] }, + onDownloadAll: vi.fn(), + onPageChange: vi.fn(), + onPageSizeChange: vi.fn(), + onView: vi.fn(), + }, + ...overrides, + }; + + return { + ...render( + + + , + ), + props, + }; +} + +describe('OrganizationProfileBillingPanelView', () => { + it('composes subscription, payment, and invoice sections', () => { + renderView(); + + expect(screen.getByRole('heading', { level: 3, name: 'Billing' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Subscription' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Payment' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Invoices' })).toBeInTheDocument(); + expect(screen.getByText('$200 per month, billed monthly')).toBeInTheDocument(); + expect(screen.getByText('6 of 10 seats used')).toBeInTheDocument(); + expect(screen.getByText('Visa •••• 0644')).toBeInTheDocument(); + expect(screen.getByText('stmt_202605_0s64a')).toBeInTheDocument(); + }); + + it('forwards subscription and payment actions', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + await user.click(screen.getByRole('button', { name: 'Change plan' })); + await user.click(screen.getByRole('button', { name: 'Manage subscription' })); + await user.click(screen.getByRole('button', { name: 'Manage seats' })); + await user.click(screen.getByRole('button', { name: 'Add' })); + await user.click(screen.getByRole('button', { name: 'Manage Visa •••• 0644' })); + + expect(props.subscription.onChangePlan).toHaveBeenCalledOnce(); + expect(props.subscription.onManagePlan).toHaveBeenCalledOnce(); + expect(props.subscription.onManageSeats).toHaveBeenCalledOnce(); + expect(props.paymentMethods.onAdd).toHaveBeenCalledOnce(); + expect(props.paymentMethods.onManage).toHaveBeenCalledWith('visa'); + }); + + it('forwards invoice actions and pagination changes', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + await user.click(screen.getByRole('button', { name: 'Download all' })); + await user.click(screen.getByRole('button', { name: 'View' })); + await user.click(screen.getByRole('button', { name: 'Next invoice page' })); + await user.selectOptions(screen.getByRole('combobox', { name: 'Results per page' }), '25'); + + expect(props.invoices.onDownloadAll).toHaveBeenCalledOnce(); + expect(props.invoices.onView).toHaveBeenCalledWith('invoice'); + expect(props.invoices.onPageChange).toHaveBeenCalledWith(2); + expect(props.invoices.onPageSizeChange).toHaveBeenCalledWith(25); + }); + + it('renders empty payment and invoice states', () => { + renderView({ + paymentMethods: { paymentMethods: [] }, + invoices: { invoices: [] }, + }); + + expect(screen.getByText('No payment methods added')).toBeInTheDocument(); + expect(screen.getByText('No invoices yet')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-billing-panel.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-billing-panel.view.tsx new file mode 100644 index 00000000000..dca8a4aa2e0 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-billing-panel.view.tsx @@ -0,0 +1,40 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './organization-profile-billing.styles'; +import type { OrganizationProfileInvoicesSectionViewProps } from './organization-profile-invoices-section.view'; +import { OrganizationProfileInvoicesSectionView } from './organization-profile-invoices-section.view'; +import type { OrganizationProfilePaymentMethodsSectionViewProps } from './organization-profile-payment-methods-section.view'; +import { OrganizationProfilePaymentMethodsSectionView } from './organization-profile-payment-methods-section.view'; +import type { OrganizationProfileSubscriptionSectionViewProps } from './organization-profile-subscription-section.view'; +import { OrganizationProfileSubscriptionSectionView } from './organization-profile-subscription-section.view'; + +export interface OrganizationProfileBillingPanelViewProps { + subscription: OrganizationProfileSubscriptionSectionViewProps; + paymentMethods: OrganizationProfilePaymentMethodsSectionViewProps; + invoices: OrganizationProfileInvoicesSectionViewProps; +} + +export function OrganizationProfileBillingPanelView({ + subscription, + paymentMethods, + invoices, +}: OrganizationProfileBillingPanelViewProps): ReactElement { + return ( +
+

} + size='2xl' + > + Billing + +
+ + + +
+

+ ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 37233c77227..442bacb1650 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -121,6 +121,8 @@ export type { OrganizationProfileMembersSectionViewProps, OrganizationProfileMemberStatus, } from '../organization-profile/organization-profile-members-section.view'; +export { OrganizationProfileBillingPanelView } from '../organization-profile/organization-profile-billing-panel.view'; +export type { OrganizationProfileBillingPanelViewProps } from '../organization-profile/organization-profile-billing-panel.view'; export { OrganizationProfileInvoicesSectionView } from '../organization-profile/organization-profile-invoices-section.view'; export type { OrganizationProfileInvoice, From 48aae006b7d516d932b04472c349b420e87bb155 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:52:05 -0600 Subject: [PATCH 12/25] docs(swingset): add organization billing compositions --- .../swingset/src/components/DocsViewer.tsx | 10 +++ packages/swingset/src/lib/registry.ts | 40 +++++++++++ .../src/stories/organization-page.mdx | 5 ++ .../src/stories/organization-page.stories.tsx | 24 ++++++- .../organization-profile-billing-panel.mdx | 27 ++++++++ ...nization-profile-billing-panel.stories.tsx | 39 +++++++++++ .../organization-profile-invoices-section.mdx | 15 ++++ ...ation-profile-invoices-section.stories.tsx | 68 +++++++++++++++++++ ...zation-profile-payment-methods-section.mdx | 15 ++++ ...rofile-payment-methods-section.stories.tsx | 37 ++++++++++ ...anization-profile-subscription-section.mdx | 10 +++ ...n-profile-subscription-section.stories.tsx | 36 ++++++++++ 12 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 packages/swingset/src/stories/organization-profile-billing-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-billing-panel.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-invoices-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-invoices-section.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-payment-methods-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-payment-methods-section.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-subscription-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-subscription-section.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index b669c568f19..1c32a36787c 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -13,6 +13,7 @@ const docModules: Record> = { 'organization-profile': { 'organization-page': dynamic(() => import('../stories/organization-page.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), + 'organization-profile-billing-panel': dynamic(() => import('../stories/organization-profile-billing-panel.mdx')), 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), 'organization-profile-security-panel': dynamic(() => import('../stories/organization-profile-security-panel.mdx')), 'organization-profile-details-section': dynamic( @@ -22,7 +23,16 @@ const docModules: Record> = { 'organization-profile-members-section': dynamic( () => import('../stories/organization-profile-members-section.mdx'), ), + 'organization-profile-invoices-section': dynamic( + () => import('../stories/organization-profile-invoices-section.mdx'), + ), + 'organization-profile-payment-methods-section': dynamic( + () => import('../stories/organization-profile-payment-methods-section.mdx'), + ), 'organization-profile-sso-section': dynamic(() => import('../stories/organization-profile-sso-section.mdx')), + 'organization-profile-subscription-section': dynamic( + () => import('../stories/organization-profile-subscription-section.mdx'), + ), 'organization-profile-verified-domains-section': dynamic( () => import('../stories/organization-profile-verified-domains-section.mdx'), ), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 9225aac3e8a..e24bf180eaf 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -62,6 +62,10 @@ import { import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationPageDefault, meta as organizationPageMeta } from '../stories/organization-page.stories'; +import { + Default as OrganizationProfileBillingPanelDefault, + meta as organizationProfileBillingPanelMeta, +} from '../stories/organization-profile-billing-panel.stories'; import { Default as OrganizationProfileDangerSectionDefault, meta as organizationProfileDangerSectionMeta, @@ -74,6 +78,11 @@ import { Default as OrganizationProfileGeneralPanelDefault, meta as organizationProfileGeneralPanelMeta, } from '../stories/organization-profile-general-panel.stories'; +import { + Default as OrganizationProfileInvoicesSectionDefault, + Empty as OrganizationProfileInvoicesSectionEmpty, + meta as organizationProfileInvoicesSectionMeta, +} from '../stories/organization-profile-invoices-section.stories'; import { Default as OrganizationProfileMembersPanelDefault, meta as organizationProfileMembersPanelMeta, @@ -83,6 +92,11 @@ import { Empty as OrganizationProfileMembersSectionEmpty, meta as organizationProfileMembersSectionMeta, } from '../stories/organization-profile-members-section.stories'; +import { + Default as OrganizationProfilePaymentMethodsSectionDefault, + Empty as OrganizationProfilePaymentMethodsSectionEmpty, + meta as organizationProfilePaymentMethodsSectionMeta, +} from '../stories/organization-profile-payment-methods-section.stories'; import { Default as OrganizationProfileSecurityPanelDefault, meta as organizationProfileSecurityPanelMeta, @@ -92,6 +106,10 @@ import { Empty as OrganizationProfileSsoSectionEmpty, meta as organizationProfileSsoSectionMeta, } from '../stories/organization-profile-sso-section.stories'; +import { + Default as OrganizationProfileSubscriptionSectionDefault, + meta as organizationProfileSubscriptionSectionMeta, +} from '../stories/organization-profile-subscription-section.stories'; import { Default as OrganizationProfileVerifiedDomainsSectionDefault, Empty as OrganizationProfileVerifiedDomainsSectionEmpty, @@ -326,6 +344,10 @@ const organizationProfileGeneralPanelModule: StoryModule = { meta: organizationProfileGeneralPanelMeta, Default: OrganizationProfileGeneralPanelDefault, }; +const organizationProfileBillingPanelModule: StoryModule = { + meta: organizationProfileBillingPanelMeta, + Default: OrganizationProfileBillingPanelDefault, +}; const organizationProfileMembersPanelModule: StoryModule = { meta: organizationProfileMembersPanelMeta, Default: OrganizationProfileMembersPanelDefault, @@ -347,11 +369,25 @@ const organizationProfileMembersSectionModule: StoryModule = { Default: OrganizationProfileMembersSectionDefault, Empty: OrganizationProfileMembersSectionEmpty, }; +const organizationProfileInvoicesSectionModule: StoryModule = { + meta: organizationProfileInvoicesSectionMeta, + Default: OrganizationProfileInvoicesSectionDefault, + Empty: OrganizationProfileInvoicesSectionEmpty, +}; +const organizationProfilePaymentMethodsSectionModule: StoryModule = { + meta: organizationProfilePaymentMethodsSectionMeta, + Default: OrganizationProfilePaymentMethodsSectionDefault, + Empty: OrganizationProfilePaymentMethodsSectionEmpty, +}; const organizationProfileSsoSectionModule: StoryModule = { meta: organizationProfileSsoSectionMeta, Default: OrganizationProfileSsoSectionDefault, Empty: OrganizationProfileSsoSectionEmpty, }; +const organizationProfileSubscriptionSectionModule: StoryModule = { + meta: organizationProfileSubscriptionSectionMeta, + Default: OrganizationProfileSubscriptionSectionDefault, +}; const organizationProfileVerifiedDomainsSectionModule: StoryModule = { meta: organizationProfileVerifiedDomainsSectionMeta, Default: OrganizationProfileVerifiedDomainsSectionDefault, @@ -439,11 +475,15 @@ export const registry: StoryModule[] = [ organizationProfileGeneralPanelModule, organizationProfileMembersPanelModule, organizationProfileSecurityPanelModule, + organizationProfileBillingPanelModule, // Organization Profile · Sections organizationProfileDetailsSectionModule, organizationProfileDangerSectionModule, + organizationProfileInvoicesSectionModule, organizationProfileMembersSectionModule, + organizationProfilePaymentMethodsSectionModule, organizationProfileSsoSectionModule, + organizationProfileSubscriptionSectionModule, organizationProfileVerifiedDomainsSectionModule, // User Profile userPageModule, diff --git a/packages/swingset/src/stories/organization-page.mdx b/packages/swingset/src/stories/organization-page.mdx index 2aaca4745fd..e6c663269b9 100644 --- a/packages/swingset/src/stories/organization-page.mdx +++ b/packages/swingset/src/stories/organization-page.mdx @@ -23,5 +23,10 @@ The Organization profile shell. It owns the organization navigation and composes href: '/organization-profile/organization-profile-security-panel', layer: 'Organization Profile', }, + { + name: 'Billing panel', + href: '/organization-profile/organization-profile-billing-panel', + layer: 'Organization Profile', + }, ]} /> diff --git a/packages/swingset/src/stories/organization-page.stories.tsx b/packages/swingset/src/stories/organization-page.stories.tsx index ef8aae89b9f..5d01b2feb67 100644 --- a/packages/swingset/src/stories/organization-page.stories.tsx +++ b/packages/swingset/src/stories/organization-page.stories.tsx @@ -1,5 +1,6 @@ import type { OrganizationPagePanels } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; import { OrganizationPageView } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; +import { OrganizationProfileBillingPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-billing-panel.view'; import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-general-panel.view'; import { OrganizationProfileMembersPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-panel.view'; import { OrganizationProfileSecurityPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-security-panel.view'; @@ -8,7 +9,10 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { invoices } from './organization-profile-invoices-section.stories'; import { members } from './organization-profile-members-section.stories'; +import { paymentMethods } from './organization-profile-payment-methods-section.stories'; +import { subscription } from './organization-profile-subscription-section.stories'; export { default as __source } from './organization-page.stories?raw'; @@ -74,7 +78,25 @@ export function Default() { }} /> ), - billing: , + billing: ( + undefined, + onPageChange: () => undefined, + onPageSizeChange: () => undefined, + onView: () => undefined, + }} + paymentMethods={{ paymentMethods, onAdd: () => undefined, onManage: () => undefined }} + subscription={{ + subscription, + onChangePlan: () => undefined, + onManagePlan: () => undefined, + onManageSeats: () => undefined, + }} + /> + ), 'api-keys': , }; diff --git a/packages/swingset/src/stories/organization-profile-billing-panel.mdx b/packages/swingset/src/stories/organization-profile-billing-panel.mdx new file mode 100644 index 00000000000..c40583e42d5 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-billing-panel.mdx @@ -0,0 +1,27 @@ +import * as Stories from './organization-profile-billing-panel.stories'; + +# OrganizationProfileBillingPanel + +The Billing organization panel. It composes subscription, payment-method, and invoice sections. + + diff --git a/packages/swingset/src/stories/organization-profile-billing-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-billing-panel.stories.tsx new file mode 100644 index 00000000000..1501e791f00 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-billing-panel.stories.tsx @@ -0,0 +1,39 @@ +import { OrganizationProfileBillingPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-billing-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { invoices } from './organization-profile-invoices-section.stories'; +import { paymentMethods } from './organization-profile-payment-methods-section.stories'; +import { subscription } from './organization-profile-subscription-section.stories'; + +export { default as __source } from './organization-profile-billing-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileBillingPanel', + label: 'Billing panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-billing-panel.view.tsx', +}; + +export function Default() { + return ( + undefined, + onPageChange: () => undefined, + onPageSizeChange: () => undefined, + onView: () => undefined, + }} + paymentMethods={{ paymentMethods, onAdd: () => undefined, onManage: () => undefined }} + subscription={{ + subscription, + onChangePlan: () => undefined, + onManagePlan: () => undefined, + onManageSeats: () => undefined, + }} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-invoices-section.mdx b/packages/swingset/src/stories/organization-profile-invoices-section.mdx new file mode 100644 index 00000000000..1f088247691 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-invoices-section.mdx @@ -0,0 +1,15 @@ +import * as Stories from './organization-profile-invoices-section.stories'; + +# OrganizationProfileInvoicesSection + +The organization invoice table and pagination controls. + + + + diff --git a/packages/swingset/src/stories/organization-profile-invoices-section.stories.tsx b/packages/swingset/src/stories/organization-profile-invoices-section.stories.tsx new file mode 100644 index 00000000000..68569a83854 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-invoices-section.stories.tsx @@ -0,0 +1,68 @@ +import { OrganizationProfileInvoicesSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-invoices-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-invoices-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileInvoicesSection', + label: 'Invoices', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-invoices-section.view.tsx', +}; + +export const invoices = [ + { + id: 'may-26', + dateLabel: 'May 26, 2026', + invoiceLabel: 'stmt_202605_0s64a', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'jun-03', + dateLabel: 'Jun 3, 2026', + invoiceLabel: 'stmt_202606_n392f', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'jun-10', + dateLabel: 'Jun 10, 2026', + invoiceLabel: 'stmt_202606_q18bd', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'jun-18', + dateLabel: 'Jun 18, 2026', + invoiceLabel: 'stmt_202606_76mkr', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, + { + id: 'jul-01', + dateLabel: 'Jul 1, 2026', + invoiceLabel: 'stmt_202607_h90ap', + amountLabel: '$25.00', + statusLabel: 'Paid', + }, +]; + +export function Default() { + return ( + undefined} + onPageChange={() => undefined} + onPageSizeChange={() => undefined} + onView={() => undefined} + /> + ); +} + +export function Empty() { + return ; +} diff --git a/packages/swingset/src/stories/organization-profile-payment-methods-section.mdx b/packages/swingset/src/stories/organization-profile-payment-methods-section.mdx new file mode 100644 index 00000000000..42608eefb7e --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-payment-methods-section.mdx @@ -0,0 +1,15 @@ +import * as Stories from './organization-profile-payment-methods-section.stories'; + +# OrganizationProfilePaymentMethodsSection + +The organization payment-method collection. + + + + diff --git a/packages/swingset/src/stories/organization-profile-payment-methods-section.stories.tsx b/packages/swingset/src/stories/organization-profile-payment-methods-section.stories.tsx new file mode 100644 index 00000000000..8d8757b70a9 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-payment-methods-section.stories.tsx @@ -0,0 +1,37 @@ +import { OrganizationProfilePaymentMethodsSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-payment-methods-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-payment-methods-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfilePaymentMethodsSection', + label: 'Payment methods', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-payment-methods-section.view.tsx', +}; + +export const paymentMethods = [ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + { id: 'mastercard', label: 'Mastercard •••• 1212', expiryLabel: 'Expires 02/2029' }, +]; + +export function Default() { + return ( + undefined} + onManage={() => undefined} + /> + ); +} + +export function Empty() { + return ( + undefined} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-subscription-section.mdx b/packages/swingset/src/stories/organization-profile-subscription-section.mdx new file mode 100644 index 00000000000..509c59fac2a --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-subscription-section.mdx @@ -0,0 +1,10 @@ +import * as Stories from './organization-profile-subscription-section.stories'; + +# OrganizationProfileSubscriptionSection + +The organization plan and seat-usage section. + + diff --git a/packages/swingset/src/stories/organization-profile-subscription-section.stories.tsx b/packages/swingset/src/stories/organization-profile-subscription-section.stories.tsx new file mode 100644 index 00000000000..db451e43805 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-subscription-section.stories.tsx @@ -0,0 +1,36 @@ +import { OrganizationProfileSubscriptionSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-subscription-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-subscription-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileSubscriptionSection', + label: 'Subscription', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-subscription-section.view.tsx', +}; + +export const subscription = { + planName: 'Pro', + statusLabel: 'Active', + priceLabel: '$200 per month, billed monthly', + seatsUsed: 6, + seatsTotal: 10, + seatLineItems: [ + { id: 'included', label: '5 seats included with Pro', amountLabel: '$0.00' }, + { id: 'additional', label: '1 additional seat', amountLabel: '$19.00' }, + ], +}; + +export function Default() { + return ( + undefined} + onManagePlan={() => undefined} + onManageSeats={() => undefined} + /> + ); +} From f3166f2949d53e1b5e528789ce2bb3c4304ff1d5 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:59:34 -0600 Subject: [PATCH 13/25] feat(ui): add organization API keys collection --- ...ization-profile-api-keys-section.styles.ts | 128 ++++++++++ ...nization-profile-api-keys-section.view.tsx | 225 ++++++++++++++++++ packages/ui/src/mosaic/styles/index.ts | 6 + 3 files changed, 359 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.styles.ts create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.styles.ts b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.styles.ts new file mode 100644 index 00000000000..348906ea885 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.styles.ts @@ -0,0 +1,128 @@ +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['4'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + row: { + borderBlockStartColor: colorVars['--cl-color-border'], + borderBlockStartStyle: 'solid', + borderBlockStartWidth: '1px', + }, + 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%', + }, +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx new file mode 100644 index 00000000000..b02db799be0 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx @@ -0,0 +1,225 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Badge } from '../components/badge'; +import { Button } from '../components/button'; +import { Icon } from '../components/icon'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './organization-profile-api-keys-section.styles'; + +export interface OrganizationProfileApiKey { + id: string; + name: string; + expirationLabel: string; + createdAtLabel: string; + lastUsedAtLabel: string; + isExpired?: boolean; +} + +export interface OrganizationProfileApiKeysPagination { + page: number; + pageCount: number; + pageSize: number; + pageSizeOptions?: readonly number[]; +} + +export interface OrganizationProfileApiKeysSectionViewProps { + apiKeys: OrganizationProfileApiKey[]; + selectedIds: readonly string[]; + pagination?: OrganizationProfileApiKeysPagination; + onManage?: (id: string) => void; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + onSelectionChange: (ids: string[]) => void; +} + +export function OrganizationProfileApiKeysSectionView({ + apiKeys, + selectedIds, + pagination, + onManage, + onPageChange, + onPageSizeChange, + onSelectionChange, +}: OrganizationProfileApiKeysSectionViewProps): 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 ( +
+ {/* TODO: Replace this inline implementation with the Mosaic Table component. */} +
+
+ + + + + + + + + + + {apiKeys.length > 0 ? ( + apiKeys.map(apiKey => ( + + + + + + + + )) + ) : ( + + + + )} + +
+ {/* TODO: Replace these inline selection controls with the Mosaic Checkbox component. */} + + + Name + + Created + + Last used + +
+ toggleOne(apiKey.id)} + /> + +
+ {apiKey.name} + {apiKey.isExpired ? Expired : null} +
+
{apiKey.expirationLabel}
+
{apiKey.createdAtLabel}{apiKey.lastUsedAtLabel} + {onManage ? ( + + ) : null} +
+ No API keys found +
+
+
+ {pagination ? ( + // TODO: Replace this inline implementation with the Mosaic Pagination component. +
+
+ + + +
+ +
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 442bacb1650..70db930f7c8 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -106,6 +106,12 @@ export type { export { OrganizationPageView } from '../organization-profile/organization-page.view'; export type { OrganizationPagePanels, OrganizationPageViewProps } from '../organization-profile/organization-page.view'; +export { OrganizationProfileApiKeysSectionView } from '../organization-profile/organization-profile-api-keys-section.view'; +export type { + OrganizationProfileApiKey, + OrganizationProfileApiKeysPagination, + OrganizationProfileApiKeysSectionViewProps, +} from '../organization-profile/organization-profile-api-keys-section.view'; export { OrganizationProfileDangerSectionView } from '../organization-profile/organization-profile-danger-section.view'; export type { OrganizationProfileDangerSectionViewProps } from '../organization-profile/organization-profile-danger-section.view'; export { OrganizationProfileDetailsSectionView } from '../organization-profile/organization-profile-details-section.view'; From 32f02cc3d66c293e185e36e95de288b8379d72ff Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 12:59:47 -0600 Subject: [PATCH 14/25] feat(ui): add organization API keys panel --- ...ation-profile-api-keys-panel.view.test.tsx | 113 ++++++++++++++++++ ...anization-profile-api-keys-panel.styles.ts | 34 ++++++ ...ganization-profile-api-keys-panel.view.tsx | 57 +++++++++ packages/ui/src/mosaic/styles/index.ts | 2 + 4 files changed, 206 insertions(+) create mode 100644 packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.styles.ts create mode 100644 packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.view.tsx diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx new file mode 100644 index 00000000000..9fa1cbe41c0 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx @@ -0,0 +1,113 @@ +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 { OrganizationProfileApiKeysPanelView } from '../organization-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: 'integration', + name: 'Integration Key', + expirationLabel: 'Expires Nov 5, 2026', + createdAtLabel: 'Nov 5, 2025', + lastUsedAtLabel: 'Nov 5, 2026', + isExpired: true, + }, +]; + +function renderView(overrides: Partial> = {}) { + const props = { + apiKeys, + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + ...overrides, + }; + + return { + ...render( + + + , + ), + props, + }; +} + +describe('OrganizationProfileApiKeysPanelView', () => { + it('renders key metadata and expiry states', () => { + 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('Jan 05, 2026')).toBeInTheDocument(); + expect(screen.getByText('Expired')).toBeInTheDocument(); + }); + + it('forwards search, create, selection, and row actions', async () => { + const callbacks = { + onCreate: vi.fn(), + onManage: vi.fn(), + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), + }; + const user = userEvent.setup(); + + renderView(callbacks); + + 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 Integration Key' })); + + expect(callbacks.onSearchChange).toHaveBeenCalledWith('primary'); + expect(callbacks.onCreate).toHaveBeenCalledOnce(); + expect(callbacks.onSelectionChange).toHaveBeenNthCalledWith(1, ['primary']); + expect(callbacks.onSelectionChange).toHaveBeenNthCalledWith(2, ['primary', 'integration']); + expect(callbacks.onManage).toHaveBeenCalledWith('integration'); + }); + + it('forwards pagination changes and renders an empty state', async () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + const user = userEvent.setup(); + + const { rerender } = 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); + + rerender( + + undefined} + onSelectionChange={() => undefined} + /> + , + ); + expect(screen.getByText('No API keys found')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.styles.ts b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.styles.ts new file mode 100644 index 00000000000..ab8162cd28b --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.styles.ts @@ -0,0 +1,34 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + root: { + gap: space['6'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, + 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', + }, + toolbar: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.view.tsx new file mode 100644 index 00000000000..d9b57feb25d --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.view.tsx @@ -0,0 +1,57 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Button } from '../components/button'; +import { Heading } from '../components/heading'; +import { Icon } from '../components/icon'; +import { Input } from '../components/input'; +import { mergeStyleProps, themeProps } from '../props'; +import { styles } from './organization-profile-api-keys-panel.styles'; +import type { OrganizationProfileApiKeysSectionViewProps } from './organization-profile-api-keys-section.view'; +import { OrganizationProfileApiKeysSectionView } from './organization-profile-api-keys-section.view'; + +export interface OrganizationProfileApiKeysPanelViewProps extends OrganizationProfileApiKeysSectionViewProps { + searchValue: string; + onCreate?: () => void; + onSearchChange: (value: string) => void; +} + +export function OrganizationProfileApiKeysPanelView({ + searchValue, + onCreate, + onSearchChange, + ...sectionProps +}: OrganizationProfileApiKeysPanelViewProps): ReactElement { + return ( +
+

} + size='2xl' + > + API Keys + +
+
+ + onSearchChange(event.currentTarget.value)} + /> +
+ {onCreate ? : null} +
+ +

+ ); +} diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 70db930f7c8..5e0ab68f0a3 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -106,6 +106,8 @@ export type { export { OrganizationPageView } from '../organization-profile/organization-page.view'; export type { OrganizationPagePanels, OrganizationPageViewProps } from '../organization-profile/organization-page.view'; +export { OrganizationProfileApiKeysPanelView } from '../organization-profile/organization-profile-api-keys-panel.view'; +export type { OrganizationProfileApiKeysPanelViewProps } from '../organization-profile/organization-profile-api-keys-panel.view'; export { OrganizationProfileApiKeysSectionView } from '../organization-profile/organization-profile-api-keys-section.view'; export type { OrganizationProfileApiKey, From c18f12aa0426dcb51f536af13f50f68c8230a850 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 13:03:30 -0600 Subject: [PATCH 15/25] docs(swingset): add organization API keys compositions --- .../swingset/src/components/DocsViewer.tsx | 4 + packages/swingset/src/lib/registry.ts | 20 +++++ .../src/stories/organization-page.mdx | 5 ++ .../src/stories/organization-page.stories.tsx | 23 +++-- .../organization-profile-api-keys-panel.mdx | 17 ++++ ...ization-profile-api-keys-panel.stories.tsx | 32 +++++++ .../organization-profile-api-keys-section.mdx | 15 ++++ ...ation-profile-api-keys-section.stories.tsx | 85 +++++++++++++++++++ 8 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-panel.mdx create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-section.mdx create mode 100644 packages/swingset/src/stories/organization-profile-api-keys-section.stories.tsx diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 1c32a36787c..9d19c1c89ba 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -12,6 +12,7 @@ import { ViewSource } from './ViewSource'; const docModules: Record> = { 'organization-profile': { 'organization-page': dynamic(() => import('../stories/organization-page.mdx')), + 'organization-profile-api-keys-panel': dynamic(() => import('../stories/organization-profile-api-keys-panel.mdx')), 'organization-profile-general-panel': dynamic(() => import('../stories/organization-profile-general-panel.mdx')), 'organization-profile-billing-panel': dynamic(() => import('../stories/organization-profile-billing-panel.mdx')), 'organization-profile-members-panel': dynamic(() => import('../stories/organization-profile-members-panel.mdx')), @@ -20,6 +21,9 @@ const docModules: Record> = { () => import('../stories/organization-profile-details-section.mdx'), ), 'organization-profile-danger-section': dynamic(() => import('../stories/organization-profile-danger-section.mdx')), + 'organization-profile-api-keys-section': dynamic( + () => import('../stories/organization-profile-api-keys-section.mdx'), + ), 'organization-profile-members-section': dynamic( () => import('../stories/organization-profile-members-section.mdx'), ), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index e24bf180eaf..6d09906584f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -62,6 +62,15 @@ import { import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; import { Default as OrganizationPageDefault, meta as organizationPageMeta } from '../stories/organization-page.stories'; +import { + Default as OrganizationProfileApiKeysPanelDefault, + meta as organizationProfileApiKeysPanelMeta, +} from '../stories/organization-profile-api-keys-panel.stories'; +import { + Default as OrganizationProfileApiKeysSectionDefault, + Empty as OrganizationProfileApiKeysSectionEmpty, + meta as organizationProfileApiKeysSectionMeta, +} from '../stories/organization-profile-api-keys-section.stories'; import { Default as OrganizationProfileBillingPanelDefault, meta as organizationProfileBillingPanelMeta, @@ -344,6 +353,10 @@ const organizationProfileGeneralPanelModule: StoryModule = { meta: organizationProfileGeneralPanelMeta, Default: OrganizationProfileGeneralPanelDefault, }; +const organizationProfileApiKeysPanelModule: StoryModule = { + meta: organizationProfileApiKeysPanelMeta, + Default: OrganizationProfileApiKeysPanelDefault, +}; const organizationProfileBillingPanelModule: StoryModule = { meta: organizationProfileBillingPanelMeta, Default: OrganizationProfileBillingPanelDefault, @@ -369,6 +382,11 @@ const organizationProfileMembersSectionModule: StoryModule = { Default: OrganizationProfileMembersSectionDefault, Empty: OrganizationProfileMembersSectionEmpty, }; +const organizationProfileApiKeysSectionModule: StoryModule = { + meta: organizationProfileApiKeysSectionMeta, + Default: OrganizationProfileApiKeysSectionDefault, + Empty: OrganizationProfileApiKeysSectionEmpty, +}; const organizationProfileInvoicesSectionModule: StoryModule = { meta: organizationProfileInvoicesSectionMeta, Default: OrganizationProfileInvoicesSectionDefault, @@ -476,7 +494,9 @@ export const registry: StoryModule[] = [ organizationProfileMembersPanelModule, organizationProfileSecurityPanelModule, organizationProfileBillingPanelModule, + organizationProfileApiKeysPanelModule, // Organization Profile · Sections + organizationProfileApiKeysSectionModule, organizationProfileDetailsSectionModule, organizationProfileDangerSectionModule, organizationProfileInvoicesSectionModule, diff --git a/packages/swingset/src/stories/organization-page.mdx b/packages/swingset/src/stories/organization-page.mdx index e6c663269b9..dcc8631d37c 100644 --- a/packages/swingset/src/stories/organization-page.mdx +++ b/packages/swingset/src/stories/organization-page.mdx @@ -28,5 +28,10 @@ The Organization profile shell. It owns the organization navigation and composes href: '/organization-profile/organization-profile-billing-panel', layer: 'Organization Profile', }, + { + name: 'API Keys panel', + href: '/organization-profile/organization-profile-api-keys-panel', + layer: 'Organization Profile', + }, ]} /> diff --git a/packages/swingset/src/stories/organization-page.stories.tsx b/packages/swingset/src/stories/organization-page.stories.tsx index 5d01b2feb67..4fb4f712835 100644 --- a/packages/swingset/src/stories/organization-page.stories.tsx +++ b/packages/swingset/src/stories/organization-page.stories.tsx @@ -1,5 +1,6 @@ import type { OrganizationPagePanels } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; import { OrganizationPageView } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; +import { OrganizationProfileApiKeysPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-api-keys-panel.view'; import { OrganizationProfileBillingPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-billing-panel.view'; import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-general-panel.view'; import { OrganizationProfileMembersPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-panel.view'; @@ -9,6 +10,7 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; +import { apiKeys } from './organization-profile-api-keys-section.stories'; import { invoices } from './organization-profile-invoices-section.stories'; import { members } from './organization-profile-members-section.stories'; import { paymentMethods } from './organization-profile-payment-methods-section.stories'; @@ -24,15 +26,13 @@ export const meta: StoryMeta = { source: 'packages/ui/src/mosaic/organization-profile/organization-page.view.tsx', }; -function PlaceholderPanel({ title }: { title: string }) { - return

{title}

; -} - export function Default() { + const [apiKeySearch, setApiKeySearch] = useState(''); const [activePanel, setActivePanel] = useState('general'); const [name, setName] = useState('Clerk'); const [memberSearch, setMemberSearch] = useState(''); const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const [selectedApiKeyIds, setSelectedApiKeyIds] = useState([]); const panels: OrganizationPagePanels = { general: ( @@ -97,7 +97,20 @@ export function Default() { }} /> ), - 'api-keys': , + 'api-keys': ( + undefined} + onManage={() => undefined} + onPageChange={() => undefined} + onPageSizeChange={() => undefined} + onSearchChange={setApiKeySearch} + onSelectionChange={setSelectedApiKeyIds} + /> + ), }; return ( diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx new file mode 100644 index 00000000000..a13789bc81b --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.mdx @@ -0,0 +1,17 @@ +import * as Stories from './organization-profile-api-keys-panel.stories'; + +# OrganizationProfileApiKeysPanel + +The API Keys organization panel. It composes search and creation controls with the API-key collection. + + diff --git a/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx new file mode 100644 index 00000000000..33130d8dd57 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-panel.stories.tsx @@ -0,0 +1,32 @@ +import { OrganizationProfileApiKeysPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-api-keys-panel.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { apiKeys } from './organization-profile-api-keys-section.stories'; + +export { default as __source } from './organization-profile-api-keys-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileApiKeysPanel', + label: 'API Keys panel', + navigation: { category: 'Panels' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.view.tsx', +}; + +export function Default() { + return ( + undefined} + onManage={() => undefined} + onPageChange={() => undefined} + onPageSizeChange={() => undefined} + onSearchChange={() => undefined} + onSelectionChange={() => undefined} + /> + ); +} diff --git a/packages/swingset/src/stories/organization-profile-api-keys-section.mdx b/packages/swingset/src/stories/organization-profile-api-keys-section.mdx new file mode 100644 index 00000000000..4d02db5f427 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-section.mdx @@ -0,0 +1,15 @@ +import * as Stories from './organization-profile-api-keys-section.stories'; + +# OrganizationProfileApiKeysSection + +The selectable organization API-key collection with expiry states and pagination. + + + + diff --git a/packages/swingset/src/stories/organization-profile-api-keys-section.stories.tsx b/packages/swingset/src/stories/organization-profile-api-keys-section.stories.tsx new file mode 100644 index 00000000000..5b7dc396630 --- /dev/null +++ b/packages/swingset/src/stories/organization-profile-api-keys-section.stories.tsx @@ -0,0 +1,85 @@ +import { OrganizationProfileApiKeysSectionView } from '@clerk/ui/mosaic/organization-profile/organization-profile-api-keys-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './organization-profile-api-keys-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'Organization Profile', + title: 'OrganizationProfileApiKeysSection', + label: 'API keys', + navigation: { category: 'Sections' }, + source: 'packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx', +}; + +export const apiKeys = [ + { + 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() { + return ( + undefined} + onPageChange={() => undefined} + onPageSizeChange={() => undefined} + onSelectionChange={() => undefined} + /> + ); +} + +export function Empty() { + return ( + undefined} + /> + ); +} From cb9b59c4cb7d5ece8eda3b02f2c5dce4a2f1acec Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 13:07:44 -0600 Subject: [PATCH 16/25] docs(ui): clarify profile rough-in follow-ups --- .../organization-profile-api-keys-section.view.tsx | 8 ++++---- .../organization-profile-invoices-section.view.tsx | 6 +++--- .../organization-profile-members-section.view.tsx | 8 ++++---- .../user-profile/user-profile-api-keys-panel.view.tsx | 8 ++++---- .../user-profile-billing-history-section.view.tsx | 6 +++--- .../mosaic/user-profile/user-profile-provider-icon.tsx | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx index b02db799be0..5b1bc10a2a4 100644 --- a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx @@ -59,7 +59,7 @@ export function OrganizationProfileApiKeysSectionView({ aria-label='API keys' {...mergeStyleProps(themeProps('organization-profile-api-keys-section'), stylex.props(styles.root))} > - {/* TODO: Replace this inline implementation with the Mosaic Table component. */} + {/* TODO: Temporary inline table; replace with Mosaic Table while preserving the key columns and overflow. */}
@@ -69,7 +69,7 @@ export function OrganizationProfileApiKeysSectionView({ scope='col' {...stylex.props(styles.headerCell, styles.checkboxCell)} > - {/* TODO: Replace these inline selection controls with the Mosaic Checkbox component. */} + {/* TODO: Temporary native checkboxes; replace with Mosaic Checkbox while preserving controlled selection. */} {pagination ? ( - // TODO: Replace this inline implementation with the Mosaic Pagination component. + // TODO: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract.
@@ -130,7 +130,7 @@ export function OrganizationProfileInvoicesSectionView({
{pagination ? ( - // TODO: Replace this inline implementation with the Mosaic Pagination component. + // TODO: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract.
{pagination ? ( - // TODO: Replace this inline implementation with the Mosaic Pagination component. + // TODO: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract.
{pagination ? ( - // TODO: Replace this inline implementation with the Mosaic Pagination component. + // TODO: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract.
From 1eaae2731c4c2bd89275d76b922e6da8514162e3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 15:30:55 -0600 Subject: [PATCH 24/25] fix(ui): align profile page data flow --- .../src/stories/organization-page.stories.tsx | 238 +++++++++++------- .../__tests__/profile-selection.test.ts | 27 ++ .../__tests__/organization-page.view.test.tsx | 24 +- ...ation-profile-api-keys-panel.view.test.tsx | 10 + ...zation-profile-members-panel.view.test.tsx | 10 + .../organization-page.view.tsx | 68 ++++- ...nization-profile-api-keys-section.view.tsx | 8 +- ...anization-profile-members-section.view.tsx | 8 +- packages/ui/src/mosaic/profile-selection.ts | 8 + .../user-profile-api-keys-panel.view.test.tsx | 10 + .../user-profile-api-keys-panel.view.tsx | 8 +- 11 files changed, 317 insertions(+), 102 deletions(-) create mode 100644 packages/ui/src/mosaic/__tests__/profile-selection.test.ts create mode 100644 packages/ui/src/mosaic/profile-selection.ts diff --git a/packages/swingset/src/stories/organization-page.stories.tsx b/packages/swingset/src/stories/organization-page.stories.tsx index 4fb4f712835..ac77adc3d82 100644 --- a/packages/swingset/src/stories/organization-page.stories.tsx +++ b/packages/swingset/src/stories/organization-page.stories.tsx @@ -1,12 +1,7 @@ -import type { OrganizationPagePanels } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; +import type { OrganizationPageViewProps } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; import { OrganizationPageView } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; -import { OrganizationProfileApiKeysPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-api-keys-panel.view'; -import { OrganizationProfileBillingPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-billing-panel.view'; -import { OrganizationProfileGeneralPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-general-panel.view'; -import { OrganizationProfileMembersPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-members-panel.view'; -import { OrganizationProfileSecurityPanelView } from '@clerk/ui/mosaic/organization-profile/organization-profile-security-panel.view'; import type { OrganizationProfilePanelId } from '@clerk/ui/mosaic/organization-profile/organization-profile-sidebar'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -28,89 +23,164 @@ export const meta: StoryMeta = { export function Default() { const [apiKeySearch, setApiKeySearch] = useState(''); + const [apiKeyPage, setApiKeyPage] = useState(1); + const [apiKeyPageSize, setApiKeyPageSize] = useState(10); + const [organizationApiKeys, setOrganizationApiKeys] = useState(apiKeys); const [activePanel, setActivePanel] = useState('general'); + const [invoicePage, setInvoicePage] = useState(1); + const [invoicePageSize, setInvoicePageSize] = useState(10); const [name, setName] = useState('Clerk'); + const [organizationMembers, setOrganizationMembers] = useState(members); const [memberSearch, setMemberSearch] = useState(''); + const [memberPage, setMemberPage] = useState(1); + const [memberPageSize, setMemberPageSize] = useState(10); const [selectedMemberIds, setSelectedMemberIds] = useState([]); const [selectedApiKeyIds, setSelectedApiKeyIds] = useState([]); - const panels: OrganizationPagePanels = { - general: ( - undefined} - onDeleteOrganization={() => undefined} - onLeaveOrganization={() => undefined} - onNameChange={setName} - onUploadLogo={() => undefined} - /> - ), - members: ( - undefined} - onDeclineRequest={() => undefined} - onFilter={() => undefined} - onInvite={() => undefined} - onManageMember={() => undefined} - onManageRole={() => undefined} - onPageChange={() => undefined} - onPageSizeChange={() => undefined} - onSearchChange={setMemberSearch} - onSelectionChange={setSelectedMemberIds} - /> - ), - security: ( - undefined, - onManage: () => undefined, - }} - verifiedDomains={{ - domains: [{ id: 'domain', name: 'clerk.dev', enrollmentModeLabel: 'Automatic invitations' }], - onAdd: () => undefined, - onManage: () => undefined, - }} - /> - ), - billing: ( - undefined, - onPageChange: () => undefined, - onPageSizeChange: () => undefined, - onView: () => undefined, - }} - paymentMethods={{ paymentMethods, onAdd: () => undefined, onManage: () => undefined }} - subscription={{ - subscription, - onChangePlan: () => undefined, - onManagePlan: () => undefined, - onManageSeats: () => undefined, - }} - /> - ), - 'api-keys': ( - undefined} - onManage={() => undefined} - onPageChange={() => undefined} - onPageSizeChange={() => undefined} - onSearchChange={setApiKeySearch} - onSelectionChange={setSelectedApiKeyIds} - /> - ), + const filteredMembers = useMemo(() => { + const query = memberSearch.trim().toLowerCase(); + return query + ? organizationMembers.filter(member => `${member.name} ${member.emailAddress}`.toLowerCase().includes(query)) + : organizationMembers; + }, [memberSearch, organizationMembers]); + const memberPageCount = Math.max(1, Math.ceil(filteredMembers.length / memberPageSize)); + const visibleMembers = filteredMembers.slice((memberPage - 1) * memberPageSize, memberPage * memberPageSize); + + const filteredApiKeys = useMemo(() => { + const query = apiKeySearch.trim().toLowerCase(); + return query + ? organizationApiKeys.filter(apiKey => apiKey.name.toLowerCase().includes(query)) + : organizationApiKeys; + }, [apiKeySearch, organizationApiKeys]); + const apiKeyPageCount = Math.max(1, Math.ceil(filteredApiKeys.length / apiKeyPageSize)); + const visibleApiKeys = filteredApiKeys.slice((apiKeyPage - 1) * apiKeyPageSize, apiKeyPage * apiKeyPageSize); + + const invoicePageCount = Math.max(1, Math.ceil(invoices.length / invoicePageSize)); + const visibleInvoices = invoices.slice((invoicePage - 1) * invoicePageSize, invoicePage * invoicePageSize); + + const panels: OrganizationPageViewProps['panels'] = { + general: { + name, + slug: 'clerkorganization-177654156132154', + onCopySlug: () => undefined, + onDeleteOrganization: () => undefined, + onLeaveOrganization: () => undefined, + onNameChange: setName, + onUploadLogo: () => undefined, + }, + members: { + members: visibleMembers, + pagination: { + page: memberPage, + pageCount: memberPageCount, + pageSize: memberPageSize, + pageSizeOptions: [10, 25, 50], + }, + searchValue: memberSearch, + selectedIds: selectedMemberIds, + onAcceptRequest: id => + setOrganizationMembers(current => + current.map(member => + member.id === id ? { ...member, status: 'active', addedAtLabel: 'Today', roleLabel: 'Member' } : member, + ), + ), + onDeclineRequest: id => setOrganizationMembers(current => current.filter(member => member.id !== id)), + onFilter: () => undefined, + onInvite: () => + setOrganizationMembers(current => [ + ...current, + { + id: `invited-${Date.now()}`, + name: `Invited member ${current.length + 1}`, + emailAddress: `member${current.length + 1}@clerk.dev`, + status: 'invited', + addedAtLabel: 'Today', + roleLabel: 'Member', + }, + ]), + onManageMember: () => undefined, + onManageRole: () => undefined, + onPageChange: setMemberPage, + onPageSizeChange: pageSize => { + setMemberPage(1); + setMemberPageSize(pageSize); + }, + onSearchChange: value => { + setMemberPage(1); + setMemberSearch(value); + }, + onSelectionChange: setSelectedMemberIds, + }, + security: { + sso: { + connections: [{ id: 'sso', domain: 'clerk.dev', protocol: 'SAML' }], + onAdd: () => undefined, + onManage: () => undefined, + }, + verifiedDomains: { + domains: [{ id: 'domain', name: 'clerk.dev', enrollmentModeLabel: 'Automatic invitations' }], + onAdd: () => undefined, + onManage: () => undefined, + }, + }, + billing: { + invoices: { + invoices: visibleInvoices, + pagination: { + page: invoicePage, + pageCount: invoicePageCount, + pageSize: invoicePageSize, + pageSizeOptions: [10, 25, 50], + }, + onDownloadAll: () => undefined, + onPageChange: setInvoicePage, + onPageSizeChange: pageSize => { + setInvoicePage(1); + setInvoicePageSize(pageSize); + }, + onView: () => undefined, + }, + paymentMethods: { paymentMethods, onAdd: () => undefined, onManage: () => undefined }, + subscription: { + subscription, + onChangePlan: () => undefined, + onManagePlan: () => undefined, + onManageSeats: () => undefined, + }, + }, + apiKeys: { + apiKeys: visibleApiKeys, + pagination: { + page: apiKeyPage, + pageCount: apiKeyPageCount, + pageSize: apiKeyPageSize, + pageSizeOptions: [10, 25, 50], + }, + searchValue: apiKeySearch, + selectedIds: selectedApiKeyIds, + onCreate: () => + setOrganizationApiKeys(current => [ + ...current, + { + id: `key-${Date.now()}`, + name: `API Key ${current.length + 1}`, + expirationLabel: 'Expires Never', + createdAtLabel: 'Just now', + lastUsedAtLabel: 'Never', + }, + ]), + onManage: () => undefined, + onPageChange: setApiKeyPage, + onPageSizeChange: pageSize => { + setApiKeyPage(1); + setApiKeyPageSize(pageSize); + }, + onSearchChange: value => { + setApiKeyPage(1); + setApiKeySearch(value); + }, + onSelectionChange: setSelectedApiKeyIds, + }, }; return ( diff --git a/packages/ui/src/mosaic/__tests__/profile-selection.test.ts b/packages/ui/src/mosaic/__tests__/profile-selection.test.ts new file mode 100644 index 00000000000..8b961f3eda7 --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/profile-selection.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { toggleVisibleSelection } from '../profile-selection'; + +describe('toggleVisibleSelection', () => { + it('adds visible rows without dropping hidden selections', () => { + expect(toggleVisibleSelection(['hidden'], ['visible-1', 'visible-2'])).toEqual([ + 'hidden', + 'visible-1', + 'visible-2', + ]); + }); + + it('removes only visible rows when all of them are selected', () => { + expect(toggleVisibleSelection(['hidden', 'visible-1', 'visible-2'], ['visible-1', 'visible-2'])).toEqual([ + 'hidden', + ]); + }); + + it('does not duplicate a partially selected visible row', () => { + expect(toggleVisibleSelection(['hidden', 'visible-1'], ['visible-1', 'visible-2'])).toEqual([ + 'hidden', + 'visible-1', + 'visible-2', + ]); + }); +}); diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx index 9a6decd17be..49db05fc367 100644 --- a/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx @@ -4,19 +4,20 @@ import { describe, expect, it, vi } from 'vitest'; import type { OrganizationPageViewProps } from '../organization-page.view'; import { OrganizationPageView } from '../organization-page.view'; -import { OrganizationProfileGeneralPanelView } from '../organization-profile-general-panel.view'; -const general = ( - -); +const general = { name: 'Clerk', slug: 'clerk' }; +const members = { + members: [{ id: 'member', name: 'Ada Lovelace', emailAddress: 'ada@clerk.dev', status: 'active' as const }], + searchValue: '', + selectedIds: [], + onSearchChange: vi.fn(), + onSelectionChange: vi.fn(), +}; function renderView(overrides: Partial = {}) { const props: OrganizationPageViewProps = { activePanel: 'general', - panels: { general, members:

Members

}, + panels: { general, members }, onPanelChange: vi.fn(), ...overrides, }; @@ -46,6 +47,13 @@ describe('OrganizationPageView', () => { expect(onPanelChange).toHaveBeenCalledWith('members'); }); + it('renders supplied panel data through the matching panel view', () => { + renderView({ activePanel: 'members' }); + + expect(screen.getByRole('heading', { level: 3, name: 'Members' })).toBeInTheDocument(); + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument(); + }); + it('falls back to General when the requested panel is unavailable', () => { renderView({ activePanel: 'billing', panels: { general } }); diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx index 9fa1cbe41c0..37a0b91c708 100644 --- a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx @@ -78,6 +78,16 @@ describe('OrganizationProfileApiKeysPanelView', () => { expect(callbacks.onManage).toHaveBeenCalledWith('integration'); }); + it('preserves selections outside the rendered API keys when selecting all', async () => { + const onSelectionChange = vi.fn(); + const user = userEvent.setup(); + renderView({ selectedIds: ['hidden'], onSelectionChange }); + + await user.click(screen.getByRole('checkbox', { name: 'Select all API keys' })); + + expect(onSelectionChange).toHaveBeenCalledWith(['hidden', 'primary', 'integration']); + }); + it('forwards pagination changes and renders an empty state', async () => { const onPageChange = vi.fn(); const onPageSizeChange = vi.fn(); diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx index f1adaa2631b..72c3b5a1ef7 100644 --- a/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx @@ -99,6 +99,16 @@ describe('OrganizationProfileMembersPanelView', () => { expect(callbacks.onManageMember).toHaveBeenNthCalledWith(2, 'active'); }); + it('preserves selections outside the rendered members when selecting all', async () => { + const onSelectionChange = vi.fn(); + const user = userEvent.setup(); + renderView({ selectedIds: ['hidden'], onSelectionChange }); + + await user.click(screen.getByRole('checkbox', { name: 'Select all members' })); + + expect(onSelectionChange).toHaveBeenCalledWith(['hidden', 'request', 'invited', 'active']); + }); + it('forwards pagination changes and renders an empty state', async () => { const onPageChange = vi.fn(); const onPageSizeChange = vi.fn(); diff --git a/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx index 7df45fa1806..f5672b91511 100644 --- a/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx +++ b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx @@ -3,12 +3,26 @@ import type { ReactElement } from 'react'; import { styles } from '../profile-page.styles'; import { mergeStyleProps, themeProps } from '../props'; +import type { OrganizationProfileApiKeysPanelViewProps } from './organization-profile-api-keys-panel.view'; +import { OrganizationProfileApiKeysPanelView } from './organization-profile-api-keys-panel.view'; +import type { OrganizationProfileBillingPanelViewProps } from './organization-profile-billing-panel.view'; +import { OrganizationProfileBillingPanelView } from './organization-profile-billing-panel.view'; +import type { OrganizationProfileGeneralPanelViewProps } from './organization-profile-general-panel.view'; +import { OrganizationProfileGeneralPanelView } from './organization-profile-general-panel.view'; +import type { OrganizationProfileMembersPanelViewProps } from './organization-profile-members-panel.view'; +import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view'; +import type { OrganizationProfileSecurityPanelViewProps } from './organization-profile-security-panel.view'; +import { OrganizationProfileSecurityPanelView } from './organization-profile-security-panel.view'; import type { OrganizationProfilePanelId } from './organization-profile-sidebar'; import { OrganizationProfileSidebar } from './organization-profile-sidebar'; -export type OrganizationPagePanels = Partial> & { - general: ReactElement; -}; +export interface OrganizationPagePanels { + general: OrganizationProfileGeneralPanelViewProps; + members?: OrganizationProfileMembersPanelViewProps; + security?: OrganizationProfileSecurityPanelViewProps; + billing?: OrganizationProfileBillingPanelViewProps; + apiKeys?: OrganizationProfileApiKeysPanelViewProps; +} export interface OrganizationPageViewProps { activePanel: OrganizationProfilePanelId; @@ -17,10 +31,45 @@ export interface OrganizationPageViewProps { renderBranding?: boolean; } -const panelOrder: OrganizationProfilePanelId[] = ['general', 'members', 'security', 'billing', 'api-keys']; - function getAvailablePanels(panels: OrganizationPagePanels): OrganizationProfilePanelId[] { - return panelOrder.filter(panel => Boolean(panels[panel])); + return [ + 'general', + ...(panels.members ? (['members'] as const) : []), + ...(panels.security ? (['security'] as const) : []), + ...(panels.billing ? (['billing'] as const) : []), + ...(panels.apiKeys ? (['api-keys'] as const) : []), + ]; +} + +function Panel({ panel, panels }: { panel: OrganizationProfilePanelId; panels: OrganizationPagePanels }): ReactElement { + switch (panel) { + case 'members': + return panels.members ? ( + + ) : ( + + ); + case 'security': + return panels.security ? ( + + ) : ( + + ); + case 'billing': + return panels.billing ? ( + + ) : ( + + ); + case 'api-keys': + return panels.apiKeys ? ( + + ) : ( + + ); + case 'general': + return ; + } } export function OrganizationPageView({ @@ -41,7 +90,12 @@ export function OrganizationPageView({ onPanelChange={onPanelChange} />
-
{panels[resolvedPanel] ?? panels.general}
+
+ +
); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx index 5b1bc10a2a4..5d7e7cb28f0 100644 --- a/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx @@ -4,6 +4,7 @@ import type { ReactElement } from 'react'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; import { Icon } from '../components/icon'; +import { toggleVisibleSelection } from '../profile-selection'; import { mergeStyleProps, themeProps } from '../props'; import { styles } from './organization-profile-api-keys-section.styles'; @@ -45,7 +46,12 @@ export function OrganizationProfileApiKeysSectionView({ const allSelected = apiKeys.length > 0 && apiKeys.every(apiKey => selectedIds.includes(apiKey.id)); const toggleAll = () => { - onSelectionChange(allSelected ? [] : apiKeys.map(apiKey => apiKey.id)); + onSelectionChange( + toggleVisibleSelection( + selectedIds, + apiKeys.map(apiKey => apiKey.id), + ), + ); }; const toggleOne = (id: string) => { diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx index 9da40c51281..31899cea4ac 100644 --- a/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx @@ -5,6 +5,7 @@ import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; import { Icon } from '../components/icon'; +import { toggleVisibleSelection } from '../profile-selection'; import { mergeStyleProps, themeProps } from '../props'; import { styles } from './organization-profile-members-section.styles'; @@ -56,7 +57,12 @@ export function OrganizationProfileMembersSectionView({ const allSelected = members.length > 0 && members.every(member => selectedIds.includes(member.id)); const toggleAll = () => { - onSelectionChange(allSelected ? [] : members.map(member => member.id)); + onSelectionChange( + toggleVisibleSelection( + selectedIds, + members.map(member => member.id), + ), + ); }; const toggleOne = (id: string) => { diff --git a/packages/ui/src/mosaic/profile-selection.ts b/packages/ui/src/mosaic/profile-selection.ts new file mode 100644 index 00000000000..5b6a42a34ac --- /dev/null +++ b/packages/ui/src/mosaic/profile-selection.ts @@ -0,0 +1,8 @@ +export function toggleVisibleSelection(selectedIds: readonly string[], visibleIds: readonly string[]): string[] { + const visibleIdSet = new Set(visibleIds); + const allVisibleSelected = visibleIds.length > 0 && visibleIds.every(id => selectedIds.includes(id)); + + return allVisibleSelected + ? selectedIds.filter(id => !visibleIdSet.has(id)) + : [...new Set([...selectedIds, ...visibleIds])]; +} 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 index 255eb8ea7fa..4f4fa494659 100644 --- 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 @@ -76,6 +76,16 @@ describe('UserProfileApiKeysPanelView', () => { expect(onRevoke).toHaveBeenCalledWith('primary'); }); + it('preserves selections outside the rendered API keys when selecting all', async () => { + const onSelectionChange = vi.fn(); + const user = userEvent.setup(); + renderView({ selectedIds: ['hidden'], onSelectionChange }); + + await user.click(screen.getByRole('checkbox', { name: 'Select all API keys' })); + + expect(onSelectionChange).toHaveBeenCalledWith(['hidden', 'primary', 'legacy']); + }); + it('forwards page and results-per-page changes', async () => { const onPageChange = vi.fn(); const onPageSizeChange = vi.fn(); 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 index 79c70318a21..d63f9183961 100644 --- 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 @@ -7,6 +7,7 @@ import { Heading } from '../components/heading'; import { Icon } from '../components/icon'; import { Input } from '../components/input'; import { Menu } from '../components/menu'; +import { toggleVisibleSelection } from '../profile-selection'; import { mergeStyleProps, themeProps } from '../props'; import { styles } from './user-profile-api-keys-panel.styles'; @@ -54,7 +55,12 @@ export function UserProfileApiKeysPanelView({ const allSelected = apiKeys.length > 0 && apiKeys.every(apiKey => selectedIds.includes(apiKey.id)); const toggleAll = () => { - onSelectionChange(allSelected ? [] : apiKeys.map(apiKey => apiKey.id)); + onSelectionChange( + toggleVisibleSelection( + selectedIds, + apiKeys.map(apiKey => apiKey.id), + ), + ); }; const toggleOne = (id: string) => { From 9644b0701337dc70d1998cb1da2be64832af3b8b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 19 Aug 2026 16:55:54 -0600 Subject: [PATCH 25/25] refactor(ui): share organization profile page tabs --- .../__tests__/organization-page.view.test.tsx | 28 ++++-- .../organization-page.view.tsx | 88 ++++++++++++------- .../organization-profile-sidebar.tsx | 77 ++++------------ 3 files changed, 94 insertions(+), 99 deletions(-) diff --git a/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx index 49db05fc367..aef168d490b 100644 --- a/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-page.view.test.tsx @@ -30,9 +30,15 @@ describe('OrganizationPageView', () => { renderView(); expect(screen.getByRole('navigation', { name: 'Organization profile' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'General' })).toHaveAttribute('aria-current', 'page'); - expect(screen.getByRole('button', { name: 'Members' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Security' })).not.toBeInTheDocument(); + expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical'); + const generalTab = screen.getByRole('tab', { name: 'General' }); + const generalPanel = screen.getByRole('tabpanel'); + + expect(generalTab).toHaveAttribute('aria-selected', 'true'); + expect(generalTab).toHaveAttribute('aria-controls', generalPanel.id); + expect(screen.getByRole('tab', { name: 'Members' })).toBeInTheDocument(); + expect(screen.queryByRole('tab', { name: 'Security' })).not.toBeInTheDocument(); + expect(generalPanel).toHaveAccessibleName('General'); expect(screen.getByRole('heading', { level: 3, name: 'General' })).toBeInTheDocument(); expect(screen.getByText('Secured by')).toBeInTheDocument(); }); @@ -42,8 +48,20 @@ describe('OrganizationPageView', () => { const user = userEvent.setup(); renderView({ onPanelChange }); - await user.click(screen.getByRole('button', { name: 'Members' })); + await user.click(screen.getByRole('tab', { name: 'Members' })); + + expect(onPanelChange).toHaveBeenCalledWith('members'); + }); + + it('supports sidebar keyboard navigation through the tabs primitive', async () => { + const onPanelChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPanelChange }); + + screen.getByRole('tab', { name: 'General' }).focus(); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('tab', { name: 'Members' })).toHaveFocus(); expect(onPanelChange).toHaveBeenCalledWith('members'); }); @@ -57,7 +75,7 @@ describe('OrganizationPageView', () => { it('falls back to General when the requested panel is unavailable', () => { renderView({ activePanel: 'billing', panels: { general } }); - expect(screen.getByRole('button', { name: 'General' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('heading', { level: 3, name: 'General' })).toBeInTheDocument(); }); diff --git a/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx index f5672b91511..81a71f5b172 100644 --- a/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx +++ b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx @@ -1,8 +1,7 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactElement } from 'react'; +import React from 'react'; -import { styles } from '../profile-page.styles'; -import { mergeStyleProps, themeProps } from '../props'; +import type { ProfilePageRootProps } from '../profile-page'; +import { ProfilePage } from '../profile-page'; import type { OrganizationProfileApiKeysPanelViewProps } from './organization-profile-api-keys-panel.view'; import { OrganizationProfileApiKeysPanelView } from './organization-profile-api-keys-panel.view'; import type { OrganizationProfileBillingPanelViewProps } from './organization-profile-billing-panel.view'; @@ -24,7 +23,7 @@ export interface OrganizationPagePanels { apiKeys?: OrganizationProfileApiKeysPanelViewProps; } -export interface OrganizationPageViewProps { +export interface OrganizationPageViewProps extends Omit { activePanel: OrganizationProfilePanelId; panels: OrganizationPagePanels; onPanelChange: (panel: OrganizationProfilePanelId) => void; @@ -41,7 +40,13 @@ function getAvailablePanels(panels: OrganizationPagePanels): OrganizationProfile ]; } -function Panel({ panel, panels }: { panel: OrganizationProfilePanelId; panels: OrganizationPagePanels }): ReactElement { +function Panel({ + panel, + panels, +}: { + panel: OrganizationProfilePanelId; + panels: OrganizationPagePanels; +}): React.ReactElement { switch (panel) { case 'members': return panels.members ? ( @@ -72,31 +77,48 @@ function Panel({ panel, panels }: { panel: OrganizationProfilePanelId; panels: O } } -export function OrganizationPageView({ - activePanel, - panels, - onPanelChange, - renderBranding = true, -}: OrganizationPageViewProps): ReactElement { - const availablePanels = getAvailablePanels(panels); - const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'general'; +export const OrganizationPageView = React.forwardRef( + function OrganizationPageView( + { activePanel, panels, onPanelChange, renderBranding = true, render, className, style, ...rest }, + ref, + ) { + const availablePanels = getAvailablePanels(panels); + const resolvedPanel = availablePanels.includes(activePanel) ? activePanel : 'general'; + const handlePanelChange = (value: string) => { + const panel = availablePanels.find(candidate => candidate === value); + if (panel) { + onPanelChange(panel); + } + }; - return ( -
- -
-
- -
-
-
- ); -} + return ( + + + + {availablePanels.map(panel => ( + + + + ))} + + + ); + }, +); diff --git a/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx index 1e165857718..e345373b5ea 100644 --- a/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx @@ -1,12 +1,8 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactElement } from 'react'; +import React 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 { styles } from '../profile-page.styles'; -import { mergeStyleProps, themeProps } from '../props'; +import type { ProfilePageSidebarProps } from '../profile-page'; +import { ProfilePage } from '../profile-page'; export type OrganizationProfilePanelId = 'general' | 'members' | 'security' | 'billing' | 'api-keys'; @@ -18,61 +14,20 @@ const destinations: Record { panels: readonly OrganizationProfilePanelId[]; - onPanelChange: (panel: OrganizationProfilePanelId) => void; renderBranding?: boolean; } -export function OrganizationProfileSidebar({ - activePanel, - panels, - onPanelChange, - renderBranding = true, -}: OrganizationProfileSidebarProps): ReactElement { - return ( - - ); -} +export const OrganizationProfileSidebar = React.forwardRef( + function OrganizationProfileSidebar({ panels, ...rest }, ref) { + return ( + ({ value, ...destinations[value] }))} + navigationLabel='Organization profile' + {...rest} + /> + ); + }, +);