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/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index fc8820fbcf2..8dae98cfedd 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,
@@ -12,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,
@@ -45,10 +47,13 @@ export interface FakeFapiState {
apiKeys: ApiKeyJSON[];
verification: FakeVerificationState;
passwordUpdates: URLSearchParams[];
+ enterpriseConnections: EnterpriseConnectionJSON[];
+ enterpriseLinking: FakeEnterpriseLinking;
}
-export type FakeFapiSeed = Partial> & {
+export type FakeFapiSeed = Partial> & {
verification?: FakeVerificationSeed;
+ enterpriseLinking?: Partial;
};
const unhandled: string[] = [];
@@ -109,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(),
@@ -118,12 +123,21 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
suggestions: [],
apiKeys: [],
passwordUpdates: [],
+ enterpriseConnections: [],
...rest,
verification: createVerificationState(verification),
+ enterpriseLinking: {
+ enabled: false,
+ preparations: {},
+ verifiedLinks: [],
+ pendingExternalAccounts: [],
+ ...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'), () => {
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..5e816264f00
--- /dev/null
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts
@@ -0,0 +1,132 @@
+import type { ExternalAccountJSON, VerificationJSON } from '@clerk/shared/types';
+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: 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') {
+ 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.clone().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}`);
+ }
+ 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,
+ });
+ }
+ const account = preparation.account;
+ if (
+ !connection.provider.startsWith('oidc_') ||
+ !account.provider.startsWith('oauth_') ||
+ account.verification?.strategy !== account.provider ||
+ account.verification.status !== 'unverified'
+ ) {
+ throw new Error(`Invalid OIDC preparation for ${connection.id}`);
+ }
+ 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/__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',
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..24d45b1ee05
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/enterprise-accounts.fixtures.ts
@@ -0,0 +1,42 @@
+import type { FakeFapiSeed } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEmailAddress,
+ 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,
+ email_addresses: [fapiEmailAddress({ id: `email_${id}`, email_address: `${id}@example.com` })],
+ 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
new file mode 100644
index 00000000000..d81b3b70096
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-contract.feature.test.tsx
@@ -0,0 +1,272 @@
+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 { fapiUrl, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEnterpriseAccount,
+ fapiEnterpriseConnection,
+ 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';
+import { enterpriseAccountSeed, enterpriseMember, okta } from './enterprise-accounts.fixtures';
+
+function serveMember(connections = [okta]) {
+ return serveFapi(enterpriseAccountSeed({ 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.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(() => {});
+ 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();
+ });
+
+ 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).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',
+ }),
+ }),
+ provider: 'oauth_custom_mock',
+ };
+ 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(() => {});
+ 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([]);
+ 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',
+ 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: [],
+ });
+ 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');
+ }
+ 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([]);
+ },
+ );
+
+ 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(
+ 'An enterprise account is already connected for this connection email: user_1@example.com',
+ );
+ 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();
+ });
+});
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..949938e92d8
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx
@@ -0,0 +1,30 @@
+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 { 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 () => {
+ serveFapi(enterpriseAccountSeed());
+ await renderWithClerk(
+ }
+ web3WalletsSlot={
+
+ }
+ />,
+ );
+ 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();
+});
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..d27df4293e2
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-identity.feature.test.tsx
@@ -0,0 +1,132 @@
+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, 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';
+import { enterpriseAccountSeed, enterpriseMember, okta } from './enterprise-accounts.fixtures';
+
+function serveUsers() {
+ return serveFapi(
+ enterpriseAccountSeed({
+ client: fapiClient([
+ fapiSession({ id: 'sess_1', user: enterpriseMember() }),
+ fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2' }) }),
+ ]),
+ 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;
+ });
+ }
+ });
+
+ 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-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..9b46201a39f
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx
@@ -0,0 +1,371 @@
+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 { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEnterpriseAccount,
+ fapiEnterpriseConnection,
+ fapiEnvironment,
+ fapiSession,
+ fapiUser,
+ fapiVerification,
+} from '../../../__tests__/feature/fapi';
+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()) {
+ const fapi = serveFapi(seed);
+ const view = await renderWithClerk();
+ return { ...view, fapi };
+}
+
+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');
+ 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('group', { name: 'Enterprise accounts' })).toBeNull();
+ });
+
+ it('hides the section when the user is signed out', async () => {
+ await renderSection(signedIn({ client: fapiClient() }));
+ expect(screen.queryByRole('group', { name: 'Enterprise accounts' })).toBeNull();
+ });
+
+ it('hides an empty section', async () => {
+ await renderSection(signedIn({ enterpriseConnections: [] }));
+ expect(screen.queryByRole('group', { 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('saml', {
+ status: 'verified',
+ 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',
+ });
+ 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({ ...enterpriseMember(), 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({ ...enterpriseMember(), 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: {
+ object: 'external_account',
+ verification: fapiVerification('saml', { status: 'unverified' }),
+ },
+ 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();
+ });
+
+ 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);
+ });
+
+ 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/__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..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
@@ -284,6 +284,24 @@ describe('UserProfileProfilePanelView', () => {
);
});
+ it('places enterprise accounts before Web3 wallets and 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(enterprise.compareDocumentPosition(wallets) & 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-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
new file mode 100644
index 00000000000..adf4d171c62
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.test.ts
@@ -0,0 +1,49 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import { describe, expect, it } from 'vitest';
+
+import { resolveLocalization } from '../../../localization';
+import { enterpriseAccountErrorMessage } from './user-profile-enterprise-accounts-feedback';
+
+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('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' },
+ ]),
+ errorText,
+ messages.errors.generic,
+ ),
+ ).toBe('The identity provider is unavailable.');
+ expect(
+ enterpriseAccountErrorMessage(
+ apiError([{ code: 'oauth_error', message: 'Short' }]),
+ errorText,
+ messages.errors.generic,
+ ),
+ ).toBe('Short');
+ });
+
+ it('falls back to the generic message', () => {
+ 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,
+ );
+ });
+
+ 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
new file mode 100644
index 00000000000..64a39361910
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-feedback.ts
@@ -0,0 +1,16 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+
+import type { LocalizableError } from '../../../localization';
+import { toLocalizableApiError } from '../../../localization';
+
+export function enterpriseAccountErrorMessage(
+ error: unknown,
+ errorText: (error: LocalizableError) => string,
+ fallback: string,
+): string {
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ 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-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.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..b53c19e4a3f
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts
@@ -0,0 +1,61 @@
+import { useRef, useState } from 'react';
+
+import type {
+ EnterpriseAccountActionResult,
+ UserProfileEnterpriseAccount,
+ UserProfileEnterpriseConnection,
+} from './user-profile-enterprise-accounts-section.types';
+
+export interface UserProfileEnterpriseAccountsControllerOptions {
+ accounts: UserProfileEnterpriseAccount[];
+ connections: UserProfileEnterpriseConnection[];
+ onConnect: (connectionId: string) => Promise;
+ errorMessage: string;
+}
+
+export interface UserProfileEnterpriseAccountsController {
+ accounts: UserProfileEnterpriseAccount[];
+ connections: UserProfileEnterpriseConnection[];
+ pendingId: string | undefined;
+ onConnect: (connectionId: string) => void;
+}
+
+export function useUserProfileEnterpriseAccountsController({
+ accounts,
+ connections,
+ onConnect,
+ errorMessage,
+}: UserProfileEnterpriseAccountsControllerOptions): UserProfileEnterpriseAccountsController {
+ const [pendingId, setPendingId] = useState();
+ const [connectErrors, setConnectErrors] = useState>({});
+ const connecting = useRef(false);
+
+ const run = async (id: string) => {
+ if (connecting.current) {
+ return;
+ }
+ connecting.current = true;
+ setPendingId(id);
+ setConnectErrors(({ [id]: _cleared, ...rest }) => rest);
+
+ try {
+ if ((await onConnect(id)) === 'redirecting') {
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+ } catch (error) {
+ setConnectErrors(current => ({ ...current, [id]: error instanceof Error ? error.message : errorMessage }));
+ } finally {
+ connecting.current = false;
+ setPendingId(undefined);
+ }
+ };
+
+ return {
+ accounts,
+ connections: connections.map(connection =>
+ connectErrors[connection.id] ? { ...connection, connectError: connectErrors[connection.id] } : connection,
+ ),
+ 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.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts
index a601b7bada9..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
@@ -3,4 +3,9 @@ export const userProfileEnterpriseAccountsMessages = {
connect: 'Connect',
connectProvider: 'Connect {provider}',
requiresAction: 'Requires action',
+ errors: {
+ generic: 'Something went wrong. 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.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
new file mode 100644
index 00000000000..dfb1ef1dcad
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
@@ -0,0 +1,140 @@
+import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
+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 {
+ EnterpriseAccountActionResult,
+ 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 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 router = useMosaicRouter();
+ const m = useMessages('userProfileEnterpriseAccountsSection');
+ const errorText = useErrorText();
+ const { isLoaded, user } = useUser();
+ const environment = useMosaicEnvironment();
+ const { data: connections = [] } = __internal_useUserEnterpriseConnections({
+ withOrganizationAccountLinking: true,
+ keepPreviousData: false,
+ enabled: Boolean(isLoaded && user && environment?.userSettings.enterpriseSSO.enabled),
+ });
+
+ if (!isLoaded || !environment) {
+ return { status: 'loading' };
+ }
+ if (!user) {
+ return { status: 'hidden', reason: 'no_user' };
+ }
+
+ const projection = projectEnterpriseAccounts({
+ enabled: environment.userSettings.enterpriseSSO.enabled,
+ accounts: user.enterpriseAccounts,
+ connections,
+ });
+ if (projection.status === 'hidden') {
+ return { status: 'hidden', reason: 'unavailable' };
+ }
+
+ const userId = user.id;
+
+ const currentUser = () => {
+ const current = clerk.user;
+ if (!current || current.id !== userId) {
+ throw new Error(m.errors.unavailable);
+ }
+ return current;
+ };
+
+ return {
+ ...projection,
+ userId,
+ connect: async connectionId => {
+ const current = currentUser();
+ if (!projection.connections.some(connection => connection.id === connectionId)) {
+ 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 })
+ .catch(error => {
+ 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);
+ }
+ router.windowNavigate(redirect);
+ return 'redirecting';
+ },
+ };
+}
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..15411130eaf
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx
@@ -0,0 +1,40 @@
+import type { ReactNode } from 'react';
+
+import { useMessages } from '../../../localization';
+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';
+import { UserProfileEnterpriseAccountsSectionView } from './user-profile-enterprise-accounts-section.view';
+
+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;
+ }
+ if (model.status === 'hidden') {
+ return null;
+ }
+ return (
+
+ );
+}
+
+function EnterpriseAccounts({ model }: { model: Extract }) {
+ const m = useMessages('userProfileEnterpriseAccountsSection');
+ const controller = useUserProfileEnterpriseAccountsController({
+ accounts: model.accounts,
+ connections: model.connections,
+ onConnect: model.connect,
+ 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 2749d60cbe8..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
@@ -9,3 +9,5 @@ export interface UserProfileEnterpriseAccount extends UserProfileEnterpriseConne
emailAddress?: string;
requiresAction?: boolean;
}
+
+export type EnterpriseAccountActionResult = 'redirecting' | void;
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/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/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx
index 82157ac8452..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
@@ -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 {
@@ -44,6 +46,7 @@ export function UserProfileProfilePanelView({
{...account}
/>
{connectedAccountsSlot}
+ {enterpriseAccountsSlot}
{web3WalletsSlot}
{deleteAccountSlot}
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';
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..4d6b1738ada
--- /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/mosaic-provider';
+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}
+
+
+ );
+}
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);
},
};