0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

refactor(console): refactor sso detail pages (#5551)

* refactor(console): refactor sso detail pages

refactor sso details pages

* fix(console): fix the sso paywall guard content

fix the sso paywall guard content
This commit is contained in:
simeng-li 2024-03-27 14:01:41 +08:00 committed by GitHub
parent affcecd0c6
commit ba16d1cf60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 641 additions and 474 deletions

View file

@ -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: <ContactUsPhraseLink />,
}}
>
{t('upsell.paywall.organizations')}
{t('upsell.paywall.sso_connectors')}
</Trans>
</QuotaGuardFooter>
)

View file

@ -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> = 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> =
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<T extends SsoProviderName> = GuideFormType<T>;
// Help the Guide component type to be inferred from the connector's type.
export type SsoConnectorWithProviderConfigWithGeneric<T extends SsoProviderName> = Omit<
SsoConnectorWithProviderConfig,
'config' | 'providerName' | 'providerConfig'
> & {
providerName: T;
providerConfig?: ParsedSsoIdentityProviderConfig<T>;
config: SsoConnectorConfig<T>;
};

View file

@ -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<SsoConnectorWithProviderConfig, 'providerName' | 'providerConfig'>;
/**
* 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 (
<FormField title="enterprise_sso.basic_info.oidc.redirect_uri_field_name">
{/* Generated and passed in by Admin console. */}
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={applyCustomDomain(
new URL(`/callback/${ssoConnectorId}`, tenantEndpoint).toString()
)}
/>
</FormField>
);
}
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 (
<>
<FormField title="enterprise_sso.basic_info.saml.acs_url_field_name">
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={conditionalString(
result.success &&
result.data.serviceProvider.assertionConsumerServiceUrl &&
applyCustomDomain(result.data.serviceProvider.assertionConsumerServiceUrl)
)}
/>
</FormField>
<FormField title="enterprise_sso.basic_info.saml.audience_uri_field_name">
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={conditionalString(
result.success &&
result.data.serviceProvider.entityId &&
applyCustomDomain(result.data.serviceProvider.entityId)
)}
/>
</FormField>
</>
);
}
export default BasicInfo;

View file

@ -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<OidcConnectorConfig>();
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<OidcSsoConnectorWithProviderConfig>();
toast.success(t('general.saved'));
onUpdated(result);
reset(result.config);
} catch (error: unknown) {
if (error instanceof HTTPError) {
const errorBody = await error.response.clone().json<RequestErrorBody>();
// 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 (
<FormProvider {...methods}>
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
>
<FormCard
title="enterprise_sso_details.upload_idp_metadata_title_oidc"
description="enterprise_sso_details.upload_idp_metadata_description_oidc"
>
{/* Can not infer the type by narrowing down the value of `providerName`, so we need to cast it. */}
<OidcMetadataForm
providerName={providerName}
config={oidcConnectorConfig}
providerConfig={oidcProviderConfig}
/>
</FormCard>
<FormCard
title="enterprise_sso_details.service_provider_property_title"
description="enterprise_sso_details.service_provider_property_description"
descriptionInterpolation={{
protocol: 'OIDC',
}}
>
<OidcConnectorSpInfo ssoConnectorId={connectorId} />
</FormCard>
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
</FormProvider>
);
}
export default OidcConnectorForm;

View file

@ -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<SsoProviderName.OIDC>;
providerConfig: OidcProviderConfig;
className?: string;
};

View file

@ -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<SsoProviderName.OIDC>;
config?: SsoConnectorConfig<SsoProviderName.OIDC>;
providerConfig?: OidcProviderConfig;
config?: OidcConnectorConfig;
providerName: SsoProviderName;
};
@ -28,7 +25,7 @@ function OidcMetadataForm({ providerConfig, config, providerName }: Props) {
const {
register,
formState: { errors },
} = useFormContext<OidcGuideFormType>();
} = useFormContext<OidcConnectorConfig>();
const isConfigEmpty = !config || Object.keys(config).length === 0;

View file

