diff --git a/packages/core/src/connectors/index.ts b/packages/core/src/connectors/index.ts index 2fc6bea1e..3be32adde 100644 --- a/packages/core/src/connectors/index.ts +++ b/packages/core/src/connectors/index.ts @@ -3,7 +3,7 @@ import { findConnectorById, hasConnector, insertConnector } from '@/queries/conn import * as AliyunDM from './aliyun-dm'; import * as GitHub from './github'; -import { ConnectorInstance } from './types'; +import { ConnectorInstance, ConnectorType } from './types'; const allConnectors: ConnectorInstance[] = [AliyunDM, GitHub]; @@ -33,6 +33,21 @@ export const getConnectorInstanceById = async (id: string): Promise( + type: ConnectorType +): Promise => { + const connectors = await getConnectorInstances(); + const connector = connectors + .filter((connector) => connector.connector?.enabled) + .find((connector): connector is T => connector.metadata.type === type); + + if (!connector) { + throw new RequestError('connector.not_found', { type }); + } + + return connector; +}; + export const initConnectors = async () => { await Promise.all( allConnectors.map(async ({ metadata: { id } }) => { diff --git a/packages/core/src/connectors/types.ts b/packages/core/src/connectors/types.ts index ee65308d3..c84e2ceaa 100644 --- a/packages/core/src/connectors/types.ts +++ b/packages/core/src/connectors/types.ts @@ -15,7 +15,7 @@ export interface ConnectorMetadata { } // The name `Connector` is used for database, use `ConnectorInstance` to avoid confusing. -export type ConnectorInstance = EmailConector | SocialConector; +export type ConnectorInstance = SmsConnector | EmailConector | SocialConector; export interface BaseConnector { connector?: Connector; @@ -23,6 +23,10 @@ export interface BaseConnector { validateConfig: ValidateConfig; } +export interface SmsConnector extends BaseConnector { + sendMessage: SmsSendMessageFunction; +} + export interface EmailConector extends BaseConnector { sendMessage: EmailSendMessageFunction; } @@ -46,12 +50,20 @@ export interface EmailMessageTypes { Test: Record; } +type SmsMessageTypes = EmailMessageTypes; + export type EmailSendMessageFunction = ( address: string, type: keyof EmailMessageTypes, payload: EmailMessageTypes[typeof type] ) => Promise; +export type SmsSendMessageFunction = ( + address: string, + type: keyof SmsMessageTypes, + payload: SmsMessageTypes[typeof type] +) => Promise; + export class ConnectorError extends Error {} export class ConnectorConfigError extends ConnectorError {} diff --git a/packages/core/src/lib/passcode.test.ts b/packages/core/src/lib/passcode.test.ts index 740be24e3..7f9a9ef13 100644 --- a/packages/core/src/lib/passcode.test.ts +++ b/packages/core/src/lib/passcode.test.ts @@ -1,14 +1,17 @@ -import { PasscodeType } from '@logto/schemas'; +import { Passcode, PasscodeType } from '@logto/schemas'; +import { getConnectorInstanceByType } from '@/connectors'; +import { ConnectorType } from '@/connectors/types'; import { deletePasscodesByIds, findUnconsumedPasscodesBySessionIdAndType, insertPasscode, } from '@/queries/passcode'; -import { createPasscode, passcodeLength } from './passcode'; +import { createPasscode, passcodeLength, sendPasscode } from './passcode'; jest.mock('@/queries/passcode'); +jest.mock('@/connectors'); const mockedFindUnconsumedPasscodesBySessionIdAndType = findUnconsumedPasscodesBySessionIdAndType as jest.MockedFunction< @@ -18,6 +21,9 @@ const mockedDeletePasscodesByIds = deletePasscodesByIds as jest.MockedFunction< typeof deletePasscodesByIds >; const mockedInsertPasscode = insertPasscode as jest.MockedFunction; +const mockedGetConnectorInstanceByType = getConnectorInstanceByType as jest.MockedFunction< + typeof getConnectorInstanceByType +>; beforeAll(() => { mockedFindUnconsumedPasscodesBySessionIdAndType.mockResolvedValue([]); @@ -35,6 +41,7 @@ afterEach(() => { mockedFindUnconsumedPasscodesBySessionIdAndType.mockClear(); mockedDeletePasscodesByIds.mockClear(); mockedInsertPasscode.mockClear(); + mockedGetConnectorInstanceByType.mockClear(); }); describe('createPasscode', () => { @@ -78,3 +85,50 @@ describe('createPasscode', () => { expect(mockedDeletePasscodesByIds).toHaveBeenCalledWith(['id']); }); }); + +describe('sendPasscode', () => { + it('should throw error when email and phone are both empty', async () => { + const passcode: Passcode = { + id: 'id', + sessionId: 'sessionId', + phone: null, + email: null, + type: PasscodeType.SignIn, + code: '1234', + consumed: false, + tryCount: 0, + createdAt: Date.now(), + }; + await expect(sendPasscode(passcode)).rejects.toThrowError('Both email and phone are empty.'); + }); + + it('should call sendPasscode with params matching', async () => { + const sendMessage = jest.fn(); + mockedGetConnectorInstanceByType.mockResolvedValue({ + metadata: { + id: 'id', + type: ConnectorType.SMS, + name: {}, + logo: '', + description: {}, + }, + sendMessage, + validateConfig: jest.fn(), + }); + const passcode: Passcode = { + id: 'id', + sessionId: 'sessionId', + phone: 'phone', + email: null, + type: PasscodeType.SignIn, + code: '1234', + consumed: false, + tryCount: 0, + createdAt: Date.now(), + }; + await sendPasscode(passcode); + expect(sendMessage).toHaveBeenCalledWith(passcode.phone, passcode.type, { + code: passcode.code, + }); + }); +}); diff --git a/packages/core/src/lib/passcode.ts b/packages/core/src/lib/passcode.ts index 51ae8ad56..86f3457f7 100644 --- a/packages/core/src/lib/passcode.ts +++ b/packages/core/src/lib/passcode.ts @@ -1,6 +1,8 @@ -import { PasscodeType } from '@logto/schemas'; +import { Passcode, PasscodeType } from '@logto/schemas'; import { customAlphabet, nanoid } from 'nanoid'; +import { getConnectorInstanceByType } from '@/connectors'; +import { ConnectorType, EmailConector, SmsConnector } from '@/connectors/types'; import { deletePasscodesByIds, findUnconsumedPasscodesBySessionIdAndType, @@ -30,3 +32,19 @@ export const createPasscode = async ( ...payload, }); }; + +export const sendPasscode = async (passcode: Passcode) => { + const emailOrPhone = passcode.email ?? passcode.phone; + + if (!emailOrPhone) { + throw new Error('Both email and phone are empty.'); + } + + const connector = passcode.email + ? await getConnectorInstanceByType(ConnectorType.Email) + : await getConnectorInstanceByType(ConnectorType.SMS); + + return connector.sendMessage(emailOrPhone, passcode.type, { + code: passcode.code, + }); +}; diff --git a/packages/core/src/queries/passcode.ts b/packages/core/src/queries/passcode.ts index 9b5f1ddc9..c7772d223 100644 --- a/packages/core/src/queries/passcode.ts +++ b/packages/core/src/queries/passcode.ts @@ -52,7 +52,7 @@ export const deletePasscodesByIds = async (ids: string[]) => { where id in (${ids.join(',')}) `); - if (rowCount < 1) { + if (rowCount !== ids.length) { throw new DeletionError(); } }; diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 69fd0cdea..ce9efb8a3 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -43,6 +43,9 @@ const errors = { invalid_sign_in_method: 'Current sign-in method is not available.', insufficient_info: 'Insufficent sign-in info.', }, + connector: { + not_found: 'Can not find any available connector for type: {{type}}.', + }, swagger: { invalid_zod_type: 'Invalid Zod type, please check route guard config.', }, diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index 815a3ad72..f30741cb4 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -45,6 +45,9 @@ const errors = { invalid_sign_in_method: '当前登录方式不可用。', insufficient_info: '登录信息缺失,请检查您的输入。', }, + connector: { + not_found: '找不到可用的 {{type}} 类型的连接器.', + }, swagger: { invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。', },