Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-profile-modal-oauth-return.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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' });
Expand Down Expand Up @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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(<p>Page</p>);

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(<p>Page</p>);

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(<p>Page</p>);

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(<p>Page</p>);

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<UserProfileProvider mode='modal'>
Expand All @@ -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(
<UserProfileProvider mode='mounted'>
<UserProfileConnectedAccountsSection />
</UserProfileProvider>,
);
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();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<UserProfileProvider mode='modal'>
Expand All @@ -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();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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<ConnectedAccountActionResult> => {
const url = response.verification?.externalVerificationRedirectURL;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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(
<UserProfileProvider mode='modal'>
<UserProfileEmailSection />
</UserProfileProvider>,
);
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 } } })),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -33,6 +36,7 @@ function startEmailVerification(
email: EmailAddressResource,
linkRedirectUrl: string | undefined,
router: MosaicRouter,
mode: UserProfileOptions['mode'],
): UserProfileEmailVerification {
if (email.matchesSsoConnection) {
return {
Expand All @@ -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) {
Expand All @@ -76,16 +75,18 @@ 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),
};
}

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(),
);
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<UserProfileProvider mode='modal'>
Expand All @@ -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(
<UserProfileProvider mode='mounted'>
<UserProfileEnterpriseAccountsSection />
</UserProfileProvider>,
);
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();
});

Expand Down
Loading
Loading