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