diff --git a/.changeset/mosaic-connected-accounts-wire-up.md b/.changeset/mosaic-connected-accounts-wire-up.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-connected-accounts-wire-up.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index a7a4ba2dea0..fc8820fbcf2 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -1,9 +1,12 @@ +import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import type { ApiKeyJSON, ClientJSON, + OAuthProvider, OrganizationMembershipJSON, OrganizationSuggestionJSON, SessionJSON, + UserJSON, UserOrganizationInvitationJSON, } from '@clerk/shared/types'; import { http, HttpResponse, type JsonBodyType } from 'msw'; @@ -20,10 +23,12 @@ import { fapiClient, type FapiEnvironment, fapiEnvironment, + fapiExternalAccount, fapiMembership, fapiOrganization, fapiPage, fapiToken, + fapiVerification, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -88,6 +93,17 @@ function error(code: string, status = 400) { return HttpResponse.json({ errors: [{ code, message: code, long_message: code }] }, { status }); } +function activeUser(state: FakeFapiState): UserJSON | undefined { + return findSession(state, state.client.last_active_session_id)?.user; +} + +function updateUser(state: FakeFapiState, user: UserJSON): void { + state.client = { + ...state.client, + sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)), + }; +} + function missing() { return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); } @@ -110,6 +126,78 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { ...verificationHandlers(state, fapiUrl), http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), + http.get(fapiUrl('/v1/me'), () => { + const user = activeUser(state); + return user ? envelope(user, state.client) : missing(); + }), + http.post(fapiUrl('/v1/me/external_accounts'), async ({ request }) => { + const user = activeUser(state); + if (!user) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const strategy = body.get('strategy'); + if (!strategy) { + return missing(); + } + const provider: OAuthProvider | undefined = OAUTH_PROVIDERS.find(item => item.strategy === strategy)?.provider; + if (!provider) { + return missing(); + } + const account = fapiExternalAccount({ + id: `idn_${crypto.randomUUID()}`, + approved_scopes: '', + provider, + verification: fapiVerification(strategy, { + status: 'unverified', + external_verification_redirect_url: 'https://accounts.example/authorize', + }), + }); + updateUser(state, { + ...user, + external_accounts: [ + ...user.external_accounts.filter( + item => item.provider !== provider || item.verification?.status === 'verified', + ), + account, + ], + }); + return envelope(account, state.client); + }), + http.post(fapiUrl('/v1/me/external_accounts/:id/reauthorize'), ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + const user = activeUser(state); + const account = user?.external_accounts.find(item => item.id === params.id); + if (!user || !account) { + return missing(); + } + const pending = { + ...account, + verification: fapiVerification(`oauth_${account.provider}`, { + status: 'unverified', + external_verification_redirect_url: 'https://accounts.example/consent', + }), + }; + updateUser(state, { + ...user, + external_accounts: user.external_accounts.map(item => (item.id === pending.id ? pending : item)), + }); + return envelope(pending, state.client); + }), + http.post(fapiUrl('/v1/me/external_accounts/:id'), ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + const user = activeUser(state); + const account = user?.external_accounts.find(item => item.id === params.id); + if (!user || !account) { + return missing(); + } + updateUser(state, { ...user, external_accounts: user.external_accounts.filter(item => item.id !== account.id) }); + return envelope({ ...account, object: 'external_account' }, state.client); + }), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { const session = findSession(state, params.id); return session diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 81faadddb17..383e4b20ead 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -9,6 +9,7 @@ import type { EmailAddressJSON, EnterpriseAccountJSON, EnvironmentJSON, + ExternalAccountJSON, OAuthProviders, OrganizationJSON, OrganizationMembershipJSON, @@ -235,6 +236,27 @@ export function fapiEmailAddress( }; } +export function fapiExternalAccount( + overrides: Partial & Pick, +): ExternalAccountJSON { + return { + object: 'external_account', + identification_id: overrides.id, + provider_user_id: 'provider_user_1', + approved_scopes: 'email', + email_address: `${overrides.provider}@example.com`, + first_name: '', + last_name: '', + image_url: '', + username: '', + phone_number: '', + public_metadata: {}, + label: '', + verification: fapiVerification(`oauth_${overrides.provider}`, { status: 'verified' }), + ...overrides, + }; +} + export function fapiUser(overrides: Partial & Pick): UserJSON { return { object: 'user', diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index bdf8c89a047..0b4e0694f69 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -7,7 +7,7 @@ import { vi } from 'vitest'; import { MosaicProvider } from '../../mosaic-provider'; import { PUBLISHABLE_KEY } from './fake-fapi'; -export async function renderWithClerk(ui: ReactElement) { +export async function renderWithClerk(ui: ReactElement, options?: Parameters[0]) { const clerk = new Clerk(PUBLISHABLE_KEY); const navigate = vi.fn((_to: string) => Promise.resolve()); @@ -21,7 +21,7 @@ export async function renderWithClerk(ui: ReactElement) { ); let current = ui; const view = render(wrap(current)); - await act(() => clerk.load({ routerPush: to => navigate(to), routerReplace: to => navigate(to) })); + await act(() => clerk.load({ routerPush: to => navigate(to), routerReplace: to => navigate(to), ...options })); view.rerender(wrap(current)); return { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx deleted file mode 100644 index bdff60b1263..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-actions.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view'; - -const account = { id: 'github', provider: 'GitHub', identifier: 'test' }; - -function renderAccounts(onRemove: (id: string) => void | Promise) { - return render( - - - , - ); -} - -async function openRemoval(user: ReturnType) { - await user.click(screen.getByRole('button', { name: 'Manage GitHub' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove' })); -} - -describe('connected account removal', () => { - it('returns keyboard focus to the menu when canceled', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - renderAccounts(onRemove); - const trigger = screen.getByRole('button', { name: 'Manage GitHub' }); - trigger.focus(); - await user.keyboard('{Enter}'); - await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Remove' })).toHaveFocus()); - await user.keyboard('{Enter}'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await user.keyboard('{Escape}'); - await waitFor(() => expect(trigger).toHaveFocus()); - expect(onRemove).not.toHaveBeenCalled(); - }); - - it('focuses the remaining account and then Connect when the caller removes rows', async () => { - const user = userEvent.setup(); - function Example() { - const [accounts, setAccounts] = useState([account, { ...account, id: 'second', provider: 'Google' }]); - return ( - - {}} - onRemove={id => setAccounts(current => current.filter(item => item.id !== id))} - /> - - ); - } - render(); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Manage Google' })).toHaveFocus()); - await user.click(screen.getByRole('button', { name: 'Manage Google' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.getByRole('button', { name: 'Connect Other' })).toHaveFocus()); - }); - - it('shows a removal failure and allows retrying', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn().mockRejectedValueOnce(new Error('Unable to disconnect')).mockResolvedValue(undefined); - renderAccounts(onRemove); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Unable to disconnect'); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - expect(onRemove).toHaveBeenCalledTimes(2); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('keeps the selected account pending until its removal finishes', async () => { - const user = userEvent.setup(); - const removal = createDeferredPromise(); - const onRemove = vi.fn(async () => { - await removal.promise; - }); - renderAccounts(onRemove); - await openRemoval(user); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - - expect(onRemove).toHaveBeenCalledExactlyOnceWith('github'); - expect(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })).toHaveAttribute( - 'aria-busy', - 'true', - ); - await act(async () => { - removal.resolve(); - await removal.promise; - }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - }); - - it('opens the same confirmation for the chosen account after cancelling another', async () => { - const user = userEvent.setup(); - const onRemove = vi.fn(); - render( - - - , - ); - await openRemoval(user); - expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/GitHub will be removed/); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('button', { name: 'Manage GitHub' })).toHaveFocus(); - await user.click(screen.getByRole('button', { name: 'Manage Google' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove' })); - expect(screen.getAllByRole('alertdialog')).toHaveLength(1); - expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/Google will be removed/); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - expect(onRemove).toHaveBeenCalledExactlyOnceWith('google'); - }); - - it('keeps removal callbacks isolated between section instances', async () => { - const user = userEvent.setup(); - const removeGitHub = vi.fn(); - const removeGoogle = vi.fn(); - render( - - - - , - ); - await openRemoval(user); - expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/GitHub will be removed/); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(removeGitHub).toHaveBeenCalledExactlyOnceWith('github'); - expect(removeGoogle).not.toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'Manage Google' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove' })); - expect(screen.getByRole('alertdialog')).toHaveAccessibleDescription(/Google will be removed/); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - expect(removeGoogle).toHaveBeenCalledExactlyOnceWith('google'); - expect(removeGitHub).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx deleted file mode 100644 index e74f66bb7b0..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts-section.view.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view'; - -const account = { id: 'account_1', provider: 'Google', identifier: 'test@example.com' }; - -describe('connected accounts section', () => { - it.each([{ availableProviders: [] }, { availableProviders: [{ id: 'google', provider: 'Google' }] }])( - 'hides the entire section without accounts or actionable providers (%j)', - ({ availableProviders }) => { - const { container } = render( - , - ); - expect(container).toBeEmptyDOMElement(); - }, - ); - - it('shows provider names without announcing decorative logos', () => { - render( - , - ); - expect(screen.getByText('Google')).toBeVisible(); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); - }); - - it('shows connection errors while keeping Connect available', () => { - render( - , - ); - expect(screen.getByRole('alert')).toHaveTextContent('Connection failed'); - expect(screen.getByRole('button', { name: 'Connect Apple' })).toBeEnabled(); - expect(screen.queryByRole('menu')).not.toBeInTheDocument(); - }); - - it('does not offer removal for a protected account', async () => { - const user = userEvent.setup(); - render( - , - ); - await user.click(screen.getByRole('button', { name: 'Manage Google' })); - expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled(); - expect(screen.queryByRole('menuitem', { name: 'Remove' })).not.toBeInTheDocument(); - }); - - it('renders reconnect and verification errors without a generic manage action', async () => { - const user = userEvent.setup(); - const onReconnect = vi.fn(); - render( - , - ); - expect(screen.getByText('Disconnected')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Reconnect' })).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Manage Google' })); - expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled(); - expect(screen.getByText('Provider error')).toBeInTheDocument(); - expect(screen.getAllByRole('alert').some(alert => alert.textContent?.includes('Reconnection failed'))).toBe(true); - expect(screen.queryByRole('button', { name: 'Manage Other' })).not.toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx new file mode 100644 index 00000000000..3080ab7ffdb --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx @@ -0,0 +1,957 @@ +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEnterpriseAccount, + fapiEnvironment, + fapiExternalAccount, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section/user-profile-connected-accounts-section'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +const google = fapiExternalAccount({ id: 'idn_google', provider: 'google', username: 'jdoe' }); +const github = fapiExternalAccount({ id: 'idn_github', provider: 'github' }); +const disconnectedGoogle = fapiExternalAccount({ + id: 'idn_google', + provider: 'google', + verification: fapiVerification('google_one_tap', { + status: 'unverified', + error: { code: 'external_account_missing_refresh_token', message: 'Missing token', long_message: 'Missing token' }, + }), +}); + +function signedIn(accounts = [google], overrides: FakeFapiSeed = {}) { + return { + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1', external_accounts: accounts }) })]), + environment: fapiEnvironment({ + user_settings: { + social: { + oauth_google: { + enabled: true, + required: false, + authenticatable: true, + strategy: 'oauth_google', + name: 'Google', + logo_url: null, + }, + oauth_github: { + enabled: true, + required: false, + authenticatable: true, + strategy: 'oauth_github', + name: 'GitHub', + logo_url: null, + }, + }, + }, + }), + ...overrides, + }; +} + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(fulfill => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +async function renderSection(accounts = [google], overrides: FakeFapiSeed = {}) { + const fapi = serveFapi(signedIn(accounts, overrides)); + const titleRef = createRef(); + const view = await renderWithClerk( + titleRef.current} />} + />, + ); + return { ...view, fapi, titleRef }; +} + +async function openRemoval(user: ReturnType, provider: string) { + await user.click(screen.getByRole('button', { name: `Manage ${provider}` })); + await user.click(screen.getByRole('menuitem', { name: 'Remove' })); + return screen.getByRole('alertdialog'); +} + +describe('connected accounts', () => { + it('shows the fallback while Clerk loads', async () => { + serveFapi(signedIn()); + const client = holdRequests('get', '/v1/client'); + const rendering = renderWithClerk(Loading accounts

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

Connected accounts

+

+ Connects, reconnects, and removes the signed-in user's social accounts. The section stays hidden when + no social provider is enabled. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to manage connected accounts. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts index c903cae4087..7400dedd713 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts @@ -1,17 +1,18 @@ import type { UserProfileConnectedAccount, UserProfileConnectionProvider, -} from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section.view'; +} from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosEmail, chaosText } from '@/lib/chaos'; -export const connectedAccount = { +export const connectedAccount: UserProfileConnectedAccount = { id: 'google', provider: 'Google', identifier: 'test@example.com', iconUrl: 'https://img.clerk.com/static/google.svg', + status: 'connected', }; const connectionProviders: UserProfileConnectionProvider[] = [ { id: 'google', provider: 'Google', iconUrl: 'https://img.clerk.com/static/google.svg' }, @@ -49,7 +50,13 @@ export function useConnectedAccountsFixture({ if (provider) { setAccounts(current => [ ...current, - { id: provider.id, provider: provider.provider, iconUrl: provider.iconUrl, identifier: 'test@example.com' }, + { + id: provider.id, + provider: provider.provider, + iconUrl: provider.iconUrl, + identifier: 'test@example.com', + status: 'connected', + }, ]); } }, diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index de99d9b055b..3132c03f8fc 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -5,13 +5,15 @@ import type { UserProfilePaymentMethod, UserProfileSubscription, } from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view'; +import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; import { UserProfileDeleteSectionView } from '@clerk/mosaic/features/user-profile/user-profile-delete-section/user-profile-delete-section.view'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; import type { UserProfileEmail, UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import { useState } from 'react'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { useRef, useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosEmail, chaosRows, chaosText } from '@/lib/chaos'; @@ -64,6 +66,7 @@ const examplePaymentMethods: UserProfilePaymentMethod[] = [ * stories that need a realistic profile surface without being about it. */ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) { + const titleRef = useRef(null); const connections = useConnectedAccountsFixture(); const wallets = useWeb3WalletsFixture(); const editName = useUserProfileEditNameFixture(); @@ -114,16 +117,19 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions account: { ...editName, ...editUsername, - connectedAccounts: connections.accounts, - availableConnectionProviders: connections.availableProviders, - onConnectAccount: connections.onConnect, - onReconnectAccount: connections.onReconnect, - onRemoveConnectedAccount: connections.onRemove, - web3Wallets: wallets.wallets, - availableWeb3Providers: wallets.availableProviders, - onConnectWeb3Wallet: wallets.onConnect, - onSetPrimaryWeb3Wallet: wallets.onSetPrimary, - onRemoveWeb3Wallet: wallets.onRemove, + titleRef, + connectedAccountsSlot: ( + titleRef.current} + /> + ), + web3WalletsSlot: ( + titleRef.current} + /> + ), allowMultipleAccounts: true, hasImage: Boolean(imageUrl), imageUrl, diff --git a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx index 63f7d06698e..ad0418cedab 100644 --- a/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-connected-accounts-section.stories.tsx @@ -1,4 +1,4 @@ -import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section.view'; +import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; import type { StoryMeta } from '@/lib/types'; @@ -11,7 +11,8 @@ export const meta: StoryMeta = { title: 'UserProfileConnectedAccountsSection', label: 'Connected accounts', navigation: { category: 'Sections' }, - source: 'packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section.view.tsx', + source: + 'packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx', }; export function Default() { const fixture = useConnectedAccountsFixture(); diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 7e392ec0679..5094a1b339e 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -16,7 +16,12 @@ import * as UserProfileProfilePanelStories from './user-profile-profile-panel.st ## Usage ```tsx +import { useRef } from 'react'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; +import { UserProfileConnectedAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; + +const titleRef = useRef(null); ({ - id: account.id, - provider: account.provider, - identifier: account.emailAddress, - connected: true, - }))} - web3Wallets={supportedWeb3Wallets.map(provider => { - const wallet = user.web3Wallets.find(wallet => wallet.provider === provider.id); - - return { - id: provider.id, - provider: provider.name, - iconUrl: provider.iconUrl, - address: wallet?.web3Wallet, - connected: Boolean(wallet), - isPrimary: wallet?.id === user.primaryWeb3WalletId, - isVerified: wallet ? wallet.verification.status === 'verified' : undefined, - }; - })} + titleRef={titleRef} + connectedAccountsSlot={ titleRef.current} />} + web3WalletsSlot={ titleRef.current} />} onVerifyEmail={verifyEmail} onSendEmailCode={sendEmailCode} onVerifyEmailCode={verifyEmailCode} @@ -62,9 +51,5 @@ import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile onVerifyPhone={verifyPhone} onSetPrimaryPhone={setPrimaryPhone} onRemovePhone={removePhone} - onRemoveConnectedAccount={removeConnectedAccount} - onConnectWeb3Wallet={connectWeb3Wallet} - onSetPrimaryWeb3Wallet={setPrimaryWeb3Wallet} - onRemoveWeb3Wallet={removeWeb3Wallet} /> ``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index aabb012c536..a656c4fd8d6 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -1,9 +1,11 @@ +import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; import type { UserProfileEmail, UserProfilePhone, } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import { useState } from 'react'; +import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section.view'; +import { useRef, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -30,6 +32,7 @@ export const meta: StoryMeta = { }; export function Default(_args: Record) { + const titleRef = useRef(null); const [emails, setEmails] = useState([ { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, @@ -53,29 +56,32 @@ export function Default(_args: Record) { {...emailFlow} allowMultipleAccounts emails={emails} - connectedAccounts={connections.accounts} - availableConnectionProviders={connections.availableProviders} - onReconnectAccount={connections.onReconnect} - web3Wallets={wallets.wallets} - availableWeb3Providers={wallets.availableProviders} + titleRef={titleRef} + connectedAccountsSlot={ + titleRef.current} + /> + } + web3WalletsSlot={ + titleRef.current} + /> + } hasImage={Boolean(imageUrl)} imageUrl={imageUrl} phones={phones} {...createUserProfileAddPhoneFixture({ onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), })} - onConnectAccount={connections.onConnect} deleteAccountSlot={} onManageEmail={() => undefined} onManagePhone={() => undefined} onProfilePictureChange={showFile} - onRemoveConnectedAccount={connections.onRemove} onRemoveProfilePicture={clearImage} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} - onConnectWeb3Wallet={wallets.onConnect} - onRemoveWeb3Wallet={wallets.onRemove} - onSetPrimaryWeb3Wallet={wallets.onSetPrimary} onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onVerifyEmail={() => undefined}