diff --git a/.changeset/configure-sso-segmented-control-shell.md b/.changeset/configure-sso-segmented-control-shell.md new file mode 100644 index 00000000000..5c971716b77 --- /dev/null +++ b/.changeset/configure-sso-segmented-control-shell.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Add a two-mode segmented control to the SAML config submission sub-step in `<__experimental_ConfigureSSO />`. Users pick between **Add via metadata URL** (default) and **Configure manually**. The metadata URL form is unchanged; the manual entry form ships in a follow-up commit. Locale keys added under `configureSSO.configureStep.samlOkta.modes` in `en-US`. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 92cc7bd8af8..918f475e494 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -288,11 +288,11 @@ export const enUS: LocalizationResource = { subtitle: 'Create a new enterprise application in your Okta Dashboard', createApp: { title: 'Create a new enterprise application in Okta', - step1: 'Sign in to Okta and go to Admin → Applications.', - step2: 'Click Create App Integration.', - step3: 'Select SAML 2.0.', + step1: 'Sign in to Okta and go to Admin → Applications.', + step2: 'Click Create App Integration.', + step3: 'Select SAML 2.0.', step4: 'Fill in the General Settings (App name is required).', - step5: 'Click Next to complete creating the application.', + step5: 'Click Next to complete creating the application.', }, serviceProvider: { title: 'Configure service provider', @@ -303,12 +303,13 @@ export const enUS: LocalizationResource = { }, completeSamlIntegration: { title: 'Complete SAML integration', - step1: 'Select This is an internal app that we have created from the options menu.', - step2: 'Complete the form with any comments and select "Finish".', + step1: 'Select This is an internal app that we have created from the options menu.', + step2: 'Complete the form with any comments and select Finish.', }, configureAttributes: { - step1: 'In the Okta dashboard, find the Attribute Statements section.', - step2: 'Select Add Expression for each attribute, and enter the following name and expression pairs:', + step1: 'In the Okta dashboard, find the Attribute Statements section.', + step2: + 'Select Add Expression for each attribute, and enter the following name and expression pairs:', pairs: { conjunction: ' and ', email: { @@ -328,17 +329,44 @@ export const enUS: LocalizationResource = { assignUsers: { 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 Assignments tab.', - step2: 'Select the Assign dropdown. You can either select Assign to people or Assign to groups.', + step1: 'In the Okta dashboard, select the Assignments tab.', + step2: + 'Select the Assign dropdown. You can either select Assign to people or Assign to groups.', step3: 'In the search field, enter the user or group of users that you want to assign to the application.', - step4: 'Select the Assign button next to the user or group that you want to assign.', - step5: 'Select the Done button to complete the assignment.', + step4: 'Select the Assign button next to the user or group that you want to assign.', + step5: 'Select the Done button to complete the assignment.', }, metadataUrl: { 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.', }, + modes: { + ariaLabel: 'Configuration mode', + metadataUrl: 'Add via metadata', + manual: 'Configure manually', + }, + submitSamlConfig: { + title: 'Fill in your Okta SAML application details', + }, + manual: { + description: 'In your Okta SAML app, go to the Sign On tab and 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', + }, + }, }, }, }, diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 4d2d9ab72f2..f16db90125a 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -26,7 +26,10 @@ export type FieldId = | 'apiKeyExpirationDate' | 'apiKeyRevokeConfirmation' | 'apiKeySecret' + | 'idpCertificate' + | 'idpEntityId' | 'idpMetadataUrl' + | 'idpSsoUrl' | 'acsUrl' | 'spEntityId' | 'web3WalletName'; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 7b23db716c7..054aa19d9f2 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1427,6 +1427,32 @@ export type __internal_LocalizationResource = { 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; + }; + }; }; }; }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx index 48c92857b49..c9c93571978 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx @@ -1,14 +1,17 @@ import { __internal_useUserEnterpriseConnections, useReverification } from '@clerk/shared/react'; -import type { UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; +import type { FieldId, UpdateMeEnterpriseConnectionParams } from '@clerk/shared/types'; import React from 'react'; import { Badge, + Box, + Button, Col, descriptors, Flex, Flow, Heading, + Icon, localizationKeys, Table, Tbody, @@ -17,13 +20,17 @@ import { Th, Thead, Tr, + useLocalizations, } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; import { useCardState } from '@/elements/contexts'; +import { Field } from '@/elements/FieldControl'; import { Form } from '@/elements/Form'; -import { Check, ClipboardOutline } from '@/icons'; -import { handleError } from '@/utils/errorHandler'; +import { SegmentedControl } from '@/elements/SegmentedControl'; +import { Check, ClipboardOutline, Close, Upload } from '@/icons'; +import type { FormControlState } from '@/ui/utils/useFormControl'; import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; import { useConfigureSSO } from '../ConfigureSSOContext'; import { Step } from '../elements/Step'; @@ -270,139 +277,137 @@ export const ConfigureAttributesSubStep = (): JSX.Element => { <> ({ gap: theme.space.$3 })}> - ({ gap: theme.space.$3 })}> - + - ({ - 'tr > th:first-of-type': { - paddingInlineStart: theme.space.$4, - }, - })} - > - - - +
- ({ fontSize: theme.fontSizes.$xs })} - localizationKey={localizationKeys( - 'configureSSO.configureStep.attributeMapping.columns.attribute', - )} - /> -
({ + 'tr > th:first-of-type': { + paddingInlineStart: theme.space.$4, + }, + })} + > + + + + + + + + + + {ATTRIBUTE_ROWS.map(row => ( + + - - - - - {ATTRIBUTE_ROWS.map(row => ( - - - - - - ))} - -
+ ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys('configureSSO.configureStep.attributeMapping.columns.attribute')} + /> + + ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys('configureSSO.configureStep.attributeMapping.columns.claimName')} + /> +
+ ({ gap: theme.space.$2 })} + > + + + + + + ({ fontSize: theme.fontSizes.$xs })} - localizationKey={localizationKeys( - 'configureSSO.configureStep.attributeMapping.columns.claimName', - )} + as='span' + sx={{ fontFamily: 'monospace' }} + localizationKey={row.claim} /> - +
- ({ gap: theme.space.$2 })} - > - - - - - - -
- + ))} + + + + - ({ gap: theme.space.$3 })}> + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'decimal', + })} + > - - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'decimal', - })} + - ({ + gap: theme.space.$1x5, + margin: 0, + marginTop: theme.space.$1x5, + paddingInlineStart: theme.space.$5, + listStyleType: '"- "', + })} > - - ({ - gap: theme.space.$1x5, - margin: 0, - marginTop: theme.space.$1x5, - paddingInlineStart: theme.space.$5, - listStyleType: '"- "', - })} - > - {ATTRIBUTE_PAIRS.map(pair => ( - - + {ATTRIBUTE_PAIRS.map(pair => ( + + - + - - - ))} - - - + + + ))} + +
@@ -427,54 +432,52 @@ export const AssignUsersSubStep = (): JSX.Element => { return ( <> - ({ gap: theme.space.$5 })}> - ({ gap: theme.space.$3 })}> - ({ gap: theme.space.$3 })}> + + + + ({ + gap: theme.space.$1x5, + margin: 0, + paddingInlineStart: theme.space.$5, + listStyleType: 'decimal', + })} + > + + + + - - ({ - gap: theme.space.$1x5, - margin: 0, - paddingInlineStart: theme.space.$5, - listStyleType: 'decimal', - })} - > - - - - - - @@ -495,10 +498,23 @@ export const AssignUsersSubStep = (): JSX.Element => { export const SubmitSamlConfigSubStep = (): JSX.Element => { const card = useCardState(); + const { t } = useLocalizations(); const { goNext, goPrev, isFirstStep } = useWizard(); const { enterpriseConnection } = useConfigureSSO(); const { updateEnterpriseConnection } = __internal_useUserEnterpriseConnections(); + const samlConnection = enterpriseConnection?.samlConnection; + const hasExistingConfig = Boolean( + samlConnection?.idpSsoUrl || + samlConnection?.idpEntityId || + samlConnection?.idpCertificate || + samlConnection?.idpMetadataUrl, + ); + const existingCertPresent = Boolean(samlConnection?.idpCertificate); + + const [mode, setMode] = React.useState<'metadataUrl' | 'manual'>(hasExistingConfig ? 'manual' : 'metadataUrl'); + const [certFile, setCertFile] = React.useState(null); + const updateConnection = useReverification( React.useCallback( async (params: UpdateMeEnterpriseConnectionParams) => { @@ -512,15 +528,42 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { ), ); - const metadataUrlField = useFormControl('idpMetadataUrl', '', { + const metadataUrlField = useFormControl('idpMetadataUrl', samlConnection?.idpMetadataUrl ?? '', { type: 'text', label: localizationKeys('configureSSO.configureStep.samlOkta.metadataUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.samlOkta.metadataUrl.placeholder'), isRequired: true, }); + const signOnUrlField = useFormControl('idpSsoUrl', samlConnection?.idpSsoUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlOkta.manual.signOnUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.samlOkta.manual.signOnUrl.placeholder'), + isRequired: true, + }); + + const issuerField = useFormControl('idpEntityId', samlConnection?.idpEntityId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlOkta.manual.issuer.label'), + placeholder: localizationKeys('configureSSO.configureStep.samlOkta.manual.issuer.placeholder'), + isRequired: true, + }); + + const certFileField = useFormControl('idpCertificate', '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.samlOkta.manual.signingCertificate.label'), + isRequired: true, + }); + const trimmedMetadataUrl = metadataUrlField.value.trim(); - const canSubmit = trimmedMetadataUrl.length > 0 && !card.isLoading; + const trimmedSignOnUrl = signOnUrlField.value.trim(); + const trimmedIssuer = issuerField.value.trim(); + + const hasCert = certFile !== null || existingCertPresent; + const canSubmit = + !card.isLoading && + ((mode === 'metadataUrl' && trimmedMetadataUrl.length > 0) || + (mode === 'manual' && trimmedSignOnUrl.length > 0 && trimmedIssuer.length > 0 && hasCert)); const handleContinue = async () => { if (!enterpriseConnection || !canSubmit) { @@ -531,10 +574,27 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { card.setLoading(); try { - await updateConnection({ saml: { idpMetadataUrl: trimmedMetadataUrl } }); + if (mode === 'metadataUrl') { + await updateConnection({ saml: { idpMetadataUrl: trimmedMetadataUrl } }); + } else { + const samlPayload: NonNullable = { + idpSsoUrl: trimmedSignOnUrl, + idpEntityId: trimmedIssuer, + }; + + if (certFile !== null) { + samlPayload.idpCertificate = await certFile.text(); + } + + await updateConnection({ saml: samlPayload }); + } void goNext(); } catch (err) { - handleError(err as Error, [metadataUrlField], card.setError); + if (mode === 'metadataUrl') { + handleError(err as Error, [metadataUrlField], card.setError); + } else { + handleError(err as Error, [signOnUrlField, issuerField, certFileField], card.setError); + } } finally { card.setIdle(); } @@ -545,16 +605,41 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { ({ gap: theme.space.$5 })} + gap={5} > - - - - + setMode(value as 'metadataUrl' | 'manual')} + fullWidth + > + + + + + {mode === 'metadataUrl' ? ( + + ) : ( + + )} @@ -572,3 +657,158 @@ export const SubmitSamlConfigSubStep = (): JSX.Element => { ); }; + +type FormControl = FormControlState; + +type MetadataUrlPanelProps = { + field: FormControl; +}; + +type ManualEntryPanelProps = { + signOnUrlField: FormControl; + issuerField: FormControl; + certFileField: FormControl; + certFile: File | null; + setCertFile: React.Dispatch>; + existingCertPresent: boolean; +}; + +const MetadataUrlPanel = ({ field }: MetadataUrlPanelProps): JSX.Element => { + return ( + <> + + + + + + ); +}; + +const ManualEntryPanel = ({ + signOnUrlField, + issuerField, + certFileField, + certFile, + setCertFile, + existingCertPresent, +}: ManualEntryPanelProps): JSX.Element => { + const { t } = useLocalizations(); + const certInputRef = React.useRef(null); + + return ( + <> + + + + + + + + + + + + + + + + + + { + setCertFile(e.target.files?.[0] ?? null); + certFileField.clearFeedback(); + }} + /> + + {certFile === null ? ( + + {existingCertPresent && ( + + )} + + + ) : ( + ({ paddingTop: theme.space.$1, paddingBottom: theme.space.$1 })} + > + + {certFile.name} + + + + + )} + + + + + + ); +};