Skip to content

Commit 8a5cdf2

Browse files
committed
Implement POC
1 parent c89ef55 commit 8a5cdf2

10 files changed

Lines changed: 1286 additions & 49 deletions

File tree

packages/clerk-js/src/core/resources/User.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ import type {
4343
VerifyTOTPParams,
4444
Web3WalletResource,
4545
} from '@clerk/shared/types';
46-
import { deepCamelToSnake } from '@clerk/shared/underscore';
4746

4847
import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams';
4948
import { unixEpochToDate } from '../../utils/date';
@@ -551,25 +550,64 @@ export class User extends BaseResource implements UserResource {
551550
* Serializes `CreateMeEnterpriseConnectionParams` / `UpdateMeEnterpriseConnectionParams`
552551
* for the `/me/enterprise_connections` FAPI endpoints.
553552
*
554-
* Uses `deepCamelToSnake` but preserves `saml.attributeMapping` and `customAttributes` as-is. Their keys are
553+
* The handler expects a flat form body where SAML and OIDC fields are
554+
* prefixed (e.g. `saml_idp_metadata_url`, `oidc_client_id`) rather
555+
* than nested under `saml`/`oidc` objects. `attribute_mapping` and
556+
* `custom_attributes` stay as object values and are JSON-stringified
557+
* by the form serializer downstream — their inner keys are
555558
* user-supplied data and must not be camel→snake transformed.
556559
*/
557560
function toMeEnterpriseConnectionBody(
558561
params: CreateMeEnterpriseConnectionParams | UpdateMeEnterpriseConnectionParams,
559562
): Record<string, unknown> {
560-
const originalAttributeMapping =
561-
params.saml && typeof params.saml === 'object' ? params.saml.attributeMapping : undefined;
562-
const originalCustomAttributes = 'customAttributes' in params ? params.customAttributes : undefined;
563-
564-
const body = deepCamelToSnake(params) as Record<string, any>;
565-
566-
if (originalAttributeMapping !== undefined && body.saml && typeof body.saml === 'object') {
567-
body.saml.attribute_mapping = originalAttributeMapping;
563+
const body: Record<string, unknown> = {};
564+
565+
// Top-level fields. `provider` is only on Create, the rest are shared
566+
setIfDefined(body, 'provider', (params as CreateMeEnterpriseConnectionParams).provider);
567+
setIfDefined(body, 'name', params.name);
568+
setIfDefined(body, 'organization_id', params.organizationId);
569+
setIfDefined(body, 'active', (params as UpdateMeEnterpriseConnectionParams).active);
570+
setIfDefined(body, 'sync_user_attributes', (params as UpdateMeEnterpriseConnectionParams).syncUserAttributes);
571+
setIfDefined(
572+
body,
573+
'disable_additional_identifications',
574+
(params as UpdateMeEnterpriseConnectionParams).disableAdditionalIdentifications,
575+
);
576+
setIfDefined(body, 'custom_attributes', (params as UpdateMeEnterpriseConnectionParams).customAttributes);
577+
578+
if (params.saml) {
579+
setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId);
580+
setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl);
581+
setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate);
582+
setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl);
583+
setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata);
584+
setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping);
585+
setIfDefined(body, 'saml_allow_subdomains', params.saml.allowSubdomains);
586+
setIfDefined(body, 'saml_allow_idp_initiated', params.saml.allowIdpInitiated);
587+
setIfDefined(body, 'saml_force_authn', params.saml.forceAuthn);
568588
}
569589

570-
if (originalCustomAttributes !== undefined) {
571-
body.custom_attributes = originalCustomAttributes;
590+
if (params.oidc) {
591+
setIfDefined(body, 'oidc_client_id', params.oidc.clientId);
592+
setIfDefined(body, 'oidc_client_secret', params.oidc.clientSecret);
593+
setIfDefined(body, 'oidc_discovery_url', params.oidc.discoveryUrl);
594+
setIfDefined(body, 'oidc_auth_url', params.oidc.authUrl);
595+
setIfDefined(body, 'oidc_token_url', params.oidc.tokenUrl);
596+
setIfDefined(body, 'oidc_user_info_url', params.oidc.userInfoUrl);
597+
setIfDefined(body, 'oidc_requires_pkce', params.oidc.requiresPkce);
572598
}
573599

574600
return body;
575601
}
602+
603+
/**
604+
* Adds `value` under `key` only when the caller actually provided it.
605+
* Mirrors the SDK's existing semantics: `undefined` means "don't send
606+
* this field"; `null` is forwarded so users can explicitly clear a
607+
* value via the form-encoded body
608+
*/
609+
function setIfDefined(target: Record<string, unknown>, key: string, value: unknown): void {
610+
if (value !== undefined) {
611+
target[key] = value;
612+
}
613+
}

