From 13fcf6f3c2cb40539a031272d6bee7c1a4ca48f5 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Tue, 7 Mar 2023 16:14:23 +0800 Subject: [PATCH] feat(core,console): handle connector target confict (#3251) --- .../components/ConnectorContent.tsx | 1 - .../ConnectorForm/BasicForm.module.scss | 25 ++++ .../components/ConnectorForm/BasicForm.tsx | 113 +++++++++++------- .../Connectors/components/Guide/index.tsx | 83 +++++++++---- packages/core/src/routes/connector.ts | 28 +++-- .../translation/admin-console/connectors.ts | 6 + .../translation/admin-console/connectors.ts | 6 + .../translation/admin-console/connectors.ts | 6 + .../translation/admin-console/connectors.ts | 6 + .../translation/admin-console/connectors.ts | 6 + .../translation/admin-console/connectors.ts | 6 + .../translation/admin-console/connectors.ts | 6 + .../translation/admin-console/connectors.ts | 5 + packages/schemas/src/api/error.ts | 4 +- 14 files changed, 225 insertions(+), 76 deletions(-) diff --git a/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx b/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx index c8cf7c56e..25561dae5 100644 --- a/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx +++ b/packages/console/src/pages/ConnectorDetails/components/ConnectorContent.tsx @@ -103,7 +103,6 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop learnMoreLink={getDocumentationUrl('/docs/references/connectors')} > diff --git a/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.module.scss b/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.module.scss index 8ba804715..2cd1f193c 100644 --- a/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.module.scss +++ b/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.module.scss @@ -6,6 +6,31 @@ margin-top: _.unit(0.5); } +.error { + color: var(--color-text); + font: var(--font-body-2); + margin-top: _.unit(0.5); + background-color: var(--color-danger-toast-background); + padding: _.unit(3) _.unit(4); + border-radius: 8px; + display: flex; + align-items: center; + + .icon { + margin-right: _.unit(3); + } + + .content { + span { + font-weight: bold; + } + + > ul { + padding-left: _.unit(3); + } + } +} + .fieldButton { margin-top: _.unit(2); } diff --git a/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.tsx b/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.tsx index 62595d9fb..b07390f33 100644 --- a/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.tsx +++ b/packages/console/src/pages/Connectors/components/ConnectorForm/BasicForm.tsx @@ -1,15 +1,16 @@ -import { ConnectorType } from '@logto/connector-kit'; import { useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { Trans, useTranslation } from 'react-i18next'; import CaretDown from '@/assets/images/caret-down.svg'; import CaretUp from '@/assets/images/caret-up.svg'; +import Error from '@/assets/images/toast-error.svg'; import Button from '@/components/Button'; import FormField from '@/components/FormField'; import Select from '@/components/Select'; import TextInput from '@/components/TextInput'; import TextLink from '@/components/TextLink'; +import UnnamedTrans from '@/components/UnnamedTrans'; import useDocumentationUrl from '@/hooks/use-documentation-url'; import { uriValidator } from '@/utils/validator'; @@ -21,14 +22,14 @@ type Props = { isAllowEditTarget?: boolean; isDarkDefaultVisible?: boolean; isStandard?: boolean; - connectorType: ConnectorType; + conflictConnectorName?: Record; }; const BasicForm = ({ isAllowEditTarget, isDarkDefaultVisible, - connectorType, isStandard, + conflictConnectorName, }: Props) => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { getDocumentationUrl } = useDocumentationUrl(); @@ -104,48 +105,74 @@ const BasicForm = ({ onClick={toggleDarkVisible} /> - ( - - ), - }} - > - {t('connectors.guide.target_tooltip')} - - )} - > - -
{t('connectors.guide.target_tip')}
-
)} - {connectorType === ConnectorType.Social && ( - - ( - + )} + /> +
{t('connectors.guide.sync_profile_tip')}
+
); }; diff --git a/packages/console/src/pages/Connectors/components/Guide/index.tsx b/packages/console/src/pages/Connectors/components/Guide/index.tsx index b5ad19fdf..03d564ebd 100644 --- a/packages/console/src/pages/Connectors/components/Guide/index.tsx +++ b/packages/console/src/pages/Connectors/components/Guide/index.tsx @@ -1,10 +1,11 @@ import { generateStandardId } from '@logto/core-kit'; import { isLanguageTag } from '@logto/language-kit'; -import type { ConnectorFactoryResponse, ConnectorResponse } from '@logto/schemas'; +import type { ConnectorFactoryResponse, ConnectorResponse, RequestErrorBody } from '@logto/schemas'; import { ConnectorType } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import i18next from 'i18next'; -import { useState } from 'react'; +import { HTTPError } from 'ky'; +import { useEffect, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; @@ -30,19 +31,30 @@ import { useConfigParser } from '../ConnectorForm/hooks'; import { initFormData, parseFormConfig } from '../ConnectorForm/utils'; import * as styles from './index.module.scss'; +const targetErrorCode = 'connector.multiple_target_with_same_platform'; + type Props = { connector: ConnectorFactoryResponse; onClose: () => void; }; const Guide = ({ connector, onClose }: Props) => { - const api = useApi(); + const api = useApi({ hideErrorToast: true }); const navigate = useNavigate(); const [callbackConnectorId, setCallbackConnectorId] = useState(generateStandardId()); const { updateConfigs } = useConfigs(); const parseJsonConfig = useConfigParser(); + const [conflictConnectorName, setConflictConnectorName] = useState>(); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { id: connectorId, type: connectorType, name, readme, formItems } = connector; + const { + id: connectorId, + type: connectorType, + name, + readme, + formItems, + target, + isStandard, + } = connector; const { title, content } = splitMarkdownByTitle(readme); const { language } = i18next; const connectorName = conditional(isLanguageTag(language) && name[language]) ?? name.en; @@ -59,13 +71,24 @@ const Guide = ({ connector, onClose }: Props) => { formState: { isSubmitting }, handleSubmit, watch, + setValue, + setError, } = methods; + useEffect(() => { + if (isSocialConnector && !isStandard) { + setValue('target', target); + } + }, [isSocialConnector, target, isStandard, setValue]); + const onSubmit = handleSubmit(async (data) => { if (isSubmitting) { return; } + // Recover error state + setConflictConnectorName(undefined); + const { formItems, isStandard, id: connectorId, type } = connector; const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config); const { syncProfile, name, logo, logoDark, target } = data; @@ -88,23 +111,41 @@ const Guide = ({ connector, onClose }: Props) => { ? { ...basePayload, syncProfile: syncProfile === SyncProfileMode.EachSignIn } : basePayload; - const createdConnector = await api - .post('api/connectors', { - json: payload, - }) - .json(); + try { + const createdConnector = await api + .post('api/connectors', { + json: payload, + }) + .json(); - await updateConfigs({ - ...conditional(!isSocialConnector && { passwordlessConfigured: true }), - }); + await updateConfigs({ + ...conditional(!isSocialConnector && { passwordlessConfigured: true }), + }); - onClose(); - toast.success(t('general.saved')); - navigate( - `/connectors/${isSocialConnector ? ConnectorsTabs.Social : ConnectorsTabs.Passwordless}/${ - createdConnector.id - }` - ); + onClose(); + toast.success(t('general.saved')); + navigate( + `/connectors/${isSocialConnector ? ConnectorsTabs.Social : ConnectorsTabs.Passwordless}/${ + createdConnector.id + }` + ); + } catch (error: unknown) { + if (error instanceof HTTPError) { + const { response } = error; + const metadata = await response.json< + RequestErrorBody<{ connectorName: Record }> + >(); + + if (metadata.code === targetErrorCode) { + setConflictConnectorName(metadata.data.connectorName); + setError('target', {}, { shouldFocus: true }); + + return; + } + } + + throw error; + } }); return ( @@ -135,9 +176,9 @@ const Guide = ({ connector, onClose }: Props) => {
{t('connectors.guide.general_setting')}
)} diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index a22cd37b9..943ceeec1 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -158,16 +158,26 @@ export default function connectorRoutes( if (connectorFactory.type === ConnectorType.Social) { const connectors = await getLogtoConnectors(); + const duplicateConnector = connectors + .filter(({ type }) => type === ConnectorType.Social) + .find( + ({ metadata: { target, platform } }) => + target === + (metadata ? cleanDeep(metadata).target : connectorFactory.metadata.target) && + platform === connectorFactory.metadata.platform + ); assertThat( - !connectors - .filter(({ type }) => type === ConnectorType.Social) - .some( - ({ metadata: { target, platform } }) => - target === - (metadata ? cleanDeep(metadata).target : connectorFactory.metadata.target) && - platform === connectorFactory.metadata.platform - ), - new RequestError({ code: 'connector.multiple_target_with_same_platform', status: 422 }) + !duplicateConnector, + new RequestError( + { + code: 'connector.multiple_target_with_same_platform', + status: 422, + }, + { + connectorId: duplicateConnector?.metadata.id, + connectorName: duplicateConnector?.metadata.name, + } + ) ); } diff --git a/packages/phrases/src/locales/de/translation/admin-console/connectors.ts b/packages/phrases/src/locales/de/translation/admin-console/connectors.ts index d40013f1c..ffee0f471 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/connectors.ts @@ -55,6 +55,12 @@ const connectors = { 'The value of “IdP name” can be a unique identifier string to distinguish your social identifies. This setting cannot be changed after the connector is built.', // UNTRANSLATED target_tooltip: '"Target" in Logto social connectors refers to the "source" of your social identities. In Logto design, we do not accept the same "target" of a specific platform to avoid conflicts. You should be very careful before you add a connector since you CAN NOT change its value once you create it. Learn more.', // UNTRANSLATED + target_conflict: + 'The IdP name entered matches the existing name. Using the same idp name may cause unexpected sign-in behavior where users may access the same account through two different connectors.', // UNTRANSLATED + target_conflict_line2: + 'If you\'d like to replace the current connector with the same identity provider and allow previous users to sign in without registering again, please delete the name connector and create a new one with the same "IdP name".', // UNTRANSLATED + target_conflict_line3: + 'If you\'d like to connect to a different identity provider, please modify the "IdP name" and proceed.', // UNTRANSLATED config: 'Enter your config JSON', // UNTRANSLATED sync_profile: 'Sync profile information', // UNTRANSLATED sync_profile_only_at_sign_up: 'Only sync at sign-up', // UNTRANSLATED diff --git a/packages/phrases/src/locales/en/translation/admin-console/connectors.ts b/packages/phrases/src/locales/en/translation/admin-console/connectors.ts index 5e8b859d5..d05f75943 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/connectors.ts @@ -55,6 +55,12 @@ const connectors = { 'The value of “IdP name” can be a unique identifier string to distinguish your social identifies. This setting cannot be changed after the connector is built.', target_tooltip: '"IdP name" in Logto social connectors refers to the "source" of your social identities. In Logto design, we do not accept the same "IdP name" of a specific platform to avoid conflicts. You should be very careful before you add a connector since you CAN NOT change its value once you create it. Learn more.', + target_conflict: + 'The IdP name entered matches the existing name connector. Using the same idp name may cause unexpected sign-in behavior where users may access the same account through two different connectors.', + target_conflict_line2: + 'If you\'d like to replace the current connector with the same identity provider and allow previous users to sign in without registering again, please delete the name connector and create a new one with the same "IdP name".', + target_conflict_line3: + 'If you\'d like to connect to a different identity provider, please modify the "IdP name" and proceed.', config: 'Enter your config JSON', sync_profile: 'Sync profile information', sync_profile_only_at_sign_up: 'Only sync at sign-up', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts b/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts index 3470becbd..337c0c086 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/connectors.ts @@ -56,6 +56,12 @@ const connectors = { 'The value of “IdP name” can be a unique identifier string to distinguish your social identifies. This setting cannot be changed after the connector is built.', // UNTRANSLATED target_tooltip: '"Target" in Logto social connectors refers to the "source" of your social identities. In Logto design, we do not accept the same "target" of a specific platform to avoid conflicts. You should be very careful before you add a connector since you CAN NOT change its value once you create it. Learn more.', // UNTRANSLATED + target_conflict: + 'The IdP name entered matches the existing name. Using the same idp name may cause unexpected sign-in behavior where users may access the same account through two different connectors.', // UNTRANSLATED + target_conflict_line2: + 'If you\'d like to replace the current connector with the same identity provider and allow previous users to sign in without registering again, please delete the name connector and create a new one with the same "IdP name".', // UNTRANSLATED + target_conflict_line3: + 'If you\'d like to connect to a different identity provider, please modify the "IdP name" and proceed.', // UNTRANSLATED config: 'Enter your config JSON', // UNTRANSLATED sync_profile: 'Sync profile information', // UNTRANSLATED sync_profile_only_at_sign_up: 'Only sync at sign-up', // UNTRANSLATED diff --git a/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts b/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts index 6a9aec476..044cec4d8 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/connectors.ts @@ -54,6 +54,12 @@ const connectors = { 'The value of “IdP name” can be a unique identifier string to distinguish your social identifies. This setting cannot be changed after the connector is built.', // UNTRANSLATED target_tooltip: 'Logto의 소셜 연동에서의 "목표"는 소셜 정보의 원천을 뜻해요. Logto의 디자인은 충돌을 피하기 위해서 같은 "목표"를 허용하지 않아요. 연동을 추가한 후에는 값을 변경할 수 없으므로 주의해주세요. 자세히 알아보기', + target_conflict: + 'The IdP name entered matches the existing name. Using the same idp name may cause unexpected sign-in behavior where users may access the same account through two different connectors.', // UNTRANSLATED + target_conflict_line2: + 'If you\'d like to replace the current connector with the same identity provider and allow previous users to sign in without registering again, please delete the name connector and create a new one with the same "IdP name".', // UNTRANSLATED + target_conflict_line3: + 'If you\'d like to connect to a different identity provider, please modify the "IdP name" and proceed.', // UNTRANSLATED config: '여기에 JSON을 입력', sync_profile: '프로필 정보 동기화', sync_profile_only_at_sign_up: '회원가입할 때 동기화', diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/connectors.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/connectors.ts index a764e9252..8baea7bb4 100644 --- a/packages/phrases/src/locales/pt-br/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/connectors.ts @@ -54,6 +54,12 @@ const connectors = { 'The value of “IdP name” can be a unique identifier string to distinguish your social identifies. This setting cannot be changed after the connector is built.', // UNTRANSLATED target_tooltip: '"Target" in Logto social connectors refers to the "source" of your social identities. In Logto design, we do not accept the same "target" of a specific platform to avoid conflicts. You should be very careful before you add a connector since you CAN NOT change its value once you create it. Learn more.', // UNTRANSLATED + target_conflict: + 'The IdP name entered matches the existing name. Using the same idp name may cause unexpected sign-in behavior where users may access the same account through two different connectors.', // UNTRANSLATED + target_conflict_line2: + 'If you\'d like to replace the current connector with the same identity provider and allow previous users to sign in without registering again, please delete the name connector and create a new one with the same "IdP name".', // UNTRANSLATED + target_conflict_line3: + 'If you\'d like to connect to a different identity provider, please modify the "IdP name" and proceed.', // UNTRANSLATED config: 'Digite seu JSON aqui', sync_profile: 'Sincronizar informações de perfil', sync_profile_only_at_sign_up: 'Sincronizar apenas no registro', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts index d6c90241d..7fbcdc89e 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/connectors.ts @@ -55,6 +55,12 @@ const connectors = { 'The value of “IdP name” can be a unique identifier string to distinguish your social identifies. This setting cannot be changed after the connector is built.', // UNTRANSLATED target_tooltip: '"Target" in Logto social connectors refers to the "source" of your social identities. In Logto design, we do not accept the same "target" of a specific platform to avoid conflicts. You should be very careful before you add a connector since you CAN NOT change its value once you create it. Learn more.', // UNTRANSLATED + target_conflict: + 'The IdP name entered matches the existing name. Using the same idp name may cause unexpected sign-in behavior where users may access the same account through two different connectors.', // UNTRANSLATED + target_conflict_line2: + 'If you\'d like to replace the current connector with the same identity provider and allow previous users to sign in without registering again, please delete the name connector and create a new one with the same "IdP name".', // UNTRANSLATED + target_conflict_line3: + 'If you\'d like to connect to a different identity provider, please modify the "IdP name" and proceed.', // UNTRANSLATED config: 'Enter your config JSON', // UNTRANSLATED sync_profile: 'Sync profile information', // UNTRANSLATED sync_profile_only_at_sign_up: 'Only sync at sign-up', // UNTRANSLATED diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts index 5d2a88315..803ee0596 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/connectors.ts @@ -56,6 +56,12 @@ const connectors = { 'The value of “IdP name” can be a unique identifier string to distinguish your social identifies. This setting cannot be changed after the connector is built.', // UNTRANSLATED target_tooltip: '"Target" in Logto social connectors refers to the "source" of your social identities. In Logto design, we do not accept the same "target" of a specific platform to avoid conflicts. You should be very careful before you add a connector since you CAN NOT change its value once you create it. Learn more.', // UNTRANSLATED + target_conflict: + 'The IdP name entered matches the existing name. Using the same idp name may cause unexpected sign-in behavior where users may access the same account through two different connectors.', // UNTRANSLATED + target_conflict_line2: + 'If you\'d like to replace the current connector with the same identity provider and allow previous users to sign in without registering again, please delete the name connector and create a new one with the same "IdP name".', // UNTRANSLATED + target_conflict_line3: + 'If you\'d like to connect to a different identity provider, please modify the "IdP name" and proceed.', // UNTRANSLATED config: 'Enter your config JSON', // UNTRANSLATED sync_profile: 'Sync profile information', // UNTRANSLATED sync_profile_only_at_sign_up: 'Only sync at sign-up', // UNTRANSLATED diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts index b035a960a..5300c2bef 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/connectors.ts @@ -51,6 +51,11 @@ const connectors = { '在“身份供应商名称”字段中输入唯一的标识符字符串,用于区分社交身份来源。注意,在连接器创建成功后,无法再次修改此设置。', target_tooltip: 'Logto 社交连接器的「target」指的是社交身份的「来源」。在 Logto 的设计里,我们不允许某一平台的连接器中有相同的「target」以避免身份的冲突。在添加连接器时,你需要格外小心,我们「不允许」用户在创建之后更改「target」的值。 了解更多', + target_conflict: + '此「身份供应商名称」值与现有的 name 连接器相同。使用相同的身份供应商名称会导致不符合预期的登录行为,用户可能通过两个不同的连接器访问同一个帐户。', + target_conflict_line2: + '如果你想替换当前的连接器,并连接相同的身份提供商(IdP),以便先前的用户可以直接登录而无需重新注册,请先删除 name 连接器,再创建一个新的连接器并使用相同的「身份供应商名称」值。', + target_conflict_line3: '如果您想连接一个新的身份验证提供程序,请修改「身份供应商名称」并继续。', config: '粘贴你的 JSON 代码', sync_profile: '开启用户资料同步', sync_profile_only_at_sign_up: '首次注册时同步', diff --git a/packages/schemas/src/api/error.ts b/packages/schemas/src/api/error.ts index 89e07c384..f2948a640 100644 --- a/packages/schemas/src/api/error.ts +++ b/packages/schemas/src/api/error.ts @@ -6,9 +6,9 @@ export type RequestErrorMetadata = Record & { expose?: boolean; }; -export type RequestErrorBody = { +export type RequestErrorBody = { message: string; - data: unknown; + data: T; code: LogtoErrorCode; details?: string; };