diff --git a/packages/console/src/pages/EnterpriseSso/SsoCreationModal/index.tsx b/packages/console/src/pages/EnterpriseSso/SsoCreationModal/index.tsx index 929432cc5..7c185b9bd 100644 --- a/packages/console/src/pages/EnterpriseSso/SsoCreationModal/index.tsx +++ b/packages/console/src/pages/EnterpriseSso/SsoCreationModal/index.tsx @@ -1,10 +1,10 @@ import { + type RequestErrorBody, type SsoConnectorProvidersResponse, type SsoConnectorWithProviderConfig, - type RequestErrorBody, } from '@logto/schemas'; import { HTTPError } from 'ky'; -import { useMemo, useState, useContext } from 'react'; +import { useContext, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Trans, useTranslation } from 'react-i18next'; import Modal from 'react-modal'; @@ -21,8 +21,7 @@ import DynamicT from '@/ds-components/DynamicT'; import FormField from '@/ds-components/FormField'; import ModalLayout from '@/ds-components/ModalLayout'; import TextInput from '@/ds-components/TextInput'; -import { type RequestError } from '@/hooks/use-api'; -import useApi from '@/hooks/use-api'; +import useApi, { type RequestError } from '@/hooks/use-api'; import * as modalStyles from '@/scss/modal.module.scss'; import { trySubmitSafe } from '@/utils/form'; @@ -150,7 +149,7 @@ function SsoCreationModal({ isOpen, onClose: rawOnClose }: Props) { a: , }} > - {t('upsell.paywall.organizations')} + {t('upsell.paywall.sso_connectors')} ) diff --git a/packages/console/src/pages/EnterpriseSso/types.ts b/packages/console/src/pages/EnterpriseSso/types.ts deleted file mode 100644 index 595acc9d4..000000000 --- a/packages/console/src/pages/EnterpriseSso/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Type definitions for Enterprise SSO guide form, since the type of SAML config is defined in - * @logto/core and can not be imported here, should align with SAML config types. - * See {@link @logto/core/packages/core/src/sso/SamlConnector/index.ts}. - */ -import { type SsoProviderName, type SsoConnectorWithProviderConfig } from '@logto/schemas'; - -type AttributeMapping = { - id?: string; - email?: string; - name?: string; -}; - -export const attributeKeys = Object.freeze(['id', 'email', 'name']) satisfies ReadonlyArray< - keyof AttributeMapping ->; - -export type SamlGuideFormType = { - metadataUrl?: string; - metadata?: string; - signInEndpoint?: string; - entityId?: string; - x509Certificate?: string; - attributeMapping?: AttributeMapping; -}; - -export type OidcGuideFormType = { - clientId?: string; - clientSecret?: string; - issuer?: string; - scope?: string; -}; - -export type GuideFormType = T extends - | SsoProviderName.OIDC - | SsoProviderName.GOOGLE_WORKSPACE - | SsoProviderName.OKTA - ? OidcGuideFormType - : T extends SsoProviderName.SAML | SsoProviderName.AZURE_AD - ? SamlGuideFormType - : never; - -/** - * This type aligned with the type of `SamlIdentityProviderMetadata` (packages/core/src/sso/types/saml.ts) - * and `OidcConfigResponse` (packages/core/src/sso/types/oidc.ts). - * - * Since these types are defined in @logto/core, we can't import them directly here. - */ -export type ParsedSsoIdentityProviderConfig = - T extends SsoProviderName.OIDC - ? { - authorizationEndpoint: string; - tokenEndpoint: string; - userinfoEndpoint: string; - jwksUri: string; - issuer: string; - } - : T extends SsoProviderName.SAML - ? { - defaultAttributeMapping: AttributeMapping; - serviceProvider: { - entityId: string; - assertionConsumerServiceUrl: string; - }; - identityProvider?: { - entityId: string; - signInEndpoint: string; - x509Certificate: string; - certificateExpiresAt: number; - isCertificateValid: boolean; - }; - } - : never; - -export type SsoConnectorConfig = GuideFormType; - -// Help the Guide component type to be inferred from the connector's type. -export type SsoConnectorWithProviderConfigWithGeneric = Omit< - SsoConnectorWithProviderConfig, - 'config' | 'providerName' | 'providerConfig' -> & { - providerName: T; - providerConfig?: ParsedSsoIdentityProviderConfig; - config: SsoConnectorConfig; -}; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/BasicInfo/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/BasicInfo/index.tsx deleted file mode 100644 index 9d37b184f..000000000 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/BasicInfo/index.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { SsoProviderName, type SsoConnectorWithProviderConfig } from '@logto/schemas'; -import { conditionalString } from '@silverhand/essentials'; -import { useContext } from 'react'; -import { z } from 'zod'; - -import { AppDataContext } from '@/contexts/AppDataProvider'; -import CopyToClipboard from '@/ds-components/CopyToClipboard'; -import FormField from '@/ds-components/FormField'; -import useCustomDomain from '@/hooks/use-custom-domain'; - -import * as styles from './index.module.scss'; - -type Props = { - ssoConnectorId: string; -} & Pick; - -/** - * TODO: Should align this with the guard `samlServiceProviderMetadataGuard` defined in {@link logto/core/src/sso/types/saml.ts}. - * This only applies to SAML SSO connectors. - */ -const providerPropertiesGuard = z.object({ - serviceProvider: z.object({ - assertionConsumerServiceUrl: z.string().min(1), - entityId: z.string().min(1), - }), -}); - -function BasicInfo({ ssoConnectorId, providerName, providerConfig }: Props) { - const { tenantEndpoint } = useContext(AppDataContext); - const { applyDomain: applyCustomDomain } = useCustomDomain(); - - if ( - [SsoProviderName.OIDC, SsoProviderName.GOOGLE_WORKSPACE, SsoProviderName.OKTA].includes( - providerName - ) - ) { - return ( - - {/* Generated and passed in by Admin console. */} - - - ); - } - - const result = providerPropertiesGuard.safeParse(providerConfig); - - /** - * Should not fallback to some other manually concatenated URL, show empty string instead. - * Empty string should never show up unless the API does not work properly. - */ - return ( - <> - - - - - - - - ); -} - -export default BasicInfo; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcConnectorForm.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcConnectorForm.tsx new file mode 100644 index 000000000..6c816ab78 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcConnectorForm.tsx @@ -0,0 +1,138 @@ +import { type RequestErrorBody } from '@logto/schemas'; +import cleanDeep from 'clean-deep'; +import { HTTPError } from 'ky'; +import { useEffect, useMemo } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; + +import DetailsForm from '@/components/DetailsForm'; +import FormCard from '@/components/FormCard'; +import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; +import useApi from '@/hooks/use-api'; + +import { invalidConfigErrorCode } from '../config'; +import { + type OidcSsoConnectorWithProviderConfig, + type OidcConnectorConfig, + oidcConnectorConfigGuard, + oidcProviderConfigGuard, +} from '../types/oidc'; + +import OidcMetadataForm from './OidcMetadataForm'; +import OidcConnectorSpInfo from './ServiceProviderInfo/OidcConnectorSpInfo'; + +type Props = { + isDeleted: boolean; + data: OidcSsoConnectorWithProviderConfig; + onUpdated: (data: OidcSsoConnectorWithProviderConfig) => void; +}; + +function OidcConnectorForm({ isDeleted, data, onUpdated }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const api = useApi({ hideErrorToast: [invalidConfigErrorCode] }); + + const methods = useForm(); + + const { + formState: { isSubmitting, isDirty }, + handleSubmit, + reset, + setError, + } = methods; + + const { config, providerConfig, providerName, id: connectorId } = data; + + // Guard the config data + const oidcConnectorConfig = useMemo(() => { + const result = oidcConnectorConfigGuard.safeParse(config); + const { success } = result; + const guardedConfig = success ? result.data : undefined; + + return guardedConfig; + }, [config]); + + const oidcProviderConfig = useMemo(() => { + const result = oidcProviderConfigGuard.safeParse(providerConfig); + const { success } = result; + const guardedConfig = success ? result.data : undefined; + + return guardedConfig; + }, [providerConfig]); + + useEffect(() => { + reset(oidcConnectorConfig); + }, [oidcConnectorConfig, reset]); + + const onSubmit = handleSubmit(async (formData) => { + if (isSubmitting) { + return; + } + + try { + const result = await api + .patch(`api/sso-connectors/${connectorId}`, { + json: { + config: cleanDeep(formData), + }, + }) + .json(); + + toast.success(t('general.saved')); + + onUpdated(result); + + reset(result.config); + } catch (error: unknown) { + if (error instanceof HTTPError) { + const errorBody = await error.response.clone().json(); + + // Manually handle the error to show the error message in the form. + if (errorBody.code === invalidConfigErrorCode) { + setError('issuer', { + type: 'custom', + message: errorBody.message, + }); + return; + } + } + + throw error; + } + }); + + return ( + + + + {/* Can not infer the type by narrowing down the value of `providerName`, so we need to cast it. */} + + + + + + + + + ); +} + +export default OidcConnectorForm; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/ParsedConfigPreview/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/ParsedConfigPreview/index.tsx index d12b79638..37976269d 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/ParsedConfigPreview/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/ParsedConfigPreview/index.tsx @@ -1,13 +1,12 @@ -import { type SsoProviderName } from '@logto/schemas'; import classNames from 'classnames'; import { useTranslation } from 'react-i18next'; -import { type ParsedSsoIdentityProviderConfig } from '@/pages/EnterpriseSso/types.js'; +import { type OidcProviderConfig } from '@/pages/EnterpriseSsoDetails/types/oidc'; import * as styles from './index.module.scss'; type Props = { - providerConfig: ParsedSsoIdentityProviderConfig; + providerConfig: OidcProviderConfig; className?: string; }; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx index 2fb056fa5..7ca7fd80c 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/OidcMetadataForm/index.tsx @@ -6,19 +6,16 @@ import CopyToClipboard from '@/ds-components/CopyToClipboard'; import FormField from '@/ds-components/FormField'; import InlineNotification from '@/ds-components/InlineNotification'; import TextInput from '@/ds-components/TextInput'; -import { - type ParsedSsoIdentityProviderConfig, - type OidcGuideFormType, - type SsoConnectorConfig, -} from '@/pages/EnterpriseSso/types.js'; import { uriValidator } from '@/utils/validator'; +import { type OidcConnectorConfig, type OidcProviderConfig } from '../../types/oidc'; + import ParsedConfigPreview from './ParsedConfigPreview'; import * as styles from './index.module.scss'; type Props = { - providerConfig?: ParsedSsoIdentityProviderConfig; - config?: SsoConnectorConfig; + providerConfig?: OidcProviderConfig; + config?: OidcConnectorConfig; providerName: SsoProviderName; }; @@ -28,7 +25,7 @@ function OidcMetadataForm({ providerConfig, config, providerName }: Props) { const { register, formState: { errors }, - } = useFormContext(); + } = useFormContext(); const isConfigEmpty = !config || Object.keys(config).length === 0; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlAttributeMapping/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlAttributeMapping/index.tsx index eeec8db4e..bf139fa85 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlAttributeMapping/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlAttributeMapping/index.tsx @@ -1,39 +1,25 @@ -import { socialUserInfoGuard } from '@logto/connector-kit'; -import { type SsoConnectorWithProviderConfig } from '@logto/schemas'; import { conditional, conditionalString } from '@silverhand/essentials'; import { useFormContext } from 'react-hook-form'; -import { z } from 'zod'; import CopyToClipboard from '@/ds-components/CopyToClipboard'; import DynamicT from '@/ds-components/DynamicT'; import TextInput from '@/ds-components/TextInput'; - -import { attributeKeys, type SamlGuideFormType } from '../../../EnterpriseSso/types.js'; +import { + samlAttributeKeys, + type SamlProviderConfig, + type SamlConnectorConfig, +} from '@/pages/EnterpriseSsoDetails/types/saml'; import * as styles from './index.module.scss'; -type Props = Pick; - -/** - * TODO: Should align this with the guard `samlAttributeMappingGuard` defined in {@link logto/core/src/sso/types/saml.ts}. - * This only applies to SAML-protocol-based SSO connectors. - */ -const providerPropertiesGuard = z.object({ - defaultAttributeMapping: socialUserInfoGuard - .pick({ - id: true, - email: true, - name: true, - }) - .required(), -}); +type Props = { + samlProviderConfig?: SamlProviderConfig; +}; const primaryKey = 'attributeMapping'; -function SamlAttributeMapping({ providerConfig }: Props) { - const { register } = useFormContext(); - - const result = providerPropertiesGuard.safeParse(providerConfig); +function SamlAttributeMapping({ samlProviderConfig }: Props) { + const { register } = useFormContext(); return ( @@ -48,7 +34,7 @@ function SamlAttributeMapping({ providerConfig }: Props) { - {attributeKeys.map((key) => { + {samlAttributeKeys.map((key) => { return ( diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlConnectorForm.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlConnectorForm.tsx new file mode 100644 index 000000000..04fdc0ef4 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlConnectorForm.tsx @@ -0,0 +1,149 @@ +import { type LogtoErrorCode } from '@logto/phrases'; +import { type RequestErrorBody } from '@logto/schemas'; +import cleanDeep from 'clean-deep'; +import { HTTPError } from 'ky'; +import { useEffect, useMemo } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; + +import DetailsForm from '@/components/DetailsForm'; +import FormCard from '@/components/FormCard'; +import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; +import useApi from '@/hooks/use-api'; +import { + type SamlSsoConnectorWithProviderConfig, + type SamlConnectorConfig, + samlConnectorConfigGuard, + samlProviderConfigGuard, +} from '@/pages/EnterpriseSsoDetails/types/saml'; +import { trySubmitSafe } from '@/utils/form'; + +import { invalidConfigErrorCode, invalidMetadataErrorCode } from '../config'; + +import SamlAttributeMapping from './SamlAttributeMapping'; +import SamlMetadataForm from './SamlMetadataForm'; +import SamlConnectorSpInfo from './ServiceProviderInfo/SamlConnectorSpInfo'; +import * as styles from './index.module.scss'; + +type Props = { + isDeleted: boolean; + data: SamlSsoConnectorWithProviderConfig; + onUpdated: (data: SamlSsoConnectorWithProviderConfig) => void; +}; + +const manualHandleErrorCodes: LogtoErrorCode[] = [invalidConfigErrorCode, invalidMetadataErrorCode]; + +function SamlConnectorForm({ isDeleted, data, onUpdated }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + const api = useApi({ hideErrorToast: manualHandleErrorCodes }); + const methods = useForm(); + + const { + formState: { isSubmitting, isDirty }, + handleSubmit, + reset, + setError, + } = methods; + + const { config, providerConfig, id: connectorId } = data; + + // Guard the config data + const samlConnectorConfig = useMemo(() => { + const result = samlConnectorConfigGuard.safeParse(config); + const { success } = result; + const guardedConfig = success ? result.data : undefined; + + return guardedConfig; + }, [config]); + + // Guard the provider config data + const samlProviderConfig = useMemo(() => { + const result = samlProviderConfigGuard.safeParse(providerConfig); + const { success } = result; + const guardedConfig = success ? result.data : undefined; + + return guardedConfig; + }, [providerConfig]); + + useEffect(() => { + reset(samlConnectorConfig); + }, [samlConnectorConfig, reset]); + + const onSubmit = handleSubmit( + trySubmitSafe(async (formData) => { + if (isSubmitting) { + return; + } + + try { + const result = await api + .patch(`api/sso-connectors/${connectorId}`, { + json: { config: cleanDeep(formData) }, + }) + .json(); + + toast.success(t('general.saved')); + + onUpdated(result); + + reset(result.config); + } catch (error: unknown) { + console.log(error); + + if (error instanceof HTTPError) { + const errorBody = await error.response.clone().json(); + + // Manually handle the error to show the error message in the form. + if (manualHandleErrorCodes.includes(errorBody.code)) { + const errorFormKey = formData.metadataUrl ? 'metadataUrl' : 'metadata'; + + setError(errorFormKey, { type: 'custom', message: errorBody.message }); + return; + } + } + + throw error; + } + }) + ); + + return ( + + + +
+ +
+
+ + + + + + +
+ +
+ ); +} + +export default SamlConnectorForm; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/ParsedConfigPreview/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/ParsedConfigPreview/index.tsx index d87cb9682..4d7d57e47 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/ParsedConfigPreview/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/ParsedConfigPreview/index.tsx @@ -1,5 +1,4 @@ import { isLanguageTag } from '@logto/language-kit'; -import { type SsoProviderName } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import classNames from 'classnames'; import i18next from 'i18next'; @@ -7,12 +6,12 @@ import { useTranslation } from 'react-i18next'; import CopyToClipboard from '@/ds-components/CopyToClipboard'; import DynamicT from '@/ds-components/DynamicT'; -import { type ParsedSsoIdentityProviderConfig } from '@/pages/EnterpriseSso/types.js'; +import { type SamlProviderConfig } from '@/pages/EnterpriseSsoDetails/types/saml'; import * as styles from './index.module.scss'; type Props = { - identityProviderConfig: ParsedSsoIdentityProviderConfig['identityProvider']; + identityProviderConfig: SamlProviderConfig['identityProvider']; }; type CertificatePreviewProps = { diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/index.tsx index be224324f..254f8353d 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/SamlMetadataForm/index.tsx @@ -1,4 +1,3 @@ -import { type SsoProviderName } from '@logto/schemas'; import { useState } from 'react'; import { useFormContext, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -7,10 +6,9 @@ import FormField from '@/ds-components/FormField'; import InlineNotification from '@/ds-components/InlineNotification'; import TextInput from '@/ds-components/TextInput'; import { - type ParsedSsoIdentityProviderConfig, - type SamlGuideFormType, - type SsoConnectorConfig, -} from '@/pages/EnterpriseSso/types.js'; + type SamlConnectorConfig, + type SamlProviderConfig, +} from '@/pages/EnterpriseSsoDetails/types/saml'; import { uriValidator } from '@/utils/validator'; import FileReader, { type Props as FileReaderProps } from '../FileReader'; @@ -20,17 +18,13 @@ import SwitchFormatButton, { FormFormat } from './SwitchFormatButton'; import * as styles from './index.module.scss'; type SamlMetadataFormFieldsProps = Pick & { - identityProviderConfig?: ParsedSsoIdentityProviderConfig['identityProvider']; + identityProviderConfig?: SamlProviderConfig['identityProvider']; formFormat: FormFormat; }; -type SamlMetadataFormProps = { - config?: SsoConnectorConfig; - providerConfig?: ParsedSsoIdentityProviderConfig; -}; +type FileValueKeyType = keyof Pick; // I.e. 'metadata' | 'x509Certificate'. -type KeyType = keyof Pick; // I.e. 'metadata' | 'x509Certificate'. -const keyToAttributes: Record = { +const fileReaderAttributesMap: Record = { // Accept xml file. metadata: { buttonTitle: 'enterprise_sso.metadata.saml.metadata_xml_uploader_text', @@ -59,12 +53,13 @@ function SamlMetadataFormFields({ config, }: SamlMetadataFormFieldsProps) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + const { setError, control, register, formState: { errors }, - } = useFormContext(); + } = useFormContext(); switch (formFormat) { case FormFormat.Manual: { @@ -107,7 +102,7 @@ function SamlMetadataFormFields({ render={({ field: { onChange, value } }) => ( <> { @@ -137,7 +132,7 @@ function SamlMetadataFormFields({ name="metadata" render={({ field: { onChange, value } }) => ( { @@ -166,7 +161,7 @@ function SamlMetadataFormFields({ validate: (value) => !value || uriValidator(value) || t('errors.invalid_uri_format'), })} - error={Boolean(errors.metadataUrl)} + error={errors.metadataUrl?.message} placeholder="https://" />
@@ -187,10 +182,15 @@ function SamlMetadataFormFields({ } } +type SamlMetadataFormProps = { + config?: SamlConnectorConfig; + providerConfig?: SamlProviderConfig; +}; + // Do not show inline notification and parsed config preview if it is on guide page. function SamlMetadataForm({ config, providerConfig }: SamlMetadataFormProps) { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { setValue } = useFormContext(); + const { setValue } = useFormContext(); const identityProviderConfig = providerConfig?.identityProvider; const isConfigEmpty = !config || Object.keys(config).length === 0; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/OidcConnectorSpInfo.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/OidcConnectorSpInfo.tsx new file mode 100644 index 000000000..3a1b3704a --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/OidcConnectorSpInfo.tsx @@ -0,0 +1,30 @@ +import { useContext } from 'react'; + +import { AppDataContext } from '@/contexts/AppDataProvider'; +import CopyToClipboard from '@/ds-components/CopyToClipboard'; +import FormField from '@/ds-components/FormField'; +import useCustomDomain from '@/hooks/use-custom-domain'; + +import * as styles from './index.module.scss'; + +type Props = { + ssoConnectorId: string; +}; + +function OidcConnectorSpInfo({ ssoConnectorId }: Props) { + const { tenantEndpoint } = useContext(AppDataContext); + const { applyDomain: applyCustomDomain } = useCustomDomain(); + + return ( + + {/* Generated and passed in by Admin console. */} + + + ); +} + +export default OidcConnectorSpInfo; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/SamlConnectorSpInfo.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/SamlConnectorSpInfo.tsx new file mode 100644 index 000000000..c0de49f3c --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/SamlConnectorSpInfo.tsx @@ -0,0 +1,48 @@ +import { conditionalString } from '@silverhand/essentials'; + +import CopyToClipboard from '@/ds-components/CopyToClipboard'; +import FormField from '@/ds-components/FormField'; +import useCustomDomain from '@/hooks/use-custom-domain'; + +import { type SamlProviderConfig } from '../../types/saml'; + +import * as styles from './index.module.scss'; + +type Props = { + samlProviderConfig?: SamlProviderConfig; +}; + +function SamlConnectorSpInfo({ samlProviderConfig }: Props) { + const { applyDomain: applyCustomDomain } = useCustomDomain(); + + /** + * Should not fallback to some other manually concatenated URL, show empty string instead. + * Empty string should never show up unless the API does not work properly. + */ + return ( + <> + + + + + + + + ); +} + +export default SamlConnectorSpInfo; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/BasicInfo/index.module.scss b/packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/index.module.scss similarity index 100% rename from packages/console/src/pages/EnterpriseSsoDetails/Connection/BasicInfo/index.module.scss rename to packages/console/src/pages/EnterpriseSsoDetails/Connection/ServiceProviderInfo/index.module.scss diff --git a/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx index 0ce4f6971..37ccabbf0 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/Connection/index.tsx @@ -1,211 +1,39 @@ -import { SsoProviderName, type RequestErrorBody } from '@logto/schemas'; -import { conditional, type Optional } from '@silverhand/essentials'; -import cleanDeep from 'clean-deep'; -import { HTTPError } from 'ky'; -import { useEffect } from 'react'; -import { useForm, FormProvider, type Path } from 'react-hook-form'; -import { toast } from 'react-hot-toast'; -import { useTranslation } from 'react-i18next'; +import { SsoProviderType, type SsoConnectorWithProviderConfig } from '@logto/schemas'; -import DetailsForm from '@/components/DetailsForm'; -import FormCard from '@/components/FormCard'; -import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; -import useApi from '@/hooks/use-api'; -import { - type SsoConnectorWithProviderConfigWithGeneric, - type ParsedSsoIdentityProviderConfig, - type GuideFormType, - type SsoConnectorConfig, - type SamlGuideFormType, -} from '@/pages/EnterpriseSso/types'; -import { trySubmitSafe } from '@/utils/form'; +import { type OidcSsoConnectorWithProviderConfig } from '../types/oidc'; +import { type SamlSsoConnectorWithProviderConfig } from '../types/saml'; -import BasicInfo from './BasicInfo'; -import OidcMetadataForm from './OidcMetadataForm'; -import SamlAttributeMapping from './SamlAttributeMapping'; -import SamlMetadataForm from './SamlMetadataForm'; -import * as styles from './index.module.scss'; +import OidcConnectorForm from './OidcConnectorForm'; +import SamlConnectorForm from './SamlConnectorForm'; -type Props = { +type Props = { isDeleted: boolean; - data: SsoConnectorWithProviderConfigWithGeneric; - onUpdated: (data: SsoConnectorWithProviderConfigWithGeneric) => void; + data: SsoConnectorWithProviderConfig; + onUpdated: (data: SsoConnectorWithProviderConfig) => void; }; -const invalidConfigErrorCode = 'connector.invalid_config'; -const invalidMetadataErrorCode = 'connector.invalid_metadata'; +function isSamlProviderData( + data: SsoConnectorWithProviderConfig +): data is SamlSsoConnectorWithProviderConfig { + return data.providerType === SsoProviderType.SAML; +} -// This component contains only `data.config`. -function Connection({ isDeleted, data, onUpdated }: Props) { - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { id: ssoConnectorId, providerName, providerConfig, config } = data; +function isOidcProviderData( + data: SsoConnectorWithProviderConfig +): data is OidcSsoConnectorWithProviderConfig { + return data.providerType === SsoProviderType.OIDC; +} - const api = useApi({ hideErrorToast: true }); +function Connection({ isDeleted, data, onUpdated }: Props) { + if (isSamlProviderData(data)) { + return ; + } - const methods = useForm>(); + if (isOidcProviderData(data)) { + return ; + } - const { - watch, - setError, - formState: { isSubmitting, isDirty }, - handleSubmit, - reset, - } = methods; - - useEffect(() => { - reset(config); - }, [config, reset]); - - const onSubmit = handleSubmit( - trySubmitSafe(async (formData) => { - if (isSubmitting) { - return; - } - - try { - const updatedSsoConnector = await api - // TODO: @darcyYe add console test case to remove attribute mapping config. - .patch(`api/sso-connectors/${ssoConnectorId}`, { - json: { config: cleanDeep(formData) }, - }) - .json>(); - - toast.success(t('general.saved')); - onUpdated(updatedSsoConnector); - - reset(updatedSsoConnector.config); - } catch (error: unknown) { - if (error instanceof HTTPError) { - const { response } = error; - const metadata = await response.clone().json(); - - // TODO: @darcyYe refactor the generic of `GuideFormType`. - // Typescript can not infer the generic `GuideFormType`, find a better way to deal with the types later. - - if (metadata.code === invalidConfigErrorCode) { - // OIDC-based SSO connector's config only relies on the result of read from `issuer` field. - if ( - [ - SsoProviderName.OIDC, - SsoProviderName.GOOGLE_WORKSPACE, - SsoProviderName.OKTA, - ].includes(providerName) - ) { - // eslint-disable-next-line no-restricted-syntax - setError('issuer' as Path>, { - type: 'custom', - message: metadata.message, - }); - } - - // OIDC-based config has been excluded in previous condition check. - // eslint-disable-next-line no-restricted-syntax - const formConfig = watch() as SamlGuideFormType; - const key = - conditional(formConfig.metadata && 'metadata') ?? - conditional(formConfig.metadataUrl && 'metadataUrl'); - if (key) { - // eslint-disable-next-line no-restricted-syntax - setError(key as Path>, { - type: 'custom', - message: metadata.message, - }); - } - } - - // Invalid metadata error only happens for SAML based SSO connectors, when trying to init IdP with XML-format metadata. - if ( - metadata.code === invalidMetadataErrorCode && - [SsoProviderName.SAML, SsoProviderName.AZURE_AD].includes(providerName) - ) { - // Typescript can not infer the generic of setError() path. - // eslint-disable-next-line no-restricted-syntax - const formConfig = watch() as SamlGuideFormType; - const key = - conditional(formConfig.metadata && 'metadata') ?? - conditional(formConfig.metadataUrl && 'metadataUrl'); - // eslint-disable-next-line no-restricted-syntax - setError(key as Path>, { type: 'custom', message: metadata.message }); - } - } - - throw error; - } - }) - ); - - return ( - - - {[SsoProviderName.OIDC, SsoProviderName.GOOGLE_WORKSPACE, SsoProviderName.OKTA].includes( - providerName - ) ? ( - - {/* Can not infer the type by narrowing down the value of `providerName`, so we need to cast it. */} - } - providerConfig={ - // eslint-disable-next-line no-restricted-syntax - providerConfig as Optional> - } - /> - - ) : ( - - {/* Can not infer the type by narrowing down the value of `providerName`, so we need to cast it. */} - {/* Modify spacing between form fields and switch button of SAML metadata form. */} -
- } - providerConfig={ - // eslint-disable-next-line no-restricted-syntax - providerConfig as Optional> - } - /> -
-
- )} - - - - {[SsoProviderName.SAML, SsoProviderName.AZURE_AD].includes(providerName) && ( - - - - )} -
- -
- ); + return null; } export default Connection; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/config.ts b/packages/console/src/pages/EnterpriseSsoDetails/config.ts new file mode 100644 index 000000000..00f44620e --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/config.ts @@ -0,0 +1,3 @@ +export const enterpriseSsoPathname = '/enterprise-sso'; +export const invalidConfigErrorCode = 'connector.invalid_config'; +export const invalidMetadataErrorCode = 'connector.invalid_metadata'; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/index.tsx b/packages/console/src/pages/EnterpriseSsoDetails/index.tsx index a17228469..9351609be 100644 --- a/packages/console/src/pages/EnterpriseSsoDetails/index.tsx +++ b/packages/console/src/pages/EnterpriseSsoDetails/index.tsx @@ -1,11 +1,9 @@ import { withAppInsights } from '@logto/app-insights/react'; -import { type SsoProviderName, type SignInExperience } from '@logto/schemas'; +import { type SignInExperience, type SsoConnectorWithProviderConfig } from '@logto/schemas'; import { pick } from '@silverhand/essentials'; import { useEffect, useState } from 'react'; -import { toast } from 'react-hot-toast'; -import { useTranslation } from 'react-i18next'; import { useLocation, useParams } from 'react-router-dom'; -import useSWR, { useSWRConfig } from 'swr'; +import useSWR from 'swr'; import Delete from '@/assets/icons/delete.svg'; import File from '@/assets/icons/file.svg'; @@ -19,52 +17,46 @@ import ConfirmModal from '@/ds-components/ConfirmModal'; import DynamicT from '@/ds-components/DynamicT'; import TabNav, { TabNavItem } from '@/ds-components/TabNav'; import type { RequestError } from '@/hooks/use-api'; -import useApi from '@/hooks/use-api'; -import useTenantPathname from '@/hooks/use-tenant-pathname'; import useUserAssetsService from '@/hooks/use-user-assets-service'; import SsoConnectorLogo from '../EnterpriseSso/SsoConnectorLogo'; -import { type SsoConnectorWithProviderConfigWithGeneric } from '../EnterpriseSso/types'; import Connection from './Connection'; import Experience from './Experience'; import SsoGuide from './SsoGuide'; +import { enterpriseSsoPathname } from './config'; import * as styles from './index.module.scss'; +import useDeleteConnector from './use-delete-connector'; -const enterpriseSsoPathname = '/enterprise-sso'; const getSsoConnectorDetailsPathname = (ssoConnectorId: string, tab: EnterpriseSsoDetailsTabs) => `${enterpriseSsoPathname}/${ssoConnectorId}/${tab}`; -function EnterpriseSsoConnectorDetails() { +function EnterpriseSsoConnectorDetails() { const { pathname } = useLocation(); const { ssoConnectorId, tab } = useParams(); - const { mutate: mutateGlobal } = useSWRConfig(); - const [isDeleted, setIsDeleted] = useState(false); + const { isDeleted, isDeleting, onDeleteHandler } = useDeleteConnector(ssoConnectorId); + const [isReadmeOpen, setIsReadmeOpen] = useState(false); const { isLoading: isUserAssetServiceLoading } = useUserAssetsService(); + const { data: signInExperience, isLoading: isSignInExperienceLoading } = useSWR('api/sign-in-exp'); - const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { data: ssoConnector, error: requestError, mutate, isLoading: isSsoConnectorLoading, - } = useSWR, RequestError>( + } = useSWR( ssoConnectorId && `api/sso-connectors/${ssoConnectorId}`, { keepPreviousData: true } ); const isLoading = isSsoConnectorLoading || isUserAssetServiceLoading || isSignInExperienceLoading; - const api = useApi(); - const { navigate } = useTenantPathname(); - const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); const isDarkModeEnabled = signInExperience?.color.isDarkModeEnabled ?? false; @@ -72,27 +64,6 @@ function EnterpriseSsoConnectorDetails() { setIsDeleteAlertOpen(false); }, [pathname]); - const handleDelete = async () => { - if (!ssoConnectorId || isDeleting) { - return; - } - setIsDeleting(true); - - try { - await api - .delete(`api/sso-connectors/${ssoConnectorId}`) - .json>(); - - setIsDeleted(true); - - toast.success(t('enterprise_sso_details.enterprise_sso_deleted')); - await mutateGlobal('api/sso-connectors'); - navigate(enterpriseSsoPathname, { replace: true }); - } finally { - setIsDeleting(false); - } - }; - if (!ssoConnectorId) { return null; } @@ -194,7 +165,7 @@ function EnterpriseSsoConnectorDetails() { onCancel={async () => { setIsDeleteAlertOpen(false); }} - onConfirm={handleDelete} + onConfirm={onDeleteHandler} > diff --git a/packages/console/src/pages/EnterpriseSsoDetails/types/oidc.ts b/packages/console/src/pages/EnterpriseSsoDetails/types/oidc.ts new file mode 100644 index 000000000..c366b5dfc --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/types/oidc.ts @@ -0,0 +1,36 @@ +import { type SsoProviderType, type SsoConnectorWithProviderConfig } from '@logto/schemas'; +import { z } from 'zod'; + +/* Oidc Connectors */ +export type OidcSsoConnectorWithProviderConfig = Omit< + SsoConnectorWithProviderConfig, + 'providerType' +> & { + providerType: SsoProviderType.OIDC; +}; + +/** + * All the following guards are copied from {@link @logto/core/packages/core/src/sso/types/oidc } + * @TODO: consider to move them to a shared package e.g. @logto/schemas + */ + +export const oidcConnectorConfigGuard = z + .object({ + clientId: z.string(), + clientSecret: z.string(), + issuer: z.string(), + scope: z.string().optional(), + }) + .partial(); + +export type OidcConnectorConfig = z.infer; + +export const oidcProviderConfigGuard = z.object({ + authorizationEndpoint: z.string(), + tokenEndpoint: z.string(), + userinfoEndpoint: z.string(), + jwksUri: z.string(), + issuer: z.string(), +}); + +export type OidcProviderConfig = z.infer; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/types/saml.ts b/packages/console/src/pages/EnterpriseSsoDetails/types/saml.ts new file mode 100644 index 000000000..0df241363 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/types/saml.ts @@ -0,0 +1,64 @@ +import { type SsoConnectorWithProviderConfig, type SsoProviderType } from '@logto/schemas'; +import { z } from 'zod'; + +/* Saml Connectors */ +export type SamlSsoConnectorWithProviderConfig = Omit< + SsoConnectorWithProviderConfig, + 'providerType' +> & { + providerType: SsoProviderType.SAML; +}; + +/** + * All the following guards are copied from {@link @logto/core/packages/core/src/sso/types/saml } + * @TODO: consider to move them to a shared package e.g. @logto/schemas + */ +const samlAttributeMappingGuard = z + .object({ + id: z.string(), + email: z.string(), + name: z.string(), + }) + .partial(); + +type SamlAttributeMapping = z.infer; + +export const samlAttributeKeys = Object.freeze(['id', 'email', 'name']) satisfies ReadonlyArray< + keyof SamlAttributeMapping +>; + +// Guard the saml connector config data from the response of the API. +export const samlConnectorConfigGuard = z + .object({ + metadataUrl: z.string(), + metadata: z.string(), + signInEndpoint: z.string(), + entityId: z.string(), + x509Certificate: z.string(), + attributeMapping: samlAttributeMappingGuard, + }) + .partial(); + +export type SamlConnectorConfig = z.infer; + +// Guard the saml provider config from the response of the API. +const samlServiceProviderMetadataGuard = z.object({ + entityId: z.string().min(1), + assertionConsumerServiceUrl: z.string().min(1), +}); + +const samlIdentityProviderMetadataGuard = z.object({ + entityId: z.string(), + signInEndpoint: z.string(), + x509Certificate: z.string(), + certificateExpiresAt: z.number(), // Timestamp in milliseconds. + isCertificateValid: z.boolean(), +}); + +export const samlProviderConfigGuard = z.object({ + defaultAttributeMapping: samlAttributeMappingGuard, + serviceProvider: samlServiceProviderMetadataGuard, + identityProvider: samlIdentityProviderMetadataGuard.optional(), +}); + +export type SamlProviderConfig = z.infer; diff --git a/packages/console/src/pages/EnterpriseSsoDetails/use-delete-connector.ts b/packages/console/src/pages/EnterpriseSsoDetails/use-delete-connector.ts new file mode 100644 index 000000000..15e1a1869 --- /dev/null +++ b/packages/console/src/pages/EnterpriseSsoDetails/use-delete-connector.ts @@ -0,0 +1,50 @@ +import { useCallback, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import { useSWRConfig } from 'swr'; + +import useApi from '@/hooks/use-api'; +import useTenantPathname from '@/hooks/use-tenant-pathname'; + +import { enterpriseSsoPathname } from './config'; + +function useDeleteConnector(ssoConnectorId?: string) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + const [isDeleted, setIsDeleted] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const api = useApi(); + const { mutate: mutateGlobal } = useSWRConfig(); + const { navigate } = useTenantPathname(); + + const onDeleteHandler = useCallback(async () => { + if (!ssoConnectorId || isDeleting) { + return; + } + + setIsDeleting(true); + + try { + await api.delete(`api/sso-connectors/${ssoConnectorId}`); + setIsDeleted(true); + + toast.success(t('enterprise_sso_details.enterprise_sso_deleted')); + + // Reset the sso-connectors data to refresh the list. + await mutateGlobal('api/sso-connectors'); + + navigate(enterpriseSsoPathname, { replace: true }); + } finally { + setIsDeleting(false); + } + }, [api, isDeleting, mutateGlobal, navigate, ssoConnectorId, t]); + + return { + isDeleted, + isDeleting, + onDeleteHandler, + }; +} + +export default useDeleteConnector; diff --git a/packages/phrases/src/locales/de/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/de/translation/admin-console/upsell/paywall.ts index b07b09356..52a435b73 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/en/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/en/translation/admin-console/upsell/paywall.ts index a77d7afb6..93030983c 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/upsell/paywall.ts @@ -54,6 +54,8 @@ const paywall = { 'Unlock organizations by upgrading to a paid plan. Don’t hesitate to contact us if you need any assistance.', third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/es/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/es/translation/admin-console/upsell/paywall.ts index c167df086..3df71345d 100644 --- a/packages/phrases/src/locales/es/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/es/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/fr/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/fr/translation/admin-console/upsell/paywall.ts index 17dcfcf76..e1ff37316 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/it/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/it/translation/admin-console/upsell/paywall.ts index f4c3dee77..76fbe24de 100644 --- a/packages/phrases/src/locales/it/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/it/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/ja/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/ja/translation/admin-console/upsell/paywall.ts index b00d144f3..ecd6df925 100644 --- a/packages/phrases/src/locales/ja/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/ja/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/ko/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/ko/translation/admin-console/upsell/paywall.ts index f53601e7a..3373b6ab6 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/pl-pl/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/pl-pl/translation/admin-console/upsell/paywall.ts index 97ffd8e93..ffd193ca4 100644 --- a/packages/phrases/src/locales/pl-pl/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/pl-pl/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/upsell/paywall.ts index 4e525000d..e3cc12b73 100644 --- a/packages/phrases/src/locales/pt-br/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/upsell/paywall.ts index 66e36fe7d..1243b2a90 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/ru/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/ru/translation/admin-console/upsell/paywall.ts index 6763cdc3e..6d13ad100 100644 --- a/packages/phrases/src/locales/ru/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/ru/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/upsell/paywall.ts index 4d320c96a..e1750692b 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/upsell/paywall.ts @@ -55,6 +55,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/upsell/paywall.ts index 50a9095c7..87b87fe6d 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/upsell/paywall.ts @@ -54,6 +54,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/zh-hk/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/zh-hk/translation/admin-console/upsell/paywall.ts index 0d565bf63..be7775432 100644 --- a/packages/phrases/src/locales/zh-hk/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/zh-hk/translation/admin-console/upsell/paywall.ts @@ -54,6 +54,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall); diff --git a/packages/phrases/src/locales/zh-tw/translation/admin-console/upsell/paywall.ts b/packages/phrases/src/locales/zh-tw/translation/admin-console/upsell/paywall.ts index a8316b11a..9cd5b878b 100644 --- a/packages/phrases/src/locales/zh-tw/translation/admin-console/upsell/paywall.ts +++ b/packages/phrases/src/locales/zh-tw/translation/admin-console/upsell/paywall.ts @@ -54,6 +54,9 @@ const paywall = { /** UNTRANSLATED */ third_party_apps: 'Unlock Logto as IdP for third-party apps by upgrading to a paid plan. For any assistance, feel free to contact us.', + /** UNTRANSLATED */ + sso_connectors: + 'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to contact us.', }; export default Object.freeze(paywall);
@@ -61,16 +47,12 @@ function SamlAttributeMapping({ providerConfig }: Props) { ) : ( )}