packages/clerk-js/src/core/resources/__tests__/User.test.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ describe('User', () => {
184184
provider: 'saml_okta',
185185
name: 'New SSO',
186186
organization_id: 'org_1',
187-
saml: { idp_entity_id: 'https://idp.example.com' },
187+
saml_idp_entity_id: 'https://idp.example.com',
188188
},
189189
});
190190

@@ -291,13 +291,11 @@ describe('User', () => {
291291
body: {
292292
provider: 'saml_okta',
293293
name: 'New SSO',
294-
saml: {
295-
idp_entity_id: 'https://idp.example.com',
296-
attribute_mapping: {
297-
emailAddress: 'mail',
298-
firstName: 'givenName',
299-
'custom:role': 'role',
300-
},
294+
saml_idp_entity_id: 'https://idp.example.com',
295+
saml_attribute_mapping: {
296+
emailAddress: 'mail',
297+
firstName: 'givenName',
298+
'custom:role': 'role',
301299
},
302300
},
303301
});
@@ -359,11 +357,9 @@ describe('User', () => {
359357
CustomValue: 'y',
360358
nestedCamelKey: { innerCamelKey: 'z' },
361359
},
362-
saml: {
363-
attribute_mapping: {
364-
emailAddress: 'mail',
365-
firstName: 'givenName',
366-
},
360+
saml_attribute_mapping: {
361+
emailAddress: 'mail',
362+
firstName: 'givenName',
367363
},
368364
},
369365
});

packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import { BoxIcon } from '@/icons';
1212
import { Route, Switch } from '@/router';
1313

1414
import { ConfigureSSOFlowProvider } from './ConfigureSSOContext';
15-
import { ConfigureCreateApp, ConfirmationStep, ProvideEmail, TestConfigurationStep, VerifyDomainStep } from './steps';
15+
import {
16+
ConfigureCreateApp,
17+
ConfirmationStep,
18+
ProvideEmail,
19+
SelectProviderStep,
20+
TestConfigurationStep,
21+
VerifyDomainStep,
22+
} from './steps';
1623
import { ConfigureSSOWizard } from './wizard';
1724

1825
const ConfigureSSOInternal = () => {
@@ -34,8 +41,14 @@ const AuthenticatedContent = withCoreUserGuard(() => {
3441
const { parsedOptions } = useAppearance();
3542
const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl);
3643

37-
const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections } =
38-
__internal_useUserEnterpriseConnections({ enabled: true });
44+
const {
45+
data: enterpriseConnections,
46+
isLoading: isLoadingEnterpriseConnections,
47+
createEnterpriseConnection,
48+
updateEnterpriseConnection,
49+
deleteEnterpriseConnection,
50+
revalidate: revalidateEnterpriseConnections,
51+
} = __internal_useUserEnterpriseConnections({ enabled: true });
3952
// Currently FAPI only supports one enterprise connection per user
4053
const enterpriseConnection = enterpriseConnections?.[0];
4154

@@ -116,6 +129,10 @@ const AuthenticatedContent = withCoreUserGuard(() => {
116129
<ConfigureSSOFlowProvider
117130
enterpriseConnection={enterpriseConnection}
118131
isLoading={isLoadingEnterpriseConnections}
132+
createEnterpriseConnection={createEnterpriseConnection}
133+
updateEnterpriseConnection={updateEnterpriseConnection}
134+
deleteEnterpriseConnection={deleteEnterpriseConnection}
135+
revalidate={revalidateEnterpriseConnections}
119136
>
120137
<ConfigureSSOSteps />
121138
</ConfigureSSOFlowProvider>
@@ -132,6 +149,13 @@ const ConfigureSSOSteps = () => {
132149

133150
return (
134151
<ConfigureSSOWizard>
152+
<ConfigureSSOWizard.Step
153+
id='select-provider'
154+
path='select-provider'
155+
label='Select provider'
156+
>
157+
<SelectProviderStep />
158+
</ConfigureSSOWizard.Step>
135159
<ConfigureSSOWizard.Step
136160
id='verify-email-domain'
137161
path='verify-email-domain'
@@ -159,15 +183,7 @@ const ConfigureSSOSteps = () => {
159183
path='configure'
160184
label='Configure'
161185
>
162-
<ConfigureSSOWizard>
163-
{/* TODO: Implement configure steps */}
164-
<ConfigureSSOWizard.Step
165-
id='create-app'
166-
path='create-app'
167-
>
168-
<ConfigureCreateApp />
169-
</ConfigureSSOWizard.Step>
170-
</ConfigureSSOWizard>
186+
<ConfigureCreateApp />
171187
</ConfigureSSOWizard.Step>
172188
<ConfigureSSOWizard.Step
173189
id='test'

packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
import type { EnterpriseConnectionResource } from '@clerk/shared/types';
1+
import type {
2+
CreateMeEnterpriseConnectionParams,
3+
DeletedObjectResource,
4+
EnterpriseConnectionResource,
5+
MeEnterpriseConnectionProvider,
6+
UpdateMeEnterpriseConnectionParams,
7+
} from '@clerk/shared/types';
28
import React, { type PropsWithChildren } from 'react';
39

10+
/**
11+
* Identity providers exposed in the wizard. Only `saml_okta` is wired
12+
* end-to-end for the PoC; the rest are intentionally inert and
13+
* surfaced as disabled options
14+
*/
15+
export type ConfigureSSOSupportedProvider = MeEnterpriseConnectionProvider;
16+
417
/**
518
* Shared form state for the ConfigureSSO wizard, persisted across steps
619
*/
@@ -9,6 +22,11 @@ export interface ConfigureSSOData {
922
* The enterprise connection from the user's primary email address domain
1023
*/
1124
enterpriseConnection: EnterpriseConnectionResource | undefined;
25+
/**
26+
* The provider the user picked on the first step. Drives the
27+
* `provider` field sent to the FAPI Create endpoint
28+
*/
29+
selectedProvider: ConfigureSSOSupportedProvider | undefined;
1230
}
1331

1432
export interface ConfigureSSOContextValue extends ConfigureSSOData {
@@ -17,11 +35,30 @@ export interface ConfigureSSOContextValue extends ConfigureSSOData {
1735
* connection
1836
*/
1937
isLoading: boolean;
38+
setSelectedProvider: (provider: ConfigureSSOSupportedProvider | undefined) => void;
39+
createEnterpriseConnection: (
40+
params: CreateMeEnterpriseConnectionParams,
41+
) => Promise<EnterpriseConnectionResource | undefined>;
42+
updateEnterpriseConnection: (
43+
enterpriseConnectionId: string,
44+
params: UpdateMeEnterpriseConnectionParams,
45+
) => Promise<EnterpriseConnectionResource | undefined>;
46+
deleteEnterpriseConnection: (enterpriseConnectionId: string) => Promise<DeletedObjectResource | undefined>;
47+
revalidate: () => Promise<void>;
2048
}
2149

2250
interface ConfigureSSOFlowProviderProps {
2351
enterpriseConnection: EnterpriseConnectionResource | undefined;
2452
isLoading: boolean;
53+
createEnterpriseConnection: (
54+
params: CreateMeEnterpriseConnectionParams,
55+
) => Promise<EnterpriseConnectionResource | undefined>;
56+
updateEnterpriseConnection: (
57+
enterpriseConnectionId: string,
58+
params: UpdateMeEnterpriseConnectionParams,
59+
) => Promise<EnterpriseConnectionResource | undefined>;
60+
deleteEnterpriseConnection: (enterpriseConnectionId: string) => Promise<DeletedObjectResource | undefined>;
61+
revalidate: () => Promise<void>;
2562
}
2663

2764
const ConfigureSSOFlowContext = React.createContext<ConfigureSSOContextValue | null>(null);
@@ -30,14 +67,45 @@ ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext';
3067
export const ConfigureSSOFlowProvider = ({
3168
enterpriseConnection,
3269
isLoading,
70+
createEnterpriseConnection,
71+
updateEnterpriseConnection,
72+
deleteEnterpriseConnection,
73+
revalidate,
3374
children,
3475
}: PropsWithChildren<ConfigureSSOFlowProviderProps>): JSX.Element => {
76+
const [selectedProvider, setSelectedProvider] = React.useState<ConfigureSSOSupportedProvider | undefined>(
77+
enterpriseConnection?.provider as ConfigureSSOSupportedProvider | undefined,
78+
);
79+
80+
// Adopt the provider of the existing connection once it's fetched, so
81+
// the user lands on the configure step pre-populated when they
82+
// re-enter the wizard
83+
React.useEffect(() => {
84+
if (enterpriseConnection?.provider && !selectedProvider) {
85+
setSelectedProvider(enterpriseConnection.provider as ConfigureSSOSupportedProvider);
86+
}
87+
}, [enterpriseConnection?.provider, selectedProvider]);
88+
3589
const value = React.useMemo<ConfigureSSOContextValue>(
3690
() => ({
3791
enterpriseConnection,
3892
isLoading,
93+
selectedProvider,
94+
setSelectedProvider,
95+
createEnterpriseConnection,
96+
updateEnterpriseConnection,
97+
deleteEnterpriseConnection,
98+
revalidate,
3999
}),
40-
[enterpriseConnection, isLoading],
100+
[
101+
enterpriseConnection,
102+
isLoading,
103+
selectedProvider,
104+
createEnterpriseConnection,
105+
updateEnterpriseConnection,
106+
deleteEnterpriseConnection,
107+
revalidate,
108+
],
41109
);
42110

43111
return <ConfigureSSOFlowContext.Provider value={value}>{children}</ConfigureSSOFlowContext.Provider>;

0 commit comments

Comments
 (0)