Skip to content
Open
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
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