From c4d45f17793ebb0b0f2618be794c1f22e455ffeb Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 25 Sep 2026 15:40:36 -0600
Subject: [PATCH 01/41] feat(mosaic): add a pending state and monochrome icons
to the connected accounts view
Connect shows a pending state and the other connect and reconnect actions are disabled while one runs. Monochrome provider logos render as masks so they stay visible in dark themes.
---
...ser-profile-connected-account-row.view.tsx | 22 ++++++++++++++-----
...rofile-connected-accounts-section.view.tsx | 7 ++++++
...ser-profile-connected-accounts.messages.ts | 3 +++
.../user-profile-connected-accounts.styles.ts | 10 +++++++++
4 files changed, 37 insertions(+), 5 deletions(-)
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
index a88437e4339..b65f898a070 100644
--- 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
@@ -4,7 +4,7 @@ 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 { SubmitButton } from '../../components/button';
import { Icon, IconFrame } from '../../components/icon';
import { Section } from '../../components/section';
import { fill, useMessages } from '../../localization';
@@ -14,12 +14,16 @@ import type { UserProfileConnectedAccount } from './user-profile-connected-accou
export function UserProfileConnectedAccountRowView({
account,
triggerRef,
+ isPending = false,
+ isDisabled = false,
onConnect,
onReconnect,
onRemove,
}: {
account: UserProfileConnectedAccount;
triggerRef?: Ref;
+ isPending?: boolean;
+ isDisabled?: boolean;
onConnect?: (id: string) => void;
onReconnect?: (id: string) => void;
onRemove?: (account: UserProfileConnectedAccount) => void;
@@ -27,7 +31,7 @@ export function UserProfileConnectedAccountRowView({
const m = useMessages('userProfileConnectedAccounts');
const iconUrl = account.iconUrl?.trim();
const actions: ActionMenuAction[] = [];
- if (account.status === 'reconnect' && onReconnect) {
+ if (account.status === 'reconnect' && onReconnect && !isDisabled) {
actions.push({ label: m.reconnect, onClick: () => onReconnect(account.id) });
}
if (onRemove && account.canRemove !== false) {
@@ -38,7 +42,12 @@ export function UserProfileConnectedAccountRowView({
- {iconUrl ? (
+ {iconUrl && account.monochromeIcon ? (
+
+ ) : iconUrl ? (
{onConnect ? (
-
+
) : actions.length > 0 ? (
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.view.tsx
index aaed3a43dde..cd1dbdb4300 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.view.tsx
@@ -10,6 +10,7 @@ export interface UserProfileConnectionProvider {
id: string;
provider: string;
iconUrl?: string;
+ monochromeIcon?: boolean;
connectError?: string;
}
@@ -25,6 +26,7 @@ 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;
@@ -34,6 +36,7 @@ export function UserProfileConnectedAccountsSectionView({
accounts,
fallbackFocus,
availableProviders = [],
+ pendingId,
onConnect,
onReconnect,
onRemove,
@@ -51,6 +54,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,6 +73,7 @@ export function UserProfileConnectedAccountsSectionView({
key={account.id}
account={account}
triggerRef={removalFocus.registerTrigger(account.id)}
+ isDisabled={isBusy}
onReconnect={onReconnect}
onRemove={onRemove ? account => removeAccount.open(account) : undefined}
/>
@@ -78,6 +83,8 @@ export function UserProfileConnectedAccountsSectionView({
))
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.messages.ts
index a31102ac5fe..3af4d9907e0 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.messages.ts
@@ -13,4 +13,7 @@ export const userProfileConnectedAccountsMessages = {
confirm: 'Remove',
cancel: 'Cancel',
},
+ errors: {
+ generic: 'Something went wrong. Please try again.',
+ },
} as const;
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.styles.ts
index 1cc5a4d1947..f7cebd2cf30 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.styles.ts
@@ -5,6 +5,16 @@ 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',
From 92c7a5fa8a801e5e24872a2505b57486cc39b739 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 25 Sep 2026 15:42:15 -0600
Subject: [PATCH 02/41] feat(mosaic): wire the connected accounts section to
Clerk
Adds UserProfileConnectedAccountsSection, which projects the user's external accounts and enabled providers, connects through createExternalAccount or the OAuth transport, reconnects by reauthorizing or recreating, and removes accounts. The profile panel accepts it through connectedAccountsSlot.
---
.../mosaic-connected-accounts-wire-up.md | 2 +
...cted-accounts-section.integration.test.tsx | 308 ++++++++++++++++++
...e-connected-accounts-section.model.test.ts | 271 +++++++++++++++
...e-connected-accounts-section.controller.ts | 72 ++++
...rofile-connected-accounts-section.model.ts | 157 +++++++++
...ser-profile-connected-accounts-section.tsx | 146 +++++++++
.../user-profile-profile-panel.view.tsx | 21 +-
7 files changed, 969 insertions(+), 8 deletions(-)
create mode 100644 .changeset/mosaic-connected-accounts-wire-up.md
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx
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/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
new file mode 100644
index 00000000000..173f26d1fc3
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
@@ -0,0 +1,308 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import type * as SharedReact from '@clerk/shared/react';
+import { createDeferredPromise } from '@clerk/shared/utils';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { MosaicProvider } from '../../../MosaicProvider';
+import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section';
+
+let isLoaded: boolean;
+let externalAccounts: Record[];
+let enterpriseAccounts: unknown[];
+let social: Record;
+let transport: { getRedirectUrl: () => string; open: ReturnType } | null;
+let createExternalAccount: ReturnType;
+let reload: ReturnType;
+let navigate: ReturnType;
+
+vi.mock('@clerk/shared/react', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useUser: () => ({
+ isLoaded,
+ isSignedIn: isLoaded,
+ user: isLoaded
+ ? {
+ id: 'user_1',
+ externalAccounts,
+ verifiedExternalAccounts: externalAccounts.filter(a => (a.verification as any)?.status === 'verified'),
+ unverifiedExternalAccounts: externalAccounts.filter(a => (a.verification as any)?.status !== 'verified'),
+ enterpriseAccounts,
+ createExternalAccount,
+ reload,
+ }
+ : undefined,
+ }),
+ useClerk: () => ({
+ __internal_environment: {
+ userSettings: { social, enterpriseSSO: { enabled: enterpriseAccounts.length > 0 } },
+ },
+ __internal_oauthTransport: transport,
+ __internal_getOption: () => undefined,
+ navigate,
+ }),
+ };
+});
+
+function oauth(strategy: string) {
+ return { enabled: true, strategy, name: strategy, logo_url: null };
+}
+
+function externalAccount(overrides: Record & { id: string; provider: string }) {
+ return {
+ approvedScopes: 'email',
+ username: '',
+ emailAddress: `${overrides.provider}@example.com`,
+ verification: { status: 'verified', strategy: `oauth_${overrides.provider}`, error: null },
+ destroy: vi.fn(() => Promise.resolve()),
+ reauthorize: vi.fn(),
+ ...overrides,
+ };
+}
+
+function verificationResponse(url = 'https://accounts.example/authorize') {
+ return { verification: { externalVerificationRedirectURL: new URL(url) } };
+}
+
+function renderSection(props: Parameters[0] = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
+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('UserProfileConnectedAccountsSection', () => {
+ beforeEach(() => {
+ isLoaded = true;
+ externalAccounts = [];
+ enterpriseAccounts = [];
+ social = { oauth_google: oauth('oauth_google'), oauth_github: oauth('oauth_github') };
+ transport = null;
+ createExternalAccount = vi.fn(() => Promise.resolve(verificationResponse()));
+ reload = vi.fn(() => Promise.resolve());
+ navigate = vi.fn(() => Promise.resolve());
+ });
+
+ it('renders the fallback until the user has loaded', () => {
+ isLoaded = false;
+ renderSection({ fallback: Loading accounts
});
+ expect(screen.getByText('Loading accounts')).toBeInTheDocument();
+ });
+
+ it('renders nothing when no social provider is enabled', () => {
+ social = {};
+ const { container } = renderSection();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders connected accounts from the user and offers the remaining providers', () => {
+ externalAccounts = [externalAccount({ id: 'idn_google', provider: 'google', username: 'jdoe' })];
+ renderSection();
+
+ 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' })).not.toBeInTheDocument();
+ });
+
+ it('hides Connect when an enterprise connection disables additional identifications', () => {
+ externalAccounts = [externalAccount({ id: 'idn_google', provider: 'google' })];
+ enterpriseAccounts = [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }];
+ renderSection();
+
+ expect(screen.getByRole('button', { name: 'Manage Google' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /^Connect/ })).not.toBeInTheDocument();
+ });
+
+ it('connects with the current URL and requested scopes, then navigates to the provider', async () => {
+ const user = userEvent.setup();
+ renderSection({ additionalOAuthScopes: { github: ['repo'] } });
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+
+ await waitFor(() =>
+ expect(createExternalAccount).toHaveBeenCalledWith({
+ strategy: 'oauth_github',
+ redirectUrl: window.location.href,
+ additionalScopes: ['repo'],
+ }),
+ );
+ expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
+ });
+
+ it('keeps Connect pending and ignores repeat clicks while the request runs', async () => {
+ const deferred = createDeferredPromise();
+ createExternalAccount = vi.fn(() => deferred.promise);
+ const user = userEvent.setup();
+ renderSection();
+
+ const connect = screen.getByRole('button', { name: 'Connect GitHub' });
+ await user.click(connect);
+ await user.click(connect);
+ await user.click(screen.getByRole('button', { name: 'Connect Google' }));
+
+ expect(createExternalAccount).toHaveBeenCalledOnce();
+ expect(connect).toHaveAttribute('aria-busy', 'true');
+
+ deferred.resolve(verificationResponse());
+ await waitFor(() => expect(navigate).toHaveBeenCalledOnce());
+ });
+
+ it('shows the API error on the provider row and re-enables Connect', async () => {
+ createExternalAccount = vi.fn(() =>
+ Promise.reject(
+ new ClerkAPIResponseError('failed', {
+ data: [{ code: 'oauth_error', message: 'failed', long_message: 'GitHub is unavailable right now.' }],
+ status: 422,
+ }),
+ ),
+ );
+ const user = userEvent.setup();
+ renderSection();
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+
+ expect(await screen.findByText('GitHub is unavailable right now.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true');
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ it('shows a generic error when the provider returns no verification URL', async () => {
+ createExternalAccount = vi.fn(() => Promise.resolve({ verification: null }));
+ const user = userEvent.setup();
+ renderSection();
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+
+ expect(await screen.findByText('Something went wrong. Please try again.')).toBeInTheDocument();
+ });
+
+ it('opens the transport and reloads the user with the callback nonce', async () => {
+ transport = {
+ getRedirectUrl: () => 'myapp://sso-callback',
+ open: vi.fn(() => Promise.resolve({ callbackUrl: 'myapp://sso-callback?rotating_token_nonce=abc' })),
+ };
+ const user = userEvent.setup();
+ renderSection();
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+
+ await waitFor(() => expect(reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'abc' }));
+ expect(createExternalAccount).toHaveBeenCalledWith(
+ expect.objectContaining({ redirectUrl: 'myapp://sso-callback' }),
+ );
+ expect(transport.open).toHaveBeenCalledWith(new URL('https://accounts.example/authorize'));
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
+ );
+ });
+
+ it('recreates an account that needs reconnecting', async () => {
+ externalAccounts = [
+ externalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ verification: {
+ status: 'unverified',
+ strategy: 'google_one_tap',
+ error: { code: 'external_account_missing_refresh_token', longMessage: 'Missing token' },
+ },
+ }),
+ ];
+ const user = userEvent.setup();
+ renderSection();
+
+ 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(createExternalAccount).toHaveBeenCalledWith({
+ strategy: 'oauth_google',
+ redirectUrl: window.location.href,
+ additionalScopes: [],
+ }),
+ );
+ expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
+ });
+
+ it('reauthorizes an account missing requested scopes', async () => {
+ const google = externalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ approvedScopes: 'email',
+ reauthorize: vi.fn(() => Promise.resolve(verificationResponse('https://accounts.example/consent'))),
+ });
+ externalAccounts = [google];
+ const user = userEvent.setup();
+ renderSection({ additionalOAuthScopes: { google: ['email', 'calendar'] } });
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+
+ await waitFor(() =>
+ expect(google.reauthorize).toHaveBeenCalledWith({
+ additionalScopes: ['email', 'calendar'],
+ redirectUrl: window.location.href,
+ }),
+ );
+ expect(createExternalAccount).not.toHaveBeenCalled();
+ expect(navigate).toHaveBeenCalledWith('https://accounts.example/consent');
+ });
+
+ it('removes the selected account and closes the confirmation', async () => {
+ const google = externalAccount({ id: 'idn_google', provider: 'google' });
+ const github = externalAccount({ id: 'idn_github', provider: 'github' });
+ externalAccounts = [google, github];
+ const user = userEvent.setup();
+ renderSection();
+
+ const dialog = await openRemoval(user, 'GitHub');
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+
+ await waitFor(() => expect(github.destroy).toHaveBeenCalledOnce());
+ expect(google.destroy).not.toHaveBeenCalled();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ });
+
+ it('keeps the confirmation open with the API message when removal is rejected', async () => {
+ const google = externalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ destroy: vi.fn(() =>
+ Promise.reject(
+ new ClerkAPIResponseError('failed', {
+ data: [
+ {
+ code: 'last_identification',
+ message: 'failed',
+ long_message: 'You cannot remove your last sign-in method.',
+ },
+ ],
+ status: 403,
+ }),
+ ),
+ ),
+ });
+ externalAccounts = [google];
+ const user = userEvent.setup();
+ renderSection();
+
+ const dialog = await openRemoval(user, 'Google');
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+
+ expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
new file mode 100644
index 00000000000..562b5cdc037
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
@@ -0,0 +1,271 @@
+import type { EnterpriseSSOSettings, ExternalAccountResource, OAuthProviders, UserResource } from '@clerk/shared/types';
+import { describe, expect, it } from 'vitest';
+
+import {
+ allowsIdentificationCreation,
+ getEnabledOAuthStrategies,
+ getRecovery,
+ projectConnectedAccounts,
+} from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model';
+
+type AccountInput = {
+ id: string;
+ provider: string;
+ 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): ExternalAccountResource {
+ return {
+ id,
+ provider,
+ approvedScopes,
+ username,
+ emailAddress,
+ verification: {
+ status,
+ strategy: strategy ?? `oauth_${provider}`,
+ error: errorCode ? { code: errorCode, longMessage: longMessage ?? errorCode } : null,
+ },
+ } as unknown as ExternalAccountResource;
+}
+
+function user(accounts: ExternalAccountResource[], enterpriseAccounts: unknown[] = []): UserResource {
+ return {
+ externalAccounts: accounts,
+ verifiedExternalAccounts: accounts.filter(a => a.verification?.status === 'verified'),
+ unverifiedExternalAccounts: accounts.filter(a => a.verification?.status !== 'verified'),
+ enterpriseAccounts,
+ } as unknown as UserResource;
+}
+
+function social(...strategies: string[]): Partial {
+ return 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,
+ },
+ ]),
+ ) as Partial;
+}
+
+describe('projectConnectedAccounts', () => {
+ it('is hidden when no social provider is enabled', () => {
+ expect(projectConnectedAccounts({ user: user([]), social: {}, allowCreation: true })).toEqual({
+ status: 'hidden',
+ });
+ });
+
+ it('is hidden when creation is disallowed and the user has no external accounts', () => {
+ expect(projectConnectedAccounts({ user: user([]), social: social('oauth_google'), allowCreation: false })).toEqual({
+ status: 'hidden',
+ });
+ });
+
+ it('stays visible when creation is disallowed but a pending account exists', () => {
+ const projection = projectConnectedAccounts({
+ user: user([account({ id: 'idn_1', provider: 'google', status: 'unverified' })]),
+ social: social('oauth_google'),
+ 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: user([
+ 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' }),
+ ]),
+ social: social('oauth_google', 'oauth_github', 'oauth_apple'),
+ 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: user([
+ account({ id: 'idn_1', provider: 'github', username: 'octo', emailAddress: 'octo@example.com' }),
+ account({ id: 'idn_2', provider: 'google', emailAddress: 'g@example.com' }),
+ ]),
+ social: social('oauth_google', 'oauth_github'),
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.accounts.map(a => a.identifier)).toEqual([
+ 'octo',
+ 'g@example.com',
+ ]);
+ });
+
+ it('offers enabled providers without a verified account, keyed by strategy', () => {
+ const projection = projectConnectedAccounts({
+ user: user([
+ account({ id: 'idn_google', provider: 'google' }),
+ account({ id: 'idn_github', provider: 'github', status: 'unverified', errorCode: 'oauth_access_denied' }),
+ ]),
+ social: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.availableProviders.map(p => p.id)).toEqual([
+ 'oauth_github',
+ 'oauth_custom_acme',
+ ]);
+ });
+
+ it('uses configured names and logos for custom providers', () => {
+ const projection = projectConnectedAccounts({
+ user: user([account({ id: 'idn_1', provider: 'custom_acme' })]),
+ social: social('oauth_custom_acme'),
+ 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: user([]),
+ social: social('oauth_github', 'oauth_google'),
+ 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: user([account({ id: 'idn_1', provider: 'google' })]),
+ social: social('oauth_google', 'oauth_github'),
+ 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: user([
+ 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.',
+ }),
+ ]),
+ social: social('oauth_google', 'oauth_github'),
+ 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('getRecovery', () => {
+ 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(getRecovery(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 = getRecovery(
+ 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 = getRecovery(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(
+ getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: 'email calendar' }), {
+ google: ['calendar'],
+ }),
+ ).toBeNull();
+ });
+
+ it('does not reauthorize when no scopes were approved', () => {
+ expect(
+ getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
+ ).toBeNull();
+ });
+
+ it('ignores unrecognized verification errors', () => {
+ expect(getRecovery(account({ id: 'idn_1', provider: 'google', errorCode: 'oauth_access_denied' }), undefined)).toBe(
+ null,
+ );
+ });
+});
+
+describe('getEnabledOAuthStrategies', () => {
+ it('sorts known strategies, appends custom ones, and drops unknown ones', () => {
+ expect(
+ getEnabledOAuthStrategies(social('oauth_github', 'oauth_custom_acme', 'oauth_future', 'oauth_apple')),
+ ).toEqual(['oauth_apple', 'oauth_github', 'oauth_custom_acme']);
+ });
+});
+
+describe('allowsIdentificationCreation', () => {
+ const enterpriseSSO = { enabled: true } as EnterpriseSSOSettings;
+
+ it('blocks creation for an active enterprise connection that disables additional identifications', () => {
+ const blocked = user([], [{ 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 = user([], [{ active: false, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
+ expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true);
+
+ const active = user([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
+ expect(allowsIdentificationCreation(active, { enabled: false } as EnterpriseSSOSettings)).toBe(true);
+ });
+});
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..a452edf8663
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.ts
@@ -0,0 +1,72 @@
+import { useRef, useState } from 'react';
+
+import type {
+ UserProfileConnectedAccount,
+ UserProfileConnectionProvider,
+} from '../user-profile-connected-accounts-section.view';
+
+export type ConnectedAccountActionResult = 'redirecting' | void;
+
+export interface UserProfileConnectedAccountsController {
+ accounts: UserProfileConnectedAccount[];
+ availableProviders: UserProfileConnectionProvider[];
+ pendingId: string | undefined;
+ onConnect: (id: string) => void;
+ onReconnect: (id: string) => void;
+}
+
+export function useUserProfileConnectedAccountsController({
+ accounts,
+ availableProviders,
+ onConnect,
+ onReconnect,
+ fallbackErrorMessage,
+}: {
+ accounts: UserProfileConnectedAccount[];
+ availableProviders: UserProfileConnectionProvider[];
+ onConnect: (id: string) => Promise;
+ onReconnect: (id: string) => Promise;
+ fallbackErrorMessage: string;
+}): UserProfileConnectedAccountsController {
+ const [pendingId, setPendingId] = useState();
+ const [connectErrors, setConnectErrors] = useState>({});
+ const [reconnectErrors, setReconnectErrors] = useState>({});
+ const inFlight = useRef(undefined);
+
+ const run = async (
+ id: string,
+ action: (id: string) => Promise,
+ setErrors: typeof setConnectErrors,
+ ) => {
+ if (inFlight.current) {
+ return;
+ }
+ inFlight.current = id;
+ setPendingId(id);
+ setErrors(({ [id]: _cleared, ...rest }) => rest);
+
+ try {
+ if ((await action(id)) === 'redirecting') {
+ return;
+ }
+ } catch (error) {
+ const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage;
+ setErrors(current => ({ ...current, [id]: message }));
+ }
+
+ inFlight.current = undefined;
+ setPendingId(undefined);
+ };
+
+ return {
+ accounts: accounts.map(account =>
+ reconnectErrors[account.id] ? { ...account, reconnectError: reconnectErrors[account.id] } : account,
+ ),
+ availableProviders: availableProviders.map(provider =>
+ connectErrors[provider.id] ? { ...provider, connectError: connectErrors[provider.id] } : provider,
+ ),
+ pendingId,
+ onConnect: id => void run(id, onConnect, setConnectErrors),
+ onReconnect: id => void run(id, onReconnect, setReconnectErrors),
+ };
+}
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..7f8d5bee01d
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -0,0 +1,157 @@
+import { iconImageUrl } from '@clerk/shared/constants';
+import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
+import type {
+ EnterpriseSSOSettings,
+ ExternalAccountResource,
+ OAuthProvider,
+ OAuthProviders,
+ OAuthScope,
+ OAuthStrategy,
+ UserResource,
+} from '@clerk/shared/types';
+
+import type {
+ UserProfileConnectedAccount,
+ UserProfileConnectionProvider,
+} from '../user-profile-connected-accounts-section.view';
+
+export type AdditionalOAuthScopes = Partial>;
+
+export type ConnectedAccountRecovery =
+ | { kind: 'reauthorize'; additionalScopes: string[] }
+ | { kind: 'create'; strategy: OAuthStrategy; additionalScopes: string[] };
+
+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'];
+
+const KNOWN_OAUTH_STRATEGIES: readonly string[] = OAUTH_PROVIDERS.map(p => p.strategy);
+
+export function getProviderDisplay(
+ provider: string,
+ social: Partial,
+): Omit {
+ const known = OAUTH_PROVIDERS.find(p => p.provider === provider);
+ if (known) {
+ return {
+ provider: known.name,
+ iconUrl: iconImageUrl(known.provider),
+ monochromeIcon: MONOCHROME_PROVIDERS.includes(known.provider),
+ };
+ }
+
+ const custom = social[`oauth_${provider}` as OAuthStrategy];
+ if (custom) {
+ return { provider: custom.name || provider, iconUrl: custom.logo_url || undefined };
+ }
+
+ return { provider };
+}
+
+export function getEnabledOAuthStrategies(social: Partial): OAuthStrategy[] {
+ const enabled = Object.values(social)
+ .flatMap(settings => (settings?.enabled ? [settings.strategy] : []))
+ .sort();
+ const known = enabled.filter(strategy => KNOWN_OAUTH_STRATEGIES.includes(strategy));
+ const custom = enabled.filter(
+ strategy => !KNOWN_OAUTH_STRATEGIES.includes(strategy) && strategy.startsWith('oauth_custom_'),
+ );
+ return [...known, ...custom];
+}
+
+export function allowsIdentificationCreation(user: UserResource, enterpriseSSO: EnterpriseSSOSettings): boolean {
+ if (!enterpriseSSO.enabled) {
+ return true;
+ }
+ return !user.enterpriseAccounts.some(
+ account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications,
+ );
+}
+
+function findAdditionalScopes(account: ExternalAccountResource, scopes: AdditionalOAuthScopes | undefined): string[] {
+ const requested = scopes?.[account.provider] ?? [];
+ const approved = account.approvedScopes.split(' ');
+ return requested.some(scope => !approved.includes(scope)) ? requested : [];
+}
+
+export function getRecovery(
+ account: ExternalAccountResource,
+ scopes: AdditionalOAuthScopes | undefined,
+): ConnectedAccountRecovery | null {
+ const additionalScopes = findAdditionalScopes(account, scopes);
+ if (additionalScopes.length > 0 && account.approvedScopes !== '') {
+ return { kind: 'reauthorize', additionalScopes };
+ }
+
+ if (!RECONNECT_ERROR_CODES.includes(account.verification?.error?.code ?? '')) {
+ return null;
+ }
+
+ const verificationStrategy = account.verification?.strategy;
+ const strategy = (
+ verificationStrategy === 'google_one_tap' ? 'oauth_google' : verificationStrategy || `oauth_${account.provider}`
+ ) as OAuthStrategy;
+ return { kind: 'create', strategy, additionalScopes };
+}
+
+function toAccountRow(
+ account: ExternalAccountResource,
+ social: Partial,
+ scopes: AdditionalOAuthScopes | undefined,
+): UserProfileConnectedAccount {
+ const error = account.verification?.error;
+ const status = getRecovery(account, scopes) ? 'reconnect' : error?.code ? 'error' : 'connected';
+ return {
+ id: account.id,
+ ...getProviderDisplay(account.provider, social),
+ identifier: account.username || account.emailAddress || undefined,
+ status,
+ verificationError: status === 'error' ? error?.longMessage : undefined,
+ };
+}
+
+export function projectConnectedAccounts({
+ user,
+ social,
+ allowCreation,
+ additionalOAuthScopes,
+}: {
+ user: UserResource;
+ social: Partial;
+ allowCreation: boolean;
+ additionalOAuthScopes?: AdditionalOAuthScopes;
+}): ConnectedAccountsProjection {
+ const socialEnabled = Object.values(social).some(settings => settings?.enabled);
+ if (!socialEnabled || (!allowCreation && user.externalAccounts.length === 0)) {
+ return { status: 'hidden' };
+ }
+
+ const displayed = [
+ ...user.verifiedExternalAccounts,
+ ...user.unverifiedExternalAccounts.filter(account => account.verification?.error),
+ ];
+ const connectedStrategies = user.verifiedExternalAccounts.map(account => `oauth_${account.provider}`);
+
+ return {
+ status: 'ready',
+ accounts: displayed.map(account => toAccountRow(account, social, additionalOAuthScopes)),
+ availableProviders: allowCreation
+ ? getEnabledOAuthStrategies(social)
+ .filter(strategy => !connectedStrategies.includes(strategy))
+ .map(strategy => ({ id: strategy, ...getProviderDisplay(strategy.replace('oauth_', ''), social) }))
+ : [],
+ };
+}
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..4636c879d09
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.tsx
@@ -0,0 +1,146 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { useClerk, useUser } from '@clerk/shared/react';
+import type { ExternalAccountResource, OAuthProvider, OAuthStrategy } from '@clerk/shared/types';
+import type { ReactNode } from 'react';
+
+import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
+import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
+import { useMessages } from '../../../localization';
+import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
+import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
+import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
+import type { AdditionalOAuthScopes } from './user-profile-connected-accounts-section.model';
+import {
+ allowsIdentificationCreation,
+ getRecovery,
+ projectConnectedAccounts,
+} from './user-profile-connected-accounts-section.model';
+
+export type UserProfileConnectedAccountsSectionProps = {
+ additionalOAuthScopes?: AdditionalOAuthScopes;
+ fallback?: ReactNode;
+ fallbackFocus?: () => HTMLElement | null;
+};
+
+export function UserProfileConnectedAccountsSection({
+ additionalOAuthScopes,
+ fallback,
+ fallbackFocus,
+}: UserProfileConnectedAccountsSectionProps) {
+ const m = useMessages('userProfileConnectedAccounts');
+
+ // -- Model --
+ const clerk = useClerk();
+ const { isLoaded, user } = useUser();
+ const environment = useMosaicEnvironment();
+ const router = useMosaicRouter();
+ const transport = clerk.__internal_oauthTransport;
+
+ const guard = async (run: () => Promise): Promise => {
+ try {
+ return await run();
+ } catch (error) {
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ throw new Error(first?.longMessage || first?.message || m.errors.generic);
+ }
+ throw new Error(m.errors.generic);
+ }
+ };
+
+ const getRedirectUrl = async () => (transport ? String(await transport.getRedirectUrl()) : window.location.href);
+
+ const completeVerification = async (
+ response: ExternalAccountResource | undefined,
+ ): Promise => {
+ const url = response?.verification?.externalVerificationRedirectURL;
+ if (!url) {
+ throw new Error(m.errors.generic);
+ }
+
+ if (transport) {
+ await guard(async () => {
+ const { callbackUrl } = await transport.open(url);
+ const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
+ await (nonce ? user?.reload({ rotatingTokenNonce: nonce }) : user?.reload());
+ });
+ return;
+ }
+
+ await router.navigate(url.href);
+ return 'redirecting';
+ };
+
+ const connect = async (strategy: string) => {
+ const provider = strategy.replace('oauth_', '') as OAuthProvider;
+ const response = await guard(async () =>
+ user?.createExternalAccount({
+ strategy: strategy as OAuthStrategy,
+ redirectUrl: await getRedirectUrl(),
+ additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
+ }),
+ );
+ return completeVerification(response);
+ };
+
+ const reconnect = async (accountId: string) => {
+ const account = user?.externalAccounts.find(candidate => candidate.id === accountId);
+ const recovery = account ? getRecovery(account, additionalOAuthScopes) : null;
+ if (!account || !recovery) {
+ return;
+ }
+
+ const response = await guard(async () => {
+ const redirectUrl = await getRedirectUrl();
+ return recovery.kind === 'reauthorize'
+ ? account.reauthorize({ additionalScopes: recovery.additionalScopes, redirectUrl })
+ : user?.createExternalAccount({
+ strategy: recovery.strategy,
+ redirectUrl,
+ additionalScopes: recovery.additionalScopes,
+ });
+ });
+ return completeVerification(response);
+ };
+
+ const remove = (accountId: string) =>
+ guard(async () => {
+ await user?.externalAccounts.find(account => account.id === accountId)?.destroy();
+ });
+
+ const projection =
+ user && environment
+ ? projectConnectedAccounts({
+ user,
+ social: environment.userSettings.social,
+ allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO),
+ additionalOAuthScopes,
+ })
+ : ({ status: 'hidden' } as const);
+
+ // -- Controller --
+ const controller = useUserProfileConnectedAccountsController({
+ accounts: projection.status === 'ready' ? projection.accounts : [],
+ availableProviders: projection.status === 'ready' ? projection.availableProviders : [],
+ onConnect: connect,
+ onReconnect: reconnect,
+ fallbackErrorMessage: m.errors.generic,
+ });
+
+ // -- View --
+ if (!isLoaded || !environment) {
+ return fallback ?? null;
+ }
+
+ if (projection.status === 'hidden') {
+ return null;
+ }
+
+ return (
+
+ );
+}
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..3797efa915c 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
@@ -32,6 +32,8 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect
availableWeb3Providers?: UserProfileWeb3Provider[];
onConnectAccount?: (id: string) => void;
onRemoveConnectedAccount?: (id: string) => void | Promise;
+ /** Connected accounts section. Replaces the one built from `connectedAccounts` and its callbacks. */
+ connectedAccountsSlot?: ReactNode;
onConnectWeb3Wallet?: (id: string) => void;
onSetPrimaryWeb3Wallet?: (id: string) => void;
onRemoveWeb3Wallet?: (id: string) => void | Promise;
@@ -76,6 +78,7 @@ export function UserProfileProfilePanelView({
onRemovePhone,
onConnectAccount,
onRemoveConnectedAccount,
+ connectedAccountsSlot,
onConnectWeb3Wallet,
onSetPrimaryWeb3Wallet,
onRemoveWeb3Wallet,
@@ -122,14 +125,16 @@ export function UserProfileProfilePanelView({
onSubmitName={onSubmitName}
onSubmitUsername={onSubmitUsername}
/>
- pageTitle.current}
- accounts={connectedAccounts}
- availableProviders={availableConnectionProviders}
- onReconnect={onReconnectAccount}
- onConnect={onConnectAccount}
- onRemove={onRemoveConnectedAccount}
- />
+ {connectedAccountsSlot ?? (
+ pageTitle.current}
+ accounts={connectedAccounts}
+ availableProviders={availableConnectionProviders}
+ onReconnect={onReconnectAccount}
+ onConnect={onConnectAccount}
+ onRemove={onRemoveConnectedAccount}
+ />
+ )}
pageTitle.current}
wallets={web3Wallets}
From 32c36551e7ab98539d6725df14f4c8c52b1dd4ce Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 25 Sep 2026 15:42:30 -0600
Subject: [PATCH 03/41] chore(swingset): add connected accounts live page
---
.../swingset/src/app/(clerk)/live-sidebar.tsx | 1 +
.../(clerk)/live/connected-accounts/page.tsx | 37 +++++++++++++++++++
2 files changed, 38 insertions(+)
create mode 100644 packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx
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: '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/live/api-keys' },
{ title: 'Password', href: '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/live/password' },
+ { title: 'Connected accounts', href: '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/live/connected-accounts' },
{ title: 'Delete account', href: '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/live/delete-account' },
{ title: 'Reverification', href: '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/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..8314d49ee8f
--- /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/MosaicProvider';
+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}
+
+
+ );
+}
From c26ec3a5703310b1e48c11ac83f31abaf9eab2c6 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 13:25:38 -0600
Subject: [PATCH 04/41] fix(mosaic): preserve connected account migration
behavior
---
...cted-accounts-section.integration.test.tsx | 43 ++++++++++++++++++
...e-connected-accounts-section.model.test.ts | 4 +-
...rofile-connected-accounts-section.model.ts | 2 +-
...ser-profile-connected-accounts-section.tsx | 45 ++++++++++++++-----
4 files changed, 79 insertions(+), 15 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
index 173f26d1fc3..ffffb529ce0 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
@@ -44,6 +44,8 @@ vi.mock('@clerk/shared/react', async importOriginal => {
__internal_getOption: () => undefined,
navigate,
}),
+ useSession: () => ({ session: { id: 'session_1' } }),
+ useReverification: (fetcher: F) => fetcher,
};
});
@@ -141,6 +143,21 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
});
+ it('preserves modal state when connecting an account', async () => {
+ const user = userEvent.setup();
+ renderSection({ mode: 'modal' });
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+
+ await waitFor(() => expect(createExternalAccount).toHaveBeenCalledOnce());
+ const redirectUrl = createExternalAccount.mock.calls[0]?.[0]?.redirectUrl;
+ expect(redirectUrl).toBeDefined();
+ const encoded = new URL(redirectUrl).searchParams.get('__clerk_modal_state');
+ expect(encoded).toBeTruthy();
+ const state = JSON.parse(window.atob(encoded));
+ expect(state).toMatchObject({ componentName: 'UserProfile', socialProvider: 'github' });
+ });
+
it('keeps Connect pending and ignores repeat clicks while the request runs', async () => {
const deferred = createDeferredPromise();
createExternalAccount = vi.fn(() => deferred.promise);
@@ -238,6 +255,32 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
});
+ it('preserves modal state when reconnecting an account', async () => {
+ externalAccounts = [
+ externalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ verification: {
+ status: 'unverified',
+ strategy: 'oauth_google',
+ error: { code: 'external_account_missing_refresh_token', longMessage: 'Missing token' },
+ },
+ }),
+ ];
+ const user = userEvent.setup();
+ renderSection({ mode: 'modal' });
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+
+ await waitFor(() => expect(createExternalAccount).toHaveBeenCalledOnce());
+ const redirectUrl = createExternalAccount.mock.calls[0]?.[0]?.redirectUrl;
+ expect(redirectUrl).toBeDefined();
+ const encoded = new URL(redirectUrl).searchParams.get('__clerk_modal_state');
+ expect(encoded).toBeTruthy();
+ expect(JSON.parse(window.atob(encoded))).toMatchObject({ componentName: 'UserProfile' });
+ });
+
it('reauthorizes an account missing requested scopes', async () => {
const google = externalAccount({
id: 'idn_google',
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
index 562b5cdc037..ee80b724366 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
@@ -232,10 +232,10 @@ describe('getRecovery', () => {
).toBeNull();
});
- it('does not reauthorize when no scopes were approved', () => {
+ it('recreates an account when scopes are missing and none were approved', () => {
expect(
getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
- ).toBeNull();
+ ).toEqual({ kind: 'create', strategy: 'oauth_google', additionalScopes: ['calendar'] });
});
it('ignores unrecognized verification errors', () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 7f8d5bee01d..708a44c37aa 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -96,7 +96,7 @@ export function getRecovery(
return { kind: 'reauthorize', additionalScopes };
}
- if (!RECONNECT_ERROR_CODES.includes(account.verification?.error?.code ?? '')) {
+ if (additionalScopes.length === 0 && !RECONNECT_ERROR_CODES.includes(account.verification?.error?.code ?? '')) {
return null;
}
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
index 4636c879d09..a7cbfa82499 100644
--- 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
@@ -1,11 +1,18 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { useClerk, useUser } from '@clerk/shared/react';
-import type { ExternalAccountResource, OAuthProvider, OAuthStrategy } from '@clerk/shared/types';
+import type {
+ CreateExternalAccountParams,
+ ExternalAccountResource,
+ OAuthProvider,
+ OAuthStrategy,
+} from '@clerk/shared/types';
import type { ReactNode } from 'react';
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
import { useMessages } from '../../../localization';
+import { Reverification, useReverificationWithState } from '../../reverification';
import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
@@ -20,12 +27,14 @@ export type UserProfileConnectedAccountsSectionProps = {
additionalOAuthScopes?: AdditionalOAuthScopes;
fallback?: ReactNode;
fallbackFocus?: () => HTMLElement | null;
+ mode?: 'modal' | 'mounted';
};
export function UserProfileConnectedAccountsSection({
additionalOAuthScopes,
fallback,
fallbackFocus,
+ mode,
}: UserProfileConnectedAccountsSectionProps) {
const m = useMessages('userProfileConnectedAccounts');
@@ -35,6 +44,12 @@ export function UserProfileConnectedAccountsSection({
const environment = useMosaicEnvironment();
const router = useMosaicRouter();
const transport = clerk.__internal_oauthTransport;
+ const [createExternalAccount, createReverification] = useReverificationWithState(
+ (params: CreateExternalAccountParams) => user?.createExternalAccount(params),
+ );
+ const [destroyAccount, removeReverification] = useReverificationWithState((accountId: string) =>
+ user?.externalAccounts.find(account => account.id === accountId)?.destroy(),
+ );
const guard = async (run: () => Promise): Promise => {
try {
@@ -49,6 +64,8 @@ export function UserProfileConnectedAccountsSection({
};
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 | undefined,
@@ -74,9 +91,9 @@ export function UserProfileConnectedAccountsSection({
const connect = async (strategy: string) => {
const provider = strategy.replace('oauth_', '') as OAuthProvider;
const response = await guard(async () =>
- user?.createExternalAccount({
+ createExternalAccount({
strategy: strategy as OAuthStrategy,
- redirectUrl: await getRedirectUrl(),
+ redirectUrl: withModalState(await getRedirectUrl(), provider),
additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
}),
);
@@ -93,10 +110,10 @@ export function UserProfileConnectedAccountsSection({
const response = await guard(async () => {
const redirectUrl = await getRedirectUrl();
return recovery.kind === 'reauthorize'
- ? account.reauthorize({ additionalScopes: recovery.additionalScopes, redirectUrl })
- : user?.createExternalAccount({
+ ? account.reauthorize({ additionalScopes: recovery.additionalScopes, redirectUrl: withModalState(redirectUrl) })
+ : createExternalAccount({
strategy: recovery.strategy,
- redirectUrl,
+ redirectUrl: withModalState(redirectUrl),
additionalScopes: recovery.additionalScopes,
});
});
@@ -105,7 +122,7 @@ export function UserProfileConnectedAccountsSection({
const remove = (accountId: string) =>
guard(async () => {
- await user?.externalAccounts.find(account => account.id === accountId)?.destroy();
+ await destroyAccount(accountId);
});
const projection =
@@ -137,10 +154,14 @@ export function UserProfileConnectedAccountsSection({
}
return (
-
+ <>
+
+ {createReverification.isActive ? : null}
+ {removeReverification.isActive ? : null}
+ >
);
}
From 45c17def187bc473826d6f595276dacd67bbf7c5 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 13:37:15 -0600
Subject: [PATCH 05/41] fix(mosaic): handle connected account reverification
cancellation
---
.../confirmation/confirmation.controller.ts | 7 +++-
...cted-accounts-section.integration.test.tsx | 41 +++++++++++++++++--
...e-connected-accounts-section.controller.ts | 7 +++-
...ser-profile-connected-accounts-section.tsx | 7 +++-
4 files changed, 54 insertions(+), 8 deletions(-)
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
index be5c6200f4c..03b94ae8d84 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
@@ -1,3 +1,5 @@
+import { isReverificationCancelledError } from '@clerk/shared/error';
+
import { setup } from '../../machine/setup';
import { useMachine } from '../../machine/use-machine';
@@ -14,7 +16,10 @@ function notSeated(): Promise {
return Promise.reject(new Error('confirmation run is not seated'));
}
-function toMessage(cause: unknown): string {
+function toMessage(cause: unknown): string | undefined {
+ if (isReverificationCancelledError(cause)) {
+ return undefined;
+ }
return cause instanceof Error ? cause.message : 'Something went wrong. Please try again.';
}
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
index ffffb529ce0..eaf42f234e5 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
@@ -1,4 +1,4 @@
-import { ClerkAPIResponseError } from '@clerk/shared/error';
+import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error';
import type * as SharedReact from '@clerk/shared/react';
import { createDeferredPromise } from '@clerk/shared/utils';
import { render, screen, waitFor, within } from '@testing-library/react';
@@ -195,14 +195,30 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(navigate).not.toHaveBeenCalled();
});
- it('shows a generic error when the provider returns no verification URL', async () => {
+ it('clears Connect without an error when reverification is cancelled', async () => {
+ createExternalAccount = vi.fn(() =>
+ Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })),
+ );
+ const user = userEvent.setup();
+ renderSection();
+
+ await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
+ );
+ expect(screen.queryByText('Something went wrong. Please try again.')).not.toBeInTheDocument();
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ it('shows the OAuth error when the provider returns no verification URL', async () => {
createExternalAccount = vi.fn(() => Promise.resolve({ verification: null }));
const user = userEvent.setup();
renderSection();
await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
- expect(await screen.findByText('Something went wrong. Please try again.')).toBeInTheDocument();
+ expect(await screen.findByText('OAuth flow did not receive a verification URL.')).toBeInTheDocument();
});
it('opens the transport and reloads the user with the callback nonce', async () => {
@@ -348,4 +364,23 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
});
+
+ it('keeps the confirmation open without an error when reverification is cancelled', async () => {
+ externalAccounts = [
+ externalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ destroy: vi.fn(() => Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' }))),
+ }),
+ ];
+ const user = userEvent.setup();
+ renderSection();
+
+ const dialog = await openRemoval(user, 'Google');
+ await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
+
+ await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Remove' })).not.toBeDisabled());
+ expect(dialog).toBeInTheDocument();
+ expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument();
+ });
});
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
index a452edf8663..8a3fa300015 100644
--- 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
@@ -1,3 +1,4 @@
+import { isReverificationCancelledError } from '@clerk/shared/error';
import { useRef, useState } from 'react';
import type {
@@ -50,8 +51,10 @@ export function useUserProfileConnectedAccountsController({
return;
}
} catch (error) {
- const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage;
- setErrors(current => ({ ...current, [id]: message }));
+ if (!isReverificationCancelledError(error)) {
+ const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage;
+ setErrors(current => ({ ...current, [id]: message }));
+ }
}
inFlight.current = undefined;
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
index a7cbfa82499..5abf830ddda 100644
--- 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
@@ -1,4 +1,4 @@
-import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error';
import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { useClerk, useUser } from '@clerk/shared/react';
import type {
@@ -55,6 +55,9 @@ export function UserProfileConnectedAccountsSection({
try {
return await run();
} catch (error) {
+ if (isReverificationCancelledError(error)) {
+ throw error;
+ }
if (isClerkAPIResponseError(error)) {
const first = error.errors[0];
throw new Error(first?.longMessage || first?.message || m.errors.generic);
@@ -72,7 +75,7 @@ export function UserProfileConnectedAccountsSection({
): Promise => {
const url = response?.verification?.externalVerificationRedirectURL;
if (!url) {
- throw new Error(m.errors.generic);
+ throw new Error('OAuth flow did not receive a verification URL.');
}
if (transport) {
From f754ade0bb1f4b1986da56c7423406176ed2f43b Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 14:03:21 -0600
Subject: [PATCH 06/41] test(mosaic): cover connected account reconnect flow
---
...cted-accounts-section.integration.test.tsx | 59 +++++++++++++++++++
...ser-profile-connected-accounts-section.tsx | 12 ++--
2 files changed, 65 insertions(+), 6 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
index eaf42f234e5..5fbb28aca2d 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
@@ -39,6 +39,7 @@ vi.mock('@clerk/shared/react', async importOriginal => {
useClerk: () => ({
__internal_environment: {
userSettings: { social, enterpriseSSO: { enabled: enterpriseAccounts.length > 0 } },
+ displayConfig: { supportEmail: 'support@example.com' },
},
__internal_oauthTransport: transport,
__internal_getOption: () => undefined,
@@ -297,6 +298,34 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(JSON.parse(window.atob(encoded))).toMatchObject({ componentName: 'UserProfile' });
});
+ it('clears Reconnect without an error when reverification is cancelled', async () => {
+ externalAccounts = [
+ externalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ verification: {
+ status: 'unverified',
+ strategy: 'oauth_google',
+ error: { code: 'external_account_missing_refresh_token', longMessage: 'Missing token' },
+ },
+ }),
+ ];
+ createExternalAccount = vi.fn(() =>
+ Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })),
+ );
+ const user = userEvent.setup();
+ renderSection();
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+
+ await waitFor(() => expect(createExternalAccount).toHaveBeenCalledOnce());
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(navigate).not.toHaveBeenCalled();
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled();
+ });
+
it('reauthorizes an account missing requested scopes', async () => {
const google = externalAccount({
id: 'idn_google',
@@ -321,6 +350,36 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(navigate).toHaveBeenCalledWith('https://accounts.example/consent');
});
+ it('shows the API error and allows another Reconnect attempt after reauthorization fails', async () => {
+ const google = externalAccount({
+ id: 'idn_google',
+ provider: 'google',
+ approvedScopes: 'email',
+ reauthorize: vi
+ .fn()
+ .mockRejectedValueOnce(
+ new ClerkAPIResponseError('failed', {
+ data: [{ code: 'oauth_error', message: 'failed', long_message: 'Calendar access was denied.' }],
+ status: 422,
+ }),
+ )
+ .mockResolvedValueOnce(verificationResponse()),
+ });
+ externalAccounts = [google];
+ const user = userEvent.setup();
+ renderSection({ additionalOAuthScopes: { google: ['calendar'] } });
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ expect(await screen.findByText('Calendar access was denied.')).toBeInTheDocument();
+ expect(navigate).not.toHaveBeenCalled();
+
+ await user.click(screen.getByRole('button', { name: 'Manage Google' }));
+ await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
+ await waitFor(() => expect(google.reauthorize).toHaveBeenCalledTimes(2));
+ expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
+ });
+
it('removes the selected account and closes the confirmation', async () => {
const google = externalAccount({ id: 'idn_google', provider: 'google' });
const github = externalAccount({ id: 'idn_github', provider: 'github' });
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
index 5abf830ddda..b9c1f0ccd53 100644
--- 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
@@ -12,7 +12,7 @@ import type { ReactNode } from 'react';
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
import { useMessages } from '../../../localization';
-import { Reverification, useReverificationWithState } from '../../reverification';
+import { Reverification, useReverificationFlow } from '../../reverification';
import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
@@ -44,10 +44,10 @@ export function UserProfileConnectedAccountsSection({
const environment = useMosaicEnvironment();
const router = useMosaicRouter();
const transport = clerk.__internal_oauthTransport;
- const [createExternalAccount, createReverification] = useReverificationWithState(
- (params: CreateExternalAccountParams) => user?.createExternalAccount(params),
+ const [createExternalAccount, createReverification] = useReverificationFlow((params: CreateExternalAccountParams) =>
+ user?.createExternalAccount(params),
);
- const [destroyAccount, removeReverification] = useReverificationWithState((accountId: string) =>
+ const [destroyAccount, removeReverification] = useReverificationFlow((accountId: string) =>
user?.externalAccounts.find(account => account.id === accountId)?.destroy(),
);
@@ -163,8 +163,8 @@ export function UserProfileConnectedAccountsSection({
fallbackFocus={fallbackFocus}
onRemove={remove}
/>
- {createReverification.isActive ? : null}
- {removeReverification.isActive ? : null}
+
+
>
);
}
From 222b66088bc7558d875f410a13e6e24266c748f7 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 14:14:09 -0600
Subject: [PATCH 07/41] fix(mosaic): embed reverification in connected account
confirmation
---
.../confirmation/confirmation.controller.ts | 43 +++--
.../blocks/confirmation/confirmation.test.tsx | 151 ++++++++++++++++++
.../src/blocks/confirmation/confirmation.tsx | 125 ++++++++++-----
...cted-accounts-section.integration.test.tsx | 7 +-
...e-connected-accounts-section.view.test.tsx | 18 +++
...rofile-connected-accounts-section.view.tsx | 4 +
...ser-profile-connected-accounts-section.tsx | 2 +-
7 files changed, 295 insertions(+), 55 deletions(-)
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
index 03b94ae8d84..4ebee9d45d8 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
@@ -8,7 +8,11 @@ export interface ConfirmationContext {
error: string | undefined;
}
-export type ConfirmationEvent = { type: 'OPEN' } | { type: 'CONFIRM'; run: () => Promise } | { type: 'CANCEL' };
+export type ConfirmationEvent =
+ | { type: 'OPEN' }
+ | { type: 'CONFIRM'; run: () => Promise }
+ | { type: 'CANCEL' }
+ | { type: 'CANCEL_REVERIFICATION' };
const { createMachine, assign, fromPromise } = setup();
@@ -16,10 +20,7 @@ function notSeated(): Promise {
return Promise.reject(new Error('confirmation run is not seated'));
}
-function toMessage(cause: unknown): string | undefined {
- if (isReverificationCancelledError(cause)) {
- return undefined;
- }
+function toMessage(cause: unknown): string {
return cause instanceof Error ? cause.message : 'Something went wrong. Please try again.';
}
@@ -43,12 +44,22 @@ export const confirmationMachine = createMachine({
},
},
pending: {
+ on: {
+ CANCEL_REVERIFICATION: { target: 'idle', actions: assign(() => ({ error: undefined })) },
+ },
invoke: fromPromise(context => context.run(), {
onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) },
- onError: {
- target: 'confirming',
- actions: assign((_, event) => ({ error: toMessage(event.error) })),
- },
+ onError: [
+ {
+ guard: (_, event) => isReverificationCancelledError(event.error),
+ target: 'idle',
+ actions: assign(() => ({ error: undefined })),
+ },
+ {
+ target: 'confirming',
+ actions: assign((_, event) => ({ error: toMessage(event.error) })),
+ },
+ ],
}),
},
},
@@ -62,12 +73,22 @@ export interface ConfirmationController {
errorMessage: string | undefined;
}
-export function useConfirmationController(): ConfirmationController {
+export function useConfirmationController({
+ canCancelPending = false,
+}: { canCancelPending?: boolean } = {}): ConfirmationController {
const [snapshot, send] = useMachine(confirmationMachine);
return {
isOpen: snapshot.value === 'confirming' || snapshot.value === 'pending',
- onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }),
+ onOpenChange: open => {
+ if (open) {
+ send({ type: 'OPEN' });
+ } else if (canCancelPending && snapshot.value === 'pending') {
+ send({ type: 'CANCEL_REVERIFICATION' });
+ } else {
+ send({ type: 'CANCEL' });
+ }
+ },
onConfirm: run => send({ type: 'CONFIRM', run }),
isConfirming: snapshot.value === 'pending',
errorMessage: snapshot.context.error,
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
index cd341740ffa..e4dc0a5c0f7 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
@@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from '../../components/button';
+import type { ReverificationController } from '../../features/reverification';
+import { deferred } from '../../machines/__tests__/test-utils';
import { MosaicProvider } from '../../mosaic-provider';
import type { ConfirmationControlledProps, ConfirmationHandleProps } from './confirmation';
import { Confirmation } from './confirmation';
@@ -26,6 +28,33 @@ function renderBlock(overrides: Partial = {}) {
const confirmButton = () => screen.getByRole('button', { name: 'Remove' });
describe('Confirmation', () => {
+ it.each(['active', 'retrying'] as const)('shows verification loading during %s', phase => {
+ renderBlock({ reverification: { status: 'loading', phase } });
+
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
+ });
+
+ it('ignores Escape while the verified action is retrying', async () => {
+ const onOpenChange = vi.fn();
+ const user = userEvent.setup();
+ renderBlock({ onOpenChange, isConfirming: true, reverification: { status: 'loading', phase: 'retrying' } });
+
+ await user.keyboard('{Escape}');
+
+ expect(onOpenChange).not.toHaveBeenCalled();
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ });
+
+ it('shows reverification inside its existing dialog without typed confirmation', () => {
+ renderBlock({ reverification: { status: 'unavailable', phase: 'active', onCancel: vi.fn() } });
+
+ expect(screen.getAllByRole('alertdialog')).toHaveLength(1);
+ expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
+ });
+
it('renders nothing until the caller opens it', () => {
renderBlock({ open: false });
@@ -124,6 +153,128 @@ function renderWithHandle(onConfirm: ConfirmationHandleProps['onConfirm'
const removeButton = () => screen.getByRole('button', { name: 'Remove Preston Booth' });
describe('Confirmation with a handle', () => {
+ it.each(['resolve', 'reject'] as const)('ignores a stale %s after cancelling and reopening', async settlement => {
+ const pending = deferred();
+ const user = userEvent.setup();
+ const handle = Confirmation.createHandle();
+ const view = (reverification: ReverificationController) => (
+
+ pending.promise}
+ reverification={reverification}
+ />
+
+ );
+ const { rerender } = render(view({ status: 'idle', phase: 'inactive' }));
+ act(() => handle.open(preston));
+ await user.click(confirmButton());
+ rerender(view({ status: 'loading', phase: 'active', onCancel: vi.fn() }));
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ rerender(view({ status: 'idle', phase: 'inactive' }));
+ act(() => handle.open(preston));
+
+ await act(async () => {
+ if (settlement === 'resolve') {
+ pending.resolve();
+ } else {
+ pending.reject(new Error('Old operation failed'));
+ }
+ await pending.promise.catch(() => undefined);
+ });
+
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true');
+ });
+
+ it.each(['resolve', 'reject'] as const)(
+ 'stays pending through verification until the action %ss',
+ async settlement => {
+ const pending = deferred();
+ const user = userEvent.setup();
+ const handle = Confirmation.createHandle();
+ const onConfirm = vi.fn(() => pending.promise);
+ const view = (reverification: ReverificationController) => (
+
+
+
+ );
+ const { rerender } = render(view({ status: 'idle', phase: 'inactive' }));
+ act(() => handle.open(preston));
+ await user.click(confirmButton());
+ rerender(view({ status: 'loading', phase: 'active', onCancel: vi.fn() }));
+ rerender(view({ status: 'loading', phase: 'retrying' }));
+
+ await user.keyboard('{Escape}');
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
+ expect(onConfirm).toHaveBeenCalledOnce();
+
+ await act(async () => {
+ if (settlement === 'resolve') {
+ pending.resolve();
+ } else {
+ pending.reject(new Error('Removal failed'));
+ }
+ await pending.promise.catch(() => undefined);
+ });
+ rerender(view({ status: 'idle', phase: 'inactive' }));
+
+ if (settlement === 'resolve') {
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ } else {
+ expect(screen.getByRole('alert')).toHaveTextContent('Removal failed');
+ expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true');
+ }
+ },
+ );
+
+ it('keeps verification in the same dialog and cancels the pending challenge on Escape', async () => {
+ const user = userEvent.setup();
+ const handle = Confirmation.createHandle();
+ const onCancel = vi.fn();
+ const onConfirm = vi.fn(() => new Promise(() => {}));
+ const view = (verifying: boolean) => (
+
+
+
+ );
+ const { rerender } = render(view(false));
+ act(() => handle.open(preston));
+ await user.click(screen.getByRole('button', { name: 'Remove' }));
+ const dialog = screen.getByRole('alertdialog');
+
+ rerender(view(true));
+ expect(screen.getByRole('alertdialog')).toBe(dialog);
+ expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+ expect(onCancel).toHaveBeenCalledOnce();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ });
+
it('renders nothing until opened through the handle', () => {
renderWithHandle();
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
index ed56c3f75ae..511825041dc 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
@@ -3,8 +3,10 @@ import type { ReactNode } from 'react';
import { Banner } from '../../components/banner';
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
-import type { DialogFocusTarget, DialogHandle, DialogTriggerProps } from '../../components/dialog';
+import type { DialogFocusTarget, DialogHandle, DialogRootProps, DialogTriggerProps } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
+import { Flow } from '../../components/flow';
+import { Reverification, type ReverificationController } from '../../features/reverification';
import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload';
import { useConfirmationController } from './confirmation.controller';
@@ -21,6 +23,7 @@ interface ConfirmationCardProps {
onConfirm: () => void;
isConfirming: boolean;
errorMessage: string | undefined;
+ reverification?: ReverificationController;
}
function ConfirmationCard({
@@ -33,7 +36,49 @@ function ConfirmationCard({
onConfirm,
isConfirming,
errorMessage,
+ reverification,
}: ConfirmationCardProps) {
+ const step = reverification && reverification.phase !== 'inactive' ? 'verify' : 'confirm';
+ const confirmation = (
+ <>
+
+ {title}
+ {description}
+
+ {errorMessage ? (
+
+
+ {errorMessage}
+
+
+ ) : null}
+
+
+ {cancelLabel}
+
+ }
+ />
+
+ {actionLabel}
+
+
+ >
+ );
+
return (
-
- {title}
- {description}
-
- {errorMessage ? (
-
-
- {errorMessage}
-
-
- ) : null}
-
-
- {cancelLabel}
-
- }
- />
-
- {actionLabel}
-
-
+ {() => (
+ <>
+ {confirmation}
+
+
+
+ >
+ )}
+
+ ) : (
+ confirmation
+ )}
);
}
export interface ConfirmationControlledProps {
+ reverification?: ReverificationController;
/** Whether the dialog is open */
open: boolean;
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
@@ -94,7 +123,7 @@ export interface ConfirmationControlledProps {
*/
finalFocus?: DialogFocusTarget;
/** Callback when open state changes */
- onOpenChange: (open: boolean) => void;
+ onOpenChange: NonNullable;
/** Element that opens the dialog */
trigger?: DialogTriggerProps['render'];
/** Dialog heading */
@@ -126,12 +155,21 @@ function ControlledConfirmation({
onConfirm,
isConfirming = false,
errorMessage,
+ reverification,
}: ConfirmationControlledProps) {
return (
{
+ if (!nextOpen && reverification?.phase === 'retrying') {
+ return;
+ }
+ if (!nextOpen && reverification?.phase === 'active') {
+ reverification.onCancel?.();
+ }
+ onOpenChange(nextOpen, details);
+ }}
>
{trigger ? : null}
);
@@ -162,6 +201,7 @@ function createHandle(): ConfirmationHandle {
}
export interface ConfirmationHandleProps {
+ reverification?: ReverificationController;
/** Opens the dialog with a payload from anywhere: `handle.open(payload)` */
handle: ConfirmationHandle;
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
@@ -192,15 +232,21 @@ function HandleConfirmation({
actionLabel,
cancelLabel = 'Cancel',
onConfirm,
+ reverification,
}: ConfirmationHandleProps) {
- const controller = useConfirmationController();
+ const controller = useConfirmationController({ canCancelPending: reverification?.phase === 'active' });
return (
{
+ if (!nextOpen && reverification?.phase === 'active') {
+ reverification.onCancel?.();
+ }
+ controller.onOpenChange(nextOpen);
+ }}
>
{({ payload }) =>
payload === undefined ? null : (
@@ -218,6 +264,7 @@ function HandleConfirmation({
}
isConfirming={controller.isConfirming}
errorMessage={controller.errorMessage}
+ reverification={reverification}
/>
)
}
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
index 5fbb28aca2d..de2164b569a 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
@@ -424,7 +424,7 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
});
- it('keeps the confirmation open without an error when reverification is cancelled', async () => {
+ it('closes the confirmation without an error when reverification is cancelled', async () => {
externalAccounts = [
externalAccount({
id: 'idn_google',
@@ -438,8 +438,7 @@ describe('UserProfileConnectedAccountsSection', () => {
const dialog = await openRemoval(user, 'Google');
await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
- await waitFor(() => expect(within(dialog).getByRole('button', { name: 'Remove' })).not.toBeDisabled());
- expect(dialog).toBeInTheDocument();
- expect(within(dialog).queryByRole('alert')).not.toBeInTheDocument();
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
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
index e74f66bb7b0..3c9467d0453 100644
--- 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
@@ -7,6 +7,24 @@ import { UserProfileConnectedAccountsSectionView } from '../user-profile-connect
const account = { id: 'account_1', provider: 'Google', identifier: 'test@example.com' };
describe('connected accounts section', () => {
+ it('shows removal reverification in the confirmation dialog without a typed phrase', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ 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.getByText('Cannot verify your account')).toBeInTheDocument();
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ });
+
it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'google', provider: 'Google' }] }])(
'hides the entire section without accounts or actionable providers (%j)',
({ availableProviders }) => {
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.view.tsx
index cd1dbdb4300..3ed1d8bdc6a 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.view.tsx
@@ -4,6 +4,7 @@ import { Confirmation } from '../../blocks/confirmation';
import { Section } from '../../components/section';
import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
import { fill, useMessages } from '../../localization';
+import type { ReverificationController } from '../reverification';
import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view';
export interface UserProfileConnectionProvider {
@@ -30,6 +31,7 @@ export interface UserProfileConnectedAccountsSectionViewProps {
onConnect?: (id: string) => void;
onReconnect?: (id: string) => void;
onRemove?: (id: string) => void | Promise;
+ removeReverification?: ReverificationController;
}
export function UserProfileConnectedAccountsSectionView({
@@ -40,6 +42,7 @@ export function UserProfileConnectedAccountsSectionView({
onConnect,
onReconnect,
onRemove,
+ removeReverification,
}: UserProfileConnectedAccountsSectionViewProps) {
const m = useMessages('userProfileConnectedAccounts');
const section = useRef(null);
@@ -95,6 +98,7 @@ export function UserProfileConnectedAccountsSectionView({
) : null}
{onRemove ? (
fill(m.removeDialog.description, { provider: account.provider })}
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
index b9c1f0ccd53..e01828c074b 100644
--- 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
@@ -162,9 +162,9 @@ export function UserProfileConnectedAccountsSection({
{...controller}
fallbackFocus={fallbackFocus}
onRemove={remove}
+ removeReverification={removeReverification}
/>
-
>
);
}
From b176bb98828b37eef1bba0797bc5f10ba8d22b4a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 14:56:35 -0600
Subject: [PATCH 08/41] fix(mosaic): show account creation reverification in a
dialog
---
.../__tests__/reverification.dialog.test.tsx | 128 ++++++++++++++++++
.../reverification/reverification.dialog.tsx | 32 +++++
...ser-profile-connected-accounts-section.tsx | 17 ++-
3 files changed, 174 insertions(+), 3 deletions(-)
create mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
create mode 100644 packages/mosaic/src/features/reverification/reverification.dialog.tsx
diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
new file mode 100644
index 00000000000..ba3fae752ca
--- /dev/null
+++ b/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
@@ -0,0 +1,128 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useRef, useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { MosaicProvider } from '../../../MosaicProvider';
+import type { ReverificationController } from '../reverification.controller';
+import { ReverificationDialog } from '../reverification.dialog';
+
+function ready(phase: 'active' | 'retrying', onCancel = vi.fn()): ReverificationController {
+ return {
+ status: 'ready',
+ phase,
+ step: 'password',
+ value: '',
+ onValueChange: vi.fn(),
+ isPending: phase === 'retrying',
+ onSubmit: vi.fn(),
+ onShowMethods: vi.fn(),
+ onShowHelp: vi.fn(),
+ onBack: vi.fn(),
+ onEmailSupport: vi.fn(),
+ onResend: vi.fn(),
+ canResend: true,
+ methods: [],
+ onSelectMethod: vi.fn(),
+ onCancel,
+ };
+}
+
+describe('ReverificationDialog', () => {
+ it('opens a named card dialog for the active password challenge', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('dialog', { name: 'Verification required' })).toBeInTheDocument();
+ expect(screen.getByLabelText('Password')).toBeInTheDocument();
+ expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1);
+ });
+
+ it('cancels the active challenge when dismissed', async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(onCancel).toHaveBeenCalledOnce();
+ });
+
+ it('keeps the retrying challenge open and does not cancel it', async () => {
+ const user = userEvent.setup();
+ const onCancel = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(onCancel).not.toHaveBeenCalled();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('stays closed when the flow is inactive', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('returns focus to the action after an async challenge is cancelled', async () => {
+ const user = userEvent.setup();
+
+ function Harness() {
+ const [phase, setPhase] = useState<'inactive' | 'active'>('inactive');
+ const actionRef = useRef(null);
+ const controller: ReverificationController =
+ phase === 'active' ? ready('active', () => setPhase('inactive')) : { status: 'idle', phase: 'inactive' };
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ render(
+
+
+ ,
+ );
+
+ const action = screen.getByRole('button', { name: 'Connect' });
+ await user.click(action);
+ expect(await screen.findByRole('dialog')).toBeInTheDocument();
+
+ await user.keyboard('{Escape}');
+
+ await waitFor(() => expect(action).toHaveFocus());
+ });
+});
diff --git a/packages/mosaic/src/features/reverification/reverification.dialog.tsx b/packages/mosaic/src/features/reverification/reverification.dialog.tsx
new file mode 100644
index 00000000000..a24df055fbf
--- /dev/null
+++ b/packages/mosaic/src/features/reverification/reverification.dialog.tsx
@@ -0,0 +1,32 @@
+import { Card } from '../../components/card';
+import { Dialog, type DialogFocusTarget } from '../../components/dialog';
+import { Reverification } from './reverification';
+import type { ReverificationController } from './reverification.controller';
+
+export function ReverificationDialog({
+ finalFocus,
+ ...controller
+}: ReverificationController & { finalFocus?: DialogFocusTarget }) {
+ return (
+ {
+ if (!open && controller.phase === 'active') {
+ controller.onCancel?.();
+ }
+ }}
+ >
+
+
+
+
+
+
+ );
+}
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
index e01828c074b..aab2aabb466 100644
--- 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
@@ -7,12 +7,13 @@ import type {
OAuthProvider,
OAuthStrategy,
} from '@clerk/shared/types';
-import type { ReactNode } from 'react';
+import { type ReactNode, useRef } from 'react';
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
import { useMessages } from '../../../localization';
-import { Reverification, useReverificationFlow } from '../../reverification';
+import { currentInteractionOrigin } from '../../../primitives/utils/interaction-origin';
+import { ReverificationDialog, useReverificationFlow } from '../../reverification';
import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
@@ -43,6 +44,7 @@ export function UserProfileConnectedAccountsSection({
const { isLoaded, user } = useUser();
const environment = useMosaicEnvironment();
const router = useMosaicRouter();
+ const reverificationFocus = useRef(null);
const transport = clerk.__internal_oauthTransport;
const [createExternalAccount, createReverification] = useReverificationFlow((params: CreateExternalAccountParams) =>
user?.createExternalAccount(params),
@@ -69,6 +71,10 @@ export function UserProfileConnectedAccountsSection({
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 captureReverificationFocus = () => {
+ const active = document.activeElement;
+ reverificationFocus.current = currentInteractionOrigin() ?? (active instanceof HTMLElement ? active : null);
+ };
const completeVerification = async (
response: ExternalAccountResource | undefined,
@@ -92,6 +98,7 @@ export function UserProfileConnectedAccountsSection({
};
const connect = async (strategy: string) => {
+ captureReverificationFocus();
const provider = strategy.replace('oauth_', '') as OAuthProvider;
const response = await guard(async () =>
createExternalAccount({
@@ -104,6 +111,7 @@ export function UserProfileConnectedAccountsSection({
};
const reconnect = async (accountId: string) => {
+ captureReverificationFocus();
const account = user?.externalAccounts.find(candidate => candidate.id === accountId);
const recovery = account ? getRecovery(account, additionalOAuthScopes) : null;
if (!account || !recovery) {
@@ -164,7 +172,10 @@ export function UserProfileConnectedAccountsSection({
onRemove={remove}
removeReverification={removeReverification}
/>
-
+
>
);
}
From 45fdb59c891f7f99167e65451633a73f0d0377f8 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 15:33:16 -0600
Subject: [PATCH 09/41] fix(mosaic): return from reverification to account
confirmation
---
.../confirmation/confirmation.controller.ts | 17 +----
.../blocks/confirmation/confirmation.test.tsx | 71 +++++++++++++++----
.../src/blocks/confirmation/confirmation.tsx | 51 +++++++++++--
...cted-accounts-section.integration.test.tsx | 7 +-
...e-connected-accounts-section.view.test.tsx | 2 +-
...rofile-connected-accounts-section.view.tsx | 1 +
...ser-profile-connected-accounts.messages.ts | 1 +
7 files changed, 113 insertions(+), 37 deletions(-)
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
index 4ebee9d45d8..7b9e8bbddd5 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
@@ -8,11 +8,7 @@ export interface ConfirmationContext {
error: string | undefined;
}
-export type ConfirmationEvent =
- | { type: 'OPEN' }
- | { type: 'CONFIRM'; run: () => Promise }
- | { type: 'CANCEL' }
- | { type: 'CANCEL_REVERIFICATION' };
+export type ConfirmationEvent = { type: 'OPEN' } | { type: 'CONFIRM'; run: () => Promise } | { type: 'CANCEL' };
const { createMachine, assign, fromPromise } = setup();
@@ -44,15 +40,12 @@ export const confirmationMachine = createMachine({
},
},
pending: {
- on: {
- CANCEL_REVERIFICATION: { target: 'idle', actions: assign(() => ({ error: undefined })) },
- },
invoke: fromPromise(context => context.run(), {
onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) },
onError: [
{
guard: (_, event) => isReverificationCancelledError(event.error),
- target: 'idle',
+ target: 'confirming',
actions: assign(() => ({ error: undefined })),
},
{
@@ -73,9 +66,7 @@ export interface ConfirmationController {
errorMessage: string | undefined;
}
-export function useConfirmationController({
- canCancelPending = false,
-}: { canCancelPending?: boolean } = {}): ConfirmationController {
+export function useConfirmationController(): ConfirmationController {
const [snapshot, send] = useMachine(confirmationMachine);
return {
@@ -83,8 +74,6 @@ export function useConfirmationController({
onOpenChange: open => {
if (open) {
send({ type: 'OPEN' });
- } else if (canCancelPending && snapshot.value === 'pending') {
- send({ type: 'CANCEL_REVERIFICATION' });
} else {
send({ type: 'CANCEL' });
}
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
index e4dc0a5c0f7..26ffafba9a4 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
@@ -1,3 +1,4 @@
+import { ClerkRuntimeError } from '@clerk/shared/error';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
@@ -31,7 +32,7 @@ describe('Confirmation', () => {
it.each(['active', 'retrying'] as const)('shows verification loading during %s', phase => {
renderBlock({ reverification: { status: 'loading', phase } });
- expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
});
@@ -43,18 +44,39 @@ describe('Confirmation', () => {
await user.keyboard('{Escape}');
expect(onOpenChange).not.toHaveBeenCalled();
- expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
});
it('shows reverification inside its existing dialog without typed confirmation', () => {
renderBlock({ reverification: { status: 'unavailable', phase: 'active', onCancel: vi.fn() } });
- expect(screen.getAllByRole('alertdialog')).toHaveLength(1);
+ expect(screen.getAllByRole('dialog')).toHaveLength(1);
expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
});
+ it.each(['Back', 'Close', 'Escape'])(
+ 'keeps controlled confirmation open after %s cancels verification',
+ async action => {
+ const onOpenChange = vi.fn();
+ const onCancel = vi.fn();
+ const user = userEvent.setup();
+ renderBlock({ onOpenChange, reverification: { status: 'unavailable', phase: 'active', onCancel } });
+
+ if (action === 'Escape') {
+ await user.keyboard('{Escape}');
+ } else {
+ await user.click(screen.getByRole('button', { name: action }));
+ }
+
+ expect(onCancel).toHaveBeenCalledOnce();
+ expect(onOpenChange).not.toHaveBeenCalled();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ },
+ );
+
it('renders nothing until the caller opens it', () => {
renderBlock({ open: false });
@@ -155,6 +177,9 @@ const removeButton = () => screen.getByRole('button', { name: 'Remove Preston Bo
describe('Confirmation with a handle', () => {
it.each(['resolve', 'reject'] as const)('ignores a stale %s after cancelling and reopening', async settlement => {
const pending = deferred();
+ const cancellation = deferred();
+ const onCancel = () =>
+ cancellation.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' }));
const user = userEvent.setup();
const handle = Confirmation.createHandle();
const view = (reverification: ReverificationController) => (
@@ -164,7 +189,7 @@ describe('Confirmation with a handle', () => {
title='Remove member'
description='Remove this member'
actionLabel='Remove'
- onConfirm={() => pending.promise}
+ onConfirm={() => Promise.race([pending.promise, cancellation.promise])}
reverification={reverification}
/>
@@ -172,10 +197,12 @@ describe('Confirmation with a handle', () => {
const { rerender } = render(view({ status: 'idle', phase: 'inactive' }));
act(() => handle.open(preston));
await user.click(confirmButton());
- rerender(view({ status: 'loading', phase: 'active', onCancel: vi.fn() }));
+ rerender(view({ status: 'loading', phase: 'active', onCancel }));
await user.keyboard('{Escape}');
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
rerender(view({ status: 'idle', phase: 'inactive' }));
+ await waitFor(() => expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true'));
+ await user.click(screen.getByRole('button', { name: 'Cancel' }));
+ await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
act(() => handle.open(preston));
await act(async () => {
@@ -218,7 +245,10 @@ describe('Confirmation with a handle', () => {
rerender(view({ status: 'loading', phase: 'retrying' }));
await user.keyboard('{Escape}');
- expect(screen.getByRole('alertdialog')).toBeInTheDocument();
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
expect(onConfirm).toHaveBeenCalledOnce();
@@ -241,11 +271,14 @@ describe('Confirmation with a handle', () => {
},
);
- it('keeps verification in the same dialog and cancels the pending challenge on Escape', async () => {
+ it.each(['Back', 'Close', 'Escape'])('returns to confirmation without an error from %s', async action => {
const user = userEvent.setup();
const handle = Confirmation.createHandle();
- const onCancel = vi.fn();
- const onConfirm = vi.fn(() => new Promise(() => {}));
+ const pending = deferred();
+ const onCancel = vi.fn(() =>
+ pending.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })),
+ );
+ const onConfirm = vi.fn(() => pending.promise);
const view = (verifying: boolean) => (
{
);
const { rerender } = render(view(false));
act(() => handle.open(preston));
- await user.click(screen.getByRole('button', { name: 'Remove' }));
+ await user.click(confirmButton());
const dialog = screen.getByRole('alertdialog');
rerender(view(true));
- expect(screen.getByRole('alertdialog')).toBe(dialog);
+ expect(screen.getByRole('dialog')).toBe(dialog);
expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
- await user.keyboard('{Escape}');
+ if (action === 'Escape') {
+ await user.keyboard('{Escape}');
+ } else {
+ await user.click(screen.getByRole('button', { name: action }));
+ }
expect(onCancel).toHaveBeenCalledOnce();
+ rerender(view(false));
+
+ await waitFor(() => expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true'));
+ expect(screen.getByRole('alertdialog')).toBe(dialog);
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus());
+ await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
});
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
index 511825041dc..0dfb6b3b004 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from 'react';
+import { type ReactNode, useEffect, useRef } from 'react';
import { Banner } from '../../components/banner';
import { Button, SubmitButton } from '../../components/button';
@@ -20,6 +20,7 @@ interface ConfirmationCardProps {
description: ReactNode;
actionLabel: string;
cancelLabel: string;
+ backLabel: string;
onConfirm: () => void;
isConfirming: boolean;
errorMessage: string | undefined;
@@ -33,12 +34,25 @@ function ConfirmationCard({
description,
actionLabel,
cancelLabel,
+ backLabel,
onConfirm,
isConfirming,
errorMessage,
reverification,
}: ConfirmationCardProps) {
const step = reverification && reverification.phase !== 'inactive' ? 'verify' : 'confirm';
+ const cancelRef = useRef(null);
+ const restoreConfirmationFocus = useRef(false);
+
+ useEffect(() => {
+ if (step === 'verify') {
+ restoreConfirmationFocus.current = true;
+ } else if (restoreConfirmationFocus.current && !isConfirming) {
+ restoreConfirmationFocus.current = false;
+ cancelRef.current?.focus({ preventScroll: true });
+ }
+ }, [step, isConfirming]);
+
const confirmation = (
<>
@@ -59,6 +73,7 @@ function ConfirmationCard({
@@ -99,6 +114,17 @@ function ConfirmationCard({
{confirmation}
+
+
+
>
)}
@@ -134,6 +160,7 @@ export interface ConfirmationControlledProps {
actionLabel: string;
/** Text of the cancel button (default: "Cancel") */
cancelLabel?: string;
+ backLabel?: string;
/** Callback when the action is confirmed */
onConfirm: () => void;
/** Whether the confirmed action is in progress */
@@ -152,6 +179,7 @@ function ControlledConfirmation({
description,
actionLabel,
cancelLabel = 'Cancel',
+ backLabel = 'Back',
onConfirm,
isConfirming = false,
errorMessage,
@@ -159,7 +187,7 @@ function ControlledConfirmation({
}: ConfirmationControlledProps) {
return (
{
if (!nextOpen && reverification?.phase === 'retrying') {
@@ -167,6 +195,7 @@ function ControlledConfirmation({
}
if (!nextOpen && reverification?.phase === 'active') {
reverification.onCancel?.();
+ return;
}
onOpenChange(nextOpen, details);
}}
@@ -179,6 +208,7 @@ function ControlledConfirmation({
description={description}
actionLabel={actionLabel}
cancelLabel={cancelLabel}
+ backLabel={backLabel}
onConfirm={onConfirm}
isConfirming={isConfirming}
errorMessage={errorMessage}
@@ -219,6 +249,7 @@ export interface ConfirmationHandleProps {
actionLabel: FromPayload;
/** Text of the cancel button (default: "Cancel") */
cancelLabel?: string;
+ backLabel?: string;
/** Runs the action for the payload. Resolve to close the dialog; reject with an `Error` to keep it open showing why */
onConfirm: (payload: Payload) => Promise | void;
}
@@ -231,19 +262,24 @@ function HandleConfirmation({
description,
actionLabel,
cancelLabel = 'Cancel',
+ backLabel = 'Back',
onConfirm,
reverification,
}: ConfirmationHandleProps) {
- const controller = useConfirmationController({ canCancelPending: reverification?.phase === 'active' });
+ const controller = useConfirmationController();
return (
{
+ if (!nextOpen && reverification?.phase === 'retrying') {
+ return;
+ }
if (!nextOpen && reverification?.phase === 'active') {
reverification.onCancel?.();
+ return;
}
controller.onOpenChange(nextOpen);
}}
@@ -257,6 +293,7 @@ function HandleConfirmation({
description={resolve(description, payload)}
actionLabel={resolve(actionLabel, payload)}
cancelLabel={cancelLabel}
+ backLabel={backLabel}
onConfirm={() =>
controller.onConfirm(async () => {
await onConfirm(payload);
@@ -279,9 +316,9 @@ export type ConfirmationProps = ConfirmationControlledProps |
* Use `Destructive` for the destructive actions that are. `color` sets the weight the confirming
* button carries: `negative` for what cannot be undone, `primary` for what can.
*
- * An `alertdialog`: it announces as an interruption, an outside press cannot answer it, and the
- * card withholds its corner dismiss. Escape still closes it, the way the cancel action does. Under
- * the phone band it arrives as a bottom sheet, within reach of the thumb that has to answer it.
+ * The confirmation is an `alertdialog` with Cancel as its dismiss action. Reverification uses
+ * a dialog with Back and corner dismiss actions that return to confirmation. Under the phone
+ * band it arrives as a bottom sheet.
*
* Two forms. Controlled: the caller owns `open`, `isConfirming`, and `errorMessage`, and the
* block holds nothing of its own. With a `handle`: the block owns all three. Mount it once,
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
index de2164b569a..4f667348644 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
@@ -424,7 +424,7 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
});
- it('closes the confirmation without an error when reverification is cancelled', async () => {
+ it('returns to confirmation without an error when reverification is cancelled', async () => {
externalAccounts = [
externalAccount({
id: 'idn_google',
@@ -438,7 +438,10 @@ describe('UserProfileConnectedAccountsSection', () => {
const dialog = await openRemoval(user, 'Google');
await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
+ await waitFor(() =>
+ expect(within(dialog).getByRole('button', { name: 'Remove' })).not.toHaveAttribute('aria-busy', 'true'),
+ );
+ expect(screen.getByRole('alertdialog')).toBe(dialog);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
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
index 3c9467d0453..45046415369 100644
--- 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
@@ -20,7 +20,7 @@ describe('connected accounts section', () => {
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.getAllByRole('dialog')).toHaveLength(1);
expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
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.view.tsx
index 3ed1d8bdc6a..1f44f13f219 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.view.tsx
@@ -104,6 +104,7 @@ export function UserProfileConnectedAccountsSectionView({
description={account => fill(m.removeDialog.description, { provider: account.provider })}
actionLabel={m.removeDialog.confirm}
cancelLabel={m.removeDialog.cancel}
+ backLabel={m.removeDialog.back}
finalFocus={removalFocus.finalFocus}
onConfirm={account => removalFocus.remove(account.id)}
/>
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.messages.ts
index 3af4d9907e0..4f476b8cd6a 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.messages.ts
@@ -12,6 +12,7 @@ export const userProfileConnectedAccountsMessages = {
'{provider} will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.',
confirm: 'Remove',
cancel: 'Cancel',
+ back: 'Back',
},
errors: {
generic: 'Something went wrong. Please try again.',
From 006f55d78ddbb943af140e17c77afec3cd138913 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 15:46:06 -0600
Subject: [PATCH 10/41] fix(mosaic): keep confirmation pending while
reverification loads
---
.../blocks/confirmation/confirmation.test.tsx | 23 ++++++++++++++-----
.../src/blocks/confirmation/confirmation.tsx | 15 +++++++++---
2 files changed, 29 insertions(+), 9 deletions(-)
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
index 26ffafba9a4..0cb7211bc55 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
@@ -29,17 +29,19 @@ function renderBlock(overrides: Partial = {}) {
const confirmButton = () => screen.getByRole('button', { name: 'Remove' });
describe('Confirmation', () => {
- it.each(['active', 'retrying'] as const)('shows verification loading during %s', phase => {
- renderBlock({ reverification: { status: 'loading', phase } });
+ it('keeps the confirmation pending while verification loads', () => {
+ renderBlock({ isConfirming: true, reverification: { status: 'loading', phase: 'active' } });
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
+ expect(screen.getByRole('alertdialog')).toHaveAccessibleName('Remove connected account');
+ expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
+ expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
});
it('ignores Escape while the verified action is retrying', async () => {
const onOpenChange = vi.fn();
const user = userEvent.setup();
- renderBlock({ onOpenChange, isConfirming: true, reverification: { status: 'loading', phase: 'retrying' } });
+ renderBlock({ onOpenChange, isConfirming: true, reverification: { status: 'unavailable', phase: 'retrying' } });
await user.keyboard('{Escape}');
@@ -242,7 +244,16 @@ describe('Confirmation with a handle', () => {
act(() => handle.open(preston));
await user.click(confirmButton());
rerender(view({ status: 'loading', phase: 'active', onCancel: vi.fn() }));
- rerender(view({ status: 'loading', phase: 'retrying' }));
+ const dialog = screen.getByRole('alertdialog');
+ expect(dialog).toHaveAccessibleName('Remove member');
+ expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
+ expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
+ rerender(view({ status: 'unavailable', phase: 'active', onCancel: vi.fn() }));
+ expect(screen.getByRole('dialog')).toBe(dialog);
+ expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
+ rerender(view({ status: 'unavailable', phase: 'retrying' }));
await user.keyboard('{Escape}');
expect(screen.getByRole('dialog')).toBeInTheDocument();
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
index 0dfb6b3b004..153f542a686 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
@@ -40,7 +40,8 @@ function ConfirmationCard({
errorMessage,
reverification,
}: ConfirmationCardProps) {
- const step = reverification && reverification.phase !== 'inactive' ? 'verify' : 'confirm';
+ const step =
+ reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm';
const cancelRef = useRef(null);
const restoreConfirmationFocus = useRef(false);
@@ -187,7 +188,11 @@ function ControlledConfirmation({
}: ConfirmationControlledProps) {
return (
{
if (!nextOpen && reverification?.phase === 'retrying') {
@@ -270,7 +275,11 @@ function HandleConfirmation({
return (
{
From 6a6574dbdaed1f000ce4807c21e8096534176306 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Mon, 28 Sep 2026 16:00:00 -0600
Subject: [PATCH 11/41] fix(mosaic): wait for standalone reverification to load
---
.../__tests__/reverification.dialog.test.tsx | 36 +++++++++++++++++++
.../reverification/reverification.dialog.tsx | 2 +-
2 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
index ba3fae752ca..7518c728217 100644
--- a/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
+++ b/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
@@ -41,6 +41,42 @@ describe('ReverificationDialog', () => {
expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1);
});
+ it('waits for the challenge to load before opening', () => {
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('dialog', { name: 'Verification required' })).toBeInTheDocument();
+ expect(screen.getByLabelText('Password')).toBeInTheDocument();
+ });
+
+ it('opens when verification is unavailable', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
+ });
+
it('cancels the active challenge when dismissed', async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
diff --git a/packages/mosaic/src/features/reverification/reverification.dialog.tsx b/packages/mosaic/src/features/reverification/reverification.dialog.tsx
index a24df055fbf..90dc4b1cb1b 100644
--- a/packages/mosaic/src/features/reverification/reverification.dialog.tsx
+++ b/packages/mosaic/src/features/reverification/reverification.dialog.tsx
@@ -9,7 +9,7 @@ export function ReverificationDialog({
}: ReverificationController & { finalFocus?: DialogFocusTarget }) {
return (
{
if (!open && controller.phase === 'active') {
controller.onCancel?.();
From f9c75aa2158ebede50d2b70b5d0cc17ae3d347ec Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 29 Sep 2026 09:18:30 -0600
Subject: [PATCH 12/41] refactor(mosaic): compose confirmation reverification
in the feature
---
.../confirmation/confirmation.controller.ts | 6 +-
.../blocks/confirmation/confirmation.test.tsx | 62 ++++++++----
.../src/blocks/confirmation/confirmation.tsx | 95 ++++++-------------
.../__tests__/reverification.dialog.test.tsx | 25 ++---
.../reverification-confirmation.tsx | 68 +++++++++++++
.../reverification/reverification.dialog.tsx | 4 +-
...e-connected-accounts-section.view.test.tsx | 2 +-
...rofile-connected-accounts-section.view.tsx | 3 +-
8 files changed, 153 insertions(+), 112 deletions(-)
create mode 100644 packages/mosaic/src/features/reverification/reverification-confirmation.tsx
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
index 7b9e8bbddd5..f7875349370 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
@@ -66,7 +66,9 @@ export interface ConfirmationController {
errorMessage: string | undefined;
}
-export function useConfirmationController(): ConfirmationController {
+export function useConfirmationController({
+ onPendingCancel,
+}: { onPendingCancel?: () => void } = {}): ConfirmationController {
const [snapshot, send] = useMachine(confirmationMachine);
return {
@@ -74,6 +76,8 @@ export function useConfirmationController(): ConfirmationController {
onOpenChange: open => {
if (open) {
send({ type: 'OPEN' });
+ } else if (snapshot.value === 'pending') {
+ onPendingCancel?.();
} else {
send({ type: 'CANCEL' });
}
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
index 0cb7211bc55..d84877033f6 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
@@ -5,15 +5,37 @@ import { describe, expect, it, vi } from 'vitest';
import { Button } from '../../components/button';
import type { ReverificationController } from '../../features/reverification';
+import { ReverificationConfirmation } from '../../features/reverification/reverification-confirmation';
import { deferred } from '../../machines/__tests__/test-utils';
import { MosaicProvider } from '../../mosaic-provider';
import type { ConfirmationControlledProps, ConfirmationHandleProps } from './confirmation';
import { Confirmation } from './confirmation';
-function renderBlock(overrides: Partial = {}) {
+function retrying(): ReverificationController {
+ return {
+ status: 'retrying',
+ step: 'password',
+ value: '',
+ isPending: true,
+ onValueChange: vi.fn(),
+ onSubmit: vi.fn(),
+ onShowMethods: vi.fn(),
+ onShowHelp: vi.fn(),
+ onBack: vi.fn(),
+ onEmailSupport: vi.fn(),
+ onResend: vi.fn(),
+ canResend: true,
+ methods: [],
+ onSelectMethod: vi.fn(),
+ };
+}
+
+function renderBlock(
+ overrides: Partial & { reverification?: ReverificationController } = {},
+) {
return render(
- screen.getByRole('button', { name: 'Remove' });
describe('Confirmation', () => {
it('keeps the confirmation pending while verification loads', () => {
- renderBlock({ isConfirming: true, reverification: { status: 'loading', phase: 'active' } });
+ renderBlock({ isConfirming: true, reverification: { status: 'loading' } });
expect(screen.getByRole('alertdialog')).toHaveAccessibleName('Remove connected account');
expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
@@ -41,7 +63,7 @@ describe('Confirmation', () => {
it('ignores Escape while the verified action is retrying', async () => {
const onOpenChange = vi.fn();
const user = userEvent.setup();
- renderBlock({ onOpenChange, isConfirming: true, reverification: { status: 'unavailable', phase: 'retrying' } });
+ renderBlock({ onOpenChange, isConfirming: true, reverification: retrying() });
await user.keyboard('{Escape}');
@@ -51,7 +73,7 @@ describe('Confirmation', () => {
});
it('shows reverification inside its existing dialog without typed confirmation', () => {
- renderBlock({ reverification: { status: 'unavailable', phase: 'active', onCancel: vi.fn() } });
+ renderBlock({ reverification: { status: 'unavailable', onCancel: vi.fn() } });
expect(screen.getAllByRole('dialog')).toHaveLength(1);
expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
@@ -65,7 +87,7 @@ describe('Confirmation', () => {
const onOpenChange = vi.fn();
const onCancel = vi.fn();
const user = userEvent.setup();
- renderBlock({ onOpenChange, reverification: { status: 'unavailable', phase: 'active', onCancel } });
+ renderBlock({ onOpenChange, reverification: { status: 'unavailable', onCancel } });
if (action === 'Escape') {
await user.keyboard('{Escape}');
@@ -158,7 +180,7 @@ function renderWithHandle(onConfirm: ConfirmationHandleProps['onConfirm'
const handle = Confirmation.createHandle();
render(
- (
@@ -186,7 +208,7 @@ describe('Confirmation with a handle', () => {
const handle = Confirmation.createHandle();
const view = (reverification: ReverificationController) => (
- {
/>
);
- const { rerender } = render(view({ status: 'idle', phase: 'inactive' }));
+ const { rerender } = render(view({ status: 'idle' }));
act(() => handle.open(preston));
await user.click(confirmButton());
- rerender(view({ status: 'loading', phase: 'active', onCancel }));
+ rerender(view({ status: 'loading', onCancel }));
await user.keyboard('{Escape}');
- rerender(view({ status: 'idle', phase: 'inactive' }));
+ rerender(view({ status: 'idle' }));
await waitFor(() => expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true'));
await user.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
@@ -230,7 +252,7 @@ describe('Confirmation with a handle', () => {
const onConfirm = vi.fn(() => pending.promise);
const view = (reverification: ReverificationController) => (
- {
/>
);
- const { rerender } = render(view({ status: 'idle', phase: 'inactive' }));
+ const { rerender } = render(view({ status: 'idle' }));
act(() => handle.open(preston));
await user.click(confirmButton());
- rerender(view({ status: 'loading', phase: 'active', onCancel: vi.fn() }));
+ rerender(view({ status: 'loading', onCancel: vi.fn() }));
const dialog = screen.getByRole('alertdialog');
expect(dialog).toHaveAccessibleName('Remove member');
expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
- rerender(view({ status: 'unavailable', phase: 'active', onCancel: vi.fn() }));
+ rerender(view({ status: 'unavailable', onCancel: vi.fn() }));
expect(screen.getByRole('dialog')).toBe(dialog);
expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
- rerender(view({ status: 'unavailable', phase: 'retrying' }));
+ rerender(view(retrying()));
await user.keyboard('{Escape}');
expect(screen.getByRole('dialog')).toBeInTheDocument();
@@ -271,7 +293,7 @@ describe('Confirmation with a handle', () => {
}
await pending.promise.catch(() => undefined);
});
- rerender(view({ status: 'idle', phase: 'inactive' }));
+ rerender(view({ status: 'idle' }));
if (settlement === 'resolve') {
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
@@ -292,15 +314,13 @@ describe('Confirmation with a handle', () => {
const onConfirm = vi.fn(() => pending.promise);
const view = (verifying: boolean) => (
-
);
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
index 153f542a686..a07a613ae5b 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
@@ -6,7 +6,6 @@ import { Card } from '../../components/card';
import type { DialogFocusTarget, DialogHandle, DialogRootProps, DialogTriggerProps } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
import { Flow } from '../../components/flow';
-import { Reverification, type ReverificationController } from '../../features/reverification';
import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload';
import { useConfirmationController } from './confirmation.controller';
@@ -20,11 +19,11 @@ interface ConfirmationCardProps {
description: ReactNode;
actionLabel: string;
cancelLabel: string;
- backLabel: string;
onConfirm: () => void;
isConfirming: boolean;
errorMessage: string | undefined;
- reverification?: ReverificationController;
+ step?: 'confirm' | 'verify';
+ verificationSlot?: ReactNode;
}
function ConfirmationCard({
@@ -34,14 +33,12 @@ function ConfirmationCard({
description,
actionLabel,
cancelLabel,
- backLabel,
onConfirm,
isConfirming,
errorMessage,
- reverification,
+ step = 'confirm',
+ verificationSlot,
}: ConfirmationCardProps) {
- const step =
- reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm';
const cancelRef = useRef(null);
const restoreConfirmationFocus = useRef(false);
@@ -104,7 +101,7 @@ function ConfirmationCard({
elevation='overlay'
renderBranding={false}
>
- {reverification ? (
+ {verificationSlot ? (
(
<>
{confirmation}
-
-
-
-
-
-
+ {verificationSlot}
>
)}
@@ -139,7 +123,8 @@ function ConfirmationCard({
}
export interface ConfirmationControlledProps {
- reverification?: ReverificationController;
+ step?: 'confirm' | 'verify';
+ verificationSlot?: ReactNode;
/** Whether the dialog is open */
open: boolean;
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
@@ -161,7 +146,6 @@ export interface ConfirmationControlledProps {
actionLabel: string;
/** Text of the cancel button (default: "Cancel") */
cancelLabel?: string;
- backLabel?: string;
/** Callback when the action is confirmed */
onConfirm: () => void;
/** Whether the confirmed action is in progress */
@@ -180,30 +164,17 @@ function ControlledConfirmation({
description,
actionLabel,
cancelLabel = 'Cancel',
- backLabel = 'Back',
onConfirm,
isConfirming = false,
errorMessage,
- reverification,
+ step = 'confirm',
+ verificationSlot,
}: ConfirmationControlledProps) {
return (
{
- if (!nextOpen && reverification?.phase === 'retrying') {
- return;
- }
- if (!nextOpen && reverification?.phase === 'active') {
- reverification.onCancel?.();
- return;
- }
- onOpenChange(nextOpen, details);
- }}
+ onOpenChange={onOpenChange}
>
{trigger ? : null}
);
@@ -236,7 +207,9 @@ function createHandle(): ConfirmationHandle {
}
export interface ConfirmationHandleProps {
- reverification?: ReverificationController;
+ onPendingCancel?: () => void;
+ step?: 'confirm' | 'verify';
+ verificationSlot?: ReactNode;
/** Opens the dialog with a payload from anywhere: `handle.open(payload)` */
handle: ConfirmationHandle;
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
@@ -254,12 +227,12 @@ export interface ConfirmationHandleProps {
actionLabel: FromPayload;
/** Text of the cancel button (default: "Cancel") */
cancelLabel?: string;
- backLabel?: string;
/** Runs the action for the payload. Resolve to close the dialog; reject with an `Error` to keep it open showing why */
onConfirm: (payload: Payload) => Promise | void;
}
function HandleConfirmation({
+ onPendingCancel,
handle,
color = 'negative',
finalFocus,
@@ -267,31 +240,18 @@ function HandleConfirmation({
description,
actionLabel,
cancelLabel = 'Cancel',
- backLabel = 'Back',
onConfirm,
- reverification,
+ step = 'confirm',
+ verificationSlot,
}: ConfirmationHandleProps) {
- const controller = useConfirmationController();
+ const controller = useConfirmationController({ onPendingCancel });
return (
{
- if (!nextOpen && reverification?.phase === 'retrying') {
- return;
- }
- if (!nextOpen && reverification?.phase === 'active') {
- reverification.onCancel?.();
- return;
- }
- controller.onOpenChange(nextOpen);
- }}
+ onOpenChange={controller.onOpenChange}
>
{({ payload }) =>
payload === undefined ? null : (
@@ -302,7 +262,6 @@ function HandleConfirmation({
description={resolve(description, payload)}
actionLabel={resolve(actionLabel, payload)}
cancelLabel={cancelLabel}
- backLabel={backLabel}
onConfirm={() =>
controller.onConfirm(async () => {
await onConfirm(payload);
@@ -310,7 +269,8 @@ function HandleConfirmation({
}
isConfirming={controller.isConfirming}
errorMessage={controller.errorMessage}
- reverification={reverification}
+ step={step}
+ verificationSlot={verificationSlot}
/>
)
}
@@ -325,9 +285,8 @@ export type ConfirmationProps = ConfirmationControlledProps |
* Use `Destructive` for the destructive actions that are. `color` sets the weight the confirming
* button carries: `negative` for what cannot be undone, `primary` for what can.
*
- * The confirmation is an `alertdialog` with Cancel as its dismiss action. Reverification uses
- * a dialog with Back and corner dismiss actions that return to confirmation. Under the phone
- * band it arrives as a bottom sheet.
+ * The confirmation is an `alertdialog` with Cancel as its dismiss action. A verification slot
+ * uses a dialog. Under the phone band it arrives as a bottom sheet.
*
* Two forms. Controlled: the caller owns `open`, `isConfirming`, and `errorMessage`, and the
* block holds nothing of its own. With a `handle`: the block owns all three. Mount it once,
diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
index 7518c728217..a9d2aa284e5 100644
--- a/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
+++ b/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
@@ -8,10 +8,8 @@ import type { ReverificationController } from '../reverification.controller';
import { ReverificationDialog } from '../reverification.dialog';
function ready(phase: 'active' | 'retrying', onCancel = vi.fn()): ReverificationController {
- return {
- status: 'ready',
- phase,
- step: 'password',
+ const view = {
+ step: 'password' as const,
value: '',
onValueChange: vi.fn(),
isPending: phase === 'retrying',
@@ -24,8 +22,8 @@ function ready(phase: 'active' | 'retrying', onCancel = vi.fn()): Reverification
canResend: true,
methods: [],
onSelectMethod: vi.fn(),
- onCancel,
};
+ return phase === 'retrying' ? { ...view, status: 'retrying' } : { ...view, status: 'ready', onCancel };
}
describe('ReverificationDialog', () => {
@@ -44,10 +42,7 @@ describe('ReverificationDialog', () => {
it('waits for the challenge to load before opening', () => {
const { rerender } = render(
-
+
,
);
@@ -66,10 +61,7 @@ describe('ReverificationDialog', () => {
it('opens when verification is unavailable', () => {
render(
-
+
,
);
@@ -109,10 +101,7 @@ describe('ReverificationDialog', () => {
it('stays closed when the flow is inactive', () => {
render(
-
+
,
);
@@ -126,7 +115,7 @@ describe('ReverificationDialog', () => {
const [phase, setPhase] = useState<'inactive' | 'active'>('inactive');
const actionRef = useRef(null);
const controller: ReverificationController =
- phase === 'active' ? ready('active', () => setPhase('inactive')) : { status: 'idle', phase: 'inactive' };
+ phase === 'active' ? ready('active', () => setPhase('inactive')) : { status: 'idle', onCancel: undefined };
return (
<>
diff --git a/packages/mosaic/src/features/reverification/reverification-confirmation.tsx b/packages/mosaic/src/features/reverification/reverification-confirmation.tsx
new file mode 100644
index 00000000000..4788d4280de
--- /dev/null
+++ b/packages/mosaic/src/features/reverification/reverification-confirmation.tsx
@@ -0,0 +1,68 @@
+import {
+ Confirmation,
+ type ConfirmationControlledProps,
+ type ConfirmationHandleProps,
+} from '../../blocks/confirmation';
+import { Button } from '../../components/button';
+import { Card } from '../../components/card';
+import { Reverification } from './reverification';
+import type { ReverificationController } from './reverification.controller';
+
+type ReverificationConfirmationProps = (
+ | Omit
+ | Omit, 'step' | 'verificationSlot' | 'onPendingCancel'>
+) & {
+ reverification?: ReverificationController;
+ backLabel?: string;
+};
+
+export function ReverificationConfirmation({
+ reverification,
+ backLabel = 'Back',
+ ...props
+}: ReverificationConfirmationProps) {
+ const step =
+ reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm';
+ const verificationSlot = reverification ? (
+ <>
+
+
+
+
+ >
+ ) : undefined;
+
+ if ('handle' in props) {
+ return (
+
+ );
+ }
+
+ return (
+ {
+ if (!open && reverification && reverification.status !== 'idle') {
+ reverification.onCancel?.();
+ return;
+ }
+ props.onOpenChange(open, details);
+ }}
+ />
+ );
+}
diff --git a/packages/mosaic/src/features/reverification/reverification.dialog.tsx b/packages/mosaic/src/features/reverification/reverification.dialog.tsx
index 90dc4b1cb1b..48c5ca587cd 100644
--- a/packages/mosaic/src/features/reverification/reverification.dialog.tsx
+++ b/packages/mosaic/src/features/reverification/reverification.dialog.tsx
@@ -9,9 +9,9 @@ export function ReverificationDialog({
}: ReverificationController & { finalFocus?: DialogFocusTarget }) {
return (
{
- if (!open && controller.phase === 'active') {
+ if (!open) {
controller.onCancel?.();
}
}}
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
index 45046415369..14ab0583b06 100644
--- 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
@@ -13,7 +13,7 @@ describe('connected accounts section', () => {
,
);
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.view.tsx
index 1f44f13f219..2856fd07828 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.view.tsx
@@ -5,6 +5,7 @@ import { Section } from '../../components/section';
import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
import { fill, useMessages } from '../../localization';
import type { ReverificationController } from '../reverification';
+import { ReverificationConfirmation } from '../reverification/reverification-confirmation';
import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view';
export interface UserProfileConnectionProvider {
@@ -97,7 +98,7 @@ export function UserProfileConnectedAccountsSectionView({
) : null}
{onRemove ? (
-
Date: Tue, 29 Sep 2026 09:37:16 -0600
Subject: [PATCH 13/41] refactor(mosaic): defer connected account
reverification
---
.../confirmation/confirmation.controller.ts | 31 +--
.../blocks/confirmation/confirmation.test.tsx | 233 +-----------------
.../src/blocks/confirmation/confirmation.tsx | 138 ++++-------
.../__tests__/reverification.dialog.test.tsx | 153 ------------
.../reverification-confirmation.tsx | 68 -----
.../reverification/reverification.dialog.tsx | 32 ---
...cted-accounts-section.integration.test.tsx | 39 ++-
...e-connected-accounts-section.view.test.tsx | 7 +-
...rofile-connected-accounts-section.view.tsx | 8 +-
...e-connected-accounts-section.controller.ts | 7 +-
...ser-profile-connected-accounts-section.tsx | 52 +---
...ser-profile-connected-accounts.messages.ts | 1 -
12 files changed, 97 insertions(+), 672 deletions(-)
delete mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
delete mode 100644 packages/mosaic/src/features/reverification/reverification-confirmation.tsx
delete mode 100644 packages/mosaic/src/features/reverification/reverification.dialog.tsx
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
index f7875349370..be5c6200f4c 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts
@@ -1,5 +1,3 @@
-import { isReverificationCancelledError } from '@clerk/shared/error';
-
import { setup } from '../../machine/setup';
import { useMachine } from '../../machine/use-machine';
@@ -42,17 +40,10 @@ export const confirmationMachine = createMachine({
pending: {
invoke: fromPromise(context => context.run(), {
onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) },
- onError: [
- {
- guard: (_, event) => isReverificationCancelledError(event.error),
- target: 'confirming',
- actions: assign(() => ({ error: undefined })),
- },
- {
- target: 'confirming',
- actions: assign((_, event) => ({ error: toMessage(event.error) })),
- },
- ],
+ onError: {
+ target: 'confirming',
+ actions: assign((_, event) => ({ error: toMessage(event.error) })),
+ },
}),
},
},
@@ -66,22 +57,12 @@ export interface ConfirmationController {
errorMessage: string | undefined;
}
-export function useConfirmationController({
- onPendingCancel,
-}: { onPendingCancel?: () => void } = {}): ConfirmationController {
+export function useConfirmationController(): ConfirmationController {
const [snapshot, send] = useMachine(confirmationMachine);
return {
isOpen: snapshot.value === 'confirming' || snapshot.value === 'pending',
- onOpenChange: open => {
- if (open) {
- send({ type: 'OPEN' });
- } else if (snapshot.value === 'pending') {
- onPendingCancel?.();
- } else {
- send({ type: 'CANCEL' });
- }
- },
+ onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }),
onConfirm: run => send({ type: 'CONFIRM', run }),
isConfirming: snapshot.value === 'pending',
errorMessage: snapshot.context.error,
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
index d84877033f6..cd341740ffa 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.test.tsx
@@ -1,41 +1,16 @@
-import { ClerkRuntimeError } from '@clerk/shared/error';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from '../../components/button';
-import type { ReverificationController } from '../../features/reverification';
-import { ReverificationConfirmation } from '../../features/reverification/reverification-confirmation';
-import { deferred } from '../../machines/__tests__/test-utils';
import { MosaicProvider } from '../../mosaic-provider';
import type { ConfirmationControlledProps, ConfirmationHandleProps } from './confirmation';
import { Confirmation } from './confirmation';
-function retrying(): ReverificationController {
- return {
- status: 'retrying',
- step: 'password',
- value: '',
- isPending: true,
- onValueChange: vi.fn(),
- onSubmit: vi.fn(),
- onShowMethods: vi.fn(),
- onShowHelp: vi.fn(),
- onBack: vi.fn(),
- onEmailSupport: vi.fn(),
- onResend: vi.fn(),
- canResend: true,
- methods: [],
- onSelectMethod: vi.fn(),
- };
-}
-
-function renderBlock(
- overrides: Partial & { reverification?: ReverificationController } = {},
-) {
+function renderBlock(overrides: Partial = {}) {
return render(
- screen.getByRole('button', { name: 'Remove' });
describe('Confirmation', () => {
- it('keeps the confirmation pending while verification loads', () => {
- renderBlock({ isConfirming: true, reverification: { status: 'loading' } });
-
- expect(screen.getByRole('alertdialog')).toHaveAccessibleName('Remove connected account');
- expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
- expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
- });
-
- it('ignores Escape while the verified action is retrying', async () => {
- const onOpenChange = vi.fn();
- const user = userEvent.setup();
- renderBlock({ onOpenChange, isConfirming: true, reverification: retrying() });
-
- await user.keyboard('{Escape}');
-
- expect(onOpenChange).not.toHaveBeenCalled();
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
- });
-
- it('shows reverification inside its existing dialog without typed confirmation', () => {
- renderBlock({ reverification: { status: 'unavailable', onCancel: vi.fn() } });
-
- expect(screen.getAllByRole('dialog')).toHaveLength(1);
- expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
- expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
- });
-
- it.each(['Back', 'Close', 'Escape'])(
- 'keeps controlled confirmation open after %s cancels verification',
- async action => {
- const onOpenChange = vi.fn();
- const onCancel = vi.fn();
- const user = userEvent.setup();
- renderBlock({ onOpenChange, reverification: { status: 'unavailable', onCancel } });
-
- if (action === 'Escape') {
- await user.keyboard('{Escape}');
- } else {
- await user.click(screen.getByRole('button', { name: action }));
- }
-
- expect(onCancel).toHaveBeenCalledOnce();
- expect(onOpenChange).not.toHaveBeenCalled();
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- },
- );
-
it('renders nothing until the caller opens it', () => {
renderBlock({ open: false });
@@ -180,7 +105,7 @@ function renderWithHandle(onConfirm: ConfirmationHandleProps['onConfirm'
const handle = Confirmation.createHandle();
render(
- (
@@ -199,158 +124,6 @@ function renderWithHandle(onConfirm: ConfirmationHandleProps['onConfirm'
const removeButton = () => screen.getByRole('button', { name: 'Remove Preston Booth' });
describe('Confirmation with a handle', () => {
- it.each(['resolve', 'reject'] as const)('ignores a stale %s after cancelling and reopening', async settlement => {
- const pending = deferred();
- const cancellation = deferred();
- const onCancel = () =>
- cancellation.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' }));
- const user = userEvent.setup();
- const handle = Confirmation.createHandle();
- const view = (reverification: ReverificationController) => (
-
- Promise.race([pending.promise, cancellation.promise])}
- reverification={reverification}
- />
-
- );
- const { rerender } = render(view({ status: 'idle' }));
- act(() => handle.open(preston));
- await user.click(confirmButton());
- rerender(view({ status: 'loading', onCancel }));
- await user.keyboard('{Escape}');
- rerender(view({ status: 'idle' }));
- await waitFor(() => expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true'));
- await user.click(screen.getByRole('button', { name: 'Cancel' }));
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- act(() => handle.open(preston));
-
- await act(async () => {
- if (settlement === 'resolve') {
- pending.resolve();
- } else {
- pending.reject(new Error('Old operation failed'));
- }
- await pending.promise.catch(() => undefined);
- });
-
- expect(screen.getByRole('alertdialog')).toBeInTheDocument();
- expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true');
- });
-
- it.each(['resolve', 'reject'] as const)(
- 'stays pending through verification until the action %ss',
- async settlement => {
- const pending = deferred();
- const user = userEvent.setup();
- const handle = Confirmation.createHandle();
- const onConfirm = vi.fn(() => pending.promise);
- const view = (reverification: ReverificationController) => (
-
-
-
- );
- const { rerender } = render(view({ status: 'idle' }));
- act(() => handle.open(preston));
- await user.click(confirmButton());
- rerender(view({ status: 'loading', onCancel: vi.fn() }));
- const dialog = screen.getByRole('alertdialog');
- expect(dialog).toHaveAccessibleName('Remove member');
- expect(confirmButton()).toHaveAttribute('aria-busy', 'true');
- expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
- rerender(view({ status: 'unavailable', onCancel: vi.fn() }));
- expect(screen.getByRole('dialog')).toBe(dialog);
- expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
- rerender(view(retrying()));
-
- await user.keyboard('{Escape}');
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled();
- await user.click(screen.getByRole('button', { name: 'Close' }));
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
- expect(onConfirm).toHaveBeenCalledOnce();
-
- await act(async () => {
- if (settlement === 'resolve') {
- pending.resolve();
- } else {
- pending.reject(new Error('Removal failed'));
- }
- await pending.promise.catch(() => undefined);
- });
- rerender(view({ status: 'idle' }));
-
- if (settlement === 'resolve') {
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- } else {
- expect(screen.getByRole('alert')).toHaveTextContent('Removal failed');
- expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true');
- }
- },
- );
-
- it.each(['Back', 'Close', 'Escape'])('returns to confirmation without an error from %s', async action => {
- const user = userEvent.setup();
- const handle = Confirmation.createHandle();
- const pending = deferred();
- const onCancel = vi.fn(() =>
- pending.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })),
- );
- const onConfirm = vi.fn(() => pending.promise);
- const view = (verifying: boolean) => (
-
-
-
- );
- const { rerender } = render(view(false));
- act(() => handle.open(preston));
- await user.click(confirmButton());
- const dialog = screen.getByRole('alertdialog');
-
- rerender(view(true));
- expect(screen.getByRole('dialog')).toBe(dialog);
- expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
- expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
-
- if (action === 'Escape') {
- await user.keyboard('{Escape}');
- } else {
- await user.click(screen.getByRole('button', { name: action }));
- }
- expect(onCancel).toHaveBeenCalledOnce();
- rerender(view(false));
-
- await waitFor(() => expect(confirmButton()).not.toHaveAttribute('aria-busy', 'true'));
- expect(screen.getByRole('alertdialog')).toBe(dialog);
- expect(screen.queryByRole('alert')).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
- await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus());
- await user.keyboard('{Escape}');
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
-
it('renders nothing until opened through the handle', () => {
renderWithHandle();
diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.tsx b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
index a07a613ae5b..ed56c3f75ae 100644
--- a/packages/mosaic/src/blocks/confirmation/confirmation.tsx
+++ b/packages/mosaic/src/blocks/confirmation/confirmation.tsx
@@ -1,11 +1,10 @@
-import { type ReactNode, useEffect, useRef } from 'react';
+import type { ReactNode } from 'react';
import { Banner } from '../../components/banner';
import { Button, SubmitButton } from '../../components/button';
import { Card } from '../../components/card';
-import type { DialogFocusTarget, DialogHandle, DialogRootProps, DialogTriggerProps } from '../../components/dialog';
+import type { DialogFocusTarget, DialogHandle, DialogTriggerProps } from '../../components/dialog';
import { Dialog } from '../../components/dialog';
-import { Flow } from '../../components/flow';
import { type FromPayload, resolveFromPayload as resolve } from '../../utils/resolve-from-payload';
import { useConfirmationController } from './confirmation.controller';
@@ -22,8 +21,6 @@ interface ConfirmationCardProps {
onConfirm: () => void;
isConfirming: boolean;
errorMessage: string | undefined;
- step?: 'confirm' | 'verify';
- verificationSlot?: ReactNode;
}
function ConfirmationCard({
@@ -36,62 +33,7 @@ function ConfirmationCard({
onConfirm,
isConfirming,
errorMessage,
- step = 'confirm',
- verificationSlot,
}: ConfirmationCardProps) {
- const cancelRef = useRef(null);
- const restoreConfirmationFocus = useRef(false);
-
- useEffect(() => {
- if (step === 'verify') {
- restoreConfirmationFocus.current = true;
- } else if (restoreConfirmationFocus.current && !isConfirming) {
- restoreConfirmationFocus.current = false;
- cancelRef.current?.focus({ preventScroll: true });
- }
- }, [step, isConfirming]);
-
- const confirmation = (
- <>
-
- {title}
- {description}
-
- {errorMessage ? (
-
-
- {errorMessage}
-
-
- ) : null}
-
-
- {cancelLabel}
-
- }
- />
-
- {actionLabel}
-
-
- >
- );
-
return (
- {verificationSlot ? (
-
+ {title}
+ {description}
+
+ {errorMessage ? (
+
+
+ {errorMessage}
+
+
+ ) : null}
+
+
+ {cancelLabel}
+
+ }
+ />
+
- {() => (
- <>
- {confirmation}
- {verificationSlot}
- >
- )}
-
- ) : (
- confirmation
- )}
+ {actionLabel}
+
+
);
}
export interface ConfirmationControlledProps {
- step?: 'confirm' | 'verify';
- verificationSlot?: ReactNode;
/** Whether the dialog is open */
open: boolean;
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
@@ -135,7 +94,7 @@ export interface ConfirmationControlledProps {
*/
finalFocus?: DialogFocusTarget;
/** Callback when open state changes */
- onOpenChange: NonNullable;
+ onOpenChange: (open: boolean) => void;
/** Element that opens the dialog */
trigger?: DialogTriggerProps['render'];
/** Dialog heading */
@@ -167,12 +126,10 @@ function ControlledConfirmation({
onConfirm,
isConfirming = false,
errorMessage,
- step = 'confirm',
- verificationSlot,
}: ConfirmationControlledProps) {
return (
@@ -187,8 +144,6 @@ function ControlledConfirmation({
onConfirm={onConfirm}
isConfirming={isConfirming}
errorMessage={errorMessage}
- step={step}
- verificationSlot={verificationSlot}
/>
);
@@ -207,9 +162,6 @@ function createHandle(): ConfirmationHandle {
}
export interface ConfirmationHandleProps {
- onPendingCancel?: () => void;
- step?: 'confirm' | 'verify';
- verificationSlot?: ReactNode;
/** Opens the dialog with a payload from anywhere: `handle.open(payload)` */
handle: ConfirmationHandle;
/** The weight the confirming button carries. An action that can be undone takes `primary` (default: `negative`) */
@@ -232,7 +184,6 @@ export interface ConfirmationHandleProps {
}
function HandleConfirmation({
- onPendingCancel,
handle,
color = 'negative',
finalFocus,
@@ -241,14 +192,12 @@ function HandleConfirmation({
actionLabel,
cancelLabel = 'Cancel',
onConfirm,
- step = 'confirm',
- verificationSlot,
}: ConfirmationHandleProps) {
- const controller = useConfirmationController({ onPendingCancel });
+ const controller = useConfirmationController();
return (
({
}
isConfirming={controller.isConfirming}
errorMessage={controller.errorMessage}
- step={step}
- verificationSlot={verificationSlot}
/>
)
}
@@ -285,8 +232,9 @@ export type ConfirmationProps = ConfirmationControlledProps |
* Use `Destructive` for the destructive actions that are. `color` sets the weight the confirming
* button carries: `negative` for what cannot be undone, `primary` for what can.
*
- * The confirmation is an `alertdialog` with Cancel as its dismiss action. A verification slot
- * uses a dialog. Under the phone band it arrives as a bottom sheet.
+ * An `alertdialog`: it announces as an interruption, an outside press cannot answer it, and the
+ * card withholds its corner dismiss. Escape still closes it, the way the cancel action does. Under
+ * the phone band it arrives as a bottom sheet, within reach of the thumb that has to answer it.
*
* Two forms. Controlled: the caller owns `open`, `isConfirming`, and `errorMessage`, and the
* block holds nothing of its own. With a `handle`: the block owns all three. Mount it once,
diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx b/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
deleted file mode 100644
index a9d2aa284e5..00000000000
--- a/packages/mosaic/src/features/reverification/__tests__/reverification.dialog.test.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { useRef, useState } from 'react';
-import { describe, expect, it, vi } from 'vitest';
-
-import { MosaicProvider } from '../../../MosaicProvider';
-import type { ReverificationController } from '../reverification.controller';
-import { ReverificationDialog } from '../reverification.dialog';
-
-function ready(phase: 'active' | 'retrying', onCancel = vi.fn()): ReverificationController {
- const view = {
- step: 'password' as const,
- value: '',
- onValueChange: vi.fn(),
- isPending: phase === 'retrying',
- onSubmit: vi.fn(),
- onShowMethods: vi.fn(),
- onShowHelp: vi.fn(),
- onBack: vi.fn(),
- onEmailSupport: vi.fn(),
- onResend: vi.fn(),
- canResend: true,
- methods: [],
- onSelectMethod: vi.fn(),
- };
- return phase === 'retrying' ? { ...view, status: 'retrying' } : { ...view, status: 'ready', onCancel };
-}
-
-describe('ReverificationDialog', () => {
- it('opens a named card dialog for the active password challenge', () => {
- render(
-
-
- ,
- );
-
- expect(screen.getByRole('dialog', { name: 'Verification required' })).toBeInTheDocument();
- expect(screen.getByLabelText('Password')).toBeInTheDocument();
- expect(document.querySelectorAll('.cl-card-root')).toHaveLength(1);
- });
-
- it('waits for the challenge to load before opening', () => {
- const { rerender } = render(
-
-
- ,
- );
-
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
-
- rerender(
-
-
- ,
- );
-
- expect(screen.getByRole('dialog', { name: 'Verification required' })).toBeInTheDocument();
- expect(screen.getByLabelText('Password')).toBeInTheDocument();
- });
-
- it('opens when verification is unavailable', () => {
- render(
-
-
- ,
- );
-
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
- });
-
- it('cancels the active challenge when dismissed', async () => {
- const user = userEvent.setup();
- const onCancel = vi.fn();
- render(
-
-
- ,
- );
-
- await user.click(screen.getByRole('button', { name: 'Close' }));
-
- expect(onCancel).toHaveBeenCalledOnce();
- });
-
- it('keeps the retrying challenge open and does not cancel it', async () => {
- const user = userEvent.setup();
- const onCancel = vi.fn();
- render(
-
-
- ,
- );
-
- await user.click(screen.getByRole('button', { name: 'Close' }));
-
- expect(onCancel).not.toHaveBeenCalled();
- expect(screen.getByRole('dialog')).toBeInTheDocument();
- });
-
- it('stays closed when the flow is inactive', () => {
- render(
-
-
- ,
- );
-
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
- });
-
- it('returns focus to the action after an async challenge is cancelled', async () => {
- const user = userEvent.setup();
-
- function Harness() {
- const [phase, setPhase] = useState<'inactive' | 'active'>('inactive');
- const actionRef = useRef(null);
- const controller: ReverificationController =
- phase === 'active' ? ready('active', () => setPhase('inactive')) : { status: 'idle', onCancel: undefined };
-
- return (
- <>
-
-
- >
- );
- }
-
- render(
-
-
- ,
- );
-
- const action = screen.getByRole('button', { name: 'Connect' });
- await user.click(action);
- expect(await screen.findByRole('dialog')).toBeInTheDocument();
-
- await user.keyboard('{Escape}');
-
- await waitFor(() => expect(action).toHaveFocus());
- });
-});
diff --git a/packages/mosaic/src/features/reverification/reverification-confirmation.tsx b/packages/mosaic/src/features/reverification/reverification-confirmation.tsx
deleted file mode 100644
index 4788d4280de..00000000000
--- a/packages/mosaic/src/features/reverification/reverification-confirmation.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import {
- Confirmation,
- type ConfirmationControlledProps,
- type ConfirmationHandleProps,
-} from '../../blocks/confirmation';
-import { Button } from '../../components/button';
-import { Card } from '../../components/card';
-import { Reverification } from './reverification';
-import type { ReverificationController } from './reverification.controller';
-
-type ReverificationConfirmationProps = (
- | Omit
- | Omit, 'step' | 'verificationSlot' | 'onPendingCancel'>
-) & {
- reverification?: ReverificationController;
- backLabel?: string;
-};
-
-export function ReverificationConfirmation({
- reverification,
- backLabel = 'Back',
- ...props
-}: ReverificationConfirmationProps) {
- const step =
- reverification && reverification.status !== 'idle' && reverification.status !== 'loading' ? 'verify' : 'confirm';
- const verificationSlot = reverification ? (
- <>
-
-
-
-
- >
- ) : undefined;
-
- if ('handle' in props) {
- return (
-
- );
- }
-
- return (
- {
- if (!open && reverification && reverification.status !== 'idle') {
- reverification.onCancel?.();
- return;
- }
- props.onOpenChange(open, details);
- }}
- />
- );
-}
diff --git a/packages/mosaic/src/features/reverification/reverification.dialog.tsx b/packages/mosaic/src/features/reverification/reverification.dialog.tsx
deleted file mode 100644
index 48c5ca587cd..00000000000
--- a/packages/mosaic/src/features/reverification/reverification.dialog.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Card } from '../../components/card';
-import { Dialog, type DialogFocusTarget } from '../../components/dialog';
-import { Reverification } from './reverification';
-import type { ReverificationController } from './reverification.controller';
-
-export function ReverificationDialog({
- finalFocus,
- ...controller
-}: ReverificationController & { finalFocus?: DialogFocusTarget }) {
- return (
- {
- if (!open) {
- controller.onCancel?.();
- }
- }}
- >
-
-
-
-
-
-
- );
-}
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
index 4f667348644..b069de50fb5 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
@@ -1,4 +1,4 @@
-import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error';
+import { ClerkAPIResponseError } from '@clerk/shared/error';
import type * as SharedReact from '@clerk/shared/react';
import { createDeferredPromise } from '@clerk/shared/utils';
import { render, screen, waitFor, within } from '@testing-library/react';
@@ -46,7 +46,6 @@ vi.mock('@clerk/shared/react', async importOriginal => {
navigate,
}),
useSession: () => ({ session: { id: 'session_1' } }),
- useReverification: (fetcher: F) => fetcher,
};
});
@@ -196,9 +195,14 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(navigate).not.toHaveBeenCalled();
});
- it('clears Connect without an error when reverification is cancelled', async () => {
+ it('shows verification-required Connect errors without opening a challenge', async () => {
createExternalAccount = vi.fn(() =>
- Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })),
+ Promise.reject(
+ new ClerkAPIResponseError('Verification required', {
+ status: 403,
+ data: [{ code: 'session_reverification_required', message: 'Verify your session.' }],
+ }),
+ ),
);
const user = userEvent.setup();
renderSection();
@@ -208,7 +212,8 @@ describe('UserProfileConnectedAccountsSection', () => {
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
);
- expect(screen.queryByText('Something went wrong. Please try again.')).not.toBeInTheDocument();
+ expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
+ expect(createExternalAccount).toHaveBeenCalledOnce();
expect(navigate).not.toHaveBeenCalled();
});
@@ -298,7 +303,7 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(JSON.parse(window.atob(encoded))).toMatchObject({ componentName: 'UserProfile' });
});
- it('clears Reconnect without an error when reverification is cancelled', async () => {
+ it('shows verification-required Reconnect errors and allows another attempt', async () => {
externalAccounts = [
externalAccount({
id: 'idn_google',
@@ -311,7 +316,12 @@ describe('UserProfileConnectedAccountsSection', () => {
}),
];
createExternalAccount = vi.fn(() =>
- Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' })),
+ Promise.reject(
+ new ClerkAPIResponseError('Verification required', {
+ status: 403,
+ data: [{ code: 'session_reverification_required', message: 'Verify your session.' }],
+ }),
+ ),
);
const user = userEvent.setup();
renderSection();
@@ -320,7 +330,7 @@ describe('UserProfileConnectedAccountsSection', () => {
await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
await waitFor(() => expect(createExternalAccount).toHaveBeenCalledOnce());
- expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: 'Manage Google' }));
expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled();
@@ -424,12 +434,19 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
});
- it('returns to confirmation without an error when reverification is cancelled', async () => {
+ it('keeps removal confirmation open with a verification-required API error', async () => {
externalAccounts = [
externalAccount({
id: 'idn_google',
provider: 'google',
- destroy: vi.fn(() => Promise.reject(new ClerkRuntimeError('Cancelled', { code: 'reverification_cancelled' }))),
+ destroy: vi.fn(() =>
+ Promise.reject(
+ new ClerkAPIResponseError('Verification required', {
+ status: 403,
+ data: [{ code: 'session_reverification_required', message: 'Verify your session.' }],
+ }),
+ ),
+ ),
}),
];
const user = userEvent.setup();
@@ -442,6 +459,6 @@ describe('UserProfileConnectedAccountsSection', () => {
expect(within(dialog).getByRole('button', { name: 'Remove' })).not.toHaveAttribute('aria-busy', 'true'),
);
expect(screen.getByRole('alertdialog')).toBe(dialog);
- expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
});
});
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
index 14ab0583b06..7c474820cca 100644
--- 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
@@ -7,21 +7,20 @@ import { UserProfileConnectedAccountsSectionView } from '../user-profile-connect
const account = { id: 'account_1', provider: 'Google', identifier: 'test@example.com' };
describe('connected accounts section', () => {
- it('shows removal reverification in the confirmation dialog without a typed phrase', async () => {
+ it('shows plain removal confirmation without a typed phrase', async () => {
const user = userEvent.setup();
render(
,
);
await user.click(screen.getByRole('button', { name: 'Manage Google' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
- expect(screen.getAllByRole('dialog')).toHaveLength(1);
- expect(screen.getByText('Cannot verify your account')).toBeInTheDocument();
+ expect(screen.getAllByRole('alertdialog')).toHaveLength(1);
+ expect(screen.getByRole('alertdialog')).toHaveAccessibleName('Remove connected account');
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
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.view.tsx
index 2856fd07828..cd1dbdb4300 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.view.tsx
@@ -4,8 +4,6 @@ import { Confirmation } from '../../blocks/confirmation';
import { Section } from '../../components/section';
import { useListRemovalFocus } from '../../hooks/use-list-removal-focus';
import { fill, useMessages } from '../../localization';
-import type { ReverificationController } from '../reverification';
-import { ReverificationConfirmation } from '../reverification/reverification-confirmation';
import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view';
export interface UserProfileConnectionProvider {
@@ -32,7 +30,6 @@ export interface UserProfileConnectedAccountsSectionViewProps {
onConnect?: (id: string) => void;
onReconnect?: (id: string) => void;
onRemove?: (id: string) => void | Promise;
- removeReverification?: ReverificationController;
}
export function UserProfileConnectedAccountsSectionView({
@@ -43,7 +40,6 @@ export function UserProfileConnectedAccountsSectionView({
onConnect,
onReconnect,
onRemove,
- removeReverification,
}: UserProfileConnectedAccountsSectionViewProps) {
const m = useMessages('userProfileConnectedAccounts');
const section = useRef(null);
@@ -98,14 +94,12 @@ export function UserProfileConnectedAccountsSectionView({
) : null}
{onRemove ? (
- fill(m.removeDialog.description, { provider: account.provider })}
actionLabel={m.removeDialog.confirm}
cancelLabel={m.removeDialog.cancel}
- backLabel={m.removeDialog.back}
finalFocus={removalFocus.finalFocus}
onConfirm={account => removalFocus.remove(account.id)}
/>
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
index 8a3fa300015..a452edf8663 100644
--- 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
@@ -1,4 +1,3 @@
-import { isReverificationCancelledError } from '@clerk/shared/error';
import { useRef, useState } from 'react';
import type {
@@ -51,10 +50,8 @@ export function useUserProfileConnectedAccountsController({
return;
}
} catch (error) {
- if (!isReverificationCancelledError(error)) {
- const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage;
- setErrors(current => ({ ...current, [id]: message }));
- }
+ const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage;
+ setErrors(current => ({ ...current, [id]: message }));
}
inFlight.current = undefined;
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
index aab2aabb466..e6a6a816d1c 100644
--- 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
@@ -1,19 +1,12 @@
-import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error';
+import { isClerkAPIResponseError } from '@clerk/shared/error';
import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { useClerk, useUser } from '@clerk/shared/react';
-import type {
- CreateExternalAccountParams,
- ExternalAccountResource,
- OAuthProvider,
- OAuthStrategy,
-} from '@clerk/shared/types';
-import { type ReactNode, useRef } from 'react';
+import type { ExternalAccountResource, OAuthProvider, OAuthStrategy } from '@clerk/shared/types';
+import type { ReactNode } from 'react';
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
import { useMessages } from '../../../localization';
-import { currentInteractionOrigin } from '../../../primitives/utils/interaction-origin';
-import { ReverificationDialog, useReverificationFlow } from '../../reverification';
import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
@@ -44,22 +37,12 @@ export function UserProfileConnectedAccountsSection({
const { isLoaded, user } = useUser();
const environment = useMosaicEnvironment();
const router = useMosaicRouter();
- const reverificationFocus = useRef(null);
const transport = clerk.__internal_oauthTransport;
- const [createExternalAccount, createReverification] = useReverificationFlow((params: CreateExternalAccountParams) =>
- user?.createExternalAccount(params),
- );
- const [destroyAccount, removeReverification] = useReverificationFlow((accountId: string) =>
- user?.externalAccounts.find(account => account.id === accountId)?.destroy(),
- );
const guard = async (run: () => Promise): Promise => {
try {
return await run();
} catch (error) {
- if (isReverificationCancelledError(error)) {
- throw error;
- }
if (isClerkAPIResponseError(error)) {
const first = error.errors[0];
throw new Error(first?.longMessage || first?.message || m.errors.generic);
@@ -71,10 +54,6 @@ export function UserProfileConnectedAccountsSection({
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 captureReverificationFocus = () => {
- const active = document.activeElement;
- reverificationFocus.current = currentInteractionOrigin() ?? (active instanceof HTMLElement ? active : null);
- };
const completeVerification = async (
response: ExternalAccountResource | undefined,
@@ -98,10 +77,9 @@ export function UserProfileConnectedAccountsSection({
};
const connect = async (strategy: string) => {
- captureReverificationFocus();
const provider = strategy.replace('oauth_', '') as OAuthProvider;
const response = await guard(async () =>
- createExternalAccount({
+ user?.createExternalAccount({
strategy: strategy as OAuthStrategy,
redirectUrl: withModalState(await getRedirectUrl(), provider),
additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
@@ -111,7 +89,6 @@ export function UserProfileConnectedAccountsSection({
};
const reconnect = async (accountId: string) => {
- captureReverificationFocus();
const account = user?.externalAccounts.find(candidate => candidate.id === accountId);
const recovery = account ? getRecovery(account, additionalOAuthScopes) : null;
if (!account || !recovery) {
@@ -122,7 +99,7 @@ export function UserProfileConnectedAccountsSection({
const redirectUrl = await getRedirectUrl();
return recovery.kind === 'reauthorize'
? account.reauthorize({ additionalScopes: recovery.additionalScopes, redirectUrl: withModalState(redirectUrl) })
- : createExternalAccount({
+ : user?.createExternalAccount({
strategy: recovery.strategy,
redirectUrl: withModalState(redirectUrl),
additionalScopes: recovery.additionalScopes,
@@ -133,7 +110,7 @@ export function UserProfileConnectedAccountsSection({
const remove = (accountId: string) =>
guard(async () => {
- await destroyAccount(accountId);
+ await user?.externalAccounts.find(account => account.id === accountId)?.destroy();
});
const projection =
@@ -165,17 +142,10 @@ export function UserProfileConnectedAccountsSection({
}
return (
- <>
-
-
- >
+
);
}
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.messages.ts
index 4f476b8cd6a..3af4d9907e0 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.messages.ts
@@ -12,7 +12,6 @@ export const userProfileConnectedAccountsMessages = {
'{provider} will be removed from this account. You will no longer be able to use this connected account and any dependent features will no longer work.',
confirm: 'Remove',
cancel: 'Cancel',
- back: 'Back',
},
errors: {
generic: 'Something went wrong. Please try again.',
From c0e56f897fbdb4b8e7488f6032075040e5a88eed Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 29 Sep 2026 11:06:21 -0600
Subject: [PATCH 14/41] test(mosaic): cover connected accounts through real
Clerk
---
.../mosaic/src/__tests__/feature/fake-fapi.ts | 76 +++
packages/mosaic/src/__tests__/feature/fapi.ts | 75 +++
.../mosaic/src/__tests__/feature/render.tsx | 4 +-
...rofile-connected-accounts-actions.test.tsx | 157 ------
...cted-accounts-section.integration.test.tsx | 464 ------------------
...e-connected-accounts-section.view.test.tsx | 97 ----
...rofile-connected-accounts.feature.test.tsx | 451 +++++++++++++++++
7 files changed, 604 insertions(+), 720 deletions(-)
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index a7a4ba2dea0..9c8db692b5d 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,66 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
...verificationHandlers(state, fapiUrl),
http.get(fapiUrl('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/v1/environment'), () => HttpResponse.json(state.environment)),
http.get(fapiUrl('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/v1/client'), () => envelope(state.client, null)),
+ http.get(fapiUrl('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/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_${provider}`,
+ provider,
+ verification: fapiVerification({
+ status: 'unverified',
+ strategy,
+ external_verification_redirect_url: 'https://accounts.example/authorize',
+ }),
+ });
+ 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 account = activeUser(state)?.external_accounts.find(item => item.id === params.id);
+ return account
+ ? envelope(
+ {
+ ...account,
+ verification: fapiVerification({
+ status: 'unverified',
+ strategy: `oauth_${account.provider}`,
+ external_verification_redirect_url: 'https://accounts.example/consent',
+ }),
+ },
+ state.client,
+ )
+ : missing();
+ }),
+ 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..9482ec59274 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,80 @@ 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({ strategy: `oauth_${overrides.provider}` }),
+ ...overrides,
+ };
+}
+
+export function fapiVerification(
+ overrides: Partial & Pick,
+): VerificationJSON {
+ return {
+ object: 'verification',
+ id: 'verification_1',
+ status: 'verified',
+ verified_at_client: '',
+ attempts: 0,
+ expire_at: Date.UTC(2100, 0, 1),
+ error: { code: '', message: '', long_message: '' },
+ ...overrides,
+ };
+}
+
+export function fapiEnterpriseAccount(
+ overrides: Partial & Pick,
+): EnterpriseAccountJSON {
+ return {
+ object: 'enterprise_account',
+ active: true,
+ email_address: 'sso@example.com',
+ enterprise_connection: {
+ object: 'enterprise_connection',
+ id: 'sso_1',
+ active: true,
+ allow_idp_initiated: false,
+ allow_subdomains: false,
+ disable_additional_identifications: true,
+ domain: 'example.com',
+ logo_public_url: null,
+ name: 'Company SSO',
+ protocol: 'saml',
+ provider: 'saml_okta',
+ sync_user_attributes: false,
+ allow_organization_account_linking: false,
+ created_at: createdAt,
+ updated_at: createdAt,
+ enterprise_connection_id: 'sso_1',
+ },
+ first_name: null,
+ last_name: null,
+ protocol: 'saml',
+ provider: 'saml_okta',
+ provider_user_id: null,
+ public_metadata: {},
+ verification: null,
+ last_authenticated_at: null,
+ enterprise_connection_id: 'sso_1',
+ ...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.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
deleted file mode 100644
index b069de50fb5..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.integration.test.tsx
+++ /dev/null
@@ -1,464 +0,0 @@
-import { ClerkAPIResponseError } from '@clerk/shared/error';
-import type * as SharedReact from '@clerk/shared/react';
-import { createDeferredPromise } from '@clerk/shared/utils';
-import { render, screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-import { MosaicProvider } from '../../../MosaicProvider';
-import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section';
-
-let isLoaded: boolean;
-let externalAccounts: Record[];
-let enterpriseAccounts: unknown[];
-let social: Record;
-let transport: { getRedirectUrl: () => string; open: ReturnType } | null;
-let createExternalAccount: ReturnType;
-let reload: ReturnType;
-let navigate: ReturnType;
-
-vi.mock('@clerk/shared/react', async importOriginal => {
- const actual = await importOriginal();
- return {
- ...actual,
- useUser: () => ({
- isLoaded,
- isSignedIn: isLoaded,
- user: isLoaded
- ? {
- id: 'user_1',
- externalAccounts,
- verifiedExternalAccounts: externalAccounts.filter(a => (a.verification as any)?.status === 'verified'),
- unverifiedExternalAccounts: externalAccounts.filter(a => (a.verification as any)?.status !== 'verified'),
- enterpriseAccounts,
- createExternalAccount,
- reload,
- }
- : undefined,
- }),
- useClerk: () => ({
- __internal_environment: {
- userSettings: { social, enterpriseSSO: { enabled: enterpriseAccounts.length > 0 } },
- displayConfig: { supportEmail: 'support@example.com' },
- },
- __internal_oauthTransport: transport,
- __internal_getOption: () => undefined,
- navigate,
- }),
- useSession: () => ({ session: { id: 'session_1' } }),
- };
-});
-
-function oauth(strategy: string) {
- return { enabled: true, strategy, name: strategy, logo_url: null };
-}
-
-function externalAccount(overrides: Record & { id: string; provider: string }) {
- return {
- approvedScopes: 'email',
- username: '',
- emailAddress: `${overrides.provider}@example.com`,
- verification: { status: 'verified', strategy: `oauth_${overrides.provider}`, error: null },
- destroy: vi.fn(() => Promise.resolve()),
- reauthorize: vi.fn(),
- ...overrides,
- };
-}
-
-function verificationResponse(url = 'https://accounts.example/authorize') {
- return { verification: { externalVerificationRedirectURL: new URL(url) } };
-}
-
-function renderSection(props: Parameters[0] = {}) {
- return render(
-
-
- ,
- );
-}
-
-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('UserProfileConnectedAccountsSection', () => {
- beforeEach(() => {
- isLoaded = true;
- externalAccounts = [];
- enterpriseAccounts = [];
- social = { oauth_google: oauth('oauth_google'), oauth_github: oauth('oauth_github') };
- transport = null;
- createExternalAccount = vi.fn(() => Promise.resolve(verificationResponse()));
- reload = vi.fn(() => Promise.resolve());
- navigate = vi.fn(() => Promise.resolve());
- });
-
- it('renders the fallback until the user has loaded', () => {
- isLoaded = false;
- renderSection({ fallback: Loading accounts
});
- expect(screen.getByText('Loading accounts')).toBeInTheDocument();
- });
-
- it('renders nothing when no social provider is enabled', () => {
- social = {};
- const { container } = renderSection();
- expect(container).toBeEmptyDOMElement();
- });
-
- it('renders connected accounts from the user and offers the remaining providers', () => {
- externalAccounts = [externalAccount({ id: 'idn_google', provider: 'google', username: 'jdoe' })];
- renderSection();
-
- 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' })).not.toBeInTheDocument();
- });
-
- it('hides Connect when an enterprise connection disables additional identifications', () => {
- externalAccounts = [externalAccount({ id: 'idn_google', provider: 'google' })];
- enterpriseAccounts = [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }];
- renderSection();
-
- expect(screen.getByRole('button', { name: 'Manage Google' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: /^Connect/ })).not.toBeInTheDocument();
- });
-
- it('connects with the current URL and requested scopes, then navigates to the provider', async () => {
- const user = userEvent.setup();
- renderSection({ additionalOAuthScopes: { github: ['repo'] } });
-
- await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
-
- await waitFor(() =>
- expect(createExternalAccount).toHaveBeenCalledWith({
- strategy: 'oauth_github',
- redirectUrl: window.location.href,
- additionalScopes: ['repo'],
- }),
- );
- expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
- });
-
- it('preserves modal state when connecting an account', async () => {
- const user = userEvent.setup();
- renderSection({ mode: 'modal' });
-
- await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
-
- await waitFor(() => expect(createExternalAccount).toHaveBeenCalledOnce());
- const redirectUrl = createExternalAccount.mock.calls[0]?.[0]?.redirectUrl;
- expect(redirectUrl).toBeDefined();
- const encoded = new URL(redirectUrl).searchParams.get('__clerk_modal_state');
- expect(encoded).toBeTruthy();
- const state = JSON.parse(window.atob(encoded));
- expect(state).toMatchObject({ componentName: 'UserProfile', socialProvider: 'github' });
- });
-
- it('keeps Connect pending and ignores repeat clicks while the request runs', async () => {
- const deferred = createDeferredPromise();
- createExternalAccount = vi.fn(() => deferred.promise);
- const user = userEvent.setup();
- renderSection();
-
- const connect = screen.getByRole('button', { name: 'Connect GitHub' });
- await user.click(connect);
- await user.click(connect);
- await user.click(screen.getByRole('button', { name: 'Connect Google' }));
-
- expect(createExternalAccount).toHaveBeenCalledOnce();
- expect(connect).toHaveAttribute('aria-busy', 'true');
-
- deferred.resolve(verificationResponse());
- await waitFor(() => expect(navigate).toHaveBeenCalledOnce());
- });
-
- it('shows the API error on the provider row and re-enables Connect', async () => {
- createExternalAccount = vi.fn(() =>
- Promise.reject(
- new ClerkAPIResponseError('failed', {
- data: [{ code: 'oauth_error', message: 'failed', long_message: 'GitHub is unavailable right now.' }],
- status: 422,
- }),
- ),
- );
- const user = userEvent.setup();
- renderSection();
-
- await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
-
- expect(await screen.findByText('GitHub is unavailable right now.')).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true');
- expect(navigate).not.toHaveBeenCalled();
- });
-
- it('shows verification-required Connect errors without opening a challenge', async () => {
- createExternalAccount = vi.fn(() =>
- Promise.reject(
- new ClerkAPIResponseError('Verification required', {
- status: 403,
- data: [{ code: 'session_reverification_required', message: 'Verify your session.' }],
- }),
- ),
- );
- const user = userEvent.setup();
- renderSection();
-
- await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
-
- await waitFor(() =>
- expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
- );
- expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
- expect(createExternalAccount).toHaveBeenCalledOnce();
- expect(navigate).not.toHaveBeenCalled();
- });
-
- it('shows the OAuth error when the provider returns no verification URL', async () => {
- createExternalAccount = vi.fn(() => Promise.resolve({ verification: null }));
- const user = userEvent.setup();
- renderSection();
-
- await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
-
- expect(await screen.findByText('OAuth flow did not receive a verification URL.')).toBeInTheDocument();
- });
-
- it('opens the transport and reloads the user with the callback nonce', async () => {
- transport = {
- getRedirectUrl: () => 'myapp://sso-callback',
- open: vi.fn(() => Promise.resolve({ callbackUrl: 'myapp://sso-callback?rotating_token_nonce=abc' })),
- };
- const user = userEvent.setup();
- renderSection();
-
- await user.click(screen.getByRole('button', { name: 'Connect GitHub' }));
-
- await waitFor(() => expect(reload).toHaveBeenCalledWith({ rotatingTokenNonce: 'abc' }));
- expect(createExternalAccount).toHaveBeenCalledWith(
- expect.objectContaining({ redirectUrl: 'myapp://sso-callback' }),
- );
- expect(transport.open).toHaveBeenCalledWith(new URL('https://accounts.example/authorize'));
- expect(navigate).not.toHaveBeenCalled();
- await waitFor(() =>
- expect(screen.getByRole('button', { name: 'Connect GitHub' })).not.toHaveAttribute('aria-busy', 'true'),
- );
- });
-
- it('recreates an account that needs reconnecting', async () => {
- externalAccounts = [
- externalAccount({
- id: 'idn_google',
- provider: 'google',
- verification: {
- status: 'unverified',
- strategy: 'google_one_tap',
- error: { code: 'external_account_missing_refresh_token', longMessage: 'Missing token' },
- },
- }),
- ];
- const user = userEvent.setup();
- renderSection();
-
- 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(createExternalAccount).toHaveBeenCalledWith({
- strategy: 'oauth_google',
- redirectUrl: window.location.href,
- additionalScopes: [],
- }),
- );
- expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
- });
-
- it('preserves modal state when reconnecting an account', async () => {
- externalAccounts = [
- externalAccount({
- id: 'idn_google',
- provider: 'google',
- verification: {
- status: 'unverified',
- strategy: 'oauth_google',
- error: { code: 'external_account_missing_refresh_token', longMessage: 'Missing token' },
- },
- }),
- ];
- const user = userEvent.setup();
- renderSection({ mode: 'modal' });
-
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
-
- await waitFor(() => expect(createExternalAccount).toHaveBeenCalledOnce());
- const redirectUrl = createExternalAccount.mock.calls[0]?.[0]?.redirectUrl;
- expect(redirectUrl).toBeDefined();
- const encoded = new URL(redirectUrl).searchParams.get('__clerk_modal_state');
- expect(encoded).toBeTruthy();
- expect(JSON.parse(window.atob(encoded))).toMatchObject({ componentName: 'UserProfile' });
- });
-
- it('shows verification-required Reconnect errors and allows another attempt', async () => {
- externalAccounts = [
- externalAccount({
- id: 'idn_google',
- provider: 'google',
- verification: {
- status: 'unverified',
- strategy: 'oauth_google',
- error: { code: 'external_account_missing_refresh_token', longMessage: 'Missing token' },
- },
- }),
- ];
- createExternalAccount = vi.fn(() =>
- Promise.reject(
- new ClerkAPIResponseError('Verification required', {
- status: 403,
- data: [{ code: 'session_reverification_required', message: 'Verify your session.' }],
- }),
- ),
- );
- const user = userEvent.setup();
- renderSection();
-
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
-
- await waitFor(() => expect(createExternalAccount).toHaveBeenCalledOnce());
- expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
- expect(navigate).not.toHaveBeenCalled();
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled();
- });
-
- it('reauthorizes an account missing requested scopes', async () => {
- const google = externalAccount({
- id: 'idn_google',
- provider: 'google',
- approvedScopes: 'email',
- reauthorize: vi.fn(() => Promise.resolve(verificationResponse('https://accounts.example/consent'))),
- });
- externalAccounts = [google];
- const user = userEvent.setup();
- renderSection({ additionalOAuthScopes: { google: ['email', 'calendar'] } });
-
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
-
- await waitFor(() =>
- expect(google.reauthorize).toHaveBeenCalledWith({
- additionalScopes: ['email', 'calendar'],
- redirectUrl: window.location.href,
- }),
- );
- expect(createExternalAccount).not.toHaveBeenCalled();
- expect(navigate).toHaveBeenCalledWith('https://accounts.example/consent');
- });
-
- it('shows the API error and allows another Reconnect attempt after reauthorization fails', async () => {
- const google = externalAccount({
- id: 'idn_google',
- provider: 'google',
- approvedScopes: 'email',
- reauthorize: vi
- .fn()
- .mockRejectedValueOnce(
- new ClerkAPIResponseError('failed', {
- data: [{ code: 'oauth_error', message: 'failed', long_message: 'Calendar access was denied.' }],
- status: 422,
- }),
- )
- .mockResolvedValueOnce(verificationResponse()),
- });
- externalAccounts = [google];
- const user = userEvent.setup();
- renderSection({ additionalOAuthScopes: { google: ['calendar'] } });
-
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
- expect(await screen.findByText('Calendar access was denied.')).toBeInTheDocument();
- expect(navigate).not.toHaveBeenCalled();
-
- await user.click(screen.getByRole('button', { name: 'Manage Google' }));
- await user.click(screen.getByRole('menuitem', { name: 'Reconnect' }));
- await waitFor(() => expect(google.reauthorize).toHaveBeenCalledTimes(2));
- expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize');
- });
-
- it('removes the selected account and closes the confirmation', async () => {
- const google = externalAccount({ id: 'idn_google', provider: 'google' });
- const github = externalAccount({ id: 'idn_github', provider: 'github' });
- externalAccounts = [google, github];
- const user = userEvent.setup();
- renderSection();
-
- const dialog = await openRemoval(user, 'GitHub');
- await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
-
- await waitFor(() => expect(github.destroy).toHaveBeenCalledOnce());
- expect(google.destroy).not.toHaveBeenCalled();
- await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
- });
-
- it('keeps the confirmation open with the API message when removal is rejected', async () => {
- const google = externalAccount({
- id: 'idn_google',
- provider: 'google',
- destroy: vi.fn(() =>
- Promise.reject(
- new ClerkAPIResponseError('failed', {
- data: [
- {
- code: 'last_identification',
- message: 'failed',
- long_message: 'You cannot remove your last sign-in method.',
- },
- ],
- status: 403,
- }),
- ),
- ),
- });
- externalAccounts = [google];
- const user = userEvent.setup();
- renderSection();
-
- const dialog = await openRemoval(user, 'Google');
- await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
-
- expect(await within(dialog).findByRole('alert')).toHaveTextContent('You cannot remove your last sign-in method.');
- });
-
- it('keeps removal confirmation open with a verification-required API error', async () => {
- externalAccounts = [
- externalAccount({
- id: 'idn_google',
- provider: 'google',
- destroy: vi.fn(() =>
- Promise.reject(
- new ClerkAPIResponseError('Verification required', {
- status: 403,
- data: [{ code: 'session_reverification_required', message: 'Verify your session.' }],
- }),
- ),
- ),
- }),
- ];
- const user = userEvent.setup();
- renderSection();
-
- const dialog = await openRemoval(user, 'Google');
- await user.click(within(dialog).getByRole('button', { name: 'Remove' }));
-
- await waitFor(() =>
- expect(within(dialog).getByRole('button', { name: 'Remove' })).not.toHaveAttribute('aria-busy', 'true'),
- );
- expect(screen.getByRole('alertdialog')).toBe(dialog);
- expect(await screen.findByText('Verify your session.')).toBeInTheDocument();
- });
-});
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 7c474820cca..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx
+++ /dev/null
@@ -1,97 +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('shows plain removal confirmation without a typed phrase', async () => {
- const user = userEvent.setup();
- render(
- ,
- );
-
- 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')).toHaveAccessibleName('Remove connected account');
- expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
- });
-
- 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..826c781fa4b
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx
@@ -0,0 +1,451 @@
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+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 { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section';
+
+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({
+ status: 'unverified',
+ strategy: 'google_one_tap',
+ 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,
+ };
+}
+
+async function renderSection(accounts = [google], overrides: FakeFapiSeed = {}) {
+ const fapi = serveFapi(signedIn(accounts, overrides));
+ const view = await renderWithClerk();
+ return { ...view, fapi };
+}
+
+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', '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/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: '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('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({
+ status: 'unverified',
+ strategy: 'oauth_google',
+ error: { code: 'provider_error', message: 'Provider error', long_message: 'Provider error' },
+ }),
+ });
+ await renderSection([failed]);
+
+ expect(screen.getByText('Provider error')).toBeInTheDocument();
+ });
+
+ 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({ status: 'unverified', strategy: 'oauth_github' }),
+ }),
+ 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', '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/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('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 user = userEvent.setup();
+ const dialog = await openRemoval(user, 'GitHub');
+
+ expect(dialog).toHaveAccessibleName('Remove connected account');
+ expect(screen.queryByRole('textbox')).toBeNull();
+ await user.click(within(dialog).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('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());
+ });
+});
From 516de91f5b9c3097270e53ee454e82381c1bcda5 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 29 Sep 2026 15:51:27 -0600
Subject: [PATCH 15/41] refactor(mosaic): use slots for account panel sections
---
.changeset/mosaic-section-slot-alignment.md | 2 +
.../user-profile-profile-panel.view.test.tsx | 171 +++++++++++++-----
.../user-profile-profile-panel.view.tsx | 61 +------
.../src/stories/fixtures/user-profile.tsx | 28 +--
.../stories/user-profile-profile-panel.mdx | 31 +---
.../user-profile-profile-panel.stories.tsx | 28 +--
6 files changed, 180 insertions(+), 141 deletions(-)
create mode 100644 .changeset/mosaic-section-slot-alignment.md
diff --git a/.changeset/mosaic-section-slot-alignment.md b/.changeset/mosaic-section-slot-alignment.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-section-slot-alignment.md
@@ -0,0 +1,2 @@
+---
+---
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..72926f693d1 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,13 +1,16 @@
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';
import { MosaicProvider } from '../../../mosaic-provider';
+import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
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,23 +40,48 @@ function renderView(overrides: Partial = {}) {
}
describe('UserProfileProfilePanelView', () => {
+ it('renders account and wallet slots with a focus target at the panel title', () => {
+ const titleRef = createRef();
+
+ renderView({
+ titleRef,
+ connectedAccountsSlot: Connected
,
+ web3WalletsSlot: Wallets
,
+ });
+
+ expect(screen.getByTestId('connected-slot')).toBeInTheDocument();
+ expect(screen.getByTestId('wallet-slot')).toBeInTheDocument();
+ expect(titleRef.current).toHaveTextContent(/^Account$/);
+ });
+
it('hides connected accounts when only providers without a connect callback are supplied', () => {
renderView({
- connectedAccounts: [],
- availableConnectionProviders: [{ id: 'google', provider: 'Google' }],
+ connectedAccountsSlot: (
+
+ ),
});
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 titleRef = createRef();
const removal = createDeferredPromise();
const onRemoveConnectedAccount = vi.fn(async () => {
await removal.promise;
});
const { rerender } = renderView({
- connectedAccounts: [{ id: 'github', provider: 'GitHub' }],
- onRemoveConnectedAccount,
+ titleRef,
+ connectedAccountsSlot: (
+ titleRef.current}
+ onRemove={onRemoveConnectedAccount}
+ />
+ ),
});
await user.click(screen.getByRole('button', { name: 'Manage GitHub' }));
await user.click(screen.getByRole('menuitem', { name: 'Remove' }));
@@ -63,8 +91,14 @@ describe('UserProfileProfilePanelView', () => {
titleRef.current}
+ onRemove={onRemoveConnectedAccount}
+ />
+ }
/>
,
);
@@ -82,13 +116,20 @@ describe('UserProfileProfilePanelView', () => {
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 +138,14 @@ describe('UserProfileProfilePanelView', () => {
titleRef.current}
+ onRemove={onRemoveWeb3Wallet}
+ />
+ }
/>
,
);
@@ -115,9 +162,13 @@ describe('UserProfileProfilePanelView', () => {
it('keeps available providers visible without connected accounts', () => {
const onConnectAccount = vi.fn();
renderView({
- connectedAccounts: [],
- availableConnectionProviders: [{ id: 'google', provider: 'Google' }],
- onConnectAccount,
+ connectedAccountsSlot: (
+
+ ),
});
expect(screen.getByRole('button', { name: 'Connect Google' })).toBeVisible();
});
@@ -294,10 +345,20 @@ describe('UserProfileProfilePanelView', () => {
it('renders connected accounts and the danger zone when provided', () => {
renderView({
- connectedAccounts: [
- { id: 'google', provider: 'Google', identifier: 'test@google.com', iconUrl: 'https://example.com/google.svg' },
- ],
- deleteAccountSlot: ,
+ connectedAccountsSlot: (
+
+ ),
+ onDeleteAccount,
});
expect(screen.getByRole('heading', { level: 3, name: 'Connected accounts' })).toBeInTheDocument();
@@ -311,10 +372,18 @@ describe('UserProfileProfilePanelView', () => {
it('renders connected provider and 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' },
- ],
+ connectedAccountsSlot: (
+
+ ),
+ web3WalletsSlot: (
+
+ ),
});
const frames = container.querySelectorAll('.cl-icon-frame');
@@ -328,26 +397,30 @@ describe('UserProfileProfilePanelView', () => {
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();
@@ -445,14 +518,18 @@ 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' }],
+ connectedAccountsSlot: (
+
+ ),
onVerifyEmail,
onSetPrimaryEmail,
onRemoveEmail,
onVerifyPhone,
onSetPrimaryPhone,
onRemovePhone,
- onRemoveConnectedAccount,
});
await user.click(screen.getByRole('button', { name: 'Manage primary@clerk.dev' }));
@@ -522,11 +599,15 @@ describe('UserProfileProfilePanelView', () => {
},
],
phones: [],
- connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }],
+ connectedAccountsSlot: (
+
+ ),
onVerifyEmail: vi.fn(),
onSetPrimaryEmail: vi.fn(),
onRemoveEmail: vi.fn(),
- onRemoveConnectedAccount: vi.fn(),
});
expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument();
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 3797efa915c..e0e6f191b64 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.view';
+import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view';
export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet };
export type {
@@ -25,19 +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;
- /** Connected accounts section. Replaces the one built from `connectedAccounts` and its callbacks. */
+ titleRef?: Ref;
connectedAccountsSlot?: ReactNode;
- onConnectWeb3Wallet?: (id: string) => void;
- onSetPrimaryWeb3Wallet?: (id: string) => void;
- onRemoveWeb3Wallet?: (id: string) => void | Promise;
- /** Danger zone. Omit to hide it. */
+ web3WalletsSlot?: ReactNode;
deleteAccountSlot?: ReactNode;
}
@@ -53,11 +37,7 @@ export function UserProfileProfilePanelView({
lastNameAttribute,
emails = [],
phones = [],
- connectedAccounts = [],
- availableConnectionProviders = [],
- onReconnectAccount,
- web3Wallets = [],
- availableWeb3Providers = [],
+ titleRef,
onProfilePictureChange,
onProfilePictureReject,
onRemoveProfilePicture,
@@ -76,19 +56,14 @@ export function UserProfileProfilePanelView({
onVerifyPhone,
onSetPrimaryPhone,
onRemovePhone,
- onConnectAccount,
- onRemoveConnectedAccount,
connectedAccountsSlot,
- onConnectWeb3Wallet,
- onSetPrimaryWeb3Wallet,
- onRemoveWeb3Wallet,
+ web3WalletsSlot,
deleteAccountSlot,
}: UserProfileProfilePanelViewProps): ReactElement {
- const pageTitle = useRef(null);
return (
}>
Account
@@ -125,24 +100,8 @@ export function UserProfileProfilePanelView({
onSubmitName={onSubmitName}
onSubmitUsername={onSubmitUsername}
/>
- {connectedAccountsSlot ?? (
- 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/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index de99d9b055b..2f2b7f01abe 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.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-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..baf406d5fd0 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.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}
From 26da0ef6f029961137db5c901f1db417ca0cffbb Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Tue, 29 Sep 2026 16:54:01 -0600
Subject: [PATCH 16/41] fix(mosaic): release connected redirect pending state
---
...ected-accounts-section.controller.test.tsx | 24 +++++++++++++++++++
...e-connected-accounts-section.controller.ts | 2 +-
2 files changed, 25 insertions(+), 1 deletion(-)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
new file mode 100644
index 00000000000..0ae6cd8fff1
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
@@ -0,0 +1,24 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { expect, it, vi } from 'vitest';
+
+import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
+
+it('releases pending actions when a redirect leaves the page mounted', async () => {
+ const onConnect = vi.fn().mockResolvedValue('redirecting');
+ const { result } = renderHook(() =>
+ useUserProfileConnectedAccountsController({
+ accounts: [],
+ availableProviders: [],
+ onConnect,
+ onReconnect: onConnect,
+ fallbackErrorMessage: 'Something went wrong.',
+ }),
+ );
+
+ act(() => result.current.onConnect('github'));
+ expect(result.current.pendingId).toBe('github');
+
+ await waitFor(() => expect(result.current.pendingId).toBeUndefined(), { timeout: 2500 });
+ act(() => result.current.onConnect('google'));
+ expect(onConnect).toHaveBeenCalledTimes(2);
+});
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
index a452edf8663..d37e1d4c05d 100644
--- 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
@@ -47,7 +47,7 @@ export function useUserProfileConnectedAccountsController({
try {
if ((await action(id)) === 'redirecting') {
- return;
+ await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage;
From 703dbfd461a9cf18da7e427b33490971ea536084 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 01:31:24 -0600
Subject: [PATCH 17/41] chore(mosaic): mark session reverification as deferred
---
.../user-profile-connected-accounts-section.tsx | 2 ++
1 file changed, 2 insertions(+)
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
index e6a6a816d1c..66da715d6da 100644
--- 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
@@ -39,6 +39,8 @@ export function UserProfileConnectedAccountsSection({
const router = useMosaicRouter();
const transport = clerk.__internal_oauthTransport;
+ // TODO: Add session reverification for account connect, reconnect, and removal;
+ // surface API errors until then.
const guard = async (run: () => Promise): Promise => {
try {
return await run();
From b6c6ff5a23014cdfe7bc23d63f395984794cd039 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 22:18:31 -0600
Subject: [PATCH 18/41] refactor(mosaic): move connected accounts Clerk logic
into a model hook
---
...rofile-connected-accounts-section.model.ts | 130 +++++++++++++++
...ser-profile-connected-accounts-section.tsx | 152 ++++--------------
2 files changed, 161 insertions(+), 121 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 708a44c37aa..69868075fd6 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -1,5 +1,8 @@
import { iconImageUrl } from '@clerk/shared/constants';
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
+import { useClerk, useUser } from '@clerk/shared/react';
import type {
EnterpriseSSOSettings,
ExternalAccountResource,
@@ -10,10 +13,14 @@ import type {
UserResource,
} from '@clerk/shared/types';
+import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
+import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
+import { useMessages } from '../../../localization';
import type {
UserProfileConnectedAccount,
UserProfileConnectionProvider,
} from '../user-profile-connected-accounts-section.view';
+import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
export type AdditionalOAuthScopes = Partial>;
@@ -155,3 +162,126 @@ export function projectConnectedAccounts({
: [],
};
}
+
+export type UserProfileConnectedAccountsModel =
+ | { status: 'loading' }
+ | { status: 'hidden' }
+ | {
+ status: 'ready';
+ 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 m = useMessages('userProfileConnectedAccounts');
+ const clerk = useClerk();
+ const { isLoaded, user } = useUser();
+ const environment = useMosaicEnvironment();
+ const router = useMosaicRouter();
+
+ if (!isLoaded || !environment) {
+ return { status: 'loading' };
+ }
+ if (!user) {
+ return { status: 'hidden' };
+ }
+
+ const projection = projectConnectedAccounts({
+ user,
+ social: environment.userSettings.social,
+ allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO),
+ additionalOAuthScopes,
+ });
+ if (projection.status === 'hidden') {
+ return projection;
+ }
+
+ const transport = clerk.__internal_oauthTransport;
+
+ // TODO: Add session reverification for account connect, reconnect, and removal;
+ // surface API errors until then.
+ const guard = async (run: () => Promise): Promise => {
+ try {
+ return await run();
+ } catch (error) {
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ throw new Error(first?.longMessage || first?.message || m.errors.generic);
+ }
+ throw new Error(m.errors.generic);
+ }
+ };
+
+ 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 Error('OAuth flow did not receive a verification URL.');
+ }
+
+ if (transport) {
+ await guard(async () => {
+ const { callbackUrl } = await transport.open(url);
+ const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
+ await (nonce ? user.reload({ rotatingTokenNonce: nonce }) : user.reload());
+ });
+ return;
+ }
+
+ await router.navigate(url.href);
+ return 'redirecting';
+ };
+
+ return {
+ ...projection,
+ connect: async strategy => {
+ const provider = strategy.replace('oauth_', '') as OAuthProvider;
+ const response = await guard(async () =>
+ user.createExternalAccount({
+ strategy: strategy as OAuthStrategy,
+ redirectUrl: withModalState(await getRedirectUrl(), provider),
+ additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
+ }),
+ );
+ return completeVerification(response);
+ },
+ reconnect: async accountId => {
+ const account = user.externalAccounts.find(candidate => candidate.id === accountId);
+ const recovery = account ? getRecovery(account, additionalOAuthScopes) : null;
+ if (!account || !recovery) {
+ return;
+ }
+
+ const response = await guard(async () => {
+ const redirectUrl = await getRedirectUrl();
+ return recovery.kind === 'reauthorize'
+ ? account.reauthorize({
+ additionalScopes: recovery.additionalScopes,
+ redirectUrl: withModalState(redirectUrl),
+ })
+ : user.createExternalAccount({
+ strategy: recovery.strategy,
+ redirectUrl: withModalState(redirectUrl),
+ additionalScopes: recovery.additionalScopes,
+ });
+ });
+ return completeVerification(response);
+ },
+ remove: accountId =>
+ guard(async () => {
+ await user.externalAccounts.find(account => account.id === accountId)?.destroy();
+ }),
+ };
+}
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
index 66da715d6da..ba67ffd007c 100644
--- 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
@@ -1,21 +1,13 @@
-import { isClerkAPIResponseError } from '@clerk/shared/error';
-import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
-import { useClerk, useUser } from '@clerk/shared/react';
-import type { ExternalAccountResource, OAuthProvider, OAuthStrategy } from '@clerk/shared/types';
import type { ReactNode } from 'react';
-import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
-import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
import { useMessages } from '../../../localization';
import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
-import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
-import type { AdditionalOAuthScopes } from './user-profile-connected-accounts-section.model';
-import {
- allowsIdentificationCreation,
- getRecovery,
- projectConnectedAccounts,
+import type {
+ AdditionalOAuthScopes,
+ UserProfileConnectedAccountsModel,
} from './user-profile-connected-accounts-section.model';
+import { useUserProfileConnectedAccountsModel } from './user-profile-connected-accounts-section.model';
export type UserProfileConnectedAccountsSectionProps = {
additionalOAuthScopes?: AdditionalOAuthScopes;
@@ -30,124 +22,42 @@ export function UserProfileConnectedAccountsSection({
fallbackFocus,
mode,
}: UserProfileConnectedAccountsSectionProps) {
- const m = useMessages('userProfileConnectedAccounts');
-
- // -- Model --
- const clerk = useClerk();
- const { isLoaded, user } = useUser();
- const environment = useMosaicEnvironment();
- const router = useMosaicRouter();
- const transport = clerk.__internal_oauthTransport;
-
- // TODO: Add session reverification for account connect, reconnect, and removal;
- // surface API errors until then.
- const guard = async (run: () => Promise): Promise => {
- try {
- return await run();
- } catch (error) {
- if (isClerkAPIResponseError(error)) {
- const first = error.errors[0];
- throw new Error(first?.longMessage || first?.message || m.errors.generic);
- }
- throw new Error(m.errors.generic);
- }
- };
-
- 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 | undefined,
- ): Promise => {
- const url = response?.verification?.externalVerificationRedirectURL;
- if (!url) {
- throw new Error('OAuth flow did not receive a verification URL.');
- }
-
- if (transport) {
- await guard(async () => {
- const { callbackUrl } = await transport.open(url);
- const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
- await (nonce ? user?.reload({ rotatingTokenNonce: nonce }) : user?.reload());
- });
- return;
- }
-
- await router.navigate(url.href);
- return 'redirecting';
- };
-
- const connect = async (strategy: string) => {
- const provider = strategy.replace('oauth_', '') as OAuthProvider;
- const response = await guard(async () =>
- user?.createExternalAccount({
- strategy: strategy as OAuthStrategy,
- redirectUrl: withModalState(await getRedirectUrl(), provider),
- additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
- }),
- );
- return completeVerification(response);
- };
-
- const reconnect = async (accountId: string) => {
- const account = user?.externalAccounts.find(candidate => candidate.id === accountId);
- const recovery = account ? getRecovery(account, additionalOAuthScopes) : null;
- if (!account || !recovery) {
- return;
- }
-
- const response = await guard(async () => {
- const redirectUrl = await getRedirectUrl();
- return recovery.kind === 'reauthorize'
- ? account.reauthorize({ additionalScopes: recovery.additionalScopes, redirectUrl: withModalState(redirectUrl) })
- : user?.createExternalAccount({
- strategy: recovery.strategy,
- redirectUrl: withModalState(redirectUrl),
- additionalScopes: recovery.additionalScopes,
- });
- });
- return completeVerification(response);
- };
-
- const remove = (accountId: string) =>
- guard(async () => {
- await user?.externalAccounts.find(account => account.id === accountId)?.destroy();
- });
-
- const projection =
- user && environment
- ? projectConnectedAccounts({
- user,
- social: environment.userSettings.social,
- allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO),
- additionalOAuthScopes,
- })
- : ({ status: 'hidden' } as const);
-
- // -- Controller --
- const controller = useUserProfileConnectedAccountsController({
- accounts: projection.status === 'ready' ? projection.accounts : [],
- availableProviders: projection.status === 'ready' ? projection.availableProviders : [],
- onConnect: connect,
- onReconnect: reconnect,
- fallbackErrorMessage: m.errors.generic,
- });
-
- // -- View --
- if (!isLoaded || !environment) {
+ const model = useUserProfileConnectedAccountsModel({ additionalOAuthScopes, mode });
+ if (model.status === 'loading') {
return fallback ?? null;
}
-
- if (projection.status === 'hidden') {
+ if (model.status === 'hidden') {
return null;
}
+ return (
+
+ );
+}
+
+function ConnectedAccounts({
+ model,
+ fallbackFocus,
+}: {
+ model: Extract;
+ fallbackFocus?: () => HTMLElement | null;
+}) {
+ const m = useMessages('userProfileConnectedAccounts');
+ const controller = useUserProfileConnectedAccountsController({
+ accounts: model.accounts,
+ availableProviders: model.availableProviders,
+ onConnect: model.connect,
+ onReconnect: model.reconnect,
+ fallbackErrorMessage: m.errors.generic,
+ });
return (
);
}
From da04c936600404c3b72f9ba66d49bb498fae2f4f Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 22:26:23 -0600
Subject: [PATCH 19/41] fix(mosaic): recheck the connected accounts user before
acting
---
.../user-profile-connected-accounts-errors.ts | 19 +++
...ected-accounts-section.controller.test.tsx | 2 +-
...e-connected-accounts-section.controller.ts | 7 +-
...e-connected-accounts-section.model.test.ts | 156 ++++++++++++++++++
...rofile-connected-accounts-section.model.ts | 93 ++++++-----
...ser-profile-connected-accounts-section.tsx | 13 +-
...ser-profile-connected-accounts.messages.ts | 2 +
7 files changed, 242 insertions(+), 50 deletions(-)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-errors.ts
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.test.ts
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-errors.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-errors.ts
new file mode 100644
index 00000000000..f086fb5f236
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-errors.ts
@@ -0,0 +1,19 @@
+import { isClerkAPIResponseError } from '@clerk/shared/error';
+
+import type { MosaicMessages } from '../../../localization';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.model';
+
+type Messages = MosaicMessages['userProfileConnectedAccounts'];
+
+export function connectedAccountErrorMessage(error: unknown, messages: Messages): string {
+ if (error instanceof ConnectedAccountActionError) {
+ return error.code === 'missing_verification_url'
+ ? messages.errors.missingVerificationUrl
+ : messages.errors.unavailable;
+ }
+ if (isClerkAPIResponseError(error)) {
+ const first = error.errors[0];
+ return first?.longMessage || first?.message || messages.errors.generic;
+ }
+ return messages.errors.generic;
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
index 0ae6cd8fff1..72d70f44f12 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
@@ -11,7 +11,7 @@ it('releases pending actions when a redirect leaves the page mounted', async ()
availableProviders: [],
onConnect,
onReconnect: onConnect,
- fallbackErrorMessage: 'Something went wrong.',
+ formatError: () => 'Something went wrong.',
}),
);
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
index d37e1d4c05d..b4676369eb0 100644
--- 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
@@ -20,13 +20,13 @@ export function useUserProfileConnectedAccountsController({
availableProviders,
onConnect,
onReconnect,
- fallbackErrorMessage,
+ formatError,
}: {
accounts: UserProfileConnectedAccount[];
availableProviders: UserProfileConnectionProvider[];
onConnect: (id: string) => Promise;
onReconnect: (id: string) => Promise;
- fallbackErrorMessage: string;
+ formatError: (error: unknown) => string;
}): UserProfileConnectedAccountsController {
const [pendingId, setPendingId] = useState();
const [connectErrors, setConnectErrors] = useState>({});
@@ -50,8 +50,7 @@ export function useUserProfileConnectedAccountsController({
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
- const message = error instanceof Error && error.message ? error.message : fallbackErrorMessage;
- setErrors(current => ({ ...current, [id]: message }));
+ setErrors(current => ({ ...current, [id]: formatError(error) }));
}
inFlight.current = undefined;
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..f948ffdb2a5
--- /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,156 @@
+import { cleanup, renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useUserProfileConnectedAccountsModel } from './user-profile-connected-accounts-section.model';
+
+type TestAccount = {
+ id: string;
+ provider: string;
+ approvedScopes: string;
+ verification: { status: string; strategy: string; error: null } | null;
+ destroy: ReturnType;
+};
+
+type TestUser = {
+ id: string;
+ enterpriseAccounts: never[];
+ externalAccounts: TestAccount[];
+ verifiedExternalAccounts: TestAccount[];
+ unverifiedExternalAccounts: TestAccount[];
+ createExternalAccount: ReturnType;
+ reload: ReturnType;
+};
+
+function createUser(id: string): TestUser {
+ const github: TestAccount = {
+ id: 'idn_github',
+ provider: 'github',
+ approvedScopes: 'email',
+ verification: { status: 'verified', strategy: 'oauth_github', error: null },
+ destroy: vi.fn(),
+ };
+ return {
+ id,
+ enterpriseAccounts: [],
+ externalAccounts: [github],
+ verifiedExternalAccounts: [github],
+ unverifiedExternalAccounts: [],
+ createExternalAccount: vi.fn(() =>
+ Promise.resolve({
+ verification: { externalVerificationRedirectURL: new URL('https://accounts.example/authorize') },
+ }),
+ ),
+ reload: vi.fn(() => Promise.resolve()),
+ };
+}
+
+let user: TestUser | null;
+let transport: { getRedirectUrl: () => Promise; open: ReturnType } | undefined;
+let socialEnabled: boolean;
+
+const clerk = {
+ get user() {
+ return user;
+ },
+ get __internal_oauthTransport() {
+ return transport;
+ },
+ navigate: vi.fn(() => Promise.resolve()),
+};
+
+vi.mock('@clerk/shared/react', () => ({
+ useClerk: () => clerk,
+ useUser: () => ({ isLoaded: true, user }),
+}));
+
+vi.mock('../../../hooks/useMosaicEnvironment', () => ({
+ useMosaicEnvironment: () => ({
+ userSettings: {
+ social: {
+ oauth_github: { enabled: socialEnabled, strategy: 'oauth_github', name: 'GitHub' },
+ oauth_google: { enabled: socialEnabled, strategy: 'oauth_google', name: 'Google' },
+ },
+ enterpriseSSO: { enabled: false },
+ },
+ }),
+}));
+
+beforeEach(() => {
+ user = createUser('user_1');
+ transport = undefined;
+ socialEnabled = true;
+ clerk.navigate.mockClear();
+});
+
+afterEach(cleanup);
+
+function ready(model: ReturnType) {
+ if (model.status !== 'ready') {
+ throw new Error('expected ready model');
+ }
+ return model;
+}
+
+describe('useUserProfileConnectedAccountsModel', () => {
+ it('explains why the section is hidden', () => {
+ user = null;
+ expect(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current).toEqual({
+ status: 'hidden',
+ reason: 'no_user',
+ });
+
+ user = createUser('user_1');
+ socialEnabled = false;
+ expect(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current).toEqual({
+ status: 'hidden',
+ reason: 'unavailable',
+ });
+ });
+
+ it.each(['signed out', 'different user'])('rejects captured actions after %s', async change => {
+ const original = user;
+ if (!original) {
+ throw new Error('expected user');
+ }
+ const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
+
+ user = change === 'signed out' ? null : createUser('user_2');
+
+ await expect(model.connect('oauth_google')).rejects.toMatchObject({ code: 'unavailable' });
+ await expect(model.reconnect('idn_github')).rejects.toMatchObject({ code: 'unavailable' });
+ await expect(model.remove('idn_github')).rejects.toMatchObject({ code: 'unavailable' });
+ expect(original.createExternalAccount).not.toHaveBeenCalled();
+ expect(original.externalAccounts[0].destroy).not.toHaveBeenCalled();
+ });
+
+ it('does not reload a user who changed while the OAuth popup was open', async () => {
+ const original = user;
+ if (!original) {
+ throw new Error('expected user');
+ }
+ transport = {
+ getRedirectUrl: () => Promise.resolve('https://app.example/profile'),
+ open: vi.fn(() => {
+ user = createUser('user_2');
+ return Promise.resolve({ callbackUrl: 'https://app.example/callback?rotating_token_nonce=nonce' });
+ }),
+ };
+ const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
+
+ await expect(model.connect('oauth_google')).rejects.toMatchObject({ code: 'unavailable' });
+ expect(original.reload).not.toHaveBeenCalled();
+ });
+
+ it('rejects a missing verification URL with a typed error', async () => {
+ user?.createExternalAccount.mockResolvedValue({ verification: null });
+ const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
+
+ await expect(model.connect('oauth_google')).rejects.toMatchObject({ code: 'missing_verification_url' });
+ });
+
+ it('rejects removal of an account that no longer exists', async () => {
+ const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
+
+ await expect(model.remove('idn_missing')).rejects.toMatchObject({ code: 'unavailable' });
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 69868075fd6..fae2c81f413 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -1,5 +1,4 @@
import { iconImageUrl } from '@clerk/shared/constants';
-import { isClerkAPIResponseError } from '@clerk/shared/error';
import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams';
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import { useClerk, useUser } from '@clerk/shared/react';
@@ -15,7 +14,6 @@ import type {
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
-import { useMessages } from '../../../localization';
import type {
UserProfileConnectedAccount,
UserProfileConnectionProvider,
@@ -163,11 +161,19 @@ export function projectConnectedAccounts({
};
}
+export class ConnectedAccountActionError extends Error {
+ constructor(readonly code: 'unavailable' | 'missing_verification_url') {
+ super(code);
+ this.name = 'ConnectedAccountActionError';
+ }
+}
+
export type UserProfileConnectedAccountsModel =
| { status: 'loading' }
- | { status: 'hidden' }
+ | { status: 'hidden'; reason: 'no_user' | 'unavailable' }
| {
status: 'ready';
+ userId: string;
accounts: UserProfileConnectedAccount[];
availableProviders: UserProfileConnectionProvider[];
connect: (strategy: string) => Promise;
@@ -182,7 +188,6 @@ export function useUserProfileConnectedAccountsModel({
additionalOAuthScopes?: AdditionalOAuthScopes;
mode?: 'modal' | 'mounted';
}): UserProfileConnectedAccountsModel {
- const m = useMessages('userProfileConnectedAccounts');
const clerk = useClerk();
const { isLoaded, user } = useUser();
const environment = useMosaicEnvironment();
@@ -192,7 +197,7 @@ export function useUserProfileConnectedAccountsModel({
return { status: 'loading' };
}
if (!user) {
- return { status: 'hidden' };
+ return { status: 'hidden', reason: 'no_user' };
}
const projection = projectConnectedAccounts({
@@ -202,41 +207,37 @@ export function useUserProfileConnectedAccountsModel({
additionalOAuthScopes,
});
if (projection.status === 'hidden') {
- return projection;
+ return { status: 'hidden', reason: 'unavailable' };
}
+ const userId = user.id;
const transport = clerk.__internal_oauthTransport;
- // TODO: Add session reverification for account connect, reconnect, and removal;
- // surface API errors until then.
- const guard = async (run: () => Promise): Promise => {
- try {
- return await run();
- } catch (error) {
- if (isClerkAPIResponseError(error)) {
- const first = error.errors[0];
- throw new Error(first?.longMessage || first?.message || m.errors.generic);
- }
- throw new Error(m.errors.generic);
+ const currentUser = () => {
+ 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;
+ // TODO: Add session reverification for account connect, reconnect, and removal;
+ // surface API errors until then.
const completeVerification = async (response: ExternalAccountResource): Promise => {
const url = response.verification?.externalVerificationRedirectURL;
if (!url) {
- throw new Error('OAuth flow did not receive a verification URL.');
+ throw new ConnectedAccountActionError('missing_verification_url');
}
if (transport) {
- await guard(async () => {
- const { callbackUrl } = await transport.open(url);
- const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
- await (nonce ? user.reload({ rotatingTokenNonce: nonce }) : user.reload());
- });
+ const { callbackUrl } = await transport.open(url);
+ const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
+ const current = currentUser();
+ await (nonce ? current.reload({ rotatingTokenNonce: nonce }) : current.reload());
return;
}
@@ -246,42 +247,48 @@ export function useUserProfileConnectedAccountsModel({
return {
...projection,
+ userId,
connect: async strategy => {
+ const current = currentUser();
const provider = strategy.replace('oauth_', '') as OAuthProvider;
- const response = await guard(async () =>
- user.createExternalAccount({
- strategy: strategy as OAuthStrategy,
- redirectUrl: withModalState(await getRedirectUrl(), provider),
- additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
- }),
- );
+ const response = await current.createExternalAccount({
+ strategy: strategy as OAuthStrategy,
+ redirectUrl: withModalState(await getRedirectUrl(), provider),
+ additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
+ });
return completeVerification(response);
},
reconnect: async accountId => {
- const account = user.externalAccounts.find(candidate => candidate.id === accountId);
- const recovery = account ? getRecovery(account, additionalOAuthScopes) : null;
- if (!account || !recovery) {
+ const current = currentUser();
+ const account = current.externalAccounts.find(candidate => candidate.id === accountId);
+ if (!account) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ const recovery = getRecovery(account, additionalOAuthScopes);
+ if (!recovery) {
return;
}
- const response = await guard(async () => {
- const redirectUrl = await getRedirectUrl();
- return recovery.kind === 'reauthorize'
- ? account.reauthorize({
+ const redirectUrl = await getRedirectUrl();
+ const response =
+ recovery.kind === 'reauthorize'
+ ? await account.reauthorize({
additionalScopes: recovery.additionalScopes,
redirectUrl: withModalState(redirectUrl),
})
- : user.createExternalAccount({
+ : await current.createExternalAccount({
strategy: recovery.strategy,
redirectUrl: withModalState(redirectUrl),
additionalScopes: recovery.additionalScopes,
});
- });
return completeVerification(response);
},
- remove: accountId =>
- guard(async () => {
- await user.externalAccounts.find(account => account.id === accountId)?.destroy();
- }),
+ remove: async accountId => {
+ const account = currentUser().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-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
index ba67ffd007c..e153e914f54 100644
--- 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
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
import { useMessages } from '../../../localization';
import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
+import { connectedAccountErrorMessage } from './user-profile-connected-accounts-errors';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
import type {
AdditionalOAuthScopes,
@@ -31,6 +32,7 @@ export function UserProfileConnectedAccountsSection({
}
return (
@@ -50,14 +52,21 @@ function ConnectedAccounts({
availableProviders: model.availableProviders,
onConnect: model.connect,
onReconnect: model.reconnect,
- fallbackErrorMessage: m.errors.generic,
+ formatError: error => connectedAccountErrorMessage(error, m),
});
+ const remove = async (accountId: string) => {
+ try {
+ await model.remove(accountId);
+ } catch (error) {
+ throw new Error(connectedAccountErrorMessage(error, m));
+ }
+ };
return (
);
}
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.messages.ts
index 3af4d9907e0..7d41c8f0720 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.messages.ts
@@ -15,5 +15,7 @@ export const userProfileConnectedAccountsMessages = {
},
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;
From 96085c61d831d86f66459907d7dfd756ef148303 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 23:06:13 -0600
Subject: [PATCH 20/41] fix(mosaic): only offer reconnect for recognized
connected account errors
---
.../user-profile-connected-accounts-section.model.test.ts | 4 ++--
.../user-profile-connected-accounts-section.model.ts | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
index ee80b724366..74aae8ac681 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
@@ -232,10 +232,10 @@ describe('getRecovery', () => {
).toBeNull();
});
- it('recreates an account when scopes are missing and none were approved', () => {
+ it('does not offer recovery when scopes are missing and none were approved', () => {
expect(
getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
- ).toEqual({ kind: 'create', strategy: 'oauth_google', additionalScopes: ['calendar'] });
+ ).toBeNull();
});
it('ignores unrecognized verification errors', () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index fae2c81f413..f64adedd71c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -101,7 +101,7 @@ export function getRecovery(
return { kind: 'reauthorize', additionalScopes };
}
- if (additionalScopes.length === 0 && !RECONNECT_ERROR_CODES.includes(account.verification?.error?.code ?? '')) {
+ if (!RECONNECT_ERROR_CODES.includes(account.verification?.error?.code ?? '')) {
return null;
}
From 0b279ab6909495590931edaad1b0a2f1eb18dd69 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 23:23:58 -0600
Subject: [PATCH 21/41] refactor(mosaic): validate connected account strategies
instead of casting
---
...e-connected-accounts-section.model.test.ts | 7 ++++++
...rofile-connected-accounts-section.model.ts | 22 ++++++++++++++++---
2 files changed, 26 insertions(+), 3 deletions(-)
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
index f948ffdb2a5..afc21e0c354 100644
--- 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
@@ -148,6 +148,13 @@ describe('useUserProfileConnectedAccountsModel', () => {
await expect(model.connect('oauth_google')).rejects.toMatchObject({ code: 'missing_verification_url' });
});
+ it('rejects a strategy that is not enabled', async () => {
+ const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
+
+ await expect(model.connect('oauth_facebook')).rejects.toMatchObject({ code: 'unavailable' });
+ expect(user?.createExternalAccount).not.toHaveBeenCalled();
+ });
+
it('rejects removal of an account that no longer exists', async () => {
const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index f64adedd71c..70d65214a37 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -3,6 +3,7 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStatePara
import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import { useClerk, useUser } from '@clerk/shared/react';
import type {
+ CustomOauthProvider,
EnterpriseSSOSettings,
ExternalAccountResource,
OAuthProvider,
@@ -77,6 +78,15 @@ export function getEnabledOAuthStrategies(social: Partial): OAut
return [...known, ...custom];
}
+function providerFor(strategy: OAuthStrategy): OAuthProvider {
+ const known = OAUTH_PROVIDERS.find(p => p.strategy === strategy);
+ if (known) {
+ return known.provider;
+ }
+ const custom: CustomOauthProvider = `custom_${strategy.slice('oauth_custom_'.length)}`;
+ return custom;
+}
+
export function allowsIdentificationCreation(user: UserResource, enterpriseSSO: EnterpriseSSOSettings): boolean {
if (!enterpriseSSO.enabled) {
return true;
@@ -248,11 +258,17 @@ export function useUserProfileConnectedAccountsModel({
return {
...projection,
userId,
- connect: async strategy => {
+ connect: async strategyId => {
const current = currentUser();
- const provider = strategy.replace('oauth_', '') as OAuthProvider;
+ const strategy = getEnabledOAuthStrategies(environment.userSettings.social).find(
+ candidate => candidate === strategyId,
+ );
+ if (!strategy) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ const provider = providerFor(strategy);
const response = await current.createExternalAccount({
- strategy: strategy as OAuthStrategy,
+ strategy,
redirectUrl: withModalState(await getRedirectUrl(), provider),
additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
});
From 26da468358c5f4b99ed7383e9178b1aca279045e Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 30 Sep 2026 23:51:04 -0600
Subject: [PATCH 22/41] refactor(mosaic): colocate connected accounts files
like the password section
---
...e-connected-accounts-section.model.test.ts | 271 -----------------
.../user-profile-profile-panel.view.test.tsx | 2 +-
...ser-profile-connected-account-row.view.tsx | 18 +-
...e-connected-accounts-section.controller.ts | 2 +-
...-connected-accounts-section.errors.test.ts | 41 +++
...file-connected-accounts-section.errors.ts} | 0
...le-connected-accounts-section.messages.ts} | 0
...e-connected-accounts-section.model.test.ts | 276 +++++++++++++++++-
...rofile-connected-accounts-section.model.ts | 4 +-
...file-connected-accounts-section.styles.ts} | 2 +-
...ser-profile-connected-accounts-section.tsx | 4 +-
...rofile-connected-accounts-section.types.ts | 25 ++
...rofile-connected-accounts-section.view.tsx | 42 +--
.../user-profile-profile-panel.view.tsx | 2 +-
packages/mosaic/src/localization/registry.ts | 2 +-
.../user-profile-connected-accounts.ts | 2 +-
.../src/stories/fixtures/user-profile.tsx | 2 +-
...ile-connected-accounts-section.stories.tsx | 5 +-
.../user-profile-profile-panel.stories.tsx | 2 +-
19 files changed, 378 insertions(+), 324 deletions(-)
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
rename packages/mosaic/src/features/user-profile/{ => user-profile-connected-accounts-section}/user-profile-connected-account-row.view.tsx (87%)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.test.ts
rename packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/{user-profile-connected-accounts-errors.ts => user-profile-connected-accounts-section.errors.ts} (100%)
rename packages/mosaic/src/features/user-profile/{user-profile-connected-accounts.messages.ts => user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages.ts} (100%)
rename packages/mosaic/src/features/user-profile/{user-profile-connected-accounts.styles.ts => user-profile-connected-accounts-section/user-profile-connected-accounts-section.styles.ts} (93%)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts
rename packages/mosaic/src/features/user-profile/{ => user-profile-connected-accounts-section}/user-profile-connected-accounts-section.view.tsx (73%)
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
deleted file mode 100644
index 74aae8ac681..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.model.test.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-import type { EnterpriseSSOSettings, ExternalAccountResource, OAuthProviders, UserResource } from '@clerk/shared/types';
-import { describe, expect, it } from 'vitest';
-
-import {
- allowsIdentificationCreation,
- getEnabledOAuthStrategies,
- getRecovery,
- projectConnectedAccounts,
-} from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.model';
-
-type AccountInput = {
- id: string;
- provider: string;
- 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): ExternalAccountResource {
- return {
- id,
- provider,
- approvedScopes,
- username,
- emailAddress,
- verification: {
- status,
- strategy: strategy ?? `oauth_${provider}`,
- error: errorCode ? { code: errorCode, longMessage: longMessage ?? errorCode } : null,
- },
- } as unknown as ExternalAccountResource;
-}
-
-function user(accounts: ExternalAccountResource[], enterpriseAccounts: unknown[] = []): UserResource {
- return {
- externalAccounts: accounts,
- verifiedExternalAccounts: accounts.filter(a => a.verification?.status === 'verified'),
- unverifiedExternalAccounts: accounts.filter(a => a.verification?.status !== 'verified'),
- enterpriseAccounts,
- } as unknown as UserResource;
-}
-
-function social(...strategies: string[]): Partial {
- return 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,
- },
- ]),
- ) as Partial;
-}
-
-describe('projectConnectedAccounts', () => {
- it('is hidden when no social provider is enabled', () => {
- expect(projectConnectedAccounts({ user: user([]), social: {}, allowCreation: true })).toEqual({
- status: 'hidden',
- });
- });
-
- it('is hidden when creation is disallowed and the user has no external accounts', () => {
- expect(projectConnectedAccounts({ user: user([]), social: social('oauth_google'), allowCreation: false })).toEqual({
- status: 'hidden',
- });
- });
-
- it('stays visible when creation is disallowed but a pending account exists', () => {
- const projection = projectConnectedAccounts({
- user: user([account({ id: 'idn_1', provider: 'google', status: 'unverified' })]),
- social: social('oauth_google'),
- 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: user([
- 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' }),
- ]),
- social: social('oauth_google', 'oauth_github', 'oauth_apple'),
- 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: user([
- account({ id: 'idn_1', provider: 'github', username: 'octo', emailAddress: 'octo@example.com' }),
- account({ id: 'idn_2', provider: 'google', emailAddress: 'g@example.com' }),
- ]),
- social: social('oauth_google', 'oauth_github'),
- allowCreation: true,
- });
- expect(projection.status === 'ready' && projection.accounts.map(a => a.identifier)).toEqual([
- 'octo',
- 'g@example.com',
- ]);
- });
-
- it('offers enabled providers without a verified account, keyed by strategy', () => {
- const projection = projectConnectedAccounts({
- user: user([
- account({ id: 'idn_google', provider: 'google' }),
- account({ id: 'idn_github', provider: 'github', status: 'unverified', errorCode: 'oauth_access_denied' }),
- ]),
- social: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
- allowCreation: true,
- });
- expect(projection.status === 'ready' && projection.availableProviders.map(p => p.id)).toEqual([
- 'oauth_github',
- 'oauth_custom_acme',
- ]);
- });
-
- it('uses configured names and logos for custom providers', () => {
- const projection = projectConnectedAccounts({
- user: user([account({ id: 'idn_1', provider: 'custom_acme' })]),
- social: social('oauth_custom_acme'),
- 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: user([]),
- social: social('oauth_github', 'oauth_google'),
- 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: user([account({ id: 'idn_1', provider: 'google' })]),
- social: social('oauth_google', 'oauth_github'),
- 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: user([
- 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.',
- }),
- ]),
- social: social('oauth_google', 'oauth_github'),
- 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('getRecovery', () => {
- 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(getRecovery(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 = getRecovery(
- 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 = getRecovery(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(
- getRecovery(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(
- getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
- ).toBeNull();
- });
-
- it('ignores unrecognized verification errors', () => {
- expect(getRecovery(account({ id: 'idn_1', provider: 'google', errorCode: 'oauth_access_denied' }), undefined)).toBe(
- null,
- );
- });
-});
-
-describe('getEnabledOAuthStrategies', () => {
- it('sorts known strategies, appends custom ones, and drops unknown ones', () => {
- expect(
- getEnabledOAuthStrategies(social('oauth_github', 'oauth_custom_acme', 'oauth_future', 'oauth_apple')),
- ).toEqual(['oauth_apple', 'oauth_github', 'oauth_custom_acme']);
- });
-});
-
-describe('allowsIdentificationCreation', () => {
- const enterpriseSSO = { enabled: true } as EnterpriseSSOSettings;
-
- it('blocks creation for an active enterprise connection that disables additional identifications', () => {
- const blocked = user([], [{ 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 = user([], [{ active: false, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
- expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true);
-
- const active = user([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
- expect(allowsIdentificationCreation(active, { enabled: false } as EnterpriseSSOSettings)).toBe(true);
- });
-});
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 72926f693d1..ea1662d8d51 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
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
import { MosaicProvider } from '../../../mosaic-provider';
-import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
+import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
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';
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-accounts-section/user-profile-connected-account-row.view.tsx
similarity index 87%
rename from packages/mosaic/src/features/user-profile/user-profile-connected-account-row.view.tsx
rename to packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx
index b65f898a070..d80736aacda 100644
--- a/packages/mosaic/src/features/user-profile/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
@@ -1,15 +1,15 @@
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.styles';
-import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view';
+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 { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.types';
export function UserProfileConnectedAccountRowView({
account,
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
index b4676369eb0..3bde3c84322 100644
--- 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
@@ -3,7 +3,7 @@ import { useRef, useState } from 'react';
import type {
UserProfileConnectedAccount,
UserProfileConnectionProvider,
-} from '../user-profile-connected-accounts-section.view';
+} from './user-profile-connected-accounts-section.types';
export type ConnectedAccountActionResult = 'redirecting' | void;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.test.ts
new file mode 100644
index 00000000000..63c25afee2a
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.test.ts
@@ -0,0 +1,41 @@
+import { ClerkAPIResponseError } from '@clerk/shared/error';
+import { describe, expect, it } from 'vitest';
+
+import { resolveLocalization } from '../../../localization';
+import { connectedAccountErrorMessage } from './user-profile-connected-accounts-section.errors';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.model';
+
+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('localizes errors raised by the model', () => {
+ expect(connectedAccountErrorMessage(new ConnectedAccountActionError('unavailable'), messages)).toBe(
+ messages.errors.unavailable,
+ );
+ expect(connectedAccountErrorMessage(new ConnectedAccountActionError('missing_verification_url'), messages)).toBe(
+ messages.errors.missingVerificationUrl,
+ );
+ });
+
+ it('shows the first API error, preferring its long message', () => {
+ expect(
+ connectedAccountErrorMessage(
+ apiError([
+ { code: 'oauth_error', message: 'Short', long_message: 'GitHub is unavailable right now.' },
+ { code: 'other', message: 'Second' },
+ ]),
+ messages,
+ ),
+ ).toBe('GitHub is unavailable right now.');
+ expect(connectedAccountErrorMessage(apiError([{ code: 'oauth_error', message: 'Short' }]), messages)).toBe('Short');
+ });
+
+ it('falls back to the generic message', () => {
+ expect(connectedAccountErrorMessage(apiError([]), messages)).toBe(messages.errors.generic);
+ expect(connectedAccountErrorMessage(new Error('Popup closed'), messages)).toBe(messages.errors.generic);
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-errors.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.ts
similarity index 100%
rename from packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-errors.ts
rename to packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.ts
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 100%
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
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
index afc21e0c354..7f83b378291 100644
--- 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
@@ -1,7 +1,14 @@
+import type { EnterpriseSSOSettings, ExternalAccountResource, OAuthProviders, UserResource } from '@clerk/shared/types';
import { cleanup, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { useUserProfileConnectedAccountsModel } from './user-profile-connected-accounts-section.model';
+import {
+ allowsIdentificationCreation,
+ getEnabledOAuthStrategies,
+ getRecovery,
+ projectConnectedAccounts,
+ useUserProfileConnectedAccountsModel,
+} from './user-profile-connected-accounts-section.model';
type TestAccount = {
id: string;
@@ -161,3 +168,270 @@ describe('useUserProfileConnectedAccountsModel', () => {
await expect(model.remove('idn_missing')).rejects.toMatchObject({ code: 'unavailable' });
});
});
+
+type AccountInput = {
+ id: string;
+ provider: string;
+ 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): ExternalAccountResource {
+ return {
+ id,
+ provider,
+ approvedScopes,
+ username,
+ emailAddress,
+ verification: {
+ status,
+ strategy: strategy ?? `oauth_${provider}`,
+ error: errorCode ? { code: errorCode, longMessage: longMessage ?? errorCode } : null,
+ },
+ } as unknown as ExternalAccountResource;
+}
+
+function userWith(accounts: ExternalAccountResource[], enterpriseAccounts: unknown[] = []): UserResource {
+ return {
+ externalAccounts: accounts,
+ verifiedExternalAccounts: accounts.filter(a => a.verification?.status === 'verified'),
+ unverifiedExternalAccounts: accounts.filter(a => a.verification?.status !== 'verified'),
+ enterpriseAccounts,
+ } as unknown as UserResource;
+}
+
+function social(...strategies: string[]): Partial {
+ return 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,
+ },
+ ]),
+ ) as Partial;
+}
+
+describe('projectConnectedAccounts', () => {
+ it('is hidden when no social provider is enabled', () => {
+ expect(projectConnectedAccounts({ user: userWith([]), social: {}, allowCreation: true })).toEqual({
+ status: 'hidden',
+ });
+ });
+
+ it('is hidden when creation is disallowed and the user has no external accounts', () => {
+ expect(
+ projectConnectedAccounts({ user: userWith([]), social: social('oauth_google'), allowCreation: false }),
+ ).toEqual({
+ status: 'hidden',
+ });
+ });
+
+ 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' })]),
+ social: social('oauth_google'),
+ 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' }),
+ ]),
+ social: social('oauth_google', 'oauth_github', 'oauth_apple'),
+ 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' }),
+ ]),
+ social: social('oauth_google', 'oauth_github'),
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.accounts.map(a => a.identifier)).toEqual([
+ 'octo',
+ 'g@example.com',
+ ]);
+ });
+
+ it('offers enabled providers without a verified account, 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' }),
+ ]),
+ social: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
+ allowCreation: true,
+ });
+ expect(projection.status === 'ready' && projection.availableProviders.map(p => p.id)).toEqual([
+ 'oauth_github',
+ 'oauth_custom_acme',
+ ]);
+ });
+
+ it('uses configured names and logos for custom providers', () => {
+ const projection = projectConnectedAccounts({
+ user: userWith([account({ id: 'idn_1', provider: 'custom_acme' })]),
+ social: social('oauth_custom_acme'),
+ 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([]),
+ social: social('oauth_github', 'oauth_google'),
+ 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' })]),
+ social: social('oauth_google', 'oauth_github'),
+ 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.',
+ }),
+ ]),
+ social: social('oauth_google', 'oauth_github'),
+ 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('getRecovery', () => {
+ 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(getRecovery(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 = getRecovery(
+ 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 = getRecovery(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(
+ getRecovery(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(
+ getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
+ ).toBeNull();
+ });
+
+ it('ignores unrecognized verification errors', () => {
+ expect(getRecovery(account({ id: 'idn_1', provider: 'google', errorCode: 'oauth_access_denied' }), undefined)).toBe(
+ null,
+ );
+ });
+});
+
+describe('getEnabledOAuthStrategies', () => {
+ it('sorts known strategies, appends custom ones, and drops unknown ones', () => {
+ expect(
+ getEnabledOAuthStrategies(social('oauth_github', 'oauth_custom_acme', 'oauth_future', 'oauth_apple')),
+ ).toEqual(['oauth_apple', 'oauth_github', 'oauth_custom_acme']);
+ });
+});
+
+describe('allowsIdentificationCreation', () => {
+ const enterpriseSSO = { enabled: true } as EnterpriseSSOSettings;
+
+ 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 } as EnterpriseSSOSettings)).toBe(true);
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 70d65214a37..ad5e3b6fa2a 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -15,11 +15,11 @@ import type {
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
+import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
import type {
UserProfileConnectedAccount,
UserProfileConnectionProvider,
-} from '../user-profile-connected-accounts-section.view';
-import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
+} from './user-profile-connected-accounts-section.types';
export type AdditionalOAuthScopes = Partial>;
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 93%
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 f7cebd2cf30..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,6 +1,6 @@
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' },
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
index e153e914f54..4c6f4a5f332 100644
--- 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
@@ -1,14 +1,14 @@
import type { ReactNode } from 'react';
import { useMessages } from '../../../localization';
-import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view';
-import { connectedAccountErrorMessage } from './user-profile-connected-accounts-errors';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
+import { connectedAccountErrorMessage } from './user-profile-connected-accounts-section.errors';
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;
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..7b82571d622
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts
@@ -0,0 +1,25 @@
+export interface UserProfileConnectionProvider {
+ id: string;
+ provider: string;
+ iconUrl?: string;
+ monochromeIcon?: boolean;
+ 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[];
+ pendingId?: string;
+ onConnect?: (id: string) => void;
+ onReconnect?: (id: string) => void;
+ onRemove?: (id: string) => void | Promise;
+}
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 73%
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 cd1dbdb4300..58219279388 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,36 +1,20 @@
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 { 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 type {
+ UserProfileConnectedAccount,
+ UserProfileConnectedAccountsSectionViewProps,
+} from './user-profile-connected-accounts-section.types';
-export interface UserProfileConnectionProvider {
- id: string;
- provider: string;
- iconUrl?: string;
- monochromeIcon?: boolean;
- 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[];
- pendingId?: string;
- 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,
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 e0e6f191b64..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
@@ -8,7 +8,7 @@ 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 } from './user-profile-connected-accounts-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 };
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/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts
index c903cae4087..74070344775 100644
--- a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts
+++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts
@@ -1,7 +1,7 @@
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';
diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx
index 2f2b7f01abe..3132c03f8fc 100644
--- a/packages/swingset/src/stories/fixtures/user-profile.tsx
+++ b/packages/swingset/src/stories/fixtures/user-profile.tsx
@@ -5,7 +5,7 @@ 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.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 {
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.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx
index baf406d5fd0..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,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 {
UserProfileEmail,
UserProfilePhone,
From 05d5759cedce20f5d6899a5321ab815a9ecf3700 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 00:12:34 -0600
Subject: [PATCH 23/41] chore: consolidate connected accounts changesets
---
.changeset/mosaic-section-slot-alignment.md | 2 --
1 file changed, 2 deletions(-)
delete mode 100644 .changeset/mosaic-section-slot-alignment.md
diff --git a/.changeset/mosaic-section-slot-alignment.md b/.changeset/mosaic-section-slot-alignment.md
deleted file mode 100644
index a845151cc84..00000000000
--- a/.changeset/mosaic-section-slot-alignment.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
From 139de09f66589b10c35d9d4e13692a300edf40ff Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 00:20:52 -0600
Subject: [PATCH 24/41] refactor(mosaic): align connected accounts controller
with the password section
---
...ected-accounts-section.controller.test.tsx | 52 +++++++++++++++++++
...e-connected-accounts-section.controller.ts | 17 +++---
...rofile-connected-accounts-section.model.ts | 2 +-
...rofile-connected-accounts-section.types.ts | 2 +
4 files changed, 64 insertions(+), 9 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
index 72d70f44f12..d7675852757 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
@@ -22,3 +22,55 @@ it('releases pending actions when a redirect leaves the page mounted', async ()
act(() => result.current.onConnect('google'));
expect(onConnect).toHaveBeenCalledTimes(2);
});
+
+it('ignores another action while one is pending', async () => {
+ let release: () => void = () => {};
+ const onConnect = vi.fn(() => new Promise(resolve => (release = resolve)));
+ const onReconnect = vi.fn(() => Promise.resolve());
+ const { result } = renderHook(() =>
+ useUserProfileConnectedAccountsController({
+ accounts: [{ id: 'idn_github', provider: 'GitHub' }],
+ availableProviders: [{ id: 'oauth_google', provider: 'Google' }],
+ onConnect,
+ onReconnect,
+ formatError: () => 'Something went wrong.',
+ }),
+ );
+
+ act(() => result.current.onConnect('oauth_google'));
+ act(() => result.current.onConnect('oauth_google'));
+ act(() => result.current.onReconnect('idn_github'));
+ expect(onConnect).toHaveBeenCalledTimes(1);
+ expect(onReconnect).not.toHaveBeenCalled();
+
+ act(() => release());
+ await waitFor(() => expect(result.current.pendingId).toBeUndefined());
+});
+
+it('shows a formatted error on the failed row and clears it on retry', async () => {
+ const onConnect = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('raw'))
+ .mockReturnValueOnce(new Promise(() => {}));
+ const formatError = vi.fn(() => 'Formatted error.');
+ const { result } = renderHook(() =>
+ useUserProfileConnectedAccountsController({
+ accounts: [],
+ availableProviders: [
+ { id: 'oauth_google', provider: 'Google' },
+ { id: 'oauth_github', provider: 'GitHub' },
+ ],
+ onConnect,
+ onReconnect: vi.fn(),
+ formatError,
+ }),
+ );
+
+ act(() => result.current.onConnect('oauth_google'));
+ await waitFor(() => expect(result.current.availableProviders[0].connectError).toBe('Formatted error.'));
+ expect(formatError).toHaveBeenCalledWith(expect.objectContaining({ message: 'raw' }));
+ expect(result.current.availableProviders[1].connectError).toBeUndefined();
+
+ act(() => result.current.onConnect('oauth_google'));
+ expect(result.current.availableProviders[0].connectError).toBeUndefined();
+});
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
index 3bde3c84322..14e3d80b5a8 100644
--- 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
@@ -1,11 +1,18 @@
import { useRef, useState } from 'react';
import type {
+ ConnectedAccountActionResult,
UserProfileConnectedAccount,
UserProfileConnectionProvider,
} from './user-profile-connected-accounts-section.types';
-export type ConnectedAccountActionResult = 'redirecting' | void;
+export interface UserProfileConnectedAccountsControllerOptions {
+ accounts: UserProfileConnectedAccount[];
+ availableProviders: UserProfileConnectionProvider[];
+ onConnect: (id: string) => Promise;
+ onReconnect: (id: string) => Promise;
+ formatError: (error: unknown) => string;
+}
export interface UserProfileConnectedAccountsController {
accounts: UserProfileConnectedAccount[];
@@ -21,13 +28,7 @@ export function useUserProfileConnectedAccountsController({
onConnect,
onReconnect,
formatError,
-}: {
- accounts: UserProfileConnectedAccount[];
- availableProviders: UserProfileConnectionProvider[];
- onConnect: (id: string) => Promise;
- onReconnect: (id: string) => Promise;
- formatError: (error: unknown) => string;
-}): UserProfileConnectedAccountsController {
+}: UserProfileConnectedAccountsControllerOptions): UserProfileConnectedAccountsController {
const [pendingId, setPendingId] = useState();
const [connectErrors, setConnectErrors] = useState>({});
const [reconnectErrors, setReconnectErrors] = useState>({});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index ad5e3b6fa2a..0065d4569ad 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -15,8 +15,8 @@ import type {
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
-import type { ConnectedAccountActionResult } from './user-profile-connected-accounts-section.controller';
import type {
+ ConnectedAccountActionResult,
UserProfileConnectedAccount,
UserProfileConnectionProvider,
} from './user-profile-connected-accounts-section.types';
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
index 7b82571d622..6f528ac39bb 100644
--- 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
@@ -1,3 +1,5 @@
+export type ConnectedAccountActionResult = 'redirecting' | void;
+
export interface UserProfileConnectionProvider {
id: string;
provider: string;
From 3620fbdcdfe1540103555c1df5d3ea31ce44be22 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 00:22:06 -0600
Subject: [PATCH 25/41] refactor(mosaic): match password section error naming
in connected accounts
---
...s => user-profile-connected-accounts-feedback.test.ts} | 4 ++--
...ors.ts => user-profile-connected-accounts-feedback.ts} | 2 +-
.../user-profile-connected-accounts-section.model.ts | 8 +-------
.../user-profile-connected-accounts-section.tsx | 2 +-
.../user-profile-connected-accounts-section.types.ts | 7 +++++++
5 files changed, 12 insertions(+), 11 deletions(-)
rename packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/{user-profile-connected-accounts-section.errors.test.ts => user-profile-connected-accounts-feedback.test.ts} (96%)
rename packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/{user-profile-connected-accounts-section.errors.ts => user-profile-connected-accounts-feedback.ts} (95%)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts
similarity index 96%
rename from packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.test.ts
rename to packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts
index 63c25afee2a..c6da86a9075 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.test.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts
@@ -2,8 +2,8 @@ import { ClerkAPIResponseError } from '@clerk/shared/error';
import { describe, expect, it } from 'vitest';
import { resolveLocalization } from '../../../localization';
-import { connectedAccountErrorMessage } from './user-profile-connected-accounts-section.errors';
-import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.model';
+import { connectedAccountErrorMessage } from './user-profile-connected-accounts-feedback';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
const messages = resolveLocalization({ locale: 'en' }).messages.userProfileConnectedAccounts;
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts
similarity index 95%
rename from packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.ts
rename to packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts
index f086fb5f236..1cd1f55eab4 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.errors.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.ts
@@ -1,7 +1,7 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
import type { MosaicMessages } from '../../../localization';
-import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.model';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
type Messages = MosaicMessages['userProfileConnectedAccounts'];
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 0065d4569ad..6d05af8948b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -20,6 +20,7 @@ import type {
UserProfileConnectedAccount,
UserProfileConnectionProvider,
} from './user-profile-connected-accounts-section.types';
+import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
export type AdditionalOAuthScopes = Partial>;
@@ -171,13 +172,6 @@ export function projectConnectedAccounts({
};
}
-export class ConnectedAccountActionError extends Error {
- constructor(readonly code: 'unavailable' | 'missing_verification_url') {
- super(code);
- this.name = 'ConnectedAccountActionError';
- }
-}
-
export type UserProfileConnectedAccountsModel =
| { status: 'loading' }
| { status: 'hidden'; reason: 'no_user' | 'unavailable' }
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
index 4c6f4a5f332..6f8ce1420ba 100644
--- 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
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
import { useMessages } from '../../../localization';
+import { connectedAccountErrorMessage } from './user-profile-connected-accounts-feedback';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
-import { connectedAccountErrorMessage } from './user-profile-connected-accounts-section.errors';
import type {
AdditionalOAuthScopes,
UserProfileConnectedAccountsModel,
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
index 6f528ac39bb..8abda17aca5 100644
--- 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
@@ -25,3 +25,10 @@ export interface UserProfileConnectedAccountsSectionViewProps {
onReconnect?: (id: string) => void;
onRemove?: (id: string) => void | Promise;
}
+
+export class ConnectedAccountActionError extends Error {
+ constructor(readonly code: 'unavailable' | 'missing_verification_url') {
+ super(code);
+ this.name = 'ConnectedAccountActionError';
+ }
+}
From e26bba637f05a274b8e3bfac69a3d67a81baee79 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 00:32:43 -0600
Subject: [PATCH 26/41] fix(mosaic): retry failed connected accounts from their
row
---
...rofile-connected-accounts.feature.test.tsx | 31 +++++++++++++++++++
...ser-profile-connected-account-row.view.tsx | 7 +++--
...ile-connected-accounts-section.messages.ts | 1 +
...e-connected-accounts-section.model.test.ts | 26 ++++++++++++++--
...rofile-connected-accounts-section.model.ts | 20 ++++++++++--
5 files changed, 78 insertions(+), 7 deletions(-)
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
index 826c781fa4b..42c4bdeda16 100644
--- 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
@@ -132,6 +132,37 @@ describe('connected accounts', () => {
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({
+ status: 'unverified',
+ strategy: 'oauth_google',
+ 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(
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
index d80736aacda..f608f0926dd 100644
--- 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
@@ -31,8 +31,11 @@ export function UserProfileConnectedAccountRowView({
const m = useMessages('userProfileConnectedAccounts');
const iconUrl = account.iconUrl?.trim();
const actions: ActionMenuAction[] = [];
- if (account.status === 'reconnect' && onReconnect && !isDisabled) {
- actions.push({ label: m.reconnect, onClick: () => onReconnect(account.id) });
+ 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 && account.canRemove !== false) {
actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(account) });
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages.ts
index 7d41c8f0720..c181f31860b 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.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',
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
index 7f83b378291..47b5a5911de 100644
--- 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
@@ -6,6 +6,7 @@ import {
allowsIdentificationCreation,
getEnabledOAuthStrategies,
getRecovery,
+ getRetry,
projectConnectedAccounts,
useUserProfileConnectedAccountsModel,
} from './user-profile-connected-accounts-section.model';
@@ -281,17 +282,17 @@ describe('projectConnectedAccounts', () => {
]);
});
- it('offers enabled providers without a verified account, keyed by strategy', () => {
+ 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' }),
]),
social: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
allowCreation: true,
});
expect(projection.status === 'ready' && projection.availableProviders.map(p => p.id)).toEqual([
- 'oauth_github',
'oauth_custom_acme',
]);
});
@@ -435,3 +436,24 @@ describe('allowsIdentificationCreation', () => {
expect(allowsIdentificationCreation(active, { enabled: false } as EnterpriseSSOSettings)).toBe(true);
});
});
+
+describe('getRetry', () => {
+ it('retries a failed connection with its strategy and missing scopes', () => {
+ expect(
+ getRetry(
+ 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(getRetry(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
index 6d05af8948b..6eee76a19b4 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -116,6 +116,20 @@ export function getRecovery(
return null;
}
+ return createRecovery(account, additionalScopes);
+}
+
+export function getRetry(
+ account: ExternalAccountResource,
+ scopes: AdditionalOAuthScopes | undefined,
+): ConnectedAccountRecovery | null {
+ if (!account.verification?.error) {
+ return null;
+ }
+ return createRecovery(account, findAdditionalScopes(account, scopes));
+}
+
+function createRecovery(account: ExternalAccountResource, additionalScopes: string[]): ConnectedAccountRecovery {
const verificationStrategy = account.verification?.strategy;
const strategy = (
verificationStrategy === 'google_one_tap' ? 'oauth_google' : verificationStrategy || `oauth_${account.provider}`
@@ -159,14 +173,14 @@ export function projectConnectedAccounts({
...user.verifiedExternalAccounts,
...user.unverifiedExternalAccounts.filter(account => account.verification?.error),
];
- const connectedStrategies = user.verifiedExternalAccounts.map(account => `oauth_${account.provider}`);
+ const shownStrategies = displayed.map(account => `oauth_${account.provider}`);
return {
status: 'ready',
accounts: displayed.map(account => toAccountRow(account, social, additionalOAuthScopes)),
availableProviders: allowCreation
? getEnabledOAuthStrategies(social)
- .filter(strategy => !connectedStrategies.includes(strategy))
+ .filter(strategy => !shownStrategies.includes(strategy))
.map(strategy => ({ id: strategy, ...getProviderDisplay(strategy.replace('oauth_', ''), social) }))
: [],
};
@@ -274,7 +288,7 @@ export function useUserProfileConnectedAccountsModel({
if (!account) {
throw new ConnectedAccountActionError('unavailable');
}
- const recovery = getRecovery(account, additionalOAuthScopes);
+ const recovery = getRecovery(account, additionalOAuthScopes) ?? getRetry(account, additionalOAuthScopes);
if (!recovery) {
return;
}
From aca024782673a02c1c3bf0a5494175ffd98df571 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 09:14:32 -0600
Subject: [PATCH 27/41] fix(mosaic): reset connected account connect guard in
finally
---
...-profile-connected-accounts-section.controller.ts | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
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
index 14e3d80b5a8..18a1c4f4db6 100644
--- 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
@@ -32,17 +32,17 @@ export function useUserProfileConnectedAccountsController({
const [pendingId, setPendingId] = useState();
const [connectErrors, setConnectErrors] = useState>({});
const [reconnectErrors, setReconnectErrors] = useState>({});
- const inFlight = useRef(undefined);
+ const connecting = useRef(false);
const run = async (
id: string,
action: (id: string) => Promise,
setErrors: typeof setConnectErrors,
) => {
- if (inFlight.current) {
+ if (connecting.current) {
return;
}
- inFlight.current = id;
+ connecting.current = true;
setPendingId(id);
setErrors(({ [id]: _cleared, ...rest }) => rest);
@@ -52,10 +52,10 @@ export function useUserProfileConnectedAccountsController({
}
} catch (error) {
setErrors(current => ({ ...current, [id]: formatError(error) }));
+ } finally {
+ connecting.current = false;
+ setPendingId(undefined);
}
-
- inFlight.current = undefined;
- setPendingId(undefined);
};
return {
From ee289b2da28692f376fad0f279facb250230c5d8 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 09:48:19 -0600
Subject: [PATCH 28/41] test(mosaic): cover connected accounts through profile
feature tests
---
...e-connected-account-removal-focus.test.tsx | 60 +++++++
...rofile-connected-accounts.feature.test.tsx | 27 +++-
.../user-profile-profile-panel.view.test.tsx | 148 +-----------------
3 files changed, 92 insertions(+), 143 deletions(-)
create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx
new file mode 100644
index 00000000000..6fa8a11d729
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx
@@ -0,0 +1,60 @@
+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 } from 'vitest';
+
+import { MosaicProvider } from '../../../MosaicProvider';
+import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
+import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view';
+
+describe('connected account removal focus', () => {
+ it('keeps the final account confirmation mounted until removal settles', async () => {
+ const user = userEvent.setup();
+ const titleRef = createRef();
+ const removal = createDeferredPromise();
+ const onRemove = () => removal.promise;
+ const { rerender } = render(
+
+ titleRef.current}
+ onRemove={onRemove}
+ />
+ }
+ />
+ ,
+ );
+ 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(
+
+ titleRef.current}
+ onRemove={onRemove}
+ />
+ }
+ />
+ ,
+ );
+ 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$/));
+ });
+});
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
index 42c4bdeda16..9fe21b7ff89 100644
--- 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
@@ -1,6 +1,7 @@
import { 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';
@@ -15,6 +16,7 @@ import {
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
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' });
@@ -59,7 +61,13 @@ function signedIn(accounts = [google], overrides: FakeFapiSeed = {}) {
async function renderSection(accounts = [google], overrides: FakeFapiSeed = {}) {
const fapi = serveFapi(signedIn(accounts, overrides));
- const view = await renderWithClerk();
+ const titleRef = createRef();
+ const view = await renderWithClerk(
+ titleRef.current} />}
+ />,
+ );
return { ...view, fapi };
}
@@ -85,6 +93,8 @@ describe('connected accounts', () => {
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();
@@ -98,6 +108,14 @@ describe('connected accounts', () => {
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] });
@@ -378,14 +396,21 @@ describe('connected accounts', () => {
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']);
});
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 ea1662d8d51..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
@@ -6,7 +6,6 @@ import { describe, expect, it, vi } from 'vitest';
import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
import { MosaicProvider } from '../../../mosaic-provider';
-import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
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';
@@ -40,80 +39,6 @@ function renderView(overrides: Partial = {}) {
}
describe('UserProfileProfilePanelView', () => {
- it('renders account and wallet slots with a focus target at the panel title', () => {
- const titleRef = createRef();
-
- renderView({
- titleRef,
- connectedAccountsSlot: Connected
,
- web3WalletsSlot: Wallets
,
- });
-
- expect(screen.getByTestId('connected-slot')).toBeInTheDocument();
- expect(screen.getByTestId('wallet-slot')).toBeInTheDocument();
- expect(titleRef.current).toHaveTextContent(/^Account$/);
- });
-
- it('hides connected accounts when only providers without a connect callback are supplied', () => {
- renderView({
- connectedAccountsSlot: (
-
- ),
- });
- 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 titleRef = createRef();
- const removal = createDeferredPromise();
- const onRemoveConnectedAccount = vi.fn(async () => {
- await removal.promise;
- });
- const { rerender } = renderView({
- titleRef,
- connectedAccountsSlot: (
- titleRef.current}
- onRemove={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(
-
- titleRef.current}
- onRemove={onRemoveConnectedAccount}
- />
- }
- />
- ,
- );
- 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();
@@ -159,20 +84,6 @@ describe('UserProfileProfilePanelView', () => {
await waitFor(() => expect(document.activeElement).toHaveTextContent(/^Account$/));
});
- it('keeps available providers visible without connected accounts', () => {
- const onConnectAccount = vi.fn();
- renderView({
- connectedAccountsSlot: (
-
- ),
- });
- 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,
@@ -343,26 +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({
- connectedAccountsSlot: (
-
- ),
- onDeleteAccount,
+ 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(
@@ -370,13 +266,8 @@ describe('UserProfileProfilePanelView', () => {
);
});
- it('renders connected provider and Web3 images inside icon frames', () => {
+ it('renders Web3 images inside icon frames', () => {
const { container } = renderView({
- connectedAccountsSlot: (
-
- ),
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'));
});
@@ -498,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({
@@ -518,12 +407,6 @@ describe('UserProfileProfilePanelView', () => {
{ id: 'phone_unverified', value: '+1 801-555-0100', isVerified: false },
{ id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true },
],
- connectedAccountsSlot: (
-
- ),
onVerifyEmail,
onSetPrimaryEmail,
onRemoveEmail,
@@ -573,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', () => {
@@ -599,18 +470,11 @@ describe('UserProfileProfilePanelView', () => {
},
],
phones: [],
- connectedAccountsSlot: (
-
- ),
onVerifyEmail: vi.fn(),
onSetPrimaryEmail: vi.fn(),
onRemoveEmail: vi.fn(),
});
expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument();
});
});
From 71e1d6fff027f02d00748d1eeb185c85003cc59c Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 12:14:44 -0600
Subject: [PATCH 29/41] fix(mosaic): reconcile connected account fixtures with
main
---
.../mosaic/src/__tests__/feature/fake-fapi.ts | 6 ++----
packages/mosaic/src/__tests__/feature/fapi.ts | 17 +----------------
...-profile-connected-accounts.feature.test.tsx | 11 ++++-------
3 files changed, 7 insertions(+), 27 deletions(-)
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index 9c8db692b5d..e05a23ea380 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -147,9 +147,8 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const account = fapiExternalAccount({
id: `idn_${provider}`,
provider,
- verification: fapiVerification({
+ verification: fapiVerification(strategy, {
status: 'unverified',
- strategy,
external_verification_redirect_url: 'https://accounts.example/authorize',
}),
});
@@ -164,9 +163,8 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
? envelope(
{
...account,
- verification: fapiVerification({
+ verification: fapiVerification(`oauth_${account.provider}`, {
status: 'unverified',
- strategy: `oauth_${account.provider}`,
external_verification_redirect_url: 'https://accounts.example/consent',
}),
},
diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts
index 9482ec59274..92d0047e080 100644
--- a/packages/mosaic/src/__tests__/feature/fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fapi.ts
@@ -252,22 +252,7 @@ export function fapiExternalAccount(
phone_number: '',
public_metadata: {},
label: '',
- verification: fapiVerification({ strategy: `oauth_${overrides.provider}` }),
- ...overrides,
- };
-}
-
-export function fapiVerification(
- overrides: Partial & Pick,
-): VerificationJSON {
- return {
- object: 'verification',
- id: 'verification_1',
- status: 'verified',
- verified_at_client: '',
- attempts: 0,
- expire_at: Date.UTC(2100, 0, 1),
- error: { code: '', message: '', long_message: '' },
+ verification: fapiVerification(`oauth_${overrides.provider}`, { status: 'verified' }),
...overrides,
};
}
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
index 9fe21b7ff89..ac2aed2f15a 100644
--- 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
@@ -23,9 +23,8 @@ const github = fapiExternalAccount({ id: 'idn_github', provider: 'github' });
const disconnectedGoogle = fapiExternalAccount({
id: 'idn_google',
provider: 'google',
- verification: fapiVerification({
+ verification: fapiVerification('google_one_tap', {
status: 'unverified',
- strategy: 'google_one_tap',
error: { code: 'external_account_missing_refresh_token', message: 'Missing token', long_message: 'Missing token' },
}),
});
@@ -139,9 +138,8 @@ describe('connected accounts', () => {
const failed = fapiExternalAccount({
id: 'idn_google',
provider: 'google',
- verification: fapiVerification({
+ verification: fapiVerification('oauth_google', {
status: 'unverified',
- strategy: 'oauth_google',
error: { code: 'provider_error', message: 'Provider error', long_message: 'Provider error' },
}),
});
@@ -154,9 +152,8 @@ describe('connected accounts', () => {
const canceled = fapiExternalAccount({
id: 'idn_google',
provider: 'google',
- verification: fapiVerification({
+ verification: fapiVerification('oauth_google', {
status: 'unverified',
- strategy: 'oauth_google',
error: {
code: 'oauth_access_denied',
message: 'Access denied',
@@ -253,7 +250,7 @@ describe('connected accounts', () => {
response: fapiExternalAccount({
id: 'idn_github',
provider: 'github',
- verification: fapiVerification({ status: 'unverified', strategy: 'oauth_github' }),
+ verification: fapiVerification('oauth_github', { status: 'unverified' }),
}),
client: null,
}),
From 348ad8bccf50094a0f1b37c7491b80e7fa013922 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 20:53:32 -0600
Subject: [PATCH 30/41] test(mosaic): cover connected account lifecycle
regressions
---
...rofile-connected-accounts.feature.test.tsx | 113 +++++++++++++++++-
1 file changed, 112 insertions(+), 1 deletion(-)
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
index ac2aed2f15a..8f430af79a4 100644
--- 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
@@ -1,4 +1,4 @@
-import { screen, waitFor, within } from '@testing-library/react';
+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';
@@ -501,4 +501,115 @@ describe('connected accounts', () => {
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 => {
+ 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 = Promise.withResolvers();
+ 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()));
+ await act(async () => {
+ redirect.resolve('https://app.example/callback');
+ });
+ expect(create).not.toHaveBeenCalled();
+ expect(open).not.toHaveBeenCalled();
+ });
+
+ it('restores the profile title after removing the final enterprise-restricted account', async () => {
+ const environment = signedIn().environment;
+ 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(screen.getByRole('heading', { name: 'Account', level: 2 })).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 = Promise.withResolvers<{ 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 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 = Promise.withResolvers<{ callbackUrl: string }>();
+ const open = vi.fn(() => callback.promise);
+ 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);
+ callback.resolve({ callbackUrl: 'https://app.example/callback' });
+ 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);
+ });
});
From 9c8c9290f009daa9b5fefd32872a633d180b9a2c Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 21:01:00 -0600
Subject: [PATCH 31/41] test(mosaic): exercise OAuth continuations through
Clerk
---
...rofile-connected-accounts.feature.test.tsx | 259 +++++++++++++++++-
1 file changed, 254 insertions(+), 5 deletions(-)
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
index 8f430af79a4..8145a284794 100644
--- 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
@@ -15,6 +15,7 @@ import {
fapiVerification,
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
+import { MosaicProvider } from '../../../MosaicProvider';
import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section';
import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view';
@@ -67,7 +68,7 @@ async function renderSection(accounts = [google], overrides: FakeFapiSeed = {})
connectedAccountsSlot={ titleRef.current} />}
/>,
);
- return { ...view, fapi };
+ return { ...view, fapi, titleRef };
}
async function openRemoval(user: ReturnType, provider: string) {
@@ -532,7 +533,7 @@ describe('connected accounts', () => {
it('restores the profile title after removing the final enterprise-restricted account', async () => {
const environment = signedIn().environment;
- await renderSection([google], {
+ const { titleRef } = await renderSection([google], {
client: fapiClient([
fapiSession({
id: 'sess_1',
@@ -556,7 +557,7 @@ describe('connected accounts', () => {
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(screen.getByRole('heading', { name: 'Account', level: 2 })).toHaveFocus());
+ await waitFor(() => expect(titleRef.current).toHaveFocus());
});
it('persists pending Connect and replaces a superseded provider attempt', async () => {
@@ -597,7 +598,7 @@ describe('connected accounts', () => {
const fapi = serveFapi(signedIn([google, github]));
const callback = Promise.withResolvers<{ callbackUrl: string }>();
const open = vi.fn(() => callback.promise);
- await renderWithClerk(
+ const { clerk } = await renderWithClerk(
,
{
__internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
@@ -608,8 +609,256 @@ describe('connected accounts', () => {
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);
- callback.resolve({ callbackUrl: 'https://app.example/callback' });
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 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 = Promise.withResolvers();
+ 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');
+ });
+ 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 = Promise.withResolvers();
+ 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({
+ 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 = Promise.withResolvers<{ 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' });
+ });
+ 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 = Promise.withResolvers<{ 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' });
+ });
+ 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('form_password_incorrect', 'Server copy');
+ expect(await screen.findByText('Localized account error.')).toBeInTheDocument();
+ expect(screen.queryByText('Server copy')).toBeNull();
+ });
+
+ it.todo('challenges and resumes connect, reconnect, and removal when session reverification is required');
});
From 36a23dc884f63a8e0e27bbe6ca9598c87fd2f742 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 21:39:11 -0600
Subject: [PATCH 32/41] fix(mosaic): localize connected account errors in the
model
---
...ser-profile-connected-accounts-feedback.ts | 12 +-
...e-connected-accounts-section.controller.ts | 6 +-
...rofile-connected-accounts-section.model.ts | 162 +++++++++++-------
...ser-profile-connected-accounts-section.tsx | 13 +-
packages/mosaic/src/localization/index.ts | 2 +
5 files changed, 118 insertions(+), 77 deletions(-)
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
index 1cd1f55eab4..63a114440c9 100644
--- 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
@@ -1,11 +1,15 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
-import type { MosaicMessages } from '../../../localization';
+import type { LocalizableError, MosaicMessages } from '../../../localization';
import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
type Messages = MosaicMessages['userProfileConnectedAccounts'];
-export function connectedAccountErrorMessage(error: unknown, messages: Messages): string {
+export function connectedAccountErrorMessage(
+ error: unknown,
+ messages: Messages,
+ errorText: (error: LocalizableError) => string,
+): string {
if (error instanceof ConnectedAccountActionError) {
return error.code === 'missing_verification_url'
? messages.errors.missingVerificationUrl
@@ -13,7 +17,9 @@ export function connectedAccountErrorMessage(error: unknown, messages: Messages)
}
if (isClerkAPIResponseError(error)) {
const first = error.errors[0];
- return first?.longMessage || first?.message || messages.errors.generic;
+ return first
+ ? errorText({ code: first.code, paramName: first.meta?.paramName, message: first.longMessage || first.message })
+ : messages.errors.generic;
}
return messages.errors.generic;
}
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
index 18a1c4f4db6..057e4cee85e 100644
--- 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
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
+import { useMessages } from '../../../localization';
import type {
ConnectedAccountActionResult,
UserProfileConnectedAccount,
@@ -11,7 +12,6 @@ export interface UserProfileConnectedAccountsControllerOptions {
availableProviders: UserProfileConnectionProvider[];
onConnect: (id: string) => Promise;
onReconnect: (id: string) => Promise;
- formatError: (error: unknown) => string;
}
export interface UserProfileConnectedAccountsController {
@@ -27,8 +27,8 @@ export function useUserProfileConnectedAccountsController({
availableProviders,
onConnect,
onReconnect,
- formatError,
}: UserProfileConnectedAccountsControllerOptions): UserProfileConnectedAccountsController {
+ const messages = useMessages('userProfileConnectedAccounts');
const [pendingId, setPendingId] = useState();
const [connectErrors, setConnectErrors] = useState>({});
const [reconnectErrors, setReconnectErrors] = useState>({});
@@ -51,7 +51,7 @@ export function useUserProfileConnectedAccountsController({
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
- setErrors(current => ({ ...current, [id]: formatError(error) }));
+ setErrors(current => ({ ...current, [id]: error instanceof Error ? error.message : messages.errors.generic }));
} finally {
connecting.current = false;
setPendingId(undefined);
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 6eee76a19b4..197f09072bc 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -10,11 +10,12 @@ import type {
OAuthProviders,
OAuthScope,
OAuthStrategy,
- UserResource,
} from '@clerk/shared/types';
import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
+import { useErrorText, useMessages } from '../../../localization';
+import { connectedAccountErrorMessage } from './user-profile-connected-accounts-feedback';
import type {
ConnectedAccountActionResult,
UserProfileConnectedAccount,
@@ -22,6 +23,24 @@ import type {
} from './user-profile-connected-accounts-section.types';
import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
+type AccountData = Pick & {
+ verification: {
+ strategy: string | null;
+ error: { code: string; longMessage?: string } | null;
+ } | null;
+};
+type EnterpriseUser = {
+ enterpriseAccounts: {
+ active: boolean;
+ enterpriseConnection?: { disableAdditionalIdentifications: boolean } | null;
+ }[];
+};
+type ProjectedUser = {
+ externalAccounts: AccountData[];
+ verifiedExternalAccounts: AccountData[];
+ unverifiedExternalAccounts: AccountData[];
+};
+
export type AdditionalOAuthScopes = Partial>;
export type ConnectedAccountRecovery =
@@ -45,11 +64,13 @@ const RECONNECT_ERROR_CODES = [
const MONOCHROME_PROVIDERS = ['agentid', 'apple', 'github', 'okx_wallet', 'vercel', 'x'];
-const KNOWN_OAUTH_STRATEGIES: readonly string[] = OAUTH_PROVIDERS.map(p => p.strategy);
+type SocialSettings = Partial<
+ Record & { strategy: string }>
+>;
export function getProviderDisplay(
provider: string,
- social: Partial,
+ social: SocialSettings,
): Omit {
const known = OAUTH_PROVIDERS.find(p => p.provider === provider);
if (known) {
@@ -60,7 +81,7 @@ export function getProviderDisplay(
};
}
- const custom = social[`oauth_${provider}` as OAuthStrategy];
+ const custom = Object.values(social).find(settings => settings?.strategy === `oauth_${provider}`);
if (custom) {
return { provider: custom.name || provider, iconUrl: custom.logo_url || undefined };
}
@@ -68,14 +89,21 @@ export function getProviderDisplay(
return { provider };
}
-export function getEnabledOAuthStrategies(social: Partial): OAuthStrategy[] {
+export function getEnabledOAuthStrategies(social: SocialSettings): OAuthStrategy[] {
const enabled = Object.values(social)
.flatMap(settings => (settings?.enabled ? [settings.strategy] : []))
.sort();
- const known = enabled.filter(strategy => KNOWN_OAUTH_STRATEGIES.includes(strategy));
- const custom = enabled.filter(
- strategy => !KNOWN_OAUTH_STRATEGIES.includes(strategy) && strategy.startsWith('oauth_custom_'),
- );
+ const known = enabled.flatMap(strategy => {
+ const provider = OAUTH_PROVIDERS.find(provider => provider.strategy === strategy);
+ return provider ? [provider.strategy] : [];
+ });
+ const custom = enabled.flatMap(strategy => {
+ if (!strategy.startsWith('oauth_custom_')) {
+ return [];
+ }
+ const customStrategy: OAuthStrategy = `oauth_custom_${strategy.slice('oauth_custom_'.length)}`;
+ return [customStrategy];
+ });
return [...known, ...custom];
}
@@ -88,7 +116,10 @@ function providerFor(strategy: OAuthStrategy): OAuthProvider {
return custom;
}
-export function allowsIdentificationCreation(user: UserResource, enterpriseSSO: EnterpriseSSOSettings): boolean {
+export function allowsIdentificationCreation(
+ user: EnterpriseUser,
+ enterpriseSSO: Pick,
+): boolean {
if (!enterpriseSSO.enabled) {
return true;
}
@@ -97,14 +128,14 @@ export function allowsIdentificationCreation(user: UserResource, enterpriseSSO:
);
}
-function findAdditionalScopes(account: ExternalAccountResource, scopes: AdditionalOAuthScopes | undefined): string[] {
+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 getRecovery(
- account: ExternalAccountResource,
+ account: AccountData,
scopes: AdditionalOAuthScopes | undefined,
): ConnectedAccountRecovery | null {
const additionalScopes = findAdditionalScopes(account, scopes);
@@ -120,7 +151,7 @@ export function getRecovery(
}
export function getRetry(
- account: ExternalAccountResource,
+ account: AccountData,
scopes: AdditionalOAuthScopes | undefined,
): ConnectedAccountRecovery | null {
if (!account.verification?.error) {
@@ -129,7 +160,7 @@ export function getRetry(
return createRecovery(account, findAdditionalScopes(account, scopes));
}
-function createRecovery(account: ExternalAccountResource, additionalScopes: string[]): ConnectedAccountRecovery {
+function createRecovery(account: AccountData, additionalScopes: string[]): ConnectedAccountRecovery {
const verificationStrategy = account.verification?.strategy;
const strategy = (
verificationStrategy === 'google_one_tap' ? 'oauth_google' : verificationStrategy || `oauth_${account.provider}`
@@ -138,8 +169,8 @@ function createRecovery(account: ExternalAccountResource, additionalScopes: stri
}
function toAccountRow(
- account: ExternalAccountResource,
- social: Partial,
+ account: AccountData,
+ social: SocialSettings,
scopes: AdditionalOAuthScopes | undefined,
): UserProfileConnectedAccount {
const error = account.verification?.error;
@@ -159,8 +190,8 @@ export function projectConnectedAccounts({
allowCreation,
additionalOAuthScopes,
}: {
- user: UserResource;
- social: Partial;
+ user: ProjectedUser;
+ social: SocialSettings;
allowCreation: boolean;
additionalOAuthScopes?: AdditionalOAuthScopes;
}): ConnectedAccountsProjection {
@@ -210,6 +241,8 @@ export function useUserProfileConnectedAccountsModel({
const { isLoaded, user } = useUser();
const environment = useMosaicEnvironment();
const router = useMosaicRouter();
+ const messages = useMessages('userProfileConnectedAccounts');
+ const errorText = useErrorText();
if (!isLoaded || !environment) {
return { status: 'loading' };
@@ -243,8 +276,6 @@ export function useUserProfileConnectedAccountsModel({
const withModalState = (url: string, socialProvider?: string) =>
mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile', socialProvider }) : url;
- // TODO: Add session reverification for account connect, reconnect, and removal;
- // surface API errors until then.
const completeVerification = async (response: ExternalAccountResource): Promise => {
const url = response.verification?.externalVerificationRedirectURL;
if (!url) {
@@ -267,52 +298,65 @@ export function useUserProfileConnectedAccountsModel({
...projection,
userId,
connect: async strategyId => {
- const current = currentUser();
- const strategy = getEnabledOAuthStrategies(environment.userSettings.social).find(
- candidate => candidate === strategyId,
- );
- if (!strategy) {
- throw new ConnectedAccountActionError('unavailable');
+ try {
+ const current = currentUser();
+ const strategy = getEnabledOAuthStrategies(environment.userSettings.social).find(
+ candidate => candidate === strategyId,
+ );
+ if (!strategy) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ const provider = providerFor(strategy);
+ const redirectUrl = await getRedirectUrl();
+ const response = await current.createExternalAccount({
+ strategy,
+ redirectUrl: withModalState(redirectUrl, provider),
+ additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
+ });
+ return await completeVerification(response);
+ } catch (error) {
+ throw new Error(connectedAccountErrorMessage(error, messages, errorText));
}
- const provider = providerFor(strategy);
- const response = await current.createExternalAccount({
- strategy,
- redirectUrl: withModalState(await getRedirectUrl(), provider),
- additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
- });
- return completeVerification(response);
},
reconnect: async accountId => {
- const current = currentUser();
- const account = current.externalAccounts.find(candidate => candidate.id === accountId);
- if (!account) {
- throw new ConnectedAccountActionError('unavailable');
+ try {
+ const current = currentUser();
+ const account = current.externalAccounts.find(candidate => candidate.id === accountId);
+ if (!account) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ const recovery = getRecovery(account, additionalOAuthScopes) ?? getRetry(account, additionalOAuthScopes);
+ if (!recovery) {
+ return;
+ }
+
+ const redirectUrl = await getRedirectUrl();
+ 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 await completeVerification(response);
+ } catch (error) {
+ throw new Error(connectedAccountErrorMessage(error, messages, errorText));
}
- const recovery = getRecovery(account, additionalOAuthScopes) ?? getRetry(account, additionalOAuthScopes);
- if (!recovery) {
- return;
- }
-
- const redirectUrl = await getRedirectUrl();
- 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: async accountId => {
- const account = currentUser().externalAccounts.find(candidate => candidate.id === accountId);
- if (!account) {
- throw new ConnectedAccountActionError('unavailable');
+ try {
+ const account = currentUser().externalAccounts.find(candidate => candidate.id === accountId);
+ if (!account) {
+ throw new ConnectedAccountActionError('unavailable');
+ }
+ await account.destroy();
+ } catch (error) {
+ throw new Error(connectedAccountErrorMessage(error, messages, errorText));
}
- await account.destroy();
},
};
}
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
index 6f8ce1420ba..d601119b69c 100644
--- 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
@@ -1,7 +1,5 @@
import type { ReactNode } from 'react';
-import { useMessages } from '../../../localization';
-import { connectedAccountErrorMessage } from './user-profile-connected-accounts-feedback';
import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
import type {
AdditionalOAuthScopes,
@@ -46,27 +44,18 @@ function ConnectedAccounts({
model: Extract;
fallbackFocus?: () => HTMLElement | null;
}) {
- const m = useMessages('userProfileConnectedAccounts');
const controller = useUserProfileConnectedAccountsController({
accounts: model.accounts,
availableProviders: model.availableProviders,
onConnect: model.connect,
onReconnect: model.reconnect,
- formatError: error => connectedAccountErrorMessage(error, m),
});
- const remove = async (accountId: string) => {
- try {
- await model.remove(accountId);
- } catch (error) {
- throw new Error(connectedAccountErrorMessage(error, m));
- }
- };
return (
);
}
diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts
index 8d5633e4bac..5a0ef3edfab 100644
--- a/packages/mosaic/src/localization/index.ts
+++ b/packages/mosaic/src/localization/index.ts
@@ -6,3 +6,5 @@ export type { MosaicMessages } from './registry';
export { mosaicMessages } from './registry';
export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages';
export { fill, plural, rich } from './messages';
+export { useErrorText } from './errors';
+export type { LocalizableError } from './errors';
From 7147537929ce160e6d36366033721d28c5c4a1b9 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 21:39:11 -0600
Subject: [PATCH 33/41] fix(mosaic): abort stale connected account OAuth
actions
---
.../user-profile-connected-accounts-section.model.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 197f09072bc..75744cdf7dd 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -282,6 +282,7 @@ export function useUserProfileConnectedAccountsModel({
throw new ConnectedAccountActionError('missing_verification_url');
}
+ currentUser();
if (transport) {
const { callbackUrl } = await transport.open(url);
const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
@@ -299,7 +300,6 @@ export function useUserProfileConnectedAccountsModel({
userId,
connect: async strategyId => {
try {
- const current = currentUser();
const strategy = getEnabledOAuthStrategies(environment.userSettings.social).find(
candidate => candidate === strategyId,
);
@@ -308,6 +308,7 @@ export function useUserProfileConnectedAccountsModel({
}
const provider = providerFor(strategy);
const redirectUrl = await getRedirectUrl();
+ const current = currentUser();
const response = await current.createExternalAccount({
strategy,
redirectUrl: withModalState(redirectUrl, provider),
@@ -320,6 +321,7 @@ export function useUserProfileConnectedAccountsModel({
},
reconnect: async accountId => {
try {
+ const redirectUrl = await getRedirectUrl();
const current = currentUser();
const account = current.externalAccounts.find(candidate => candidate.id === accountId);
if (!account) {
@@ -330,7 +332,6 @@ export function useUserProfileConnectedAccountsModel({
return;
}
- const redirectUrl = await getRedirectUrl();
const response =
recovery.kind === 'reauthorize'
? await account.reauthorize({
From b56560ee338470f9c00b3adb37a7e772e85eee19 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 21:39:11 -0600
Subject: [PATCH 34/41] fix(mosaic): preserve focus after final connected
account removal
---
.../user-profile-connected-accounts-section.model.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 75744cdf7dd..42b3e12967a 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -36,7 +36,6 @@ type EnterpriseUser = {
}[];
};
type ProjectedUser = {
- externalAccounts: AccountData[];
verifiedExternalAccounts: AccountData[];
unverifiedExternalAccounts: AccountData[];
};
@@ -196,7 +195,7 @@ export function projectConnectedAccounts({
additionalOAuthScopes?: AdditionalOAuthScopes;
}): ConnectedAccountsProjection {
const socialEnabled = Object.values(social).some(settings => settings?.enabled);
- if (!socialEnabled || (!allowCreation && user.externalAccounts.length === 0)) {
+ if (!socialEnabled) {
return { status: 'hidden' };
}
From f305b9f1f3e57b5c6820f2136a59181b0e375b0e Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 21:39:11 -0600
Subject: [PATCH 35/41] fix(mosaic): persist pending connected accounts in fake
FAPI
---
.../mosaic-connected-accounts-wire-up.md | 3 ++
.../mosaic/src/__tests__/feature/fake-fapi.ts | 42 ++++++++++++-------
2 files changed, 31 insertions(+), 14 deletions(-)
diff --git a/.changeset/mosaic-connected-accounts-wire-up.md b/.changeset/mosaic-connected-accounts-wire-up.md
index a845151cc84..3b2964a6e16 100644
--- a/.changeset/mosaic-connected-accounts-wire-up.md
+++ b/.changeset/mosaic-connected-accounts-wire-up.md
@@ -1,2 +1,5 @@
---
+"@clerk/mosaic": patch
---
+
+Add connected account management to UserProfile, including OAuth connection, reconnection, and removal. Keep account actions bound to the active user and restore focus after removing the final account.
diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
index e05a23ea380..fc8820fbcf2 100644
--- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts
@@ -145,32 +145,46 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
return missing();
}
const account = fapiExternalAccount({
- id: `idn_${provider}`,
+ 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 account = activeUser(state)?.external_accounts.find(item => item.id === params.id);
- return account
- ? envelope(
- {
- ...account,
- verification: fapiVerification(`oauth_${account.provider}`, {
- status: 'unverified',
- external_verification_redirect_url: 'https://accounts.example/consent',
- }),
- },
- state.client,
- )
- : missing();
+ 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') {
From 96e7b6a364aa0ee1a92e96cffdf5a3e99b32bf2a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 21:39:11 -0600
Subject: [PATCH 36/41] test(mosaic): replace connected account layer tests
---
...e-connected-account-removal-focus.test.tsx | 60 ------
...rofile-connected-accounts.feature.test.tsx | 92 +++++++--
...rofile-connected-accounts-feedback.test.ts | 30 ++-
...ected-accounts-section.controller.test.tsx | 76 -------
...e-connected-accounts-section.model.test.ts | 193 ++----------------
5 files changed, 119 insertions(+), 332 deletions(-)
delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx
delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx
deleted file mode 100644
index 6fa8a11d729..00000000000
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-account-removal-focus.test.tsx
+++ /dev/null
@@ -1,60 +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 { createRef } from 'react';
-import { describe, expect, it } from 'vitest';
-
-import { MosaicProvider } from '../../../MosaicProvider';
-import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section.view';
-import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view';
-
-describe('connected account removal focus', () => {
- it('keeps the final account confirmation mounted until removal settles', async () => {
- const user = userEvent.setup();
- const titleRef = createRef();
- const removal = createDeferredPromise();
- const onRemove = () => removal.promise;
- const { rerender } = render(
-
- titleRef.current}
- onRemove={onRemove}
- />
- }
- />
- ,
- );
- 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(
-
- titleRef.current}
- onRemove={onRemove}
- />
- }
- />
- ,
- );
- 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$/));
- });
-});
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
index 8145a284794..8aa3a9f8b96 100644
--- 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
@@ -59,11 +59,23 @@ function signedIn(accounts = [google], overrides: FakeFapiSeed = {}) {
};
}
+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} />}
/>,
@@ -504,7 +516,7 @@ describe('connected accounts', () => {
});
it.each(['switch', 'sign out'] as const)('aborts Connect after %s during redirect preparation', async change => {
- serveFapi(
+ const fapi = serveFapi(
signedIn([], {
client: fapiClient([
fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: [] }) }),
@@ -512,23 +524,30 @@ describe('connected accounts', () => {
]),
}),
);
- const redirect = Promise.withResolvers();
+ 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');
+ 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 () => {
@@ -572,7 +591,7 @@ describe('connected accounts', () => {
}),
]),
);
- const callback = Promise.withResolvers<{ callbackUrl: string }>();
+ const callback = deferred<{ callbackUrl: string }>();
const open = vi.fn(() => callback.promise);
const { clerk } = await renderWithClerk(, {
__internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
@@ -580,6 +599,7 @@ describe('connected accounts', () => {
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'),
);
@@ -596,7 +616,7 @@ describe('connected accounts', () => {
it('persists reauthorization without granting scopes before callback completion', async () => {
const fapi = serveFapi(signedIn([google, github]));
- const callback = Promise.withResolvers<{ callbackUrl: string }>();
+ const callback = deferred<{ callbackUrl: string }>();
const open = vi.fn(() => callback.promise);
const { clerk } = await renderWithClerk(
,
@@ -629,6 +649,7 @@ describe('connected accounts', () => {
}));
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');
@@ -646,7 +667,7 @@ describe('connected accounts', () => {
]),
}),
);
- const redirect = Promise.withResolvers();
+ const redirect = deferred();
const getRedirectUrl = vi.fn(() => redirect.promise);
const open = vi.fn(() => Promise.resolve({ callbackUrl: 'https://app.example/callback' }));
const { clerk } = await renderWithClerk(
@@ -656,7 +677,9 @@ describe('connected accounts', () => {
},
);
const original = clerk.user?.externalAccounts[0];
- if (!original) throw new Error('Expected Google account');
+ 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' }));
@@ -666,10 +689,13 @@ describe('connected accounts', () => {
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]);
+ 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 () => {
@@ -681,7 +707,7 @@ describe('connected accounts', () => {
]),
}),
);
- const response = Promise.withResolvers();
+ const response = deferred();
let received = false;
worker.use(
http.post(fapiUrl('/v1/me/external_accounts'), () => {
@@ -694,12 +720,15 @@ describe('connected accounts', () => {
__internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
});
const original = clerk.user;
- if (!original) throw new Error('Expected original 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',
@@ -724,22 +753,27 @@ describe('connected accounts', () => {
]),
}),
);
- const callback = Promise.withResolvers<{ callbackUrl: string }>();
+ 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');
+ 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');
+ 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();
@@ -748,7 +782,7 @@ describe('connected accounts', () => {
it('shows the connected account after explicit provider callback completion', async () => {
const fapi = serveFapi(signedIn([]));
- const callback = Promise.withResolvers<{ callbackUrl: string }>();
+ const callback = deferred<{ callbackUrl: string }>();
const open = vi.fn(() => callback.promise);
const { clerk } = await renderWithClerk(
,
@@ -772,6 +806,7 @@ describe('connected accounts', () => {
}));
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();
@@ -860,5 +895,34 @@ describe('connected accounts', () => {
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');
});
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
index c6da86a9075..b2f7b037535 100644
--- 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
@@ -5,6 +5,7 @@ import { resolveLocalization } from '../../../localization';
import { connectedAccountErrorMessage } 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']) {
@@ -13,12 +14,12 @@ function apiError(data: ConstructorParameters[1]['
describe('connected account error messages', () => {
it('localizes errors raised by the model', () => {
- expect(connectedAccountErrorMessage(new ConnectedAccountActionError('unavailable'), messages)).toBe(
+ expect(connectedAccountErrorMessage(new ConnectedAccountActionError('unavailable'), messages, errorText)).toBe(
messages.errors.unavailable,
);
- expect(connectedAccountErrorMessage(new ConnectedAccountActionError('missing_verification_url'), messages)).toBe(
- messages.errors.missingVerificationUrl,
- );
+ expect(
+ connectedAccountErrorMessage(new ConnectedAccountActionError('missing_verification_url'), messages, errorText),
+ ).toBe(messages.errors.missingVerificationUrl);
});
it('shows the first API error, preferring its long message', () => {
@@ -29,13 +30,28 @@ describe('connected account error messages', () => {
{ code: 'other', message: 'Second' },
]),
messages,
+ errorText,
),
).toBe('GitHub is unavailable right now.');
- expect(connectedAccountErrorMessage(apiError([{ code: 'oauth_error', message: 'Short' }]), messages)).toBe('Short');
+ expect(
+ connectedAccountErrorMessage(apiError([{ code: 'oauth_error', message: 'Short' }]), messages, errorText),
+ ).toBe('Short');
+ });
+
+ it('passes the API code, parameter, and fallback copy to the canonical formatter', () => {
+ expect(
+ connectedAccountErrorMessage(
+ apiError([
+ { code: 'form_param_invalid', message: 'Short', long_message: 'Long', meta: { param_name: 'strategy' } },
+ ]),
+ messages,
+ ({ code, paramName, message }) => `${code}:${paramName}:${message}`,
+ ),
+ ).toBe('form_param_invalid:strategy:Long');
});
it('falls back to the generic message', () => {
- expect(connectedAccountErrorMessage(apiError([]), messages)).toBe(messages.errors.generic);
- expect(connectedAccountErrorMessage(new Error('Popup closed'), messages)).toBe(messages.errors.generic);
+ expect(connectedAccountErrorMessage(apiError([]), messages, errorText)).toBe(messages.errors.generic);
+ expect(connectedAccountErrorMessage(new Error('Popup closed'), messages, errorText)).toBe(messages.errors.generic);
});
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
deleted file mode 100644
index d7675852757..00000000000
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.controller.test.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
-import { expect, it, vi } from 'vitest';
-
-import { useUserProfileConnectedAccountsController } from './user-profile-connected-accounts-section.controller';
-
-it('releases pending actions when a redirect leaves the page mounted', async () => {
- const onConnect = vi.fn().mockResolvedValue('redirecting');
- const { result } = renderHook(() =>
- useUserProfileConnectedAccountsController({
- accounts: [],
- availableProviders: [],
- onConnect,
- onReconnect: onConnect,
- formatError: () => 'Something went wrong.',
- }),
- );
-
- act(() => result.current.onConnect('github'));
- expect(result.current.pendingId).toBe('github');
-
- await waitFor(() => expect(result.current.pendingId).toBeUndefined(), { timeout: 2500 });
- act(() => result.current.onConnect('google'));
- expect(onConnect).toHaveBeenCalledTimes(2);
-});
-
-it('ignores another action while one is pending', async () => {
- let release: () => void = () => {};
- const onConnect = vi.fn(() => new Promise(resolve => (release = resolve)));
- const onReconnect = vi.fn(() => Promise.resolve());
- const { result } = renderHook(() =>
- useUserProfileConnectedAccountsController({
- accounts: [{ id: 'idn_github', provider: 'GitHub' }],
- availableProviders: [{ id: 'oauth_google', provider: 'Google' }],
- onConnect,
- onReconnect,
- formatError: () => 'Something went wrong.',
- }),
- );
-
- act(() => result.current.onConnect('oauth_google'));
- act(() => result.current.onConnect('oauth_google'));
- act(() => result.current.onReconnect('idn_github'));
- expect(onConnect).toHaveBeenCalledTimes(1);
- expect(onReconnect).not.toHaveBeenCalled();
-
- act(() => release());
- await waitFor(() => expect(result.current.pendingId).toBeUndefined());
-});
-
-it('shows a formatted error on the failed row and clears it on retry', async () => {
- const onConnect = vi
- .fn()
- .mockRejectedValueOnce(new Error('raw'))
- .mockReturnValueOnce(new Promise(() => {}));
- const formatError = vi.fn(() => 'Formatted error.');
- const { result } = renderHook(() =>
- useUserProfileConnectedAccountsController({
- accounts: [],
- availableProviders: [
- { id: 'oauth_google', provider: 'Google' },
- { id: 'oauth_github', provider: 'GitHub' },
- ],
- onConnect,
- onReconnect: vi.fn(),
- formatError,
- }),
- );
-
- act(() => result.current.onConnect('oauth_google'));
- await waitFor(() => expect(result.current.availableProviders[0].connectError).toBe('Formatted error.'));
- expect(formatError).toHaveBeenCalledWith(expect.objectContaining({ message: 'raw' }));
- expect(result.current.availableProviders[1].connectError).toBeUndefined();
-
- act(() => result.current.onConnect('oauth_google'));
- expect(result.current.availableProviders[0].connectError).toBeUndefined();
-});
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
index 47b5a5911de..a1d6a5510f7 100644
--- 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
@@ -1,6 +1,5 @@
-import type { EnterpriseSSOSettings, ExternalAccountResource, OAuthProviders, UserResource } from '@clerk/shared/types';
-import { cleanup, renderHook } from '@testing-library/react';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { OAuthProvider } from '@clerk/shared/types';
+import { describe, expect, it } from 'vitest';
import {
allowsIdentificationCreation,
@@ -8,171 +7,11 @@ import {
getRecovery,
getRetry,
projectConnectedAccounts,
- useUserProfileConnectedAccountsModel,
} from './user-profile-connected-accounts-section.model';
-type TestAccount = {
- id: string;
- provider: string;
- approvedScopes: string;
- verification: { status: string; strategy: string; error: null } | null;
- destroy: ReturnType;
-};
-
-type TestUser = {
- id: string;
- enterpriseAccounts: never[];
- externalAccounts: TestAccount[];
- verifiedExternalAccounts: TestAccount[];
- unverifiedExternalAccounts: TestAccount[];
- createExternalAccount: ReturnType;
- reload: ReturnType;
-};
-
-function createUser(id: string): TestUser {
- const github: TestAccount = {
- id: 'idn_github',
- provider: 'github',
- approvedScopes: 'email',
- verification: { status: 'verified', strategy: 'oauth_github', error: null },
- destroy: vi.fn(),
- };
- return {
- id,
- enterpriseAccounts: [],
- externalAccounts: [github],
- verifiedExternalAccounts: [github],
- unverifiedExternalAccounts: [],
- createExternalAccount: vi.fn(() =>
- Promise.resolve({
- verification: { externalVerificationRedirectURL: new URL('https://accounts.example/authorize') },
- }),
- ),
- reload: vi.fn(() => Promise.resolve()),
- };
-}
-
-let user: TestUser | null;
-let transport: { getRedirectUrl: () => Promise; open: ReturnType } | undefined;
-let socialEnabled: boolean;
-
-const clerk = {
- get user() {
- return user;
- },
- get __internal_oauthTransport() {
- return transport;
- },
- navigate: vi.fn(() => Promise.resolve()),
-};
-
-vi.mock('@clerk/shared/react', () => ({
- useClerk: () => clerk,
- useUser: () => ({ isLoaded: true, user }),
-}));
-
-vi.mock('../../../hooks/useMosaicEnvironment', () => ({
- useMosaicEnvironment: () => ({
- userSettings: {
- social: {
- oauth_github: { enabled: socialEnabled, strategy: 'oauth_github', name: 'GitHub' },
- oauth_google: { enabled: socialEnabled, strategy: 'oauth_google', name: 'Google' },
- },
- enterpriseSSO: { enabled: false },
- },
- }),
-}));
-
-beforeEach(() => {
- user = createUser('user_1');
- transport = undefined;
- socialEnabled = true;
- clerk.navigate.mockClear();
-});
-
-afterEach(cleanup);
-
-function ready(model: ReturnType) {
- if (model.status !== 'ready') {
- throw new Error('expected ready model');
- }
- return model;
-}
-
-describe('useUserProfileConnectedAccountsModel', () => {
- it('explains why the section is hidden', () => {
- user = null;
- expect(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current).toEqual({
- status: 'hidden',
- reason: 'no_user',
- });
-
- user = createUser('user_1');
- socialEnabled = false;
- expect(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current).toEqual({
- status: 'hidden',
- reason: 'unavailable',
- });
- });
-
- it.each(['signed out', 'different user'])('rejects captured actions after %s', async change => {
- const original = user;
- if (!original) {
- throw new Error('expected user');
- }
- const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
-
- user = change === 'signed out' ? null : createUser('user_2');
-
- await expect(model.connect('oauth_google')).rejects.toMatchObject({ code: 'unavailable' });
- await expect(model.reconnect('idn_github')).rejects.toMatchObject({ code: 'unavailable' });
- await expect(model.remove('idn_github')).rejects.toMatchObject({ code: 'unavailable' });
- expect(original.createExternalAccount).not.toHaveBeenCalled();
- expect(original.externalAccounts[0].destroy).not.toHaveBeenCalled();
- });
-
- it('does not reload a user who changed while the OAuth popup was open', async () => {
- const original = user;
- if (!original) {
- throw new Error('expected user');
- }
- transport = {
- getRedirectUrl: () => Promise.resolve('https://app.example/profile'),
- open: vi.fn(() => {
- user = createUser('user_2');
- return Promise.resolve({ callbackUrl: 'https://app.example/callback?rotating_token_nonce=nonce' });
- }),
- };
- const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
-
- await expect(model.connect('oauth_google')).rejects.toMatchObject({ code: 'unavailable' });
- expect(original.reload).not.toHaveBeenCalled();
- });
-
- it('rejects a missing verification URL with a typed error', async () => {
- user?.createExternalAccount.mockResolvedValue({ verification: null });
- const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
-
- await expect(model.connect('oauth_google')).rejects.toMatchObject({ code: 'missing_verification_url' });
- });
-
- it('rejects a strategy that is not enabled', async () => {
- const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
-
- await expect(model.connect('oauth_facebook')).rejects.toMatchObject({ code: 'unavailable' });
- expect(user?.createExternalAccount).not.toHaveBeenCalled();
- });
-
- it('rejects removal of an account that no longer exists', async () => {
- const model = ready(renderHook(() => useUserProfileConnectedAccountsModel({})).result.current);
-
- await expect(model.remove('idn_missing')).rejects.toMatchObject({ code: 'unavailable' });
- });
-});
-
type AccountInput = {
id: string;
- provider: string;
+ provider: OAuthProvider;
status?: 'verified' | 'unverified';
errorCode?: string;
longMessage?: string;
@@ -192,7 +31,7 @@ function account({
approvedScopes = 'email',
username = '',
emailAddress = '',
-}: AccountInput): ExternalAccountResource {
+}: AccountInput) {
return {
id,
provider,
@@ -204,19 +43,21 @@ function account({
strategy: strategy ?? `oauth_${provider}`,
error: errorCode ? { code: errorCode, longMessage: longMessage ?? errorCode } : null,
},
- } as unknown as ExternalAccountResource;
+ };
}
-function userWith(accounts: ExternalAccountResource[], enterpriseAccounts: unknown[] = []): UserResource {
+function userWith(
+ accounts: ReturnType[],
+ enterpriseAccounts: Parameters[0]['enterpriseAccounts'] = [],
+) {
return {
- externalAccounts: accounts,
verifiedExternalAccounts: accounts.filter(a => a.verification?.status === 'verified'),
unverifiedExternalAccounts: accounts.filter(a => a.verification?.status !== 'verified'),
enterpriseAccounts,
- } as unknown as UserResource;
+ };
}
-function social(...strategies: string[]): Partial {
+function social(...strategies: string[]): Parameters[0] {
return Object.fromEntries(
strategies.map(strategy => [
strategy,
@@ -227,7 +68,7 @@ function social(...strategies: string[]): Partial {
logo_url: strategy.startsWith('oauth_custom_') ? 'https://img.example/custom.png' : null,
},
]),
- ) as Partial;
+ );
}
describe('projectConnectedAccounts', () => {
@@ -237,11 +78,13 @@ describe('projectConnectedAccounts', () => {
});
});
- it('is hidden when creation is disallowed and the user has no external accounts', () => {
+ it('resolves empty rows when creation is disallowed and no accounts remain', () => {
expect(
projectConnectedAccounts({ user: userWith([]), social: social('oauth_google'), allowCreation: false }),
).toEqual({
- status: 'hidden',
+ status: 'ready',
+ accounts: [],
+ availableProviders: [],
});
});
@@ -418,7 +261,7 @@ describe('getEnabledOAuthStrategies', () => {
});
describe('allowsIdentificationCreation', () => {
- const enterpriseSSO = { enabled: true } as EnterpriseSSOSettings;
+ const enterpriseSSO = { enabled: true };
it('blocks creation for an active enterprise connection that disables additional identifications', () => {
const blocked = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
@@ -433,7 +276,7 @@ describe('allowsIdentificationCreation', () => {
expect(allowsIdentificationCreation(inactive, enterpriseSSO)).toBe(true);
const active = userWith([], [{ active: true, enterpriseConnection: { disableAdditionalIdentifications: true } }]);
- expect(allowsIdentificationCreation(active, { enabled: false } as EnterpriseSSOSettings)).toBe(true);
+ expect(allowsIdentificationCreation(active, { enabled: false })).toBe(true);
});
});
From 25918d3bd7fd3eb0ec7cec65a01adbe07bdc581e Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 1 Oct 2026 21:39:12 -0600
Subject: [PATCH 37/41] test(mosaic): verify localized OAuth API feedback
---
.../user-profile-connected-accounts.feature.test.tsx | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
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
index 8aa3a9f8b96..05223de1e39 100644
--- 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
@@ -883,15 +883,20 @@ describe('connected accounts', () => {
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('form_password_incorrect', 'Server copy');
- expect(await screen.findByText('Localized account error.')).toBeInTheDocument();
+ request.fail('verification_invalid_strategy', 'Server copy');
+ expect(await screen.findByText('Cette connexion est indisponible.')).toBeInTheDocument();
expect(screen.queryByText('Server copy')).toBeNull();
});
From d5d7b8d630671fc9177392e52411be775044937d Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:01:17 -0600
Subject: [PATCH 38/41] chore(mosaic): use an empty connected accounts
changeset
---
.changeset/mosaic-connected-accounts-wire-up.md | 3 ---
1 file changed, 3 deletions(-)
diff --git a/.changeset/mosaic-connected-accounts-wire-up.md b/.changeset/mosaic-connected-accounts-wire-up.md
index 3b2964a6e16..a845151cc84 100644
--- a/.changeset/mosaic-connected-accounts-wire-up.md
+++ b/.changeset/mosaic-connected-accounts-wire-up.md
@@ -1,5 +1,2 @@
---
-"@clerk/mosaic": patch
---
-
-Add connected account management to UserProfile, including OAuth connection, reconnection, and removal. Keep account actions bound to the active user and restore focus after removing the final account.
From 23b9d6bd9fd85432764b24881e6eb70db3baccfc Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:23:19 -0600
Subject: [PATCH 39/41] test(mosaic): record retry loading transition gap
---
.../__tests__/user-profile-connected-accounts.feature.test.tsx | 1 +
1 file changed, 1 insertion(+)
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
index 05223de1e39..e3f4de5593f 100644
--- 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
@@ -930,4 +930,5 @@ describe('connected accounts', () => {
});
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');
});
From 3a9107529978581b8a92a48c927f8a87de754ced Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 09:56:06 -0600
Subject: [PATCH 40/41] fix(mosaic): reconcile connected accounts with latest
main
---
packages/mosaic/src/__tests__/feature/fapi.ts | 38 -------------------
...rofile-connected-accounts.feature.test.tsx | 2 +-
...rofile-connected-accounts-section.model.ts | 4 +-
packages/mosaic/src/localization/index.ts | 2 -
.../(clerk)/live/connected-accounts/page.tsx | 2 +-
5 files changed, 4 insertions(+), 44 deletions(-)
diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts
index 92d0047e080..383e4b20ead 100644
--- a/packages/mosaic/src/__tests__/feature/fapi.ts
+++ b/packages/mosaic/src/__tests__/feature/fapi.ts
@@ -257,44 +257,6 @@ export function fapiExternalAccount(
};
}
-export function fapiEnterpriseAccount(
- overrides: Partial & Pick,
-): EnterpriseAccountJSON {
- return {
- object: 'enterprise_account',
- active: true,
- email_address: 'sso@example.com',
- enterprise_connection: {
- object: 'enterprise_connection',
- id: 'sso_1',
- active: true,
- allow_idp_initiated: false,
- allow_subdomains: false,
- disable_additional_identifications: true,
- domain: 'example.com',
- logo_public_url: null,
- name: 'Company SSO',
- protocol: 'saml',
- provider: 'saml_okta',
- sync_user_attributes: false,
- allow_organization_account_linking: false,
- created_at: createdAt,
- updated_at: createdAt,
- enterprise_connection_id: 'sso_1',
- },
- first_name: null,
- last_name: null,
- protocol: 'saml',
- provider: 'saml_okta',
- provider_user_id: null,
- public_metadata: {},
- verification: null,
- last_authenticated_at: null,
- enterprise_connection_id: 'sso_1',
- ...overrides,
- };
-}
-
export function fapiUser(overrides: Partial & Pick): UserJSON {
return {
object: 'user',
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
index e3f4de5593f..4f8250fe7c2 100644
--- 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
@@ -15,7 +15,7 @@ import {
fapiVerification,
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
-import { MosaicProvider } from '../../../MosaicProvider';
+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';
diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
index 42b3e12967a..7eb9d1919d3 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -12,8 +12,8 @@ import type {
OAuthStrategy,
} from '@clerk/shared/types';
-import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment';
-import { useMosaicRouter } from '../../../hooks/useMosaicRouter';
+import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
+import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
import { useErrorText, useMessages } from '../../../localization';
import { connectedAccountErrorMessage } from './user-profile-connected-accounts-feedback';
import type {
diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts
index 5a0ef3edfab..8d5633e4bac 100644
--- a/packages/mosaic/src/localization/index.ts
+++ b/packages/mosaic/src/localization/index.ts
@@ -6,5 +6,3 @@ export type { MosaicMessages } from './registry';
export { mosaicMessages } from './registry';
export type { MessageComponents, MessageValues, PluralForms, RichOptions } from './messages';
export { fill, plural, rich } from './messages';
-export { useErrorText } from './errors';
-export type { LocalizableError } from './errors';
diff --git a/packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx b/packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx
index 8314d49ee8f..4ddfc3f5d7d 100644
--- a/packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx
+++ b/packages/swingset/src/app/(clerk)/live/connected-accounts/page.tsx
@@ -1,7 +1,7 @@
'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/MosaicProvider';
+import { MosaicProvider } from '@clerk/mosaic/mosaic-provider';
import { useUser } from '@clerk/nextjs';
import Link from 'next/link';
From 1a919aea49b35630ea007119b89c2bb0a20c8ea8 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Fri, 2 Oct 2026 10:19:40 -0600
Subject: [PATCH 41/41] refactor(mosaic): unify connected account provider and
recovery policy
---
...rofile-connected-accounts.feature.test.tsx | 23 ++
...ser-profile-connected-account-row.view.tsx | 139 ++++++----
...rofile-connected-accounts-feedback.test.ts | 31 ++-
...ser-profile-connected-accounts-feedback.ts | 23 +-
...e-connected-accounts-section.controller.ts | 21 +-
...e-connected-accounts-section.model.test.ts | 124 ++++++---
...rofile-connected-accounts-section.model.ts | 259 +++++++++---------
...rofile-connected-accounts-section.types.ts | 21 +-
...rofile-connected-accounts-section.view.tsx | 9 +-
.../user-profile-connected-accounts.ts | 11 +-
10 files changed, 406 insertions(+), 255 deletions(-)
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
index 4f8250fe7c2..3080ab7ffdb 100644
--- 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
@@ -329,6 +329,29 @@ describe('connected accounts', () => {
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());
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
index f608f0926dd..e5dfb8bcd30 100644
--- 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
@@ -9,27 +9,102 @@ 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 { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.types';
+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,
- isPending = false,
isDisabled = false,
- onConnect,
onReconnect,
onRemove,
}: {
account: UserProfileConnectedAccount;
triggerRef?: Ref;
- isPending?: boolean;
isDisabled?: boolean;
- 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' || account.status === 'error') && onReconnect && !isDisabled) {
actions.push({
@@ -37,36 +112,13 @@ export function UserProfileConnectedAccountRowView({
onClick: () => onReconnect(account.id),
});
}
- if (onRemove && account.canRemove !== false) {
+ if (onRemove) {
actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(account) });
}
return (
-
+
-
-
- {iconUrl && account.monochromeIcon ? (
-
- ) : iconUrl ? (
-
- ) : (
-
- {account.provider.trim().charAt(0).toUpperCase()}
-
- )}
-
-
+
{account.provider}
@@ -81,27 +133,7 @@ export function UserProfileConnectedAccountRowView({
) : null}
- {onConnect ? (
-
- onConnect(account.id)}
- >
- {m.connect}
-
-
-
- ) : actions.length > 0 ? (
+ {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-accounts-feedback.test.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-feedback.test.ts
index b2f7b037535..199fd568410 100644
--- 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
@@ -2,7 +2,7 @@ import { ClerkAPIResponseError } from '@clerk/shared/error';
import { describe, expect, it } from 'vitest';
import { resolveLocalization } from '../../../localization';
-import { connectedAccountErrorMessage } from './user-profile-connected-accounts-feedback';
+import { connectedAccountFeedback } from './user-profile-connected-accounts-feedback';
import { ConnectedAccountActionError } from './user-profile-connected-accounts-section.types';
const errorText = ({ message }: { message?: string }) => message ?? 'Fallback';
@@ -13,45 +13,56 @@ function apiError(data: ConstructorParameters[1]['
}
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(connectedAccountErrorMessage(new ConnectedAccountActionError('unavailable'), messages, errorText)).toBe(
+ expect(connectedAccountFeedback(new ConnectedAccountActionError('unavailable'), messages, errorText).message).toBe(
messages.errors.unavailable,
);
expect(
- connectedAccountErrorMessage(new ConnectedAccountActionError('missing_verification_url'), messages, errorText),
+ connectedAccountFeedback(new ConnectedAccountActionError('missing_verification_url'), messages, errorText)
+ .message,
).toBe(messages.errors.missingVerificationUrl);
});
it('shows the first API error, preferring its long message', () => {
expect(
- connectedAccountErrorMessage(
+ 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(
- connectedAccountErrorMessage(apiError([{ code: 'oauth_error', message: 'Short' }]), messages, errorText),
+ 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(
- connectedAccountErrorMessage(
+ 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(connectedAccountErrorMessage(apiError([]), messages, errorText)).toBe(messages.errors.generic);
- expect(connectedAccountErrorMessage(new Error('Popup closed'), messages, errorText)).toBe(messages.errors.generic);
+ 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
index 63a114440c9..ce3e9a3897c 100644
--- 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
@@ -5,21 +5,26 @@ import { ConnectedAccountActionError } from './user-profile-connected-accounts-s
type Messages = MosaicMessages['userProfileConnectedAccounts'];
-export function connectedAccountErrorMessage(
+export function connectedAccountFeedback(
error: unknown,
messages: Messages,
errorText: (error: LocalizableError) => string,
-): string {
+): Error {
if (error instanceof ConnectedAccountActionError) {
- return error.code === 'missing_verification_url'
- ? messages.errors.missingVerificationUrl
- : messages.errors.unavailable;
+ 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];
- return first
- ? errorText({ code: first.code, paramName: first.meta?.paramName, message: first.longMessage || first.message })
- : messages.errors.generic;
+ if (first) {
+ message = errorText({
+ code: first.code,
+ paramName: first.meta?.paramName,
+ message: first.longMessage || first.message,
+ });
+ }
}
- return messages.errors.generic;
+ 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
index 057e4cee85e..588b29e4b4a 100644
--- 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
@@ -22,6 +22,8 @@ export interface UserProfileConnectedAccountsController {
onReconnect: (id: string) => void;
}
+const OAUTH_REDIRECT_HOLD_MS = 2000;
+
export function useUserProfileConnectedAccountsController({
accounts,
availableProviders,
@@ -30,15 +32,10 @@ export function useUserProfileConnectedAccountsController({
}: UserProfileConnectedAccountsControllerOptions): UserProfileConnectedAccountsController {
const messages = useMessages('userProfileConnectedAccounts');
const [pendingId, setPendingId] = useState();
- const [connectErrors, setConnectErrors] = useState>({});
- const [reconnectErrors, setReconnectErrors] = useState>({});
+ const [errors, setErrors] = useState>({});
const connecting = useRef(false);
- const run = async (
- id: string,
- action: (id: string) => Promise,
- setErrors: typeof setConnectErrors,
- ) => {
+ const run = async (id: string, action: (id: string) => Promise) => {
if (connecting.current) {
return;
}
@@ -48,7 +45,7 @@ export function useUserProfileConnectedAccountsController({
try {
if ((await action(id)) === 'redirecting') {
- await new Promise(resolve => setTimeout(resolve, 2000));
+ await new Promise(resolve => setTimeout(resolve, OAUTH_REDIRECT_HOLD_MS));
}
} catch (error) {
setErrors(current => ({ ...current, [id]: error instanceof Error ? error.message : messages.errors.generic }));
@@ -60,13 +57,13 @@ export function useUserProfileConnectedAccountsController({
return {
accounts: accounts.map(account =>
- reconnectErrors[account.id] ? { ...account, reconnectError: reconnectErrors[account.id] } : account,
+ errors[account.id] ? { ...account, reconnectError: errors[account.id] } : account,
),
availableProviders: availableProviders.map(provider =>
- connectErrors[provider.id] ? { ...provider, connectError: connectErrors[provider.id] } : provider,
+ errors[provider.id] ? { ...provider, connectError: errors[provider.id] } : provider,
),
pendingId,
- onConnect: id => void run(id, onConnect, setConnectErrors),
- onReconnect: id => void run(id, onReconnect, setReconnectErrors),
+ 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-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
index a1d6a5510f7..ff24ae057a6 100644
--- 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
@@ -3,10 +3,9 @@ import { describe, expect, it } from 'vitest';
import {
allowsIdentificationCreation,
- getEnabledOAuthStrategies,
- getRecovery,
- getRetry,
+ createProviderCatalog,
projectConnectedAccounts,
+ recoveryFor,
} from './user-profile-connected-accounts-section.model';
type AccountInput = {
@@ -57,8 +56,8 @@ function userWith(
};
}
-function social(...strategies: string[]): Parameters[0] {
- return Object.fromEntries(
+function social(...strategies: string[]) {
+ const settings = Object.fromEntries(
strategies.map(strategy => [
strategy,
{
@@ -69,18 +68,58 @@ function social(...strategies: string[]): Parameters, 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([]), social: {}, allowCreation: true })).toEqual({
+ 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([]), social: social('oauth_google'), allowCreation: false }),
+ projectConnectedAccounts({
+ user: userWith([]),
+ providers: social('oauth_google'),
+ socialEnabled: true,
+ allowCreation: false,
+ }),
).toEqual({
status: 'ready',
accounts: [],
@@ -91,7 +130,8 @@ describe('projectConnectedAccounts', () => {
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' })]),
- social: social('oauth_google'),
+ providers: social('oauth_google'),
+ socialEnabled: true,
allowCreation: false,
});
expect(projection).toEqual({ status: 'ready', accounts: [], availableProviders: [] });
@@ -104,7 +144,8 @@ describe('projectConnectedAccounts', () => {
account({ id: 'idn_pending', provider: 'apple', status: 'unverified' }),
account({ id: 'idn_verified', provider: 'google' }),
]),
- social: social('oauth_google', 'oauth_github', 'oauth_apple'),
+ 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']);
@@ -116,7 +157,8 @@ describe('projectConnectedAccounts', () => {
account({ id: 'idn_1', provider: 'github', username: 'octo', emailAddress: 'octo@example.com' }),
account({ id: 'idn_2', provider: 'google', emailAddress: 'g@example.com' }),
]),
- social: social('oauth_google', 'oauth_github'),
+ providers: social('oauth_google', 'oauth_github'),
+ socialEnabled: true,
allowCreation: true,
});
expect(projection.status === 'ready' && projection.accounts.map(a => a.identifier)).toEqual([
@@ -132,7 +174,8 @@ describe('projectConnectedAccounts', () => {
account({ id: 'idn_github', provider: 'github', status: 'unverified', errorCode: 'oauth_access_denied' }),
account({ id: 'idn_acme', provider: 'custom_acme', status: 'unverified' }),
]),
- social: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
+ providers: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
+ socialEnabled: true,
allowCreation: true,
});
expect(projection.status === 'ready' && projection.availableProviders.map(p => p.id)).toEqual([
@@ -143,7 +186,8 @@ describe('projectConnectedAccounts', () => {
it('uses configured names and logos for custom providers', () => {
const projection = projectConnectedAccounts({
user: userWith([account({ id: 'idn_1', provider: 'custom_acme' })]),
- social: social('oauth_custom_acme'),
+ providers: social('oauth_custom_acme'),
+ socialEnabled: true,
allowCreation: true,
});
expect(projection.status === 'ready' && projection.accounts[0]).toMatchObject({
@@ -155,7 +199,8 @@ describe('projectConnectedAccounts', () => {
it('marks monochrome provider logos', () => {
const projection = projectConnectedAccounts({
user: userWith([]),
- social: social('oauth_github', 'oauth_google'),
+ providers: social('oauth_github', 'oauth_google'),
+ socialEnabled: true,
allowCreation: true,
});
expect(
@@ -169,7 +214,8 @@ describe('projectConnectedAccounts', () => {
it('offers no providers when creation is disallowed', () => {
const projection = projectConnectedAccounts({
user: userWith([account({ id: 'idn_1', provider: 'google' })]),
- social: social('oauth_google', 'oauth_github'),
+ providers: social('oauth_google', 'oauth_github'),
+ socialEnabled: true,
allowCreation: false,
});
expect(projection.status === 'ready' && projection.availableProviders).toEqual([]);
@@ -192,7 +238,8 @@ describe('projectConnectedAccounts', () => {
longMessage: 'This account is already connected.',
}),
]),
- social: social('oauth_google', 'oauth_github'),
+ providers: social('oauth_google', 'oauth_github'),
+ socialEnabled: true,
allowCreation: true,
});
expect(projection.status === 'ready' && projection.accounts).toMatchObject([
@@ -202,14 +249,23 @@ describe('projectConnectedAccounts', () => {
});
});
-describe('getRecovery', () => {
+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(getRecovery(account({ id: 'idn_1', provider: 'github', errorCode: code }), undefined)).toEqual({
+ expect(recoveryPlan(account({ id: 'idn_1', provider: 'github', errorCode: code }), undefined)).toEqual({
kind: 'create',
strategy: 'oauth_github',
additionalScopes: [],
@@ -217,7 +273,7 @@ describe('getRecovery', () => {
});
it('normalizes google one tap to the google strategy', () => {
- const recovery = getRecovery(
+ const recovery = recoveryPlan(
account({ id: 'idn_1', provider: 'google', strategy: 'google_one_tap', errorCode: 'oauth_fetch_user_error' }),
undefined,
);
@@ -225,7 +281,7 @@ describe('getRecovery', () => {
});
it('reauthorizes with the full requested scope list when any scope is missing', () => {
- const recovery = getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: 'email profile' }), {
+ const recovery = recoveryPlan(account({ id: 'idn_1', provider: 'google', approvedScopes: 'email profile' }), {
google: ['email', 'calendar'],
});
expect(recovery).toEqual({ kind: 'reauthorize', additionalScopes: ['email', 'calendar'] });
@@ -233,7 +289,7 @@ describe('getRecovery', () => {
it('does not reauthorize when every requested scope is approved', () => {
expect(
- getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: 'email calendar' }), {
+ recoveryPlan(account({ id: 'idn_1', provider: 'google', approvedScopes: 'email calendar' }), {
google: ['calendar'],
}),
).toBeNull();
@@ -241,22 +297,26 @@ describe('getRecovery', () => {
it('does not offer recovery when scopes are missing and none were approved', () => {
expect(
- getRecovery(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
+ recoveryPlan(account({ id: 'idn_1', provider: 'google', approvedScopes: '' }), { google: ['calendar'] }),
).toBeNull();
});
- it('ignores unrecognized verification errors', () => {
- expect(getRecovery(account({ id: 'idn_1', provider: 'google', errorCode: 'oauth_access_denied' }), undefined)).toBe(
- null,
- );
+ 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('getEnabledOAuthStrategies', () => {
- it('sorts known strategies, appends custom ones, and drops unknown ones', () => {
+describe('createProviderCatalog', () => {
+ it('keeps canonical strategy order and drops unknown strategies', () => {
expect(
- getEnabledOAuthStrategies(social('oauth_github', 'oauth_custom_acme', 'oauth_future', 'oauth_apple')),
- ).toEqual(['oauth_apple', 'oauth_github', 'oauth_custom_acme']);
+ 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']);
});
});
@@ -280,10 +340,10 @@ describe('allowsIdentificationCreation', () => {
});
});
-describe('getRetry', () => {
+describe('recoveryFor retry', () => {
it('retries a failed connection with its strategy and missing scopes', () => {
expect(
- getRetry(
+ recoveryPlan(
account({
id: 'idn_1',
provider: 'google',
@@ -297,6 +357,6 @@ describe('getRetry', () => {
});
it('does not retry an account without an error', () => {
- expect(getRetry(account({ id: 'idn_1', provider: 'google' }), undefined)).toBeNull();
+ 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
index 7eb9d1919d3..60970f8f377 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts
@@ -3,37 +3,40 @@ import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStatePara
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 { connectedAccountErrorMessage } from './user-profile-connected-accounts-feedback';
+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: {
- strategy: string | null;
- error: { code: string; longMessage?: string } | null;
- } | null;
+ verification:
+ | (Pick & { error: Pick | null })
+ | null;
};
type EnterpriseUser = {
- enterpriseAccounts: {
- active: boolean;
- enterpriseConnection?: { disableAdditionalIdentifications: boolean } | null;
- }[];
+ enterpriseAccounts: (Pick & {
+ enterpriseConnection?: Pick | null;
+ })[];
};
type ProjectedUser = {
verifiedExternalAccounts: AccountData[];
@@ -46,6 +49,18 @@ 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' }
| {
@@ -64,55 +79,52 @@ const RECONNECT_ERROR_CODES = [
const MONOCHROME_PROVIDERS = ['agentid', 'apple', 'github', 'okx_wallet', 'vercel', 'x'];
type SocialSettings = Partial<
- Record & { strategy: string }>
+ Record & { strategy: string }>
>;
-export function getProviderDisplay(
- provider: string,
+export function createProviderCatalog(
+ enabledStrategies: readonly string[],
social: SocialSettings,
-): Omit {
- const known = OAUTH_PROVIDERS.find(p => p.provider === provider);
- if (known) {
- return {
- provider: known.name,
- iconUrl: iconImageUrl(known.provider),
- monochromeIcon: MONOCHROME_PROVIDERS.includes(known.provider),
- };
- }
-
- const custom = Object.values(social).find(settings => settings?.strategy === `oauth_${provider}`);
- if (custom) {
- return { provider: custom.name || provider, iconUrl: custom.logo_url || undefined };
- }
-
- return { provider };
-}
-
-export function getEnabledOAuthStrategies(social: SocialSettings): OAuthStrategy[] {
- const enabled = Object.values(social)
- .flatMap(settings => (settings?.enabled ? [settings.strategy] : []))
- .sort();
- const known = enabled.flatMap(strategy => {
- const provider = OAUTH_PROVIDERS.find(provider => provider.strategy === strategy);
- return provider ? [provider.strategy] : [];
- });
- const custom = enabled.flatMap(strategy => {
- if (!strategy.startsWith('oauth_custom_')) {
- return [];
+ 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 customStrategy: OAuthStrategy = `oauth_custom_${strategy.slice('oauth_custom_'.length)}`;
- return [customStrategy];
- });
- return [...known, ...custom];
-}
-
-function providerFor(strategy: OAuthStrategy): OAuthProvider {
- const known = OAUTH_PROVIDERS.find(p => p.strategy === strategy);
- if (known) {
- return known.provider;
+ 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 },
+ });
}
- const custom: CustomOauthProvider = `custom_${strategy.slice('oauth_custom_'.length)}`;
- return custom;
+ return [
+ ...enabledStrategies.flatMap(strategy => {
+ const provider = providers.find(provider => provider.strategy === strategy);
+ return provider ? [provider] : [];
+ }),
+ ...providers.filter(provider => !provider.enabled),
+ ];
}
export function allowsIdentificationCreation(
@@ -133,50 +145,42 @@ function findAdditionalScopes(account: AccountData, scopes: AdditionalOAuthScope
return requested.some(scope => !approved.includes(scope)) ? requested : [];
}
-export function getRecovery(
+export function recoveryFor(
account: AccountData,
scopes: AdditionalOAuthScopes | undefined,
-): ConnectedAccountRecovery | null {
+ providers: ConnectedAccountProvider[],
+): ConnectedAccountRecoveryState {
const additionalScopes = findAdditionalScopes(account, scopes);
if (additionalScopes.length > 0 && account.approvedScopes !== '') {
- return { kind: 'reauthorize', additionalScopes };
- }
-
- if (!RECONNECT_ERROR_CODES.includes(account.verification?.error?.code ?? '')) {
- return null;
+ return { status: 'reconnect', plan: { kind: 'reauthorize', additionalScopes } };
}
-
- return createRecovery(account, additionalScopes);
-}
-
-export function getRetry(
- account: AccountData,
- scopes: AdditionalOAuthScopes | undefined,
-): ConnectedAccountRecovery | null {
- if (!account.verification?.error) {
- return null;
+ const error = account.verification?.error;
+ if (!error) {
+ return { status: 'connected', plan: null };
}
- return createRecovery(account, findAdditionalScopes(account, scopes));
-}
-
-function createRecovery(account: AccountData, additionalScopes: string[]): ConnectedAccountRecovery {
const verificationStrategy = account.verification?.strategy;
- const strategy = (
- verificationStrategy === 'google_one_tap' ? 'oauth_google' : verificationStrategy || `oauth_${account.provider}`
- ) as OAuthStrategy;
- return { kind: 'create', strategy, additionalScopes };
+ 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,
- social: SocialSettings,
+ providers: ConnectedAccountProvider[],
scopes: AdditionalOAuthScopes | undefined,
): UserProfileConnectedAccount {
const error = account.verification?.error;
- const status = getRecovery(account, scopes) ? 'reconnect' : error?.code ? 'error' : 'connected';
+ const { status } = recoveryFor(account, scopes, providers);
return {
id: account.id,
- ...getProviderDisplay(account.provider, social),
+ ...(providers.find(provider => provider.provider === account.provider)?.display ?? { provider: account.provider }),
identifier: account.username || account.emailAddress || undefined,
status,
verificationError: status === 'error' ? error?.longMessage : undefined,
@@ -185,16 +189,17 @@ function toAccountRow(
export function projectConnectedAccounts({
user,
- social,
+ providers,
+ socialEnabled,
allowCreation,
additionalOAuthScopes,
}: {
user: ProjectedUser;
- social: SocialSettings;
+ providers: ConnectedAccountProvider[];
+ socialEnabled: boolean;
allowCreation: boolean;
additionalOAuthScopes?: AdditionalOAuthScopes;
}): ConnectedAccountsProjection {
- const socialEnabled = Object.values(social).some(settings => settings?.enabled);
if (!socialEnabled) {
return { status: 'hidden' };
}
@@ -203,15 +208,15 @@ export function projectConnectedAccounts({
...user.verifiedExternalAccounts,
...user.unverifiedExternalAccounts.filter(account => account.verification?.error),
];
- const shownStrategies = displayed.map(account => `oauth_${account.provider}`);
+ const shownProviders = new Set(displayed.map(account => account.provider));
return {
status: 'ready',
- accounts: displayed.map(account => toAccountRow(account, social, additionalOAuthScopes)),
+ accounts: displayed.map(account => toAccountRow(account, providers, additionalOAuthScopes)),
availableProviders: allowCreation
- ? getEnabledOAuthStrategies(social)
- .filter(strategy => !shownStrategies.includes(strategy))
- .map(strategy => ({ id: strategy, ...getProviderDisplay(strategy.replace('oauth_', ''), social) }))
+ ? providers
+ .filter(provider => provider.enabled && !shownProviders.has(provider.provider))
+ .map(provider => ({ id: provider.strategy, ...provider.display }))
: [],
};
}
@@ -250,9 +255,15 @@ export function useUserProfileConnectedAccountsModel({
return { status: 'hidden', reason: 'no_user' };
}
+ const providers = createProviderCatalog(
+ environment.userSettings.socialProviderStrategies,
+ environment.userSettings.social,
+ user.externalAccounts,
+ );
const projection = projectConnectedAccounts({
user,
- social: environment.userSettings.social,
+ providers,
+ socialEnabled: environment.userSettings.socialProviderStrategies.length > 0,
allowCreation: allowsIdentificationCreation(user, environment.userSettings.enterpriseSSO),
additionalOAuthScopes,
});
@@ -263,7 +274,7 @@ export function useUserProfileConnectedAccountsModel({
const userId = user.id;
const transport = clerk.__internal_oauthTransport;
- const currentUser = () => {
+ const requireCurrentUser = () => {
const current = clerk.user;
if (!current || current.id !== userId) {
throw new ConnectedAccountActionError('unavailable');
@@ -281,11 +292,11 @@ export function useUserProfileConnectedAccountsModel({
throw new ConnectedAccountActionError('missing_verification_url');
}
- currentUser();
+ requireCurrentUser();
if (transport) {
const { callbackUrl } = await transport.open(url);
const nonce = new URL(callbackUrl).searchParams.get('rotating_token_nonce');
- const current = currentUser();
+ const current = requireCurrentUser();
await (nonce ? current.reload({ rotatingTokenNonce: nonce }) : current.reload());
return;
}
@@ -294,41 +305,43 @@ export function useUserProfileConnectedAccountsModel({
return 'redirecting';
};
+ async function runAction(action: () => Promise): Promise {
+ try {
+ return await action();
+ } catch (error) {
+ throw connectedAccountFeedback(error, messages, errorText);
+ }
+ }
+
return {
...projection,
userId,
- connect: async strategyId => {
- try {
- const strategy = getEnabledOAuthStrategies(environment.userSettings.social).find(
- candidate => candidate === strategyId,
- );
- if (!strategy) {
+ connect: strategyId =>
+ runAction(async () => {
+ const provider = providers.find(candidate => candidate.enabled && candidate.strategy === strategyId);
+ if (!provider) {
throw new ConnectedAccountActionError('unavailable');
}
- const provider = providerFor(strategy);
const redirectUrl = await getRedirectUrl();
- const current = currentUser();
+ const current = requireCurrentUser();
const response = await current.createExternalAccount({
- strategy,
- redirectUrl: withModalState(redirectUrl, provider),
- additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider] : [],
+ strategy: provider.strategy,
+ redirectUrl: withModalState(redirectUrl, provider.provider),
+ additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider.provider] : [],
});
- return await completeVerification(response);
- } catch (error) {
- throw new Error(connectedAccountErrorMessage(error, messages, errorText));
- }
- },
- reconnect: async accountId => {
- try {
+ return completeVerification(response);
+ }),
+ reconnect: accountId =>
+ runAction(async () => {
const redirectUrl = await getRedirectUrl();
- const current = currentUser();
+ const current = requireCurrentUser();
const account = current.externalAccounts.find(candidate => candidate.id === accountId);
if (!account) {
throw new ConnectedAccountActionError('unavailable');
}
- const recovery = getRecovery(account, additionalOAuthScopes) ?? getRetry(account, additionalOAuthScopes);
+ const { plan: recovery } = recoveryFor(account, additionalOAuthScopes, providers);
if (!recovery) {
- return;
+ throw new ConnectedAccountActionError('unavailable');
}
const response =
@@ -342,21 +355,15 @@ export function useUserProfileConnectedAccountsModel({
redirectUrl: withModalState(redirectUrl),
additionalScopes: recovery.additionalScopes,
});
- return await completeVerification(response);
- } catch (error) {
- throw new Error(connectedAccountErrorMessage(error, messages, errorText));
- }
- },
- remove: async accountId => {
- try {
- const account = currentUser().externalAccounts.find(candidate => candidate.id === accountId);
+ 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();
- } catch (error) {
- throw new Error(connectedAccountErrorMessage(error, messages, errorText));
- }
- },
+ }),
};
}
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
index 8abda17aca5..276150261f0 100644
--- 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
@@ -1,17 +1,20 @@
export type ConnectedAccountActionResult = 'redirecting' | void;
-export interface UserProfileConnectionProvider {
- id: string;
+export interface ConnectedAccountProviderDisplay {
provider: string;
iconUrl?: string;
monochromeIcon?: boolean;
+}
+
+export interface UserProfileConnectionProvider extends ConnectedAccountProviderDisplay {
+ id: string;
connectError?: string;
}
-export interface UserProfileConnectedAccount extends UserProfileConnectionProvider {
+export interface UserProfileConnectedAccount extends ConnectedAccountProviderDisplay {
+ id: string;
identifier?: string;
- canRemove?: boolean;
- status?: 'connected' | 'reconnect' | 'error';
+ status: 'connected' | 'reconnect' | 'error';
verificationError?: string;
reconnectError?: string;
}
@@ -27,8 +30,12 @@ export interface UserProfileConnectedAccountsSectionViewProps {
}
export class ConnectedAccountActionError extends Error {
- constructor(readonly code: 'unavailable' | 'missing_verification_url') {
- super(code);
+ 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/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
index 58219279388..edd6a7aebac 100644
--- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/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
@@ -4,7 +4,10 @@ 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 {
+ UserProfileConnectedAccountRowView,
+ UserProfileConnectProviderRowView,
+} from './user-profile-connected-account-row.view';
import type {
UserProfileConnectedAccount,
UserProfileConnectedAccountsSectionViewProps,
@@ -64,9 +67,9 @@ export function UserProfileConnectedAccountsSectionView({
))}
{onConnect
? availableProviders.map(provider => (
- [
...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',
+ },
]);
}
},