Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-profile-modal-oauth-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -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(<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('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) => (mode === 'modal' ? withUserProfileModalReturn(url) : url);

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
@@ -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;
}
6 changes: 5 additions & 1 deletion packages/mosaic/src/mosaic-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,7 +51,10 @@ export function MosaicProvider({
<MosaicNowProvider value={now}>
<MosaicExperimentalProvider value={experimentalValue}>
<ToastProvider>
<ModalHost surfaces={modalSurfaces}>{children}</ModalHost>
<ModalHost surfaces={modalSurfaces}>
<UserProfileModalReturn />
{children}
</ModalHost>
</ToastProvider>
</MosaicExperimentalProvider>
</MosaicNowProvider>
Expand Down
Loading