diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3a52fbae376..9d19c1c89ba 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -10,6 +10,37 @@ 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-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')), + 'organization-profile-security-panel': dynamic(() => import('../stories/organization-profile-security-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-api-keys-section': dynamic( + () => import('../stories/organization-profile-api-keys-section.mdx'), + ), + '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'), + ), + }, '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..6d09906584f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -61,6 +61,69 @@ 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 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, +} from '../stories/organization-profile-billing-panel.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 { + Default as OrganizationProfileInvoicesSectionDefault, + Empty as OrganizationProfileInvoicesSectionEmpty, + meta as organizationProfileInvoicesSectionMeta, +} from '../stories/organization-profile-invoices-section.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 { + Default as OrganizationProfilePaymentMethodsSectionDefault, + Empty as OrganizationProfilePaymentMethodsSectionEmpty, + meta as organizationProfilePaymentMethodsSectionMeta, +} from '../stories/organization-profile-payment-methods-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 OrganizationProfileSubscriptionSectionDefault, + meta as organizationProfileSubscriptionSectionMeta, +} from '../stories/organization-profile-subscription-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, @@ -282,6 +345,73 @@ const scrollAreaModule: StoryModule = { const useDataTableModule: StoryModule = { meta: useDataTableMeta }; +const organizationPageModule: StoryModule = { + meta: organizationPageMeta, + Default: OrganizationPageDefault, +}; +const organizationProfileGeneralPanelModule: StoryModule = { + meta: organizationProfileGeneralPanelMeta, + Default: OrganizationProfileGeneralPanelDefault, +}; +const organizationProfileApiKeysPanelModule: StoryModule = { + meta: organizationProfileApiKeysPanelMeta, + Default: OrganizationProfileApiKeysPanelDefault, +}; +const organizationProfileBillingPanelModule: StoryModule = { + meta: organizationProfileBillingPanelMeta, + Default: OrganizationProfileBillingPanelDefault, +}; +const organizationProfileMembersPanelModule: StoryModule = { + meta: organizationProfileMembersPanelMeta, + Default: OrganizationProfileMembersPanelDefault, +}; +const organizationProfileSecurityPanelModule: StoryModule = { + meta: organizationProfileSecurityPanelMeta, + Default: OrganizationProfileSecurityPanelDefault, +}; +const organizationProfileDetailsSectionModule: StoryModule = { + meta: organizationProfileDetailsSectionMeta, + Default: OrganizationProfileDetailsSectionDefault, +}; +const organizationProfileDangerSectionModule: StoryModule = { + meta: organizationProfileDangerSectionMeta, + Default: OrganizationProfileDangerSectionDefault, +}; +const organizationProfileMembersSectionModule: StoryModule = { + meta: organizationProfileMembersSectionMeta, + Default: OrganizationProfileMembersSectionDefault, + Empty: OrganizationProfileMembersSectionEmpty, +}; +const organizationProfileApiKeysSectionModule: StoryModule = { + meta: organizationProfileApiKeysSectionMeta, + Default: OrganizationProfileApiKeysSectionDefault, + Empty: OrganizationProfileApiKeysSectionEmpty, +}; +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, + Empty: OrganizationProfileVerifiedDomainsSectionEmpty, +}; + const userProfileApiKeysPanelModule: StoryModule = { meta: userProfileApiKeysPanelMeta, Default: UserProfileApiKeysPanelDefault, @@ -357,6 +487,24 @@ const userProfileDeleteSectionModule: StoryModule = { export const registry: StoryModule[] = [ // User Button userButtonModule, + // Organization Profile + organizationPageModule, + // Organization Profile · Panels + organizationProfileGeneralPanelModule, + organizationProfileMembersPanelModule, + organizationProfileSecurityPanelModule, + organizationProfileBillingPanelModule, + organizationProfileApiKeysPanelModule, + // Organization Profile · Sections + organizationProfileApiKeysSectionModule, + organizationProfileDetailsSectionModule, + organizationProfileDangerSectionModule, + organizationProfileInvoicesSectionModule, + organizationProfileMembersSectionModule, + organizationProfilePaymentMethodsSectionModule, + organizationProfileSsoSectionModule, + organizationProfileSubscriptionSectionModule, + 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 new file mode 100644 index 00000000000..dcc8631d37c --- /dev/null +++ b/packages/swingset/src/stories/organization-page.mdx @@ -0,0 +1,37 @@ +import * as Stories from './organization-page.stories'; + +# OrganizationPage + +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 new file mode 100644 index 00000000000..ac77adc3d82 --- /dev/null +++ b/packages/swingset/src/stories/organization-page.stories.tsx @@ -0,0 +1,193 @@ +import type { OrganizationPageViewProps } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; +import { OrganizationPageView } from '@clerk/ui/mosaic/organization-profile/organization-page.view'; +import type { OrganizationProfilePanelId } from '@clerk/ui/mosaic/organization-profile/organization-profile-sidebar'; +import { useMemo, 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'; +import { subscription } from './organization-profile-subscription-section.stories'; + +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', +}; + +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 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/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} + /> + ); +} 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-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} + /> + ); +} 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-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} + /> + ); +} 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-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-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} + /> + ); +} 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} + /> + ); +} 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/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( <> = {}) { + const props: OrganizationPageViewProps = { + activePanel: 'general', + panels: { general, 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('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(); + }); + + it('forwards panel changes', async () => { + const onPanelChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onPanelChange }); + + 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'); + }); + + 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 } }); + + expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + 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/__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..37a0b91c708 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-api-keys-panel.view.test.tsx @@ -0,0 +1,123 @@ +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('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(); + 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/__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/__tests__/organization-profile-general-panel.view.test.tsx b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-general-panel.view.test.tsx new file mode 100644 index 00000000000..24869cd1efe --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-general-panel.view.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { OrganizationProfileGeneralPanelView } from '../organization-profile-general-panel.view'; + +describe('OrganizationProfileGeneralPanelView', () => { + 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 organization' })); + + 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/__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..72c3b5a1ef7 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/__tests__/organization-profile-members-panel.view.test.tsx @@ -0,0 +1,144 @@ +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('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(); + 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/__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-page.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx new file mode 100644 index 00000000000..81a71f5b172 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-page.view.tsx @@ -0,0 +1,124 @@ +import React from 'react'; + +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'; +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 interface OrganizationPagePanels { + general: OrganizationProfileGeneralPanelViewProps; + members?: OrganizationProfileMembersPanelViewProps; + security?: OrganizationProfileSecurityPanelViewProps; + billing?: OrganizationProfileBillingPanelViewProps; + apiKeys?: OrganizationProfileApiKeysPanelViewProps; +} + +export interface OrganizationPageViewProps extends Omit { + activePanel: OrganizationProfilePanelId; + panels: OrganizationPagePanels; + onPanelChange: (panel: OrganizationProfilePanelId) => void; + renderBranding?: boolean; +} + +function getAvailablePanels(panels: OrganizationPagePanels): OrganizationProfilePanelId[] { + 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; +}): React.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 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 ( + + + + {availablePanels.map(panel => ( + + + + ))} + + + ); + }, +); 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..f183fba392b --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-panel.view.tsx @@ -0,0 +1,58 @@ +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 + +
+ {/* TODO: Temporary search composition; replace with Mosaic InputGroup while preserving the icon and controlled value. */} +
+ + onSearchChange(event.currentTarget.value)} + /> +
+ {onCreate ? : null} +
+ +

+ ); +} 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..5d7e7cb28f0 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-api-keys-section.view.tsx @@ -0,0 +1,231 @@ +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 { toggleVisibleSelection } from '../profile-selection'; +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( + toggleVisibleSelection( + selectedIds, + apiKeys.map(apiKey => apiKey.id), + ), + ); + }; + + const toggleOne = (id: string) => { + onSelectionChange( + selectedIds.includes(id) ? selectedIds.filter(selectedId => selectedId !== id) : [...selectedIds, id], + ); + }; + + return ( +
+ {/* TODO: Temporary inline table; replace with Mosaic Table while preserving the key columns and overflow. */} +
+
+ + + + + + + + + + + {apiKeys.length > 0 ? ( + apiKeys.map(apiKey => ( + + + + + + + + )) + ) : ( + + + + )} + +
+ {/* TODO: Temporary native checkboxes; replace with Mosaic Checkbox while preserving controlled selection. */} + + + 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: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract. +
+
+ + + +
+ +
+ ) : null} +
+ ); +} 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/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-danger-section.view.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-danger-section.view.tsx new file mode 100644 index 00000000000..64de460ef34 --- /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 + +
+ + +
+

