diff --git a/.changeset/mosaic-profile-modal-oauth-return.md b/.changeset/mosaic-profile-modal-oauth-return.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-profile-modal-oauth-return.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx new file mode 100644 index 00000000000..32349ada5ac --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx @@ -0,0 +1,59 @@ +import { configure, screen, within } from '@testing-library/react'; +import { beforeAll, describe, expect, it, onTestFinished } from 'vitest'; +import { page } from 'vitest/browser'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; + +beforeAll(() => page.viewport(1280, 800)); + +function serve() { + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); +} + +function visit(search: string) { + const original = window.location.href; + window.history.replaceState(null, '', `${window.location.pathname}${search}#accounts`); + onTestFinished(() => window.history.replaceState(null, '', original)); +} + +function strictMode() { + configure({ reactStrictMode: true }); + onTestFinished(() => configure({ reactStrictMode: false })); +} + +describe('UserProfile modal return', () => { + it.each([ + { name: 'renders normally', strict: false }, + { name: 'renders in StrictMode', strict: true }, + ])('reopens the profile on the account page and clears the param when it $name', async ({ strict }) => { + if (strict) { + strictMode(); + } + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount&tab=connections'); + + await renderWithClerk(

Page

); + + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(window.location.search).toBe('?tab=connections'); + expect(window.location.hash).toBe('#accounts'); + }); + + it('ignores an unknown value', async () => { + serve(); + visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections'); + + await renderWithClerk(

Page

); + + expect(await screen.findByText('Page')).toBeVisible(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(window.location.search).toBe('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections'); + expect(window.location.hash).toBe('#accounts'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx index 2595e112eaf..1d6d556d55a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx @@ -214,7 +214,7 @@ describe('connected accounts', () => { expect(await screen.findByText('The connection could not start. Please try again.')).toBeInTheDocument(); }); - it('preserves modal state when connecting', async () => { + it('returns to the profile modal after connecting', async () => { serveFapi(signedIn([])); await renderWithClerk( @@ -226,12 +226,27 @@ describe('connected accounts', () => { await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' })); 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', - socialProvider: 'github', - }); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account'); + expect(params.has('__clerk_modal_state')).toBe(false); + request.fail(); + }); + + it('returns to the page without modal state when connecting from a mounted profile', async () => { + serveFapi(signedIn([])); + await renderWithClerk( + + + , + ); + const request = holdRequests('post', '/v1/me/external_accounts'); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + const body = new URLSearchParams(await request.requests[0]?.text()); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.has('__clerk_mosaic_modal')).toBe(false); + expect(params.has('__clerk_modal_state')).toBe(false); request.fail(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx index 2daba3a4ef1..be98c19e55a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx @@ -99,7 +99,7 @@ describe('connected accounts', () => { expect(open).not.toHaveBeenCalled(); }); - it('preserves modal state when reconnecting', async () => { + it('returns to the profile modal after reconnecting', async () => { serveFapi(signedIn([disconnectedGoogle])); await renderWithClerk( @@ -112,9 +112,9 @@ describe('connected accounts', () => { await startReconnect(user, 'Google'); 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' }); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account'); + expect(params.has('__clerk_modal_state')).toBe(false); request.fail(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts index e0cc0b7249d..93e85423c7c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts @@ -1,6 +1,5 @@ import { iconImageUrl } from '@clerk/shared/constants'; import { ClerkRuntimeError } from '@clerk/shared/error'; -import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import { useClerk, useUser } from '@clerk/shared/react'; import type { @@ -20,6 +19,7 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; import type { ReverificationController } from '../../reverification'; import { useReverificationFlow } from '../../reverification'; +import { withUserProfileModalReturn } from '../user-profile.modal-return'; import { useUserProfileOptions } from '../user-profile.provider'; import { isMonochromeProvider } from '../user-profile-provider.utils'; import type { @@ -283,8 +283,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco }; const getRedirectUrl = async () => (transport ? String(await transport.getRedirectUrl()) : window.location.href); - const withModalState = (url: string, socialProvider?: string) => - mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile', socialProvider }) : url; + const withModalState = (url: string) => (mode === 'modal' ? withUserProfileModalReturn(url) : url); const completeVerification = async (response: ExternalAccountResource): Promise => { const url = response.verification?.externalVerificationRedirectURL; @@ -319,7 +318,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco const current = requireCurrentUser(); const response = await createAccount(current, { strategy: provider.strategy, - redirectUrl: withModalState(redirectUrl, provider.provider), + redirectUrl: withModalState(redirectUrl), additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider.provider] : [], }); return completeVerification(response); diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts new file mode 100644 index 00000000000..b56945f08bd --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts @@ -0,0 +1,29 @@ +import React from 'react'; + +import { useModal } from '../../modals/use-modal'; +import { userProfileModal } from './user-profile.modal'; + +const MODAL_PARAM = '__clerk_mosaic_modal'; +const ACCOUNT_PAGE = 'user-profile/account'; + +export function withUserProfileModalReturn(url: string): string { + const next = new URL(url); + next.searchParams.set(MODAL_PARAM, ACCOUNT_PAGE); + return next.toString(); +} + +export function UserProfileModalReturn(): null { + const { open } = useModal(userProfileModal); + + React.useEffect(() => { + const url = new URL(window.location.href); + if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) { + return; + } + url.searchParams.delete(MODAL_PARAM); + window.history.replaceState(null, '', url); + open({ page: 'account' }); + }, [open]); + + return null; +} diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 3d7355893ba..7f90a73d2c0 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; import { organizationProfileModal } from './features/organization-profile/organization-profile.modal'; import { userProfileModal } from './features/user-profile/user-profile.modal'; +import { UserProfileModalReturn } from './features/user-profile/user-profile.modal-return'; import type { MosaicExperimental } from './hooks/use-mosaic-experimental'; import { MosaicExperimentalProvider } from './hooks/use-mosaic-experimental'; import { MosaicNowProvider } from './hooks/use-now'; @@ -50,7 +51,10 @@ export function MosaicProvider({ - {children} + + + {children} +