From 6ad2392d8218bee12743eb37410f0c0e7d3ffeea Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 12:30:36 -0600 Subject: [PATCH] feat(ui): add enterprise account views and interactive stories --- .changeset/enterprise-accounts-view.md | 2 + .../swingset/src/components/DocsViewer.tsx | 3 + packages/swingset/src/lib/registry.ts | 17 ++++ .../user-profile-enterprise-accounts.ts | 55 +++++++++++ ...er-profile-enterprise-accounts-section.mdx | 35 +++++++ ...le-enterprise-accounts-section.stories.tsx | 46 +++++++++ ...-enterprise-accounts-section.view.test.tsx | 89 ++++++++++++++++++ ...er-profile-enterprise-account-row.view.tsx | 93 +++++++++++++++++++ ...le-enterprise-accounts-section.messages.ts | 6 ++ ...file-enterprise-accounts-section.styles.ts | 37 ++++++++ ...ofile-enterprise-accounts-section.types.ts | 11 +++ ...ofile-enterprise-accounts-section.view.tsx | 49 ++++++++++ 12 files changed, 443 insertions(+) create mode 100644 .changeset/enterprise-accounts-view.md create mode 100644 packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts create mode 100644 packages/swingset/src/stories/user-profile-enterprise-accounts-section.mdx create mode 100644 packages/swingset/src/stories/user-profile-enterprise-accounts-section.stories.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts create mode 100644 packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx diff --git a/.changeset/enterprise-accounts-view.md b/.changeset/enterprise-accounts-view.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/enterprise-accounts-view.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 69020f986e0..cef4f9e83e6 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -30,6 +30,9 @@ const docModules: Record> = { 'user-profile-billing-history-section': dynamic( () => import('../stories/user-profile-billing-history-section.mdx'), ), + 'user-profile-enterprise-accounts-section': dynamic( + () => import('../stories/user-profile-enterprise-accounts-section.mdx'), + ), 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), ), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 05fcb700e63..26c31a302ac 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -225,6 +225,14 @@ import { meta as userProfileDeleteSectionMeta, WithError as UserProfileDeleteSectionWithError, } from '../stories/user-profile-delete-section.stories'; +import { + ConnectionError as UserProfileEnterpriseAccountsSectionConnectionError, + ConnectOnly as UserProfileEnterpriseAccountsSectionConnectOnly, + Default as UserProfileEnterpriseAccountsSectionDefault, + LinkedAccounts as UserProfileEnterpriseAccountsSectionLinkedAccounts, + meta as userProfileEnterpriseAccountsSectionMeta, + RequiresAction as UserProfileEnterpriseAccountsSectionRequiresAction, +} from '../stories/user-profile-enterprise-accounts-section.stories'; import { Default as UserProfileMfaSectionDefault, Empty as UserProfileMfaSectionEmpty, @@ -519,6 +527,14 @@ const userProfilePaymentMethodsSectionModule: StoryModule = { Default: UserProfilePaymentMethodsSectionDefault, Empty: UserProfilePaymentMethodsSectionEmpty, }; +const userProfileEnterpriseAccountsSectionModule: StoryModule = { + meta: userProfileEnterpriseAccountsSectionMeta, + Default: UserProfileEnterpriseAccountsSectionDefault, + LinkedAccounts: UserProfileEnterpriseAccountsSectionLinkedAccounts, + RequiresAction: UserProfileEnterpriseAccountsSectionRequiresAction, + ConnectOnly: UserProfileEnterpriseAccountsSectionConnectOnly, + ConnectionError: UserProfileEnterpriseAccountsSectionConnectionError, +}; const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, @@ -586,6 +602,7 @@ export const registry: StoryModule[] = [ userProfilePaymentMethodsSectionModule, userProfileBillingHistorySectionModule, userProfileConnectedAccountsSectionModule, + userProfileEnterpriseAccountsSectionModule, userProfileWeb3WalletsSectionModule, userProfileDeleteSectionModule, // Reverification diff --git a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts new file mode 100644 index 00000000000..cc4c8ca4ef8 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts @@ -0,0 +1,55 @@ +import type { + UserProfileEnterpriseAccount, + UserProfileEnterpriseConnection, +} from '@clerk/ui/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types'; +import { useState } from 'react'; + +export const accounts: UserProfileEnterpriseAccount[] = [ + { + id: 'account_okta', + name: 'Acme Okta', + emailAddress: 'alex@acme.com', + iconUrl: 'https://img.clerk.com/static/okta.svg', + }, + { id: 'account_custom', name: 'Internal SSO', emailAddress: 'alex@internal.acme.com' }, +]; +const connections: UserProfileEnterpriseConnection[] = [ + { id: 'connection_google', name: 'Google Workspace', iconUrl: 'https://img.clerk.com/static/google.svg' }, + { id: 'connection_saml', name: 'Partner SAML' }, +]; + +export function useEnterpriseAccountsFixture({ + initialAccounts = accounts, + initialError, +}: { + initialAccounts?: UserProfileEnterpriseAccount[]; + initialError?: string; +} = {}) { + const [linkedAccounts, setLinkedAccounts] = useState(initialAccounts); + const [availableConnections, setAvailableConnections] = useState( + connections.map((connection, index) => ({ ...connection, connectError: index === 0 ? initialError : undefined })), + ); + const [pendingConnectionId, setPendingConnectionId] = useState(); + + return { + accounts: linkedAccounts, + connections: availableConnections, + pendingConnectionId, + onConnect: (id: string) => { + const connection = availableConnections.find(item => item.id === id); + if (!connection || pendingConnectionId) { + return; + } + setPendingConnectionId(id); + setAvailableConnections(current => current.map(item => ({ ...item, connectError: undefined }))); + setTimeout(() => { + setLinkedAccounts(current => [ + ...current, + { id: `account_${id}`, name: connection.name, iconUrl: connection.iconUrl, emailAddress: 'alex@acme.com' }, + ]); + setAvailableConnections(current => current.filter(item => item.id !== id)); + setPendingConnectionId(undefined); + }, 1500); + }, + }; +} diff --git a/packages/swingset/src/stories/user-profile-enterprise-accounts-section.mdx b/packages/swingset/src/stories/user-profile-enterprise-accounts-section.mdx new file mode 100644 index 00000000000..7c47a5575a3 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-enterprise-accounts-section.mdx @@ -0,0 +1,35 @@ +import * as Stories from './user-profile-enterprise-accounts-section.stories'; + +# UserProfileEnterpriseAccountsSection + +Linked enterprise accounts and available connection rows, driven entirely by supplied data and callbacks. Accounts and available connections are already filtered by the caller. Selecting a connection in these examples only changes preview state; it does not start SSO. + + + +## Linked accounts + + + +## Requires action + +The badge is informational. The caller decides whether it applies; it does not introduce a repair or remove action. + + + +## Connect only + +Eligible connections can be displayed without any linked accounts. Select Connect to see its pending state; after a short delay, it becomes a linked account. + + + +## Connection error + +The inline error is supplied by the caller. Selecting Connect clears the error, shows pending state, then adds the linked account. + + diff --git a/packages/swingset/src/stories/user-profile-enterprise-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-enterprise-accounts-section.stories.tsx new file mode 100644 index 00000000000..c1877cb9eb3 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-enterprise-accounts-section.stories.tsx @@ -0,0 +1,46 @@ +import { UserProfileEnterpriseAccountsSectionView } from '@clerk/ui/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +import { accounts, useEnterpriseAccountsFixture } from './fixtures/user-profile-enterprise-accounts'; + +export { default as __source } from './user-profile-enterprise-accounts-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + status: 'wip', + title: 'UserProfileEnterpriseAccountsSection', + label: 'Enterprise accounts', + navigation: { category: 'Sections' }, + source: + 'packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx', +}; + +export function Default() { + const fixture = useEnterpriseAccountsFixture(); + return ; +} + +export function LinkedAccounts() { + return ; +} + +export function RequiresAction() { + return ( + ({ ...account, requiresAction: true }))} + /> + ); +} + +export function ConnectOnly() { + const fixture = useEnterpriseAccountsFixture({ initialAccounts: [] }); + return ; +} + +export function ConnectionError() { + const fixture = useEnterpriseAccountsFixture({ + initialError: 'Unable to connect your account. Please try again.', + }); + return ; +} diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx new file mode 100644 index 00000000000..d265a034b6d --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx @@ -0,0 +1,89 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { UserProfileEnterpriseAccountsSectionView } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view'; + +describe('UserProfileEnterpriseAccountsSectionView', () => { + it.each([{ connections: [] }, { connections: [{ id: 'okta', name: 'Acme Okta' }] }])( + 'renders nothing without accounts or actionable connections (%j)', + ({ connections }) => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }, + ); + + it('offers a keyboard-accessible Connect button for each available connection', async () => { + const user = userEvent.setup(); + const onConnect = vi.fn(); + render( + , + ); + const button = screen.getByRole('button', { name: 'Connect SSO' }); + expect(screen.getByText('SSO')).toBeVisible(); + expect(screen.getByText('S', { exact: true })).toBeInTheDocument(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + button.focus(); + await user.keyboard('{Enter}'); + expect(onConnect).toHaveBeenCalledExactlyOnceWith('sso'); + }); + + it('blocks connection actions while pending and enables retry with a row error', async () => { + const user = userEvent.setup(); + const onConnect = vi.fn(); + const connections = [ + { id: 'okta', name: 'Acme Okta' }, + { id: 'saml', name: 'Custom SAML' }, + ]; + const { rerender } = render( + , + ); + expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('button', { name: 'Connect Custom SAML' })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Connect Custom SAML' })); + expect(onConnect).not.toHaveBeenCalled(); + rerender( + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect'); + expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Connect Custom SAML' })).toBeEnabled(); + }); + + it('renders linked account identity and requires-action status from plain props', () => { + render( + , + ); + expect(screen.getByRole('region', { name: 'Enterprise accounts' })).toBeInTheDocument(); + expect(screen.getByText('Acme Okta')).toBeInTheDocument(); + expect(screen.getByText('test@acme.com')).toBeInTheDocument(); + expect(screen.getByText('Requires action')).toBeInTheDocument(); + expect(screen.getByText('A', { exact: true })).toBeInTheDocument(); + expect(screen.getByText('Custom SSO')).toBeVisible(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx new file mode 100644 index 00000000000..b8e879d039a --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx @@ -0,0 +1,93 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Badge } from '../../../components/badge'; +import { Button } from '../../../components/button'; +import { Icon, IconFrame } from '../../../components/icon'; +import { Section } from '../../../components/section'; +import { Spinner } from '../../../components/spinner'; +import { userProfileEnterpriseAccountsMessages as m } from './user-profile-enterprise-accounts-section.messages'; +import { styles } from './user-profile-enterprise-accounts-section.styles'; +import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-accounts-section.types'; + +export function UserProfileEnterpriseAccountRowView({ + account, + onConnect, + isPending, + disabled, +}: { + account: UserProfileEnterpriseAccount; + onConnect?: (id: string) => void; + isPending?: boolean; + disabled?: boolean; +}) { + const iconUrl = account.iconUrl?.trim(); + return ( + + + + + {iconUrl ? ( + + ) : ( + + {account.name.trim().charAt(0).toUpperCase()} + + )} + + + + + + {account.name} + + {account.requiresAction ? {m.requiresAction} : null} + + {account.emailAddress ? ( + + {account.emailAddress} + + ) : null} + + {onConnect ? ( + + + + ) : null} + + {onConnect && account.connectError ? {account.connectError} : null} + + ); +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts new file mode 100644 index 00000000000..095f8c062d3 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts @@ -0,0 +1,6 @@ +export const userProfileEnterpriseAccountsMessages = { + title: 'Enterprise accounts', + connect: 'Connect', + connectProvider: 'Connect {provider}', + requiresAction: 'Requires action', +}; diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts new file mode 100644 index 00000000000..ed513bb6f8c --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.styles.ts @@ -0,0 +1,37 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space } from '../../../tokens.stylex'; + +export const styles = stylex.create({ + connectRow: { justifyContent: 'center' }, + fallback: { + borderRadius: radiusVars['--cl-radius-sm'], + alignItems: 'center', + backgroundColor: `color-mix(in oklab, ${colorVars['--cl-color-neutral']} 8%, transparent)`, + color: colorVars['--cl-color-neutral'], + display: 'inline-flex', + flexShrink: 0, + fontSize: '0.625rem', + justifyContent: 'center', + height: '100%', + width: '100%', + }, + label: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + minWidth: 0, + }, + text: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + minWidth: 0, + }, + icon: { + display: 'block', + objectFit: 'contain', + height: space['5'], + width: space['5'], + }, +}); diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts new file mode 100644 index 00000000000..2749d60cbe8 --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts @@ -0,0 +1,11 @@ +export interface UserProfileEnterpriseConnection { + id: string; + name: string; + iconUrl?: string; + connectError?: string; +} + +export interface UserProfileEnterpriseAccount extends UserProfileEnterpriseConnection { + emailAddress?: string; + requiresAction?: boolean; +} diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx new file mode 100644 index 00000000000..0c9923801ad --- /dev/null +++ b/packages/ui/src/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx @@ -0,0 +1,49 @@ +import { Section } from '../../../components/section'; +import { UserProfileEnterpriseAccountRowView } from './user-profile-enterprise-account-row.view'; +import { userProfileEnterpriseAccountsMessages as m } from './user-profile-enterprise-accounts-section.messages'; +import type { + UserProfileEnterpriseAccount, + UserProfileEnterpriseConnection, +} from './user-profile-enterprise-accounts-section.types'; + +export interface UserProfileEnterpriseAccountsSectionViewProps { + accounts: UserProfileEnterpriseAccount[]; + connections?: UserProfileEnterpriseConnection[]; + pendingConnectionId?: string; + onConnect?: (id: string) => void; +} + +export function UserProfileEnterpriseAccountsSectionView({ + accounts, + connections = [], + pendingConnectionId, + onConnect, +}: UserProfileEnterpriseAccountsSectionViewProps) { + if (accounts.length === 0 && (connections.length === 0 || !onConnect)) { + return null; + } + return ( + + {m.title} + + {accounts.map(account => ( + + ))} + {onConnect + ? connections.map(connection => ( + + )) + : null} + + + ); +}