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';