0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(core,console): handle connector target confict (#3251)

This commit is contained in:
wangsijie 2023-03-07 16:14:23 +08:00 committed by GitHub
parent f25a9d343c
commit 13fcf6f3c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 225 additions and 76 deletions

View file

@ -103,7 +103,6 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
learnMoreLink={getDocumentationUrl('/docs/references/connectors')}
>
<BasicForm
connectorType={connectorData.type}
isStandard={connectorData.isStandard}
isDarkDefaultVisible={Boolean(connectorData.metadata.logoDark)}
/>

View file

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

View file

@ -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<string, string>;
};
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}
/>
</div>
<FormField
isRequired
title="connectors.guide.target"
tip={(closeTipHandler) => (
<Trans
components={{
a: (
<TextLink
href={getDocumentationUrl('/docs/references/connectors/#target')}
target="_blank"
onClick={closeTipHandler}
/>
),
}}
>
{t('connectors.guide.target_tooltip')}
</Trans>
)}
>
<TextInput
placeholder={t('connectors.guide.target_placeholder')}
hasError={Boolean(errors.target)}
disabled={!isAllowEditTarget}
{...register('target', { required: true })}
/>
<div className={styles.tip}>{t('connectors.guide.target_tip')}</div>
</FormField>
</>
)}
{connectorType === ConnectorType.Social && (
<FormField title="connectors.guide.sync_profile">
<Controller
name="syncProfile"
control={control}
rules={{ required: true }}
render={({ field: { onChange, value } }) => (
<Select options={syncProfileOptions} value={value} onChange={onChange} />
)}
/>
<div className={styles.tip}>{t('connectors.guide.sync_profile_tip')}</div>
</FormField>
)}
<FormField
isRequired
title="connectors.guide.target"
tip={(closeTipHandler) => (
<Trans
components={{
a: (
<TextLink
href={getDocumentationUrl('/docs/references/connectors/#target')}
target="_blank"
onClick={closeTipHandler}
/>
),
}}
>
{t('connectors.guide.target_tooltip')}
</Trans>
)}
>
<TextInput
placeholder={t('connectors.guide.target_placeholder')}
hasError={Boolean(errors.target)}
disabled={!isAllowEditTarget}
{...register('target', { required: true })}
/>
<div className={styles.tip}>{t('connectors.guide.target_tip')}</div>
{conflictConnectorName && (
<div className={styles.error}>
<div className={styles.icon}>
<Error />
</div>
<div className={styles.content}>
<Trans
components={{
span: <UnnamedTrans resource={conflictConnectorName} />,
}}
>
{t('connectors.guide.target_conflict')}
</Trans>
<ul>
<li>
<Trans
components={{
span: <UnnamedTrans resource={conflictConnectorName} />,
}}
>
{t('connectors.guide.target_conflict_line2')}
</Trans>
</li>
<li>{t('connectors.guide.target_conflict_line3')}</li>
</ul>
</div>
</div>
)}
</FormField>
<FormField title="connectors.guide.sync_profile">
<Controller
name="syncProfile"
control={control}
rules={{ required: true }}
render={({ field: { onChange, value } }) => (
<Select options={syncProfileOptions} value={value} onChange={onChange} />
)}
/>
<div className={styles.tip}>{t('connectors.guide.sync_profile_tip')}</div>
</FormField>
</div>
);
};

View file

@ -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<string>(generateStandardId());
const { updateConfigs } = useConfigs();
const parseJsonConfig = useConfigParser();
const [conflictConnectorName, setConflictConnectorName] = useState<Record<string, string>>();
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<ConnectorResponse>();
try {
const createdConnector = await api
.post('api/connectors', {
json: payload,
})
.json<ConnectorResponse>();
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<string, string> }>
>();
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) => {
<div>{t('connectors.guide.general_setting')}</div>
</div>
<BasicForm
isAllowEditTarget
connectorType={connector.type}
isAllowEditTarget={connector.isStandard}
isStandard={connector.isStandard}
conflictConnectorName={conflictConnectorName}
/>
</div>
)}

View file

@ -158,16 +158,26 @@ export default function connectorRoutes<T extends AuthedRouter>(
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,
}
)
);
}

View file

@ -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. <a>Learn more.</a>', // UNTRANSLATED
target_conflict:
'The IdP name entered matches the existing <span>name</span>. 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 <span>name</span> 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

View file

@ -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. <a>Learn more.</a>',
target_conflict:
'The IdP name entered matches the existing <span>name</span> 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 <span>name</span> 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',

View file

@ -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. <a>Learn more.</a>', // UNTRANSLATED
target_conflict:
'The IdP name entered matches the existing <span>name</span>. 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 <span>name</span> 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

View file

@ -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의 디자인은 충돌을 피하기 위해서 같은 "목표"를 허용하지 않아요. 연동을 추가한 후에는 값을 변경할 수 없으므로 주의해주세요. <a>자세히 알아보기</a>',
target_conflict:
'The IdP name entered matches the existing <span>name</span>. 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 <span>name</span> 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: '회원가입할 때 동기화',

View file

@ -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. <a>Learn more.</a>', // UNTRANSLATED
target_conflict:
'The IdP name entered matches the existing <span>name</span>. 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 <span>name</span> 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',

View file

@ -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. <a>Learn more.</a>', // UNTRANSLATED
target_conflict:
'The IdP name entered matches the existing <span>name</span>. 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 <span>name</span> 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

View file

@ -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. <a>Learn more.</a>', // UNTRANSLATED
target_conflict:
'The IdP name entered matches the existing <span>name</span>. 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 <span>name</span> 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

View file

@ -51,6 +51,11 @@ const connectors = {
'在“身份供应商名称”字段中输入唯一的标识符字符串,用于区分社交身份来源。注意,在连接器创建成功后,无法再次修改此设置。',
target_tooltip:
'Logto 社交连接器的「target」指的是社交身份的「来源」。在 Logto 的设计里我们不允许某一平台的连接器中有相同的「target」以避免身份的冲突。在添加连接器时你需要格外小心我们「不允许」用户在创建之后更改「target」的值。 <a>了解更多</a>',
target_conflict:
'此「身份供应商名称」值与现有的 <span>name</span> 连接器相同。使用相同的身份供应商名称会导致不符合预期的登录行为,用户可能通过两个不同的连接器访问同一个帐户。',
target_conflict_line2:
'如果你想替换当前的连接器并连接相同的身份提供商IdP以便先前的用户可以直接登录而无需重新注册请先删除 <span>name</span> 连接器,再创建一个新的连接器并使用相同的「身份供应商名称」值。',
target_conflict_line3: '如果您想连接一个新的身份验证提供程序,请修改「身份供应商名称」并继续。',
config: '粘贴你的 JSON 代码',
sync_profile: '开启用户资料同步',
sync_profile_only_at_sign_up: '首次注册时同步',

View file

@ -6,9 +6,9 @@ export type RequestErrorMetadata = Record<string, unknown> & {
expose?: boolean;
};
export type RequestErrorBody = {
export type RequestErrorBody<T = unknown> = {
message: string;
data: unknown;
data: T;
code: LogtoErrorCode;
details?: string;
};