Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
18b9d23
test(mosaic): support enterprise accounts in fake FAPI
austincalvelage Sep 30, 2026
3adc53f
feat(mosaic): wire enterprise accounts
austincalvelage Sep 30, 2026
69c7845
feat(mosaic): integrate enterprise accounts section and live preview
austincalvelage Sep 30, 2026
9e366ac
refactor(mosaic): align enterprise accounts with the password section
austincalvelage Oct 1, 2026
f8f3163
fix(mosaic): reset enterprise account connect guard in finally
austincalvelage Oct 1, 2026
701f801
refactor(mosaic): rename enterprise pending connection to pendingId
austincalvelage Oct 1, 2026
cb5fa11
fix(mosaic): reconcile enterprise tests with main
austincalvelage Oct 1, 2026
cf4faa4
test(mosaic): reproduce enterprise account identity races
austincalvelage Oct 2, 2026
ccae608
fix(mosaic): keep enterprise linking scoped to the current user
austincalvelage Oct 2, 2026
bd20e46
test(mosaic): cover localized enterprise API errors
austincalvelage Oct 2, 2026
4a6d913
fix(mosaic): localize enterprise errors at the model boundary
austincalvelage Oct 2, 2026
e6559a6
test(mosaic): expose enterprise linking fake contract gaps
austincalvelage Oct 2, 2026
da3e6ac
test(mosaic): model enterprise linking eligibility and protocol outcomes
austincalvelage Oct 2, 2026
f0b7bc2
test(mosaic): pin enterprise provider and claimed SAML contracts
austincalvelage Oct 2, 2026
4559199
test(mosaic): preserve raw enterprise providers and SAML eligibility
austincalvelage Oct 2, 2026
08eeb15
test(mosaic): pin enterprise section order in the connected host
austincalvelage Oct 2, 2026
c10ea6e
fix(mosaic): restore enterprise accounts before Web3 wallets
austincalvelage Oct 2, 2026
036720e
test(mosaic): replace enterprise hook tests with connected coverage
austincalvelage Oct 2, 2026
afecfad
fix(mosaic): reconcile enterprise accounts with latest main
austincalvelage Oct 2, 2026
3122ed3
fix(swingset): update enterprise page provider import
austincalvelage Oct 2, 2026
cd2c66c
fix(mosaic): reconcile enterprise tests with connected accounts
austincalvelage Oct 2, 2026
be39204
fix(mosaic): share navigation and API error adaptation
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/bright-enterprise-accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
18 changes: 16 additions & 2 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { OAUTH_PROVIDERS } from '@clerk/shared/oauth';
import type {
ApiKeyJSON,
ClientJSON,
EnterpriseConnectionJSON,
OAuthProvider,
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
Expand All @@ -12,6 +13,7 @@ import type {
import { http, HttpResponse, type JsonBodyType } from 'msw';
import { setupWorker } from 'msw/browser';

import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise';
import {
createVerificationState,
type FakeVerificationSeed,
Expand Down Expand Up @@ -45,10 +47,13 @@ export interface FakeFapiState {
apiKeys: ApiKeyJSON[];
verification: FakeVerificationState;
passwordUpdates: URLSearchParams[];
enterpriseConnections: EnterpriseConnectionJSON[];
enterpriseLinking: FakeEnterpriseLinking;
}

export type FakeFapiSeed = Partial<Omit<FakeFapiState, 'verification'>> & {
export type FakeFapiSeed = Partial<Omit<FakeFapiState, 'verification' | 'enterpriseLinking'>> & {
verification?: FakeVerificationSeed;
enterpriseLinking?: Partial<FakeEnterpriseLinking>;
};

const unhandled: string[] = [];
Expand Down Expand Up @@ -109,7 +114,7 @@ function missing() {
}

export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
const { verification, ...rest } = seed;
const { verification, enterpriseLinking, ...rest } = seed;
const state: FakeFapiState = {
environment: fapiEnvironment(),
client: fapiClient(),
Expand All @@ -118,12 +123,21 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
suggestions: [],
apiKeys: [],
passwordUpdates: [],
enterpriseConnections: [],
...rest,
verification: createVerificationState(verification),
enterpriseLinking: {
enabled: false,
preparations: {},
verifiedLinks: [],
pendingExternalAccounts: [],
...enterpriseLinking,
},
};

worker.use(
...verificationHandlers(state, fapiUrl),
...enterpriseHandlers(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'), () => {
Expand Down
132 changes: 132 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi/enterprise.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { ExternalAccountJSON, VerificationJSON } from '@clerk/shared/types';
import { http, HttpResponse } from 'msw';

import type { FakeFapiState } from '../fake-fapi';

export type EnterpriseExternalAccount = Omit<ExternalAccountJSON, 'provider'> & { provider: string };

export type EnterprisePreparation =
| { kind: 'saml'; verification: VerificationJSON }
| { kind: 'oidc'; account: EnterpriseExternalAccount };

export interface FakeEnterpriseLinking {
enabled: boolean;
preparations: Record<string, EnterprisePreparation>;
verifiedLinks: { userId: string; connectionId: string }[];
pendingExternalAccounts: { userId: string; connectionId: string; account: EnterpriseExternalAccount }[];
}

function rejected(code: 'resource_not_found' | 'feature_not_enabled') {
return HttpResponse.json(
{ errors: [{ code, message: code === 'resource_not_found' ? 'not found' : 'Feature not enabled' }] },
{ status: code === 'resource_not_found' ? 404 : 403 },
);
}

export function enterpriseHandlers(state: FakeFapiState, url: (path: string) => string) {
return [
http.get(url('/v1/me/enterprise_connections'), ({ request }) => {
const user = state.client.sessions.find(session => session.id === state.client.last_active_session_id)?.user;
if (!user) {
return rejected('resource_not_found');
}
const withLinking = new URL(request.url).searchParams.get('with_organization_account_linking') === 'true';
const connections = withLinking
? state.enterpriseConnections.filter(
connection =>
connection.active &&
connection.organization_id &&
connection.allow_organization_account_linking &&
user.organization_memberships.some(
membership => membership.organization.id === connection.organization_id,
) &&
!state.enterpriseLinking.verifiedLinks.some(
link => link.userId === user.id && link.connectionId === connection.id,
),
)
: state.enterpriseConnections;
return HttpResponse.json({ response: connections, client: state.client });
}),
http.post(url('/v1/me/external_accounts'), async ({ request }) => {
const user = state.client.sessions.find(session => session.id === state.client.last_active_session_id)?.user;
if (!user) {
return rejected('resource_not_found');
}
const body = new URLSearchParams(await request.clone().text());
const connectionId = body.get('enterprise_connection_id');
if (!connectionId) {
return undefined;
}
if (!state.enterpriseLinking.enabled) {
return rejected('feature_not_enabled');
}
const connection = state.enterpriseConnections.find(item => item.id === connectionId);
if (!connection?.active || !connection.organization_id) {
return rejected('resource_not_found');
}
if (!connection.allow_organization_account_linking) {
return rejected('feature_not_enabled');
}
if (
!user.organization_memberships.some(membership => membership.organization.id === connection.organization_id)
) {
return rejected('resource_not_found');
}
const preparation = state.enterpriseLinking.preparations[connection.id];
if (!preparation) {
throw new Error(`Missing enterprise preparation for ${connection.id}`);
}
if (preparation.kind === 'saml') {
if (
!connection.provider.startsWith('saml_') ||
preparation.verification.strategy !== 'saml' ||
preparation.verification.status !== 'unverified'
) {
throw new Error(`Invalid SAML preparation for ${connection.id}`);
}
const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id);
if (!primaryEmail) {
return rejected('resource_not_found');
}
if (
state.enterpriseLinking.verifiedLinks.some(
link => link.userId === user.id && link.connectionId === connection.id,
)
) {
return HttpResponse.json(
{
errors: [
{
code: 'enterprise_sso_account_already_connected',
message: 'Already connected',
long_message: `An enterprise account is already connected for this connection email: ${primaryEmail.email_address}`,
},
],
},
{ status: 400 },
);
}
return HttpResponse.json({
response: { object: 'external_account', verification: preparation.verification },
client: state.client,
});
}
const account = preparation.account;
if (
!connection.provider.startsWith('oidc_') ||
!account.provider.startsWith('oauth_') ||
account.verification?.strategy !== account.provider ||
account.verification.status !== 'unverified'
) {
throw new Error(`Invalid OIDC preparation for ${connection.id}`);
}
state.enterpriseLinking.pendingExternalAccounts = [
...state.enterpriseLinking.pendingExternalAccounts.filter(
item => item.userId !== user.id || item.account.id !== account.id,
),
{ userId: user.id, connectionId: connection.id, account },
];
return HttpResponse.json({ response: account, client: state.client });
}),
];
}
21 changes: 21 additions & 0 deletions packages/mosaic/src/__tests__/feature/fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
DisplayConfigJSON,
EmailAddressJSON,
EnterpriseAccountJSON,
EnterpriseConnectionJSON,
EnvironmentJSON,
ExternalAccountJSON,
OAuthProviders,
Expand Down Expand Up @@ -257,6 +258,26 @@ export function fapiExternalAccount(
};
}

export function fapiEnterpriseConnection(
overrides: Partial<EnterpriseConnectionJSON> & Pick<EnterpriseConnectionJSON, 'id'>,
): EnterpriseConnectionJSON {
return {
object: 'enterprise_connection',
name: overrides.id,
active: true,
provider: 'saml_okta',
logo_public_url: null,
domains: [],
organization_id: null,
sync_user_attributes: false,
disable_additional_identifications: false,
allow_organization_account_linking: true,
created_at: createdAt,
updated_at: createdAt,
...overrides,
};
}

export function fapiUser(overrides: Partial<UserJSON> & Pick<UserJSON, 'id'>): UserJSON {
return {
object: 'user',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { FakeFapiSeed } from '../../../__tests__/feature/fake-fapi';
import {
fapiClient,
fapiEmailAddress,
fapiEnterpriseConnection,
fapiEnvironment,
fapiMembership,
fapiOrganization,
fapiSession,
fapiUser,
fapiVerification,
} from '../../../__tests__/feature/fapi';

export const okta = fapiEnterpriseConnection({ id: 'okta', name: 'Acme Okta', organization_id: 'org_acme' });
export const custom = fapiEnterpriseConnection({ id: 'saml', name: 'Custom SAML', organization_id: 'org_acme' });

export function enterpriseMember(id = 'user_1') {
return fapiUser({
id,
email_addresses: [fapiEmailAddress({ id: `email_${id}`, email_address: `${id}@example.com` })],
organization_memberships: [fapiMembership(fapiOrganization({ id: 'org_acme', name: 'Acme' }))],
});
}

export function enterpriseAccountSeed(overrides: FakeFapiSeed = {}): FakeFapiSeed {
const verification = fapiVerification('saml', {
status: 'unverified',
external_verification_redirect_url: 'https://accounts.example/enterprise-authorize',
});
return {
client: fapiClient([fapiSession({ id: 'sess_1', user: enterpriseMember() })]),
environment: fapiEnvironment({
user_settings: { enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false } },
}),
enterpriseConnections: [okta, custom],
enterpriseLinking: {
enabled: true,
preparations: { okta: { kind: 'saml', verification }, saml: { kind: 'saml', verification } },
},
...overrides,
};
}
Loading
Loading