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 3b52c224807..20e7360ba9b 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -1,6 +1,7 @@
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import type {
ClientJSON,
+ EnterpriseConnectionJSON,
OAuthProvider,
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
@@ -36,6 +37,7 @@ export interface FakeFapiState {
memberships: OrganizationMembershipJSON[];
invitations: UserOrganizationInvitationJSON[];
suggestions: OrganizationSuggestionJSON[];
+ enterpriseConnections: EnterpriseConnectionJSON[];
}
export type FakeFapiSeed = Partial;
@@ -114,6 +116,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
memberships: [],
invitations: [],
suggestions: [],
+ enterpriseConnections: [],
...seed,
};
@@ -124,12 +127,39 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const user = activeUser(state);
return user ? envelope(user, state.client) : missing();
}),
+ http.get(fapiUrl('/v1/me/enterprise_connections'), ({ request }) => {
+ const url = new URL(request.url);
+ const withLinking = url.searchParams.get('with_organization_account_linking') === 'true';
+ const connections = withLinking
+ ? state.enterpriseConnections.filter(connection => connection.allow_organization_account_linking)
+ : state.enterpriseConnections;
+ return envelope(connections, state.client);
+ }),
http.post(fapiUrl('/v1/me/external_accounts'), async ({ request }) => {
const user = activeUser(state);
if (!user) {
return missing();
}
const body = new URLSearchParams(await request.text());
+ const enterpriseConnectionId = body.get('enterprise_connection_id');
+ if (enterpriseConnectionId) {
+ const connection = state.enterpriseConnections.find(item => item.id === enterpriseConnectionId);
+ if (!connection) {
+ return missing();
+ }
+ return envelope(
+ fapiExternalAccount({
+ id: `idn_${connection.id}`,
+ provider: 'google',
+ verification: fapiVerification({
+ status: 'unverified',
+ strategy: 'enterprise_sso',
+ external_verification_redirect_url: 'https://accounts.example/enterprise-authorize',
+ }),
+ }),
+ state.client,
+ );
+ }
const strategy = body.get('strategy');
if (!strategy) {
return missing();
diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts
index fca1f533de5..42a2fda4f92 100644
--- a/packages/mosaic/src/__tests__/feature/fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fapi.ts
@@ -6,6 +6,7 @@ import type {
DisplayConfigJSON,
EmailAddressJSON,
EnterpriseAccountJSON,
+ EnterpriseConnectionJSON,
EnvironmentJSON,
ExternalAccountJSON,
OAuthProviders,
@@ -332,6 +333,26 @@ export function fapiWeb3Wallet(
};
}
+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__/user-profile-enterprise-accounts-compatibility.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx
new file mode 100644
index 00000000000..0e1a302fd65
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-compatibility.test.tsx
@@ -0,0 +1,48 @@
+import type * as SharedReact from '@clerk/shared/react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { expect, it, vi } from 'vitest';
+
+import { MosaicProvider } from '../../../MosaicProvider';
+import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section';
+
+const fallbackNavigate = vi.fn();
+
+vi.mock('@clerk/shared/internal/clerk-js/windowNavigate', () => ({
+ windowNavigate: (url: URL | string) => fallbackNavigate(url),
+}));
+
+vi.mock('@clerk/shared/react', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useUser: () => ({
+ isLoaded: true,
+ user: {
+ id: 'user_1',
+ enterpriseAccounts: [],
+ createExternalAccount: () =>
+ Promise.resolve({
+ verification: { externalVerificationRedirectURL: new URL('https://accounts.example/authorize') },
+ }),
+ },
+ }),
+ __internal_useUserEnterpriseConnections: () => ({
+ data: [{ id: 'okta', name: 'Acme Okta', allowOrganizationAccountLinking: true }],
+ }),
+ useClerk: () => ({
+ __internal_environment: { userSettings: { enterpriseSSO: { enabled: true } } },
+ __internal_getOption: () => undefined,
+ }),
+ };
+});
+
+it('uses the shared navigation fallback when an older Clerk lacks the internal method', async () => {
+ render(
+
+
+ ,
+ );
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect Acme Okta' }));
+ await waitFor(() => expect(fallbackNavigate).toHaveBeenCalledWith(new URL('https://accounts.example/authorize')));
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx
deleted file mode 100644
index d265a034b6d..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx
+++ /dev/null
@@ -1,89 +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('region', { name: 'Enterprise accounts' })).toBeInTheDocument();
- expect(screen.getByText('Acme Okta')).toBeInTheDocument();
- expect(screen.getByText('test@acme.com')).toBeInTheDocument();
- expect(screen.getByText('Requires action')).toBeInTheDocument();
- expect(screen.getByText('A', { exact: true })).toBeInTheDocument();
- expect(screen.getByText('Custom SSO')).toBeVisible();
- expect(screen.queryByRole('img')).not.toBeInTheDocument();
- expect(screen.queryByRole('button')).not.toBeInTheDocument();
- });
-});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx
new file mode 100644
index 00000000000..48cbdcf8c0b
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts.feature.test.tsx
@@ -0,0 +1,236 @@
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { describe, expect, it, vi } from 'vitest';
+
+import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEnterpriseAccount,
+ fapiEnterpriseConnection,
+ fapiEnvironment,
+ fapiExternalAccount,
+ fapiSession,
+ fapiUser,
+ fapiVerification,
+} from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section';
+
+const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta' });
+const custom = fapiEnterpriseConnection({ id: 'saml', name: 'Custom SAML' });
+
+function signedIn(overrides: FakeFapiSeed = {}) {
+ return {
+ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]),
+ environment: fapiEnvironment({
+ user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } },
+ }),
+ enterpriseConnections: [okta, custom],
+ ...overrides,
+ };
+}
+
+async function renderSection(seed: FakeFapiSeed = signedIn()) {
+ const fapi = serveFapi(seed);
+ const view = await renderWithClerk();
+ return { ...view, fapi };
+}
+
+describe('enterprise accounts', () => {
+ it('shows the fallback while Clerk loads', async () => {
+ serveFapi(signedIn());
+ const client = holdRequests('get', '/v1/client');
+ const rendering = renderWithClerk(Loading accounts
} />);
+
+ await waitFor(() => expect(client.requests).toHaveLength(1));
+ expect(screen.getByText('Loading accounts')).toBeInTheDocument();
+ client.release();
+ await rendering;
+ expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeInTheDocument();
+ });
+
+ it('hides the section when SSO is disabled', async () => {
+ await renderSection(signedIn({ environment: fapiEnvironment() }));
+ expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull();
+ });
+
+ it('hides the section when the user is signed out', async () => {
+ await renderSection(signedIn({ client: fapiClient() }));
+ expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull();
+ });
+
+ it('hides an empty section', async () => {
+ await renderSection(signedIn({ enterpriseConnections: [] }));
+ expect(screen.queryByRole('region', { name: 'Enterprise accounts' })).toBeNull();
+ });
+
+ it('shows only unlinked, organization-linkable connections', async () => {
+ const nonLinkable = fapiEnterpriseConnection({
+ id: 'other',
+ name: 'Other SAML',
+ allow_organization_account_linking: false,
+ });
+ await renderSection(signedIn({ enterpriseConnections: [okta, custom, nonLinkable] }));
+
+ expect(await screen.findByRole('button', { name: 'Connect Acme Okta' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Connect Custom SAML' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Connect Other SAML' })).toBeNull();
+ });
+
+ it('shows linked accounts with errors and offers only unlinked connections', async () => {
+ const linked = fapiEnterpriseAccount({
+ id: 'enterprise_1',
+ enterprise_connection_id: 'okta',
+ email_address: 'linked@example.com',
+ verification: fapiVerification({
+ strategy: 'enterprise_sso',
+ error: { code: 'enterprise_error', message: 'Fix this account', long_message: 'Fix this account' },
+ }),
+ });
+ const inactive = fapiEnterpriseAccount({
+ id: 'enterprise_inactive',
+ enterprise_connection_id: 'inactive',
+ email_address: 'inactive@example.com',
+ enterprise_connection: fapiEnterpriseConnection({ id: 'inactive', name: 'Inactive SAML', active: false }),
+ });
+ const client = fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', enterprise_accounts: [linked, inactive] }) }),
+ ]);
+ await renderSection(
+ signedIn({
+ client,
+ enterpriseConnections: [okta, custom, fapiEnterpriseConnection({ id: 'inactive', name: 'Inactive SAML' })],
+ }),
+ );
+
+ expect(screen.queryByText('inactive@example.com')).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Connect Inactive SAML' })).toBeNull();
+ expect(screen.getByText('linked@example.com')).toBeInTheDocument();
+ expect(screen.getByText('Requires action')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Connect Acme Okta' })).toBeNull();
+ expect(await screen.findByRole('button', { name: 'Connect Custom SAML' })).toBeInTheDocument();
+ });
+
+ it('keeps linked rows visible while connections load', async () => {
+ const linked = fapiEnterpriseAccount({ id: 'enterprise_1', email_address: 'linked@example.com' });
+ const client = fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', enterprise_accounts: [linked] }) }),
+ ]);
+ serveFapi(signedIn({ client }));
+ const connections = holdRequests('get', '/v1/me/enterprise_connections');
+ const rendering = renderWithClerk();
+
+ expect(await screen.findByText('linked@example.com')).toBeInTheDocument();
+ await waitFor(() => expect(connections.requests).toHaveLength(1));
+ connections.release();
+ await rendering;
+ });
+
+ it('sends the selected connection and current URL, then opens its redirect', async () => {
+ const { clerk } = await renderSection();
+ const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {});
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ const connect = await screen.findByRole('button', { name: 'Connect Acme Okta' });
+ connect.focus();
+ await user.keyboard('{Enter}');
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ expect(body.get('enterprise_connection_id')).toBe('okta');
+ expect(body.get('redirect_url')).toBe(window.location.href);
+ expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toHaveAttribute('aria-busy', 'true');
+ expect(screen.getByRole('button', { name: 'Connect Custom SAML' })).toBeDisabled();
+ await user.click(screen.getByRole('button', { name: 'Connect Custom SAML' }));
+ expect(request.requests).toHaveLength(1);
+
+ request.release();
+ await waitFor(() =>
+ expect(navigate).toHaveBeenCalledWith(new URL('https://accounts.example/enterprise-authorize')),
+ );
+ });
+
+ it('preserves modal return state in the redirect URL', async () => {
+ serveFapi(signedIn());
+ await renderWithClerk();
+ const request = holdRequests('post', '/v1/me/external_accounts');
+
+ await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
+ expect(encoded).toBeTruthy();
+ expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ componentName: 'UserProfile' });
+ request.fail();
+ });
+
+ it('shows a missing redirect error and allows another attempt', async () => {
+ serveFapi(signedIn());
+ worker.use(
+ http.post(fapiUrl('/v1/me/external_accounts'), () =>
+ HttpResponse.json({
+ response: fapiExternalAccount({
+ id: 'idn_okta',
+ provider: 'google',
+ verification: fapiVerification({ status: 'unverified', strategy: 'enterprise_sso' }),
+ }),
+ client: null,
+ }),
+ ),
+ );
+ const { clerk } = await renderWithClerk();
+ const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {});
+ const user = userEvent.setup();
+
+ await user.click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('The connection could not start. Please try again.');
+ expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled();
+ serveFapi(signedIn());
+ await user.click(screen.getByRole('button', { name: 'Connect Acme Okta' }));
+ await waitFor(() =>
+ expect(navigate).toHaveBeenCalledWith(new URL('https://accounts.example/enterprise-authorize')),
+ );
+ });
+
+ it('shows the API long message and succeeds after a manual retry', async () => {
+ const { clerk } = await renderSection();
+ const navigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {});
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ await user.click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.fail('enterprise_error', 'Acme is unavailable.');
+ expect(await screen.findByRole('alert')).toHaveTextContent('Acme is unavailable.');
+ expect(navigate).not.toHaveBeenCalled();
+ serveFapi(signedIn());
+ await user.click(screen.getByRole('button', { name: 'Connect Acme Okta' }));
+ await waitFor(() =>
+ expect(navigate).toHaveBeenCalledWith(new URL('https://accounts.example/enterprise-authorize')),
+ );
+ });
+
+ it('shows a direct verification-required API error without opening a dialog', async () => {
+ await renderSection();
+ const request = holdRequests('post', '/v1/me/external_accounts');
+
+ await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.fail('session_reverification_required', 'Verify your session.');
+ expect(await screen.findByRole('alert')).toHaveTextContent('Verify your session.');
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled();
+ });
+
+ it('reports a network failure without opening a dialog', async () => {
+ serveFapi(signedIn());
+ worker.use(http.post(fapiUrl('/v1/me/external_accounts'), () => HttpResponse.error()));
+ await renderWithClerk();
+
+ await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('The connection could not start. Please try again.');
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx
index 1c07e23e1fd..34a7612cf58 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
@@ -368,6 +368,20 @@ describe('UserProfileProfilePanelView', () => {
expect(onDeleteAccount).toHaveBeenCalledOnce();
});
+ it('places enterprise accounts after Web3 wallets and before the danger zone', () => {
+ renderView({
+ web3WalletsSlot: Wallets
,
+ enterpriseAccountsSlot: Enterprise accounts
,
+ onDeleteAccount: () => Promise.resolve(),
+ });
+
+ const wallets = screen.getByTestId('wallets');
+ const enterprise = screen.getByTestId('enterprise');
+ const danger = screen.getByRole('heading', { name: 'Danger zone' });
+ expect(wallets.compareDocumentPosition(enterprise) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ expect(enterprise.compareDocumentPosition(danger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
it('renders connected provider and Web3 images inside icon frames', () => {
const { container } = renderView({
connectedAccountsSlot: (
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx
new file mode 100644
index 00000000000..6492dbb661c
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.test.tsx
@@ -0,0 +1,63 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller';
+
+const connections = [
+ { id: 'okta', name: 'Acme Okta' },
+ { id: 'saml', name: 'Custom SAML' },
+];
+
+function deferred() {
+ let resolve: (value: T) => void = () => undefined;
+ let reject: (error: unknown) => void = () => undefined;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+afterEach(() => vi.useRealTimers());
+
+describe('useUserProfileEnterpriseAccountsController', () => {
+ it('synchronously suppresses duplicate and competing connects while keeping the selected row pending', async () => {
+ const operation = deferred<'redirecting'>();
+ const onConnect = vi.fn(() => operation.promise);
+ const { result } = renderHook(() =>
+ useUserProfileEnterpriseAccountsController({ status: 'ready', accounts: [], connections, onConnect }),
+ );
+
+ act(() => {
+ result.current.onConnect?.('okta');
+ result.current.onConnect?.('okta');
+ result.current.onConnect?.('saml');
+ });
+ expect(onConnect).toHaveBeenCalledExactlyOnceWith('okta');
+ expect(result.current.pendingConnectionId).toBe('okta');
+
+ await act(async () => {
+ operation.resolve('redirecting');
+ await operation.promise;
+ });
+ expect(result.current.pendingConnectionId).toBe('okta');
+ });
+
+ it('releases redirect pending after two seconds', async () => {
+ vi.useFakeTimers();
+ const onConnect = vi.fn().mockResolvedValue('redirecting');
+ const { result } = renderHook(() =>
+ useUserProfileEnterpriseAccountsController({ status: 'ready', accounts: [], connections, onConnect }),
+ );
+ await act(async () => {
+ result.current.onConnect?.('okta');
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(result.current.pendingConnectionId).toBe('okta');
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ expect(result.current.pendingConnectionId).toBeUndefined();
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts
new file mode 100644
index 00000000000..3ca2e52d925
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.controller.ts
@@ -0,0 +1,89 @@
+import { useMessages } from '../../../localization';
+import { setup } from '../../../machine/setup';
+import { useMachine } from '../../../machine/useMachine';
+import type { UserProfileEnterpriseAccountsSectionViewProps } from './user-profile-enterprise-accounts-section.view';
+
+type Ready = Required> & {
+ status: 'ready';
+ onConnect: (connectionId: string) => Promise<'redirecting' | void>;
+};
+
+interface Context {
+ selectedId: string | undefined;
+ errorId: string | undefined;
+ errorMessage: string | undefined;
+ run: () => Promise<'redirecting' | void>;
+}
+
+type Event = { type: 'CONNECT'; id: string; run: () => Promise<'redirecting' | void> };
+
+const { createMachine, assign, fromPromise } = setup();
+
+const machine = createMachine({
+ id: 'enterpriseAccountConnect',
+ initial: 'idle',
+ context: {
+ selectedId: undefined,
+ errorId: undefined,
+ errorMessage: undefined,
+ run: () => Promise.resolve(),
+ },
+ states: {
+ idle: {
+ on: {
+ CONNECT: {
+ target: 'connecting',
+ actions: assign((_, event) => ({
+ selectedId: event.id,
+ errorId: undefined,
+ errorMessage: undefined,
+ run: event.run,
+ })),
+ },
+ },
+ },
+ connecting: {
+ invoke: fromPromise(context => context.run(), {
+ onDone: [
+ { target: 'redirecting', guard: (_, event) => event.output === 'redirecting' },
+ { target: 'idle', actions: assign(() => ({ selectedId: undefined })) },
+ ],
+ onError: {
+ target: 'idle',
+ actions: assign((context, event) => ({
+ selectedId: undefined,
+ errorId: context.selectedId,
+ errorMessage: event.error instanceof Error && event.error.message ? event.error.message : undefined,
+ })),
+ },
+ }),
+ },
+ redirecting: {
+ after: {
+ 2000: { target: 'idle', actions: assign(() => ({ selectedId: undefined })) },
+ },
+ },
+ },
+});
+
+export function useUserProfileEnterpriseAccountsController(
+ model: Ready,
+): UserProfileEnterpriseAccountsSectionViewProps {
+ const messages = useMessages('userProfileEnterpriseAccountsSection');
+ const [snapshot, send] = useMachine(machine);
+ const { selectedId, errorId, errorMessage } = snapshot.context;
+
+ return {
+ accounts: model.accounts,
+ connections: model.connections.map(connection =>
+ connection.id === errorId ? { ...connection, connectError: errorMessage ?? messages.errors.generic } : connection,
+ ),
+ pendingConnectionId: selectedId,
+ onConnect: id => {
+ if (!model.connections.some(connection => connection.id === id)) {
+ return;
+ }
+ send({ type: 'CONNECT', id, run: () => model.onConnect(id) });
+ },
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts
index a601b7bada9..48c0d27c24b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts
@@ -3,4 +3,8 @@ export const userProfileEnterpriseAccountsMessages = {
connect: 'Connect',
connectProvider: 'Connect {provider}',
requiresAction: 'Requires action',
+ errors: {
+ generic: 'Something went wrong. Please try again.',
+ missingRedirect: 'The connection could not start. Please try again.',
+ },
} as const;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx
new file mode 100644
index 00000000000..4beb50d8270
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.tsx
@@ -0,0 +1,120 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
+import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
+import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react';
+import type { EnterpriseAccountResource, EnterpriseConnectionResource } from '@clerk/shared/types';
+
+import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
+import { useMessages } from '../../../localization';
+import type {
+ UserProfileEnterpriseAccount,
+ UserProfileEnterpriseConnection,
+} from './user-profile-enterprise-accounts-section.types';
+
+type Account = Pick & {
+ verification?: { error?: { longMessage?: string | null } | null } | null;
+ enterpriseConnection: {
+ active: boolean;
+ name: string;
+ logoPublicUrl?: string | null;
+ } | null;
+};
+
+type Connection = Pick & {
+ logoPublicUrl?: string | null;
+};
+
+export type EnterpriseAccountsProjection =
+ | { status: 'hidden' }
+ | { status: 'ready'; accounts: UserProfileEnterpriseAccount[]; connections: UserProfileEnterpriseConnection[] };
+
+export function projectEnterpriseAccounts({
+ enabled,
+ accounts,
+ connections,
+}: {
+ enabled: boolean;
+ accounts: readonly Account[];
+ connections: readonly Connection[];
+}): EnterpriseAccountsProjection {
+ if (!enabled) {
+ return { status: 'hidden' };
+ }
+
+ const linkedConnectionIds = new Set(accounts.map(account => account.enterpriseConnectionId));
+ const visibleAccounts = accounts
+ .filter(account => account.enterpriseConnection?.active)
+ .map(account => ({
+ id: account.id ?? '',
+ name: account.enterpriseConnection?.name ?? '',
+ iconUrl: account.enterpriseConnection?.logoPublicUrl ?? undefined,
+ emailAddress: account.emailAddress,
+ requiresAction: Boolean(account.verification?.error?.longMessage),
+ }));
+ const linkableConnections = connections
+ .filter(connection => connection.allowOrganizationAccountLinking && !linkedConnectionIds.has(connection.id))
+ .map(connection => ({ id: connection.id, name: connection.name, iconUrl: connection.logoPublicUrl ?? undefined }));
+
+ return visibleAccounts.length || linkableConnections.length
+ ? { status: 'ready', accounts: visibleAccounts, connections: linkableConnections }
+ : { status: 'hidden' };
+}
+
+export function useUserProfileEnterpriseAccountsModel({ mode }: { mode?: 'modal' | 'mounted' } = {}) {
+ const clerk = useClerk();
+ const { isLoaded, user } = useUser();
+ const environment = useMosaicEnvironment();
+ const { data: connections = [] } = __internal_useUserEnterpriseConnections({
+ withOrganizationAccountLinking: true,
+ enabled: Boolean(isLoaded && user && environment?.userSettings.enterpriseSSO.enabled),
+ });
+ const messages = useMessages('userProfileEnterpriseAccountsSection');
+
+ if (!isLoaded || !environment) {
+ return { status: 'loading' } as const;
+ }
+ if (!user) {
+ return { status: 'hidden' } as const;
+ }
+
+ const projection = projectEnterpriseAccounts({
+ enabled: environment.userSettings.enterpriseSSO.enabled,
+ accounts: user.enterpriseAccounts,
+ connections,
+ });
+ if (projection.status === 'hidden') {
+ return projection;
+ }
+
+ const connect = async (connectionId: string): Promise<'redirecting' | void> => {
+ if (!projection.connections.some(connection => connection.id === connectionId)) {
+ throw new Error(messages.errors.generic);
+ }
+ const url = window.location.href;
+ const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url;
+ let account: Awaited>;
+ try {
+ account = await user.createExternalAccount({ enterpriseConnectionId: connectionId, redirectUrl });
+ } catch (error) {
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ throw new Error(first?.longMessage || first?.message || messages.errors.generic);
+ }
+ throw new Error(messages.errors.generic);
+ }
+ const redirect = account?.verification?.externalVerificationRedirectURL;
+ if (!redirect) {
+ throw new Error(messages.errors.missingRedirect);
+ }
+ if (typeof clerk.__internal_windowNavigate === 'function') {
+ clerk.__internal_windowNavigate(redirect);
+ } else {
+ windowNavigate(redirect);
+ }
+ return 'redirecting';
+ };
+
+ return { ...projection, onConnect: connect };
+}
+
+export type UserProfileEnterpriseAccountsModel = ReturnType;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx
new file mode 100644
index 00000000000..0df423b5093
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.tsx
@@ -0,0 +1,32 @@
+import type { ReactNode } from 'react';
+
+import { useUserProfileEnterpriseAccountsController } from './user-profile-enterprise-accounts-section.controller';
+import {
+ type UserProfileEnterpriseAccountsModel,
+ useUserProfileEnterpriseAccountsModel,
+} from './user-profile-enterprise-accounts-section.model';
+import { UserProfileEnterpriseAccountsSectionView } from './user-profile-enterprise-accounts-section.view';
+
+type Ready = Extract;
+
+function ReadyEnterpriseAccountsSection({ model }: { model: Ready }) {
+ const controller = useUserProfileEnterpriseAccountsController(model);
+ return ;
+}
+
+export function UserProfileEnterpriseAccountsSection({
+ fallback,
+ mode,
+}: {
+ fallback?: ReactNode;
+ mode?: 'modal' | 'mounted';
+}) {
+ const model = useUserProfileEnterpriseAccountsModel({ mode });
+ if (model.status === 'loading') {
+ return fallback ?? null;
+ }
+ if (model.status === 'hidden') {
+ return null;
+ }
+ return ;
+}
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 f2b3b144a54..85e1be5e4a6 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
@@ -23,6 +23,7 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect
titleRef?: Ref;
connectedAccountsSlot?: ReactNode;
web3WalletsSlot?: ReactNode;
+ enterpriseAccountsSlot?: ReactNode;
/** Resolve to close the danger zone's confirmation dialog, reject to show why it failed. */
onDeleteAccount?: () => Promise;
}
@@ -60,6 +61,7 @@ export function UserProfileProfilePanelView({
onRemovePhone,
connectedAccountsSlot,
web3WalletsSlot,
+ enterpriseAccountsSlot,
onDeleteAccount,
}: UserProfileProfilePanelViewProps): ReactElement {
return (
@@ -104,6 +106,7 @@ export function UserProfileProfilePanelView({
/>
{connectedAccountsSlot}
{web3WalletsSlot}
+ {enterpriseAccountsSlot}
{onDeleteAccount ? : null}
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index b069dfb7618..9af3cbd403c 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 { UserProfileApiKeysPanelView } from '../features/user-profile/user-profile-api-keys-panel.view';
+export { UserProfileEnterpriseAccountsSectionView } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.view';
export { OrganizationProfileApiKeysPanelView } from '../features/organization-profile/organization-profile-api-keys-panel.view';
export { OrganizationProfileCreateAPIKeyDialog } from '../features/organization-profile/organization-profile-create-api-key.dialog';
diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx
index 69aa840738c..bd73ab524e4 100644
--- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx
+++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx
@@ -21,6 +21,7 @@ import {
const flows = [
{ title: 'Connected accounts', href: '/live/connected-accounts' },
{ title: 'Web3 wallets', href: '/live/web3-wallets' },
+ { title: 'Enterprise accounts', href: '/live/enterprise-accounts' },
{ title: 'Reverification', href: '/live/reverification' },
];
diff --git a/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx b/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx
new file mode 100644
index 00000000000..f91ab619133
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live/enterprise-accounts/page.tsx
@@ -0,0 +1,36 @@
+'use client';
+
+import { UserProfileEnterpriseAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section';
+import { MosaicProvider } from '@clerk/mosaic/MosaicProvider';
+import { useUser } from '@clerk/nextjs';
+import Link from 'next/link';
+
+export default function EnterpriseAccountsLivePage() {
+ const { isLoaded, isSignedIn } = useUser();
+
+ return (
+
+
+
+
Enterprise accounts
+
+ Connect and view the signed-in user's enterprise accounts.
+
+
+ {!isLoaded ?
Loading…
: null}
+ {isLoaded && !isSignedIn ? (
+
+
+ Sign in
+ {' '}
+ to manage enterprise accounts.
+
+ ) : null}
+ {isLoaded && isSignedIn ?
: null}
+
+
+ );
+}