Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 12 additions & 0 deletions .changeset/orgs-1597-configure-sso-org-scoped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@clerk/ui': minor
'@clerk/shared': minor
---

`<ConfigureSSO />` now calls the org-scoped enterprise connections endpoints via `organization.*EnterpriseConnection*` methods. Previously, the wizard called `user.*EnterpriseConnection*` against the `/me/*` paths.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`<ConfigureSSO />` now calls the org-scoped enterprise connections endpoints via `organization.*EnterpriseConnection*` methods. Previously, the wizard called `user.*EnterpriseConnection*` against the `/me/*` paths.
Internal `<ConfigureSSO />` refactor to call new org-scoped enterprise connections FAPI endpoints, replacing the `/me/` deprecated scope

Changing the copy a bit here to denote that this is an internal change, not important for external consumers

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also let's use the automatic generated snapshot naming with pnpm run changeset


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.
15 changes: 15 additions & 0 deletions .changeset/orgs-1597-organization-enterprise-connections.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +1 to +15
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can keep all on the same changeset, as those endpoints aren't public on the frontend API as well, it's not like developers were relying on the clerk-js resources for it

124 changes: 123 additions & 1 deletion packages/clerk-js/src/core/resources/Organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -142,6 +163,107 @@ export class Organization extends BaseResource implements OrganizationResource {
return new OrganizationDomain(json);
};

getEnterpriseConnections = async (
params?: GetEnterpriseConnectionsParams,
): Promise<EnterpriseConnectionResource[]> => {
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<EnterpriseConnectionResource> => {
const json = (
await BaseResource._fetch<EnterpriseConnectionJSON>({
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<EnterpriseConnectionResource> => {
const json = (
await BaseResource._fetch<EnterpriseConnectionJSON>({
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<DeletedObjectResource> => {
const json = (
await BaseResource._fetch<DeletedObjectJSON>({
path: `/organizations/${this.id}/enterprise_connections/${enterpriseConnectionId}`,
method: 'DELETE',
})
)?.response as unknown as DeletedObjectJSON;

return new DeletedObject(json);
};

createEnterpriseConnectionTestRun = async (
enterpriseConnectionId: string,
): Promise<EnterpriseConnectionTestRunInitResource> => {
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<ClerkPaginatedResponse<EnterpriseConnectionTestRunResource>> => {
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<ClerkPaginatedResponse<OrganizationMembershipRequestResource>> => {
Expand Down
79 changes: 7 additions & 72 deletions packages/clerk-js/src/core/resources/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
ClerkPaginatedResponse,
CreateEmailAddressParams,
CreateExternalAccountParams,
CreateMeEnterpriseConnectionParams,
CreateOrganizationEnterpriseConnectionParams,
CreatePhoneNumberParams,
CreateWeb3WalletParams,
DeletedObjectJSON,
Expand Down Expand Up @@ -34,7 +34,7 @@ import type {
SetProfileImageParams,
TOTPJSON,
TOTPResource,
UpdateMeEnterpriseConnectionParams,
UpdateOrganizationEnterpriseConnectionParams,
UpdateUserMetadataParams,
UpdateUserParams,
UpdateUserPasswordParams,
Expand All @@ -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';
Expand Down Expand Up @@ -336,13 +337,13 @@ export class User extends BaseResource implements UserResource {
};

createEnterpriseConnection = async (
params: CreateMeEnterpriseConnectionParams,
params: CreateOrganizationEnterpriseConnectionParams,
): Promise<EnterpriseConnectionResource> => {
const json = (
await BaseResource._fetch<EnterpriseConnectionJSON>({
path: `${this.path()}/enterprise_connections`,
method: 'POST',
body: toMeEnterpriseConnectionBody(params) as any,
body: toEnterpriseConnectionBody(params) as any,
})
)?.response as unknown as EnterpriseConnectionJSON;

Expand All @@ -351,13 +352,13 @@ export class User extends BaseResource implements UserResource {

updateEnterpriseConnection = async (
enterpriseConnectionId: string,
params: UpdateMeEnterpriseConnectionParams,
params: UpdateOrganizationEnterpriseConnectionParams,
): Promise<EnterpriseConnectionResource> => {
const json = (
await BaseResource._fetch<EnterpriseConnectionJSON>({
path: `${this.path()}/enterprise_connections/${enterpriseConnectionId}`,
method: 'PATCH',
body: toMeEnterpriseConnectionBody(params) as any,
body: toEnterpriseConnectionBody(params) as any,
})
)?.response as unknown as EnterpriseConnectionJSON;

Expand Down Expand Up @@ -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<string, unknown> {
const body: Record<string, unknown> = {};

// 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<string, unknown>, key: string, value: unknown): void {
if (value !== undefined) {
target[key] = value;
}
}
Loading
Loading