mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(console): connector sender test (#367)
This commit is contained in:
parent
29b52b2a88
commit
043b20a05a
11 changed files with 361 additions and 21 deletions
|
@ -9,6 +9,7 @@ type Props<T> = {
|
|||
content: ReactNode;
|
||||
domRef: RefObject<Nullable<T>>;
|
||||
className?: string;
|
||||
behavior?: 'visibleOnHover' | 'visibleByDefault';
|
||||
};
|
||||
|
||||
type Position = {
|
||||
|
@ -16,10 +17,15 @@ type Position = {
|
|||
left: number;
|
||||
};
|
||||
|
||||
const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) => {
|
||||
const Tooltip = <T extends Element>({
|
||||
content,
|
||||
domRef,
|
||||
className,
|
||||
behavior = 'visibleOnHover',
|
||||
}: Props<T>) => {
|
||||
const [tooltipDom, setTooltipDom] = useState<HTMLDivElement>();
|
||||
const [position, setPosition] = useState<Position>();
|
||||
const isVisible = position !== undefined;
|
||||
const positionCaculated = position !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!domRef.current) {
|
||||
|
@ -28,6 +34,14 @@ const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) =>
|
|||
|
||||
const dom = domRef.current;
|
||||
|
||||
if (behavior === 'visibleByDefault') {
|
||||
const { top, left, width } = domRef.current.getBoundingClientRect();
|
||||
const { scrollTop, scrollLeft } = document.documentElement;
|
||||
setPosition({ top: top + scrollTop - 12, left: left + scrollLeft + width / 2 });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const enterHandler = () => {
|
||||
if (domRef.current) {
|
||||
const { top, left, width } = domRef.current.getBoundingClientRect();
|
||||
|
@ -47,10 +61,10 @@ const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) =>
|
|||
dom.removeEventListener('mouseenter', enterHandler);
|
||||
dom.removeEventListener('mouseleave', leaveHandler);
|
||||
};
|
||||
}, [domRef]);
|
||||
}, [domRef, behavior]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
if (!positionCaculated) {
|
||||
if (tooltipDom) {
|
||||
tooltipDom.remove();
|
||||
setTooltipDom(undefined);
|
||||
|
@ -66,7 +80,7 @@ const Tooltip = <T extends Element>({ content, domRef, className }: Props<T>) =>
|
|||
}
|
||||
|
||||
return () => tooltipDom?.remove();
|
||||
}, [isVisible, tooltipDom]);
|
||||
}, [positionCaculated, tooltipDom]);
|
||||
|
||||
if (!tooltipDom || !position) {
|
||||
return null;
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-bottom: _.unit(1);
|
||||
|
||||
.textField {
|
||||
@include _.form-text-field;
|
||||
}
|
||||
|
||||
.send {
|
||||
margin-left: _.unit(1);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.description {
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-component-caption);
|
||||
}
|
||||
|
||||
div.successTooltip {
|
||||
background: #008a71;
|
||||
color: #fff;
|
||||
|
||||
&::after {
|
||||
border-top-color: #008a71;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
import { ConnectorType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import ky from 'ky';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
import FormField from '@/components/FormField';
|
||||
import TextInput from '@/components/TextInput';
|
||||
import Tooltip from '@/components/Tooltip';
|
||||
import { phoneRegEx, emailRegEx } from '@/utils/regex';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
connectorType: Exclude<ConnectorType, ConnectorType.Social>;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
sendTo: string;
|
||||
};
|
||||
|
||||
const SenderTester = ({ connectorType }: Props) => {
|
||||
const buttonPosReference = useRef(null);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: {
|
||||
errors: { sendTo: inputError },
|
||||
},
|
||||
} = useForm<FormData>();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const isSms = connectorType === ConnectorType.SMS;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTooltip) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tooltipTimeout = setTimeout(() => {
|
||||
setShowTooltip(false);
|
||||
setSubmitting(false);
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(tooltipTimeout);
|
||||
};
|
||||
}, [showTooltip]);
|
||||
|
||||
const onSubmit = handleSubmit(async (formData) => {
|
||||
const { sendTo } = formData;
|
||||
setSubmitting(true);
|
||||
|
||||
const data = isSms ? { phone: sendTo } : { email: sendTo };
|
||||
|
||||
try {
|
||||
await ky
|
||||
.post(`/api/connectors/test/${connectorType.toLowerCase()}`, {
|
||||
json: data,
|
||||
})
|
||||
.json();
|
||||
setShowTooltip(true);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className={styles.fields}>
|
||||
<FormField
|
||||
isRequired
|
||||
title={
|
||||
isSms
|
||||
? 'admin_console.connector_details.test_sms_sender'
|
||||
: 'admin_console.connector_details.test_email_sender'
|
||||
}
|
||||
className={styles.textField}
|
||||
>
|
||||
<TextInput
|
||||
hasError={Boolean(inputError?.message)}
|
||||
{...register('sendTo', {
|
||||
required: true,
|
||||
pattern: {
|
||||
value: isSms ? phoneRegEx : emailRegEx,
|
||||
message: t('connector_details.send_error_invalid_format'),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<div ref={buttonPosReference} className={styles.send}>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
disabled={submitting}
|
||||
title="admin_console.connector_details.send"
|
||||
type="primary"
|
||||
/>
|
||||
</div>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
behavior="visibleByDefault"
|
||||
domRef={buttonPosReference}
|
||||
className={styles.successTooltip}
|
||||
content={t('connector_details.test_message_sent')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={classNames(inputError?.message ? styles.error : styles.description)}>
|
||||
{inputError?.message ? inputError.message : t('connector_details.test_sender_description')}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SenderTester;
|
|
@ -50,6 +50,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.container .body {
|
||||
> :not(:first-child) {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
||||
.readme {
|
||||
background: var(--color-card-background);
|
||||
padding: _.unit(6);
|
||||
|
@ -65,10 +71,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.space {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.actions {
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ConnectorDTO, RequestErrorBody } from '@logto/schemas';
|
||||
import { ConnectorDTO, ConnectorType, RequestErrorBody } from '@logto/schemas';
|
||||
import ky, { HTTPError } from 'ky';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
@ -18,6 +18,7 @@ import Close from '@/icons/Close';
|
|||
import * as drawerStyles from '@/scss/drawer.module.scss';
|
||||
import { RequestError } from '@/swr';
|
||||
|
||||
import SenderTester from './components/SenderTester';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const ConnectorDetails = () => {
|
||||
|
@ -125,13 +126,12 @@ const ConnectorDetails = () => {
|
|||
</Card>
|
||||
)}
|
||||
{data && (
|
||||
<Card>
|
||||
<Card className={styles.body}>
|
||||
<TabNav>
|
||||
<TabNavLink href={`/connectors/${connectorId ?? ''}`}>
|
||||
{t('connector_details.tab_settings')}
|
||||
</TabNavLink>
|
||||
</TabNav>
|
||||
<div className={styles.space} />
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={config}
|
||||
|
@ -139,6 +139,9 @@ const ConnectorDetails = () => {
|
|||
setConfig(value);
|
||||
}}
|
||||
/>
|
||||
{data.metadata.type !== ConnectorType.Social && (
|
||||
<SenderTester connectorType={data.metadata.type} />
|
||||
)}
|
||||
{saveError && <div>{saveError}</div>}
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
|
|
3
packages/console/src/utils/regex.ts
Normal file
3
packages/console/src/utils/regex.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
// TODO - LOG-1876: Share Regex Between Logto Core and Front-End
|
||||
export const emailRegEx = /^\S+@\S+\.\S+$/;
|
||||
export const phoneRegEx = /^[1-9]\d{10}$/;
|
|
@ -36,7 +36,7 @@ export interface SocialConnector extends BaseConnector {
|
|||
|
||||
export type SocialConnectorInstance = SocialConnector & { connector: Connector };
|
||||
|
||||
type EmailMessageTypes = {
|
||||
export type EmailMessageTypes = {
|
||||
SignIn: {
|
||||
code: string;
|
||||
};
|
||||
|
|
|
@ -4,6 +4,8 @@ import {
|
|||
ConnectorError,
|
||||
ConnectorErrorCodes,
|
||||
ConnectorMetadata,
|
||||
EmailConnectorInstance,
|
||||
EmailMessageTypes,
|
||||
ValidateConfig,
|
||||
} from '@/connectors/types';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
|
@ -26,6 +28,9 @@ const findConnectorByIdPlaceHolder = jest.fn() as jest.MockedFunction<
|
|||
const getConnectorInstanceByIdPlaceHolder = jest.fn() as jest.MockedFunction<
|
||||
(connectorId: string) => Promise<ConnectorInstance>
|
||||
>;
|
||||
const getConnectorInstanceByTypePlaceHolder = jest.fn() as jest.MockedFunction<
|
||||
(type: ConnectorType) => Promise<ConnectorInstance>
|
||||
>;
|
||||
const getConnectorInstancesPlaceHolder = jest.fn() as jest.MockedFunction<
|
||||
() => Promise<ConnectorInstance[]>
|
||||
>;
|
||||
|
@ -38,6 +43,8 @@ jest.mock('@/queries/connector', () => ({
|
|||
jest.mock('@/connectors', () => ({
|
||||
getConnectorInstanceById: async (connectorId: string) =>
|
||||
getConnectorInstanceByIdPlaceHolder(connectorId),
|
||||
getConnectorInstanceByType: async (type: ConnectorType) =>
|
||||
getConnectorInstanceByTypePlaceHolder(type),
|
||||
getConnectorInstances: async () => getConnectorInstancesPlaceHolder(),
|
||||
}));
|
||||
|
||||
|
@ -452,4 +459,92 @@ describe('connector route', () => {
|
|||
expect(response).toHaveProperty('statusCode', 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /connectors/test/email', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get email connector and send message', async () => {
|
||||
const mockedEmailConnector: EmailConnectorInstance = {
|
||||
connector: {
|
||||
id: 'connector_0',
|
||||
enabled: true,
|
||||
config: {},
|
||||
createdAt: 1_234_567_890_123,
|
||||
},
|
||||
metadata: {
|
||||
id: 'connector_0',
|
||||
type: ConnectorType.Email,
|
||||
name: {},
|
||||
logo: './logo.png',
|
||||
description: {},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
validateConfig: async (_config: any) => {},
|
||||
sendMessage: async (
|
||||
address: string,
|
||||
type: keyof EmailMessageTypes,
|
||||
_payload: EmailMessageTypes[typeof type]
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
): Promise<any> => {},
|
||||
};
|
||||
|
||||
getConnectorInstanceByTypePlaceHolder.mockImplementationOnce(async (_: ConnectorType) => {
|
||||
return mockedEmailConnector;
|
||||
});
|
||||
|
||||
const sendMessageSpy = jest.spyOn(mockedEmailConnector, 'sendMessage');
|
||||
const response = await connectorRequest
|
||||
.post('/connectors/test/email')
|
||||
.send({ email: 'test@email.com' });
|
||||
expect(getConnectorInstanceByTypePlaceHolder).toHaveBeenCalledWith(ConnectorType.Email);
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(response).toHaveProperty('statusCode', 204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /connectors/test/sms', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should get SMS connector and send message', async () => {
|
||||
const mockedEmailConnector: EmailConnectorInstance = {
|
||||
connector: {
|
||||
id: 'connector_0',
|
||||
enabled: true,
|
||||
config: {},
|
||||
createdAt: 1_234_567_890_123,
|
||||
},
|
||||
metadata: {
|
||||
id: 'connector_0',
|
||||
type: ConnectorType.SMS,
|
||||
name: {},
|
||||
logo: './logo.png',
|
||||
description: {},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
validateConfig: async (_config: any) => {},
|
||||
sendMessage: async (
|
||||
address: string,
|
||||
type: keyof EmailMessageTypes,
|
||||
_payload: EmailMessageTypes[typeof type]
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
): Promise<any> => {},
|
||||
};
|
||||
|
||||
getConnectorInstanceByTypePlaceHolder.mockImplementationOnce(async (_: ConnectorType) => {
|
||||
return mockedEmailConnector;
|
||||
});
|
||||
|
||||
const sendMessageSpy = jest.spyOn(mockedEmailConnector, 'sendMessage');
|
||||
const response = await connectorRequest
|
||||
.post('/connectors/test/sms')
|
||||
.send({ phone: '12345678901' });
|
||||
expect(getConnectorInstanceByTypePlaceHolder).toHaveBeenCalledWith(ConnectorType.SMS);
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(response).toHaveProperty('statusCode', 204);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,11 +1,20 @@
|
|||
import { ConnectorDTO, Connectors, ConnectorType } from '@logto/schemas';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import { getConnectorInstances, getConnectorInstanceById } from '@/connectors';
|
||||
import { ConnectorInstance } from '@/connectors/types';
|
||||
import {
|
||||
getConnectorInstances,
|
||||
getConnectorInstanceById,
|
||||
getConnectorInstanceByType,
|
||||
} from '@/connectors';
|
||||
import {
|
||||
ConnectorInstance,
|
||||
EmailConnectorInstance,
|
||||
SmsConnectorInstance,
|
||||
} from '@/connectors/types';
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
import { updateConnector } from '@/queries/connector';
|
||||
import assertThat from '@/utils/assert-that';
|
||||
import { emailRegEx, phoneRegEx } from '@/utils/regex';
|
||||
|
||||
import { AuthedRouter } from './types';
|
||||
|
||||
|
@ -117,4 +126,52 @@ export default function connectorRoutes<T extends AuthedRouter>(router: T) {
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/connectors/test/email',
|
||||
koaGuard({
|
||||
body: object({
|
||||
email: string().regex(emailRegEx),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { email } = ctx.guard.body;
|
||||
|
||||
const connector = await getConnectorInstanceByType<EmailConnectorInstance>(
|
||||
ConnectorType.Email
|
||||
);
|
||||
|
||||
// TODO - LOG-1875: SMS & Email Template for Test
|
||||
await connector.sendMessage(email, 'Test', {
|
||||
code: 'email-test',
|
||||
});
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/connectors/test/sms',
|
||||
koaGuard({
|
||||
body: object({
|
||||
phone: string().regex(phoneRegEx),
|
||||
}),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { phone } = ctx.guard.body;
|
||||
|
||||
const connector = await getConnectorInstanceByType<SmsConnectorInstance>(ConnectorType.SMS);
|
||||
|
||||
// TODO - LOG-1875: SMS & Email Template for Test
|
||||
await connector.sendMessage(phone, 'Test', {
|
||||
code: '123456',
|
||||
});
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -74,6 +74,9 @@ const translation = {
|
|||
},
|
||||
},
|
||||
},
|
||||
application_details: {
|
||||
back_to_applications: 'Back to Applications',
|
||||
},
|
||||
api_resources: {
|
||||
title: 'API Resources',
|
||||
subtitle: 'Define APIs that you can consume from your authorized applications.',
|
||||
|
@ -107,9 +110,6 @@ const translation = {
|
|||
social: 'Social',
|
||||
},
|
||||
},
|
||||
application_details: {
|
||||
back_to_applications: 'Back to Applications',
|
||||
},
|
||||
connector_details: {
|
||||
back_to_connectors: 'Back to Connectors',
|
||||
check_readme: 'Check README',
|
||||
|
@ -118,6 +118,12 @@ const translation = {
|
|||
save_error_empty_config: 'Please enter config.',
|
||||
save_error_json_parse_error: 'Please enter valid JSON.',
|
||||
save_success: 'Saved!',
|
||||
send: 'Send',
|
||||
send_error_invalid_format: 'Invalid input',
|
||||
test_email_sender: 'Test your email sender',
|
||||
test_sms_sender: 'Test your SMS sender',
|
||||
test_message_sent: 'Test Message Sent!',
|
||||
test_sender_description: 'Test sender description',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -76,6 +76,9 @@ const translation = {
|
|||
},
|
||||
},
|
||||
},
|
||||
application_details: {
|
||||
back_to_applications: '返回应用集',
|
||||
},
|
||||
api_resources: {
|
||||
title: 'API Resources',
|
||||
subtitle: 'Define APIs that you can consume from your authorized applications.',
|
||||
|
@ -109,9 +112,6 @@ const translation = {
|
|||
social: '社会化登录',
|
||||
},
|
||||
},
|
||||
application_details: {
|
||||
back_to_applications: '返回应用集',
|
||||
},
|
||||
connector_details: {
|
||||
back_to_connectors: '返回连接器',
|
||||
check_readme: '查看文档',
|
||||
|
@ -120,6 +120,12 @@ const translation = {
|
|||
save_error_empty_config: '请输入配置内容。',
|
||||
save_error_json_parse_error: '请输入符合 JSON 格式的配置。',
|
||||
save_success: '保存成功',
|
||||
send: 'Send',
|
||||
send_error_invalid_format: 'Invalid input',
|
||||
test_email_sender: 'Test your email sender',
|
||||
test_sms_sender: 'Test your SMS sender',
|
||||
test_message_sent: 'Test Message Sent!',
|
||||
test_sender_description: 'Test sender description',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue