diff --git a/.changeset/mosaic-connected-accounts-wire-up.md b/.changeset/mosaic-connected-accounts-wire-up.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-connected-accounts-wire-up.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index a7a4ba2dea0..fc8820fbcf2 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -1,9 +1,12 @@
+import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import type {
ApiKeyJSON,
ClientJSON,
+ OAuthProvider,
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
SessionJSON,
+ UserJSON,
UserOrganizationInvitationJSON,
} from '@clerk/shared/types';
import { http, HttpResponse, type JsonBodyType } from 'msw';
@@ -20,10 +23,12 @@ import {
fapiClient,
type FapiEnvironment,
fapiEnvironment,
+ fapiExternalAccount,
fapiMembership,
fapiOrganization,
fapiPage,
fapiToken,
+ fapiVerification,
} from './fapi';
export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k';
@@ -88,6 +93,17 @@ function error(code: string, status = 400) {
return HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status });
}
+function activeUser(state: FakeFapiState): UserJSON | undefined {
+ return findSession(state, state.client.last_active_session_id)?.user;
+}
+
+function updateUser(state: FakeFapiState, user: UserJSON): void {
+ state.client = {
+ ...state.client,
+ sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)),
+ };
+}
+
function missing() {
return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 });
}
@@ -110,6 +126,78 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
...verificationHandlers(state, fapiUrl),
http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)),
http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)),
+ http.get(fapiUrl('/v1/me'), () => {
+ const user = activeUser(state);
+ return user ? envelope(user, state.client) : missing();
+ }),
+ http.post(fapiUrl('/v1/me/external_accounts'), async ({ request }) => {
+ const user = activeUser(state);
+ if (!user) {
+ return missing();
+ }
+ const body = new URLSearchParams(await request.text());
+ const strategy = body.get('strategy');
+ if (!strategy) {
+ return missing();
+ }
+ const provider: OAuthProvider | undefined = OAUTH_PROVIDERS.find(item => item.strategy === strategy)?.provider;
+ if (!provider) {
+ return missing();
+ }
+ const account = fapiExternalAccount({
+ id: `idn_${crypto.randomUUID()}`,
+ approved_scopes: '',
+ provider,
+ verification: fapiVerification(strategy, {
+ status: 'unverified',
+ external_verification_redirect_url: 'https://accounts.example/authorize',
+ }),
+ });
+ updateUser(state, {
+ ...user,
+ external_accounts: [
+ ...user.external_accounts.filter(
+ item => item.provider !== provider || item.verification?.status === 'verified',
+ ),
+ account,
+ ],
+ });
+ return envelope(account, state.client);
+ }),
+ http.post(fapiUrl('/v1/me/external_accounts/:id/reauthorize'), ({ params, request }) => {
+ if (new URL(request.url).searchParams.get('_method') !== 'PATCH') {
+ return undefined;
+ }
+ const user = activeUser(state);
+ const account = user?.external_accounts.find(item => item.id === params.id);
+ if (!user || !account) {
+ return missing();
+ }
+ const pending = {
+ ...account,
+ verification: fapiVerification(`oauth_${account.provider}`, {
+ status: 'unverified',
+ external_verification_redirect_url: 'https://accounts.example/consent',
+ }),
+ };
+ updateUser(state, {
+ ...user,
+ external_accounts: user.external_accounts.map(item => (item.id === pending.id ? pending : item)),
+ });
+ return envelope(pending, state.client);
+ }),
+ http.post(fapiUrl('/v1/me/external_accounts/:id'), ({ params, request }) => {
+ if (new URL(request.url).searchParams.get('_method') !== 'DELETE') {
+ return undefined;
+ }
+ const user = activeUser(state);
+ const account = user?.external_accounts.find(item => item.id === params.id);
+ if (!user || !account) {
+ return missing();
+ }
+ updateUser(state, { ...user, external_accounts: user.external_accounts.filter(item => item.id !== account.id) });
+ return envelope({ ...account, object: 'external_account' }, state.client);
+ }),
http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => {
const session = findSession(state, params.id);
return session
diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts
index 81faadddb17..383e4b20ead 100644
--- a/packages/mosaic/src/__tests__/feature/fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fapi.ts
@@ -9,6 +9,7 @@ import type {
EmailAddressJSON,
EnterpriseAccountJSON,
EnvironmentJSON,
+ ExternalAccountJSON,
OAuthProviders,
OrganizationJSON,
OrganizationMembershipJSON,
@@ -235,6 +236,27 @@ export function fapiEmailAddress(
};
}
+export function fapiExternalAccount(
+ overrides: Partial & Pick,
+): ExternalAccountJSON {
+ return {
+ object: 'external_account',
+ identification_id: overrides.id,
+ provider_user_id: 'provider_user_1',
+ approved_scopes: 'email',
+ email_address: `${overrides.provider}@example.com`,
+ first_name: '',
+ last_name: '',
+ image_url: '',
+ username: '',
+ phone_number: '',
+ public_metadata: {},
+ label: '',
+ verification: fapiVerification(`oauth_${overrides.provider}`, { status: 'verified' }),
+ ...overrides,
+ };
+}
+
export function fapiUser(overrides: Partial & Pick): UserJSON {
return {
object: 'user',
diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx
index bdf8c89a047..0b4e0694f69 100644
--- a/packages/mosaic/src/__tests__/feature/render.tsx
+++ b/packages/mosaic/src/__tests__/feature/render.tsx
@@ -7,7 +7,7 @@ import { vi } from 'vitest';
import { MosaicProvider } from '../../mosaic-provider';
import { PUBLISHABLE_KEY } from './fake-fapi';
-export async function renderWithClerk(ui: ReactElement) {
+export async function renderWithClerk(ui: ReactElement, options?: Parameters[0]) {
const clerk = new Clerk(PUBLISHABLE_KEY);
const navigate = vi.fn((_to: string) => Promise.resolve());
@@ -21,7 +21,7 @@ export async function renderWithClerk(ui: ReactElement) {
);
let current = ui;
const view = render(wrap(current));
- await act(() => clerk.load({ routerPush: to => navigate(to), routerReplace: to => navigate(to) }));
+ await act(() => clerk.load({ routerPush: to => navigate(to), routerReplace: to => navigate(to), ...options }));
view.rerender(wrap(current));
return {
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx
deleted file mode 100644
index bdff60b1263..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { createDeferredPromise } from '@clerk/shared/utils';
-import { act, render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { useState } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { MosaicProvider } from '../../../mosaic-provider';
-import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
-
-const account = { id: 'github', provider: 'GitHub', identifier: 'test' };
-
-function renderAccounts(onRemove: (id: string) => void | Promise) {
- return render(
-
-
- ,
- );
-}
-
-async function openRemoval(user: ReturnType) {
- await user.click(screen.getByRole('button', { name: 'Manage GitHub' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
-}
-
-describe('connected account removal', () => {
- it('returns keyboard focus to the menu when canceled', async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn();
- renderAccounts(onRemove);
- const trigger = screen.getByRole('button', { name: 'Manage GitHub' });
- trigger.focus();
- await user.keyboard('{Enter}');
- await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove' })).toHaveFocus());
- await user.keyboard('{Enter}');
- expect(screen.getByRole('alertdialog')).toBeInTheDocument();
- await user.keyboard('{Escape}');
- await waitFor(() => expect(trigger).toHaveFocus());
- expect(onRemove).not.toHaveBeenCalled();
- });
-
- it('focuses the remaining account and then Connect when the caller removes rows', async () => {
- const user = userEvent.setup();
- function Example() {
- const [accounts, setAccounts] = useState([account, { ...account, id: 'second', provider: 'Google' }]);
- return (
-
- {}}
- onRemove={id => setAccounts(current => current.filter(item => item.id !== id))}
- />
-
- );
- }
- render();
- await openRemoval(user);
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
- await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Google' })).toHaveFocus());
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
- await waitFor(() => expect(screen.getByRole('button', { name: 'Connect Other' })).toHaveFocus());
- });
-
- it('shows a removal failure and allows retrying', async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn().mockRejectedValueOnce(new Error('Unable to disconnect')).mockResolvedValue(undefined);
- renderAccounts(onRemove);
- await openRemoval(user);
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
- expect(await screen.findByRole('alert')).toHaveTextContent('Unable to disconnect');
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
- expect(onRemove).toHaveBeenCalledTimes(2);
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
-
- it('keeps the selected account pending until its removal finishes', async () => {
- const user = userEvent.setup();
- const removal = createDeferredPromise();
- const onRemove = vi.fn(async () => {
- await removal.promise;
- });
- renderAccounts(onRemove);
- await openRemoval(user);
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
-
- expect(onRemove).toHaveBeenCalledExactlyOnceWith('github');
- expect(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })).toHaveAttribute(
- 'aria-busy',
- 'true',
- );
- await act(async () => {
- removal.resolve();
- await removal.promise;
- });
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
-
- it('opens the same confirmation for the chosen account after cancelling another', async () => {
- const user = userEvent.setup();
- const onRemove = vi.fn();
- render(
-
-
- ,
- );
- await openRemoval(user);
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/GitHub will be removed/);
- await user.click(screen.getByRole('button', { name: 'Cancel' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(screen.getByRole('button', { name: 'Manage GitHub' })).toHaveFocus();
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
- expect(screen.getAllByRole('alertdialog')).toHaveLength(1);
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/Google will be removed/);
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
- expect(onRemove).toHaveBeenCalledExactlyOnceWith('google');
- });
-
- it('keeps removal callbacks isolated between section instances', async () => {
- const user = userEvent.setup();
- const removeGitHub = vi.fn();
- const removeGoogle = vi.fn();
- render(
-
-
-
- ,
- );
- await openRemoval(user);
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/GitHub will be removed/);
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(removeGitHub).toHaveBeenCalledExactlyOnceWith('github');
- expect(removeGoogle).not.toHaveBeenCalled();
-
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
- expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/Google will be removed/);
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
- expect(removeGoogle).toHaveBeenCalledExactlyOnceWith('google');
- expect(removeGitHub).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx
deleted file mode 100644
index e74f66bb7b0..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { describe, expect, it, vi } from 'vitest';
-
-import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
-
-const account = { id: 'account_1', provider: 'Google', identifier: 'test@example.com' };
-
-describe('connected accounts section', () => {
- it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'google', provider: 'Google' }] }])(
- 'hides the entire section without accounts or actionable providers (%j)',
- ({ availableProviders }) => {
- const { container } = render(
- ,
- );
- expect(container).toBeEmptyDOMElement();
- },
- );
-
- it('shows provider names without announcing decorative logos', () => {
- render(
- ,
- );
- expect(screen.getByText('Google')).toBeVisible();
- expect(screen.queryByRole('img')).not.toBeInTheDocument();
- });
-
- it('shows connection errors while keeping Connect available', () => {
- render(
- ,
- );
- expect(screen.getByRole('alert')).toHaveTextContent('Connection failed');
- expect(screen.getByRole('button', { name: 'Connect Apple' })).toBeEnabled();
- expect(screen.queryByRole('menu')).not.toBeInTheDocument();
- });
-
- it('does not offer removal for a protected account', async () => {
- const user = userEvent.setup();
- render(
- ,
- );
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled();
- expect(screen.queryByRole('menuitem', { name: 'Remove' })).not.toBeInTheDocument();
- });
-
- it('renders reconnect and verification errors without a generic manage action', async () => {
- const user = userEvent.setup();
- const onReconnect = vi.fn();
- render(
- ,
- );
- expect(screen.getByText('Disconnected')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Reconnect' })).not.toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled();
- expect(screen.getByText('Provider error')).toBeInTheDocument();
- expect(screen.getAllByRole('alert').some(alert => alert.textContent?.includes('Reconnection failed'))).toBe(true);
- expect(screen.queryByRole('button', { name: 'Manage Other' })).not.toBeInTheDocument();
- });
-});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
new file mode 100644
index 00000000000..3080ab7ffdb
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
@@ -0,0 +1,957 @@
+import { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { createRef } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEnterpriseAccount,
+ fapiEnvironment,
+ fapiExternalAccount,
+ fapiSession,
+ fapiUser,
+ fapiVerification,
+} from '../../../__tests__/feature/fapi';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../mosaic-provider';
+import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section';
+import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view';
+
+const google = fapiExternalAccount({ id: 'idn_google', provider: 'google', username: 'jdoe' });
+const github = fapiExternalAccount({ id: 'idn_github', provider: 'github' });
+const disconnectedGoogle = fapiExternalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ verification: fapiVerification('google_one_tap', {
+ status: 'unverified',
+ error: { code: 'external_account_missing_refresh_token', message: 'Missing token', long_message: 'Missing token' },
+ }),
+});
+
+function signedIn(accounts = [google], overrides: FakeFapiSeed = {}) {
+ return {
+ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: accounts }) })]),
+ environment: fapiEnvironment({
+ user_settings: {
+ social: {
+ oauth_google: {
+ enabled: true,
+ required: false,
+ authenticatable: true,
+ strategy: 'oauth_google',
+ name: 'Google',
+ logo_url: null,
+ },
+ oauth_github: {
+ enabled: true,
+ required: false,
+ authenticatable: true,
+ strategy: 'oauth_github',
+ name: 'GitHub',
+ logo_url: null,
+ },
+ },
+ },
+ }),
+ ...overrides,
+ };
+}
+
+function deferred() {
+ let resolve: (value: T) => void = () => {};
+ const promise = new Promise(fulfill => {
+ resolve = fulfill;
+ });
+ return { promise, resolve };
+}
+
+async function renderSection(accounts = [google], overrides: FakeFapiSeed = {}) {
+ const fapi = serveFapi(signedIn(accounts, overrides));
+ const titleRef = createRef();
+ const view = await renderWithClerk(
+ titleRef.current} />}
+ />,
+ );
+ return { ...view, fapi, titleRef };
+}
+
+async function openRemoval(user: ReturnType, provider: string) {
+ await user.click(screen.getByRole('button', { name: `Manage ${provider}` }));
+ await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
+ return screen.getByRole('alertdialog');
+}
+
+describe('connected accounts', () => {
+ it('shows the fallback while Clerk loads', async () => {
+ serveFapi(signedIn());
+ const client = holdRequests('get', '/v1/client');
+ const rendering = renderWithClerk(Loading accounts
} />);
+
+ await waitFor(() => expect(client.requests).toHaveLength(1));
+ expect(screen.getByText('Loading accounts')).toBeInTheDocument();
+ client.release();
+ await rendering;
+ expect(screen.getByRole('heading', { name: 'Connected accounts' })).toBeInTheDocument();
+ });
+
+ it('shows connected accounts and offers remaining providers', async () => {
+ await renderSection();
+
+ expect(screen.getByRole('heading', { name: 'Account', level: 2 })).toBeVisible();
+ expect(screen.getByRole('group', { name: 'Connected accounts' })).toBeVisible();
+ expect(screen.getByRole('heading', { name: 'Connected accounts' })).toBeInTheDocument();
+ expect(screen.getByText('jdoe')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Manage Google' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Connect Google' })).toBeNull();
+ expect(screen.queryByRole('img')).toBeNull();
+ });
+
+ it('hides the section when no social provider is enabled', async () => {
+ await renderSection([], { environment: fapiEnvironment() });
+ expect(screen.queryByRole('heading', { name: 'Connected accounts' })).toBeNull();
+ });
+
+ it('offers enabled providers when no accounts are connected', async () => {
+ await renderSection([]);
+
+ expect(screen.getByRole('button', { name: 'Connect Google' })).toBeVisible();
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).toBeVisible();
+ expect(screen.queryByRole('button', { name: /^Manage/ })).toBeNull();
+ });
+
+ it('hides Connect when an enterprise connection blocks new identifications', async () => {
+ const enterprise = fapiEnterpriseAccount({ id: 'sso_account_1' });
+ const user = fapiUser({ id: 'user_1', external_accounts: [google], enterprise_accounts: [enterprise] });
+ const environment = signedIn().environment;
+ await renderSection([google], {
+ client: fapiClient([fapiSession({ id: 'sess_1', user })]),
+ environment: {
+ ...environment,
+ user_settings: {
+ ...environment.user_settings,
+ enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false },
+ },
+ },
+ });
+
+ expect(screen.getByRole('button', { name: 'Manage Google' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /^Connect/ })).toBeNull();
+ });
+
+ it('shows a provider verification error', async () => {
+ const failed = fapiExternalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ verification: fapiVerification('oauth_google', {
+ status: 'unverified',
+ error: { code: 'provider_error', message: 'Provider error', long_message: 'Provider error' },
+ }),
+ });
+ await renderSection([failed]);
+
+ expect(screen.getByText('Provider error')).toBeInTheDocument();
+ });
+
+ it('retries a canceled connection from its row instead of offering Connect again', async () => {
+ const canceled = fapiExternalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ verification: fapiVerification('oauth_google', {
+ status: 'unverified',
+ error: {
+ code: 'oauth_access_denied',
+ message: 'Access denied',
+ long_message: 'You did not grant access to your Google account',
+ },
+ }),
+ });
+ const { clerk } = await renderSection([canceled]);
+ const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ expect(screen.getByText('You did not grant access to your Google account')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Connect Google' })).toBeNull();
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Try again' }));
+
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ expect(body.get('strategy')).toBe('oauth_google');
+ request.release();
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize'));
+ });
+
+ it('sends requested scopes and the current URL to connect', async () => {
+ serveFapi(signedIn([]));
+ const { clerk } = await renderWithClerk(
+ ,
+ );
+ const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ expect(body.get('strategy')).toBe('oauth_github');
+ expect(body.get('redirect_url')).toBe(window.location.href);
+ expect(body.get('additional_scope')).toBe('repo');
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).toHaveAttribute('aria-busy', 'true');
+
+ request.release();
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize'));
+ });
+
+ it('shows a failed connection and allows retrying', async () => {
+ const { clerk } = await renderSection([]);
+ const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.fail('oauth_error', 'GitHub is unavailable right now.');
+
+ expect(await screen.findByText('GitHub is unavailable right now.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).toBeEnabled();
+ serveFapi(signedIn([]));
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize'));
+ });
+
+ it('keeps only the chosen Connect action pending', async () => {
+ await renderSection([]);
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+ const githubButton = screen.getByRole('button', { name: 'Connect GitHub' });
+
+ await user.click(githubButton);
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ await user.click(screen.getByRole('button', { name: 'Connect Google' }));
+ expect(request.requests).toHaveLength(1);
+ expect(githubButton).toHaveAttribute('aria-busy', 'true');
+ request.fail();
+ });
+
+ it('shows a verification-required Connect error', async () => {
+ await renderSection([]);
+ 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));
+ request.fail('session_reverification_required', 'Verify your session.');
+ expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).toBeEnabled();
+ });
+
+ it('shows the OAuth error when FAPI returns no verification URL', async () => {
+ serveFapi(signedIn([]));
+ worker.use(
+ http.post(fapiUrl('/v1/me/external_accounts'), () =>
+ HttpResponse.json({
+ response: fapiExternalAccount({
+ id: 'idn_github',
+ provider: 'github',
+ verification: fapiVerification('oauth_github', { status: 'unverified' }),
+ }),
+ client: null,
+ }),
+ ),
+ );
+ await renderWithClerk();
+
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ expect(await screen.findByText('OAuth flow did not receive a verification URL.')).toBeInTheDocument();
+ });
+
+ it('preserves modal state when connecting', 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 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',
+ });
+ request.fail();
+ });
+
+ it('opens the OAuth transport and reloads the user with the callback nonce', async () => {
+ serveFapi(signedIn([]));
+ const open = vi.fn(() => Promise.resolve({ callbackUrl: 'myapp://sso-callback?rotating_token_nonce=abc' }));
+ await renderWithClerk(, {
+ __internal_oauthTransport: { getRedirectUrl: () => 'myapp://sso-callback', open },
+ });
+ const request = holdRequests('get', '/v1/me');
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(open).toHaveBeenCalledWith(new URL('https://accounts.example/authorize')));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ expect(new URL(request.requests[0]?.url || '').searchParams.get('rotating_token_nonce')).toBe('abc');
+ request.release();
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
+ );
+ });
+
+ it('recreates a disconnected account', async () => {
+ serveFapi(signedIn([disconnectedGoogle]));
+ const { clerk } = await renderWithClerk();
+ const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ expect(screen.getByText('Disconnected')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ expect(body.get('strategy')).toBe('oauth_google');
+ request.release();
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize'));
+ });
+
+ it('reports an unavailable recovery when the account changes during redirect preparation', async () => {
+ serveFapi(signedIn([disconnectedGoogle]));
+ const redirect = deferred();
+ const open = vi.fn(() => Promise.resolve({ callbackUrl: 'https://app.example/callback' }));
+ const { clerk } = await renderWithClerk(, {
+ __internal_oauthTransport: { getRedirectUrl: () => redirect.promise, open },
+ });
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ const account = clerk.user?.externalAccounts[0];
+ if (!account) {
+ throw new Error('Expected Google account');
+ }
+ account.verification = null;
+ await act(async () => {
+ redirect.resolve('https://app.example/callback');
+ await redirect.promise;
+ });
+ expect(await screen.findByText('This connected account is no longer available.')).toBeInTheDocument();
+ expect(open).not.toHaveBeenCalled();
+ });
+
+ it('shows a verification-required Reconnect error and permits another attempt', async () => {
+ const { clerk } = await renderSection([disconnectedGoogle]);
+ const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.fail('session_reverification_required', 'Verify your session.');
+ expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled();
+ serveFapi(signedIn([disconnectedGoogle]));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize'));
+ });
+
+ it('preserves modal state when reconnecting', async () => {
+ serveFapi(signedIn([disconnectedGoogle]));
+ await renderWithClerk();
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ 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' });
+ request.fail();
+ });
+
+ it('reauthorizes a connected account when it lacks requested scopes', async () => {
+ serveFapi(signedIn([google]));
+ const { clerk } = await renderWithClerk(
+ ,
+ );
+ const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
+ const request = holdRequests('post', '/v1/me/external_accounts/idn_google/reauthorize');
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ expect(new URL(request.requests[0]?.url || '').searchParams.get('_method')).toBe('PATCH');
+ expect(body.getAll('additional_scope')).toEqual(['email', 'calendar']);
+ request.release();
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/consent'));
+ });
+
+ it('shows a failed reauthorization and allows another attempt', async () => {
+ serveFapi(signedIn([google]));
+ const { clerk } = await renderWithClerk(
+ ,
+ );
+ const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
+ const request = holdRequests('post', '/v1/me/external_accounts/idn_google/reauthorize');
+ const user = userEvent.setup();
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.fail('oauth_error', 'Calendar access was denied.');
+ expect(await screen.findByText('Calendar access was denied.')).toBeInTheDocument();
+
+ serveFapi(signedIn([google]));
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/consent'));
+ });
+
+ it('removes the selected account and closes the confirmation', async () => {
+ const { fapi } = await renderSection([google, github]);
+ const request = holdRequests('post', '/v1/me/external_accounts/idn_github');
+ const user = userEvent.setup();
+ const dialog = await openRemoval(user, 'GitHub');
+
+ expect(dialog).toHaveAccessibleName('Remove connected account');
+ expect(screen.queryByRole('textbox')).toBeNull();
+ expect(request.requests).toHaveLength(0);
+ expect(fapi.client.sessions[0]?.user.external_accounts).toHaveLength(2);
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.release();
+
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.queryByRole('button', { name: 'Manage GitHub' })).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage Google' })).toBeVisible();
+ expect(fapi.client.sessions[0]?.user.external_accounts.map(account => account.id)).toEqual(['idn_google']);
+ });
+
+ it('returns focus to the account menu when removal is canceled', async () => {
+ await renderSection();
+ const user = userEvent.setup();
+ const trigger = screen.getByRole('button', { name: 'Manage Google' });
+
+ trigger.focus();
+ await user.keyboard('{Enter}');
+ await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove' })).toHaveFocus());
+ await user.keyboard('{Enter}');
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(trigger).toHaveFocus());
+ });
+
+ it('keeps removal pending until the request completes', async () => {
+ const { fapi } = await renderSection([google, github]);
+ const request = holdRequests('post', '/v1/me/external_accounts/idn_github');
+ const user = userEvent.setup();
+ const dialog = await openRemoval(user, 'GitHub');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ expect(within(dialog).getByRole('button', { name: 'Remove' })).toHaveAttribute('aria-busy', 'true');
+ expect(fapi.client.sessions[0]?.user.external_accounts).toHaveLength(2);
+
+ request.release();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(fapi.client.sessions[0]?.user.external_accounts).toHaveLength(1);
+ });
+
+ it('keeps the confirmation open on a removal error and allows retrying', async () => {
+ await renderSection([google]);
+ const request = holdRequests('post', '/v1/me/external_accounts/idn_google');
+ const user = userEvent.setup();
+ const dialog = await openRemoval(user, 'Google');
+
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.fail('last_identification', 'You cannot remove your last sign-in method.');
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
+
+ serveFapi(signedIn([google]));
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ });
+
+ it('keeps the confirmation open when removal needs reverification', async () => {
+ await renderSection([google]);
+ const request = holdRequests('post', '/v1/me/external_accounts/idn_google');
+ const dialog = await openRemoval(userEvent.setup(), 'Google');
+
+ await userEvent.setup().click(within(dialog).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ request.fail('session_reverification_required', 'Verify your session.');
+ expect(await within(dialog).findByText('Verify your session.')).toBeInTheDocument();
+ expect(dialog).toHaveAccessibleName('Remove connected account');
+ expect(screen.queryByRole('dialog')).toBeNull();
+ expect(within(dialog).queryByRole('textbox')).toBeNull();
+ expect(within(dialog).queryByLabelText('Password')).toBeNull();
+ expect(within(dialog).getByRole('button', { name: 'Remove' })).not.toHaveAttribute('aria-busy', 'true');
+ });
+
+ it('targets the account selected after canceling another removal', async () => {
+ const { fapi } = await renderSection([google, github]);
+ const user = userEvent.setup();
+
+ const first = await openRemoval(user, 'Google');
+ expect(first).toHaveAccessibleDescription(/Google will be removed/);
+ await user.click(within(first).getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+
+ const second = await openRemoval(user, 'GitHub');
+ expect(second).toHaveAccessibleDescription(/GitHub will be removed/);
+ await user.click(within(second).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(fapi.client.sessions[0]?.user.external_accounts.map(account => account.id)).toEqual(['idn_google']);
+ });
+
+ it('focuses the next account and then Connect as accounts are removed', async () => {
+ await renderSection([google, github]);
+ const user = userEvent.setup();
+
+ const first = await openRemoval(user, 'Google');
+ await user.click(within(first).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage GitHub' })).toHaveFocus());
+
+ const second = await openRemoval(user, 'GitHub');
+ await user.click(within(second).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Connect GitHub' })).toHaveFocus());
+ });
+
+ it.each(['switch', 'sign out'] as const)('aborts Connect after %s during redirect preparation', async change => {
+ const fapi = serveFapi(
+ signedIn([], {
+ client: fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: [] }) }),
+ fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2', external_accounts: [] }) }),
+ ]),
+ }),
+ );
+ const redirect = deferred();
+ const getRedirectUrl = vi.fn(() => redirect.promise);
+ const open = vi.fn(() => Promise.resolve({ callbackUrl: 'https://app.example/callback' }));
+ const { clerk } = await renderWithClerk(, {
+ __internal_oauthTransport: { getRedirectUrl, open },
+ });
+ const original = clerk.user;
+ if (!original) {
+ throw new Error('Expected signed-in user');
+ }
+ const create = vi.spyOn(original, 'createExternalAccount');
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ expect(getRedirectUrl).toHaveBeenCalledOnce();
+ await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut()));
+ expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined);
+ await act(async () => {
+ redirect.resolve('https://app.example/callback');
+ await redirect.promise;
+ });
+ expect(create).not.toHaveBeenCalled();
+ expect(open).not.toHaveBeenCalled();
+ if (change === 'switch') {
+ expect(fapi.client.sessions[1]?.user.external_accounts).toEqual([]);
+ }
+ });
+
+ it('restores the profile title after removing the final enterprise-restricted account', async () => {
+ const environment = signedIn().environment;
+ const { titleRef } = await renderSection([google], {
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_1',
+ user: fapiUser({
+ id: 'user_1',
+ external_accounts: [google],
+ enterprise_accounts: [fapiEnterpriseAccount({ id: 'sso_1' })],
+ }),
+ }),
+ ]),
+ environment: {
+ ...environment,
+ user_settings: {
+ ...environment.user_settings,
+ enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false },
+ },
+ },
+ });
+ const user = userEvent.setup();
+ const dialog = await openRemoval(user, 'Google');
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(screen.queryByRole('group', { name: 'Connected accounts' })).toBeNull();
+ await waitFor(() => expect(titleRef.current).toHaveFocus());
+ });
+
+ it('persists pending Connect and replaces a superseded provider attempt', async () => {
+ const fapi = serveFapi(
+ signedIn([
+ google,
+ fapiExternalAccount({
+ id: 'idn_pending',
+ provider: 'github',
+ approved_scopes: '',
+ verification: fapiVerification('oauth_github', { status: 'unverified' }),
+ }),
+ ]),
+ );
+ const callback = deferred<{ callbackUrl: string }>();
+ const open = vi.fn(() => callback.promise);
+ const { clerk } = await renderWithClerk(, {
+ __internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
+ });
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(open).toHaveBeenCalledOnce());
+ callback.resolve({ callbackUrl: 'https://app.example/callback' });
+ await callback.promise;
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
+ );
+ const accounts = fapi.client.sessions[0]?.user.external_accounts;
+ expect(accounts).toHaveLength(2);
+ expect(accounts?.find(account => account.provider === 'github')).toMatchObject({
+ approved_scopes: '',
+ verification: { status: 'unverified' },
+ });
+ expect(accounts?.map(account => account.id)).not.toContain('idn_pending');
+ expect(clerk.user?.externalAccounts.find(account => account.provider === 'github')?.id).not.toBe('idn_pending');
+ expect(screen.getByRole('button', { name: 'Manage Google' })).toBeVisible();
+ });
+
+ it('persists reauthorization without granting scopes before callback completion', async () => {
+ const fapi = serveFapi(signedIn([google, github]));
+ const callback = deferred<{ callbackUrl: string }>();
+ const open = vi.fn(() => callback.promise);
+ const { clerk } = await renderWithClerk(
+ ,
+ {
+ __internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
+ },
+ );
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ await waitFor(() => expect(open).toHaveBeenCalledOnce());
+ const account = fapi.client.sessions[0]?.user.external_accounts.find(account => account.id === google.id);
+ expect(account).toMatchObject({ approved_scopes: google.approved_scopes, verification: { status: 'unverified' } });
+ expect(fapi.client.sessions[0]?.user.external_accounts.find(account => account.id === github.id)).toEqual(github);
+
+ fapi.client.sessions = fapi.client.sessions.map(session => ({
+ ...session,
+ user: {
+ ...session.user,
+ external_accounts: session.user.external_accounts.map(item =>
+ item.id === google.id
+ ? {
+ ...item,
+ approved_scopes: 'email calendar',
+ verification: fapiVerification('oauth_google', { status: 'verified' }),
+ }
+ : item,
+ ),
+ },
+ }));
+ await act(async () => {
+ callback.resolve({ callbackUrl: 'https://app.example/callback' });
+ await callback.promise;
+ });
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Google' })).toBeInTheDocument());
+ expect(clerk.user?.externalAccounts.find(item => item.id === google.id)?.approvedScopes).toBe('email calendar');
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ expect(screen.queryByRole('menuitem', { name: 'Reconnect' })).toBeNull();
+ expect(screen.getByRole('menuitem', { name: 'Remove' })).toBeEnabled();
+ });
+
+ it.each(['switch', 'sign out'] as const)('aborts Reconnect after %s during redirect preparation', async change => {
+ const fapi = serveFapi(
+ signedIn([google], {
+ client: fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: [google] }) }),
+ fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2', external_accounts: [github] }) }),
+ ]),
+ }),
+ );
+ const redirect = deferred();
+ const getRedirectUrl = vi.fn(() => redirect.promise);
+ const open = vi.fn(() => Promise.resolve({ callbackUrl: 'https://app.example/callback' }));
+ const { clerk } = await renderWithClerk(
+ ,
+ {
+ __internal_oauthTransport: { getRedirectUrl, open },
+ },
+ );
+ const original = clerk.user?.externalAccounts[0];
+ if (!original) {
+ throw new Error('Expected Google account');
+ }
+ const reauthorize = vi.spyOn(original, 'reauthorize');
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ expect(getRedirectUrl).toHaveBeenCalledOnce();
+ await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut()));
+ expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined);
+ await act(async () => {
+ redirect.resolve('https://app.example/callback');
+ await redirect.promise;
+ });
+ expect(reauthorize).not.toHaveBeenCalled();
+ expect(open).not.toHaveBeenCalled();
+ if (change === 'switch') {
+ expect(fapi.client.sessions[1]?.user.external_accounts).toEqual([github]);
+ }
+ });
+
+ it('does not open an OAuth response after the active user changes', async () => {
+ serveFapi(
+ signedIn([], {
+ client: fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: [] }) }),
+ fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2', external_accounts: [] }) }),
+ ]),
+ }),
+ );
+ const response = deferred();
+ let received = false;
+ worker.use(
+ http.post(fapiUrl('/v1/me/external_accounts'), () => {
+ received = true;
+ return response.promise;
+ }),
+ );
+ const open = vi.fn(() => Promise.resolve({ callbackUrl: 'https://app.example/callback' }));
+ const { clerk } = await renderWithClerk(, {
+ __internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
+ });
+ const original = clerk.user;
+ if (!original) {
+ throw new Error('Expected original user');
+ }
+ const create = vi.spyOn(original, 'createExternalAccount');
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(received).toBe(true));
+ await act(() => clerk.setActive({ session: 'sess_2' }));
+ const pending = fapiExternalAccount({
+ id: 'idn_response',
+ provider: 'github',
+ verification: fapiVerification('oauth_github', {
+ status: 'unverified',
+ external_verification_redirect_url: 'https://accounts.example/authorize',
+ }),
+ });
+ await act(async () => {
+ response.resolve(HttpResponse.json({ response: pending, client: null }));
+ await create.mock.results[0]?.value;
+ });
+
+ expect(open).not.toHaveBeenCalled();
+ expect(clerk.user?.id).toBe('user_2');
+ });
+
+ it('does not reload after the user changes while the popup is open', async () => {
+ serveFapi(
+ signedIn([], {
+ client: fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: [] }) }),
+ fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2', external_accounts: [] }) }),
+ ]),
+ }),
+ );
+ const callback = deferred<{ callbackUrl: string }>();
+ const open = vi.fn(() => callback.promise);
+ const { clerk } = await renderWithClerk(, {
+ __internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
+ });
+ const original = clerk.user;
+ if (!original) {
+ throw new Error('Expected original user');
+ }
+ const reload = vi.spyOn(original, 'reload');
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(open).toHaveBeenCalledOnce());
+ await act(() => clerk.setActive({ session: 'sess_2' }));
+ const current = clerk.user;
+ if (!current) {
+ throw new Error('Expected current user');
+ }
+ const currentReload = vi.spyOn(current, 'reload');
+ await act(async () => {
+ callback.resolve({ callbackUrl: 'https://app.example/callback' });
+ await callback.promise;
+ });
+ expect(reload).not.toHaveBeenCalled();
+ expect(currentReload).not.toHaveBeenCalled();
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).toBeEnabled();
+ });
+
+ it('shows the connected account after explicit provider callback completion', async () => {
+ const fapi = serveFapi(signedIn([]));
+ const callback = deferred<{ callbackUrl: string }>();
+ const open = vi.fn(() => callback.promise);
+ const { clerk } = await renderWithClerk(
+ ,
+ {
+ __internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
+ },
+ );
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(open).toHaveBeenCalledOnce());
+ fapi.client.sessions = fapi.client.sessions.map(session => ({
+ ...session,
+ user: {
+ ...session.user,
+ external_accounts: session.user.external_accounts.map(account => ({
+ ...account,
+ username: 'octocat',
+ approved_scopes: 'email repo',
+ verification: fapiVerification('oauth_github', { status: 'verified' }),
+ })),
+ },
+ }));
+ await act(async () => {
+ callback.resolve({ callbackUrl: 'https://app.example/callback' });
+ await callback.promise;
+ });
+ expect(await screen.findByRole('button', { name: 'Manage GitHub' })).toBeVisible();
+ expect(screen.getByText('octocat')).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Connect GitHub' })).toBeNull();
+ expect(clerk.user?.externalAccounts[0]?.approvedScopes).toBe('email repo');
+ });
+
+ it('releases the redirect hold and permits another connection', async () => {
+ const { clerk } = await renderSection([]);
+ const navigate = vi.spyOn(clerk, 'navigate').mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(navigate).toHaveBeenCalledOnce());
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).toHaveAttribute('aria-busy', 'true');
+ await waitFor(
+ () => expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
+ { timeout: 2500 },
+ );
+ await user.click(screen.getByRole('button', { name: 'Connect Google' }));
+ await waitFor(() => expect(navigate).toHaveBeenCalledTimes(2));
+ });
+
+ it('keeps pending and error state local to each mounted section', async () => {
+ serveFapi(signedIn([]));
+ await renderWithClerk(
+ <>
+
+
+
+
+
+
+ >,
+ );
+ const first = within(screen.getByRole('region', { name: 'First' }));
+ const second = within(screen.getByRole('region', { name: 'Second' }));
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ await userEvent.setup().click(first.getByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+ expect(second.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true');
+ request.fail('oauth_error', 'Provider unavailable');
+ expect(await first.findByText('Provider unavailable')).toBeInTheDocument();
+ expect(second.queryByText('Provider unavailable')).toBeNull();
+ });
+
+ it.each(['preparation', 'popup'] as const)('localizes an unknown %s failure', async stage => {
+ serveFapi(signedIn([]));
+ await renderWithClerk(
+
+
+ ,
+ {
+ __internal_oauthTransport: {
+ getRedirectUrl: () =>
+ stage === 'preparation' ? Promise.reject(new Error('private failure')) : 'https://app.example/callback',
+ open: () => Promise.reject(new Error('private failure')),
+ },
+ },
+ );
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
+ expect(await screen.findByText('Try the connection again.')).toBeInTheDocument();
+ expect(screen.queryByText('private failure')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true');
+ });
+
+ it('uses canonical localized API errors instead of server copy', 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));
+ request.fail('verification_invalid_strategy', 'Server copy');
+ expect(await screen.findByText('Cette connexion est indisponible.')).toBeInTheDocument();
+ expect(screen.queryByText('Server copy')).toBeNull();
+ });
+
+ it.each(['switch', 'sign out'] as const)('closes a stale removal confirmation after %s', async change => {
+ const fapi = serveFapi(
+ signedIn([google], {
+ client: fapiClient([
+ fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: [google] }) }),
+ fapiSession({ id: 'sess_2', user: fapiUser({ id: 'user_2', external_accounts: [github] }) }),
+ ]),
+ }),
+ );
+ const { clerk } = await renderWithClerk();
+ const original = clerk.user?.externalAccounts[0];
+ if (!original) {
+ throw new Error('Expected original account');
+ }
+ const destroy = vi.spyOn(original, 'destroy');
+ await openRemoval(userEvent.setup(), 'Google');
+ await act(() => (change === 'switch' ? clerk.setActive({ session: 'sess_2' }) : clerk.signOut()));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
+ expect(clerk.user?.id).toBe(change === 'switch' ? 'user_2' : undefined);
+ expect(destroy).not.toHaveBeenCalled();
+ if (change === 'switch') {
+ expect(fapi.client.sessions[0]?.user.external_accounts).toEqual([google]);
+ expect(fapi.client.sessions[1]?.user.external_accounts).toEqual([github]);
+ expect(screen.getByRole('button', { name: 'Manage GitHub' })).toBeInTheDocument();
+ } else {
+ expect(screen.queryByRole('group', { name: 'Connected accounts' })).toBeNull();
+ }
+ });
+
+ it.todo('challenges and resumes connect, reconnect, and removal when session reverification is required');
+ it.todo('keeps the provider visibly pending when Try again replaces its failed account with a Connect row');
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx
index c3e72dd7254..fd643716d92 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx
@@ -1,6 +1,7 @@
import { createDeferredPromise } from '@clerk/shared/utils';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
@@ -8,6 +9,7 @@ import { MosaicProvider } from '../../../mosaic-provider';
import { UserProfileDeleteSectionView } from '../user-profile-delete-section/user-profile-delete-section.view';
import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view';
import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view';
+import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view';
function DeleteAccount() {
const controller = useDestructiveController({ onDelete: () => Promise.resolve() });
@@ -37,58 +39,22 @@ function renderView(overrides: Partial = {}) {
}
describe('UserProfileProfilePanelView', () => {
- it('hides connected accounts when only providers without a connect callback are supplied', () => {
- renderView({
- connectedAccounts: [],
- availableConnectionProviders: [{ id: 'google', provider: 'Google' }],
- });
- expect(screen.queryByRole('region', { name: 'Connected accounts' })).not.toBeInTheDocument();
- });
-
- it('keeps the final account confirmation mounted until removal settles', async () => {
- const user = userEvent.setup();
- const removal = createDeferredPromise();
- const onRemoveConnectedAccount = vi.fn(async () => {
- await removal.promise;
- });
- const { rerender } = renderView({
- connectedAccounts: [{ id: 'github', provider: 'GitHub' }],
- onRemoveConnectedAccount,
- });
- await user.click(screen.getByRole('button', { name: 'Manage GitHub' }));
- await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
- await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
-
- rerender(
-
-
- ,
- );
- expect(screen.queryByRole('heading', { name: 'Connected accounts' })).not.toBeInTheDocument();
- expect(screen.getByRole('alertdialog', { name: 'Remove connected account' })).toBeInTheDocument();
-
- await act(async () => {
- removal.resolve();
- await removal.promise;
- });
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- expect(screen.getByRole('heading', { name: 'Account', level: 2 })).toBeVisible();
- await waitFor(() => expect(document.activeElement).toHaveTextContent(/^Account$/));
- });
-
it('keeps the final wallet confirmation mounted until removal settles', async () => {
const user = userEvent.setup();
+ const titleRef = createRef();
const removal = createDeferredPromise();
const onRemoveWeb3Wallet = vi.fn(async () => {
await removal.promise;
});
const { rerender } = renderView({
- web3Wallets: [{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }],
- onRemoveWeb3Wallet,
+ titleRef,
+ web3WalletsSlot: (
+ titleRef.current}
+ onRemove={onRemoveWeb3Wallet}
+ />
+ ),
});
await user.click(screen.getByRole('button', { name: 'Manage MetaMask' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove wallet' }));
@@ -97,8 +63,14 @@ describe('UserProfileProfilePanelView', () => {
titleRef.current}
+ onRemove={onRemoveWeb3Wallet}
+ />
+ }
/>
,
);
@@ -112,16 +84,6 @@ describe('UserProfileProfilePanelView', () => {
await waitFor(() => expect(document.activeElement).toHaveTextContent(/^Account$/));
});
- it('keeps available providers visible without connected accounts', () => {
- const onConnectAccount = vi.fn();
- renderView({
- connectedAccounts: [],
- availableConnectionProviders: [{ id: 'google', provider: 'Google' }],
- onConnectAccount,
- });
- expect(screen.getByRole('button', { name: 'Connect Google' })).toBeVisible();
- });
-
it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => {
renderView({
allowMultipleAccounts,
@@ -292,16 +254,11 @@ describe('UserProfileProfilePanelView', () => {
expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument();
});
- it('renders connected accounts and the danger zone when provided', () => {
+ it('renders the danger zone when provided', () => {
renderView({
- connectedAccounts: [
- { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' },
- ],
deleteAccountSlot: ,
});
- expect(screen.getByRole('heading', { level: 3, name: 'Connected accounts' })).toBeInTheDocument();
- expect(screen.getByText('Google')).toBeVisible();
expect(screen.getByRole('heading', { level: 3, name: 'Danger zone' })).toBeInTheDocument();
expect(screen.getByText('Delete account', { selector: '.cl-section-label' })).toBeInTheDocument();
expect(screen.getByText('Permanently delete this account and all its data. This cannot be undone.')).toHaveClass(
@@ -309,45 +266,51 @@ describe('UserProfileProfilePanelView', () => {
);
});
- it('renders connected provider and Web3 images inside icon frames', () => {
+ it('renders Web3 images inside icon frames', () => {
const { container } = renderView({
- connectedAccounts: [{ id: 'google', provider: 'Google', iconUrl: '/google.svg' }],
- web3Wallets: [
- { id: 'metamask', provider: 'MetaMask', address: 'test', isVerified: true, iconUrl: '/metamask.svg' },
- ],
+ web3WalletsSlot: (
+
+ ),
});
const frames = container.querySelectorAll('.cl-icon-frame');
const images = container.querySelectorAll('img');
- expect(frames).toHaveLength(2);
- expect(screen.queryByRole('img', { name: 'Google' })).not.toBeInTheDocument();
+ expect(frames).toHaveLength(1);
+ expect(screen.queryByRole('img', { name: 'MetaMask' })).not.toBeInTheDocument();
expect(frames[0]).toContainElement(images[0]);
- expect(frames[1]).toContainElement(images[1]);
frames.forEach(frame => expect(frame.closest('.cl-section-media')).toHaveAttribute('data-size', 'lg'));
});
it('composes linked wallets and available providers', () => {
renderView({
- web3Wallets: [
- {
- id: 'primary',
- address: '0x1234567890abcdef1234567890abcdef12345678',
- provider: 'MetaMask',
- iconUrl: 'https://example.com/metamask.svg',
- isPrimary: true,
- isVerified: true,
- },
- {
- id: 'secondary',
- address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
- provider: 'Coinbase Wallet',
- isVerified: true,
- },
- ],
- availableWeb3Providers: [{ id: 'disconnected', provider: 'Coinbase Wallet' }],
- onConnectWeb3Wallet: vi.fn(),
- onSetPrimaryWeb3Wallet: vi.fn(),
- onRemoveWeb3Wallet: vi.fn(),
+ web3WalletsSlot: (
+
+ ),
});
expect(screen.getByRole('heading', { level: 3, name: 'Web3 wallets' })).toBeInTheDocument();
@@ -425,14 +388,13 @@ describe('UserProfileProfilePanelView', () => {
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Edit username' })).not.toBeInTheDocument());
});
- it('matches the existing conditional contact and connected-account actions', async () => {
+ it('matches the existing conditional contact actions', async () => {
const onVerifyEmail = vi.fn();
const onSetPrimaryEmail = vi.fn();
const onRemoveEmail = vi.fn();
const onVerifyPhone = vi.fn();
const onSetPrimaryPhone = vi.fn();
const onRemovePhone = vi.fn();
- const onRemoveConnectedAccount = vi.fn();
const user = userEvent.setup();
renderView({
@@ -445,14 +407,12 @@ describe('UserProfileProfilePanelView', () => {
{ id: 'phone_unverified', value: '+1 801-555-0100', isVerified: false },
{ id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true },
],
- connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz' }],
onVerifyEmail,
onSetPrimaryEmail,
onRemoveEmail,
onVerifyPhone,
onSetPrimaryPhone,
onRemovePhone,
- onRemoveConnectedAccount,
});
await user.click(screen.getByRole('button', { name: 'Manage primary@clerk.dev' }));
@@ -496,18 +456,6 @@ describe('UserProfileProfilePanelView', () => {
await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0101' }));
await user.click(screen.getByRole('menuitem', { name: 'Set as primary' }));
expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary');
-
- await user.click(screen.getByRole('button', { name: 'Manage GitHub' }));
- const removeConnectedAccount = screen.getByRole('menuitem', { name: 'Remove' });
- expect(removeConnectedAccount).toHaveAttribute('data-color', 'negative');
- await user.click(removeConnectedAccount);
- expect(onRemoveConnectedAccount).not.toHaveBeenCalled();
- await user.click(
- within(screen.getByRole('alertdialog', { name: 'Remove connected account' })).getByRole('button', {
- name: 'Remove',
- }),
- );
- expect(onRemoveConnectedAccount).toHaveBeenCalledWith('github');
});
it('hides action triggers when immutable items have no available actions', () => {
@@ -522,14 +470,11 @@ describe('UserProfileProfilePanelView', () => {
},
],
phones: [],
- connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }],
onVerifyEmail: vi.fn(),
onSetPrimaryEmail: vi.fn(),
onRemoveEmail: vi.fn(),
- onRemoveConnectedAccount: vi.fn(),
});
expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument();
});
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-account-row.view.tsx
deleted file mode 100644
index a88437e4339..00000000000
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-account-row.view.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import * as stylex from '@stylexjs/stylex';
-import type { Ref } from 'react';
-
-import type { ActionMenuAction } from '../../components/action-menu';
-import { ActionMenu } from '../../components/action-menu';
-import { Badge } from '../../components/badge';
-import { Button } from '../../components/button';
-import { Icon, IconFrame } from '../../components/icon';
-import { Section } from '../../components/section';
-import { fill, useMessages } from '../../localization';
-import { styles } from './user-profile-connected-accounts.styles';
-import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view';
-
-export function UserProfileConnectedAccountRowView({
- account,
- triggerRef,
- onConnect,
- onReconnect,
- onRemove,
-}: {
- account: UserProfileConnectedAccount;
- triggerRef?: Ref;
- onConnect?: (id: string) => void;
- onReconnect?: (id: string) => void;
- onRemove?: (account: UserProfileConnectedAccount) => void;
-}) {
- const m = useMessages('userProfileConnectedAccounts');
- const iconUrl = account.iconUrl?.trim();
- const actions: ActionMenuAction[] = [];
- if (account.status === 'reconnect' && onReconnect) {
- actions.push({ label: m.reconnect, onClick: () => onReconnect(account.id) });
- }
- if (onRemove && account.canRemove !== false) {
- actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(account) });
- }
- return (
-
-
-
-
- {iconUrl ? (
-
- ) : (
-
- {account.provider.trim().charAt(0).toUpperCase()}
-
- )}
-
-
-
-
- {account.provider}
- {account.status === 'reconnect' ? {m.disconnected} : null}
-
- {account.identifier ? (
-
- {account.identifier}
-
- ) : null}
-
- {onConnect ? (
-
-
-
- ) : actions.length > 0 ? (
-
-
-
- ) : null}
-
- {account.connectError}
- {account.reconnectError}
- {account.status === 'error' ? account.verificationError : undefined}
-
- );
-}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx
new file mode 100644
index 00000000000..e5dfb8bcd30
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx
@@ -0,0 +1,150 @@
+import * as stylex from '@stylexjs/stylex';
+import type { Ref } from 'react';
+
+import type { ActionMenuAction } from '../../../components/action-menu';
+import { ActionMenu } from '../../../components/action-menu';
+import { Badge } from '../../../components/badge';
+import { SubmitButton } from '../../../components/button';
+import { Icon, IconFrame } from '../../../components/icon';
+import { Section } from '../../../components/section';
+import { fill, useMessages } from '../../../localization';
+import { styles } from './user-profile-connected-accounts-section.styles';
+import type {
+ ConnectedAccountProviderDisplay,
+ UserProfileConnectedAccount,
+ UserProfileConnectionProvider,
+} from './user-profile-connected-accounts-section.types';
+
+function ProviderMedia({ provider }: { provider: ConnectedAccountProviderDisplay }) {
+ const iconUrl = provider.iconUrl?.trim();
+ return (
+
+
+ {iconUrl && provider.monochromeIcon ? (
+
+ ) : iconUrl ? (
+
+ ) : (
+
+ {provider.provider.trim().charAt(0).toUpperCase()}
+
+ )}
+
+
+ );
+}
+
+export function UserProfileConnectProviderRowView({
+ provider,
+ isPending,
+ isDisabled,
+ onConnect,
+}: {
+ provider: UserProfileConnectionProvider;
+ isPending: boolean;
+ isDisabled: boolean;
+ onConnect: (id: string) => void;
+}) {
+ const m = useMessages('userProfileConnectedAccounts');
+ return (
+
+
+
+
+
+ {provider.provider}
+
+
+
+ onConnect(provider.id)}
+ >
+ {m.connect}
+
+
+
+
+ {provider.connectError}
+
+ );
+}
+
+export function UserProfileConnectedAccountRowView({
+ account,
+ triggerRef,
+ isDisabled = false,
+ onReconnect,
+ onRemove,
+}: {
+ account: UserProfileConnectedAccount;
+ triggerRef?: Ref;
+ isDisabled?: boolean;
+ onReconnect?: (id: string) => void;
+ onRemove?: (account: UserProfileConnectedAccount) => void;
+}) {
+ const m = useMessages('userProfileConnectedAccounts');
+ const actions: ActionMenuAction[] = [];
+ if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) {
+ actions.push({
+ label: account.status === 'reconnect' ? m.reconnect : m.tryAgain,
+ onClick: () => onReconnect(account.id),
+ });
+ }
+ if (onRemove) {
+ actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(account) });
+ }
+ return (
+
+
+
+
+
+ {account.provider}
+ {account.status === 'reconnect' ? {m.disconnected} : null}
+
+ {account.identifier ? (
+
+ {account.identifier}
+
+ ) : null}
+
+ {actions.length > 0 ? (
+
+
+
+ ) : null}
+
+ {account.reconnectError}
+ {account.status === 'error' ? account.verificationError : undefined}
+
+ );
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts
new file mode 100644
index 00000000000..199fd568410
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts
@@ -0,0 +1,68 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import { describe, expect, it } from 'vitest';
+
+import { resolveLocalization } from '../../../localization';
+import { connectedAccountFeedback } from './user-profile-connected-accounts-feedback';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
+
+const errorText = ({ message }: { message?: string }) => message ?? 'Fallback';
+const messages = resolveLocalization({ locale: 'en' }).messages.userProfileConnectedAccounts;
+
+function apiError(data: ConstructorParameters[1]['data']) {
+ return new ClerkAPIResponseError('Invalid', { status: 422, data });
+}
+
+describe('connected account error messages', () => {
+ it('retains the internal action code and cause in localized feedback', () => {
+ const cause = new ConnectedAccountActionError('unavailable');
+ const feedback = connectedAccountFeedback(cause, messages, errorText);
+ expect(feedback).toBeInstanceOf(ConnectedAccountActionError);
+ expect(feedback.cause).toBe(cause);
+ expect(feedback).toMatchObject({ code: 'unavailable', cause, message: messages.errors.unavailable });
+ });
+
+ it('localizes errors raised by the model', () => {
+ expect(connectedAccountFeedback(new ConnectedAccountActionError('unavailable'), messages, errorText).message).toBe(
+ messages.errors.unavailable,
+ );
+ expect(
+ connectedAccountFeedback(new ConnectedAccountActionError('missing_verification_url'), messages, errorText)
+ .message,
+ ).toBe(messages.errors.missingVerificationUrl);
+ });
+
+ it('shows the first API error, preferring its long message', () => {
+ expect(
+ connectedAccountFeedback(
+ apiError([
+ { code: 'oauth_error', message: 'Short', long_message: 'GitHub is unavailable right now.' },
+ { code: 'other', message: 'Second' },
+ ]),
+ messages,
+ errorText,
+ ).message,
+ ).toBe('GitHub is unavailable right now.');
+ expect(
+ connectedAccountFeedback(apiError([{ code: 'oauth_error', message: 'Short' }]), messages, errorText).message,
+ ).toBe('Short');
+ });
+
+ it('passes the API code, parameter, and fallback copy to the canonical formatter', () => {
+ expect(
+ connectedAccountFeedback(
+ apiError([
+ { code: 'form_param_invalid', message: 'Short', long_message: 'Long', meta: { param_name: 'strategy' } },
+ ]),
+ messages,
+ ({ code, paramName, message }) => `${code}:${paramName}:${message}`,
+ ).message,
+ ).toBe('form_param_invalid:strategy:Long');
+ });
+
+ it('falls back to the generic message', () => {
+ expect(connectedAccountFeedback(apiError([]), messages, errorText).message).toBe(messages.errors.generic);
+ expect(connectedAccountFeedback(new Error('Popup closed'), messages, errorText).message).toBe(
+ messages.errors.generic,
+ );
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts
new file mode 100644
index 00000000000..ce3e9a3897c
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts
@@ -0,0 +1,30 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+
+import type { LocalizableError, MosaicMessages } from '../../../localization';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
+
+type Messages = MosaicMessages['userProfileConnectedAccounts'];
+
+export function connectedAccountFeedback(
+ error: unknown,
+ messages: Messages,
+ errorText: (error: LocalizableError) => string,
+): Error {
+ if (error instanceof ConnectedAccountActionError) {
+ const message =
+ error.code === 'missing_verification_url' ? messages.errors.missingVerificationUrl : messages.errors.unavailable;
+ return new ConnectedAccountActionError(error.code, message, { cause: error });
+ }
+ let message: string = messages.errors.generic;
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ if (first) {
+ message = errorText({
+ code: first.code,
+ paramName: first.meta?.paramName,
+ message: first.longMessage || first.message,
+ });
+ }
+ }
+ return new Error(message, { cause: error });
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts
new file mode 100644
index 00000000000..588b29e4b4a
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts
@@ -0,0 +1,69 @@
+import { useRef, useState } from 'react';
+
+import { useMessages } from '../../../localization';
+import type {
+ ConnectedAccountActionResult,
+ UserProfileConnectedAccount,
+ UserProfileConnectionProvider,
+} from './user-profile-connected-accounts-section.types';
+
+export interface UserProfileConnectedAccountsControllerOptions {
+ accounts: UserProfileConnectedAccount[];
+ availableProviders: UserProfileConnectionProvider[];
+ onConnect: (id: string) => Promise;
+ onReconnect: (id: string) => Promise;
+}
+
+export interface UserProfileConnectedAccountsController {
+ accounts: UserProfileConnectedAccount[];
+ availableProviders: UserProfileConnectionProvider[];
+ pendingId: string | undefined;
+ onConnect: (id: string) => void;
+ onReconnect: (id: string) => void;
+}
+
+const OAUTH_REDIRECT_HOLD_MS = 2000;
+
+export function useUserProfileConnectedAccountsController({
+ accounts,
+ availableProviders,
+ onConnect,
+ onReconnect,
+}: UserProfileConnectedAccountsControllerOptions): UserProfileConnectedAccountsController {
+ const messages = useMessages('userProfileConnectedAccounts');
+ const [pendingId, setPendingId] = useState();
+ const [errors, setErrors] = useState>({});
+ const connecting = useRef(false);
+
+ const run = async (id: string, action: (id: string) => Promise) => {
+ if (connecting.current) {
+ return;
+ }
+ connecting.current = true;
+ setPendingId(id);
+ setErrors(({ [id]: _cleared, ...rest }) => rest);
+
+ try {
+ if ((await action(id)) === 'redirecting') {
+ await new Promise(resolve => setTimeout(resolve, OAUTH_REDIRECT_HOLD_MS));
+ }
+ } catch (error) {
+ setErrors(current => ({ ...current, [id]: error instanceof Error ? error.message : messages.errors.generic }));
+ } finally {
+ connecting.current = false;
+ setPendingId(undefined);
+ }
+ };
+
+ return {
+ accounts: accounts.map(account =>
+ errors[account.id] ? { ...account, reconnectError: errors[account.id] } : account,
+ ),
+ availableProviders: availableProviders.map(provider =>
+ errors[provider.id] ? { ...provider, connectError: errors[provider.id] } : provider,
+ ),
+ pendingId,
+ onConnect: id => void run(id, onConnect),
+ onReconnect: id => void run(id, onReconnect),
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages.ts
similarity index 69%
rename from packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts
rename to packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages.ts
index a31102ac5fe..c181f31860b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.messages.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages.ts
@@ -3,6 +3,7 @@ export const userProfileConnectedAccountsMessages = {
connect: 'Connect',
connectLabel: 'Connect {provider}',
reconnect: 'Reconnect',
+ tryAgain: 'Try again',
disconnected: 'Disconnected',
manageLabel: 'Manage {provider}',
remove: 'Remove',
@@ -13,4 +14,9 @@ export const userProfileConnectedAccountsMessages = {
confirm: 'Remove',
cancel: 'Cancel',
},
+ errors: {
+ generic: 'Something went wrong. Please try again.',
+ unavailable: 'This connected account is no longer available.',
+ missingVerificationUrl: 'OAuth flow did not receive a verification URL.',
+ },
} as const;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.test.ts
new file mode 100644
index 00000000000..ff24ae057a6
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.test.ts
@@ -0,0 +1,362 @@
+import type { OAuthProvider } from '@clerk/shared/types';
+import { describe, expect, it } from 'vitest';
+
+import {
+ allowsIdentificationCreation,
+ createProviderCatalog,
+ projectConnectedAccounts,
+ recoveryFor,
+} from './user-profile-connected-accounts-section.model';
+
+type AccountInput = {
+ id: string;
+ provider: OAuthProvider;
+ status?: 'verified' | 'unverified';
+ errorCode?: string;
+ longMessage?: string;
+ strategy?: string;
+ approvedScopes?: string;
+ username?: string;
+ emailAddress?: string;
+};
+
+function account({
+ id,
+ provider,
+ status = 'verified',
+ errorCode,
+ longMessage,
+ strategy,
+ approvedScopes = 'email',
+ username = '',
+ emailAddress = '',
+}: AccountInput) {
+ return {
+ id,
+ provider,
+ approvedScopes,
+ username,
+ emailAddress,
+ verification: {
+ status,
+ strategy: strategy ?? `oauth_${provider}`,
+ error: errorCode ? { code: errorCode, longMessage: longMessage ?? errorCode } : null,
+ },
+ };
+}
+
+function userWith(
+ accounts: ReturnType[],
+ enterpriseAccounts: Parameters[0]['enterpriseAccounts'] = [],
+) {
+ return {
+ verifiedExternalAccounts: accounts.filter(a => a.verification?.status === 'verified'),
+ unverifiedExternalAccounts: accounts.filter(a => a.verification?.status !== 'verified'),
+ enterpriseAccounts,
+ };
+}
+
+function social(...strategies: string[]) {
+ const settings = Object.fromEntries(
+ strategies.map(strategy => [
+ strategy,
+ {
+ enabled: true,
+ strategy,
+ name: strategy.startsWith('oauth_custom_') ? 'Custom Provider' : strategy,
+ logo_url: strategy.startsWith('oauth_custom_') ? 'https://img.example/custom.png' : null,
+ },
+ ]),
+ );
+ return createProviderCatalog(strategies.toSorted(), settings);
+}
+
+function recoveryPlan(input: ReturnType, scopes: Parameters[1]) {
+ return recoveryFor(input, scopes, createProviderCatalog([], {}, [input])).plan;
+}
+
+describe('projectConnectedAccounts', () => {
+ it('keeps existing accounts visible when only an unsupported provider is enabled', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([account({ id: 'idn_google', provider: 'google' })]),
+ providers: social('oauth_future'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(projection).toMatchObject({
+ status: 'ready',
+ accounts: [{ id: 'idn_google' }],
+ availableProviders: [],
+ });
+ });
+
+ it('keeps custom and built-in providers in the settings strategy order', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([]),
+ providers: social('oauth_google', 'oauth_custom_acme', 'oauth_apple'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.availableProviders.map(provider => provider.id)).toEqual([
+ 'oauth_apple',
+ 'oauth_custom_acme',
+ 'oauth_google',
+ ]);
+ });
+
+ it('is hidden when no social provider is enabled', () => {
+ expect(
+ projectConnectedAccounts({ user: userWith([]), providers: [], socialEnabled: false, allowCreation: true }),
+ ).toEqual({
+ status: 'hidden',
+ });
+ });
+
+ it('resolves empty rows when creation is disallowed and no accounts remain', () => {
+ expect(
+ projectConnectedAccounts({
+ user: userWith([]),
+ providers: social('oauth_google'),
+ socialEnabled: true,
+ allowCreation: false,
+ }),
+ ).toEqual({
+ status: 'ready',
+ accounts: [],
+ availableProviders: [],
+ });
+ });
+
+ it('stays visible when creation is disallowed but a pending account exists', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([account({ id: 'idn_1', provider: 'google', status: 'unverified' })]),
+ providers: social('oauth_google'),
+ socialEnabled: true,
+ allowCreation: false,
+ });
+ expect(projection).toEqual({ status: 'ready', accounts: [], availableProviders: [] });
+ });
+
+ it('lists verified accounts first, then failed unverified ones, and omits pending ones', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([
+ account({ id: 'idn_failed', provider: 'github', status: 'unverified', errorCode: 'form_identifier_exists' }),
+ account({ id: 'idn_pending', provider: 'apple', status: 'unverified' }),
+ account({ id: 'idn_verified', provider: 'google' }),
+ ]),
+ providers: social('oauth_google', 'oauth_github', 'oauth_apple'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.accounts.map(a => a.id)).toEqual(['idn_verified', 'idn_failed']);
+ });
+
+ it('prefers the username over the email address as the identifier', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([
+ account({ id: 'idn_1', provider: 'github', username: 'octo', emailAddress: 'octo@example.com' }),
+ account({ id: 'idn_2', provider: 'google', emailAddress: 'g@example.com' }),
+ ]),
+ providers: social('oauth_google', 'oauth_github'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.accounts.map(a => a.identifier)).toEqual([
+ 'octo',
+ 'g@example.com',
+ ]);
+ });
+
+ it('offers enabled providers that have no account row, keyed by strategy', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([
+ account({ id: 'idn_google', provider: 'google' }),
+ account({ id: 'idn_github', provider: 'github', status: 'unverified', errorCode: 'oauth_access_denied' }),
+ account({ id: 'idn_acme', provider: 'custom_acme', status: 'unverified' }),
+ ]),
+ providers: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.availableProviders.map(p => p.id)).toEqual([
+ 'oauth_custom_acme',
+ ]);
+ });
+
+ it('uses configured names and logos for custom providers', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([account({ id: 'idn_1', provider: 'custom_acme' })]),
+ providers: social('oauth_custom_acme'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.accounts[0]).toMatchObject({
+ provider: 'Custom Provider',
+ iconUrl: 'https://img.example/custom.png',
+ });
+ });
+
+ it('marks monochrome provider logos', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([]),
+ providers: social('oauth_github', 'oauth_google'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(
+ projection.status === 'ready' && projection.availableProviders.map(p => [p.id, Boolean(p.monochromeIcon)]),
+ ).toEqual([
+ ['oauth_github', true],
+ ['oauth_google', false],
+ ]);
+ });
+
+ it('offers no providers when creation is disallowed', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([account({ id: 'idn_1', provider: 'google' })]),
+ providers: social('oauth_google', 'oauth_github'),
+ socialEnabled: true,
+ allowCreation: false,
+ });
+ expect(projection.status === 'ready' && projection.availableProviders).toEqual([]);
+ });
+
+ it('shows recovery errors as reconnect and other errors with their message', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([
+ account({
+ id: 'idn_reconnect',
+ provider: 'google',
+ status: 'unverified',
+ errorCode: 'external_account_missing_refresh_token',
+ }),
+ account({
+ id: 'idn_error',
+ provider: 'github',
+ status: 'unverified',
+ errorCode: 'external_account_exists',
+ longMessage: 'This account is already connected.',
+ }),
+ ]),
+ providers: social('oauth_google', 'oauth_github'),
+ socialEnabled: true,
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.accounts).toMatchObject([
+ { id: 'idn_reconnect', status: 'reconnect', verificationError: undefined },
+ { id: 'idn_error', status: 'error', verificationError: 'This account is already connected.' },
+ ]);
+ });
+});
+
+describe('recoveryFor', () => {
+ it('does not retry an unsupported verification strategy', () => {
+ expect(
+ recoveryPlan(
+ account({ id: 'idn_1', provider: 'google', strategy: 'unknown', errorCode: 'oauth_fetch_user_error' }),
+ undefined,
+ ),
+ ).toBeNull();
+ });
+
+ it.each([
+ 'external_account_missing_refresh_token',
+ 'oauth_fetch_user_error',
+ 'oauth_token_exchange_error',
+ 'external_account_email_address_verification_required',
+ ])('recreates the account for %s', code => {
+ expect(recoveryPlan(account({ id: 'idn_1', provider: 'github', errorCode: code }), undefined)).toEqual({
+ kind: 'create',
+ strategy: 'oauth_github',
+ additionalScopes: [],
+ });
+ });
+
+ it('normalizes google one tap to the google strategy', () => {
+ const recovery = recoveryPlan(
+ account({ id: 'idn_1', provider: 'google', strategy: 'google_one_tap', errorCode: 'oauth_fetch_user_error' }),
+ undefined,
+ );
+ expect(recovery).toMatchObject({ kind: 'create', strategy: 'oauth_google' });
+ });
+
+ it('reauthorizes with the full requested scope list when any scope is missing', () => {
+ const recovery = recoveryPlan(account({ id: 'idn_1', provider: 'google', approvedScopes: 'email profile' }), {
+ google: ['email', 'calendar'],
+ });
+ expect(recovery).toEqual({ kind: 'reauthorize', additionalScopes: ['email', 'calendar'] });
+ });
+
+ it('does not reauthorize when every requested scope is approved', () => {
+ expect(
+ recoveryPlan(account({ id: 'idn_1', provider: 'google', approvedScopes: 'email calendar' }), {
+ google: ['calendar'],
+ }),
+ ).toBeNull();
+ });
+
+ it('does not offer recovery when scopes are missing and none were approved', () => {
+ expect(
+ recoveryPlan(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
+ ).toBeNull();
+ });
+
+ it('keeps other verification errors on an error row with a retry plan', () => {
+ const input = account({ id: 'idn_1', provider: 'google', errorCode: 'oauth_access_denied' });
+ expect(recoveryFor(input, undefined, social('oauth_google'))).toEqual({
+ status: 'error',
+ plan: { kind: 'create', strategy: 'oauth_google', additionalScopes: [] },
+ });
+ });
+});
+
+describe('createProviderCatalog', () => {
+ it('keeps canonical strategy order and drops unknown strategies', () => {
+ expect(
+ social('oauth_github', 'oauth_custom_acme', 'oauth_future', 'oauth_apple')
+ .filter(provider => provider.enabled)
+ .map(provider => provider.strategy),
+ ).toEqual(['oauth_apple', 'oauth_custom_acme', 'oauth_github']);
+ });
+});
+
+describe('allowsIdentificationCreation', () => {
+ const enterpriseSSO = { enabled: true };
+
+ it('blocks creation for an active enterprise connection that disables additional identifications', () => {
+ const blocked = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
+ expect(allowsIdentificationCreation(blocked, enterpriseSSO)).toBe(false);
+ });
+
+ it('allows creation when the enterprise connection is inactive or enterprise SSO is off', () => {
+ const inactive = userWith(
+ [],
+ [{ active: false, enterpriseConnection: { disableAdditionalIdentifications: true } }],
+ );
+ expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true);
+
+ const active = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
+ expect(allowsIdentificationCreation(active, { enabled: false })).toBe(true);
+ });
+});
+
+describe('recoveryFor retry', () => {
+ it('retries a failed connection with its strategy and missing scopes', () => {
+ expect(
+ recoveryPlan(
+ account({
+ id: 'idn_1',
+ provider: 'google',
+ status: 'unverified',
+ errorCode: 'oauth_access_denied',
+ approvedScopes: '',
+ }),
+ { google: ['calendar'] },
+ ),
+ ).toEqual({ kind: 'create', strategy: 'oauth_google', additionalScopes: ['calendar'] });
+ });
+
+ it('does not retry an account without an error', () => {
+ expect(recoveryPlan(account({ id: 'idn_1', provider: 'google' }), undefined)).toBeNull();
+ });
+});
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
new file mode 100644
index 00000000000..60970f8f377
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -0,0 +1,369 @@
+import { iconImageUrl } from '@clerk/shared/constants';
+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 {
+ ClerkAPIError,
+ CustomOauthProvider,
+ EnterpriseAccountConnectionResource,
+ EnterpriseAccountResource,
+ EnterpriseSSOSettings,
+ ExternalAccountResource,
+ OAuthProvider,
+ OAuthProviders,
+ OAuthScope,
+ OAuthStrategy,
+ VerificationResource,
+} from '@clerk/shared/types';
+
+import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
+import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
+import { useErrorText, useMessages } from '../../../localization';
+import { connectedAccountFeedback } from './user-profile-connected-accounts-feedback';
+import type {
+ ConnectedAccountActionResult,
+ ConnectedAccountProviderDisplay,
+ UserProfileConnectedAccount,
+ UserProfileConnectionProvider,
+} from './user-profile-connected-accounts-section.types';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
+
+type AccountData = Pick & {
+ verification:
+ | (Pick & { error: Pick | null })
+ | null;
+};
+type EnterpriseUser = {
+ enterpriseAccounts: (Pick & {
+ enterpriseConnection?: Pick | null;
+ })[];
+};
+type ProjectedUser = {
+ verifiedExternalAccounts: AccountData[];
+ unverifiedExternalAccounts: AccountData[];
+};
+
+export type AdditionalOAuthScopes = Partial>;
+
+export type ConnectedAccountRecovery =
+ | { kind: 'reauthorize'; additionalScopes: string[] }
+ | { kind: 'create'; strategy: OAuthStrategy; additionalScopes: string[] };
+
+export interface ConnectedAccountProvider {
+ strategy: OAuthStrategy;
+ provider: OAuthProvider;
+ display: ConnectedAccountProviderDisplay;
+ enabled: boolean;
+}
+
+export interface ConnectedAccountRecoveryState {
+ status: UserProfileConnectedAccount['status'];
+ plan: ConnectedAccountRecovery | null;
+}
+
+export type ConnectedAccountsProjection =
+ | { status: 'hidden' }
+ | {
+ status: 'ready';
+ accounts: UserProfileConnectedAccount[];
+ availableProviders: UserProfileConnectionProvider[];
+ };
+
+const RECONNECT_ERROR_CODES = [
+ 'external_account_missing_refresh_token',
+ 'oauth_fetch_user_error',
+ 'oauth_token_exchange_error',
+ 'external_account_email_address_verification_required',
+];
+
+const MONOCHROME_PROVIDERS = ['agentid', 'apple', 'github', 'okx_wallet', 'vercel', 'x'];
+
+type SocialSettings = Partial<
+ Record & { strategy: string }>
+>;
+
+export function createProviderCatalog(
+ enabledStrategies: readonly string[],
+ social: SocialSettings,
+ accounts: AccountData[] = [],
+): ConnectedAccountProvider[] {
+ const enabled = new Set(enabledStrategies);
+ const providers: ConnectedAccountProvider[] = OAUTH_PROVIDERS.map(({ strategy, provider, name }) => ({
+ strategy,
+ provider,
+ enabled: enabled.has(strategy),
+ display: {
+ provider: name,
+ iconUrl: iconImageUrl(provider),
+ monochromeIcon: MONOCHROME_PROVIDERS.includes(provider),
+ },
+ }));
+ const candidates = new Set([
+ ...enabledStrategies,
+ ...Object.values(social).flatMap(settings => (settings ? [settings.strategy] : [])),
+ ...accounts.flatMap(account => [`oauth_${account.provider}`, account.verification?.strategy ?? '']),
+ ]);
+ for (const candidate of candidates) {
+ if (!candidate.startsWith('oauth_custom_')) {
+ continue;
+ }
+ const suffix = candidate.slice('oauth_custom_'.length);
+ const strategy: OAuthStrategy = `oauth_custom_${suffix}`;
+ const provider: CustomOauthProvider = `custom_${suffix}`;
+ const settings = social[strategy];
+ providers.push({
+ strategy,
+ provider,
+ enabled: enabled.has(strategy),
+ display: { provider: settings?.name || provider, iconUrl: settings?.logo_url || undefined },
+ });
+ }
+ return [
+ ...enabledStrategies.flatMap(strategy => {
+ const provider = providers.find(provider => provider.strategy === strategy);
+ return provider ? [provider] : [];
+ }),
+ ...providers.filter(provider => !provider.enabled),
+ ];
+}
+
+export function allowsIdentificationCreation(
+ user: EnterpriseUser,
+ enterpriseSSO: Pick,
+): boolean {
+ if (!enterpriseSSO.enabled) {
+ return true;
+ }
+ return !user.enterpriseAccounts.some(
+ account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications,
+ );
+}
+
+function findAdditionalScopes(account: AccountData, scopes: AdditionalOAuthScopes | undefined): string[] {
+ const requested = scopes?.[account.provider] ?? [];
+ const approved = account.approvedScopes.split(' ');
+ return requested.some(scope => !approved.includes(scope)) ? requested : [];
+}
+
+export function recoveryFor(
+ account: AccountData,
+ scopes: AdditionalOAuthScopes | undefined,
+ providers: ConnectedAccountProvider[],
+): ConnectedAccountRecoveryState {
+ const additionalScopes = findAdditionalScopes(account, scopes);
+ if (additionalScopes.length > 0 && account.approvedScopes !== '') {
+ return { status: 'reconnect', plan: { kind: 'reauthorize', additionalScopes } };
+ }
+ const error = account.verification?.error;
+ if (!error) {
+ return { status: 'connected', plan: null };
+ }
+ const verificationStrategy = account.verification?.strategy;
+ const provider = verificationStrategy
+ ? providers.find(
+ provider =>
+ provider.strategy === (verificationStrategy === 'google_one_tap' ? 'oauth_google' : verificationStrategy),
+ )
+ : providers.find(provider => provider.provider === account.provider);
+ return {
+ status: RECONNECT_ERROR_CODES.includes(error.code) ? 'reconnect' : 'error',
+ plan: provider ? { kind: 'create', strategy: provider.strategy, additionalScopes } : null,
+ };
+}
+
+function toAccountRow(
+ account: AccountData,
+ providers: ConnectedAccountProvider[],
+ scopes: AdditionalOAuthScopes | undefined,
+): UserProfileConnectedAccount {
+ const error = account.verification?.error;
+ const { status } = recoveryFor(account, scopes, providers);
+ return {
+ id: account.id,
+ ...(providers.find(provider => provider.provider === account.provider)?.display ?? { provider: account.provider }),
+ identifier: account.username || account.emailAddress || undefined,
+ status,
+ verificationError: status === 'error' ? error?.longMessage : undefined,
+ };
+}
+
+export function projectConnectedAccounts({
+ user,
+ providers,
+ socialEnabled,
+ allowCreation,
+ additionalOAuthScopes,
+}: {
+ user: ProjectedUser;
+ providers: ConnectedAccountProvider[];
+ socialEnabled: boolean;
+ allowCreation: boolean;
+ additionalOAuthScopes?: AdditionalOAuthScopes;
+}): ConnectedAccountsProjection {
+ if (!socialEnabled) {
+ return { status: 'hidden' };
+ }
+
+ const displayed = [
+ ...user.verifiedExternalAccounts,
+ ...user.unverifiedExternalAccounts.filter(account => account.verification?.error),
+ ];
+ const shownProviders = new Set(displayed.map(account => account.provider));
+
+ return {
+ status: 'ready',
+ accounts: displayed.map(account => toAccountRow(account, providers, additionalOAuthScopes)),
+ availableProviders: allowCreation
+ ? providers
+ .filter(provider => provider.enabled && !shownProviders.has(provider.provider))
+ .map(provider => ({ id: provider.strategy, ...provider.display }))
+ : [],
+ };
+}
+
+export type UserProfileConnectedAccountsModel =
+ | { status: 'loading' }
+ | { status: 'hidden'; reason: 'no_user' | 'unavailable' }
+ | {
+ status: 'ready';
+ userId: string;
+ accounts: UserProfileConnectedAccount[];
+ availableProviders: UserProfileConnectionProvider[];
+ connect: (strategy: string) => Promise;
+ reconnect: (accountId: string) => Promise;
+ remove: (accountId: string) => Promise;
+ };
+
+export function useUserProfileConnectedAccountsModel({
+ additionalOAuthScopes,
+ mode,
+}: {
+ additionalOAuthScopes?: AdditionalOAuthScopes;
+ mode?: 'modal' | 'mounted';
+}): UserProfileConnectedAccountsModel {
+ const clerk = useClerk();
+ const { isLoaded, user } = useUser();
+ const environment = useMosaicEnvironment();
+ const router = useMosaicRouter();
+ const messages = useMessages('userProfileConnectedAccounts');
+ const errorText = useErrorText();
+
+ if (!isLoaded || !environment) {
+ return { status: 'loading' };
+ }
+ if (!user) {
+ return { status: 'hidden', reason: 'no_user' };
+ }
+
+ const providers = createProviderCatalog(
+ environment.userSettings.socialProviderStrategies,
+ environment.userSettings.social,
+ user.externalAccounts,
+ );
+ const projection = projectConnectedAccounts({
+ user,
+ providers,
+ socialEnabled: environment.userSettings.socialProviderStrategies.length > 0,
+ allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO),
+ additionalOAuthScopes,
+ });
+ if (projection.status === 'hidden') {
+ return { status: 'hidden', reason: 'unavailable' };
+ }
+
+ const userId = user.id;
+ const transport = clerk.__internal_oauthTransport;
+
+ const requireCurrentUser = () => {
+ const current = clerk.user;
+ if (!current || current.id !== userId) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ return current;
+ };
+
+ 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 completeVerification = async (response: ExternalAccountResource): Promise => {
+ const url = response.verification?.externalVerificationRedirectURL;
+ if (!url) {
+ throw new ConnectedAccountActionError('missing_verification_url');
+ }
+
+ requireCurrentUser();
+ if (transport) {
+ const { callbackUrl } = await transport.open(url);
+ const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
+ const current = requireCurrentUser();
+ await (nonce ? current.reload({ rotatingTokenNonce: nonce }) : current.reload());
+ return;
+ }
+
+ await router.navigate(url.href);
+ return 'redirecting';
+ };
+
+ async function runAction(action: () => Promise): Promise {
+ try {
+ return await action();
+ } catch (error) {
+ throw connectedAccountFeedback(error, messages, errorText);
+ }
+ }
+
+ return {
+ ...projection,
+ userId,
+ connect: strategyId =>
+ runAction(async () => {
+ const provider = providers.find(candidate => candidate.enabled && candidate.strategy === strategyId);
+ if (!provider) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ const redirectUrl = await getRedirectUrl();
+ const current = requireCurrentUser();
+ const response = await current.createExternalAccount({
+ strategy: provider.strategy,
+ redirectUrl: withModalState(redirectUrl, provider.provider),
+ additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider.provider] : [],
+ });
+ return completeVerification(response);
+ }),
+ reconnect: accountId =>
+ runAction(async () => {
+ const redirectUrl = await getRedirectUrl();
+ const current = requireCurrentUser();
+ const account = current.externalAccounts.find(candidate => candidate.id === accountId);
+ if (!account) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ const { plan: recovery } = recoveryFor(account, additionalOAuthScopes, providers);
+ if (!recovery) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+
+ const response =
+ recovery.kind === 'reauthorize'
+ ? await account.reauthorize({
+ additionalScopes: recovery.additionalScopes,
+ redirectUrl: withModalState(redirectUrl),
+ })
+ : await current.createExternalAccount({
+ strategy: recovery.strategy,
+ redirectUrl: withModalState(redirectUrl),
+ additionalScopes: recovery.additionalScopes,
+ });
+ return completeVerification(response);
+ }),
+ remove: accountId =>
+ runAction(async () => {
+ const account = requireCurrentUser().externalAccounts.find(candidate => candidate.id === accountId);
+ if (!account) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ await account.destroy();
+ }),
+ };
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts
similarity index 68%
rename from packages/mosaic/src/features/user-profile/user-profile-connected-accounts.styles.ts
rename to packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts
index 1cc5a4d1947..df13c6cd152 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts.styles.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts
@@ -1,10 +1,20 @@
import * as stylex from '@stylexjs/stylex';
-import { colorVars, radiusVars, space } from '../../tokens.stylex';
+import { colorVars, radiusVars, space } from '../../../tokens.stylex';
export const styles = stylex.create({
connectRow: { justifyContent: 'center' },
icon: { display: 'block', objectFit: 'contain', height: space['6'], width: space['6'] },
+ maskedIcon: {
+ backgroundColor: 'currentColor',
+ display: 'block',
+ maskPosition: 'center',
+ maskRepeat: 'no-repeat',
+ maskSize: 'contain',
+ height: space['6'],
+ width: space['6'],
+ },
+ maskImage: (url: string) => ({ maskImage: `url(${JSON.stringify(url)})` }),
fallback: {
borderRadius: radiusVars['--cl-radius-sm'],
alignItems: 'center',
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx
new file mode 100644
index 00000000000..d601119b69c
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx
@@ -0,0 +1,61 @@
+import type { ReactNode } from 'react';
+
+import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
+import type {
+ AdditionalOAuthScopes,
+ UserProfileConnectedAccountsModel,
+} from './user-profile-connected-accounts-section.model';
+import { useUserProfileConnectedAccountsModel } from './user-profile-connected-accounts-section.model';
+import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view';
+
+export type UserProfileConnectedAccountsSectionProps = {
+ additionalOAuthScopes?: AdditionalOAuthScopes;
+ fallback?: ReactNode;
+ fallbackFocus?: () => HTMLElement | null;
+ mode?: 'modal' | 'mounted';
+};
+
+export function UserProfileConnectedAccountsSection({
+ additionalOAuthScopes,
+ fallback,
+ fallbackFocus,
+ mode,
+}: UserProfileConnectedAccountsSectionProps) {
+ const model = useUserProfileConnectedAccountsModel({ additionalOAuthScopes, mode });
+ if (model.status === 'loading') {
+ return fallback ?? null;
+ }
+ if (model.status === 'hidden') {
+ return null;
+ }
+ return (
+
+ );
+}
+
+function ConnectedAccounts({
+ model,
+ fallbackFocus,
+}: {
+ model: Extract;
+ fallbackFocus?: () => HTMLElement | null;
+}) {
+ const controller = useUserProfileConnectedAccountsController({
+ accounts: model.accounts,
+ availableProviders: model.availableProviders,
+ onConnect: model.connect,
+ onReconnect: model.reconnect,
+ });
+
+ return (
+
+ );
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts
new file mode 100644
index 00000000000..276150261f0
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts
@@ -0,0 +1,41 @@
+export type ConnectedAccountActionResult = 'redirecting' | void;
+
+export interface ConnectedAccountProviderDisplay {
+ provider: string;
+ iconUrl?: string;
+ monochromeIcon?: boolean;
+}
+
+export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay {
+ id: string;
+ connectError?: string;
+}
+
+export interface UserProfileConnectedAccount extends ConnectedAccountProviderDisplay {
+ id: string;
+ identifier?: string;
+ status: 'connected' | 'reconnect' | 'error';
+ verificationError?: string;
+ reconnectError?: string;
+}
+
+export interface UserProfileConnectedAccountsSectionViewProps {
+ fallbackFocus?: () => HTMLElement | null;
+ accounts: UserProfileConnectedAccount[];
+ availableProviders?: UserProfileConnectionProvider[];
+ pendingId?: string;
+ onConnect?: (id: string) => void;
+ onReconnect?: (id: string) => void;
+ onRemove?: (id: string) => void | Promise;
+}
+
+export class ConnectedAccountActionError extends Error {
+ constructor(
+ readonly code: 'unavailable' | 'missing_verification_url',
+ message: string = code,
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ this.name = 'ConnectedAccountActionError';
+ }
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx
similarity index 67%
rename from packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx
rename to packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx
index aaed3a43dde..edd6a7aebac 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx
@@ -1,39 +1,29 @@
import { useMemo, useRef } from 'react';
-import { Confirmation } from '../../blocks/confirmation';
-import { Section } from '../../components/section';
-import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
-import { fill, useMessages } from '../../localization';
-import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view';
+import { Confirmation } from '../../../blocks/confirmation';
+import { Section } from '../../../components/section';
+import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus';
+import { fill, useMessages } from '../../../localization';
+import {
+ UserProfileConnectedAccountRowView,
+ UserProfileConnectProviderRowView,
+} from './user-profile-connected-account-row.view';
+import type {
+ UserProfileConnectedAccount,
+ UserProfileConnectedAccountsSectionViewProps,
+} from './user-profile-connected-accounts-section.types';
-export interface UserProfileConnectionProvider {
- id: string;
- provider: string;
- iconUrl?: string;
- connectError?: string;
-}
-
-export interface UserProfileConnectedAccount extends UserProfileConnectionProvider {
- identifier?: string;
- canRemove?: boolean;
- status?: 'connected' | 'reconnect' | 'error';
- verificationError?: string;
- reconnectError?: string;
-}
-
-export interface UserProfileConnectedAccountsSectionViewProps {
- fallbackFocus?: () => HTMLElement | null;
- accounts: UserProfileConnectedAccount[];
- availableProviders?: UserProfileConnectionProvider[];
- onConnect?: (id: string) => void;
- onReconnect?: (id: string) => void;
- onRemove?: (id: string) => void | Promise;
-}
+export type {
+ UserProfileConnectedAccount,
+ UserProfileConnectedAccountsSectionViewProps,
+ UserProfileConnectionProvider,
+} from './user-profile-connected-accounts-section.types';
export function UserProfileConnectedAccountsSectionView({
accounts,
fallbackFocus,
availableProviders = [],
+ pendingId,
onConnect,
onReconnect,
onRemove,
@@ -51,6 +41,7 @@ export function UserProfileConnectedAccountsSectionView({
});
const removeAccount = useMemo(() => Confirmation.createHandle(), []);
const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect));
+ const isBusy = pendingId !== undefined;
return (
<>
@@ -69,15 +60,18 @@ export function UserProfileConnectedAccountsSectionView({
key={account.id}
account={account}
triggerRef={removalFocus.registerTrigger(account.id)}
+ isDisabled={isBusy}
onReconnect={onReconnect}
onRemove={onRemove ? account => removeAccount.open(account) : undefined}
/>
))}
{onConnect
? availableProviders.map(provider => (
-
))
diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx
index 35924d464e2..a5f256402e7 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx
@@ -1,5 +1,4 @@
-import type { ReactElement, ReactNode } from 'react';
-import { useRef } from 'react';
+import type { ReactElement, ReactNode, Ref } from 'react';
import { Panel } from '../../components/panel';
import { themeProps } from '../../props';
@@ -9,13 +8,8 @@ import type {
UserProfilePhone,
} from './user-profile-account-section/user-profile-account-section.view';
import { UserProfileAccountSectionView } from './user-profile-account-section/user-profile-account-section.view';
-import type {
- UserProfileConnectedAccount,
- UserProfileConnectionProvider,
-} from './user-profile-connected-accounts-section.view';
-import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view';
-import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view';
-import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view';
+import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
+import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view';
export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet };
export type {
@@ -25,17 +19,9 @@ export type {
export type { UserProfileEditNameValue } from './user-profile-account-section/user-profile-edit-name.dialog';
export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps {
- connectedAccounts?: UserProfileConnectedAccount[];
- availableConnectionProviders?: UserProfileConnectionProvider[];
- onReconnectAccount?: (id: string) => void;
- web3Wallets?: UserProfileWeb3Wallet[];
- availableWeb3Providers?: UserProfileWeb3Provider[];
- onConnectAccount?: (id: string) => void;
- onRemoveConnectedAccount?: (id: string) => void | Promise;
- onConnectWeb3Wallet?: (id: string) => void;
- onSetPrimaryWeb3Wallet?: (id: string) => void;
- onRemoveWeb3Wallet?: (id: string) => void | Promise;
- /** Danger zone. Omit to hide it. */
+ titleRef?: Ref;
+ connectedAccountsSlot?: ReactNode;
+ web3WalletsSlot?: ReactNode;
deleteAccountSlot?: ReactNode;
}
@@ -51,11 +37,7 @@ export function UserProfileProfilePanelView({
lastNameAttribute,
emails = [],
phones = [],
- connectedAccounts = [],
- availableConnectionProviders = [],
- onReconnectAccount,
- web3Wallets = [],
- availableWeb3Providers = [],
+ titleRef,
onProfilePictureChange,
onProfilePictureReject,
onRemoveProfilePicture,
@@ -74,18 +56,14 @@ export function UserProfileProfilePanelView({
onVerifyPhone,
onSetPrimaryPhone,
onRemovePhone,
- onConnectAccount,
- onRemoveConnectedAccount,
- onConnectWeb3Wallet,
- onSetPrimaryWeb3Wallet,
- onRemoveWeb3Wallet,
+ connectedAccountsSlot,
+ web3WalletsSlot,
deleteAccountSlot,
}: UserProfileProfilePanelViewProps): ReactElement {
- const pageTitle = useRef(null);
return (
}>
Account
@@ -122,22 +100,8 @@ export function UserProfileProfilePanelView({
onSubmitName={onSubmitName}
onSubmitUsername={onSubmitUsername}
/>
- pageTitle.current}
- accounts={connectedAccounts}
- availableProviders={availableConnectionProviders}
- onReconnect={onReconnectAccount}
- onConnect={onConnectAccount}
- onRemove={onRemoveConnectedAccount}
- />
- pageTitle.current}
- wallets={web3Wallets}
- availableProviders={availableWeb3Providers}
- onConnect={onConnectWeb3Wallet}
- onRemove={onRemoveWeb3Wallet}
- onSetPrimary={onSetPrimaryWeb3Wallet}
- />
+ {connectedAccountsSlot}
+ {web3WalletsSlot}
{deleteAccountSlot}
diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts
index 6e4a6cae586..d92f36608ab 100644
--- a/packages/mosaic/src/localization/registry.ts
+++ b/packages/mosaic/src/localization/registry.ts
@@ -21,7 +21,7 @@ import { userProfileAddSmsMessages } from '../features/user-profile/user-profile
import { userProfileApiKeysPanelMessages } from '../features/user-profile/user-profile-api-keys-panel.messages';
import { userProfileAuthenticatorSetupMessages } from '../features/user-profile/user-profile-authenticator-setup.messages';
import { userProfileBackupCodesMessages } from '../features/user-profile/user-profile-backup-codes.messages';
-import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts.messages';
+import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages';
import { userProfileDeleteSectionMessages } from '../features/user-profile/user-profile-delete-section/user-profile-delete-section.messages';
import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages';
import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages';
diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx
index 5ab40cfaa2e..db7f56f8b06 100644
--- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx
+++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx
@@ -21,6 +21,7 @@ import {
const flows = [
{ title: 'API keys', href: '/live/api-keys' },
{ title: 'Password', href: '/live/password' },
+ { title: 'Connected accounts', href: '/live/connected-accounts' },
{ title: 'Delete account', href: '/live/delete-account' },
{ title: 'Reverification', href: '/live/reverification' },
];
diff --git a/packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx b/packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx
new file mode 100644
index 00000000000..4ddfc3f5d7d
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx
@@ -0,0 +1,37 @@
+'use client';
+
+import { UserProfileConnectedAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section';
+import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
+import { useUser } from '@clerk/nextjs';
+import Link from 'next/link';
+
+export default function ConnectedAccountsLivePage() {
+ const { isLoaded, isSignedIn } = useUser();
+
+ return (
+
+
+
+
Connected accounts
+
+ Connects, reconnects, and removes the signed-in user's social accounts. The section stays hidden when
+ no social provider is enabled.
+
+
+ {!isLoaded ?
Loading…
: null}
+ {isLoaded && !isSignedIn ? (
+
+
+ Sign in
+ {' '}
+ to manage connected accounts.
+
+ ) : null}
+ {isLoaded && isSignedIn ?
: null}
+
+
+ );
+}
diff --git a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts
index c903cae4087..7400dedd713 100644
--- a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts
+++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts
@@ -1,17 +1,18 @@
import type {
UserProfileConnectedAccount,
UserProfileConnectionProvider,
-} from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section.view';
+} from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
import { useState } from 'react';
import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosEmail, chaosText } from '@/lib/chaos';
-export const connectedAccount = {
+export const connectedAccount: UserProfileConnectedAccount = {
id: 'google',
provider: 'Google',
identifier: 'test@example.com',
iconUrl: 'https://img.clerk.com/static/google.svg',
+ status: 'connected',
};
const connectionProviders: UserProfileConnectionProvider[] = [
{ id: 'google', provider: 'Google', iconUrl: 'https://img.clerk.com/static/google.svg' },
@@ -49,7 +50,13 @@ export function useConnectedAccountsFixture({
if (provider) {
setAccounts(current => [
...current,
- { id: provider.id, provider: provider.provider, iconUrl: provider.iconUrl, identifier: 'test@example.com' },
+ {
+ id: provider.id,
+ provider: provider.provider,
+ iconUrl: provider.iconUrl,
+ identifier: 'test@example.com',
+ status: 'connected',
+ },
]);
}
},
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index de99d9b055b..3132c03f8fc 100644
--- a/packages/swingset/src/stories/fixtures/user-profile.tsx
+++ b/packages/swingset/src/stories/fixtures/user-profile.tsx
@@ -5,13 +5,15 @@ import type {
UserProfilePaymentMethod,
UserProfileSubscription,
} from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view';
+import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view';
import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view';
import type {
UserProfileEmail,
UserProfilePhone,
} from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view';
-import { useState } from 'react';
+import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view';
+import { useRef, useState } from 'react';
import { useChaosFixture } from '@/components/ChaosProvider';
import { chaosEmail, chaosRows, chaosText } from '@/lib/chaos';
@@ -64,6 +66,7 @@ const examplePaymentMethods: UserProfilePaymentMethod[] = [
* stories that need a realistic profile surface without being about it.
*/
export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) {
+ const titleRef = useRef(null);
const connections = useConnectedAccountsFixture();
const wallets = useWeb3WalletsFixture();
const editName = useUserProfileEditNameFixture();
@@ -114,16 +117,19 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions
account: {
...editName,
...editUsername,
- connectedAccounts: connections.accounts,
- availableConnectionProviders: connections.availableProviders,
- onConnectAccount: connections.onConnect,
- onReconnectAccount: connections.onReconnect,
- onRemoveConnectedAccount: connections.onRemove,
- web3Wallets: wallets.wallets,
- availableWeb3Providers: wallets.availableProviders,
- onConnectWeb3Wallet: wallets.onConnect,
- onSetPrimaryWeb3Wallet: wallets.onSetPrimary,
- onRemoveWeb3Wallet: wallets.onRemove,
+ titleRef,
+ connectedAccountsSlot: (
+ titleRef.current}
+ />
+ ),
+ web3WalletsSlot: (
+ titleRef.current}
+ />
+ ),
allowMultipleAccounts: true,
hasImage: Boolean(imageUrl),
imageUrl,
diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx
index 63f7d06698e..ad0418cedab 100644
--- a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx
+++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx
@@ -1,4 +1,4 @@
-import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section.view';
+import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
import type { StoryMeta } from '@/lib/types';
@@ -11,7 +11,8 @@ export const meta: StoryMeta = {
title: 'UserProfileConnectedAccountsSection',
label: 'Connected accounts',
navigation: { category: 'Sections' },
- source: 'packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx',
+ source:
+ 'packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx',
};
export function Default() {
const fixture = useConnectedAccountsFixture();
diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx
index 7e392ec0679..5094a1b339e 100644
--- a/packages/swingset/src/stories/user-profile-profile-panel.mdx
+++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx
@@ -16,7 +16,12 @@ import * as UserProfileProfilePanelStories from './user-profile-profile-panel.st
## Usage
```tsx
+import { useRef } from 'react';
import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view';
+import { UserProfileConnectedAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section';
+import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view';
+
+const titleRef = useRef(null);
({
- id: account.id,
- provider: account.provider,
- identifier: account.emailAddress,
- connected: true,
- }))}
- web3Wallets={supportedWeb3Wallets.map(provider => {
- const wallet = user.web3Wallets.find(wallet => wallet.provider === provider.id);
-
- return {
- id: provider.id,
- provider: provider.name,
- iconUrl: provider.iconUrl,
- address: wallet?.web3Wallet,
- connected: Boolean(wallet),
- isPrimary: wallet?.id === user.primaryWeb3WalletId,
- isVerified: wallet ? wallet.verification.status === 'verified' : undefined,
- };
- })}
+ titleRef={titleRef}
+ connectedAccountsSlot={ titleRef.current} />}
+ web3WalletsSlot={ titleRef.current} />}
onVerifyEmail={verifyEmail}
onSendEmailCode={sendEmailCode}
onVerifyEmailCode={verifyEmailCode}
@@ -62,9 +51,5 @@ import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile
onVerifyPhone={verifyPhone}
onSetPrimaryPhone={setPrimaryPhone}
onRemovePhone={removePhone}
- onRemoveConnectedAccount={removeConnectedAccount}
- onConnectWeb3Wallet={connectWeb3Wallet}
- onSetPrimaryWeb3Wallet={setPrimaryWeb3Wallet}
- onRemoveWeb3Wallet={removeWeb3Wallet}
/>
```
diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx
index aabb012c536..a656c4fd8d6 100644
--- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx
+++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx
@@ -1,9 +1,11 @@
+import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
import type {
UserProfileEmail,
UserProfilePhone,
} from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view';
import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view';
-import { useState } from 'react';
+import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view';
+import { useRef, useState } from 'react';
import type { StoryMeta } from '@/lib/types';
@@ -30,6 +32,7 @@ export const meta: StoryMeta = {
};
export function Default(_args: Record) {
+ const titleRef = useRef(null);
const [emails, setEmails] = useState([
{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true },
{ id: 'email_2', value: 'item2@clerk.dev', isVerified: true },
@@ -53,29 +56,32 @@ export function Default(_args: Record) {
{...emailFlow}
allowMultipleAccounts
emails={emails}
- connectedAccounts={connections.accounts}
- availableConnectionProviders={connections.availableProviders}
- onReconnectAccount={connections.onReconnect}
- web3Wallets={wallets.wallets}
- availableWeb3Providers={wallets.availableProviders}
+ titleRef={titleRef}
+ connectedAccountsSlot={
+ titleRef.current}
+ />
+ }
+ web3WalletsSlot={
+ titleRef.current}
+ />
+ }
hasImage={Boolean(imageUrl)}
imageUrl={imageUrl}
phones={phones}
{...createUserProfileAddPhoneFixture({
onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]),
})}
- onConnectAccount={connections.onConnect}
deleteAccountSlot={}
onManageEmail={() => undefined}
onManagePhone={() => undefined}
onProfilePictureChange={showFile}
- onRemoveConnectedAccount={connections.onRemove}
onRemoveProfilePicture={clearImage}
onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))}
onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))}
- onConnectWeb3Wallet={wallets.onConnect}
- onRemoveWeb3Wallet={wallets.onRemove}
- onSetPrimaryWeb3Wallet={wallets.onSetPrimary}
onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))}
onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))}
onVerifyEmail={() => undefined}