diff --git a/.changeset/mosaic-profile-modal-oauth-return.md b/.changeset/mosaic-profile-modal-oauth-return.md
new file mode 100644
index 00000000000..764431a4989
--- /dev/null
+++ b/.changeset/mosaic-profile-modal-oauth-return.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+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.
diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
index 36ba0bc99fa..76dda54916d 100644
--- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
+++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
@@ -1,6 +1,6 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it, onTestFinished, vi } from 'vitest';
+import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest';
import { page } from 'vitest/browser';
import { openDialog } from '../../../__tests__/feature/dialog';
@@ -20,6 +20,10 @@ import { renderWithClerk } from '../../../__tests__/feature/render';
import type { OrganizationSwitcherProps } from '../organization-switcher';
import { OrganizationSwitcher } from '../organization-switcher';
+beforeAll(async () => {
+ await import('../../organization-profile/organization-profile.modal-content');
+});
+
const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 });
const other = fapiOrganization({ id: 'org_9', name: 'Other' });
const beta = fapiOrganization({ id: 'org_2', name: 'Beta' });
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
index b41f67b573a..16f93c880c7 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
@@ -1,6 +1,6 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it, onTestFinished, vi } from 'vitest';
+import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest';
import { page } from 'vitest/browser';
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
@@ -21,6 +21,10 @@ import { signedIn as withConnectedAccounts } from '../../user-profile/user-profi
import type { UserButtonProps } from '../user-button';
import { UserButton } from '../user-button';
+beforeAll(async () => {
+ await import('../../user-profile/user-profile.modal-content');
+});
+
const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 });
const other = fapiOrganization({ id: 'org_9', name: 'Other' });
const beta = fapiOrganization({ id: 'org_2', name: 'Beta' });
@@ -128,11 +132,9 @@ describe('UserButton with experimental.newProfiles', () => {
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(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();
});
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..077d23012e5
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx
@@ -0,0 +1,87 @@
+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(async () => {
+ await import('../user-profile.modal-content');
+});
+
+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('keeps the history state of the page when it clears the param', async () => {
+ serve();
+ visit('?__clerk_mosaic_modal=user-profile%2Faccount');
+ window.history.replaceState({ router: 'state' }, '');
+
+ await renderWithClerk(Page
);
+
+ await screen.findByRole('dialog');
+ expect(window.location.search).toBe('');
+ expect(window.history.state).toEqual({ router: 'state' });
+ });
+
+ it('drops the Next.js marker when it clears the param, so the App Router syncs the new URL', async () => {
+ serve();
+ visit('?__clerk_mosaic_modal=user-profile%2Faccount');
+ window.history.replaceState({ __NA: true, router: 'state' }, '');
+
+ await renderWithClerk(Page
);
+
+ await screen.findByRole('dialog');
+ expect(window.location.search).toBe('');
+ expect(window.history.state).toEqual({ router: 'state' });
+ });
+
+ 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..498e24fc37c 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) => withUserProfileModalReturn(url, mode);
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-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx
index d28b064404a..a87c52839e3 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx
@@ -22,6 +22,7 @@ import {
} from '../../../../__tests__/feature/fapi';
import { openMenu } from '../../../../__tests__/feature/menu';
import { renderWithClerk } from '../../../../__tests__/feature/render';
+import { UserProfileProvider } from '../../user-profile.provider';
import { UserProfileEmailSection } from '../user-profile-email-section';
const VERIFIED = fapiVerification('email_code', { status: 'verified' });
@@ -207,6 +208,33 @@ describe('the user profile email section', () => {
await waitFor(() => expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.acme.co/sso')));
});
+ it('returns to the profile modal after verifying through the identity provider', async () => {
+ const sso = fapiEmailAddress({
+ id: 'idn_sso',
+ email_address: 'alice@acme.co',
+ matches_sso_connection: true,
+ });
+ serveFapi(signedInWithEmails([PRIMARY, sso]));
+ await renderWithClerk(
+
+
+ ,
+ );
+ const actor = userEvent.setup();
+ const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification');
+
+ await manageEmail(actor, 'alice@acme.co', 'Verify');
+ const dialog = await screen.findByRole('dialog', { name: 'Verify your email' });
+ await actor.click(within(dialog).getByRole('button', { name: 'Connect' }));
+
+ await waitFor(() => expect(prepare.requests).toHaveLength(1));
+ const body = new URLSearchParams(await prepare.requests[0]?.text());
+ 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);
+ prepare.fail();
+ });
+
it('does not offer to add the username as an address', async () => {
const { actor } = await renderSection(
signedInWithEmails([], fapiEnvironment({ attributes: { username: { enabled: true } } })),
diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
index b94eb88cfd5..fd4419a5ac8 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts
@@ -6,6 +6,9 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation';
import { save } from '../../../utils/errors';
import { useReverificationFlow } from '../../reverification';
+import { withUserProfileModalReturn } from '../user-profile.modal-return';
+import type { UserProfileOptions } from '../user-profile.provider';
+import { useUserProfileOptions } from '../user-profile.provider';
import type { UserProfileEmailVerification, UserProfileEmailVerifier } from '../user-profile-contact.types';
import { byId, toContactAccess, toContacts } from '../user-profile-contact.utils';
import { useUserProfileUserModel } from '../user-profile-user.model';
@@ -33,6 +36,7 @@ function startEmailVerification(
email: EmailAddressResource,
linkRedirectUrl: string | undefined,
router: MosaicRouter,
+ mode: UserProfileOptions['mode'],
): UserProfileEmailVerification {
if (email.matchesSsoConnection) {
return {
@@ -41,17 +45,12 @@ function startEmailVerification(
Prepared on the click rather than on entering the step, so the redirect URL the server
answers with is in hand by the time we navigate. Preparing on entry leaves the button live
before the response lands, and an early click has nowhere to go.
-
- TODO: carry the mounting mode back from the IdP. Legacy appends `appendModalState` to this
- redirect when the profile is mounted as a modal, so returning from the provider reopens the
- modal on the step the user left. Mosaic has no modal mode to encode yet; whoever adds one has
- to encode it here too, or the user comes back to a closed dialog and a lost flow.
*/
connect: () =>
save(async () => {
const prepared = await email.prepareVerification({
strategy: 'enterprise_sso',
- redirectUrl: window.location.href,
+ redirectUrl: withUserProfileModalReturn(window.location.href, mode),
});
const url = prepared.verification.externalVerificationRedirectURL;
if (!url) {
@@ -76,9 +75,10 @@ function toEmailVerifier(
email: EmailAddressResource,
linkRedirectUrl: string | undefined,
router: MosaicRouter,
+ mode: UserProfileOptions['mode'],
): UserProfileEmailVerifier {
return {
- start: () => startEmailVerification(email, linkRedirectUrl, router),
+ start: () => startEmailVerification(email, linkRedirectUrl, router, mode),
verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS),
};
}
@@ -86,6 +86,7 @@ function toEmailVerifier(
export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel {
const model = useUserProfileUserModel();
const router = useMosaicRouter();
+ const { mode } = useUserProfileOptions();
const [removeEmail, removeReverification] = useReverificationFlow((user: UserResource, id: string) =>
byId(user.emailAddresses, id, 'email address').destroy(),
);
@@ -114,7 +115,7 @@ export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel
const verifiesByLink = Boolean(attributes.email_address?.verifications.includes('email_link'));
const linkRedirectUrl = verifiesByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined;
- const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router);
+ const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router, mode);
return {
status: 'ready',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx
index b043c5dbbf7..9216357078c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx
@@ -185,7 +185,7 @@ describe('enterprise accounts', () => {
);
});
- it('preserves modal return state in the redirect URL', async () => {
+ it('returns to the profile modal after connecting', async () => {
serveFapi(signedIn());
await renderWithClerk(
@@ -197,9 +197,27 @@ describe('enterprise 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' });
+ 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(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 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-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
index f6666ce1740..5061855edac 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts
@@ -1,5 +1,4 @@
import { ClerkRuntimeError } from '@clerk/shared/error';
-import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react';
import type { EnterpriseAccountResource, EnterpriseConnectionResource, UserResource } from '@clerk/shared/types';
@@ -7,6 +6,7 @@ import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
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 {
@@ -137,8 +137,7 @@ export function useUserProfileEnterpriseAccountsModel(): UserProfileEnterpriseAc
if (!projection.connections.some(connection => connection.id === connectionId)) {
throw unavailable();
}
- const url = window.location.href;
- const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url;
+ const redirectUrl = withUserProfileModalReturn(window.location.href, mode);
const account = await createAccount(current, { enterpriseConnectionId: connectionId, redirectUrl });
currentUser();
const redirect = account.verification?.externalVerificationRedirectURL;
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..4d06e906a64
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
@@ -0,0 +1,40 @@
+import React from 'react';
+
+import { useModal } from '../../modals/use-modal';
+import { userProfileModal } from './user-profile.modal';
+import type { UserProfileOptions } from './user-profile.provider';
+
+const MODAL_PARAM = '__clerk_mosaic_modal';
+const ACCOUNT_PAGE = 'user-profile/account';
+
+export function withUserProfileModalReturn(url: string, mode: UserProfileOptions['mode']): string {
+ if (mode !== 'modal') {
+ return url;
+ }
+ 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(withoutNextAppRouterMarker(window.history.state), '', url);
+ open({ page: 'account' });
+ }, [open]);
+
+ return null;
+}
+
+function withoutNextAppRouterMarker(state: unknown): unknown {
+ if (typeof state !== 'object' || state === null) {
+ return state;
+ }
+ return Object.fromEntries(Object.entries(state).filter(([key]) => key !== '__NA'));
+}
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}
+