@ -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<SsoConnectorWithProviderConfig, 'providerConfig'>;
/**
* 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<SamlGuideFormType>();
const result = providerPropertiesGuard.safeParse(providerConfig);
function SamlAttributeMapping({ samlProviderConfig }: Props) {
const { register } = useFormContext<SamlConnectorConfig>();
return (
<table className={styles.table}>
@ -48,7 +34,7 @@ function SamlAttributeMapping({ providerConfig }: Props) {
</tr>
</thead>
<tbody className={styles.body}>
{attributeKeys.map((key) => {
{samlAttributeKeys.map((key) => {
return (
<tr key={key} className={styles.row}>
<td>
@ -61,16 +47,12 @@ function SamlAttributeMapping({ providerConfig }: Props) {
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={conditionalString(
result.success && result.data.defaultAttributeMapping[key]
)}
value={conditionalString(samlProviderConfig?.defaultAttributeMapping[key])}
/>
) : (
<TextInput
{...register(`${primaryKey}.${key}`)}
placeholder={conditional(
result.success && result.data.defaultAttributeMapping[key]
)}
placeholder={conditional(samlProviderConfig?.defaultAttributeMapping[key])}
/>
)}
</td>

View file

@ -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<SamlConnectorConfig>();
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<SamlSsoConnectorWithProviderConfig>();
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<RequestErrorBody>();
// 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 (
<FormProvider {...methods}>
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
>
<FormCard
title="enterprise_sso_details.upload_idp_metadata_title_saml"
description="enterprise_sso_details.upload_idp_metadata_description_saml"
>
<div className={styles.samlMetadataForm}>
<SamlMetadataForm config={samlConnectorConfig} providerConfig={samlProviderConfig} />
</div>
</FormCard>
<FormCard
title="enterprise_sso_details.service_provider_property_title"
description="enterprise_sso_details.service_provider_property_description"
descriptionInterpolation={{
protocol: 'SAML 2.0',
}}
>
<SamlConnectorSpInfo samlProviderConfig={samlProviderConfig} />
</FormCard>
<FormCard
title="enterprise_sso_details.attribute_mapping_title"
description="enterprise_sso_details.attribute_mapping_description"
>
<SamlAttributeMapping samlProviderConfig={samlProviderConfig} />
</FormCard>
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
</FormProvider>
);
}
export default SamlConnectorForm;

View file

@ -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<SsoProviderName.SAML>['identityProvider'];
identityProviderConfig: SamlProviderConfig['identityProvider'];
};
type CertificatePreviewProps = {

View file

@ -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<SamlMetadataFormProps, 'config'> & {
identityProviderConfig?: ParsedSsoIdentityProviderConfig<SsoProviderName.SAML>['identityProvider'];
identityProviderConfig?: SamlProviderConfig['identityProvider'];
formFormat: FormFormat;
};
type SamlMetadataFormProps = {
config?: SsoConnectorConfig<SsoProviderName.SAML>;
providerConfig?: ParsedSsoIdentityProviderConfig<SsoProviderName.SAML>;
};
type FileValueKeyType = keyof Pick<SamlConnectorConfig, 'metadata' | 'x509Certificate'>; // I.e. 'metadata' | 'x509Certificate'.
type KeyType = keyof Pick<SamlGuideFormType, 'metadata' | 'x509Certificate'>; // I.e. 'metadata' | 'x509Certificate'.
const keyToAttributes: Record<KeyType, FileReaderProps['attributes']> = {
const fileReaderAttributesMap: Record<FileValueKeyType, FileReaderProps['attributes']> = {
// 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<SamlGuideFormType>();
} = useFormContext<SamlConnectorConfig>();
switch (formFormat) {
case FormFormat.Manual: {
@ -107,7 +102,7 @@ function SamlMetadataFormFields({
render={({ field: { onChange, value } }) => (
<>
<FileReader
attributes={keyToAttributes.x509Certificate}
attributes={fileReaderAttributesMap.x509Certificate}
value={value}
fieldError={errors.x509Certificate}
setError={(error) => {
@ -137,7 +132,7 @@ function SamlMetadataFormFields({
name="metadata"
render={({ field: { onChange, value } }) => (
<FileReader
attributes={keyToAttributes.metadata}
attributes={fileReaderAttributesMap.metadata}
value={value}
fieldError={errors.metadata}
setError={(error) => {
@ -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://"
/>
<div className={styles.description}>
@ -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<SamlGuideFormType>();
const { setValue } = useFormContext<SamlConnectorConfig>();
const identityProviderConfig = providerConfig?.identityProvider;
const isConfigEmpty = !config || Object.keys(config).length === 0;

View file

@ -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 (
<FormField title="enterprise_sso.basic_info.oidc.redirect_uri_field_name">
{/* Generated and passed in by Admin console. */}
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={applyCustomDomain(new URL(`/callback/${ssoConnectorId}`, tenantEndpoint).toString())}
/>
</FormField>
);
}
export default OidcConnectorSpInfo;

View file

@ -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 (
<>
<FormField title="enterprise_sso.basic_info.saml.acs_url_field_name">
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={conditionalString(
samlProviderConfig?.serviceProvider &&
applyCustomDomain(samlProviderConfig.serviceProvider.assertionConsumerServiceUrl)
)}
/>
</FormField>
<FormField title="enterprise_sso.basic_info.saml.audience_uri_field_name">
<CopyToClipboard
className={styles.copyToClipboard}
variant="border"
value={conditionalString(
samlProviderConfig?.serviceProvider &&
applyCustomDomain(samlProviderConfig.serviceProvider.entityId)
)}
/>
</FormField>
</>
);
}
export default SamlConnectorSpInfo;

View file

