Skip to content

Commit 5e6101b

Browse files
alexcarpenterEphem
andauthored
feat(mosaic): reopen the user profile modal after OAuth and SSO redirects (#10545)
Co-authored-by: Fredrik Höglund <fredrik@clerk.dev>
1 parent edb9bab commit 5e6101b

13 files changed

Lines changed: 240 additions & 38 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': patch
3+
---
4+
5+
The user profile modal now reopens on the Account page after a user leaves it to connect or reconnect a social or enterprise account, or to verify an email address through enterprise SSO. Mosaic no longer sends `__clerk_modal_state` on these redirects, so returning from them no longer opens the legacy profile modal.

‎packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { act, screen, waitFor, within } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import { describe, expect, it, onTestFinished, vi } from 'vitest';
3+
import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest';
44
import { page } from 'vitest/browser';
55

66
import { openDialog } from '../../../__tests__/feature/dialog';
@@ -20,6 +20,10 @@ import { renderWithClerk } from '../../../__tests__/feature/render';
2020
import type { OrganizationSwitcherProps } from '../organization-switcher';
2121
import { OrganizationSwitcher } from '../organization-switcher';
2222

23+
beforeAll(async () => {
24+
await import('../../organization-profile/organization-profile.modal-content');
25+
});
26+
2327
const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 });
2428
const other = fapiOrganization({ id: 'org_9', name: 'Other' });
2529
const beta = fapiOrganization({ id: 'org_2', name: 'Beta' });

‎packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { screen, waitFor, within } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import { describe, expect, it, onTestFinished, vi } from 'vitest';
3+
import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest';
44
import { page } from 'vitest/browser';
55

66
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
@@ -21,6 +21,10 @@ import { signedIn as withConnectedAccounts } from '../../user-profile/user-profi
2121
import type { UserButtonProps } from '../user-button';
2222
import { UserButton } from '../user-button';
2323

24+
beforeAll(async () => {
25+
await import('../../user-profile/user-profile.modal-content');
26+
});
27+
2428
const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 });
2529
const other = fapiOrganization({ id: 'org_9', name: 'Other' });
2630
const beta = fapiOrganization({ id: 'org_2', name: 'Beta' });
@@ -128,11 +132,9 @@ describe('UserButton with experimental.newProfiles', () => {
128132
await waitFor(() => expect(request.requests).toHaveLength(1));
129133

130134
const body = new URLSearchParams(await request.requests[0]?.text());
131-
const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
132-
expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({
133-
componentName: 'UserProfile',
134-
socialProvider: 'github',
135-
});
135+
const params = new URL(body.get('redirect_url') || '').searchParams;
136+
expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account');
137+
expect(params.has('__clerk_modal_state')).toBe(false);
136138
request.fail();
137139
});
138140

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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(async () => {
10+
await import('../user-profile.modal-content');
11+
});
12+
13+
beforeAll(() => page.viewport(1280, 800));
14+
15+
function serve() {
16+
serveFapi({
17+
environment: fapiEnvironment(),
18+
client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]),
19+
});
20+
}
21+
22+
function visit(search: string) {
23+
const original = window.location.href;
24+
window.history.replaceState(null, '', `${window.location.pathname}${search}#accounts`);
25+
onTestFinished(() => window.history.replaceState(null, '', original));
26+
}
27+
28+
function strictMode() {
29+
configure({ reactStrictMode: true });
30+
onTestFinished(() => configure({ reactStrictMode: false }));
31+
}
32+
33+
describe('UserProfile modal return', () => {
34+
it.each([
35+
{ name: 'renders normally', strict: false },
36+
{ name: 'renders in StrictMode', strict: true },
37+
])('reopens the profile on the account page and clears the param when it $name', async ({ strict }) => {
38+
if (strict) {
39+
strictMode();
40+
}
41+
serve();
42+
visit('?__clerk_mosaic_modal=user-profile%2Faccount&tab=connections');
43+
44+
await renderWithClerk(<p>Page</p>);
45+
46+
const dialog = await screen.findByRole('dialog');
47+
expect(await within(dialog).findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
48+
expect(window.location.search).toBe('?tab=connections');
49+
expect(window.location.hash).toBe('#accounts');
50+
});
51+
52+
it('keeps the history state of the page when it clears the param', async () => {
53+
serve();
54+
visit('?__clerk_mosaic_modal=user-profile%2Faccount');
55+
window.history.replaceState({ router: 'state' }, '');
56+
57+
await renderWithClerk(<p>Page</p>);
58+
59+
await screen.findByRole('dialog');
60+
expect(window.location.search).toBe('');
61+
expect(window.history.state).toEqual({ router: 'state' });
62+
});
63+
64+
it('drops the Next.js marker when it clears the param, so the App Router syncs the new URL', async () => {
65+
serve();
66+
visit('?__clerk_mosaic_modal=user-profile%2Faccount');
67+
window.history.replaceState({ __NA: true, router: 'state' }, '');
68+
69+
await renderWithClerk(<p>Page</p>);
70+
71+
await screen.findByRole('dialog');
72+
expect(window.location.search).toBe('');
73+
expect(window.history.state).toEqual({ router: 'state' });
74+
});
75+
76+
it('ignores an unknown value', async () => {
77+
serve();
78+
visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');
79+
80+
await renderWithClerk(<p>Page</p>);
81+
82+
expect(await screen.findByText('Page')).toBeVisible();
83+
expect(screen.queryByRole('dialog')).toBeNull();
84+
expect(window.location.search).toBe('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');
85+
expect(window.location.hash).toBe('#accounts');
86+
});
87+
});

‎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) => withUserProfileModalReturn(url, mode);
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);

