mirror of
https://github.com/logto-io/logto.git
synced 2025-02-17 22:04:19 -05:00
fix(console,core,toolkit): passwordless connector sender tester (#3492)
This commit is contained in:
parent
839ee874da
commit
0bfd8509fb
10 changed files with 79 additions and 72 deletions
|
@ -7,7 +7,7 @@ export type ConnectorFactory<T extends AllConnector = AllConnector> = Pick<
|
|||
T,
|
||||
'type' | 'metadata'
|
||||
> & {
|
||||
createConnector: CreateConnector<AllConnector>;
|
||||
createConnector: CreateConnector<T>;
|
||||
path: string;
|
||||
};
|
||||
|
||||
|
|
|
@ -2,7 +2,12 @@ import { existsSync } from 'fs';
|
|||
import { readFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import type { AllConnector, BaseConnector, GetConnectorConfig } from '@logto/connector-kit';
|
||||
import type {
|
||||
AllConnector,
|
||||
BaseConnector,
|
||||
ConnectorMetadata,
|
||||
GetConnectorConfig,
|
||||
} from '@logto/connector-kit';
|
||||
import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit';
|
||||
|
||||
import { notImplemented } from './consts.js';
|
||||
|
@ -64,10 +69,10 @@ export const parseMetadata = async (
|
|||
};
|
||||
};
|
||||
|
||||
export const buildRawConnector = async (
|
||||
connectorFactory: ConnectorFactory,
|
||||
export const buildRawConnector = async <T extends AllConnector = AllConnector>(
|
||||
connectorFactory: ConnectorFactory<T>,
|
||||
getConnectorConfig?: GetConnectorConfig
|
||||
) => {
|
||||
): Promise<{ rawConnector: T; rawMetadata: ConnectorMetadata }> => {
|
||||
const { createConnector, path: packagePath } = connectorFactory;
|
||||
const rawConnector = await createConnector({
|
||||
getConfig: getConnectorConfig ?? notImplemented,
|
||||
|
|
|
@ -14,8 +14,8 @@ import useApi from '@/hooks/use-api';
|
|||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||
import BasicForm from '@/pages/Connectors/components/ConnectorForm/BasicForm';
|
||||
import ConfigForm from '@/pages/Connectors/components/ConnectorForm/ConfigForm';
|
||||
import { useConfigParser } from '@/pages/Connectors/components/ConnectorForm/hooks';
|
||||
import { initFormData, parseFormConfig } from '@/pages/Connectors/components/ConnectorForm/utils';
|
||||
import { useConnectorFormConfigParser } from '@/pages/Connectors/components/ConnectorForm/hooks';
|
||||
import { initFormData } from '@/pages/Connectors/components/ConnectorForm/utils';
|
||||
import type { ConnectorFormType } from '@/pages/Connectors/types';
|
||||
import { SyncProfileMode } from '@/pages/Connectors/types';
|
||||
|
||||
|
@ -38,7 +38,6 @@ const getConnectorTarget = (connectorData: ConnectorResponse): Optional<string>
|
|||
const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Props) => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { getDocumentationUrl } = useDocumentationUrl();
|
||||
const parseJsonConfig = useConfigParser();
|
||||
const api = useApi();
|
||||
const methods = useForm<ConnectorFormType>({
|
||||
reValidateMode: 'onBlur',
|
||||
|
@ -70,9 +69,11 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
|
|||
});
|
||||
}, [connectorData, reset]);
|
||||
|
||||
const configParser = useConnectorFormConfigParser();
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const { formItems, isStandard, id } = connectorData;
|
||||
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
|
||||
const config = configParser(data, formItems);
|
||||
const { syncProfile, name, logo, logoDark, target } = data;
|
||||
|
||||
const payload = isSocialConnector
|
||||
|
@ -136,9 +137,9 @@ const ConnectorContent = ({ isDeleted, connectorData, onConnectorUpdated }: Prop
|
|||
{connectorData.type !== ConnectorType.Social && (
|
||||
<FormCard title="connector_details.test_connection">
|
||||
<SenderTester
|
||||
connectorId={connectorData.id}
|
||||
connectorFactoryId={connectorData.connectorId}
|
||||
connectorType={connectorData.type}
|
||||
config={watch('config')}
|
||||
parse={() => configParser(watch(), connectorData.formItems)}
|
||||
/>
|
||||
</FormCard>
|
||||
)}
|
||||
|
|
|
@ -3,7 +3,6 @@ import { ConnectorType } from '@logto/schemas';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Button from '@/components/Button';
|
||||
|
@ -12,22 +11,21 @@ import TextInput from '@/components/TextInput';
|
|||
import { Tooltip } from '@/components/Tip';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
import { safeParseJson } from '@/utils/json';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
connectorId: string;
|
||||
connectorFactoryId: string;
|
||||
connectorType: Exclude<ConnectorType, ConnectorType.Social>;
|
||||
config: string;
|
||||
className?: string;
|
||||
parse: () => unknown;
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
sendTo: string;
|
||||
};
|
||||
|
||||
const SenderTester = ({ connectorId, connectorType, config, className }: Props) => {
|
||||
const SenderTester = ({ connectorFactoryId, connectorType, className, parse }: Props) => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const {
|
||||
handleSubmit,
|
||||
|
@ -58,23 +56,15 @@ const SenderTester = ({ connectorId, connectorType, config, className }: Props)
|
|||
const onSubmit = handleSubmit(async (formData) => {
|
||||
const { sendTo } = formData;
|
||||
|
||||
const result = safeParseJson(config);
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
config: result.data,
|
||||
config: parse(),
|
||||
...(isSms
|
||||
? { phone: sendTo.replace(/[ ()-]/g, '').replace(/\+/g, '00') }
|
||||
: { email: sendTo }),
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post(`api/connectors/${connectorId}/test`, { json: data }).json();
|
||||
await api.post(`api/connectors/${connectorFactoryId}/test`, { json: data }).json();
|
||||
setShowTooltip(true);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import type { ConnectorResponse } from '@logto/schemas';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { parseFormConfig } from '@/pages/Connectors/components/ConnectorForm/utils';
|
||||
import type { ConnectorFormType } from '@/pages/Connectors/types';
|
||||
import { safeParseJson } from '@/utils/json';
|
||||
|
||||
export const useConfigParser = () => {
|
||||
export const useJsonStringConfigParser = () => {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (config: string) => {
|
||||
|
@ -24,3 +27,11 @@ export const useConfigParser = () => {
|
|||
return result.data;
|
||||
};
|
||||
};
|
||||
|
||||
export const useConnectorFormConfigParser = () => {
|
||||
const parseJsonConfig = useJsonStringConfigParser();
|
||||
|
||||
return (data: ConnectorFormType, formItems: ConnectorResponse['formItems']) => {
|
||||
return formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -29,8 +29,8 @@ import { SyncProfileMode } from '../../types';
|
|||
import { splitMarkdownByTitle } from '../../utils';
|
||||
import BasicForm from '../ConnectorForm/BasicForm';
|
||||
import ConfigForm from '../ConnectorForm/ConfigForm';
|
||||
import { useConfigParser } from '../ConnectorForm/hooks';
|
||||
import { initFormData, parseFormConfig } from '../ConnectorForm/utils';
|
||||
import { useConnectorFormConfigParser } from '../ConnectorForm/hooks';
|
||||
import { initFormData } from '../ConnectorForm/utils';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const targetErrorCode = 'connector.multiple_target_with_same_platform';
|
||||
|
@ -45,7 +45,6 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
const navigate = useNavigate();
|
||||
const callbackConnectorId = useRef(generateStandardId());
|
||||
const { updateConfigs } = useConfigs();
|
||||
const parseJsonConfig = useConfigParser();
|
||||
const [conflictConnectorName, setConflictConnectorName] = useState<Record<string, string>>();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { type: connectorType, formItems, target, isStandard, configTemplate } = connector ?? {};
|
||||
|
@ -74,6 +73,8 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
});
|
||||
}, [formItems, reset, configTemplate, target, isSocialConnector, isStandard]);
|
||||
|
||||
const configParser = useConnectorFormConfigParser();
|
||||
|
||||
if (!connector) {
|
||||
return null;
|
||||
}
|
||||
|
@ -90,7 +91,8 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
// Recover error state
|
||||
setConflictConnectorName(undefined);
|
||||
|
||||
const config = formItems ? parseFormConfig(data, formItems) : parseJsonConfig(data.config);
|
||||
const config = configParser(data, formItems);
|
||||
|
||||
const { syncProfile, name, logo, logoDark, target } = data;
|
||||
|
||||
const basePayload = {
|
||||
|
@ -213,9 +215,9 @@ const Guide = ({ connector, onClose }: Props) => {
|
|||
<div>{t('connectors.guide.test_connection')}</div>
|
||||
</div>
|
||||
<SenderTester
|
||||
connectorId={connectorId}
|
||||
connectorFactoryId={connectorId}
|
||||
connectorType={connectorType}
|
||||
config={watch('config')}
|
||||
parse={() => configParser(watch(), formItems)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { defaultConnectorMethods } from '@logto/cli/lib/connector/index.js';
|
||||
import {
|
||||
ConnectorError,
|
||||
ConnectorErrorCodes,
|
||||
ConnectorPlatform,
|
||||
VerificationCodeType,
|
||||
} from '@logto/connector-kit';
|
||||
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
|
||||
import { ConnectorPlatform, VerificationCodeType } from '@logto/connector-kit';
|
||||
import type { EmailConnector, SmsConnector } from '@logto/connector-kit';
|
||||
import type { Connector } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
|
@ -224,10 +219,6 @@ describe('connector route', () => {
|
|||
...mockLogtoConnector,
|
||||
},
|
||||
]);
|
||||
validateConfig.mockImplementationOnce((config: unknown) => {
|
||||
throw new ConnectorError(ConnectorErrorCodes.General);
|
||||
});
|
||||
buildRawConnector.mockResolvedValueOnce({ rawConnector: { configGuard: any() } });
|
||||
const response = await connectorRequest.post('/connectors').send({
|
||||
connectorId: 'connectorId',
|
||||
config: { cliend_id: 'client_id', client_secret: 'client_secret' },
|
||||
|
@ -384,8 +375,6 @@ describe('connector route', () => {
|
|||
...mockLogtoConnector,
|
||||
},
|
||||
]);
|
||||
validateConfig.mockReturnValueOnce(null);
|
||||
buildRawConnector.mockResolvedValueOnce({ rawConnector: { configGuard: any() } });
|
||||
const response = await connectorRequest.post('/connectors').send({
|
||||
connectorId: 'id0',
|
||||
metadata: { target: 'target' },
|
||||
|
@ -432,19 +421,15 @@ describe('connector route', () => {
|
|||
});
|
||||
|
||||
it('should get SMS connector and send test message', async () => {
|
||||
const mockedMetadata = {
|
||||
...mockMetadata,
|
||||
};
|
||||
const sendMessage = jest.fn();
|
||||
const mockedSmsConnector: LogtoConnector<SmsConnector> = {
|
||||
dbEntry: mockConnector,
|
||||
metadata: mockedMetadata,
|
||||
const mockedSmsConnectorFactory: ConnectorFactory<SmsConnector> = {
|
||||
...mockConnectorFactory,
|
||||
metadata: mockMetadata,
|
||||
type: ConnectorType.Sms,
|
||||
configGuard: any(),
|
||||
...defaultConnectorMethods,
|
||||
sendMessage,
|
||||
createConnector: jest.fn(),
|
||||
};
|
||||
getLogtoConnectors.mockResolvedValueOnce([mockedSmsConnector]);
|
||||
loadConnectorFactories.mockResolvedValueOnce([mockedSmsConnectorFactory]);
|
||||
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
|
||||
const response = await connectorRequest
|
||||
.post('/connectors/id/test')
|
||||
.send({ phone: '12345678901', config: { test: 123 } });
|
||||
|
@ -464,15 +449,14 @@ describe('connector route', () => {
|
|||
|
||||
it('should get email connector and send test message', async () => {
|
||||
const sendMessage = jest.fn();
|
||||
const mockedEmailConnector: LogtoConnector<EmailConnector> = {
|
||||
dbEntry: mockConnector,
|
||||
const mockedEmailConnectorFactory: ConnectorFactory<EmailConnector> = {
|
||||
...mockConnectorFactory,
|
||||
metadata: mockMetadata,
|
||||
type: ConnectorType.Email,
|
||||
configGuard: any(),
|
||||
...defaultConnectorMethods,
|
||||
sendMessage,
|
||||
createConnector: jest.fn(),
|
||||
};
|
||||
getLogtoConnectors.mockResolvedValueOnce([mockedEmailConnector]);
|
||||
loadConnectorFactories.mockResolvedValueOnce([mockedEmailConnectorFactory]);
|
||||
buildRawConnector.mockResolvedValueOnce({ rawConnector: { sendMessage } });
|
||||
const response = await connectorRequest
|
||||
.post('/connectors/id/test')
|
||||
.send({ email: 'test@email.com', config: { test: 123 } });
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { buildRawConnector } from '@logto/cli/lib/connector/index.js';
|
||||
import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js';
|
||||
import type { SmsConnector, EmailConnector } from '@logto/connector-kit';
|
||||
import { VerificationCodeType, validateConfig } from '@logto/connector-kit';
|
||||
import { emailRegEx, phoneRegEx, buildIdGenerator } from '@logto/core-kit';
|
||||
import { arbitraryObjectGuard, Connectors, ConnectorType } from '@logto/schemas';
|
||||
|
@ -281,9 +284,9 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
);
|
||||
|
||||
router.post(
|
||||
'/connectors/:id/test',
|
||||
'/connectors/:factoryId/test',
|
||||
koaGuard({
|
||||
params: object({ id: string().min(1) }),
|
||||
params: object({ factoryId: string().min(1) }),
|
||||
body: object({
|
||||
phone: string().regex(phoneRegEx).optional(),
|
||||
email: string().regex(emailRegEx).optional(),
|
||||
|
@ -292,7 +295,7 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
params: { factoryId },
|
||||
body,
|
||||
} = ctx.guard;
|
||||
const { phone, email, config } = body;
|
||||
|
@ -300,19 +303,29 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
const subject = phone ?? email;
|
||||
assertThat(subject, new RequestError({ code: 'guard.invalid_input' }));
|
||||
|
||||
const connector = await getLogtoConnectorById(id);
|
||||
const connectorFactories = await loadConnectorFactories();
|
||||
const connectorFactory = connectorFactories
|
||||
.filter(
|
||||
(factory): factory is ConnectorFactory<SmsConnector> | ConnectorFactory<EmailConnector> =>
|
||||
factory.type === ConnectorType.Email || factory.type === ConnectorType.Sms
|
||||
)
|
||||
.find(({ metadata: { id } }) => id === factoryId && !demoConnectorIds.includes(id));
|
||||
const expectType = phone ? ConnectorType.Sms : ConnectorType.Email;
|
||||
|
||||
assertThat(
|
||||
connector,
|
||||
connectorFactory,
|
||||
new RequestError({
|
||||
code: 'connector.not_found',
|
||||
type: expectType,
|
||||
factoryId,
|
||||
})
|
||||
);
|
||||
assertThat(connector.type === expectType, 'connector.unexpected_type');
|
||||
|
||||
const { sendMessage } = connector;
|
||||
assertThat(connectorFactory.type === expectType, 'connector.unexpected_type');
|
||||
|
||||
const {
|
||||
rawConnector: { sendMessage },
|
||||
} = await buildRawConnector<SmsConnector | EmailConnector>(connectorFactory);
|
||||
|
||||
await sendMessage(
|
||||
{
|
||||
|
@ -358,3 +371,4 @@ export default function connectorRoutes<T extends AuthedRouter>(
|
|||
}
|
||||
);
|
||||
}
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -142,10 +142,10 @@ test('send SMS/email test message', async () => {
|
|||
const email = 'test@example.com';
|
||||
|
||||
await expect(
|
||||
sendSmsTestMessage(connectorIdMap.get(mockSmsConnectorId), phone, mockSmsConnectorConfig)
|
||||
sendSmsTestMessage(mockSmsConnectorId, phone, mockSmsConnectorConfig)
|
||||
).resolves.not.toThrow();
|
||||
await expect(
|
||||
sendEmailTestMessage(connectorIdMap.get(mockEmailConnectorId), email, mockEmailConnectorConfig)
|
||||
sendEmailTestMessage(mockEmailConnectorId, email, mockEmailConnectorConfig)
|
||||
).resolves.not.toThrow();
|
||||
await expect(sendSmsTestMessage(mockSmsConnectorId, phone, {})).rejects.toThrow(HTTPError);
|
||||
await expect(sendEmailTestMessage(mockEmailConnectorId, email, {})).rejects.toThrow(HTTPError);
|
||||
|
|
|
@ -190,7 +190,7 @@ export type BaseConnector<Type extends ConnectorType> = {
|
|||
configGuard: ZodType;
|
||||
};
|
||||
|
||||
export type CreateConnector<T extends BaseConnector<ConnectorType>> = (options: {
|
||||
export type CreateConnector<T extends AllConnector> = (options: {
|
||||
getConfig: GetConnectorConfig;
|
||||
}) => Promise<T>;
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue