Skip to content
8 changes: 8 additions & 0 deletions .changeset/public-parts-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/shared': patch
'@clerk/ui': patch
---

Add support for custom SAML provider in `<ConfigureSSO />`
58 changes: 56 additions & 2 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,9 @@ export const enUS: LocalizationResource = {
},
},
samlOkta: {
title: 'Configure Okta Workforce',
subtitle: 'Create a new enterprise application in your Okta Dashboard',
headerTitle: 'Configure Okta Workforce',
createApp: {
headerSubtitle: 'Create a new enterprise application in your Okta Dashboard',
title: 'Create a new enterprise application in Okta',
step1: 'Sign in to Okta and go to <bold>Admin → Applications.</bold>',
step2: 'Click <bold>Create App Integration.</bold>',
Expand All @@ -402,6 +402,7 @@ export const enUS: LocalizationResource = {
step2: 'Complete the form with any comments and select <bold>Finish</bold>.',
},
configureAttributes: {
headerSubtitle: 'Map users attributes from Okta to Clerk',
step1: 'In the Okta dashboard, find the <bold>Attribute Statements</bold> section.',
step2:
'Select <bold>Add Expression</bold> for each attribute, and enter the following name and expression pairs:',
Expand All @@ -422,6 +423,7 @@ export const enUS: LocalizationResource = {
},
},
assignUsers: {
headerSubtitle: 'Assign users to the enterprise app',
title: 'Assign selected user or group in Okta',
paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.',
step1: 'In the Okta dashboard, select the <bold>Assignments</bold> tab.',
Expand All @@ -432,6 +434,7 @@ export const enUS: LocalizationResource = {
step5: 'Select the <bold>Done</bold> button to complete the assignment.',
},
metadataUrl: {
headerSubtitle: 'Configure identity provider metadata',
label: 'Metadata URL',
placeholder: 'Paste URL here...',
description: 'In your Okta SAML app, go to the Sign On tab and retrieve the metadata URL. Paste it below.',
Expand Down Expand Up @@ -463,6 +466,57 @@ export const enUS: LocalizationResource = {
},
},
},
samlCustom: {
headerTitle: 'Configure your identity provider (IdP)',
createApp: {
headerSubtitle:
'Register Clerk as a service provider in your IdP, then add your identity provider configuration.',
title: 'Create a SAML application on your identity provider',
subtitle:
'In your identity provider’s admin dashboard, create a new SAML 2.0 application and use the following service provider details:',
},
configureAttributes: {
headerSubtitle: 'Map user attributes from your identity provider to Clerk.',
title: 'We expect your SAML responses to have the following specific attributes:',
},
assignUsers: {
headerSubtitle: 'Assign users to the enterprise app',
title: 'Assign selected user or group',
paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.',
},
metadataUrl: {
headerSubtitle: 'Configure identity provider metadata',
label: 'Metadata URL',
placeholder: 'Paste URL here...',
description: 'In your enterprise app, retrieve the metadata URL. Paste it below.',
},
modes: {
ariaLabel: 'Configuration mode',
metadataUrl: 'Add via metadata',
manual: 'Configure manually',
},
submitSamlConfig: {
title: 'Fill in your SAML application details',
},
manual: {
description: 'In your SAML app, retrieve these values.',
signOnUrl: {
label: 'Sign on URL',
placeholder: 'Paste URL here...',
},
issuer: {
label: 'Issuer',
placeholder: 'Paste URL here...',
},
signingCertificate: {
label: 'Signing certificate',
uploadFile: 'Upload file',
replaceFile: 'Replace file',
removeFile: 'Remove file',
fileUploaded: 'File uploaded',
},
},
},
},
},
createOrganization: {
Expand Down
56 changes: 54 additions & 2 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1436,9 +1436,9 @@ export type __internal_LocalizationResource = {
};
};
samlOkta: {
title: LocalizationValue;
subtitle: LocalizationValue;
headerTitle: LocalizationValue;
createApp: {
headerSubtitle: LocalizationValue;
title: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
Expand All @@ -1457,6 +1457,7 @@ export type __internal_LocalizationResource = {
step2: LocalizationValue;
};
configureAttributes: {
headerSubtitle: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
pairs: {
Expand All @@ -1476,6 +1477,7 @@ export type __internal_LocalizationResource = {
};
};
assignUsers: {
headerSubtitle: LocalizationValue;
title: LocalizationValue;
paragraph: LocalizationValue;
step1: LocalizationValue;
Expand All @@ -1485,6 +1487,56 @@ export type __internal_LocalizationResource = {
step5: LocalizationValue;
};
metadataUrl: {
headerSubtitle: LocalizationValue;
label: LocalizationValue;
placeholder: LocalizationValue;
description: LocalizationValue;
};
modes: {
ariaLabel: LocalizationValue;
metadataUrl: LocalizationValue;
manual: LocalizationValue;
};
submitSamlConfig: {
title: LocalizationValue;
};
manual: {
description: LocalizationValue;
signOnUrl: {
label: LocalizationValue;
placeholder: LocalizationValue;
};
issuer: {
label: LocalizationValue;
placeholder: LocalizationValue;
};
signingCertificate: {
label: LocalizationValue;
uploadFile: LocalizationValue;
replaceFile: LocalizationValue;
removeFile: LocalizationValue;
fileUploaded: LocalizationValue;
};
};
};
samlCustom: {
headerTitle: LocalizationValue;
createApp: {
headerSubtitle: LocalizationValue;
title: LocalizationValue;
subtitle: LocalizationValue;
};
configureAttributes: {
headerSubtitle: LocalizationValue;
title: LocalizationValue;
};
assignUsers: {
headerSubtitle: LocalizationValue;
title: LocalizationValue;
paragraph: LocalizationValue;
};
metadataUrl: {
headerSubtitle: LocalizationValue;
label: LocalizationValue;
placeholder: LocalizationValue;
description: LocalizationValue;
Expand Down
71 changes: 64 additions & 7 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { __internal_useUserEnterpriseConnections, useSession } from '@clerk/shared/react';
import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types';
import {
__internal_useEnterpriseConnectionTestRuns,
__internal_useUserEnterpriseConnections,
useSession,
} from '@clerk/shared/react';
import type { __experimental_ConfigureSSOProps, EnterpriseConnectionResource } from '@clerk/shared/types';
import React from 'react';

import { useProtect } from '@/common';
import { withCoreUserGuard } from '@/contexts';
import { Col, descriptors, Flex, Flow, Heading, Icon, localizationKeys, Text } from '@/customizables';
import { withCardStateProvider } from '@/elements/contexts';
import { useCardState, withCardStateProvider } from '@/elements/contexts';
import { ProfileCard } from '@/elements/ProfileCard';
import { ExclamationTriangle } from '@/icons';
import { Route, Switch } from '@/router';
Expand All @@ -16,7 +20,7 @@ import { ConfigureSSONavbar } from './ConfigureSSONavbar';
import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton';
import { ProfileCardFooter, ProfileCardHeader } from './elements/ProfileCard';
import { Step } from './elements/Step';
import { Wizard } from './elements/Wizard';
import { useWizard, Wizard } from './elements/Wizard';
import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps';

const ConfigureSSOInternal = () => {
Expand Down Expand Up @@ -64,19 +68,31 @@ const AuthenticatedContent = withCoreUserGuard(() => {
});

const ConfigureSSOCardContent = ({ contentRef }: { contentRef: React.RefObject<HTMLDivElement> }) => {
const { data: enterpriseConnections, isLoading } = __internal_useUserEnterpriseConnections({ enabled: true });

const {
data: enterpriseConnections,
isLoading: isLoadingEnterpriseConnections,
createEnterpriseConnection,
updateEnterpriseConnection,
deleteEnterpriseConnection,
} = __internal_useUserEnterpriseConnections({ enabled: true });
// Currently FAPI only supports one enterprise connection per user
const enterpriseConnection = enterpriseConnections?.[0];

if (isLoading && !enterpriseConnection) {
const { hasSuccessfulTestRun, isLoading: isLoadingTestRuns } = useHasSuccessfulTestRun(enterpriseConnection);

const isLoading = isLoadingEnterpriseConnections || isLoadingTestRuns;
if (isLoading) {
return <ConfigureSSOSkeleton />;
}

return (
<ConfigureSSOProvider
hasSuccessfulTestRun={hasSuccessfulTestRun}
enterpriseConnection={enterpriseConnection}
contentRef={contentRef}
createEnterpriseConnection={createEnterpriseConnection}
updateEnterpriseConnection={updateEnterpriseConnection}
deleteEnterpriseConnection={deleteEnterpriseConnection}
>
<ConfigureSSOSteps />
</ConfigureSSOProvider>
Expand All @@ -88,6 +104,7 @@ const ConfigureSSOSteps = () => {

return (
<Wizard initialStepId={initialStepId}>
<ResetCardErrorOnStepChange />
<ConfigureSSOHeader />

<Wizard.Step id='select-provider'>
Expand Down Expand Up @@ -183,5 +200,45 @@ const MissingManageEnterpriseConnectionsPermission = () => (
</>
);

/**
* Sentinel component rendered inside `<Wizard>`
*
* Clears any card-level error whenever the active step transitions, so a stale failure from one step
* doesn't leak into the next
*/
const ResetCardErrorOnStepChange = (): null => {
const { currentStep } = useWizard();
const card = useCardState();
const previousStepIdRef = React.useRef(currentStep?.id);

React.useEffect(() => {
if (previousStepIdRef.current === currentStep?.id) {
return;
}

previousStepIdRef.current = currentStep?.id;
card.setError(undefined);
}, [currentStep?.id, card]);

return null;
};

/**
* Fetches a single successful test run for the given connection on mount
*/
const useHasSuccessfulTestRun = (
enterpriseConnection: EnterpriseConnectionResource | undefined,
): { hasSuccessfulTestRun: boolean; isLoading: boolean } => {
const { data: successfulTestRuns, isLoading } = __internal_useEnterpriseConnectionTestRuns({
enterpriseConnectionId: enterpriseConnection?.id ?? null,
params: { initialPage: 1, pageSize: 1, status: ['success'] },
});

return {
hasSuccessfulTestRun: (successfulTestRuns?.length ?? 0) > 0,
isLoading,
};
};

export const ConfigureSSO: React.ComponentType<__experimental_ConfigureSSOProps> =
withCardStateProvider(ConfigureSSOInternal);
Loading
Loading