‎packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '../../../../__tests__/feature/fapi';
2323
import { openMenu } from '../../../../__tests__/feature/menu';
2424
import { renderWithClerk } from '../../../../__tests__/feature/render';
25+
import { UserProfileProvider } from '../../user-profile.provider';
2526
import { UserProfileEmailSection } from '../user-profile-email-section';
2627

2728
const VERIFIED = fapiVerification('email_code', { status: 'verified' });
@@ -207,6 +208,33 @@ describe('the user profile email section', () => {
207208
await waitFor(() => expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.acme.co/sso')));
208209
});
209210

211+
it('returns to the profile modal after verifying through the identity provider', async () => {
212+
const sso = fapiEmailAddress({
213+
id: 'idn_sso',
214+
email_address: 'alice@acme.co',
215+
matches_sso_connection: true,
216+
});
217+
serveFapi(signedInWithEmails([PRIMARY, sso]));
218+
await renderWithClerk(
219+
<UserProfileProvider mode='modal'>
220+
<UserProfileEmailSection />
221+
</UserProfileProvider>,
222+
);
223+
const actor = userEvent.setup();
224+
const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification');
225+
226+
await manageEmail(actor, 'alice@acme.co', 'Verify');
227+
const dialog = await screen.findByRole('dialog', { name: 'Verify your email' });
228+
await actor.click(within(dialog).getByRole('button', { name: 'Connect' }));
229+
230+
await waitFor(() => expect(prepare.requests).toHaveLength(1));
231+
const body = new URLSearchParams(await prepare.requests[0]?.text());
232+
const params = new URL(body.get('redirect_url') || '').searchParams;
233+
expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account');
234+
expect(params.has('__clerk_modal_state')).toBe(false);
235+
prepare.fail();
236+
});
237+
210238
it('does not offer to add the username as an address', async () => {
211239
const { actor } = await renderSection(
212240
signedInWithEmails([], fapiEnvironment({ attributes: { username: { enabled: true } } })),

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
66
import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation';
77
import { save } from '../../../utils/errors';
88
import { useReverificationFlow } from '../../reverification';
9+
import { withUserProfileModalReturn } from '../user-profile.modal-return';
10+
import type { UserProfileOptions } from '../user-profile.provider';
11+
import { useUserProfileOptions } from '../user-profile.provider';
912
import type { UserProfileEmailVerification, UserProfileEmailVerifier } from '../user-profile-contact.types';
1013
import { byId, toContactAccess, toContacts } from '../user-profile-contact.utils';
1114
import { useUserProfileUserModel } from '../user-profile-user.model';
@@ -33,6 +36,7 @@ function startEmailVerification(
3336
email: EmailAddressResource,
3437
linkRedirectUrl: string | undefined,
3538
router: MosaicRouter,
39+
mode: UserProfileOptions['mode'],
3640
): UserProfileEmailVerification {
3741
if (email.matchesSsoConnection) {
3842
return {
@@ -41,17 +45,12 @@ function startEmailVerification(
4145
Prepared on the click rather than on entering the step, so the redirect URL the server
4246
answers with is in hand by the time we navigate. Preparing on entry leaves the button live
4347
before the response lands, and an early click has nowhere to go.
44-
45-
TODO: carry the mounting mode back from the IdP. Legacy appends `appendModalState` to this
46-
redirect when the profile is mounted as a modal, so returning from the provider reopens the
47-
modal on the step the user left. Mosaic has no modal mode to encode yet; whoever adds one has
48-
to encode it here too, or the user comes back to a closed dialog and a lost flow.
4948
*/
5049
connect: () =>
5150
save(async () => {
5251
const prepared = await email.prepareVerification({
5352
strategy: 'enterprise_sso',
54-
redirectUrl: window.location.href,
53+
redirectUrl: withUserProfileModalReturn(window.location.href, mode),
5554
});
5655
const url = prepared.verification.externalVerificationRedirectURL;
5756
if (!url) {
@@ -76,16 +75,18 @@ function toEmailVerifier(
7675
email: EmailAddressResource,
7776
linkRedirectUrl: string | undefined,
7877
router: MosaicRouter,
78+
mode: UserProfileOptions['mode'],
7979
): UserProfileEmailVerifier {
8080
return {
81-
start: () => startEmailVerification(email, linkRedirectUrl, router),
81+
start: () => startEmailVerification(email, linkRedirectUrl, router, mode),
8282
verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS),
8383
};
8484
}
8585

8686
export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel {
8787
const model = useUserProfileUserModel();
8888
const router = useMosaicRouter();
89+
const { mode } = useUserProfileOptions();
8990
const [removeEmail, removeReverification] = useReverificationFlow((user: UserResource, id: string) =>
9091
byId(user.emailAddresses, id, 'email address').destroy(),
9192
);
@@ -114,7 +115,7 @@ export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel
114115

115116
const verifiesByLink = Boolean(attributes.email_address?.verifications.includes('email_link'));
116117
const linkRedirectUrl = verifiesByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined;
117-
const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router);
118+
const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router, mode);
118119

119120
return {
120121
status: 'ready',

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ describe('enterprise accounts', () => {
185185
);
186186
});
187187

188-
it('preserves modal return state in the redirect URL', async () => {
188+
it('returns to the profile modal after connecting', async () => {
189189
serveFapi(signedIn());
190190
await renderWithClerk(
191191
<UserProfileProvider mode='modal'>
@@ -197,9 +197,27 @@ describe('enterprise accounts', () => {
197197
await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
198198
await waitFor(() => expect(request.requests).toHaveLength(1));
199199
const body = new URLSearchParams(await request.requests[0]?.text());
200-
const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
201-
expect(encoded).toBeTruthy();
202-
expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ componentName: 'UserProfile' });
200+
const params = new URL(body.get('redirect_url') || '').searchParams;
201+
expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account');
202+
expect(params.has('__clerk_modal_state')).toBe(false);
203+
request.fail();
204+
});
205+
206+
it('returns to the page without modal state when connecting from a mounted profile', async () => {
207+
serveFapi(signedIn());
208+
await renderWithClerk(
209+
<UserProfileProvider mode='mounted'>
210+
<UserProfileEnterpriseAccountsSection />
211+
</UserProfileProvider>,
212+
);
213+
const request = holdRequests('post', '/v1/me/external_accounts');
214+
215+
await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' }));
216+
await waitFor(() => expect(request.requests).toHaveLength(1));
217+
const body = new URLSearchParams(await request.requests[0]?.text());
218+
const params = new URL(body.get('redirect_url') || '').searchParams;
219+
expect(params.has('__clerk_mosaic_modal')).toBe(false);
220+
expect(params.has('__clerk_modal_state')).toBe(false);
203221
request.fail();
204222
});
205223

0 commit comments

Comments
 (0)