+ ); +} 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..4a85bed1aa0 --- /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: Temporary inline table; replace with Mosaic Table while preserving the invoice columns and overflow. */} +
+ + + + + + + + + + {invoices.length > 0 ? ( + invoices.map(invoice => ( + + + + + + + )) + ) : ( + + + + )} + +
+ Invoice + + Amount + + Status + +
+
{invoice.dateLabel}
+
{invoice.invoiceLabel}
+
{invoice.amountLabel} + {invoice.statusLabel} + + {onView ? ( + + ) : null} +
+ No invoices yet +
+
+ {pagination ? ( + // TODO: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract. +
+
+ + + {pagination.page} of {pagination.pageCount} + + +
+ +
+ ) : null} +
+
+ ); +} 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..35e2b999434 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-members-panel.view.tsx @@ -0,0 +1,82 @@ +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 + +
+
+ {/* TODO: Temporary search composition; replace with Mosaic InputGroup while preserving the icon and controlled value. */} +
+ + onSearchChange(event.currentTarget.value)} + /> +
+ {onFilter ? ( + + ) : null} +
+ {onInvite ? ( + + ) : null} +
+ +

+ ); +} 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..31899cea4ac --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-members-section.view.tsx @@ -0,0 +1,286 @@ +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 { toggleVisibleSelection } from '../profile-selection'; +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( + toggleVisibleSelection( + selectedIds, + members.map(member => member.id), + ), + ); + }; + + const toggleOne = (id: string) => { + onSelectionChange( + selectedIds.includes(id) ? selectedIds.filter(selectedId => selectedId !== id) : [...selectedIds, id], + ); + }; + + return ( +
+ {/* TODO: Temporary inline table; replace with Mosaic Table while preserving the current columns and overflow. */} +
+
+ + + + + + + + + + + {members.length > 0 ? ( + members.map(member => ( + + + + + + + + )) + ) : ( + + + + )} + +
+ {/* TODO: Temporary native checkboxes; replace with Mosaic Checkbox while preserving controlled selection. */} + + + 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: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract. +
+
+ + {`${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..383244b8a70 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-payment-methods-section.view.tsx @@ -0,0 +1,114 @@ +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 => ( + + {/* TODO: Temporary provider-media frame; replace with Mosaic IconFrame while preserving image and fallback icons. */} + + {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-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/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-sidebar.tsx b/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx new file mode 100644 index 00000000000..e345373b5ea --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-sidebar.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import type { IconName } from '../icons/registry'; +import type { ProfilePageSidebarProps } from '../profile-page'; +import { ProfilePage } from '../profile-page'; + +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 extends Omit { + panels: readonly OrganizationProfilePanelId[]; + renderBranding?: boolean; +} + +export const OrganizationProfileSidebar = React.forwardRef( + function OrganizationProfileSidebar({ panels, ...rest }, ref) { + return ( + ({ value, ...destinations[value] }))} + navigationLabel='Organization profile' + {...rest} + /> + ); + }, +); 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..c0ccf8d0e13 --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-sso-section.view.tsx @@ -0,0 +1,107 @@ +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 => ( + + {/* TODO: Temporary provider-media frame; replace with Mosaic IconFrame while preserving image and fallback icons. */} + + {connection.iconUrl ? ( + + ) : ( + + )} + + + {connection.domain} + {connection.protocol} + + {onManage ? ( + + + + ) : null} + + ))} + + ) : null} + + + + ); +} 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/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..9d73855598a --- /dev/null +++ b/packages/ui/src/mosaic/organization-profile/organization-profile-verified-domains-section.view.tsx @@ -0,0 +1,108 @@ +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 => ( + + {/* TODO: Temporary provider-media frame; replace with Mosaic IconFrame while preserving image and fallback icons. */} + + {domain.iconUrl ? ( + + ) : ( + + )} + + + {domain.name} + + {domain.enrollmentModeLabel ? {domain.enrollmentModeLabel} : null} + {onManage ? ( + + + + ) : null} + + ))} + + ) : null} + + + + ); +} 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/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index e1899b7ca86..5e0ab68f0a3 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -104,6 +104,66 @@ 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 { 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, + 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'; +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 { 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, + 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'; +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, + 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'; + import { colorVars, durationVars, 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 57c45c2c9e2..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) => { @@ -72,6 +78,7 @@ export function UserProfileApiKeysPanelView({ API Keys
+ {/* TODO: Temporary search composition; replace with Mosaic InputGroup while preserving the icon and controlled value. */}
{onCreate ? : null}
- {/* 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. */}
@@ -102,7 +109,7 @@ export function UserProfileApiKeysPanelView({ 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.
@@ -115,7 +115,7 @@ export function UserProfileBillingHistorySectionView({
{pagination ? ( - // TODO: Replace this inline implementation with the Mosaic Pagination component. + // TODO: Temporary inline pager; replace with Mosaic Pagination while preserving the callback contract.