@ -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<T extends SsoProviderName> = {
type Props = {
isDeleted: boolean;
data: SsoConnectorWithProviderConfigWithGeneric<T>;
onUpdated: (data: SsoConnectorWithProviderConfigWithGeneric<T>) => 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<T extends SsoProviderName>({ isDeleted, data, onUpdated }: Props<T>) {
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 <SamlConnectorForm isDeleted={isDeleted} data={data} onUpdated={onUpdated} />;
}
const methods = useForm<GuideFormType<T>>();
if (isOidcProviderData(data)) {
return <OidcConnectorForm isDeleted={isDeleted} data={data} onUpdated={onUpdated} />;
}
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<SsoConnectorWithProviderConfigWithGeneric<T>>();
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<RequestErrorBody>();
// TODO: @darcyYe refactor the generic of `GuideFormType<T>`.
// Typescript can not infer the generic `GuideFormType<T>`, 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<GuideFormType<T>>, {
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<GuideFormType<T>>, {
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<GuideFormType<T>>, { type: 'custom', message: metadata.message });
}
}
throw error;
}
})
);
return (
<FormProvider {...methods}>
<DetailsForm
isDirty={isDirty}
isSubmitting={isSubmitting}
onDiscard={reset}
onSubmit={onSubmit}
>
{[SsoProviderName.OIDC, SsoProviderName.GOOGLE_WORKSPACE, SsoProviderName.OKTA].includes(
providerName
) ? (
<FormCard
title="enterprise_sso_details.upload_idp_metadata_title_oidc"
description="enterprise_sso_details.upload_idp_metadata_description_oidc"
>
{/* Can not infer the type by narrowing down the value of `providerName`, so we need to cast it. */}
<OidcMetadataForm
providerName={providerName}
// eslint-disable-next-line no-restricted-syntax
config={config as SsoConnectorConfig<SsoProviderName.OIDC>}
providerConfig={
// eslint-disable-next-line no-restricted-syntax
providerConfig as Optional<ParsedSsoIdentityProviderConfig<SsoProviderName.OIDC>>
}
/>
</FormCard>
) : (
<FormCard
title="enterprise_sso_details.upload_idp_metadata_title_saml"
description="enterprise_sso_details.upload_idp_metadata_description_saml"
>
{/* 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. */}
<div className={styles.samlMetadataForm}>
<SamlMetadataForm
// eslint-disable-next-line no-restricted-syntax
config={config as SsoConnectorConfig<SsoProviderName.SAML>}
providerConfig={
// eslint-disable-next-line no-restricted-syntax
providerConfig as Optional<ParsedSsoIdentityProviderConfig<SsoProviderName.SAML>>
}
/>
</div>
</FormCard>
)}
<FormCard
title="enterprise_sso_details.service_provider_property_title"
description="enterprise_sso_details.service_provider_property_description"
descriptionInterpolation={{
protocol: [SsoProviderName.SAML, SsoProviderName.AZURE_AD].includes(providerName)
? 'SAML 2.0'
: 'OIDC',
}}
>
<BasicInfo
ssoConnectorId={ssoConnectorId}
providerName={providerName}
providerConfig={providerConfig}
/>
</FormCard>
{[SsoProviderName.SAML, SsoProviderName.AZURE_AD].includes(providerName) && (
<FormCard
title="enterprise_sso_details.attribute_mapping_title"
description="enterprise_sso_details.attribute_mapping_description"
>
<SamlAttributeMapping providerConfig={providerConfig} />
</FormCard>
)}
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={!isDeleted && isDirty} />
</FormProvider>
);
return null;
}
export default Connection;

View file

@ -0,0 +1,3 @@
export const enterpriseSsoPathname = '/enterprise-sso';
export const invalidConfigErrorCode = 'connector.invalid_config';
export const invalidMetadataErrorCode = 'connector.invalid_metadata';

View file

@ -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<T extends SsoProviderName>() {
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<SignInExperience>('api/sign-in-exp');
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
data: ssoConnector,
error: requestError,
mutate,
isLoading: isSsoConnectorLoading,
} = useSWR<SsoConnectorWithProviderConfigWithGeneric<T>, RequestError>(
} = useSWR<SsoConnectorWithProviderConfig, RequestError>(
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<T extends SsoProviderName>() {
setIsDeleteAlertOpen(false);
}, [pathname]);
const handleDelete = async () => {
if (!ssoConnectorId || isDeleting) {
return;
}
setIsDeleting(true);
try {
await api
.delete(`api/sso-connectors/${ssoConnectorId}`)
.json<SsoConnectorWithProviderConfigWithGeneric<T>>();
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<T extends SsoProviderName>() {
onCancel={async () => {
setIsDeleteAlertOpen(false);
}}
onConfirm={handleDelete}
onConfirm={onDeleteHandler}
>
<DynamicT forKey="enterprise_sso_details.delete_confirm_modal_content" />
</ConfirmModal>

View file

@ -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<typeof oidcConnectorConfigGuard>;
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<typeof oidcProviderConfigGuard>;

View file

@ -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<typeof samlAttributeMappingGuard>;
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<typeof samlConnectorConfigGuard>;
// 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<typeof samlProviderConfigGuard>;

View file

@ -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;

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -54,6 +54,8 @@ const paywall = {
'Unlock organizations by upgrading to a paid plan. Dont hesitate to <a>contact us</a> 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 <a>contact us</a>.',
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);

View file

@ -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 <a>contact us</a>.',
/** UNTRANSLATED */
sso_connectors:
'Unlock enterprise sso by upgrading to a paid plan. For any assistance, feel free to <a>contact us</a>.',
};
export default Object.freeze(paywall);