Skip to content

Commit 2c19a24

Browse files
committed
feat(mosaic): reopen the user profile modal after a connected account OAuth redirect
Connect and Reconnect from the Mosaic user profile modal now carry __clerk_mosaic_modal=user-profile/account through the OAuth round trip. MosaicProvider strips the param on mount and reopens the profile on the Account page. Mosaic no longer writes the legacy __clerk_modal_state param, which the clerk-js UI would act on.
1 parent bde3694 commit 2c19a24

7 files changed

Lines changed: 124 additions & 16 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { configure, screen, within } from '@testing-library/react';
2+
import { beforeAll, describe, expect, it, onTestFinished } from 'vitest';
3+
import { page } from 'vitest/browser';
4+
5+
import { serveFapi } from '../../../__tests__/feature/fake-fapi';
6+
import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
7+
import { renderWithClerk } from '../../../__tests__/feature/render';
8+
9+
beforeAll(() => page.viewport(1280, 800));
10+
11+
function serve() {
12+
serveFapi({
13+
environment: fapiEnvironment(),
14+
client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]),
15+
});
16+
}
17+
18+
function visit(search: string) {
19+
const original = window.location.href;
20+
window.history.replaceState(null, '', `${window.location.pathname}${search}#accounts`);
21+
onTestFinished(() => window.history.replaceState(null, '', original));
22+
}
23+
24+
function strictMode() {
25+
configure({ reactStrictMode: true });
26+
onTestFinished(() => configure({ reactStrictMode: false }));
27+
}
28+
29+
describe('UserProfile modal return', () => {
30+
it.each([
31+
{ name: 'renders normally', strict: false },
32+
{ name: 'renders in StrictMode', strict: true },
33+
])('reopens the profile on the account page and clears the param when it $name', async ({ strict }) => {
34+
if (strict) {
35+
strictMode();
36+
}
37+
serve();
38+
visit('?__clerk_mosaic_modal=user-profile%2Faccount&tab=connections');
39+
40+
await renderWithClerk(<p>Page</p>);
41+
42+
const dialog = await screen.findByRole('dialog');
43+
expect(await within(dialog).findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
44+
expect(window.location.search).toBe('?tab=connections');
45+
expect(window.location.hash).toBe('#accounts');
46+
});
47+
48+
it('ignores an unknown value', async () => {
49+
serve();
50+
visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');
51+
52+
await renderWithClerk(<p>Page</p>);
53+
54+
expect(await screen.findByText('Page')).toBeVisible();
55+
expect(screen.queryByRole('dialog')).toBeNull();
56+
expect(window.location.search).toBe('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');
57+
expect(window.location.hash).toBe('#accounts');
58+
});
59+
});

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ describe('connected accounts', () => {
214214
expect(await screen.findByText('The connection could not start. Please try again.')).toBeInTheDocument();
215215
});
216216

217-
it('preserves modal state when connecting', async () => {
217+
it('returns to the profile modal after connecting', async () => {
218218
serveFapi(signedIn([]));
219219
await renderWithClerk(
220220
<UserProfileProvider mode='modal'>
@@ -226,12 +226,27 @@ describe('connected accounts', () => {
226226
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
227227
await waitFor(() => expect(request.requests).toHaveLength(1));
228228
const body = new URLSearchParams(await request.requests[0]?.text());
229-
const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
230-
expect(encoded).toBeTruthy();
231-
expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({
232-
componentName: 'UserProfile',
233-
socialProvider: 'github',
234-
});
229+
const params = new URL(body.get('redirect_url') || '').searchParams;
230+
expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account');
231+
expect(params.has('__clerk_modal_state')).toBe(false);
232+
request.fail();
233+
});
234+
235+
it('returns to the page without modal state when connecting from a mounted profile', async () => {
236+
serveFapi(signedIn([]));
237+
await renderWithClerk(
238+
<UserProfileProvider mode='mounted'>
239+
<UserProfileConnectedAccountsSection />
240+
</UserProfileProvider>,
241+
);
242+
const request = holdRequests('post', '/v1/me/external_accounts');
243+
244+
await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
245+
await waitFor(() => expect(request.requests).toHaveLength(1));
246+
const body = new URLSearchParams(await request.requests[0]?.text());
247+
const params = new URL(body.get('redirect_url') || '').searchParams;
248+
expect(params.has('__clerk_mosaic_modal')).toBe(false);
249+
expect(params.has('__clerk_modal_state')).toBe(false);
235250
request.fail();
236251
});
237252

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe('connected accounts', () => {
9999
expect(open).not.toHaveBeenCalled();
100100
});
101101

102-
it('preserves modal state when reconnecting', async () => {
102+
it('returns to the profile modal after reconnecting', async () => {
103103
serveFapi(signedIn([disconnectedGoogle]));
104104
await renderWithClerk(
105105
<UserProfileProvider mode='modal'>
@@ -112,9 +112,9 @@ describe('connected accounts', () => {
112112
await startReconnect(user, 'Google');
113113
await waitFor(() => expect(request.requests).toHaveLength(1));
114114
const body = new URLSearchParams(await request.requests[0]?.text());
115-
const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
116-
expect(encoded).toBeTruthy();
117-
expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ componentName: 'UserProfile' });
115+
const params = new URL(body.get('redirect_url') || '').searchParams;
116+
expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account');
117+
expect(params.has('__clerk_modal_state')).toBe(false);
118118
request.fail();
119119
});
120120

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { iconImageUrl } from '@clerk/shared/constants';
22
import { ClerkRuntimeError } from '@clerk/shared/error';
3-
import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
43
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
54
import { useClerk, useUser } from '@clerk/shared/react';
65
import type {
@@ -20,6 +19,7 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
2019
import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation';
2120
import type { ReverificationController } from '../../reverification';
2221
import { useReverificationFlow } from '../../reverification';
22+
import { withUserProfileModalReturn } from '../user-profile.modal-return';
2323
import { useUserProfileOptions } from '../user-profile.provider';
2424
import { isMonochromeProvider } from '../user-profile-provider.utils';
2525
import type {
@@ -283,8 +283,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco
283283
};
284284

285285
const getRedirectUrl = async () => (transport ? String(await transport.getRedirectUrl()) : window.location.href);
286-
const withModalState = (url: string, socialProvider?: string) =>
287-
mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile', socialProvider }) : url;
286+
const withModalState = (url: string) => (mode === 'modal' ? withUserProfileModalReturn(url) : url);
288287

289288
const completeVerification = async (response: ExternalAccountResource): Promise<ConnectedAccountActionResult> => {
290289
const url = response.verification?.externalVerificationRedirectURL;
@@ -319,7 +318,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco
319318
const current = requireCurrentUser();
320319
const response = await createAccount(current, {
321320
strategy: provider.strategy,
322-
redirectUrl: withModalState(redirectUrl, provider.provider),
321+
redirectUrl: withModalState(redirectUrl),
323322
additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider.provider] : [],
324323
});
325324
return completeVerification(response);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
3+
import { useModal } from '../../modals/use-modal';
4+
import { userProfileModal } from './user-profile.modal';
5+
6+
const MODAL_PARAM = '__clerk_mosaic_modal';
7+
const ACCOUNT_PAGE = 'user-profile/account';
8+
9+
export function withUserProfileModalReturn(url: string): string {
10+
const next = new URL(url);
11+
next.searchParams.set(MODAL_PARAM, ACCOUNT_PAGE);
12+
return next.toString();
13+
}
14+
15+
export function UserProfileModalReturn(): null {
16+
const { open } = useModal(userProfileModal);
17+
18+
React.useEffect(() => {
19+
const url = new URL(window.location.href);
20+
if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) {
21+
return;
22+
}
23+
url.searchParams.delete(MODAL_PARAM);
24+
window.history.replaceState(null, '', url);
25+
open({ page: 'account' });
26+
}, [open]);
27+
28+
return null;
29+
}

‎packages/mosaic/src/mosaic-provider.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react';
33
import { ToastProvider } from './components/toast/toast';
44
import { organizationProfileModal } from './features/organization-profile/organization-profile.modal';
55
import { userProfileModal } from './features/user-profile/user-profile.modal';
6+
import { UserProfileModalReturn } from './features/user-profile/user-profile.modal-return';
67
import { MosaicNowProvider } from './hooks/use-now';
78
import type { MosaicIconOverrides } from './icons/overrides';
89
import { MosaicIconsProvider } from './icons/overrides';
@@ -37,7 +38,10 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos
3738
<MosaicLocalizationProvider value={localizationValue}>
3839
<MosaicNowProvider value={now}>
3940
<ToastProvider>
40-
<ModalHost surfaces={modalSurfaces}>{children}</ModalHost>
41+
<ModalHost surfaces={modalSurfaces}>
42+
<UserProfileModalReturn />
43+
{children}
44+
</ModalHost>
4145
</ToastProvider>
4246
</MosaicNowProvider>
4347
</MosaicLocalizationProvider>

0 commit comments

Comments
 (0)