diff --git a/.changeset/orgs-1597-configure-sso-org-scoped.md b/.changeset/orgs-1597-configure-sso-org-scoped.md new file mode 100644 index 00000000000..a8213d39c5f --- /dev/null +++ b/.changeset/orgs-1597-configure-sso-org-scoped.md @@ -0,0 +1,12 @@ +--- +'@clerk/ui': minor +'@clerk/shared': minor +--- + +`` now calls the org-scoped enterprise connections endpoints via `organization.*EnterpriseConnection*` methods. Previously, the wizard called `user.*EnterpriseConnection*` against the `/me/*` paths. + +Adds two new internal hooks in `@clerk/shared/react`, mirroring the user-scoped variants: +- `__internal_useOrganizationEnterpriseConnections` +- `__internal_useOrganizationEnterpriseConnectionTestRuns` + +The existing `__internal_useUserEnterpriseConnections` and `__internal_useEnterpriseConnectionTestRuns` hooks remain available as `@deprecated` aliases. diff --git a/.changeset/orgs-1597-organization-enterprise-connections.md b/.changeset/orgs-1597-organization-enterprise-connections.md new file mode 100644 index 00000000000..c9e88375a0a --- /dev/null +++ b/.changeset/orgs-1597-organization-enterprise-connections.md @@ -0,0 +1,15 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +--- + +Add organization-scoped enterprise connection methods on the `Organization` resource: `getEnterpriseConnections`, `createEnterpriseConnection`, `updateEnterpriseConnection`, `deleteEnterpriseConnection`, `createEnterpriseConnectionTestRun`, and `getEnterpriseConnectionTestRuns`. These hit `/v1/organizations/{org_id}/enterprise_connections/*` and share the same flattened SAML/OIDC request body shape as the existing `User.*` equivalents. + +Renames the parameter types from `Me*` to `Organization*`: +- `CreateOrganizationEnterpriseConnectionParams` +- `UpdateOrganizationEnterpriseConnectionParams` +- `OrganizationEnterpriseConnectionProvider` +- `OrganizationEnterpriseConnectionSamlInput` +- `OrganizationEnterpriseConnectionOidcInput` + +The previous `Me*` names remain available as `@deprecated` aliases for backwards compatibility. diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index f0a2a21b9f2..c3c91cb4b73 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -2,8 +2,20 @@ import type { AddMemberParams, ClerkPaginatedResponse, ClerkResourceReloadParams, + CreateOrganizationEnterpriseConnectionParams, CreateOrganizationParams, + DeletedObjectJSON, + DeletedObjectResource, + EnterpriseConnectionJSON, + EnterpriseConnectionResource, + EnterpriseConnectionTestRunInitJSON, + EnterpriseConnectionTestRunInitResource, + EnterpriseConnectionTestRunJSON, + EnterpriseConnectionTestRunResource, + EnterpriseConnectionTestRunsPaginatedJSON, GetDomainsParams, + GetEnterpriseConnectionsParams, + GetEnterpriseConnectionTestRunsParams, GetInvitationsParams, GetMembershipRequestParams, GetMemberships, @@ -23,13 +35,22 @@ import type { RoleJSON, SetOrganizationLogoParams, UpdateMembershipParams, + UpdateOrganizationEnterpriseConnectionParams, UpdateOrganizationParams, } from '@clerk/shared/types'; import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; import { unixEpochToDate } from '../../utils/date'; +import { toEnterpriseConnectionBody } from '../../utils/enterpriseConnection'; import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '../modules/billing'; -import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal'; +import { + BaseResource, + DeletedObject, + EnterpriseConnection, + EnterpriseConnectionTestRun, + OrganizationInvitation, + OrganizationMembership, +} from './internal'; import { OrganizationDomain } from './OrganizationDomain'; import { OrganizationMembershipRequest } from './OrganizationMembershipRequest'; import { Role } from './Role'; @@ -142,6 +163,107 @@ export class Organization extends BaseResource implements OrganizationResource { return new OrganizationDomain(json); }; + getEnterpriseConnections = async ( + params?: GetEnterpriseConnectionsParams, + ): Promise => { + const { withOrganizationAccountLinking } = params || {}; + + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections`, + method: 'GET', + ...(withOrganizationAccountLinking !== undefined + ? { + search: { + with_organization_account_linking: String(withOrganizationAccountLinking), + }, + } + : {}), + }) + )?.response as unknown as EnterpriseConnectionJSON[]; + + return (json || []).map(connection => new EnterpriseConnection(connection)); + }; + + createEnterpriseConnection = async ( + params: CreateOrganizationEnterpriseConnectionParams, + ): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections`, + method: 'POST', + body: toEnterpriseConnectionBody(params, { omitOrganizationId: true }) as any, + }) + )?.response as unknown as EnterpriseConnectionJSON; + + return new EnterpriseConnection(json); + }; + + updateEnterpriseConnection = async ( + enterpriseConnectionId: string, + params: UpdateOrganizationEnterpriseConnectionParams, + ): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}`, + method: 'PATCH', + body: toEnterpriseConnectionBody(params, { omitOrganizationId: true }) as any, + }) + )?.response as unknown as EnterpriseConnectionJSON; + + return new EnterpriseConnection(json); + }; + + deleteEnterpriseConnection = async (enterpriseConnectionId: string): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}`, + method: 'DELETE', + }) + )?.response as unknown as DeletedObjectJSON; + + return new DeletedObject(json); + }; + + createEnterpriseConnectionTestRun = async ( + enterpriseConnectionId: string, + ): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/test_runs`, + method: 'POST', + }) + )?.response as unknown as EnterpriseConnectionTestRunInitJSON; + + return { url: json.url }; + }; + + getEnterpriseConnectionTestRuns = async ( + enterpriseConnectionId: string, + params?: GetEnterpriseConnectionTestRunsParams, + ): Promise> => { + const { status, ...rest } = params || {}; + const search = convertPageToOffsetSearchParams(rest); + if (status?.length) { + for (const s of status) { + search.append('status', s); + } + } + + const res = await BaseResource._fetch({ + path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}/test_runs`, + method: 'GET', + search, + }); + + const payload = res?.response as unknown as EnterpriseConnectionTestRunsPaginatedJSON | undefined; + + return { + total_count: payload?.total_count ?? 0, + data: (payload?.data ?? []).map((row: EnterpriseConnectionTestRunJSON) => new EnterpriseConnectionTestRun(row)), + }; + }; + getMembershipRequests = async ( getRequestParam?: GetMembershipRequestParams, ): Promise> => { diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index 9ec8dfd5ea3..a88775a87ca 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -5,7 +5,7 @@ import type { ClerkPaginatedResponse, CreateEmailAddressParams, CreateExternalAccountParams, - CreateMeEnterpriseConnectionParams, + CreateOrganizationEnterpriseConnectionParams, CreatePhoneNumberParams, CreateWeb3WalletParams, DeletedObjectJSON, @@ -34,7 +34,7 @@ import type { SetProfileImageParams, TOTPJSON, TOTPResource, - UpdateMeEnterpriseConnectionParams, + UpdateOrganizationEnterpriseConnectionParams, UpdateUserMetadataParams, UpdateUserParams, UpdateUserPasswordParams, @@ -47,6 +47,7 @@ import type { import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; import { unixEpochToDate } from '../../utils/date'; +import { toEnterpriseConnectionBody } from '../../utils/enterpriseConnection'; import { normalizeUnsafeMetadata } from '../../utils/resourceParams'; import { eventBus, events } from '../events'; import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '../modules/billing'; @@ -336,13 +337,13 @@ export class User extends BaseResource implements UserResource { }; createEnterpriseConnection = async ( - params: CreateMeEnterpriseConnectionParams, + params: CreateOrganizationEnterpriseConnectionParams, ): Promise => { const json = ( await BaseResource._fetch({ path: `${this.path()}/enterprise_connections`, method: 'POST', - body: toMeEnterpriseConnectionBody(params) as any, + body: toEnterpriseConnectionBody(params) as any, }) )?.response as unknown as EnterpriseConnectionJSON; @@ -351,13 +352,13 @@ export class User extends BaseResource implements UserResource { updateEnterpriseConnection = async ( enterpriseConnectionId: string, - params: UpdateMeEnterpriseConnectionParams, + params: UpdateOrganizationEnterpriseConnectionParams, ): Promise => { const json = ( await BaseResource._fetch({ path: `${this.path()}/enterprise_connections/${enterpriseConnectionId}`, method: 'PATCH', - body: toMeEnterpriseConnectionBody(params) as any, + body: toEnterpriseConnectionBody(params) as any, }) )?.response as unknown as EnterpriseConnectionJSON; @@ -553,69 +554,3 @@ export class User extends BaseResource implements UserResource { }; } } - -/** - * Serializes `CreateMeEnterpriseConnectionParams` / `UpdateMeEnterpriseConnectionParams` - * for the `/me/enterprise_connections` FAPI endpoints. - * - * The handler expects a flat form body where SAML and OIDC fields are - * prefixed (e.g. `saml_idp_metadata_url`, `oidc_client_id`) rather - * than nested under `saml`/`oidc` objects. `attribute_mapping` and - * `custom_attributes` stay as object values and are JSON-stringified - * by the form serializer downstream — their inner keys are - * user-supplied data and must not be camel→snake transformed. - */ -function toMeEnterpriseConnectionBody( - params: CreateMeEnterpriseConnectionParams | UpdateMeEnterpriseConnectionParams, -): Record { - const body: Record = {}; - - // Top-level fields. `provider` is only on Create, the rest are shared - setIfDefined(body, 'provider', (params as CreateMeEnterpriseConnectionParams).provider); - setIfDefined(body, 'name', params.name); - setIfDefined(body, 'organization_id', params.organizationId); - setIfDefined(body, 'active', (params as UpdateMeEnterpriseConnectionParams).active); - setIfDefined(body, 'sync_user_attributes', (params as UpdateMeEnterpriseConnectionParams).syncUserAttributes); - setIfDefined( - body, - 'disable_additional_identifications', - (params as UpdateMeEnterpriseConnectionParams).disableAdditionalIdentifications, - ); - setIfDefined(body, 'custom_attributes', (params as UpdateMeEnterpriseConnectionParams).customAttributes); - - if (params.saml) { - setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId); - setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl); - setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate); - setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl); - setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata); - setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping); - setIfDefined(body, 'saml_allow_subdomains', params.saml.allowSubdomains); - setIfDefined(body, 'saml_allow_idp_initiated', params.saml.allowIdpInitiated); - setIfDefined(body, 'saml_force_authn', params.saml.forceAuthn); - } - - if (params.oidc) { - setIfDefined(body, 'oidc_client_id', params.oidc.clientId); - setIfDefined(body, 'oidc_client_secret', params.oidc.clientSecret); - setIfDefined(body, 'oidc_discovery_url', params.oidc.discoveryUrl); - setIfDefined(body, 'oidc_auth_url', params.oidc.authUrl); - setIfDefined(body, 'oidc_token_url', params.oidc.tokenUrl); - setIfDefined(body, 'oidc_user_info_url', params.oidc.userInfoUrl); - setIfDefined(body, 'oidc_requires_pkce', params.oidc.requiresPkce); - } - - return body; -} - -/** - * Adds `value` under `key` only when the caller actually provided it. - * Mirrors the SDK's existing semantics: `undefined` means "don't send - * this field"; `null` is forwarded so users can explicitly clear a - * value via the form-encoded body - */ -function setIfDefined(target: Record, key: string, value: unknown): void { - if (value !== undefined) { - target[key] = value; - } -} diff --git a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts index bcdf397c8e9..d88f1a56072 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Organization.test.ts @@ -1,6 +1,28 @@ -import { describe, expect, it } from 'vitest'; +import type { EnterpriseConnectionJSON, OrganizationJSON } from '@clerk/shared/types'; +import { describe, expect, it, vi } from 'vitest'; -import { Organization } from '../internal'; +import { BaseResource, Organization } from '../internal'; + +const ORG_ID = 'org_123'; + +function createOrganization(): Organization { + return new Organization({ + object: 'organization', + id: ORG_ID, + name: 'Acme Corp', + slug: 'acme', + image_url: '', + has_image: false, + public_metadata: {}, + members_count: 1, + pending_invitations_count: 0, + max_allowed_memberships: 5, + admin_delete_enabled: true, + self_serve_sso_enabled: true, + created_at: 1, + updated_at: 2, + } as unknown as OrganizationJSON); +} describe('Organization', () => { it('has the same initial properties', () => { @@ -41,4 +63,242 @@ describe('Organization', () => { }, }); }); + + describe('enterprise connections', () => { + it('fetches enterprise connections from the org-scoped path', async () => { + const enterpriseConnectionsJSON: EnterpriseConnectionJSON[] = [ + { + id: 'ec_123', + object: 'enterprise_connection', + name: 'Acme Corp SSO', + active: true, + allow_organization_account_linking: true, + provider: 'saml_okta', + logo_public_url: null, + domains: ['acme.com'], + organization_id: ORG_ID, + sync_user_attributes: true, + disable_additional_identifications: false, + custom_attributes: [], + oauth_config: null, + saml_connection: { + id: 'saml_123', + name: 'Acme Corp SSO', + active: true, + idp_entity_id: 'https://idp.acme.com/entity', + idp_sso_url: 'https://idp.acme.com/sso', + idp_certificate: 'MIICertificatePlaceholder', + idp_metadata_url: 'https://idp.acme.com/metadata', + idp_metadata: '', + acs_url: 'https://clerk.example.com/v1/saml/acs', + sp_entity_id: 'https://clerk.example.com', + sp_metadata_url: 'https://clerk.example.com/v1/saml/metadata', + allow_subdomains: false, + allow_idp_initiated: false, + force_authn: false, + }, + created_at: 1234567890, + updated_at: 1234567890, + }, + ]; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: enterpriseConnectionsJSON })); + + const organization = createOrganization(); + + const connections = await organization.getEnterpriseConnections(); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'GET', + path: `/organizations/${ORG_ID}/enterprise_connections`, + }); + + expect(connections).toHaveLength(1); + expect(connections[0].name).toBe('Acme Corp SSO'); + expect(connections[0].allowOrganizationAccountLinking).toBe(true); + }); + + it('creates an enterprise connection without forwarding organization_id in the body', async () => { + const enterpriseConnectionJSON = { + id: 'ec_new', + object: 'enterprise_connection' as const, + name: 'New SSO', + active: true, + provider: 'saml_okta', + logo_public_url: null, + domains: [], + organization_id: ORG_ID, + sync_user_attributes: true, + disable_additional_identifications: false, + allow_organization_account_linking: false, + custom_attributes: [], + oauth_config: null, + saml_connection: null, + created_at: 1234567890, + updated_at: 1234567890, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: enterpriseConnectionJSON })); + + const organization = createOrganization(); + + const conn = await organization.createEnterpriseConnection({ + provider: 'saml_okta', + name: 'New SSO', + // Even though callers may still pass this for convenience, the SDK + // must not include it in the body — the org URL is authoritative. + organizationId: ORG_ID, + saml: { idpEntityId: 'https://idp.example.com' }, + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: `/organizations/${ORG_ID}/enterprise_connections`, + body: { + provider: 'saml_okta', + name: 'New SSO', + saml_idp_entity_id: 'https://idp.example.com', + }, + }); + + // @ts-ignore + const callBody = BaseResource._fetch.mock.calls[0][0].body; + expect(callBody.organization_id).toBeUndefined(); + + expect(conn.id).toBe('ec_new'); + expect(conn.name).toBe('New SSO'); + }); + + it('updates an enterprise connection without forwarding organization_id in the body', async () => { + const enterpriseConnectionJSON = { + id: 'ec_123', + object: 'enterprise_connection' as const, + name: 'Updated', + active: false, + provider: 'saml_okta', + logo_public_url: null, + domains: [], + organization_id: ORG_ID, + sync_user_attributes: true, + disable_additional_identifications: false, + allow_organization_account_linking: false, + custom_attributes: [], + oauth_config: null, + saml_connection: null, + created_at: 1234567890, + updated_at: 1234567900, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: enterpriseConnectionJSON })); + + const organization = createOrganization(); + + await organization.updateEnterpriseConnection('ec_123', { + name: 'Updated', + active: false, + syncUserAttributes: true, + organizationId: ORG_ID, + }); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'PATCH', + path: `/organizations/${ORG_ID}/enterprise_connections/ec_123`, + body: { + name: 'Updated', + active: false, + sync_user_attributes: true, + }, + }); + + // @ts-ignore + const callBody = BaseResource._fetch.mock.calls[0][0].body; + expect(callBody.organization_id).toBeUndefined(); + }); + + it('deletes an enterprise connection', async () => { + const deletedJSON = { + object: 'enterprise_connection', + id: 'ec_123', + deleted: true, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: deletedJSON })); + + const organization = createOrganization(); + + const result = await organization.deleteEnterpriseConnection('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'DELETE', + path: `/organizations/${ORG_ID}/enterprise_connections/ec_123`, + }); + + expect(result.id).toBe('ec_123'); + expect(result.deleted).toBe(true); + }); + + it('creates an enterprise connection test run', async () => { + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: { url: 'https://example.com/test' } })); + + const organization = createOrganization(); + + const init = await organization.createEnterpriseConnectionTestRun('ec_123'); + + // @ts-ignore + expect(BaseResource._fetch).toHaveBeenCalledWith({ + method: 'POST', + path: `/organizations/${ORG_ID}/enterprise_connections/ec_123/test_runs`, + }); + + expect(init.url).toBe('https://example.com/test'); + }); + + it('lists enterprise connection test runs', async () => { + const paginated = { + data: [ + { + object: 'enterprise_connection_test_run' as const, + id: 'run_1', + status: 'success', + connection_type: 'saml' as const, + created_at: 1700000000000, + }, + ], + total_count: 1, + }; + + // @ts-ignore + BaseResource._fetch = vi.fn().mockReturnValue(Promise.resolve({ response: paginated })); + + const organization = createOrganization(); + + const result = await organization.getEnterpriseConnectionTestRuns('ec_123', { + initialPage: 1, + pageSize: 10, + status: ['pending', 'success'], + }); + + // @ts-ignore + const call = BaseResource._fetch.mock.calls[0][0]; + expect(call.method).toBe('GET'); + expect(call.path).toBe(`/organizations/${ORG_ID}/enterprise_connections/ec_123/test_runs`); + expect(call.search.get('limit')).toBe('10'); + expect(call.search.get('offset')).toBe('0'); + expect(call.search.getAll('status')).toEqual(['pending', 'success']); + + expect(result.total_count).toBe(1); + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe('run_1'); + expect(result.data[0].connectionType).toBe('saml'); + }); + }); }); diff --git a/packages/clerk-js/src/utils/enterpriseConnection.ts b/packages/clerk-js/src/utils/enterpriseConnection.ts new file mode 100644 index 00000000000..54413ee218d --- /dev/null +++ b/packages/clerk-js/src/utils/enterpriseConnection.ts @@ -0,0 +1,92 @@ +import type { + CreateOrganizationEnterpriseConnectionParams, + UpdateOrganizationEnterpriseConnectionParams, +} from '@clerk/shared/types'; + +type EnterpriseConnectionParams = + | CreateOrganizationEnterpriseConnectionParams + | UpdateOrganizationEnterpriseConnectionParams; + +export type ToEnterpriseConnectionBodyOptions = { + /** + * When `true`, omit `organization_id` from the body even if it was provided + * in `params`. Use this for org-scoped endpoints + * (`/v1/organizations/{org_id}/enterprise_connections/*`) where the URL + * path is authoritative. + */ + omitOrganizationId?: boolean; +}; + +/** + * Serializes `CreateOrganizationEnterpriseConnectionParams` / + * `UpdateOrganizationEnterpriseConnectionParams` for the enterprise + * connection FAPI endpoints. + * + * The handlers expect a flat form body where SAML and OIDC fields are + * prefixed (e.g. `saml_idp_metadata_url`, `oidc_client_id`) rather than + * nested under `saml`/`oidc` objects. `attribute_mapping` and + * `custom_attributes` stay as object values and are JSON-stringified by + * the form serializer downstream — their inner keys are user-supplied + * data and must not be camel→snake transformed. + */ +export function toEnterpriseConnectionBody( + params: EnterpriseConnectionParams, + options: ToEnterpriseConnectionBodyOptions = {}, +): Record { + const body: Record = {}; + + // Top-level fields. `provider` is only on Create, the rest are shared. + setIfDefined(body, 'provider', (params as CreateOrganizationEnterpriseConnectionParams).provider); + setIfDefined(body, 'name', params.name); + if (!options.omitOrganizationId) { + setIfDefined(body, 'organization_id', params.organizationId); + } + setIfDefined(body, 'active', (params as UpdateOrganizationEnterpriseConnectionParams).active); + setIfDefined( + body, + 'sync_user_attributes', + (params as UpdateOrganizationEnterpriseConnectionParams).syncUserAttributes, + ); + setIfDefined( + body, + 'disable_additional_identifications', + (params as UpdateOrganizationEnterpriseConnectionParams).disableAdditionalIdentifications, + ); + setIfDefined(body, 'custom_attributes', (params as UpdateOrganizationEnterpriseConnectionParams).customAttributes); + + if (params.saml) { + setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId); + setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl); + setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate); + setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl); + setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata); + setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping); + setIfDefined(body, 'saml_allow_subdomains', params.saml.allowSubdomains); + setIfDefined(body, 'saml_allow_idp_initiated', params.saml.allowIdpInitiated); + setIfDefined(body, 'saml_force_authn', params.saml.forceAuthn); + } + + if (params.oidc) { + setIfDefined(body, 'oidc_client_id', params.oidc.clientId); + setIfDefined(body, 'oidc_client_secret', params.oidc.clientSecret); + setIfDefined(body, 'oidc_discovery_url', params.oidc.discoveryUrl); + setIfDefined(body, 'oidc_auth_url', params.oidc.authUrl); + setIfDefined(body, 'oidc_token_url', params.oidc.tokenUrl); + setIfDefined(body, 'oidc_user_info_url', params.oidc.userInfoUrl); + setIfDefined(body, 'oidc_requires_pkce', params.oidc.requiresPkce); + } + + return body; +} + +/** + * Adds `value` under `key` only when the caller actually provided it. + * Mirrors the SDK's existing semantics: `undefined` means "don't send + * this field"; `null` is forwarded so users can explicitly clear a + * value via the form-encoded body. + */ +function setIfDefined(target: Record, key: string, value: unknown): void { + if (value !== undefined) { + target[key] = value; + } +} diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index fd2c7e5cb0d..020bad2f0b3 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -43,6 +43,16 @@ export type { UseEnterpriseConnectionTestRunsParams, UseEnterpriseConnectionTestRunsReturn, } from './useEnterpriseConnectionTestRuns'; +export { __internal_useOrganizationEnterpriseConnections } from './useOrganizationEnterpriseConnections'; +export type { + UseOrganizationEnterpriseConnectionsParams, + UseOrganizationEnterpriseConnectionsReturn, +} from './useOrganizationEnterpriseConnections'; +export { __internal_useOrganizationEnterpriseConnectionTestRuns } from './useOrganizationEnterpriseConnectionTestRuns'; +export type { + UseOrganizationEnterpriseConnectionTestRunsParams, + UseOrganizationEnterpriseConnectionTestRunsReturn, +} from './useOrganizationEnterpriseConnectionTestRuns'; export { useUserBase as __internal_useUserBase } from './base/useUserBase'; export { useClientBase as __internal_useClientBase } from './base/useClientBase'; diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.shared.ts b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.shared.ts new file mode 100644 index 00000000000..08690c8d7d4 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.shared.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; + +import type { GetEnterpriseConnectionTestRunsParams } from '../../types/enterpriseConnectionTestRun'; +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +/** + * @internal + */ +export function useOrganizationEnterpriseConnectionTestRunsCacheKeys(params: { + organizationId: string | null; + enterpriseConnectionId: string | null; + args: GetEnterpriseConnectionTestRunsParams; +}) { + const { organizationId, enterpriseConnectionId, args } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + enterpriseConnectionId: enterpriseConnectionId ?? null, + }, + untracked: { + args, + }, + }); + // The args object is intentionally serialized via the consumer to keep stability. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organizationId, enterpriseConnectionId, JSON.stringify(args)]); +} diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx new file mode 100644 index 00000000000..7d3a7af75f4 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { + EnterpriseConnectionTestRunResource, + GetEnterpriseConnectionTestRunsParams, +} from '../../types/enterpriseConnectionTestRun'; +import { useClerkInstanceContext } from '../contexts'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationEnterpriseConnectionTestRunsCacheKeys } from './useOrganizationEnterpriseConnectionTestRuns.shared'; + +const DEFAULT_POLL_INTERVAL_MS = 2_000; + +export type UseOrganizationEnterpriseConnectionTestRunsParams = { + enterpriseConnectionId: string | null; + /** + * Pass-through fetch parameters (pagination, status filter). + * Defaults to `{ initialPage: 1, pageSize: 10 }`. + */ + params?: GetEnterpriseConnectionTestRunsParams; + /** + * Polling interval (ms) applied between `revalidate()` and the moment the + * first record arrives in the response. + * + * @default 2000 + */ + pollIntervalMs?: number; + /** + * If `false`, the hook is dormant — no fetch, no polling. + * + * @default true + */ + enabled?: boolean; +}; + +export type UseOrganizationEnterpriseConnectionTestRunsReturn = { + data: EnterpriseConnectionTestRunResource[] | undefined; + totalCount: number | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + /** + * `true` while the hook is actively polling for the first record to appear + */ + isPolling: boolean; + /** + * Force a refetch and (if the list is currently empty) arm polling + */ + revalidate: () => Promise; +}; + +/** + * Subscribes to the list of enterprise-connection test runs for the active organization + * + * @internal + */ +function useOrganizationEnterpriseConnectionTestRuns( + params: UseOrganizationEnterpriseConnectionTestRunsParams, +): UseOrganizationEnterpriseConnectionTestRunsReturn { + const { + enterpriseConnectionId, + params: fetchParams = { initialPage: 1, pageSize: 10 }, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + enabled = true, + } = params; + + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + + const { queryKey, invalidationKey, stableKey, authenticated } = useOrganizationEnterpriseConnectionTestRunsCacheKeys({ + organizationId: organization?.id ?? null, + enterpriseConnectionId, + args: fetchParams, + }); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(organization) && Boolean(enterpriseConnectionId); + + const [shouldPoll, setShouldPoll] = useState(false); + + const query = useClerkQuery({ + queryKey, + queryFn: () => { + if (!enterpriseConnectionId) { + throw new Error('enterpriseConnectionId is required to fetch test runs'); + } + return organization?.getEnterpriseConnectionTestRuns(enterpriseConnectionId, fetchParams); + }, + refetchInterval: q => { + if (!shouldPoll) { + return false; + } + + const hasRows = (q.state.data?.data?.length ?? 0) > 0; + return hasRows ? false : pollIntervalMs; + }, + enabled: queryEnabled, + refetchIntervalInBackground: false, + }); + + const hasRows = (query.data?.data?.length ?? 0) > 0; + + useEffect(() => { + if (shouldPoll && hasRows) { + setShouldPoll(false); + } + }, [shouldPoll, hasRows]); + + const revalidate = useCallback(async () => { + // Only arm polling when there is nothing in the list yet — once any record + // has been seen, this is a one-shot refetch. + if (!hasRows) { + setShouldPoll(true); + } + await queryClient.invalidateQueries({ queryKey: invalidationKey }); + }, [queryClient, invalidationKey, hasRows]); + + const isPolling = queryEnabled && shouldPoll && !hasRows; + + return { + data: query.data?.data, + totalCount: query.data?.total_count, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + isPolling, + revalidate, + }; +} + +export { useOrganizationEnterpriseConnectionTestRuns as __internal_useOrganizationEnterpriseConnectionTestRuns }; diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.shared.ts b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.shared.ts new file mode 100644 index 00000000000..299f1d40883 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.shared.ts @@ -0,0 +1,27 @@ +import { useMemo } from 'react'; + +import { INTERNAL_STABLE_KEYS } from '../stable-keys'; +import { createCacheKeys } from './createCacheKeys'; + +/** + * @internal + */ +export function useOrganizationEnterpriseConnectionsCacheKeys(params: { + organizationId: string | null; + withOrganizationAccountLinking?: boolean; +}) { + const { organizationId, withOrganizationAccountLinking = false } = params; + return useMemo(() => { + return createCacheKeys({ + stablePrefix: INTERNAL_STABLE_KEYS.ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY, + authenticated: Boolean(organizationId), + tracked: { + organizationId: organizationId ?? null, + withOrganizationAccountLinking, + }, + untracked: { + args: {}, + }, + }); + }, [organizationId, withOrganizationAccountLinking]); +} diff --git a/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.tsx b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.tsx new file mode 100644 index 00000000000..7cec73bce99 --- /dev/null +++ b/packages/shared/src/react/hooks/useOrganizationEnterpriseConnections.tsx @@ -0,0 +1,116 @@ +import { useCallback } from 'react'; + +import type { DeletedObjectResource } from '../../types/deletedObject'; +import type { + CreateOrganizationEnterpriseConnectionParams, + EnterpriseConnectionResource, + UpdateOrganizationEnterpriseConnectionParams, +} from '../../types/enterpriseConnection'; +import { useClerkInstanceContext } from '../contexts'; +import { defineKeepPreviousDataFn } from '../query/keep-previous-data'; +import { useClerkQueryClient } from '../query/use-clerk-query-client'; +import { useClerkQuery } from '../query/useQuery'; +import { useOrganizationBase } from './base/useOrganizationBase'; +import { useClearQueriesOnSignOut } from './useClearQueriesOnSignOut'; +import { useOrganizationEnterpriseConnectionsCacheKeys } from './useOrganizationEnterpriseConnections.shared'; + +export type UseOrganizationEnterpriseConnectionsParams = { + enabled?: boolean; + keepPreviousData?: boolean; + withOrganizationAccountLinking?: boolean; +}; + +export type UseOrganizationEnterpriseConnectionsReturn = { + data: EnterpriseConnectionResource[] | undefined; + error: Error | null; + isLoading: boolean; + isFetching: boolean; + createEnterpriseConnection: ( + params: CreateOrganizationEnterpriseConnectionParams, + ) => Promise; + updateEnterpriseConnection: ( + enterpriseConnectionId: string, + params: UpdateOrganizationEnterpriseConnectionParams, + ) => Promise; + deleteEnterpriseConnection: (enterpriseConnectionId: string) => Promise; + revalidate: () => Promise; +}; + +/** + * Enterprise connections for the active organization + * + * @internal + */ +function useOrganizationEnterpriseConnections( + params: UseOrganizationEnterpriseConnectionsParams = {}, +): UseOrganizationEnterpriseConnectionsReturn { + const { keepPreviousData = true, enabled = true, withOrganizationAccountLinking = false } = params; + const clerk = useClerkInstanceContext(); + const organization = useOrganizationBase(); + const [queryClient] = useClerkQueryClient(); + + const { queryKey, stableKey, authenticated } = useOrganizationEnterpriseConnectionsCacheKeys({ + organizationId: organization?.id ?? null, + withOrganizationAccountLinking, + }); + + const queryEnabled = enabled && clerk.loaded && Boolean(organization); + + useClearQueriesOnSignOut({ + isSignedOut: organization === null, + authenticated, + stableKeys: stableKey, + }); + + const query = useClerkQuery({ + queryKey, + queryFn: () => organization?.getEnterpriseConnections({ withOrganizationAccountLinking }), + enabled: queryEnabled, + placeholderData: defineKeepPreviousDataFn(keepPreviousData), + }); + + const revalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: [stableKey] }), + [queryClient, stableKey], + ); + + const createEnterpriseConnection = useCallback( + async (createParams: CreateOrganizationEnterpriseConnectionParams) => { + const created = await organization?.createEnterpriseConnection(createParams); + await revalidate(); + return created; + }, + [organization, revalidate], + ); + + const updateEnterpriseConnection = useCallback( + async (enterpriseConnectionId: string, updateParams: UpdateOrganizationEnterpriseConnectionParams) => { + const updated = await organization?.updateEnterpriseConnection(enterpriseConnectionId, updateParams); + await revalidate(); + return updated; + }, + [organization, revalidate], + ); + + const deleteEnterpriseConnection = useCallback( + async (enterpriseConnectionId: string) => { + const deleted = await organization?.deleteEnterpriseConnection(enterpriseConnectionId); + await revalidate(); + return deleted; + }, + [organization, revalidate], + ); + + return { + data: query.data, + error: query.error ?? null, + isLoading: query.isLoading, + isFetching: query.isFetching, + createEnterpriseConnection, + updateEnterpriseConnection, + deleteEnterpriseConnection, + revalidate, + }; +} + +export { useOrganizationEnterpriseConnections as __internal_useOrganizationEnterpriseConnections }; diff --git a/packages/shared/src/react/stable-keys.ts b/packages/shared/src/react/stable-keys.ts index 91940a47b57..628b3640824 100644 --- a/packages/shared/src/react/stable-keys.ts +++ b/packages/shared/src/react/stable-keys.ts @@ -74,6 +74,8 @@ const BILLING_PLANS_KEY = 'billing-plan'; const BILLING_STATEMENTS_KEY = 'billing-statement'; const USER_ENTERPRISE_CONNECTIONS_KEY = 'userEnterpriseConnections'; const ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'enterpriseConnectionTestRuns'; +const ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY = 'organizationEnterpriseConnections'; +const ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY = 'organizationEnterpriseConnectionTestRuns'; export const INTERNAL_STABLE_KEYS = { PAYMENT_ATTEMPT_KEY, @@ -81,6 +83,8 @@ export const INTERNAL_STABLE_KEYS = { BILLING_STATEMENTS_KEY, USER_ENTERPRISE_CONNECTIONS_KEY, ENTERPRISE_CONNECTION_TEST_RUNS_KEY, + ORGANIZATION_ENTERPRISE_CONNECTIONS_KEY, + ORGANIZATION_ENTERPRISE_CONNECTION_TEST_RUNS_KEY, } as const; export type __internal_ResourceCacheStableKey = (typeof INTERNAL_STABLE_KEYS)[keyof typeof INTERNAL_STABLE_KEYS]; diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index c47641f2242..2a99a291081 100644 --- a/packages/shared/src/types/enterpriseConnection.ts +++ b/packages/shared/src/types/enterpriseConnection.ts @@ -98,7 +98,7 @@ export interface EnterpriseOAuthConfigResource { updatedAt: Date | null; } -export type MeEnterpriseConnectionProvider = +export type OrganizationEnterpriseConnectionProvider = | 'saml_custom' | 'saml_okta' | 'saml_google' @@ -107,7 +107,10 @@ export type MeEnterpriseConnectionProvider = | 'oidc_github_enterprise' | 'oidc_gitlab'; -export type MeEnterpriseConnectionSamlInput = { +/** @deprecated Use `OrganizationEnterpriseConnectionProvider` instead. */ +export type MeEnterpriseConnectionProvider = OrganizationEnterpriseConnectionProvider; + +export type OrganizationEnterpriseConnectionSamlInput = { idpEntityId?: string | null; idpSsoUrl?: string | null; idpCertificate?: string | null; @@ -119,7 +122,10 @@ export type MeEnterpriseConnectionSamlInput = { forceAuthn?: boolean | null; }; -export type MeEnterpriseConnectionOidcInput = { +/** @deprecated Use `OrganizationEnterpriseConnectionSamlInput` instead. */ +export type MeEnterpriseConnectionSamlInput = OrganizationEnterpriseConnectionSamlInput; + +export type OrganizationEnterpriseConnectionOidcInput = { clientId?: string | null; clientSecret?: string | null; discoveryUrl?: string | null; @@ -129,21 +135,30 @@ export type MeEnterpriseConnectionOidcInput = { requiresPkce?: boolean | null; }; -export type CreateMeEnterpriseConnectionParams = { - provider: MeEnterpriseConnectionProvider; +/** @deprecated Use `OrganizationEnterpriseConnectionOidcInput` instead. */ +export type MeEnterpriseConnectionOidcInput = OrganizationEnterpriseConnectionOidcInput; + +export type CreateOrganizationEnterpriseConnectionParams = { + provider: OrganizationEnterpriseConnectionProvider; name: string; organizationId?: string | null; - saml?: MeEnterpriseConnectionSamlInput | null; - oidc?: MeEnterpriseConnectionOidcInput | null; + saml?: OrganizationEnterpriseConnectionSamlInput | null; + oidc?: OrganizationEnterpriseConnectionOidcInput | null; }; -export type UpdateMeEnterpriseConnectionParams = { +/** @deprecated Use `CreateOrganizationEnterpriseConnectionParams` instead. */ +export type CreateMeEnterpriseConnectionParams = CreateOrganizationEnterpriseConnectionParams; + +export type UpdateOrganizationEnterpriseConnectionParams = { name?: string | null; active?: boolean | null; syncUserAttributes?: boolean | null; disableAdditionalIdentifications?: boolean | null; organizationId?: string | null; customAttributes?: Record | null; - saml?: MeEnterpriseConnectionSamlInput | null; - oidc?: MeEnterpriseConnectionOidcInput | null; + saml?: OrganizationEnterpriseConnectionSamlInput | null; + oidc?: OrganizationEnterpriseConnectionOidcInput | null; }; + +/** @deprecated Use `UpdateOrganizationEnterpriseConnectionParams` instead. */ +export type UpdateMeEnterpriseConnectionParams = UpdateOrganizationEnterpriseConnectionParams; diff --git a/packages/shared/src/types/organization.ts b/packages/shared/src/types/organization.ts index 1bd4f266317..81d123bcbaf 100644 --- a/packages/shared/src/types/organization.ts +++ b/packages/shared/src/types/organization.ts @@ -1,4 +1,15 @@ import type { BillingPayerMethods } from './billing'; +import type { DeletedObjectResource } from './deletedObject'; +import type { + CreateOrganizationEnterpriseConnectionParams, + EnterpriseConnectionResource, + UpdateOrganizationEnterpriseConnectionParams, +} from './enterpriseConnection'; +import type { + EnterpriseConnectionTestRunInitResource, + EnterpriseConnectionTestRunResource, + GetEnterpriseConnectionTestRunsParams, +} from './enterpriseConnectionTestRun'; import type { OrganizationDomainResource, OrganizationEnrollmentMode } from './organizationDomain'; import type { OrganizationInvitationResource, OrganizationInvitationStatus } from './organizationInvitation'; import type { OrganizationCustomRoleKey, OrganizationMembershipResource } from './organizationMembership'; @@ -7,6 +18,7 @@ import type { ClerkPaginatedResponse, ClerkPaginationParams } from './pagination import type { ClerkResource } from './resource'; import type { RoleResource } from './role'; import type { OrganizationJSONSnapshot } from './snapshots'; +import type { GetEnterpriseConnectionsParams } from './user'; declare global { /** @@ -64,6 +76,22 @@ export interface OrganizationResource extends ClerkResource, BillingPayerMethods removeMember: (userId: string) => Promise; createDomain: (domainName: string) => Promise; getDomain: ({ domainId }: { domainId: string }) => Promise; + getEnterpriseConnections: (params?: GetEnterpriseConnectionsParams) => Promise; + createEnterpriseConnection: ( + params: CreateOrganizationEnterpriseConnectionParams, + ) => Promise; + updateEnterpriseConnection: ( + enterpriseConnectionId: string, + params: UpdateOrganizationEnterpriseConnectionParams, + ) => Promise; + deleteEnterpriseConnection: (enterpriseConnectionId: string) => Promise; + createEnterpriseConnectionTestRun: ( + enterpriseConnectionId: string, + ) => Promise; + getEnterpriseConnectionTestRuns: ( + enterpriseConnectionId: string, + params?: GetEnterpriseConnectionTestRunsParams, + ) => Promise>; destroy: () => Promise; setLogo: (params: SetOrganizationLogoParams) => Promise; __internal_toSnapshot: () => OrganizationJSONSnapshot; diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index 5f6c392cc93..cdd8003eaab 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -4,9 +4,9 @@ import type { DeletedObjectResource } from './deletedObject'; import type { EmailAddressResource } from './emailAddress'; import type { EnterpriseAccountResource } from './enterpriseAccount'; import type { - CreateMeEnterpriseConnectionParams, + CreateOrganizationEnterpriseConnectionParams, EnterpriseConnectionResource, - UpdateMeEnterpriseConnectionParams, + UpdateOrganizationEnterpriseConnectionParams, } from './enterpriseConnection'; import type { EnterpriseConnectionTestRunInitResource, @@ -130,10 +130,12 @@ export interface UserResource extends ClerkResource, BillingPayerMethods { getOrganizationCreationDefaults: () => Promise; leaveOrganization: (organizationId: string) => Promise; getEnterpriseConnections: (params?: GetEnterpriseConnectionsParams) => Promise; - createEnterpriseConnection: (params: CreateMeEnterpriseConnectionParams) => Promise; + createEnterpriseConnection: ( + params: CreateOrganizationEnterpriseConnectionParams, + ) => Promise; updateEnterpriseConnection: ( enterpriseConnectionId: string, - params: UpdateMeEnterpriseConnectionParams, + params: UpdateOrganizationEnterpriseConnectionParams, ) => Promise; deleteEnterpriseConnection: (enterpriseConnectionId: string) => Promise; createEnterpriseConnectionTestRun: ( diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 678f1212793..5959eff4b04 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -1,6 +1,6 @@ import { - __internal_useEnterpriseConnectionTestRuns, - __internal_useUserEnterpriseConnections, + __internal_useOrganizationEnterpriseConnections, + __internal_useOrganizationEnterpriseConnectionTestRuns, useSession, } from '@clerk/shared/react'; import type { ConfigureSSOProps, EnterpriseConnectionResource } from '@clerk/shared/types'; @@ -72,7 +72,7 @@ export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObjec createEnterpriseConnection, updateEnterpriseConnection, deleteEnterpriseConnection, - } = __internal_useUserEnterpriseConnections({ enabled: true }); + } = __internal_useOrganizationEnterpriseConnections({ enabled: true }); // Currently FAPI only supports one enterprise connection per user const enterpriseConnection = enterpriseConnections?.[0]; @@ -238,7 +238,7 @@ const ResetCardErrorOnStepChange = (): null => { const useHasSuccessfulTestRun = ( enterpriseConnection: EnterpriseConnectionResource | undefined, ): { hasSuccessfulTestRun: boolean; isLoading: boolean } => { - const { data: successfulTestRuns, isLoading } = __internal_useEnterpriseConnectionTestRuns({ + const { data: successfulTestRuns, isLoading } = __internal_useOrganizationEnterpriseConnectionTestRuns({ enterpriseConnectionId: enterpriseConnection?.id ?? null, params: { initialPage: 1, pageSize: 1, status: ['success'] }, }); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index 51c217f17a3..6bc3b5d9277 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -1,4 +1,4 @@ -import type { UseUserEnterpriseConnectionsReturn } from '@clerk/shared/react/index'; +import type { UseOrganizationEnterpriseConnectionsReturn } from '@clerk/shared/react/index'; import { useSession, useUser } from '@clerk/shared/react/index'; import type { EmailAddressResource, @@ -44,11 +44,11 @@ export interface ConfigureSSOData { /** * Updates an existing enterprise connection */ - updateEnterpriseConnection: UseUserEnterpriseConnectionsReturn['updateEnterpriseConnection']; + updateEnterpriseConnection: UseOrganizationEnterpriseConnectionsReturn['updateEnterpriseConnection']; /** * Deletes an enterprise connection */ - deleteEnterpriseConnection: UseUserEnterpriseConnectionsReturn['deleteEnterpriseConnection']; + deleteEnterpriseConnection: UseOrganizationEnterpriseConnectionsReturn['deleteEnterpriseConnection']; /** * Determines if the user's domain is already wired to an enterprise connection that * doesn't belong to the org they're currently configuring @@ -60,9 +60,9 @@ interface ConfigureSSOProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; hasSuccessfulTestRun: boolean; contentRef: React.RefObject; - createEnterpriseConnection: UseUserEnterpriseConnectionsReturn['createEnterpriseConnection']; - updateEnterpriseConnection: UseUserEnterpriseConnectionsReturn['updateEnterpriseConnection']; - deleteEnterpriseConnection: UseUserEnterpriseConnectionsReturn['deleteEnterpriseConnection']; + createEnterpriseConnection: UseOrganizationEnterpriseConnectionsReturn['createEnterpriseConnection']; + updateEnterpriseConnection: UseOrganizationEnterpriseConnectionsReturn['updateEnterpriseConnection']; + deleteEnterpriseConnection: UseOrganizationEnterpriseConnectionsReturn['deleteEnterpriseConnection']; } const ConfigureSSOContext = React.createContext(null); @@ -90,7 +90,6 @@ export const ConfigureSSOProvider = ({ const createEnterpriseConnection = useCallback( async (provider: ProviderType, primaryEmailAddress?: EmailAddressResource): Promise => { const emailDomain = primaryEmailAddress?.emailAddress.split('@')[1]; - const organizationId = session?.lastActiveOrganizationId ?? null; if (!emailDomain) { return; @@ -99,16 +98,17 @@ export const ConfigureSSOProvider = ({ card.setLoading(); try { + // The organization is inferred from the URL path on the org-scoped + // endpoint, so we don't need to pass `organizationId` in the body. await createEnterpriseConnectionApi({ provider, name: emailDomain, - organizationId, }); } finally { card.setIdle(); } }, - [card, session, createEnterpriseConnectionApi], + [card, createEnterpriseConnectionApi], ); const value = React.useMemo( diff --git a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx index 55f7d53cf28..88215e22798 100644 --- a/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/__tests__/ConfigureSSO.test.tsx @@ -20,7 +20,7 @@ describe('ConfigureSSO', () => { }); }); - fixtures.clerk.user?.getEnterpriseConnections.mockResolvedValue([]); + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); const { findByText, queryByText } = render(, { wrapper }); @@ -40,7 +40,7 @@ describe('ConfigureSSO', () => { }); }); - fixtures.clerk.user?.getEnterpriseConnections.mockResolvedValue([]); + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); const { findByText, queryByText } = render(, { wrapper }); @@ -59,7 +59,7 @@ describe('ConfigureSSO', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); - fixtures.clerk.user?.getEnterpriseConnections.mockResolvedValue([]); + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); const { findByText, queryByText } = render(, { wrapper }); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx index f0d842a4b1e..65532d68738 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx @@ -1,6 +1,6 @@ import { isClerkAPIResponseError } from '@clerk/shared/error'; import { useReverification } from '@clerk/shared/react'; -import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; +import type { FieldId, UpdateOrganizationEnterpriseConnectionParams } from '@clerk/shared/types'; import React, { type JSX } from 'react'; import { @@ -651,7 +651,7 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { const updateConnection = useReverification( React.useCallback( - async (params: UpdateMeEnterpriseConnectionParams) => { + async (params: UpdateOrganizationEnterpriseConnectionParams) => { if (!enterpriseConnection) { throw new Error('Enterprise connection required'); } @@ -711,7 +711,7 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { if (mode === 'metadataUrl') { await updateConnection({ saml: { idpMetadataUrl: trimmedMetadataUrl } }); } else { - const samlPayload: NonNullable = { + const samlPayload: NonNullable = { idpSsoUrl: trimmedSignOnUrl, idpEntityId: trimmedIssuer, }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 7a7ce7d2f9b..ec48c9447c3 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,4 +1,4 @@ -import { __internal_useEnterpriseConnectionTestRuns, useUser } from '@clerk/shared/react/index'; +import { __internal_useOrganizationEnterpriseConnectionTestRuns, useOrganization } from '@clerk/shared/react/index'; import type { EnterpriseConnectionTestRunResource } from '@clerk/shared/types'; import type { ReactNode } from 'react'; import { useState } from 'react'; @@ -59,7 +59,7 @@ export const TestConfigurationStep = (): JSX.Element => { isFetching: areTestRunsFetching, isPolling, revalidate: revalidateTestRuns, - } = __internal_useEnterpriseConnectionTestRuns({ + } = __internal_useOrganizationEnterpriseConnectionTestRuns({ enterpriseConnectionId: enterpriseConnection?.id ?? null, params: { initialPage: currentPage, pageSize: TEST_RUNS_PAGE_SIZE }, }); @@ -197,13 +197,13 @@ const ContinueTestSsoStepButton = ({ enterpriseConnectionId, onContinue, }: ContinueTestSsoStepButtonProps): JSX.Element => { - const { user } = useUser(); + const { organization } = useOrganization(); const { t } = useLocalizations(); const card = useCardState(); const [isValidating, setIsValidating] = useState(false); const handleContinue = async () => { - if (!user || !enterpriseConnectionId) { + if (!organization || !enterpriseConnectionId) { return; } @@ -211,7 +211,7 @@ const ContinueTestSsoStepButton = ({ card.setError(undefined); try { - const result = await user.getEnterpriseConnectionTestRuns(enterpriseConnectionId, { + const result = await organization.getEnterpriseConnectionTestRuns(enterpriseConnectionId, { initialPage: 1, pageSize: 1, status: ['success'], @@ -717,20 +717,20 @@ type OpenTestUrlButtonProps = { }; const OpenTestUrlButton = ({ onTestRunCreated }: OpenTestUrlButtonProps): JSX.Element => { - const { user } = useUser(); + const { organization } = useOrganization(); const card = useCardState(); const { enterpriseConnection } = useConfigureSSO(); const [isCreatingTestRun, setIsCreatingTestRun] = useState(false); const openTestRun = () => { - if (!user || !enterpriseConnection) { + if (!organization || !enterpriseConnection) { return; } setIsCreatingTestRun(true); - user + organization .createEnterpriseConnectionTestRun(enterpriseConnection.id) .then(({ url }) => { onTestRunCreated?.(url);