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); }, };