Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c4d45f1
feat(mosaic): add a pending state and monochrome icons to the connect…
austincalvelage Sep 25, 2026
92c7a5f
feat(mosaic): wire the connected accounts section to Clerk
austincalvelage Sep 25, 2026
32c3655
chore(swingset): add connected accounts live page
austincalvelage Sep 25, 2026
c26ec3a
fix(mosaic): preserve connected account migration behavior
austincalvelage Sep 28, 2026
45c17de
fix(mosaic): handle connected account reverification cancellation
austincalvelage Sep 28, 2026
f754ade
test(mosaic): cover connected account reconnect flow
austincalvelage Sep 28, 2026
222b660
fix(mosaic): embed reverification in connected account confirmation
austincalvelage Sep 28, 2026
b176bb9
fix(mosaic): show account creation reverification in a dialog
austincalvelage Sep 28, 2026
45fdb59
fix(mosaic): return from reverification to account confirmation
austincalvelage Sep 28, 2026
006f55d
fix(mosaic): keep confirmation pending while reverification loads
austincalvelage Sep 28, 2026
6a6574d
fix(mosaic): wait for standalone reverification to load
austincalvelage Sep 28, 2026
f9c75aa
refactor(mosaic): compose confirmation reverification in the feature
austincalvelage Sep 29, 2026
218072b
refactor(mosaic): defer connected account reverification
austincalvelage Sep 29, 2026
c0e56f8
test(mosaic): cover connected accounts through real Clerk
austincalvelage Sep 29, 2026
516de91
refactor(mosaic): use slots for account panel sections
austincalvelage Sep 29, 2026
26da0ef
fix(mosaic): release connected redirect pending state
austincalvelage Sep 29, 2026
703dbfd
chore(mosaic): mark session reverification as deferred
austincalvelage Sep 30, 2026
b6c6ff5
refactor(mosaic): move connected accounts Clerk logic into a model hook
austincalvelage Oct 1, 2026
da04c93
fix(mosaic): recheck the connected accounts user before acting
austincalvelage Oct 1, 2026
96085c6
fix(mosaic): only offer reconnect for recognized connected account er…
austincalvelage Oct 1, 2026
0b279ab
refactor(mosaic): validate connected account strategies instead of ca…
austincalvelage Oct 1, 2026
26da468
refactor(mosaic): colocate connected accounts files like the password…
austincalvelage Oct 1, 2026
05d5759
chore: consolidate connected accounts changesets
austincalvelage Oct 1, 2026
139de09
refactor(mosaic): align connected accounts controller with the passwo…
austincalvelage Oct 1, 2026
3620fbd
refactor(mosaic): match password section error naming in connected ac…
austincalvelage Oct 1, 2026
e26bba6
fix(mosaic): retry failed connected accounts from their row
austincalvelage Oct 1, 2026
aca0247
fix(mosaic): reset connected account connect guard in finally
austincalvelage Oct 1, 2026
ee289b2
test(mosaic): cover connected accounts through profile feature tests
austincalvelage Oct 1, 2026
71e1d6f
fix(mosaic): reconcile connected account fixtures with main
austincalvelage Oct 1, 2026
348ad8b
test(mosaic): cover connected account lifecycle regressions
austincalvelage Oct 2, 2026
9c8c929
test(mosaic): exercise OAuth continuations through Clerk
austincalvelage Oct 2, 2026
36a23dc
fix(mosaic): localize connected account errors in the model
austincalvelage Oct 2, 2026
7147537
fix(mosaic): abort stale connected account OAuth actions
austincalvelage Oct 2, 2026
b56560e
fix(mosaic): preserve focus after final connected account removal
austincalvelage Oct 2, 2026
f305b9f
fix(mosaic): persist pending connected accounts in fake FAPI
austincalvelage Oct 2, 2026
96e7b6a
test(mosaic): replace connected account layer tests
austincalvelage Oct 2, 2026
25918d3
test(mosaic): verify localized OAuth API feedback
austincalvelage Oct 2, 2026
d5d7b8d
chore(mosaic): use an empty connected accounts changeset
austincalvelage Oct 2, 2026
23b9d6b
test(mosaic): record retry loading transition gap
austincalvelage Oct 2, 2026
3a91075
fix(mosaic): reconcile connected accounts with latest main
austincalvelage Oct 2, 2026
1a919ae
refactor(mosaic): unify connected account provider and recovery policy
austincalvelage Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-connected-accounts-wire-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
88 changes: 88 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,10 +23,12 @@ import {
fapiClient,
type FapiEnvironment,
fapiEnvironment,
fapiExternalAccount,
fapiMembership,
fapiOrganization,
fapiPage,
fapiToken,
fapiVerification,
} from './fapi';

export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k';
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/mosaic/src/__tests__/feature/fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
EmailAddressJSON,
EnterpriseAccountJSON,
EnvironmentJSON,
ExternalAccountJSON,
OAuthProviders,
OrganizationJSON,
OrganizationMembershipJSON,
Expand Down Expand Up @@ -235,6 +236,27 @@ export function fapiEmailAddress(
};
}

export function fapiExternalAccount(
overrides: Partial<ExternalAccountJSON> & Pick<ExternalAccountJSON, 'id' | 'provider'>,
): 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<UserJSON> & Pick<UserJSON, 'id'>): UserJSON {
return {
object: 'user',
Expand Down
4 changes: 2 additions & 2 deletions packages/mosaic/src/__tests__/feature/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Clerk['load']>[0]) {
const clerk = new Clerk(PUBLISHABLE_KEY);
const navigate = vi.fn((_to: string) => Promise.resolve());

Expand All @@ -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 {
Expand Down

This file was deleted.

Loading
Loading