From 18b9d23d5963e393f8a179c9a7c3f4cc68656e12 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 14:59:39 -0600 Subject: [PATCH 01/22] test(mosaic): support enterprise accounts in fake FAPI --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 29 +++++++++++++++++++ packages/mosaic/src/__tests__/feature/fapi.ts | 21 ++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index fc8820fbcf2..e8df26a124b 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -2,6 +2,7 @@ import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { ApiKeyJSON, ClientJSON, + EnterpriseConnectionJSON, OAuthProvider, OrganizationMembershipJSON, OrganizationSuggestionJSON, @@ -45,6 +46,7 @@ export interface FakeFapiState { apiKeys: ApiKeyJSON[]; verification: FakeVerificationState; passwordUpdates: URLSearchParams[]; + enterpriseConnections: EnterpriseConnectionJSON[]; } export type FakeFapiSeed = Partial> & { @@ -118,6 +120,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { suggestions: [], apiKeys: [], passwordUpdates: [], + enterpriseConnections: [], ...rest, verification: createVerificationState(verification), }; @@ -126,6 +129,14 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { ...verificationHandlers(state, fapiUrl), http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), + http.get(fapiUrl('/v1/me/enterprise_connections'), ({ request }) => { + const url = new URL(request.url); + const withLinking = url.searchParams.get('with_organization_account_linking') === 'true'; + const connections = withLinking + ? state.enterpriseConnections.filter(connection => connection.allow_organization_account_linking) + : state.enterpriseConnections; + return envelope(connections, state.client); + }), http.get(fapiUrl('/v1/me'), () => { const user = activeUser(state); return user ? envelope(user, state.client) : missing(); @@ -136,6 +147,24 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { return missing(); } const body = new URLSearchParams(await request.text()); + const enterpriseConnectionId = body.get('enterprise_connection_id'); + if (enterpriseConnectionId) { + const connection = state.enterpriseConnections.find(item => item.id === enterpriseConnectionId); + if (!connection) { + return missing(); + } + return envelope( + fapiExternalAccount({ + id: `idn_${connection.id}`, + provider: 'google', + verification: fapiVerification('enterprise_sso', { + status: 'unverified', + external_verification_redirect_url: 'https://accounts.example/enterprise-authorize', + }), + }), + state.client, + ); + } const strategy = body.get('strategy'); if (!strategy) { return missing(); diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 383e4b20ead..ae84f69a4e1 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -8,6 +8,7 @@ import type { DisplayConfigJSON, EmailAddressJSON, EnterpriseAccountJSON, + EnterpriseConnectionJSON, EnvironmentJSON, ExternalAccountJSON, OAuthProviders, @@ -257,6 +258,26 @@ export function fapiExternalAccount( }; } +export function fapiEnterpriseConnection( + overrides: Partial & Pick, +): EnterpriseConnectionJSON { + return { + object: 'enterprise_connection', + name: overrides.id, + active: true, + provider: 'saml_okta', + logo_public_url: null, + domains: [], + organization_id: null, + sync_user_attributes: false, + disable_additional_identifications: false, + allow_organization_account_linking: true, + created_at: createdAt, + updated_at: createdAt, + ...overrides, + }; +} + export function fapiUser(overrides: Partial & Pick): UserJSON { return { object: 'user', From 3adc53f98f41f3fcc6eb0d45e927c3271295a699 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:00:02 -0600 Subject: [PATCH 02/22] feat(mosaic): wire enterprise accounts --- .changeset/bright-enterprise-accounts.md | 2 + ...enterprise-accounts-compatibility.test.tsx | 48 ++++ ...-enterprise-accounts-section.view.test.tsx | 90 ------- ...ofile-enterprise-accounts.feature.test.tsx | 236 ++++++++++++++++++ ...prise-accounts-section.controller.test.tsx | 63 +++++ ...-enterprise-accounts-section.controller.ts | 89 +++++++ ...le-enterprise-accounts-section.messages.ts | 4 + ...file-enterprise-accounts-section.model.tsx | 121 +++++++++ ...er-profile-enterprise-accounts-section.tsx | 32 +++ 9 files changed, 595 insertions(+), 90 deletions(-) create mode 100644 .changeset/bright-enterprise-accounts.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx diff --git a/.changeset/bright-enterprise-accounts.md b/.changeset/bright-enterprise-accounts.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/bright-enterprise-accounts.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx new file mode 100644 index 00000000000..0e1a302fd65 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx @@ -0,0 +1,48 @@ +import type * as SharedReact from '@clerk/shared/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; + +const fallbackNavigate = vi.fn(); + +vi.mock('@clerk/shared/internal/clerk-js/windowNavigate', () => ({ + windowNavigate: (url: URL | string) => fallbackNavigate(url), +})); + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ + isLoaded: true, + user: { + id: 'user_1', + enterpriseAccounts: [], + createExternalAccount: () => + Promise.resolve({ + verification: { externalVerificationRedirectURL: new URL('https://accounts.example/authorize') }, + }), + }, + }), + __internal_useUserEnterpriseConnections: () => ({ + data: [{ id: 'okta', name: 'Acme Okta', allowOrganizationAccountLinking: true }], + }), + useClerk: () => ({ + __internal_environment: { userSettings: { enterpriseSSO: { enabled: true } } }, + __internal_getOption: () => undefined, + }), + }; +}); + +it('uses the shared navigation fallback when an older Clerk lacks the internal method', async () => { + render( + + + , + ); + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => expect(fallbackNavigate).toHaveBeenCalledWith(new URL('https://accounts.example/authorize'))); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx deleted file mode 100644 index c5996575d9d..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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('group', { name: 'Enterprise accounts' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { 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/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx new file mode 100644 index 00000000000..48cbdcf8c0b --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -0,0 +1,236 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnterpriseAccount, + fapiEnterpriseConnection, + fapiEnvironment, + fapiExternalAccount, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; + +const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta' }); +const custom = fapiEnterpriseConnection({ id: 'saml', name: 'Custom SAML' }); + +function signedIn(overrides: FakeFapiSeed = {}) { + return { + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + environment: fapiEnvironment({ + user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } }, + }), + enterpriseConnections: [okta, custom], + ...overrides, + }; +} + +async function renderSection(seed: FakeFapiSeed = signedIn()) { + const fapi = serveFapi(seed); + const view = await renderWithClerk(); + return { ...view, fapi }; +} + +describe('enterprise accounts', () => { + it('shows the fallback while Clerk loads', async () => { + serveFapi(signedIn()); + const client = holdRequests('get', '/v1/client'); + const rendering = renderWithClerk(Loading accounts

} />); + + await waitFor(() => expect(client.requests).toHaveLength(1)); + expect(screen.getByText('Loading accounts')).toBeInTheDocument(); + client.release(); + await rendering; + expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeInTheDocument(); + }); + + it('hides the section when SSO is disabled', async () => { + await renderSection(signedIn({ environment: fapiEnvironment() })); + expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull(); + }); + + it('hides the section when the user is signed out', async () => { + await renderSection(signedIn({ client: fapiClient() })); + expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull(); + }); + + it('hides an empty section', async () => { + await renderSection(signedIn({ enterpriseConnections: [] })); + expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull(); + }); + + it('shows only unlinked, organization-linkable connections', async () => { + const nonLinkable = fapiEnterpriseConnection({ + id: 'other', + name: 'Other SAML', + allow_organization_account_linking: false, + }); + await renderSection(signedIn({ enterpriseConnections: [okta, custom, nonLinkable] })); + + expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect Custom SAML' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect Other SAML' })).toBeNull(); + }); + + it('shows linked accounts with errors and offers only unlinked connections', async () => { + const linked = fapiEnterpriseAccount({ + id: 'enterprise_1', + enterprise_connection_id: 'okta', + email_address: 'linked@example.com', + verification: fapiVerification({ + strategy: 'enterprise_sso', + error: { code: 'enterprise_error', message: 'Fix this account', long_message: 'Fix this account' }, + }), + }); + const inactive = fapiEnterpriseAccount({ + id: 'enterprise_inactive', + enterprise_connection_id: 'inactive', + email_address: 'inactive@example.com', + enterprise_connection: fapiEnterpriseConnection({ id: 'inactive', name: 'Inactive SAML', active: false }), + }); + const client = fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', enterprise_accounts: [linked, inactive] }) }), + ]); + await renderSection( + signedIn({ + client, + enterpriseConnections: [okta, custom, fapiEnterpriseConnection({ id: 'inactive', name: 'Inactive SAML' })], + }), + ); + + expect(screen.queryByText('inactive@example.com')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Connect Inactive SAML' })).toBeNull(); + expect(screen.getByText('linked@example.com')).toBeInTheDocument(); + expect(screen.getByText('Requires action')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect Acme Okta' })).toBeNull(); + expect(await screen.findByRole('button', { name: 'Connect Custom SAML' })).toBeInTheDocument(); + }); + + it('keeps linked rows visible while connections load', async () => { + const linked = fapiEnterpriseAccount({ id: 'enterprise_1', email_address: 'linked@example.com' }); + const client = fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', enterprise_accounts: [linked] }) }), + ]); + serveFapi(signedIn({ client })); + const connections = holdRequests('get', '/v1/me/enterprise_connections'); + const rendering = renderWithClerk(); + + expect(await screen.findByText('linked@example.com')).toBeInTheDocument(); + await waitFor(() => expect(connections.requests).toHaveLength(1)); + connections.release(); + await rendering; + }); + + it('sends the selected connection and current URL, then opens its redirect', async () => { + const { clerk } = await renderSection(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const request = holdRequests('post', '/v1/me/external_accounts'); + const user = userEvent.setup(); + + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + connect.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => expect(request.requests).toHaveLength(1)); + const body = new URLSearchParams(await request.requests[0]?.text()); + expect(body.get('enterprise_connection_id')).toBe('okta'); + expect(body.get('redirect_url')).toBe(window.location.href); + 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(request.requests).toHaveLength(1); + + request.release(); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith(new URL('https://accounts.example/enterprise-authorize')), + ); + }); + + it('preserves modal return state in the redirect URL', async () => { + serveFapi(signedIn()); + await renderWithClerk(); + const request = holdRequests('post', '/v1/me/external_accounts'); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + const body = new URLSearchParams(await request.requests[0]?.text()); + const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); + expect(encoded).toBeTruthy(); + expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ componentName: 'UserProfile' }); + request.fail(); + }); + + it('shows a missing redirect error and allows another attempt', async () => { + serveFapi(signedIn()); + worker.use( + http.post(fapiUrl('/v1/me/external_accounts'), () => + HttpResponse.json({ + response: fapiExternalAccount({ + id: 'idn_okta', + provider: 'google', + verification: fapiVerification({ status: 'unverified', strategy: 'enterprise_sso' }), + }), + client: null, + }), + ), + ); + const { clerk } = await renderWithClerk(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + expect(await screen.findByRole('alert')).toHaveTextContent('The connection could not start. Please try again.'); + expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled(); + serveFapi(signedIn()); + await user.click(screen.getByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith(new URL('https://accounts.example/enterprise-authorize')), + ); + }); + + it('shows the API long message and succeeds after a manual retry', async () => { + const { clerk } = await renderSection(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const request = holdRequests('post', '/v1/me/external_accounts'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + request.fail('enterprise_error', 'Acme is unavailable.'); + expect(await screen.findByRole('alert')).toHaveTextContent('Acme is unavailable.'); + expect(navigate).not.toHaveBeenCalled(); + serveFapi(signedIn()); + await user.click(screen.getByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => + expect(navigate).toHaveBeenCalledWith(new URL('https://accounts.example/enterprise-authorize')), + ); + }); + + it('shows a direct verification-required API error without opening a dialog', async () => { + await renderSection(); + const request = holdRequests('post', '/v1/me/external_accounts'); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + request.fail('session_reverification_required', 'Verify your session.'); + expect(await screen.findByRole('alert')).toHaveTextContent('Verify your session.'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled(); + }); + + it('reports a network failure without opening a dialog', async () => { + serveFapi(signedIn()); + worker.use(http.post(fapiUrl('/v1/me/external_accounts'), () => HttpResponse.error())); + await renderWithClerk(); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + expect(await screen.findByRole('alert')).toHaveTextContent('The connection could not start. Please try again.'); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx new file mode 100644 index 00000000000..6492dbb661c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx @@ -0,0 +1,63 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller'; + +const connections = [ + { id: 'okta', name: 'Acme Okta' }, + { id: 'saml', name: 'Custom SAML' }, +]; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + let reject: (error: unknown) => void = () => undefined; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +afterEach(() => vi.useRealTimers()); + +describe('useUserProfileEnterpriseAccountsController', () => { + it('synchronously suppresses duplicate and competing connects while keeping the selected row pending', async () => { + const operation = deferred<'redirecting'>(); + const onConnect = vi.fn(() => operation.promise); + const { result } = renderHook(() => + useUserProfileEnterpriseAccountsController({ status: 'ready', accounts: [], connections, onConnect }), + ); + + act(() => { + result.current.onConnect?.('okta'); + result.current.onConnect?.('okta'); + result.current.onConnect?.('saml'); + }); + expect(onConnect).toHaveBeenCalledExactlyOnceWith('okta'); + expect(result.current.pendingConnectionId).toBe('okta'); + + await act(async () => { + operation.resolve('redirecting'); + await operation.promise; + }); + expect(result.current.pendingConnectionId).toBe('okta'); + }); + + it('releases redirect pending after two seconds', async () => { + vi.useFakeTimers(); + const onConnect = vi.fn().mockResolvedValue('redirecting'); + const { result } = renderHook(() => + useUserProfileEnterpriseAccountsController({ status: 'ready', accounts: [], connections, onConnect }), + ); + await act(async () => { + result.current.onConnect?.('okta'); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.pendingConnectionId).toBe('okta'); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.pendingConnectionId).toBeUndefined(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts new file mode 100644 index 00000000000..3ca2e52d925 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts @@ -0,0 +1,89 @@ +import { useMessages } from '../../../localization'; +import { setup } from '../../../machine/setup'; +import { useMachine } from '../../../machine/useMachine'; +import type { UserProfileEnterpriseAccountsSectionViewProps } from './user-profile-enterprise-accounts-section.view'; + +type Ready = Required> & { + status: 'ready'; + onConnect: (connectionId: string) => Promise<'redirecting' | void>; +}; + +interface Context { + selectedId: string | undefined; + errorId: string | undefined; + errorMessage: string | undefined; + run: () => Promise<'redirecting' | void>; +} + +type Event = { type: 'CONNECT'; id: string; run: () => Promise<'redirecting' | void> }; + +const { createMachine, assign, fromPromise } = setup(); + +const machine = createMachine({ + id: 'enterpriseAccountConnect', + initial: 'idle', + context: { + selectedId: undefined, + errorId: undefined, + errorMessage: undefined, + run: () => Promise.resolve(), + }, + states: { + idle: { + on: { + CONNECT: { + target: 'connecting', + actions: assign((_, event) => ({ + selectedId: event.id, + errorId: undefined, + errorMessage: undefined, + run: event.run, + })), + }, + }, + }, + connecting: { + invoke: fromPromise(context => context.run(), { + onDone: [ + { target: 'redirecting', guard: (_, event) => event.output === 'redirecting' }, + { target: 'idle', actions: assign(() => ({ selectedId: undefined })) }, + ], + onError: { + target: 'idle', + actions: assign((context, event) => ({ + selectedId: undefined, + errorId: context.selectedId, + errorMessage: event.error instanceof Error && event.error.message ? event.error.message : undefined, + })), + }, + }), + }, + redirecting: { + after: { + 2000: { target: 'idle', actions: assign(() => ({ selectedId: undefined })) }, + }, + }, + }, +}); + +export function useUserProfileEnterpriseAccountsController( + model: Ready, +): UserProfileEnterpriseAccountsSectionViewProps { + const messages = useMessages('userProfileEnterpriseAccountsSection'); + const [snapshot, send] = useMachine(machine); + const { selectedId, errorId, errorMessage } = snapshot.context; + + return { + accounts: model.accounts, + connections: model.connections.map(connection => + connection.id === errorId ? { ...connection, connectError: errorMessage ?? messages.errors.generic } : connection, + ), + pendingConnectionId: selectedId, + onConnect: id => { + if (!model.connections.some(connection => connection.id === id)) { + return; + } + send({ type: 'CONNECT', id, run: () => model.onConnect(id) }); + }, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts index a601b7bada9..48c0d27c24b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts @@ -3,4 +3,8 @@ export const userProfileEnterpriseAccountsMessages = { connect: 'Connect', connectProvider: 'Connect {provider}', requiresAction: 'Requires action', + errors: { + generic: 'Something went wrong. Please try again.', + missingRedirect: 'The connection could not start. Please try again.', + }, } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx new file mode 100644 index 00000000000..c78d07882de --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx @@ -0,0 +1,121 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; +import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; +import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react'; +import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { useMessages } from '../../../localization'; +import type { + UserProfileEnterpriseAccount, + UserProfileEnterpriseConnection, +} from './user-profile-enterprise-accounts-section.types'; + +type Account = Pick & { + verification?: { error?: { longMessage?: string | null } | null } | null; + enterpriseConnection: { + active: boolean; + name: string; + logoPublicUrl?: string | null; + } | null; +}; + +type Connection = Pick & { + logoPublicUrl?: string | null; +}; + +export type EnterpriseAccountsProjection = + | { status: 'hidden' } + | { status: 'ready'; accounts: UserProfileEnterpriseAccount[]; connections: UserProfileEnterpriseConnection[] }; + +export function projectEnterpriseAccounts({ + enabled, + accounts, + connections, +}: { + enabled: boolean; + accounts: readonly Account[]; + connections: readonly Connection[]; +}): EnterpriseAccountsProjection { + if (!enabled) { + return { status: 'hidden' }; + } + + const linkedConnectionIds = new Set(accounts.map(account => account.enterpriseConnectionId)); + const visibleAccounts = accounts + .filter(account => account.enterpriseConnection?.active) + .map(account => ({ + id: account.id ?? '', + name: account.enterpriseConnection?.name ?? '', + iconUrl: account.enterpriseConnection?.logoPublicUrl ?? undefined, + emailAddress: account.emailAddress, + requiresAction: Boolean(account.verification?.error?.longMessage), + })); + const linkableConnections = connections + .filter(connection => connection.allowOrganizationAccountLinking && !linkedConnectionIds.has(connection.id)) + .map(connection => ({ id: connection.id, name: connection.name, iconUrl: connection.logoPublicUrl ?? undefined })); + + return visibleAccounts.length || linkableConnections.length + ? { status: 'ready', accounts: visibleAccounts, connections: linkableConnections } + : { status: 'hidden' }; +} + +export function useUserProfileEnterpriseAccountsModel({ mode }: { mode?: 'modal' | 'mounted' } = {}) { + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + const { data: connections = [] } = __internal_useUserEnterpriseConnections({ + withOrganizationAccountLinking: true, + enabled: Boolean(isLoaded && user && environment?.userSettings.enterpriseSSO.enabled), + }); + const messages = useMessages('userProfileEnterpriseAccountsSection'); + + if (!isLoaded || !environment) { + return { status: 'loading' } as const; + } + if (!user) { + return { status: 'hidden' } as const; + } + + const projection = projectEnterpriseAccounts({ + enabled: environment.userSettings.enterpriseSSO.enabled, + accounts: user.enterpriseAccounts, + connections, + }); + if (projection.status === 'hidden') { + return projection; + } + + // TODO: Add session reverification for enterprise account linking; surface API errors until then. + const connect = async (connectionId: string): Promise<'redirecting' | void> => { + if (!projection.connections.some(connection => connection.id === connectionId)) { + throw new Error(messages.errors.generic); + } + const url = window.location.href; + const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url; + let account: Awaited>; + try { + account = await user.createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl }); + } catch (error) { + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + throw new Error(first?.longMessage || first?.message || messages.errors.generic); + } + throw new Error(messages.errors.generic); + } + const redirect = account?.verification?.externalVerificationRedirectURL; + if (!redirect) { + throw new Error(messages.errors.missingRedirect); + } + if (typeof clerk.__internal_windowNavigate === 'function') { + clerk.__internal_windowNavigate(redirect); + } else { + windowNavigate(redirect); + } + return 'redirecting'; + }; + + return { ...projection, onConnect: connect }; +} + +export type UserProfileEnterpriseAccountsModel = ReturnType; diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx new file mode 100644 index 00000000000..0df423b5093 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react'; + +import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller'; +import { + type UserProfileEnterpriseAccountsModel, + useUserProfileEnterpriseAccountsModel, +} from './user-profile-enterprise-accounts-section.model'; +import { UserProfileEnterpriseAccountsSectionView } from './user-profile-enterprise-accounts-section.view'; + +type Ready = Extract; + +function ReadyEnterpriseAccountsSection({ model }: { model: Ready }) { + const controller = useUserProfileEnterpriseAccountsController(model); + return ; +} + +export function UserProfileEnterpriseAccountsSection({ + fallback, + mode, +}: { + fallback?: ReactNode; + mode?: 'modal' | 'mounted'; +}) { + const model = useUserProfileEnterpriseAccountsModel({ mode }); + if (model.status === 'loading') { + return fallback ?? null; + } + if (model.status === 'hidden') { + return null; + } + return ; +} From 69c7845ad2888017ae6bf3e6ad5aed25ae8a31bd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 30 Sep 2026 15:00:09 -0600 Subject: [PATCH 03/22] feat(mosaic): integrate enterprise accounts section and live preview --- .../user-profile-profile-panel.view.test.tsx | 14 ++++++++ .../user-profile-profile-panel.view.tsx | 3 ++ packages/mosaic/src/styles/index.ts | 1 + .../swingset/src/app/(clerk)/live-sidebar.tsx | 1 + .../(clerk)/live/enterprise-accounts/page.tsx | 36 +++++++++++++++++++ 5 files changed, 55 insertions(+) create mode 100644 packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 8c4205b04d7..fbad15b724d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -284,6 +284,20 @@ describe('UserProfileProfilePanelView', () => { ); }); + it('places enterprise accounts after Web3 wallets and before the danger zone', () => { + renderView({ + web3WalletsSlot: , + enterpriseAccountsSlot:
Enterprise accounts
, + deleteAccountSlot: , + }); + + const wallets = screen.getByRole('group', { name: 'Web3 wallets' }); + const enterprise = screen.getByTestId('enterprise'); + const danger = screen.getByRole('heading', { name: 'Danger zone' }); + expect(wallets.compareDocumentPosition(enterprise) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(enterprise.compareDocumentPosition(danger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it('renders Web3 images inside icon frames', () => { const { container } = renderView({ web3WalletsSlot: ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 82157ac8452..0354672f8ed 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -19,6 +19,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect titleRef?: Ref; connectedAccountsSlot?: ReactNode; web3WalletsSlot?: ReactNode; + enterpriseAccountsSlot?: ReactNode; deleteAccountSlot?: ReactNode; } @@ -27,6 +28,7 @@ export function UserProfileProfilePanelView({ titleRef, connectedAccountsSlot, web3WalletsSlot, + enterpriseAccountsSlot, deleteAccountSlot, ...account }: UserProfileProfilePanelViewProps): ReactElement { @@ -45,6 +47,7 @@ export function UserProfileProfilePanelView({ /> {connectedAccountsSlot} {web3WalletsSlot} + {enterpriseAccountsSlot} {deleteAccountSlot} diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 68774fa4bc1..680c6f35707 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -228,6 +228,7 @@ export { UserProfileMfaSectionView } from '../features/user-profile/user-profile export { UserProfileMfaSetupView } from '../features/user-profile/user-profile-mfa-setup.view'; export { APIKeysTableView } from '../features/api-keys/api-keys-table.view'; +export { UserProfileEnterpriseAccountsSectionView } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view'; export { MembersTableTabView } from '../features/organization-profile/members-table-tab.view'; export { InvitationsTableTabView } from '../features/organization-profile/invitations-table-tab.view'; diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index deee160f546..f8230b84c5e 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -23,6 +23,7 @@ const flows = [ { title: 'API keys', href: '/live/api-keys' }, { title: 'Password', href: '/live/password' }, { title: 'Connected accounts', href: '/live/connected-accounts' }, + { title: 'Enterprise accounts', href: '/live/enterprise-accounts' }, { title: 'Delete account', href: '/live/delete-account' }, { title: 'Reverification', href: '/live/reverification' }, ]; diff --git a/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx b/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx new file mode 100644 index 00000000000..f91ab619133 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { UserProfileEnterpriseAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function EnterpriseAccountsLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Enterprise accounts

+

+ Connect and view the signed-in user's enterprise accounts. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to manage enterprise accounts. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} From 9e366ac53c9975120f7b076d12832b49281a4a5a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 00:42:02 -0600 Subject: [PATCH 04/22] refactor(mosaic): align enterprise accounts with the password section --- ...enterprise-accounts-compatibility.test.tsx | 19 ++- ...ofile-enterprise-accounts-feedback.test.ts | 43 +++++ ...er-profile-enterprise-accounts-feedback.ts | 19 +++ ...prise-accounts-section.controller.test.tsx | 31 +++- ...-enterprise-accounts-section.controller.ts | 126 ++++++--------- ...le-enterprise-accounts-section.messages.ts | 3 +- ...-enterprise-accounts-section.model.test.ts | 150 ++++++++++++++++++ ...file-enterprise-accounts-section.model.ts} | 86 +++++----- ...er-profile-enterprise-accounts-section.tsx | 43 +++-- ...ofile-enterprise-accounts-section.types.ts | 9 ++ 10 files changed, 384 insertions(+), 145 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts rename packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/{user-profile-enterprise-accounts-section.model.tsx => user-profile-enterprise-accounts-section.model.ts} (60%) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx index 0e1a302fd65..85f5cbc9346 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx @@ -8,6 +8,15 @@ import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise const fallbackNavigate = vi.fn(); +const user = { + id: 'user_1', + enterpriseAccounts: [], + createExternalAccount: () => + Promise.resolve({ + verification: { externalVerificationRedirectURL: new URL('https://accounts.example/authorize') }, + }), +}; + vi.mock('@clerk/shared/internal/clerk-js/windowNavigate', () => ({ windowNavigate: (url: URL | string) => fallbackNavigate(url), })); @@ -18,19 +27,13 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: true, - user: { - id: 'user_1', - enterpriseAccounts: [], - createExternalAccount: () => - Promise.resolve({ - verification: { externalVerificationRedirectURL: new URL('https://accounts.example/authorize') }, - }), - }, + user, }), __internal_useUserEnterpriseConnections: () => ({ data: [{ id: 'okta', name: 'Acme Okta', allowOrganizationAccountLinking: true }], }), useClerk: () => ({ + user, __internal_environment: { userSettings: { enterpriseSSO: { enabled: true } } }, __internal_getOption: () => undefined, }), diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts new file mode 100644 index 00000000000..e0039f4672e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts @@ -0,0 +1,43 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { describe, expect, it } from 'vitest'; + +import { resolveLocalization } from '../../../localization'; +import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback'; +import { EnterpriseAccountActionError } from './user-profile-enterprise-accounts-section.types'; + +const messages = resolveLocalization({ locale: 'en' }).messages.userProfileEnterpriseAccountsSection; + +function apiError(data: ConstructorParameters[1]['data']) { + return new ClerkAPIResponseError('Invalid', { status: 422, data }); +} + +describe('enterprise account error messages', () => { + it('localizes errors raised by the model', () => { + expect(enterpriseAccountErrorMessage(new EnterpriseAccountActionError('unavailable'), messages)).toBe( + messages.errors.unavailable, + ); + expect(enterpriseAccountErrorMessage(new EnterpriseAccountActionError('missing_verification_url'), messages)).toBe( + messages.errors.missingVerificationUrl, + ); + }); + + it('shows the first API error, preferring its long message', () => { + expect( + enterpriseAccountErrorMessage( + apiError([ + { code: 'oauth_error', message: 'Short', long_message: 'The identity provider is unavailable.' }, + { code: 'other', message: 'Second' }, + ]), + messages, + ), + ).toBe('The identity provider is unavailable.'); + expect(enterpriseAccountErrorMessage(apiError([{ code: 'oauth_error', message: 'Short' }]), messages)).toBe( + 'Short', + ); + }); + + it('falls back to the generic message', () => { + expect(enterpriseAccountErrorMessage(apiError([]), messages)).toBe(messages.errors.generic); + expect(enterpriseAccountErrorMessage(new TypeError('Failed to fetch'), messages)).toBe(messages.errors.generic); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts new file mode 100644 index 00000000000..577c03ca042 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts @@ -0,0 +1,19 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; + +import type { MosaicMessages } from '../../../localization'; +import { EnterpriseAccountActionError } from './user-profile-enterprise-accounts-section.types'; + +type Messages = MosaicMessages['userProfileEnterpriseAccountsSection']; + +export function enterpriseAccountErrorMessage(error: unknown, messages: Messages): string { + if (error instanceof EnterpriseAccountActionError) { + return error.code === 'missing_verification_url' + ? messages.errors.missingVerificationUrl + : messages.errors.unavailable; + } + if (isClerkAPIResponseError(error)) { + const first = error.errors[0]; + return first?.longMessage || first?.message || messages.errors.generic; + } + return messages.errors.generic; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx index 6492dbb661c..a2298113377 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller'; @@ -18,6 +18,8 @@ function deferred() { return { promise, resolve, reject }; } +const formatError = () => 'Something went wrong.'; + afterEach(() => vi.useRealTimers()); describe('useUserProfileEnterpriseAccountsController', () => { @@ -25,7 +27,7 @@ describe('useUserProfileEnterpriseAccountsController', () => { const operation = deferred<'redirecting'>(); const onConnect = vi.fn(() => operation.promise); const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ status: 'ready', accounts: [], connections, onConnect }), + useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, formatError }), ); act(() => { @@ -47,7 +49,7 @@ describe('useUserProfileEnterpriseAccountsController', () => { vi.useFakeTimers(); const onConnect = vi.fn().mockResolvedValue('redirecting'); const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ status: 'ready', accounts: [], connections, onConnect }), + useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, formatError }), ); await act(async () => { result.current.onConnect?.('okta'); @@ -55,9 +57,26 @@ describe('useUserProfileEnterpriseAccountsController', () => { await Promise.resolve(); }); expect(result.current.pendingConnectionId).toBe('okta'); - act(() => { - vi.advanceTimersByTime(2000); - }); + await act(() => vi.advanceTimersByTimeAsync(2000)); expect(result.current.pendingConnectionId).toBeUndefined(); }); + + it('shows a formatted error on the failed connection and clears it on retry', async () => { + const onConnect = vi + .fn() + .mockRejectedValueOnce(new Error('raw')) + .mockReturnValueOnce(new Promise(() => {})); + const format = vi.fn(() => 'Formatted error.'); + const { result } = renderHook(() => + useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, formatError: format }), + ); + + act(() => result.current.onConnect('okta')); + await waitFor(() => expect(result.current.connections[0].connectError).toBe('Formatted error.')); + expect(format).toHaveBeenCalledWith(expect.objectContaining({ message: 'raw' })); + expect(result.current.connections[1].connectError).toBeUndefined(); + + act(() => result.current.onConnect('okta')); + expect(result.current.connections[0].connectError).toBeUndefined(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts index 3ca2e52d925..444594a61ce 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts @@ -1,89 +1,61 @@ -import { useMessages } from '../../../localization'; -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/useMachine'; -import type { UserProfileEnterpriseAccountsSectionViewProps } from './user-profile-enterprise-accounts-section.view'; +import { useRef, useState } from 'react'; -type Ready = Required> & { - status: 'ready'; - onConnect: (connectionId: string) => Promise<'redirecting' | void>; -}; +import type { + EnterpriseAccountActionResult, + UserProfileEnterpriseAccount, + UserProfileEnterpriseConnection, +} from './user-profile-enterprise-accounts-section.types'; -interface Context { - selectedId: string | undefined; - errorId: string | undefined; - errorMessage: string | undefined; - run: () => Promise<'redirecting' | void>; +export interface UserProfileEnterpriseAccountsControllerOptions { + accounts: UserProfileEnterpriseAccount[]; + connections: UserProfileEnterpriseConnection[]; + onConnect: (connectionId: string) => Promise; + formatError: (error: unknown) => string; } -type Event = { type: 'CONNECT'; id: string; run: () => Promise<'redirecting' | void> }; +export interface UserProfileEnterpriseAccountsController { + accounts: UserProfileEnterpriseAccount[]; + connections: UserProfileEnterpriseConnection[]; + pendingConnectionId: string | undefined; + onConnect: (connectionId: string) => void; +} -const { createMachine, assign, fromPromise } = setup(); +export function useUserProfileEnterpriseAccountsController({ + accounts, + connections, + onConnect, + formatError, +}: UserProfileEnterpriseAccountsControllerOptions): UserProfileEnterpriseAccountsController { + const [pendingConnectionId, setPendingConnectionId] = useState(); + const [connectErrors, setConnectErrors] = useState>({}); + const inFlight = useRef(undefined); -const machine = createMachine({ - id: 'enterpriseAccountConnect', - initial: 'idle', - context: { - selectedId: undefined, - errorId: undefined, - errorMessage: undefined, - run: () => Promise.resolve(), - }, - states: { - idle: { - on: { - CONNECT: { - target: 'connecting', - actions: assign((_, event) => ({ - selectedId: event.id, - errorId: undefined, - errorMessage: undefined, - run: event.run, - })), - }, - }, - }, - connecting: { - invoke: fromPromise(context => context.run(), { - onDone: [ - { target: 'redirecting', guard: (_, event) => event.output === 'redirecting' }, - { target: 'idle', actions: assign(() => ({ selectedId: undefined })) }, - ], - onError: { - target: 'idle', - actions: assign((context, event) => ({ - selectedId: undefined, - errorId: context.selectedId, - errorMessage: event.error instanceof Error && event.error.message ? event.error.message : undefined, - })), - }, - }), - }, - redirecting: { - after: { - 2000: { target: 'idle', actions: assign(() => ({ selectedId: undefined })) }, - }, - }, - }, -}); + const run = async (id: string) => { + if (inFlight.current) { + return; + } + inFlight.current = id; + setPendingConnectionId(id); + setConnectErrors(({ [id]: _cleared, ...rest }) => rest); -export function useUserProfileEnterpriseAccountsController( - model: Ready, -): UserProfileEnterpriseAccountsSectionViewProps { - const messages = useMessages('userProfileEnterpriseAccountsSection'); - const [snapshot, send] = useMachine(machine); - const { selectedId, errorId, errorMessage } = snapshot.context; + try { + if ((await onConnect(id)) === 'redirecting') { + await new Promise(resolve => setTimeout(resolve, 2000)); + } + } catch (error) { + setConnectErrors(current => ({ ...current, [id]: formatError(error) })); + } + + inFlight.current = undefined; + setPendingConnectionId(undefined); + }; return { - accounts: model.accounts, - connections: model.connections.map(connection => - connection.id === errorId ? { ...connection, connectError: errorMessage ?? messages.errors.generic } : connection, + accounts, + connections: connections.map(connection => + connectErrors[connection.id] ? { ...connection, connectError: connectErrors[connection.id] } : connection, ), - pendingConnectionId: selectedId, - onConnect: id => { - if (!model.connections.some(connection => connection.id === id)) { - return; - } - send({ type: 'CONNECT', id, run: () => model.onConnect(id) }); - }, + pendingConnectionId, + onConnect: id => void run(id), }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts index 48c0d27c24b..23125b9a5c0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts @@ -5,6 +5,7 @@ export const userProfileEnterpriseAccountsMessages = { requiresAction: 'Requires action', errors: { generic: 'Something went wrong. Please try again.', - missingRedirect: 'The connection could not start. Please try again.', + unavailable: 'This enterprise connection is no longer available.', + missingVerificationUrl: 'The connection could not start. Please try again.', }, } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts new file mode 100644 index 00000000000..183b9a467b1 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts @@ -0,0 +1,150 @@ +import { cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + projectEnterpriseAccounts, + useUserProfileEnterpriseAccountsModel, +} from './user-profile-enterprise-accounts-section.model'; + +type TestUser = { + id: string; + enterpriseAccounts: never[]; + createExternalAccount: ReturnType; +}; + +function createUser(id: string): TestUser { + return { + id, + enterpriseAccounts: [], + createExternalAccount: vi.fn(() => + Promise.resolve({ + verification: { externalVerificationRedirectURL: new URL('https://idp.example/authorize') }, + }), + ), + }; +} + +let user: TestUser | null; +let enabled: boolean; + +const clerk = { + get user() { + return user; + }, + __internal_windowNavigate: vi.fn(), +}; + +vi.mock('@clerk/shared/react', () => ({ + useClerk: () => clerk, + useUser: () => ({ isLoaded: true, user }), + __internal_useUserEnterpriseConnections: () => ({ + data: [{ id: 'okta', name: 'Acme Okta', allowOrganizationAccountLinking: true }], + }), +})); + +vi.mock('../../../hooks/useMosaicEnvironment', () => ({ + useMosaicEnvironment: () => ({ userSettings: { enterpriseSSO: { enabled } } }), +})); + +beforeEach(() => { + user = createUser('user_1'); + enabled = true; + clerk.__internal_windowNavigate.mockClear(); +}); + +afterEach(cleanup); + +function ready(model: ReturnType) { + if (model.status !== 'ready') { + throw new Error('expected ready model'); + } + return model; +} + +describe('useUserProfileEnterpriseAccountsModel', () => { + it('explains why the section is hidden', () => { + user = null; + expect(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current).toEqual({ + status: 'hidden', + reason: 'no_user', + }); + + user = createUser('user_1'); + enabled = false; + expect(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current).toEqual({ + status: 'hidden', + reason: 'unavailable', + }); + }); + + it.each(['signed out', 'different user'])('rejects a captured connect after %s', async change => { + const original = user; + const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); + + user = change === 'signed out' ? null : createUser('user_2'); + + await expect(model.connect('okta')).rejects.toMatchObject({ code: 'unavailable' }); + expect(original?.createExternalAccount).not.toHaveBeenCalled(); + }); + + it('rejects a connection that is not offered', async () => { + const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); + + await expect(model.connect('saml')).rejects.toMatchObject({ code: 'unavailable' }); + expect(user?.createExternalAccount).not.toHaveBeenCalled(); + }); + + it('rejects a missing verification URL with a typed error', async () => { + user?.createExternalAccount.mockResolvedValue({ verification: null }); + const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); + + await expect(model.connect('okta')).rejects.toMatchObject({ code: 'missing_verification_url' }); + expect(clerk.__internal_windowNavigate).not.toHaveBeenCalled(); + }); +}); + +describe('projectEnterpriseAccounts', () => { + const connection = (id: string, allowOrganizationAccountLinking = true) => ({ + id, + name: id, + allowOrganizationAccountLinking, + }); + const account = (id: string, enterpriseConnectionId: string, active = true, error?: string) => ({ + id, + emailAddress: `${id}@example.com`, + enterpriseConnectionId, + verification: error ? { error: { longMessage: error } } : null, + enterpriseConnection: { active, name: enterpriseConnectionId, logoPublicUrl: null }, + }); + + it('hides the section when enterprise SSO is disabled', () => { + expect(projectEnterpriseAccounts({ enabled: false, accounts: [], connections: [connection('okta')] })).toEqual({ + status: 'hidden', + }); + }); + + it('hides the section when there is nothing to show', () => { + expect( + projectEnterpriseAccounts({ enabled: true, accounts: [], connections: [connection('saml', false)] }), + ).toEqual({ status: 'hidden' }); + }); + + it('shows active accounts and offers only unlinked, linkable connections', () => { + const projection = projectEnterpriseAccounts({ + enabled: true, + accounts: [account('ent_okta', 'okta'), account('ent_old', 'old', false)], + connections: [connection('okta'), connection('azure'), connection('saml', false)], + }); + expect(projection.status === 'ready' && projection.accounts.map(a => a.id)).toEqual(['ent_okta']); + expect(projection.status === 'ready' && projection.connections.map(c => c.id)).toEqual(['azure']); + }); + + it('flags accounts whose verification failed', () => { + const projection = projectEnterpriseAccounts({ + enabled: true, + accounts: [account('ent_okta', 'okta', true, 'Verification failed')], + connections: [], + }); + expect(projection.status === 'ready' && projection.accounts[0]?.requiresAction).toBe(true); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts similarity index 60% rename from packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx rename to packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index c78d07882de..3dd82f2ab43 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -1,15 +1,15 @@ -import { isClerkAPIResponseError } from '@clerk/shared/error'; import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react'; import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { useMessages } from '../../../localization'; import type { + EnterpriseAccountActionResult, UserProfileEnterpriseAccount, UserProfileEnterpriseConnection, } from './user-profile-enterprise-accounts-section.types'; +import { EnterpriseAccountActionError } from './user-profile-enterprise-accounts-section.types'; type Account = Pick & { verification?: { error?: { longMessage?: string | null } | null } | null; @@ -60,7 +60,20 @@ export function projectEnterpriseAccounts({ : { status: 'hidden' }; } -export function useUserProfileEnterpriseAccountsModel({ mode }: { mode?: 'modal' | 'mounted' } = {}) { +export type UserProfileEnterpriseAccountsModel = + | { status: 'loading' } + | { status: 'hidden'; reason: 'no_user' | 'unavailable' } + | { + status: 'ready'; + userId: string; + accounts: UserProfileEnterpriseAccount[]; + connections: UserProfileEnterpriseConnection[]; + connect: (connectionId: string) => Promise; + }; + +export function useUserProfileEnterpriseAccountsModel({ + mode, +}: { mode?: 'modal' | 'mounted' } = {}): UserProfileEnterpriseAccountsModel { const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); @@ -68,13 +81,12 @@ export function useUserProfileEnterpriseAccountsModel({ mode }: { mode?: 'modal' withOrganizationAccountLinking: true, enabled: Boolean(isLoaded && user && environment?.userSettings.enterpriseSSO.enabled), }); - const messages = useMessages('userProfileEnterpriseAccountsSection'); if (!isLoaded || !environment) { - return { status: 'loading' } as const; + return { status: 'loading' }; } if (!user) { - return { status: 'hidden' } as const; + return { status: 'hidden', reason: 'no_user' }; } const projection = projectEnterpriseAccounts({ @@ -83,39 +95,41 @@ export function useUserProfileEnterpriseAccountsModel({ mode }: { mode?: 'modal' connections, }); if (projection.status === 'hidden') { - return projection; + return { status: 'hidden', reason: 'unavailable' }; } - // TODO: Add session reverification for enterprise account linking; surface API errors until then. - const connect = async (connectionId: string): Promise<'redirecting' | void> => { - if (!projection.connections.some(connection => connection.id === connectionId)) { - throw new Error(messages.errors.generic); - } - const url = window.location.href; - const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url; - let account: Awaited>; - try { - account = await user.createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl }); - } catch (error) { - if (isClerkAPIResponseError(error)) { - const first = error.errors[0]; - throw new Error(first?.longMessage || first?.message || messages.errors.generic); - } - throw new Error(messages.errors.generic); - } - const redirect = account?.verification?.externalVerificationRedirectURL; - if (!redirect) { - throw new Error(messages.errors.missingRedirect); - } - if (typeof clerk.__internal_windowNavigate === 'function') { - clerk.__internal_windowNavigate(redirect); - } else { - windowNavigate(redirect); + const userId = user.id; + + const currentUser = () => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new EnterpriseAccountActionError('unavailable'); } - return 'redirecting'; + return current; }; - return { ...projection, onConnect: connect }; + return { + ...projection, + userId, + // TODO: Add session reverification for enterprise account linking; surface API errors until then. + connect: async connectionId => { + const current = currentUser(); + if (!projection.connections.some(connection => connection.id === connectionId)) { + throw new EnterpriseAccountActionError('unavailable'); + } + const url = window.location.href; + const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url; + const account = await current.createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl }); + const redirect = account.verification?.externalVerificationRedirectURL; + if (!redirect) { + throw new EnterpriseAccountActionError('missing_verification_url'); + } + if (typeof clerk.__internal_windowNavigate === 'function') { + clerk.__internal_windowNavigate(redirect); + } else { + windowNavigate(redirect); + } + return 'redirecting'; + }, + }; } - -export type UserProfileEnterpriseAccountsModel = ReturnType; diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx index 0df423b5093..b5387099ebb 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx @@ -1,26 +1,18 @@ import type { ReactNode } from 'react'; +import { useMessages } from '../../../localization'; +import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback'; import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller'; -import { - type UserProfileEnterpriseAccountsModel, - useUserProfileEnterpriseAccountsModel, -} from './user-profile-enterprise-accounts-section.model'; +import type { UserProfileEnterpriseAccountsModel } from './user-profile-enterprise-accounts-section.model'; +import { useUserProfileEnterpriseAccountsModel } from './user-profile-enterprise-accounts-section.model'; import { UserProfileEnterpriseAccountsSectionView } from './user-profile-enterprise-accounts-section.view'; -type Ready = Extract; - -function ReadyEnterpriseAccountsSection({ model }: { model: Ready }) { - const controller = useUserProfileEnterpriseAccountsController(model); - return ; -} - -export function UserProfileEnterpriseAccountsSection({ - fallback, - mode, -}: { +export type UserProfileEnterpriseAccountsSectionProps = { fallback?: ReactNode; mode?: 'modal' | 'mounted'; -}) { +}; + +export function UserProfileEnterpriseAccountsSection({ fallback, mode }: UserProfileEnterpriseAccountsSectionProps) { const model = useUserProfileEnterpriseAccountsModel({ mode }); if (model.status === 'loading') { return fallback ?? null; @@ -28,5 +20,22 @@ export function UserProfileEnterpriseAccountsSection({ if (model.status === 'hidden') { return null; } - return ; + return ( + + ); +} + +function EnterpriseAccounts({ model }: { model: Extract }) { + const m = useMessages('userProfileEnterpriseAccountsSection'); + const controller = useUserProfileEnterpriseAccountsController({ + accounts: model.accounts, + connections: model.connections, + onConnect: model.connect, + formatError: error => enterpriseAccountErrorMessage(error, m), + }); + + return ; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts index 2749d60cbe8..e1114cd055d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts @@ -9,3 +9,12 @@ export interface UserProfileEnterpriseAccount extends UserProfileEnterpriseConne emailAddress?: string; requiresAction?: boolean; } + +export type EnterpriseAccountActionResult = 'redirecting' | void; + +export class EnterpriseAccountActionError extends Error { + constructor(readonly code: 'unavailable' | 'missing_verification_url') { + super(code); + this.name = 'EnterpriseAccountActionError'; + } +} From f8f316365583541d2a8a9082bbe872c2d49c5634 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 09:14:27 -0600 Subject: [PATCH 05/22] fix(mosaic): reset enterprise account connect guard in finally --- ...profile-enterprise-accounts-section.controller.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts index 444594a61ce..ea61784de00 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts @@ -28,13 +28,13 @@ export function useUserProfileEnterpriseAccountsController({ }: UserProfileEnterpriseAccountsControllerOptions): UserProfileEnterpriseAccountsController { const [pendingConnectionId, setPendingConnectionId] = useState(); const [connectErrors, setConnectErrors] = useState>({}); - const inFlight = useRef(undefined); + const connecting = useRef(false); const run = async (id: string) => { - if (inFlight.current) { + if (connecting.current) { return; } - inFlight.current = id; + connecting.current = true; setPendingConnectionId(id); setConnectErrors(({ [id]: _cleared, ...rest }) => rest); @@ -44,10 +44,10 @@ export function useUserProfileEnterpriseAccountsController({ } } catch (error) { setConnectErrors(current => ({ ...current, [id]: formatError(error) })); + } finally { + connecting.current = false; + setPendingConnectionId(undefined); } - - inFlight.current = undefined; - setPendingConnectionId(undefined); }; return { From 701f80190345f86588e7cfa7355bbb3aa6410d56 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 09:19:59 -0600 Subject: [PATCH 06/22] refactor(mosaic): rename enterprise pending connection to pendingId --- ...ile-enterprise-accounts-section.controller.test.tsx | 8 ++++---- ...r-profile-enterprise-accounts-section.controller.ts | 10 +++++----- .../user-profile-enterprise-accounts-section.view.tsx | 8 ++++---- .../fixtures/user-profile-enterprise-accounts.ts | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx index a2298113377..dcf79825bd0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx @@ -36,13 +36,13 @@ describe('useUserProfileEnterpriseAccountsController', () => { result.current.onConnect?.('saml'); }); expect(onConnect).toHaveBeenCalledExactlyOnceWith('okta'); - expect(result.current.pendingConnectionId).toBe('okta'); + expect(result.current.pendingId).toBe('okta'); await act(async () => { operation.resolve('redirecting'); await operation.promise; }); - expect(result.current.pendingConnectionId).toBe('okta'); + expect(result.current.pendingId).toBe('okta'); }); it('releases redirect pending after two seconds', async () => { @@ -56,9 +56,9 @@ describe('useUserProfileEnterpriseAccountsController', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(result.current.pendingConnectionId).toBe('okta'); + expect(result.current.pendingId).toBe('okta'); await act(() => vi.advanceTimersByTimeAsync(2000)); - expect(result.current.pendingConnectionId).toBeUndefined(); + expect(result.current.pendingId).toBeUndefined(); }); it('shows a formatted error on the failed connection and clears it on retry', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts index ea61784de00..8751f1d3300 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts @@ -16,7 +16,7 @@ export interface UserProfileEnterpriseAccountsControllerOptions { export interface UserProfileEnterpriseAccountsController { accounts: UserProfileEnterpriseAccount[]; connections: UserProfileEnterpriseConnection[]; - pendingConnectionId: string | undefined; + pendingId: string | undefined; onConnect: (connectionId: string) => void; } @@ -26,7 +26,7 @@ export function useUserProfileEnterpriseAccountsController({ onConnect, formatError, }: UserProfileEnterpriseAccountsControllerOptions): UserProfileEnterpriseAccountsController { - const [pendingConnectionId, setPendingConnectionId] = useState(); + const [pendingId, setPendingId] = useState(); const [connectErrors, setConnectErrors] = useState>({}); const connecting = useRef(false); @@ -35,7 +35,7 @@ export function useUserProfileEnterpriseAccountsController({ return; } connecting.current = true; - setPendingConnectionId(id); + setPendingId(id); setConnectErrors(({ [id]: _cleared, ...rest }) => rest); try { @@ -46,7 +46,7 @@ export function useUserProfileEnterpriseAccountsController({ setConnectErrors(current => ({ ...current, [id]: formatError(error) })); } finally { connecting.current = false; - setPendingConnectionId(undefined); + setPendingId(undefined); } }; @@ -55,7 +55,7 @@ export function useUserProfileEnterpriseAccountsController({ connections: connections.map(connection => connectErrors[connection.id] ? { ...connection, connectError: connectErrors[connection.id] } : connection, ), - pendingConnectionId, + pendingId, onConnect: id => void run(id), }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx index 35ab56040f0..caf2100cd41 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view.tsx @@ -9,14 +9,14 @@ import type { export interface UserProfileEnterpriseAccountsSectionViewProps { accounts: UserProfileEnterpriseAccount[]; connections?: UserProfileEnterpriseConnection[]; - pendingConnectionId?: string; + pendingId?: string; onConnect?: (id: string) => void; } export function UserProfileEnterpriseAccountsSectionView({ accounts, connections = [], - pendingConnectionId, + pendingId, onConnect, }: UserProfileEnterpriseAccountsSectionViewProps) { const m = useMessages('userProfileEnterpriseAccountsSection'); @@ -42,8 +42,8 @@ export function UserProfileEnterpriseAccountsSectionView({ key={connection.id} account={connection} onConnect={onConnect} - isPending={pendingConnectionId === connection.id} - disabled={pendingConnectionId !== undefined} + isPending={pendingId === connection.id} + disabled={pendingId !== undefined} /> )) : null} diff --git a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts index a343f4f2a0b..12125133a33 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-enterprise-accounts.ts @@ -41,18 +41,18 @@ export function useEnterpriseAccountsFixture({ connectError: index === 0 ? initialError : undefined, })), ); - const [pendingConnectionId, setPendingConnectionId] = useState(); + const [pendingId, setPendingId] = useState(); return { accounts: linkedAccounts, connections: availableConnections, - pendingConnectionId, + pendingId, onConnect: (id: string) => { const connection = availableConnections.find(item => item.id === id); - if (!connection || pendingConnectionId) { + if (!connection || pendingId) { return; } - setPendingConnectionId(id); + setPendingId(id); setAvailableConnections(current => current.map(item => ({ ...item, connectError: undefined }))); setTimeout(() => { setLinkedAccounts(current => [ @@ -60,7 +60,7 @@ export function useEnterpriseAccountsFixture({ { id: `account_${id}`, name: connection.name, iconUrl: connection.iconUrl, emailAddress: 'alex@acme.com' }, ]); setAvailableConnections(current => current.filter(item => item.id !== id)); - setPendingConnectionId(undefined); + setPendingId(undefined); }, 1500); }, }; From cb5fa118b753afa3fe54a788bd1554dc85ce06cc Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 12:08:27 -0600 Subject: [PATCH 07/22] fix(mosaic): reconcile enterprise tests with main --- .../user-profile-enterprise-accounts.feature.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx index 48cbdcf8c0b..06765f7b4cf 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -83,8 +83,8 @@ describe('enterprise accounts', () => { id: 'enterprise_1', enterprise_connection_id: 'okta', email_address: 'linked@example.com', - verification: fapiVerification({ - strategy: 'enterprise_sso', + verification: fapiVerification('enterprise_sso', { + status: 'verified', error: { code: 'enterprise_error', message: 'Fix this account', long_message: 'Fix this account' }, }), }); @@ -173,7 +173,7 @@ describe('enterprise accounts', () => { response: fapiExternalAccount({ id: 'idn_okta', provider: 'google', - verification: fapiVerification({ status: 'unverified', strategy: 'enterprise_sso' }), + verification: fapiVerification('enterprise_sso', { status: 'unverified' }), }), client: null, }), From cf4faa47541afade6307432a2ad2b4e385201f60 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:20:48 -0600 Subject: [PATCH 08/22] test(mosaic): reproduce enterprise account identity races --- ...erprise-accounts-identity.feature.test.tsx | 108 ++++++++++++++++++ ...ofile-enterprise-accounts.feature.test.tsx | 6 +- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx new file mode 100644 index 00000000000..d4b5423a014 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx @@ -0,0 +1,108 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnterpriseConnection, + fapiEnvironment, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; + +const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta' }); + +function serveUsers() { + return serveFapi({ + client: fapiClient([ + fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + environment: fapiEnvironment({ + user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } }, + }), + enterpriseConnections: [okta], + }); +} + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(fulfill => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +describe('enterprise account identity', () => { + it.each(['switch', 'sign out'] as const)('does not navigate an old linking response after %s', async change => { + serveUsers(); + const response = deferred(); + let received = false; + worker.use( + http.post(fapiUrl('/v1/me/external_accounts'), () => { + received = true; + return response.promise; + }), + ); + const { clerk } = await renderWithClerk(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const original = clerk.user; + if (!original) { + throw new Error('Expected original user'); + } + const create = vi.spyOn(original, 'createExternalAccount'); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => expect(received).toBe(true)); + await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut())); + expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined); + await act(async () => { + response.resolve( + HttpResponse.json({ + response: { + object: 'external_account', + verification: fapiVerification('saml', { + status: 'unverified', + external_verification_redirect_url: 'https://idp.example/old-user-link', + }), + }, + client: null, + }), + ); + await create.mock.results[0]?.value; + }); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('does not offer the previous user connection while the next user query loads', async () => { + const fapi = serveUsers(); + const nextQuery = deferred(); + let nextReceived = false; + worker.use( + http.get(fapiUrl('/v1/me/enterprise_connections'), () => { + if (fapi.client.last_active_session_id === 'sess_2') { + nextReceived = true; + return nextQuery.promise; + } + return HttpResponse.json({ response: [okta], client: null }); + }), + ); + const { clerk } = await renderWithClerk(); + expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeVisible(); + await act(() => clerk.setActive({ session: 'sess_2' })); + await waitFor(() => expect(nextReceived).toBe(true)); + expect(clerk.user?.id).toBe('user_2'); + try { + expect(screen.queryByRole('button', { name: 'Connect Acme Okta' })).toBeNull(); + } finally { + await act(async () => { + nextQuery.resolve(HttpResponse.json({ response: [], client: null })); + await nextQuery.promise; + }); + } + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx index 06765f7b4cf..1b0f1718c8c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -52,17 +52,17 @@ describe('enterprise accounts', () => { it('hides the section when SSO is disabled', async () => { await renderSection(signedIn({ environment: fapiEnvironment() })); - expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull(); + expect(screen.queryByRole('group', { name: 'Enterprise accounts' })).toBeNull(); }); it('hides the section when the user is signed out', async () => { await renderSection(signedIn({ client: fapiClient() })); - expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull(); + expect(screen.queryByRole('group', { name: 'Enterprise accounts' })).toBeNull(); }); it('hides an empty section', async () => { await renderSection(signedIn({ enterpriseConnections: [] })); - expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull(); + expect(screen.queryByRole('group', { name: 'Enterprise accounts' })).toBeNull(); }); it('shows only unlinked, organization-linkable connections', async () => { From ccae608db00932a1df2d4d7889e91df5de43468b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:21:38 -0600 Subject: [PATCH 09/22] fix(mosaic): keep enterprise linking scoped to the current user --- .../user-profile-enterprise-accounts-section.model.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index 3dd82f2ab43..e198084e487 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -79,6 +79,7 @@ export function useUserProfileEnterpriseAccountsModel({ const environment = useMosaicEnvironment(); const { data: connections = [] } = __internal_useUserEnterpriseConnections({ withOrganizationAccountLinking: true, + keepPreviousData: false, enabled: Boolean(isLoaded && user && environment?.userSettings.enterpriseSSO.enabled), }); @@ -120,6 +121,7 @@ export function useUserProfileEnterpriseAccountsModel({ const url = window.location.href; const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url; const account = await current.createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl }); + currentUser(); const redirect = account.verification?.externalVerificationRedirectURL; if (!redirect) { throw new EnterpriseAccountActionError('missing_verification_url'); From bd20e46ae0dab39ae2332dd87dd401aa8c3d00ac Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:22:46 -0600 Subject: [PATCH 10/22] test(mosaic): cover localized enterprise API errors --- ...ofile-enterprise-accounts.feature.test.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx index 1b0f1718c8c..f46c277c3bd 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -15,6 +15,7 @@ import { fapiVerification, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta' }); @@ -233,4 +234,34 @@ describe('enterprise accounts', () => { expect(screen.queryByRole('dialog')).toBeNull(); expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled(); }); + + it.each([ + { meta: undefined, message: 'La connexion a échoué.' }, + { meta: { param_name: 'enterprise_connection_id' }, message: 'Cette connexion est indisponible.' }, + ])('localizes a coded API error with $meta', async ({ meta, message }) => { + serveFapi(signedIn()); + worker.use( + http.post(fapiUrl('/v1/me/external_accounts'), () => + HttpResponse.json( + { errors: [{ code: 'form_param_invalid', message: 'Server fallback', meta }] }, + { status: 422 }, + ), + ), + ); + await renderWithClerk( + + + , + ); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + expect(await screen.findByRole('alert')).toHaveTextContent(message); + }); }); From 4a6d9138a3b50ccaaeb3aa62a833ca3fd78a3a8a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:24:43 -0600 Subject: [PATCH 11/22] fix(mosaic): localize enterprise errors at the model boundary --- ...ofile-enterprise-accounts-feedback.test.ts | 32 +++++++++---------- ...er-profile-enterprise-accounts-feedback.ts | 24 +++++++------- ...prise-accounts-section.controller.test.tsx | 12 +++---- ...-enterprise-accounts-section.controller.ts | 6 ++-- ...-enterprise-accounts-section.model.test.ts | 12 +++++-- ...ofile-enterprise-accounts-section.model.ts | 17 +++++++--- ...er-profile-enterprise-accounts-section.tsx | 3 +- ...ofile-enterprise-accounts-section.types.ts | 7 ---- packages/mosaic/src/localization/index.ts | 2 ++ packages/mosaic/src/localization/registry.ts | 2 ++ 10 files changed, 62 insertions(+), 55 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts index e0039f4672e..ef76c3841f4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts @@ -3,24 +3,15 @@ import { describe, expect, it } from 'vitest'; import { resolveLocalization } from '../../../localization'; import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback'; -import { EnterpriseAccountActionError } from './user-profile-enterprise-accounts-section.types'; const messages = resolveLocalization({ locale: 'en' }).messages.userProfileEnterpriseAccountsSection; +const errorText = ({ message }: { message?: string }) => message || messages.errors.generic; function apiError(data: ConstructorParameters[1]['data']) { return new ClerkAPIResponseError('Invalid', { status: 422, data }); } describe('enterprise account error messages', () => { - it('localizes errors raised by the model', () => { - expect(enterpriseAccountErrorMessage(new EnterpriseAccountActionError('unavailable'), messages)).toBe( - messages.errors.unavailable, - ); - expect(enterpriseAccountErrorMessage(new EnterpriseAccountActionError('missing_verification_url'), messages)).toBe( - messages.errors.missingVerificationUrl, - ); - }); - it('shows the first API error, preferring its long message', () => { expect( enterpriseAccountErrorMessage( @@ -28,16 +19,25 @@ describe('enterprise account error messages', () => { { code: 'oauth_error', message: 'Short', long_message: 'The identity provider is unavailable.' }, { code: 'other', message: 'Second' }, ]), - messages, + errorText, + messages.errors.generic, ), ).toBe('The identity provider is unavailable.'); - expect(enterpriseAccountErrorMessage(apiError([{ code: 'oauth_error', message: 'Short' }]), messages)).toBe( - 'Short', - ); + expect( + enterpriseAccountErrorMessage( + apiError([{ code: 'oauth_error', message: 'Short' }]), + errorText, + messages.errors.generic, + ), + ).toBe('Short'); }); it('falls back to the generic message', () => { - expect(enterpriseAccountErrorMessage(apiError([]), messages)).toBe(messages.errors.generic); - expect(enterpriseAccountErrorMessage(new TypeError('Failed to fetch'), messages)).toBe(messages.errors.generic); + expect(enterpriseAccountErrorMessage(apiError([]), errorText, messages.errors.generic)).toBe( + messages.errors.generic, + ); + expect(enterpriseAccountErrorMessage(new TypeError('Failed to fetch'), errorText, messages.errors.generic)).toBe( + messages.errors.generic, + ); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts index 577c03ca042..9449be43e02 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts @@ -1,19 +1,19 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; -import type { MosaicMessages } from '../../../localization'; -import { EnterpriseAccountActionError } from './user-profile-enterprise-accounts-section.types'; +import type { LocalizableError } from '../../../localization'; -type Messages = MosaicMessages['userProfileEnterpriseAccountsSection']; - -export function enterpriseAccountErrorMessage(error: unknown, messages: Messages): string { - if (error instanceof EnterpriseAccountActionError) { - return error.code === 'missing_verification_url' - ? messages.errors.missingVerificationUrl - : messages.errors.unavailable; - } +export function enterpriseAccountErrorMessage( + error: unknown, + errorText: (error: LocalizableError) => string, + fallback: string, +): string { if (isClerkAPIResponseError(error)) { const first = error.errors[0]; - return first?.longMessage || first?.message || messages.errors.generic; + return errorText({ + code: first?.code, + paramName: first?.meta?.paramName, + message: first?.longMessage || first?.message || fallback, + }); } - return messages.errors.generic; + return fallback; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx index dcf79825bd0..98ebb414e7d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx @@ -18,7 +18,7 @@ function deferred() { return { promise, resolve, reject }; } -const formatError = () => 'Something went wrong.'; +const errorMessage = 'Something went wrong.'; afterEach(() => vi.useRealTimers()); @@ -27,7 +27,7 @@ describe('useUserProfileEnterpriseAccountsController', () => { const operation = deferred<'redirecting'>(); const onConnect = vi.fn(() => operation.promise); const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, formatError }), + useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, errorMessage }), ); act(() => { @@ -49,7 +49,7 @@ describe('useUserProfileEnterpriseAccountsController', () => { vi.useFakeTimers(); const onConnect = vi.fn().mockResolvedValue('redirecting'); const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, formatError }), + useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, errorMessage }), ); await act(async () => { result.current.onConnect?.('okta'); @@ -64,16 +64,14 @@ describe('useUserProfileEnterpriseAccountsController', () => { it('shows a formatted error on the failed connection and clears it on retry', async () => { const onConnect = vi .fn() - .mockRejectedValueOnce(new Error('raw')) + .mockRejectedValueOnce(new Error('Formatted error.')) .mockReturnValueOnce(new Promise(() => {})); - const format = vi.fn(() => 'Formatted error.'); const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, formatError: format }), + useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, errorMessage }), ); act(() => result.current.onConnect('okta')); await waitFor(() => expect(result.current.connections[0].connectError).toBe('Formatted error.')); - expect(format).toHaveBeenCalledWith(expect.objectContaining({ message: 'raw' })); expect(result.current.connections[1].connectError).toBeUndefined(); act(() => result.current.onConnect('okta')); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts index 8751f1d3300..b53c19e4a3f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts @@ -10,7 +10,7 @@ export interface UserProfileEnterpriseAccountsControllerOptions { accounts: UserProfileEnterpriseAccount[]; connections: UserProfileEnterpriseConnection[]; onConnect: (connectionId: string) => Promise; - formatError: (error: unknown) => string; + errorMessage: string; } export interface UserProfileEnterpriseAccountsController { @@ -24,7 +24,7 @@ export function useUserProfileEnterpriseAccountsController({ accounts, connections, onConnect, - formatError, + errorMessage, }: UserProfileEnterpriseAccountsControllerOptions): UserProfileEnterpriseAccountsController { const [pendingId, setPendingId] = useState(); const [connectErrors, setConnectErrors] = useState>({}); @@ -43,7 +43,7 @@ export function useUserProfileEnterpriseAccountsController({ await new Promise(resolve => setTimeout(resolve, 2000)); } } catch (error) { - setConnectErrors(current => ({ ...current, [id]: formatError(error) })); + setConnectErrors(current => ({ ...current, [id]: error instanceof Error ? error.message : errorMessage })); } finally { connecting.current = false; setPendingId(undefined); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts index 183b9a467b1..589b09cc4a4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts @@ -83,14 +83,18 @@ describe('useUserProfileEnterpriseAccountsModel', () => { user = change === 'signed out' ? null : createUser('user_2'); - await expect(model.connect('okta')).rejects.toMatchObject({ code: 'unavailable' }); + await expect(model.connect('okta')).rejects.toMatchObject({ + message: 'This enterprise connection is no longer available.', + }); expect(original?.createExternalAccount).not.toHaveBeenCalled(); }); it('rejects a connection that is not offered', async () => { const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); - await expect(model.connect('saml')).rejects.toMatchObject({ code: 'unavailable' }); + await expect(model.connect('saml')).rejects.toMatchObject({ + message: 'This enterprise connection is no longer available.', + }); expect(user?.createExternalAccount).not.toHaveBeenCalled(); }); @@ -98,7 +102,9 @@ describe('useUserProfileEnterpriseAccountsModel', () => { user?.createExternalAccount.mockResolvedValue({ verification: null }); const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); - await expect(model.connect('okta')).rejects.toMatchObject({ code: 'missing_verification_url' }); + await expect(model.connect('okta')).rejects.toMatchObject({ + message: 'The connection could not start. Please try again.', + }); expect(clerk.__internal_windowNavigate).not.toHaveBeenCalled(); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index e198084e487..de6376393ef 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -4,12 +4,13 @@ import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@cle import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { useErrorText, useMessages } from '../../../localization'; +import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback'; import type { EnterpriseAccountActionResult, UserProfileEnterpriseAccount, UserProfileEnterpriseConnection, } from './user-profile-enterprise-accounts-section.types'; -import { EnterpriseAccountActionError } from './user-profile-enterprise-accounts-section.types'; type Account = Pick & { verification?: { error?: { longMessage?: string | null } | null } | null; @@ -75,6 +76,8 @@ export function useUserProfileEnterpriseAccountsModel({ mode, }: { mode?: 'modal' | 'mounted' } = {}): UserProfileEnterpriseAccountsModel { const clerk = useClerk(); + const m = useMessages('userProfileEnterpriseAccountsSection'); + const errorText = useErrorText(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); const { data: connections = [] } = __internal_useUserEnterpriseConnections({ @@ -104,7 +107,7 @@ export function useUserProfileEnterpriseAccountsModel({ const currentUser = () => { const current = clerk.user; if (!current || current.id !== userId) { - throw new EnterpriseAccountActionError('unavailable'); + throw new Error(m.errors.unavailable); } return current; }; @@ -116,15 +119,19 @@ export function useUserProfileEnterpriseAccountsModel({ connect: async connectionId => { const current = currentUser(); if (!projection.connections.some(connection => connection.id === connectionId)) { - throw new EnterpriseAccountActionError('unavailable'); + throw new Error(m.errors.unavailable); } const url = window.location.href; const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url; - const account = await current.createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl }); + const account = await current + .createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl }) + .catch(error => { + throw new Error(enterpriseAccountErrorMessage(error, errorText, m.errors.generic)); + }); currentUser(); const redirect = account.verification?.externalVerificationRedirectURL; if (!redirect) { - throw new EnterpriseAccountActionError('missing_verification_url'); + throw new Error(m.errors.missingVerificationUrl); } if (typeof clerk.__internal_windowNavigate === 'function') { clerk.__internal_windowNavigate(redirect); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx index b5387099ebb..15411130eaf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react'; import { useMessages } from '../../../localization'; -import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback'; import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller'; import type { UserProfileEnterpriseAccountsModel } from './user-profile-enterprise-accounts-section.model'; import { useUserProfileEnterpriseAccountsModel } from './user-profile-enterprise-accounts-section.model'; @@ -34,7 +33,7 @@ function EnterpriseAccounts({ model }: { model: Extract enterpriseAccountErrorMessage(error, m), + errorMessage: m.errors.generic, }); return ; diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts index e1114cd055d..65d8e73e13d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.types.ts @@ -11,10 +11,3 @@ export interface UserProfileEnterpriseAccount extends UserProfileEnterpriseConne } export type EnterpriseAccountActionResult = 'redirecting' | void; - -export class EnterpriseAccountActionError extends Error { - constructor(readonly code: 'unavailable' | 'missing_verification_url') { - super(code); - this.name = 'EnterpriseAccountActionError'; - } -} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index 8d5633e4bac..5a0ef3edfab 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -6,3 +6,5 @@ export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages'; export { fill, plural, rich } from './messages'; +export { useErrorText } from './errors'; +export type { LocalizableError } from './errors'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index f7dc074d2cd..ca7ee13e5f9 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -28,6 +28,8 @@ import { userProfilePasswordSectionMessages } from '../features/user-profile/use import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; import { errorMessages } from './errors.messages'; +import { errorMessages } from './errors.messages'; + export const mosaicMessages = { apiKeysTable: apiKeysTableMessages, errors: errorMessages, From e6559a6b7b90359a336b7c25c179cb772f55e993 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:26:43 -0600 Subject: [PATCH 12/22] test(mosaic): expose enterprise linking fake contract gaps --- ...erprise-accounts-contract.feature.test.tsx | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx new file mode 100644 index 00000000000..840a20b544d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx @@ -0,0 +1,80 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnterpriseConnection, + fapiEnvironment, + fapiMembership, + fapiOrganization, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; + +const member = fapiUser({ + id: 'user_member', + organization_memberships: [fapiMembership(fapiOrganization({ id: 'org_acme' }))], +}); +const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta', organization_id: 'org_acme' }); + +function serveMember(connections = [okta]) { + return serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_member', user: member })]), + environment: fapiEnvironment({ + user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } }, + }), + enterpriseConnections: connections, + }); +} + +describe('enterprise linking server contract', () => { + it('offers only active organization connections the user belongs to', async () => { + serveMember([ + okta, + fapiEnterpriseConnection({ id: 'inactive', active: false, organization_id: 'org_acme' }), + fapiEnterpriseConnection({ id: 'unscoped', organization_id: null }), + fapiEnterpriseConnection({ id: 'nonmember', organization_id: 'org_other' }), + fapiEnterpriseConnection({ + id: 'disabled', + allow_organization_account_linking: false, + organization_id: 'org_acme', + }), + ]); + await renderWithClerk(); + expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeVisible(); + expect(screen.getAllByRole('button', { name: /^Connect / })).toHaveLength(1); + }); + + it('rejects a connection deactivated after it was offered', async () => { + const fapi = serveMember(); + const { clerk } = await renderWithClerk(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + fapi.enterpriseConnections = [{ ...okta, active: false }]; + await userEvent.setup().click(connect); + expect(await screen.findByRole('alert')).toHaveTextContent('not found'); + expect(navigate).not.toHaveBeenCalled(); + expect(connect).toBeEnabled(); + }); + + it('starts SAML with verification only and no pending account', async () => { + const fapi = serveMember(); + const { clerk } = await renderWithClerk(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + if (!clerk.user) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(clerk.user, 'createExternalAccount'); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => expect(navigate).toHaveBeenCalledOnce()); + const account = await create.mock.results[0]?.value; + expect(account?.verification?.strategy).toBe('saml'); + expect(account?.provider).toBeUndefined(); + expect(fapi.client.sessions[0]?.user.external_accounts).toEqual([]); + expect(fapi.client.sessions[0]?.user.enterprise_accounts).toEqual([]); + }); +}); From da3e6acd361d1fa8209de322840744a7f9073f04 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:43:57 -0600 Subject: [PATCH 13/22] test(mosaic): model enterprise linking eligibility and protocol outcomes --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 35 +--- .../__tests__/feature/fake-fapi/enterprise.ts | 117 +++++++++++ .../__tests__/enterprise-accounts.fixtures.ts | 40 ++++ ...erprise-accounts-contract.feature.test.tsx | 191 +++++++++++++++--- ...erprise-accounts-identity.feature.test.tsx | 29 +-- ...ofile-enterprise-accounts.feature.test.tsx | 42 ++-- 6 files changed, 357 insertions(+), 97 deletions(-) create mode 100644 packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts create mode 100644 packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index e8df26a124b..d1ffc4c8575 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -13,6 +13,7 @@ import type { import { http, HttpResponse, type JsonBodyType } from 'msw'; import { setupWorker } from 'msw/browser'; +import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise'; import { createVerificationState, type FakeVerificationSeed, @@ -47,10 +48,12 @@ export interface FakeFapiState { verification: FakeVerificationState; passwordUpdates: URLSearchParams[]; enterpriseConnections: EnterpriseConnectionJSON[]; + enterpriseLinking: FakeEnterpriseLinking; } -export type FakeFapiSeed = Partial> & { +export type FakeFapiSeed = Partial> & { verification?: FakeVerificationSeed; + enterpriseLinking?: Partial; }; const unhandled: string[] = []; @@ -111,7 +114,7 @@ function missing() { } export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { - const { verification, ...rest } = seed; + const { verification, enterpriseLinking, ...rest } = seed; const state: FakeFapiState = { environment: fapiEnvironment(), client: fapiClient(), @@ -123,20 +126,14 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { enterpriseConnections: [], ...rest, verification: createVerificationState(verification), + enterpriseLinking: { enabled: false, preparations: {}, verifiedLinks: [], ...enterpriseLinking }, }; worker.use( ...verificationHandlers(state, fapiUrl), + ...enterpriseHandlers(state, fapiUrl), http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), - http.get(fapiUrl('/v1/me/enterprise_connections'), ({ request }) => { - const url = new URL(request.url); - const withLinking = url.searchParams.get('with_organization_account_linking') === 'true'; - const connections = withLinking - ? state.enterpriseConnections.filter(connection => connection.allow_organization_account_linking) - : state.enterpriseConnections; - return envelope(connections, state.client); - }), http.get(fapiUrl('/v1/me'), () => { const user = activeUser(state); return user ? envelope(user, state.client) : missing(); @@ -147,24 +144,6 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { return missing(); } const body = new URLSearchParams(await request.text()); - const enterpriseConnectionId = body.get('enterprise_connection_id'); - if (enterpriseConnectionId) { - const connection = state.enterpriseConnections.find(item => item.id === enterpriseConnectionId); - if (!connection) { - return missing(); - } - return envelope( - fapiExternalAccount({ - id: `idn_${connection.id}`, - provider: 'google', - verification: fapiVerification('enterprise_sso', { - status: 'unverified', - external_verification_redirect_url: 'https://accounts.example/enterprise-authorize', - }), - }), - state.client, - ); - } const strategy = body.get('strategy'); if (!strategy) { return missing(); diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts new file mode 100644 index 00000000000..09619edb524 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts @@ -0,0 +1,117 @@ +import type { ExternalAccountJSON, VerificationJSON } from '@clerk/shared/types'; +import { http, HttpResponse } from 'msw'; + +import type { FakeFapiState } from '../fake-fapi'; + +export type EnterprisePreparation = + | { kind: 'saml'; verification: VerificationJSON } + | { kind: 'oidc'; account: ExternalAccountJSON }; + +export interface FakeEnterpriseLinking { + enabled: boolean; + preparations: Record; + verifiedLinks: { userId: string; connectionId: string }[]; +} + +function rejected(code: 'resource_not_found' | 'feature_not_enabled') { + return HttpResponse.json( + { errors: [{ code, message: code === 'resource_not_found' ? 'not found' : 'Feature not enabled' }] }, + { status: code === 'resource_not_found' ? 404 : 403 }, + ); +} + +export function enterpriseHandlers(state: FakeFapiState, url: (path: string) => string) { + return [ + http.get(url('/v1/me/enterprise_connections'), ({ request }) => { + const user = state.client.sessions.find(session => session.id === state.client.last_active_session_id)?.user; + if (!user) { + return rejected('resource_not_found'); + } + const withLinking = new URL(request.url).searchParams.get('with_organization_account_linking') === 'true'; + const connections = withLinking + ? state.enterpriseConnections.filter( + connection => + connection.active && + connection.organization_id && + connection.allow_organization_account_linking && + user.organization_memberships.some( + membership => membership.organization.id === connection.organization_id, + ) && + !state.enterpriseLinking.verifiedLinks.some( + link => link.userId === user.id && link.connectionId === connection.id, + ), + ) + : state.enterpriseConnections; + return HttpResponse.json({ response: connections, client: state.client }); + }), + http.post(url('/v1/me/external_accounts'), async ({ request }) => { + const user = state.client.sessions.find(session => session.id === state.client.last_active_session_id)?.user; + if (!user) { + return rejected('resource_not_found'); + } + const body = new URLSearchParams(await request.text()); + const connectionId = body.get('enterprise_connection_id'); + if (!connectionId) { + return undefined; + } + if (!state.enterpriseLinking.enabled) { + return rejected('feature_not_enabled'); + } + const connection = state.enterpriseConnections.find(item => item.id === connectionId); + if (!connection?.active || !connection.organization_id) { + return rejected('resource_not_found'); + } + if (!connection.allow_organization_account_linking) { + return rejected('feature_not_enabled'); + } + if ( + !user.organization_memberships.some(membership => membership.organization.id === connection.organization_id) + ) { + return rejected('resource_not_found'); + } + const preparation = state.enterpriseLinking.preparations[connection.id]; + if (!preparation) { + throw new Error(`Missing enterprise preparation for ${connection.id}`); + } + if (preparation.kind === 'saml') { + if ( + !connection.provider.startsWith('saml_') || + preparation.verification.strategy !== 'saml' || + preparation.verification.status !== 'unverified' + ) { + throw new Error(`Invalid SAML preparation for ${connection.id}`); + } + return HttpResponse.json({ + response: { object: 'external_account', verification: preparation.verification }, + client: state.client, + }); + } + const account = preparation.account; + if ( + !connection.provider.startsWith('oidc_') || + account.verification?.strategy !== `oauth_${account.provider}` || + account.verification.status !== 'unverified' + ) { + throw new Error(`Invalid OIDC preparation for ${connection.id}`); + } + state.client = { + ...state.client, + sessions: state.client.sessions.map(session => + session.user.id === user.id + ? { + ...session, + user: { + ...session.user, + external_accounts: [ + ...session.user.external_accounts.filter(item => item.id !== account.id), + account, + ], + }, + } + : session, + ), + }; + return HttpResponse.json({ response: account, client: state.client }); + }), + ]; +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts b/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts new file mode 100644 index 00000000000..8d5dcd77ae3 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts @@ -0,0 +1,40 @@ +import type { FakeFapiSeed } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnterpriseConnection, + fapiEnvironment, + fapiMembership, + fapiOrganization, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../__tests__/feature/fapi'; + +export const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta', organization_id: 'org_acme' }); +export const custom = fapiEnterpriseConnection({ id: 'saml', name: 'Custom SAML', organization_id: 'org_acme' }); + +export function enterpriseMember(id = 'user_1') { + return fapiUser({ + id, + organization_memberships: [fapiMembership(fapiOrganization({ id: 'org_acme', name: 'Acme' }))], + }); +} + +export function enterpriseAccountSeed(overrides: FakeFapiSeed = {}): FakeFapiSeed { + const verification = fapiVerification('saml', { + status: 'unverified', + external_verification_redirect_url: 'https://accounts.example/enterprise-authorize', + }); + return { + client: fapiClient([fapiSession({ id: 'sess_1', user: enterpriseMember() })]), + environment: fapiEnvironment({ + user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } }, + }), + enterpriseConnections: [okta, custom], + enterpriseLinking: { + enabled: true, + preparations: { okta: { kind: 'saml', verification }, saml: { kind: 'saml', verification } }, + }, + ...overrides, + }; +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx index 840a20b544d..c35c097a387 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx @@ -1,34 +1,24 @@ -import { screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { http } from 'msw'; import { describe, expect, it, vi } from 'vitest'; -import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, + fapiEnterpriseAccount, fapiEnterpriseConnection, - fapiEnvironment, - fapiMembership, - fapiOrganization, + fapiExternalAccount, fapiSession, fapiUser, + fapiVerification, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; - -const member = fapiUser({ - id: 'user_member', - organization_memberships: [fapiMembership(fapiOrganization({ id: 'org_acme' }))], -}); -const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta', organization_id: 'org_acme' }); +import { enterpriseAccountSeed, enterpriseMember, okta } from './enterprise-accounts.fixtures'; function serveMember(connections = [okta]) { - return serveFapi({ - client: fapiClient([fapiSession({ id: 'sess_member', user: member })]), - environment: fapiEnvironment({ - user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } }, - }), - enterpriseConnections: connections, - }); + return serveFapi(enterpriseAccountSeed({ enterpriseConnections: connections })); } describe('enterprise linking server contract', () => { @@ -49,16 +39,59 @@ describe('enterprise linking server contract', () => { expect(screen.getAllByRole('button', { name: /^Connect / })).toHaveLength(1); }); - it('rejects a connection deactivated after it was offered', async () => { - const fapi = serveMember(); + it.each(['inactive', 'unscoped', 'linking disabled', 'nonmember', 'feature disabled'] as const)( + 'rejects an offered connection that becomes %s', + async change => { + const fapi = serveMember(); + const { clerk } = await renderWithClerk(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + if (change === 'inactive') { + fapi.enterpriseConnections = [{ ...okta, active: false }]; + } else if (change === 'unscoped') { + fapi.enterpriseConnections = [{ ...okta, organization_id: null }]; + } else if (change === 'linking disabled') { + fapi.enterpriseConnections = [{ ...okta, allow_organization_account_linking: false }]; + } else if (change === 'nonmember') { + fapi.client = fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]); + } else { + fapi.enterpriseLinking.enabled = false; + } + await userEvent.setup().click(connect); + expect(await screen.findByRole('alert')).toHaveTextContent( + change === 'linking disabled' || change === 'feature disabled' ? 'Feature not enabled' : 'not found', + ); + expect(navigate).not.toHaveBeenCalled(); + expect(connect).toBeEnabled(); + }, + ); + + it('discovers memberships independently for each user', async () => { + serveFapi( + enterpriseAccountSeed({ + client: fapiClient([ + fapiSession({ id: 'sess_1', user: enterpriseMember() }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + }), + ); + const { clerk } = await renderWithClerk(); + expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeVisible(); + await act(() => clerk.setActive({ session: 'sess_2' })); + await waitFor(() => expect(screen.queryByRole('group', { name: 'Enterprise accounts' })).toBeNull()); + expect(clerk.user?.id).toBe('user_2'); + expect(await clerk.user?.getEnterpriseConnections({ withOrganizationAccountLinking: true })).toEqual([]); + await act(() => clerk.setActive({ session: 'sess_1' })); + expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeVisible(); + }); + + it('keeps discovery independent of the POST-only feature flag', async () => { + serveFapi(enterpriseAccountSeed({ enterpriseLinking: {} })); const { clerk } = await renderWithClerk(); const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); - const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); - fapi.enterpriseConnections = [{ ...okta, active: false }]; - await userEvent.setup().click(connect); - expect(await screen.findByRole('alert')).toHaveTextContent('not found'); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Feature not enabled'); expect(navigate).not.toHaveBeenCalled(); - expect(connect).toBeEnabled(); }); it('starts SAML with verification only and no pending account', async () => { @@ -73,8 +106,114 @@ describe('enterprise linking server contract', () => { await waitFor(() => expect(navigate).toHaveBeenCalledOnce()); const account = await create.mock.results[0]?.value; expect(account?.verification?.strategy).toBe('saml'); - expect(account?.provider).toBeUndefined(); + expect(account?.provider).toBe(''); + expect(account?.id).toBeUndefined(); expect(fapi.client.sessions[0]?.user.external_accounts).toEqual([]); expect(fapi.client.sessions[0]?.user.enterprise_accounts).toEqual([]); }); + + it.each(['saml', 'oidc'] as const)( + 'shows the explicit %s callback result after reloading the returned page', + async protocol => { + const connection = + protocol === 'saml' + ? okta + : fapiEnterpriseConnection({ + id: 'oidc', + name: 'Acme OIDC', + organization_id: 'org_acme', + provider: 'oidc_custom', + oauth_config: { + id: 'oauthcfg_1', + name: 'Acme OIDC', + client_id: 'oauth_client', + provider_key: 'custom_mock', + created_at: 0, + updated_at: 0, + }, + }); + const pending = fapiExternalAccount({ + id: 'idn_oidc', + provider: 'custom_mock', + verification: fapiVerification('oauth_custom_mock', { + status: 'unverified', + external_verification_redirect_url: 'https://accounts.example/oidc-authorize', + }), + }); + const fapi = serveFapi( + enterpriseAccountSeed({ + enterpriseConnections: [connection], + enterpriseLinking: { + enabled: true, + preparations: { + [connection.id]: + protocol === 'saml' + ? { + kind: 'saml', + verification: fapiVerification('saml', { + status: 'unverified', + external_verification_redirect_url: 'https://accounts.example/saml-authorize', + }), + } + : { kind: 'oidc', account: pending }, + }, + }, + }), + ); + let queries = 0; + worker.use( + http.get(fapiUrl('/v1/me/enterprise_connections'), () => { + queries++; + }), + ); + const view = await renderWithClerk(); + const navigate = vi.spyOn(view.clerk, '__internal_windowNavigate').mockImplementation(() => {}); + await userEvent.setup().click(await screen.findByRole('button', { name: `Connect ${connection.name}` })); + await waitFor(() => expect(navigate).toHaveBeenCalledOnce()); + expect(fapi.client.sessions[0]?.user.external_accounts).toEqual(protocol === 'oidc' ? [pending] : []); + expect(fapi.client.sessions[0]?.user.enterprise_accounts).toEqual([]); + const linked = fapiEnterpriseAccount({ + id: 'ent_linked', + protocol: protocol === 'saml' ? 'saml' : 'oauth', + provider: protocol === 'saml' ? 'saml_okta' : 'oauth_custom_mock', + email_address: 'linked@example.com', + enterprise_connection_id: protocol === 'saml' ? connection.id : null, + verification: fapiVerification(protocol === 'saml' ? 'saml' : 'oauth_custom_mock', { status: 'verified' }), + }); + if (!linked.enterprise_connection) { + throw new Error('Expected enterprise connection fixture'); + } + linked.enterprise_connection = { + ...linked.enterprise_connection, + id: protocol === 'saml' ? connection.id : 'oauthcfg_1', + name: connection.name, + protocol: linked.protocol, + provider: linked.provider, + enterprise_connection_id: protocol === 'saml' ? connection.id : null, + }; + const completed = fapiUser({ + ...enterpriseMember(), + enterprise_accounts: [linked], + external_accounts: + protocol === 'oidc' + ? [{ ...pending, verification: fapiVerification('oauth_custom_mock', { status: 'verified' }) }] + : [], + }); + fapi.client = fapiClient([fapiSession({ id: 'sess_1', user: completed })]); + fapi.enterpriseLinking.verifiedLinks = [{ userId: completed.id, connectionId: connection.id }]; + await act(async () => { + if (!view.clerk.client) { + throw new Error('Expected loaded client'); + } + await view.clerk.client.reload(); + await view.clerk.setActive({ session: 'sess_1' }); + }); + expect(await screen.findByText('linked@example.com')).toBeVisible(); + const initialQueries = queries; + view.rerender(); + await waitFor(() => expect(queries).toBeGreaterThan(initialQueries)); + await waitFor(() => expect(screen.queryByRole('button', { name: `Connect ${connection.name}` })).toBeNull()); + expect(await view.clerk.user?.getEnterpriseConnections({ withOrganizationAccountLinking: true })).toEqual([]); + }, + ); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx index d4b5423a014..a0899bf2e9a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx @@ -4,30 +4,21 @@ import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; import { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; -import { - fapiClient, - fapiEnterpriseConnection, - fapiEnvironment, - fapiSession, - fapiUser, - fapiVerification, -} from '../../../__tests__/feature/fapi'; +import { fapiClient, fapiSession, fapiUser, fapiVerification } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; - -const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta' }); +import { enterpriseAccountSeed, enterpriseMember, okta } from './enterprise-accounts.fixtures'; function serveUsers() { - return serveFapi({ - client: fapiClient([ - fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }), - fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), - ]), - environment: fapiEnvironment({ - user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } }, + return serveFapi( + enterpriseAccountSeed({ + client: fapiClient([ + fapiSession({ id: 'sess_1', user: enterpriseMember() }), + fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }), + ]), + enterpriseConnections: [okta], }), - enterpriseConnections: [okta], - }); + ); } function deferred() { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx index f46c277c3bd..ef8c3920b4c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -9,7 +9,6 @@ import { fapiEnterpriseAccount, fapiEnterpriseConnection, fapiEnvironment, - fapiExternalAccount, fapiSession, fapiUser, fapiVerification, @@ -17,20 +16,7 @@ import { import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; - -const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta' }); -const custom = fapiEnterpriseConnection({ id: 'saml', name: 'Custom SAML' }); - -function signedIn(overrides: FakeFapiSeed = {}) { - return { - client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), - environment: fapiEnvironment({ - user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } }, - }), - enterpriseConnections: [okta, custom], - ...overrides, - }; -} +import { custom, enterpriseAccountSeed as signedIn, enterpriseMember, okta } from './enterprise-accounts.fixtures'; async function renderSection(seed: FakeFapiSeed = signedIn()) { const fapi = serveFapi(seed); @@ -84,7 +70,7 @@ describe('enterprise accounts', () => { id: 'enterprise_1', enterprise_connection_id: 'okta', email_address: 'linked@example.com', - verification: fapiVerification('enterprise_sso', { + verification: fapiVerification('saml', { status: 'verified', error: { code: 'enterprise_error', message: 'Fix this account', long_message: 'Fix this account' }, }), @@ -93,10 +79,19 @@ describe('enterprise accounts', () => { id: 'enterprise_inactive', enterprise_connection_id: 'inactive', email_address: 'inactive@example.com', - enterprise_connection: fapiEnterpriseConnection({ id: 'inactive', name: 'Inactive SAML', active: false }), }); + if (!inactive.enterprise_connection) { + throw new Error('Expected enterprise connection fixture'); + } + inactive.enterprise_connection = { + ...inactive.enterprise_connection, + id: 'inactive', + name: 'Inactive SAML', + active: false, + enterprise_connection_id: 'inactive', + }; const client = fapiClient([ - fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', enterprise_accounts: [linked, inactive] }) }), + fapiSession({ id: 'sess_1', user: fapiUser({ ...enterpriseMember(), enterprise_accounts: [linked, inactive] }) }), ]); await renderSection( signedIn({ @@ -116,7 +111,7 @@ describe('enterprise accounts', () => { it('keeps linked rows visible while connections load', async () => { const linked = fapiEnterpriseAccount({ id: 'enterprise_1', email_address: 'linked@example.com' }); const client = fapiClient([ - fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', enterprise_accounts: [linked] }) }), + fapiSession({ id: 'sess_1', user: fapiUser({ ...enterpriseMember(), enterprise_accounts: [linked] }) }), ]); serveFapi(signedIn({ client })); const connections = holdRequests('get', '/v1/me/enterprise_connections'); @@ -171,11 +166,10 @@ describe('enterprise accounts', () => { worker.use( http.post(fapiUrl('/v1/me/external_accounts'), () => HttpResponse.json({ - response: fapiExternalAccount({ - id: 'idn_okta', - provider: 'google', - verification: fapiVerification('enterprise_sso', { status: 'unverified' }), - }), + response: { + object: 'external_account', + verification: fapiVerification('saml', { status: 'unverified' }), + }, client: null, }), ), From f0b7bc21140ef8f99c88149d70883ce63b1c2853 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:51:34 -0600 Subject: [PATCH 14/22] test(mosaic): pin enterprise provider and claimed SAML contracts --- ...erprise-accounts-contract.feature.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx index c35c097a387..0c5634949e6 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx @@ -168,8 +168,28 @@ describe('enterprise linking server contract', () => { ); const view = await renderWithClerk(); const navigate = vi.spyOn(view.clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const responsePayload = new Promise(resolve => { + const capture = ({ request, response }: { request: Request; response: Response }) => { + if (request.method === 'POST' && new URL(request.url).pathname === '/v1/me/external_accounts') { + worker.events.removeListener('response:mocked', capture); + resolve(response.clone().json()); + } + }; + worker.events.on('response:mocked', capture); + }); + await userEvent.setup().click(await screen.findByRole('button', { name: `Connect ${connection.name}` })); await waitFor(() => expect(navigate).toHaveBeenCalledOnce()); + if (protocol === 'oidc') { + expect(await responsePayload).toMatchObject({ + response: { provider: 'oauth_custom_mock', verification: { strategy: 'oauth_custom_mock' } }, + }); + } else { + expect(await responsePayload).toMatchObject({ + response: { object: 'external_account', verification: { strategy: 'saml' } }, + }); + } + expect(fapi.client.sessions[0]?.user.external_accounts).toEqual(protocol === 'oidc' ? [pending] : []); expect(fapi.client.sessions[0]?.user.enterprise_accounts).toEqual([]); const linked = fapiEnterpriseAccount({ @@ -216,4 +236,31 @@ describe('enterprise linking server contract', () => { expect(await view.clerk.user?.getEnterpriseConnections({ withOrganizationAccountLinking: true })).toEqual([]); }, ); + + it('rejects a stale SAML offer after that connection has been claimed', async () => { + const fapi = serveMember(); + const { clerk } = await renderWithClerk(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + fapi.enterpriseLinking.verifiedLinks = [{ userId: 'user_1', connectionId: okta.id }]; + await userEvent.setup().click(connect); + expect(await screen.findByRole('alert')).toHaveTextContent('Already connected'); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('rejects SAML linking when the primary email no longer exists', async () => { + const fapi = serveMember(); + const { clerk } = await renderWithClerk(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + fapi.client = fapiClient([ + fapiSession({ + id: 'sess_1', + user: { ...enterpriseMember(), primary_email_address_id: null, email_addresses: [] }, + }), + ]); + await userEvent.setup().click(connect); + expect(await screen.findByRole('alert')).toHaveTextContent('not found'); + expect(navigate).not.toHaveBeenCalled(); + }); }); From 4559199425d41d29a57f61127acd6768d0682421 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:54:11 -0600 Subject: [PATCH 15/22] test(mosaic): preserve raw enterprise providers and SAML eligibility --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 8 ++- .../__tests__/feature/fake-fapi/enterprise.ts | 51 ++++++++++++------- .../__tests__/enterprise-accounts.fixtures.ts | 2 + ...erprise-accounts-contract.feature.test.tsx | 32 +++++++----- 4 files changed, 61 insertions(+), 32 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index d1ffc4c8575..8dae98cfedd 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -126,7 +126,13 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { enterpriseConnections: [], ...rest, verification: createVerificationState(verification), - enterpriseLinking: { enabled: false, preparations: {}, verifiedLinks: [], ...enterpriseLinking }, + enterpriseLinking: { + enabled: false, + preparations: {}, + verifiedLinks: [], + pendingExternalAccounts: [], + ...enterpriseLinking, + }, }; worker.use( diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts index 09619edb524..f394c06d6ee 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts @@ -3,14 +3,17 @@ import { http, HttpResponse } from 'msw'; import type { FakeFapiState } from '../fake-fapi'; +export type EnterpriseExternalAccount = Omit & { provider: string }; + export type EnterprisePreparation = | { kind: 'saml'; verification: VerificationJSON } - | { kind: 'oidc'; account: ExternalAccountJSON }; + | { kind: 'oidc'; account: EnterpriseExternalAccount }; export interface FakeEnterpriseLinking { enabled: boolean; preparations: Record; verifiedLinks: { userId: string; connectionId: string }[]; + pendingExternalAccounts: { userId: string; connectionId: string; account: EnterpriseExternalAccount }[]; } function rejected(code: 'resource_not_found' | 'feature_not_enabled') { @@ -81,6 +84,28 @@ export function enterpriseHandlers(state: FakeFapiState, url: (path: string) => ) { throw new Error(`Invalid SAML preparation for ${connection.id}`); } + const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); + if (!primaryEmail) { + return rejected('resource_not_found'); + } + if ( + state.enterpriseLinking.verifiedLinks.some( + link => link.userId === user.id && link.connectionId === connection.id, + ) + ) { + return HttpResponse.json( + { + errors: [ + { + code: 'enterprise_sso_account_already_connected', + message: 'Already connected', + long_message: `An enterprise account is already connected for this connection email: ${primaryEmail.email_address}`, + }, + ], + }, + { status: 400 }, + ); + } return HttpResponse.json({ response: { object: 'external_account', verification: preparation.verification }, client: state.client, @@ -89,28 +114,18 @@ export function enterpriseHandlers(state: FakeFapiState, url: (path: string) => const account = preparation.account; if ( !connection.provider.startsWith('oidc_') || - account.verification?.strategy !== `oauth_${account.provider}` || + !account.provider.startsWith('oauth_') || + account.verification?.strategy !== account.provider || account.verification.status !== 'unverified' ) { throw new Error(`Invalid OIDC preparation for ${connection.id}`); } - state.client = { - ...state.client, - sessions: state.client.sessions.map(session => - session.user.id === user.id - ? { - ...session, - user: { - ...session.user, - external_accounts: [ - ...session.user.external_accounts.filter(item => item.id !== account.id), - account, - ], - }, - } - : session, + state.enterpriseLinking.pendingExternalAccounts = [ + ...state.enterpriseLinking.pendingExternalAccounts.filter( + item => item.userId !== user.id || item.account.id !== account.id, ), - }; + { userId: user.id, connectionId: connection.id, account }, + ]; return HttpResponse.json({ response: account, client: state.client }); }), ]; diff --git a/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts b/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts index 8d5dcd77ae3..24d45b1ee05 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts @@ -1,6 +1,7 @@ import type { FakeFapiSeed } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, + fapiEmailAddress, fapiEnterpriseConnection, fapiEnvironment, fapiMembership, @@ -16,6 +17,7 @@ export const custom = fapiEnterpriseConnection({ id: 'saml', name: 'Custom SAML' export function enterpriseMember(id = 'user_1') { return fapiUser({ id, + email_addresses: [fapiEmailAddress({ id: `email_${id}`, email_address: `${id}@example.com` })], organization_memberships: [fapiMembership(fapiOrganization({ id: 'org_acme', name: 'Acme' }))], }); } diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx index 0c5634949e6..d81b3b70096 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx @@ -132,14 +132,17 @@ describe('enterprise linking server contract', () => { updated_at: 0, }, }); - const pending = fapiExternalAccount({ - id: 'idn_oidc', - provider: 'custom_mock', - verification: fapiVerification('oauth_custom_mock', { - status: 'unverified', - external_verification_redirect_url: 'https://accounts.example/oidc-authorize', + const pending = { + ...fapiExternalAccount({ + id: 'idn_oidc', + provider: 'custom_mock', + verification: fapiVerification('oauth_custom_mock', { + status: 'unverified', + external_verification_redirect_url: 'https://accounts.example/oidc-authorize', + }), }), - }); + provider: 'oauth_custom_mock', + }; const fapi = serveFapi( enterpriseAccountSeed({ enterpriseConnections: [connection], @@ -190,7 +193,10 @@ describe('enterprise linking server contract', () => { }); } - expect(fapi.client.sessions[0]?.user.external_accounts).toEqual(protocol === 'oidc' ? [pending] : []); + expect(fapi.client.sessions[0]?.user.external_accounts).toEqual([]); + expect(fapi.enterpriseLinking.pendingExternalAccounts).toEqual( + protocol === 'oidc' ? [{ userId: 'user_1', connectionId: connection.id, account: pending }] : [], + ); expect(fapi.client.sessions[0]?.user.enterprise_accounts).toEqual([]); const linked = fapiEnterpriseAccount({ id: 'ent_linked', @@ -214,13 +220,11 @@ describe('enterprise linking server contract', () => { const completed = fapiUser({ ...enterpriseMember(), enterprise_accounts: [linked], - external_accounts: - protocol === 'oidc' - ? [{ ...pending, verification: fapiVerification('oauth_custom_mock', { status: 'verified' }) }] - : [], + external_accounts: [], }); fapi.client = fapiClient([fapiSession({ id: 'sess_1', user: completed })]); fapi.enterpriseLinking.verifiedLinks = [{ userId: completed.id, connectionId: connection.id }]; + fapi.enterpriseLinking.pendingExternalAccounts = []; await act(async () => { if (!view.clerk.client) { throw new Error('Expected loaded client'); @@ -244,7 +248,9 @@ describe('enterprise linking server contract', () => { const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); fapi.enterpriseLinking.verifiedLinks = [{ userId: 'user_1', connectionId: okta.id }]; await userEvent.setup().click(connect); - expect(await screen.findByRole('alert')).toHaveTextContent('Already connected'); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'An enterprise account is already connected for this connection email: user_1@example.com', + ); expect(navigate).not.toHaveBeenCalled(); }); From 08eeb155917761c9be908c081f13673c2a2b1fc8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:54:33 -0600 Subject: [PATCH 16/22] test(mosaic): pin enterprise section order in the connected host --- ...-enterprise-accounts-host.feature.test.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx new file mode 100644 index 00000000000..472563664e0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx @@ -0,0 +1,22 @@ +import { screen } from '@testing-library/react'; +import { expect, it } from 'vitest'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; +import { enterpriseAccountSeed } from './enterprise-accounts.fixtures'; + +it('places the connected enterprise section before Web3 wallets in the profile host', async () => { + serveFapi(enterpriseAccountSeed()); + await renderWithClerk( + } + web3Wallets={[{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }]} + />, + ); + const enterprise = await screen.findByRole('group', { name: 'Enterprise accounts' }); + const wallets = screen.getByRole('group', { name: 'Web3 wallets' }); + expect(enterprise.compareDocumentPosition(wallets) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); +}); From c10ea6efdceb452155e58f15427e16a43a9d93f3 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:58:03 -0600 Subject: [PATCH 17/22] fix(mosaic): restore enterprise accounts before Web3 wallets --- .../user-profile-enterprise-accounts-host.feature.test.tsx | 3 +++ .../__tests__/user-profile-profile-panel.view.test.tsx | 4 ++-- .../features/user-profile/user-profile-profile-panel.view.tsx | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx index 472563664e0..44bc49eb471 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx @@ -12,6 +12,9 @@ it('places the connected enterprise section before Web3 wallets in the profile h await renderWithClerk( } web3Wallets={[{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }]} />, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index fbad15b724d..480f6562eb3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -284,7 +284,7 @@ describe('UserProfileProfilePanelView', () => { ); }); - it('places enterprise accounts after Web3 wallets and before the danger zone', () => { + it('places enterprise accounts before Web3 wallets and the danger zone', () => { renderView({ web3WalletsSlot: , enterpriseAccountsSlot:
Enterprise accounts
, @@ -294,7 +294,7 @@ describe('UserProfileProfilePanelView', () => { const wallets = screen.getByRole('group', { name: 'Web3 wallets' }); const enterprise = screen.getByTestId('enterprise'); const danger = screen.getByRole('heading', { name: 'Danger zone' }); - expect(wallets.compareDocumentPosition(enterprise) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(enterprise.compareDocumentPosition(wallets) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(enterprise.compareDocumentPosition(danger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 0354672f8ed..287991c959e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -46,8 +46,8 @@ export function UserProfileProfilePanelView({ {...account} /> {connectedAccountsSlot} - {web3WalletsSlot} {enterpriseAccountsSlot} + {web3WalletsSlot} {deleteAccountSlot} From 036720e25fa58563fc22bebddb67ae16b00e1425 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 1 Oct 2026 23:59:04 -0600 Subject: [PATCH 18/22] test(mosaic): replace enterprise hook tests with connected coverage --- ...se-accounts-compatibility.feature.test.tsx | 45 +++++ ...enterprise-accounts-compatibility.test.tsx | 51 ------ ...erprise-accounts-identity.feature.test.tsx | 33 ++++ ...ofile-enterprise-accounts.feature.test.tsx | 85 +++++++++- ...ile-enterprise-accounts-projection.test.ts | 49 ++++++ ...prise-accounts-section.controller.test.tsx | 80 --------- ...-enterprise-accounts-section.model.test.ts | 156 ------------------ ...ofile-enterprise-accounts-section.model.ts | 1 - packages/mosaic/src/localization/registry.ts | 2 - 9 files changed, 211 insertions(+), 291 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-projection.test.ts delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx new file mode 100644 index 00000000000..d48b2e15e5e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx @@ -0,0 +1,45 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, it } from 'vitest'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiVerification } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; +import { enterpriseAccountSeed } from './enterprise-accounts.fixtures'; + +it('uses browser navigation when an older Clerk lacks its internal navigator', async () => { + const originalUrl = window.location.href; + const redirect = new URL(originalUrl); + redirect.hash = 'enterprise-compatible'; + serveFapi( + enterpriseAccountSeed({ + enterpriseLinking: { + enabled: true, + preparations: { + okta: { + kind: 'saml', + verification: fapiVerification('saml', { + status: 'unverified', + external_verification_redirect_url: redirect.href, + }), + }, + }, + }, + }), + ); + const { clerk } = await renderWithClerk(); + const descriptor = Object.getOwnPropertyDescriptor(clerk, '__internal_windowNavigate'); + Object.defineProperty(clerk, '__internal_windowNavigate', { configurable: true, value: undefined }); + try { + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + await waitFor(() => expect(window.location.href).toBe(redirect.href)); + } finally { + window.history.replaceState(null, '', originalUrl); + if (descriptor) { + Object.defineProperty(clerk, '__internal_windowNavigate', descriptor); + } else { + Reflect.deleteProperty(clerk, '__internal_windowNavigate'); + } + } +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx deleted file mode 100644 index 85f5cbc9346..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; - -const fallbackNavigate = vi.fn(); - -const user = { - id: 'user_1', - enterpriseAccounts: [], - createExternalAccount: () => - Promise.resolve({ - verification: { externalVerificationRedirectURL: new URL('https://accounts.example/authorize') }, - }), -}; - -vi.mock('@clerk/shared/internal/clerk-js/windowNavigate', () => ({ - windowNavigate: (url: URL | string) => fallbackNavigate(url), -})); - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ - isLoaded: true, - user, - }), - __internal_useUserEnterpriseConnections: () => ({ - data: [{ id: 'okta', name: 'Acme Okta', allowOrganizationAccountLinking: true }], - }), - useClerk: () => ({ - user, - __internal_environment: { userSettings: { enterpriseSSO: { enabled: true } } }, - __internal_getOption: () => undefined, - }), - }; -}); - -it('uses the shared navigation fallback when an older Clerk lacks the internal method', async () => { - render( - - - , - ); - await userEvent.setup().click(screen.getByRole('button', { name: 'Connect Acme Okta' })); - await waitFor(() => expect(fallbackNavigate).toHaveBeenCalledWith(new URL('https://accounts.example/authorize'))); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx index a0899bf2e9a..d27df4293e2 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx @@ -96,4 +96,37 @@ describe('enterprise account identity', () => { }); } }); + + it.each(['switch', 'sign out'] as const)('rejects a captured click before rendering after %s', async change => { + serveUsers(); + let requests = 0; + worker.use( + http.post(fapiUrl('/v1/me/external_accounts'), () => { + requests++; + return HttpResponse.json( + { errors: [{ code: 'unexpected_request', message: 'Unexpected request' }] }, + { status: 400 }, + ); + }), + ); + const { clerk } = await renderWithClerk(); + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + if (!clerk.user) { + throw new Error('Expected signed-in user'); + } + const create = vi.spyOn(clerk.user, 'createExternalAccount'); + await act(async () => { + await (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut()); + expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined); + expect(connect.isConnected).toBe(true); + connect.click(); + const operation = create.mock.results[0]; + if (operation?.type === 'return') { + await operation.value.catch(() => undefined); + } + }); + expect(create).not.toHaveBeenCalled(); + expect(requests).toBe(0); + expect(screen.queryByRole('button', { name: 'Connect Acme Okta' })).toBeNull(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx index ef8c3920b4c..d6da0a4e48a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; @@ -258,4 +258,87 @@ describe('enterprise accounts', () => { await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); expect(await screen.findByRole('alert')).toHaveTextContent(message); }); + + it('suppresses immediate duplicate and competing clicks, then clears feedback on retry', async () => { + await renderSection(); + const request = holdRequests('post', '/v1/me/external_accounts'); + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + const other = screen.getByRole('button', { name: 'Connect Custom SAML' }); + act(() => { + connect.click(); + connect.click(); + other.click(); + }); + await waitFor(() => expect(request.requests).toHaveLength(1)); + expect(connect).toHaveAttribute('aria-busy', 'true'); + expect(other).toBeDisabled(); + request.fail('enterprise_error', 'Retry this connection.'); + expect(await screen.findByRole('alert')).toHaveTextContent('Retry this connection.'); + const retry = holdRequests('post', '/v1/me/external_accounts'); + await userEvent.setup().click(connect); + await waitFor(() => expect(retry.requests).toHaveLength(1)); + expect(screen.queryByRole('alert')).toBeNull(); + retry.fail(); + }); + + it('releases pending after the redirect grace period', async () => { + const { clerk } = await renderSection(); + const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' }); + await userEvent.setup().click(connect); + await waitFor(() => expect(navigate).toHaveBeenCalledOnce()); + expect(connect).toHaveAttribute('aria-busy', 'true'); + await waitFor(() => expect(connect).not.toHaveAttribute('aria-busy', 'true'), { timeout: 2500 }); + expect(connect).toBeEnabled(); + }); + + it('localizes a client-defined missing redirect error', async () => { + serveFapi(signedIn()); + worker.use( + http.post(fapiUrl('/v1/me/external_accounts'), () => + HttpResponse.json({ + response: { object: 'external_account', verification: fapiVerification('saml', { status: 'unverified' }) }, + client: null, + }), + ), + ); + await renderWithClerk( + + + , + ); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + expect(await screen.findByRole('alert')).toHaveTextContent('La connexion ne peut pas démarrer.'); + }); + + it('preserves the section generic error override for empty API errors', async () => { + serveFapi(signedIn()); + worker.use( + http.post(fapiUrl('/v1/me/external_accounts'), () => HttpResponse.json({ errors: [] }, { status: 400 })), + ); + await renderWithClerk( + + + , + ); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); + expect(await screen.findByRole('alert')).toHaveTextContent('La connexion a échoué. Réessayez.'); + }); + + it.todo('challenges for session reverification before linking, resumes after success, and allows cancellation'); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-projection.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-projection.test.ts new file mode 100644 index 00000000000..b4cbb4d09df --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-projection.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { projectEnterpriseAccounts } from './user-profile-enterprise-accounts-section.model'; + +describe('projectEnterpriseAccounts', () => { + const connection = (id: string, allowOrganizationAccountLinking = true) => ({ + id, + name: id, + allowOrganizationAccountLinking, + }); + const account = (id: string, enterpriseConnectionId: string, active = true, error?: string) => ({ + id, + emailAddress: `${id}@example.com`, + enterpriseConnectionId, + verification: error ? { error: { longMessage: error } } : null, + enterpriseConnection: { active, name: enterpriseConnectionId, logoPublicUrl: null }, + }); + + it('hides the section when enterprise SSO is disabled', () => { + expect(projectEnterpriseAccounts({ enabled: false, accounts: [], connections: [connection('okta')] })).toEqual({ + status: 'hidden', + }); + }); + + it('hides the section when there is nothing to show', () => { + expect( + projectEnterpriseAccounts({ enabled: true, accounts: [], connections: [connection('saml', false)] }), + ).toEqual({ status: 'hidden' }); + }); + + it('shows active accounts and offers only unlinked, linkable connections', () => { + const projection = projectEnterpriseAccounts({ + enabled: true, + accounts: [account('ent_okta', 'okta'), account('ent_old', 'old', false)], + connections: [connection('okta'), connection('azure'), connection('saml', false)], + }); + expect(projection.status === 'ready' && projection.accounts.map(a => a.id)).toEqual(['ent_okta']); + expect(projection.status === 'ready' && projection.connections.map(c => c.id)).toEqual(['azure']); + }); + + it('flags accounts whose verification failed', () => { + const projection = projectEnterpriseAccounts({ + enabled: true, + accounts: [account('ent_okta', 'okta', true, 'Verification failed')], + connections: [], + }); + expect(projection.status === 'ready' && projection.accounts[0]?.requiresAction).toBe(true); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx deleted file mode 100644 index 98ebb414e7d..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller'; - -const connections = [ - { id: 'okta', name: 'Acme Okta' }, - { id: 'saml', name: 'Custom SAML' }, -]; - -function deferred() { - let resolve: (value: T) => void = () => undefined; - let reject: (error: unknown) => void = () => undefined; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -const errorMessage = 'Something went wrong.'; - -afterEach(() => vi.useRealTimers()); - -describe('useUserProfileEnterpriseAccountsController', () => { - it('synchronously suppresses duplicate and competing connects while keeping the selected row pending', async () => { - const operation = deferred<'redirecting'>(); - const onConnect = vi.fn(() => operation.promise); - const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, errorMessage }), - ); - - act(() => { - result.current.onConnect?.('okta'); - result.current.onConnect?.('okta'); - result.current.onConnect?.('saml'); - }); - expect(onConnect).toHaveBeenCalledExactlyOnceWith('okta'); - expect(result.current.pendingId).toBe('okta'); - - await act(async () => { - operation.resolve('redirecting'); - await operation.promise; - }); - expect(result.current.pendingId).toBe('okta'); - }); - - it('releases redirect pending after two seconds', async () => { - vi.useFakeTimers(); - const onConnect = vi.fn().mockResolvedValue('redirecting'); - const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, errorMessage }), - ); - await act(async () => { - result.current.onConnect?.('okta'); - await Promise.resolve(); - await Promise.resolve(); - }); - expect(result.current.pendingId).toBe('okta'); - await act(() => vi.advanceTimersByTimeAsync(2000)); - expect(result.current.pendingId).toBeUndefined(); - }); - - it('shows a formatted error on the failed connection and clears it on retry', async () => { - const onConnect = vi - .fn() - .mockRejectedValueOnce(new Error('Formatted error.')) - .mockReturnValueOnce(new Promise(() => {})); - const { result } = renderHook(() => - useUserProfileEnterpriseAccountsController({ accounts: [], connections, onConnect, errorMessage }), - ); - - act(() => result.current.onConnect('okta')); - await waitFor(() => expect(result.current.connections[0].connectError).toBe('Formatted error.')); - expect(result.current.connections[1].connectError).toBeUndefined(); - - act(() => result.current.onConnect('okta')); - expect(result.current.connections[0].connectError).toBeUndefined(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts deleted file mode 100644 index 589b09cc4a4..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { cleanup, renderHook } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - projectEnterpriseAccounts, - useUserProfileEnterpriseAccountsModel, -} from './user-profile-enterprise-accounts-section.model'; - -type TestUser = { - id: string; - enterpriseAccounts: never[]; - createExternalAccount: ReturnType; -}; - -function createUser(id: string): TestUser { - return { - id, - enterpriseAccounts: [], - createExternalAccount: vi.fn(() => - Promise.resolve({ - verification: { externalVerificationRedirectURL: new URL('https://idp.example/authorize') }, - }), - ), - }; -} - -let user: TestUser | null; -let enabled: boolean; - -const clerk = { - get user() { - return user; - }, - __internal_windowNavigate: vi.fn(), -}; - -vi.mock('@clerk/shared/react', () => ({ - useClerk: () => clerk, - useUser: () => ({ isLoaded: true, user }), - __internal_useUserEnterpriseConnections: () => ({ - data: [{ id: 'okta', name: 'Acme Okta', allowOrganizationAccountLinking: true }], - }), -})); - -vi.mock('../../../hooks/useMosaicEnvironment', () => ({ - useMosaicEnvironment: () => ({ userSettings: { enterpriseSSO: { enabled } } }), -})); - -beforeEach(() => { - user = createUser('user_1'); - enabled = true; - clerk.__internal_windowNavigate.mockClear(); -}); - -afterEach(cleanup); - -function ready(model: ReturnType) { - if (model.status !== 'ready') { - throw new Error('expected ready model'); - } - return model; -} - -describe('useUserProfileEnterpriseAccountsModel', () => { - it('explains why the section is hidden', () => { - user = null; - expect(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current).toEqual({ - status: 'hidden', - reason: 'no_user', - }); - - user = createUser('user_1'); - enabled = false; - expect(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current).toEqual({ - status: 'hidden', - reason: 'unavailable', - }); - }); - - it.each(['signed out', 'different user'])('rejects a captured connect after %s', async change => { - const original = user; - const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); - - user = change === 'signed out' ? null : createUser('user_2'); - - await expect(model.connect('okta')).rejects.toMatchObject({ - message: 'This enterprise connection is no longer available.', - }); - expect(original?.createExternalAccount).not.toHaveBeenCalled(); - }); - - it('rejects a connection that is not offered', async () => { - const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); - - await expect(model.connect('saml')).rejects.toMatchObject({ - message: 'This enterprise connection is no longer available.', - }); - expect(user?.createExternalAccount).not.toHaveBeenCalled(); - }); - - it('rejects a missing verification URL with a typed error', async () => { - user?.createExternalAccount.mockResolvedValue({ verification: null }); - const model = ready(renderHook(() => useUserProfileEnterpriseAccountsModel()).result.current); - - await expect(model.connect('okta')).rejects.toMatchObject({ - message: 'The connection could not start. Please try again.', - }); - expect(clerk.__internal_windowNavigate).not.toHaveBeenCalled(); - }); -}); - -describe('projectEnterpriseAccounts', () => { - const connection = (id: string, allowOrganizationAccountLinking = true) => ({ - id, - name: id, - allowOrganizationAccountLinking, - }); - const account = (id: string, enterpriseConnectionId: string, active = true, error?: string) => ({ - id, - emailAddress: `${id}@example.com`, - enterpriseConnectionId, - verification: error ? { error: { longMessage: error } } : null, - enterpriseConnection: { active, name: enterpriseConnectionId, logoPublicUrl: null }, - }); - - it('hides the section when enterprise SSO is disabled', () => { - expect(projectEnterpriseAccounts({ enabled: false, accounts: [], connections: [connection('okta')] })).toEqual({ - status: 'hidden', - }); - }); - - it('hides the section when there is nothing to show', () => { - expect( - projectEnterpriseAccounts({ enabled: true, accounts: [], connections: [connection('saml', false)] }), - ).toEqual({ status: 'hidden' }); - }); - - it('shows active accounts and offers only unlinked, linkable connections', () => { - const projection = projectEnterpriseAccounts({ - enabled: true, - accounts: [account('ent_okta', 'okta'), account('ent_old', 'old', false)], - connections: [connection('okta'), connection('azure'), connection('saml', false)], - }); - expect(projection.status === 'ready' && projection.accounts.map(a => a.id)).toEqual(['ent_okta']); - expect(projection.status === 'ready' && projection.connections.map(c => c.id)).toEqual(['azure']); - }); - - it('flags accounts whose verification failed', () => { - const projection = projectEnterpriseAccounts({ - enabled: true, - accounts: [account('ent_okta', 'okta', true, 'Verification failed')], - connections: [], - }); - expect(projection.status === 'ready' && projection.accounts[0]?.requiresAction).toBe(true); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index de6376393ef..e2491b30501 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -115,7 +115,6 @@ export function useUserProfileEnterpriseAccountsModel({ return { ...projection, userId, - // TODO: Add session reverification for enterprise account linking; surface API errors until then. connect: async connectionId => { const current = currentUser(); if (!projection.connections.some(connection => connection.id === connectionId)) { diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index ca7ee13e5f9..f7dc074d2cd 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -28,8 +28,6 @@ import { userProfilePasswordSectionMessages } from '../features/user-profile/use import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; import { errorMessages } from './errors.messages'; -import { errorMessages } from './errors.messages'; - export const mosaicMessages = { apiKeysTable: apiKeysTableMessages, errors: errorMessages, From afecfad9a633fa3b7ffa0e364f376480f785206c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 11:02:18 -0600 Subject: [PATCH 19/22] fix(mosaic): reconcile enterprise accounts with latest main --- .../__tests__/user-profile-enterprise-accounts.feature.test.tsx | 2 +- .../user-profile-enterprise-accounts-section.model.ts | 2 +- packages/mosaic/src/localization/index.ts | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx index d6da0a4e48a..82b2867396a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -14,7 +14,7 @@ import { fapiVerification, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; -import { MosaicProvider } from '../../../MosaicProvider'; +import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; import { custom, enterpriseAccountSeed as signedIn, enterpriseMember, okta } from './enterprise-accounts.fixtures'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index e2491b30501..42df21291f8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -3,7 +3,7 @@ import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react'; import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@clerk/shared/types'; -import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useErrorText, useMessages } from '../../../localization'; import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback'; import type { diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index 5a0ef3edfab..8d5633e4bac 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -6,5 +6,3 @@ export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages'; export { fill, plural, rich } from './messages'; -export { useErrorText } from './errors'; -export type { LocalizableError } from './errors'; From 3122ed3f5b453aa3fa313ff7c025914cf0140e2f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 11:13:30 -0600 Subject: [PATCH 20/22] fix(swingset): update enterprise page provider import --- .../swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx b/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx index f91ab619133..4d6b1738ada 100644 --- a/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { UserProfileEnterpriseAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; -import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; From cd2c66c6a7aca42efdb93227504fe2960b9f2043 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 12:02:19 -0600 Subject: [PATCH 21/22] fix(mosaic): reconcile enterprise tests with connected accounts --- .../mosaic/src/__tests__/feature/fake-fapi/enterprise.ts | 2 +- .../user-profile-enterprise-accounts-host.feature.test.tsx | 7 ++++++- .../__tests__/user-profile-profile-panel.view.test.tsx | 6 +++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts index f394c06d6ee..5e816264f00 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts @@ -52,7 +52,7 @@ export function enterpriseHandlers(state: FakeFapiState, url: (path: string) => if (!user) { return rejected('resource_not_found'); } - const body = new URLSearchParams(await request.text()); + const body = new URLSearchParams(await request.clone().text()); const connectionId = body.get('enterprise_connection_id'); if (!connectionId) { return undefined; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx index 44bc49eb471..949938e92d8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx @@ -5,6 +5,7 @@ import { serveFapi } from '../../../__tests__/feature/fake-fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; import { enterpriseAccountSeed } from './enterprise-accounts.fixtures'; it('places the connected enterprise section before Web3 wallets in the profile host', async () => { @@ -16,7 +17,11 @@ it('places the connected enterprise section before Web3 wallets in the profile h emails={[]} phones={[]} enterpriseAccountsSlot={} - web3Wallets={[{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }]} + web3WalletsSlot={ + + } />, ); const enterprise = await screen.findByRole('group', { name: 'Enterprise accounts' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 480f6562eb3..ff520ead6ad 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -286,7 +286,11 @@ describe('UserProfileProfilePanelView', () => { it('places enterprise accounts before Web3 wallets and the danger zone', () => { renderView({ - web3WalletsSlot: , + web3WalletsSlot: ( + + ), enterpriseAccountsSlot:
Enterprise accounts
, deleteAccountSlot: , }); From be392048678f1db85c52f763782b57ded8dbeb15 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 2 Oct 2026 14:10:24 -0600 Subject: [PATCH 22/22] fix(mosaic): share navigation and API error adaptation --- ...se-accounts-compatibility.feature.test.tsx | 45 --------- ...ofile-enterprise-accounts.feature.test.tsx | 27 ++++++ ...rofile-connected-accounts-feedback.test.ts | 7 ++ ...ser-profile-connected-accounts-feedback.ts | 7 +- ...ofile-enterprise-accounts-feedback.test.ts | 6 ++ ...er-profile-enterprise-accounts-feedback.ts | 7 +- ...ofile-enterprise-accounts-section.model.ts | 11 +-- .../user-profile-password-errors.ts | 10 +- .../use-mosaic-router.feature.test.tsx | 91 +++++++++++++++++++ .../mosaic/src/hooks/use-mosaic-router.ts | 21 ++++- .../mosaic/src/localization/errors.test.tsx | 39 +++++++- packages/mosaic/src/localization/errors.ts | 11 +++ packages/mosaic/src/localization/index.ts | 2 +- 13 files changed, 208 insertions(+), 76 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx create mode 100644 packages/mosaic/src/hooks/__tests__/use-mosaic-router.feature.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx deleted file mode 100644 index d48b2e15e5e..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.feature.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { expect, it } from 'vitest'; - -import { serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiVerification } from '../../../__tests__/feature/fapi'; -import { renderWithClerk } from '../../../__tests__/feature/render'; -import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; -import { enterpriseAccountSeed } from './enterprise-accounts.fixtures'; - -it('uses browser navigation when an older Clerk lacks its internal navigator', async () => { - const originalUrl = window.location.href; - const redirect = new URL(originalUrl); - redirect.hash = 'enterprise-compatible'; - serveFapi( - enterpriseAccountSeed({ - enterpriseLinking: { - enabled: true, - preparations: { - okta: { - kind: 'saml', - verification: fapiVerification('saml', { - status: 'unverified', - external_verification_redirect_url: redirect.href, - }), - }, - }, - }, - }), - ); - const { clerk } = await renderWithClerk(); - const descriptor = Object.getOwnPropertyDescriptor(clerk, '__internal_windowNavigate'); - Object.defineProperty(clerk, '__internal_windowNavigate', { configurable: true, value: undefined }); - try { - await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); - await waitFor(() => expect(window.location.href).toBe(redirect.href)); - } finally { - window.history.replaceState(null, '', originalUrl); - if (descriptor) { - Object.defineProperty(clerk, '__internal_windowNavigate', descriptor); - } else { - Reflect.deleteProperty(clerk, '__internal_windowNavigate'); - } - } -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx index 82b2867396a..9b46201a39f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -16,6 +16,7 @@ import { import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicProvider } from '../../../mosaic-provider'; import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; +import { useUserProfileEnterpriseAccountsModel } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model'; import { custom, enterpriseAccountSeed as signedIn, enterpriseMember, okta } from './enterprise-accounts.fixtures'; async function renderSection(seed: FakeFapiSeed = signedIn()) { @@ -25,6 +26,32 @@ async function renderSection(seed: FakeFapiSeed = signedIn()) { } describe('enterprise accounts', () => { + it('preserves the original cause when localizing a linking failure', async () => { + const feedback = vi.fn<(error: Error) => void>(); + function Connect() { + const model = useUserProfileEnterpriseAccountsModel(); + return model.status === 'ready' ? ( + + ) : null; + } + serveFapi(signedIn()); + const { clerk } = await renderWithClerk(); + const user = clerk.user; + if (!user) { + throw new Error('Expected a signed-in user'); + } + const cause = new Error('Failed to fetch'); + vi.spyOn(user, 'createExternalAccount').mockRejectedValue(cause); + await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect' })); + await waitFor(() => expect(feedback).toHaveBeenCalledOnce()); + expect(feedback.mock.calls[0]?.[0].cause).toBe(cause); + }); + it('shows the fallback while Clerk loads', async () => { serveFapi(signedIn()); const client = holdRequests('get', '/v1/client'); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts index 199fd568410..3e709579a2f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts @@ -65,4 +65,11 @@ describe('connected account error messages', () => { messages.errors.generic, ); }); + + it('preserves an empty API message and its cause', () => { + const cause = apiError([{ code: 'unknown', message: '', long_message: '' }]); + const feedback = connectedAccountFeedback(cause, messages, errorText); + expect(feedback.message).toBe(''); + expect(feedback.cause).toBe(cause); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts index ce3e9a3897c..e4cef35f9af 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts @@ -1,6 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { LocalizableError, MosaicMessages } from '../../../localization'; +import { toLocalizableApiError } from '../../../localization'; import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types'; type Messages = MosaicMessages['userProfileConnectedAccounts']; @@ -19,11 +20,7 @@ export function connectedAccountFeedback( if (isClerkAPIResponseError(error)) { const first = error.errors[0]; if (first) { - message = errorText({ - code: first.code, - paramName: first.meta?.paramName, - message: first.longMessage || first.message, - }); + message = errorText(toLocalizableApiError(first)); } } return new Error(message, { cause: error }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts index ef76c3841f4..adf4d171c62 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts @@ -40,4 +40,10 @@ describe('enterprise account error messages', () => { messages.errors.generic, ); }); + + it('uses the section fallback for empty API messages', () => { + expect( + enterpriseAccountErrorMessage(apiError([{ code: 'unknown', message: '', long_message: '' }]), errorText, 'Retry'), + ).toBe('Retry'); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts index 9449be43e02..64a39361910 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts @@ -1,6 +1,7 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import type { LocalizableError } from '../../../localization'; +import { toLocalizableApiError } from '../../../localization'; export function enterpriseAccountErrorMessage( error: unknown, @@ -9,11 +10,7 @@ export function enterpriseAccountErrorMessage( ): string { if (isClerkAPIResponseError(error)) { const first = error.errors[0]; - return errorText({ - code: first?.code, - paramName: first?.meta?.paramName, - message: first?.longMessage || first?.message || fallback, - }); + return first ? errorText(toLocalizableApiError(first, fallback)) : fallback; } return fallback; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index 42df21291f8..dfb1ef1dcad 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -1,9 +1,9 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; -import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react'; import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import { useErrorText, useMessages } from '../../../localization'; import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback'; import type { @@ -76,6 +76,7 @@ export function useUserProfileEnterpriseAccountsModel({ mode, }: { mode?: 'modal' | 'mounted' } = {}): UserProfileEnterpriseAccountsModel { const clerk = useClerk(); + const router = useMosaicRouter(); const m = useMessages('userProfileEnterpriseAccountsSection'); const errorText = useErrorText(); const { isLoaded, user } = useUser(); @@ -125,18 +126,14 @@ export function useUserProfileEnterpriseAccountsModel({ const account = await current .createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl }) .catch(error => { - throw new Error(enterpriseAccountErrorMessage(error, errorText, m.errors.generic)); + throw new Error(enterpriseAccountErrorMessage(error, errorText, m.errors.generic), { cause: error }); }); currentUser(); const redirect = account.verification?.externalVerificationRedirectURL; if (!redirect) { throw new Error(m.errors.missingVerificationUrl); } - if (typeof clerk.__internal_windowNavigate === 'function') { - clerk.__internal_windowNavigate(redirect); - } else { - windowNavigate(redirect); - } + router.windowNavigate(redirect); return 'redirecting'; }, }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-errors.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-errors.ts index 5a0f637420a..c6383bd61ec 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-errors.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-errors.ts @@ -4,6 +4,7 @@ import { snakeToCamel } from '@clerk/shared/underscore'; import { FormSubmitError } from '../../../components/form'; import type { LocalizableError, MosaicMessages } from '../../../localization'; +import { toLocalizableApiError } from '../../../localization'; import { passwordComplexityMessage, passwordStrengthMessage } from './user-profile-password-feedback'; import type { UserProfileEditPasswordValues } from './user-profile-password-section.types'; @@ -47,8 +48,7 @@ function passwordError( return ( (passwordComplexityCodes.has(first.code) ? passwordComplexityMessage(failures, settings, messages, locale) - : undefined) || - errorText({ code: first.code, paramName: first.meta?.paramName, message: first.longMessage || first.message }) + : undefined) || errorText(toLocalizableApiError(first)) ); } @@ -67,11 +67,7 @@ export function passwordFormError( const passwordErrors: ClerkAPIError[] = []; let message: string | undefined; for (const item of error.errors) { - const text = errorText({ - code: item.code, - paramName: item.meta?.paramName, - message: item.longMessage || item.message, - }); + const text = errorText(toLocalizableApiError(item)); const name = snakeToCamel(item.meta?.paramName); if (name === 'currentPassword' && requiresCurrentPassword) { fields.currentPassword ??= text; diff --git a/packages/mosaic/src/hooks/__tests__/use-mosaic-router.feature.test.tsx b/packages/mosaic/src/hooks/__tests__/use-mosaic-router.feature.test.tsx new file mode 100644 index 00000000000..4bb4cef2645 --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-mosaic-router.feature.test.tsx @@ -0,0 +1,91 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { serveFapi } from '../../__tests__/feature/fake-fapi'; +import { renderWithClerk } from '../../__tests__/feature/render'; +import { useMosaicRouter } from '../use-mosaic-router'; + +function Navigation({ to, hard = false }: { to: string; hard?: boolean }) { + const router = useMosaicRouter(); + return ( + + ); +} + +describe('Mosaic router', () => { + it('uses the host router for ordinary same-origin navigation', async () => { + serveFapi(); + const to = new URL('/router-destination', window.location.href).href; + const { navigate } = await renderWithClerk(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Navigate' })); + expect(navigate).toHaveBeenCalledWith('/router-destination'); + }); + + it.each([false, true])('hard-navigates a same-origin URL with an older runtime %s', async older => { + const originalUrl = window.location.href; + const to = new URL(originalUrl); + to.hash = 'mosaic-hard-navigation'; + serveFapi(); + const { clerk, navigate } = await renderWithClerk( + , + ); + const descriptor = Object.getOwnPropertyDescriptor(clerk, '__internal_windowNavigate'); + if (older) { + Object.defineProperty(clerk, '__internal_windowNavigate', { configurable: true, value: undefined }); + } + const beforeUnload = vi.fn(); + window.addEventListener('clerk:beforeunload', beforeUnload); + try { + await userEvent.setup().click(screen.getByRole('button', { name: 'Navigate' })); + await waitFor(() => expect(window.location.href).toBe(to.href)); + expect(beforeUnload).toHaveBeenCalledOnce(); + expect(navigate).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('clerk:beforeunload', beforeUnload); + window.history.replaceState(null, '', originalUrl); + if (older && descriptor) { + Object.defineProperty(clerk, '__internal_windowNavigate', descriptor); + } + } + }); + + it.each([false, true])('rejects unsafe hard-navigation protocols with an older runtime %s', async older => { + const originalUrl = window.location.href; + serveFapi(); + const { clerk, navigate } = await renderWithClerk( + , + ); + const descriptor = Object.getOwnPropertyDescriptor(clerk, '__internal_windowNavigate'); + if (older) { + Object.defineProperty(clerk, '__internal_windowNavigate', { configurable: true, value: undefined }); + } + const beforeUnload = vi.fn(); + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + window.addEventListener('clerk:beforeunload', beforeUnload); + try { + await userEvent.setup().click(screen.getByRole('button', { name: 'Navigate' })); + expect(window.location.href).toBe(originalUrl); + expect(beforeUnload).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + expect(warning).toHaveBeenCalledWith(expect.stringContaining('Aborting navigation')); + } finally { + warning.mockRestore(); + window.removeEventListener('clerk:beforeunload', beforeUnload); + if (older && descriptor) { + Object.defineProperty(clerk, '__internal_windowNavigate', descriptor); + } + } + }); +}); diff --git a/packages/mosaic/src/hooks/use-mosaic-router.ts b/packages/mosaic/src/hooks/use-mosaic-router.ts index 2a5d3e2f0c2..b8e5959f0de 100644 --- a/packages/mosaic/src/hooks/use-mosaic-router.ts +++ b/packages/mosaic/src/hooks/use-mosaic-router.ts @@ -1,18 +1,29 @@ +import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { useClerk } from '@clerk/shared/react'; export interface MosaicRouter { navigate: (to: string) => void | Promise; + windowNavigate: (to: URL | string) => void; } /** * Host-agnostic navigation seam for Mosaic. * - * Delegates to `clerk.navigate`, which already routes through the host app's - * `routerPush`/`routerReplace`. Controllers depend on this hook rather than on - * Clerk internals, so a future clerk-js mount path or a dedicated Mosaic router - * only has to change this one file. + * Uses `clerk.navigate` for host routing and the window navigator for hard + * redirects. Models depend on this hook rather than on Clerk internals, so a + * future clerk-js mount path or a dedicated Mosaic router only has to change + * this one file. */ export function useMosaicRouter(): MosaicRouter { const clerk = useClerk(); - return { navigate: to => clerk.navigate(to) }; + return { + navigate: to => clerk.navigate(to), + windowNavigate: to => { + if (typeof clerk.__internal_windowNavigate === 'function') { + clerk.__internal_windowNavigate(to); + } else { + windowNavigate(to); + } + }, + }; } diff --git a/packages/mosaic/src/localization/errors.test.tsx b/packages/mosaic/src/localization/errors.test.tsx index 475439a4e5e..9d0d601bc08 100644 --- a/packages/mosaic/src/localization/errors.test.tsx +++ b/packages/mosaic/src/localization/errors.test.tsx @@ -1,10 +1,11 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; import { renderHook } from '@testing-library/react'; import type { ReactNode } from 'react'; import { describe, expect, it } from 'vitest'; import type { MosaicCatalog } from './catalog'; import { MosaicLocalizationProvider, resolveLocalization } from './context'; -import { useErrorText } from './errors'; +import { toLocalizableApiError, useErrorText } from './errors'; function errorText(overrides?: MosaicCatalog) { const wrapper = ({ children }: { children: ReactNode }) => ( @@ -60,3 +61,39 @@ describe('useErrorText', () => { expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); }); }); + +describe('Clerk API error localization', () => { + function firstError(data: ConstructorParameters[1]['data']) { + const error = new ClerkAPIResponseError('Invalid', { status: 422, data }).errors[0]; + if (!error) { + throw new Error('Expected an API error'); + } + return error; + } + + it('localizes the API code and parameter before server text', () => { + const error = firstError([ + { code: 'form_param_invalid', message: 'Short', long_message: 'Long', meta: { param_name: 'username' } }, + ]); + const text = errorText({ + 'errors.form_param_invalid': 'Invalid value', + 'errors.form_param_invalid__username': 'Invalid username', + }); + expect(text(toLocalizableApiError(error, 'Retry'))).toBe('Invalid username'); + }); + + it('prefers the API long message, then its short message', () => { + const text = errorText(); + expect(text(toLocalizableApiError(firstError([{ code: 'unknown', message: 'Short', long_message: 'Long' }])))).toBe( + 'Long', + ); + expect(text(toLocalizableApiError(firstError([{ code: 'unknown', message: 'Short' }])))).toBe('Short'); + }); + + it('preserves an empty message unless the feature provides a fallback', () => { + const error = firstError([{ code: 'unknown', message: '', long_message: '' }]); + const text = errorText(); + expect(text(toLocalizableApiError(error))).toBe(''); + expect(text(toLocalizableApiError(error, 'Retry'))).toBe('Retry'); + }); +}); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts index d11da404a36..d45afbfd554 100644 --- a/packages/mosaic/src/localization/errors.ts +++ b/packages/mosaic/src/localization/errors.ts @@ -1,3 +1,5 @@ +import type { ClerkAPIError } from '@clerk/shared/types'; + import { useMessages } from './context'; import type { MessageValues } from './messages'; import { fill } from './messages'; @@ -10,6 +12,15 @@ export interface LocalizableError { params?: MessageValues; } +export function toLocalizableApiError(error: ClerkAPIError, fallback?: string): LocalizableError { + const message = error.longMessage || error.message; + return { + code: error.code, + paramName: error.meta?.paramName, + message: fallback === undefined ? message : message || fallback, + }; +} + export function useErrorText(): (error: LocalizableError) => string { const messages = useMessages('errors'); const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index 8d5633e4bac..6fb16605066 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -1,7 +1,7 @@ export type { MosaicCatalog, MosaicLocalization } from './catalog'; export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; export type { LocalizableError } from './errors'; -export { useErrorText } from './errors'; +export { toLocalizableApiError, useErrorText } from './errors'; export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages';