mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
parent
0105d19d99
commit
02491adb5d
7 changed files with 111 additions and 6 deletions
|
@ -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<ConnectorIns
|
|||
return { connector, ...found };
|
||||
};
|
||||
|
||||
export const getConnectorInstanceByType = async <T extends ConnectorInstance>(
|
||||
type: ConnectorType
|
||||
): Promise<T> => {
|
||||
const connectors = await getConnectorInstances();
|
||||
const connector = connectors
|
||||
.filter((connector) => connector.connector?.enabled)
|
||||
.find<T>((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 } }) => {
|
||||
|
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
type SmsMessageTypes = EmailMessageTypes;
|
||||
|
||||
export type EmailSendMessageFunction<T = unknown> = (
|
||||
address: string,
|
||||
type: keyof EmailMessageTypes,
|
||||
payload: EmailMessageTypes[typeof type]
|
||||
) => Promise<T>;
|
||||
|
||||
export type SmsSendMessageFunction<T = unknown> = (
|
||||
address: string,
|
||||
type: keyof SmsMessageTypes,
|
||||
payload: SmsMessageTypes[typeof type]
|
||||
) => Promise<T>;
|
||||
|
||||
export class ConnectorError extends Error {}
|
||||
|
||||
export class ConnectorConfigError extends ConnectorError {}
|
||||
|
|
|
@ -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<typeof insertPasscode>;
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<EmailConector>(ConnectorType.Email)
|
||||
: await getConnectorInstanceByType<SmsConnector>(ConnectorType.SMS);
|
||||
|
||||
return connector.sendMessage(emailOrPhone, passcode.type, {
|
||||
code: passcode.code,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
@ -45,6 +45,9 @@ const errors = {
|
|||
invalid_sign_in_method: '当前登录方式不可用。',
|
||||
insufficient_info: '登录信息缺失,请检查您的输入。',
|
||||
},
|
||||
connector: {
|
||||
not_found: '找不到可用的 {{type}} 类型的连接器.',
|
||||
},
|
||||
swagger: {
|
||||
invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。',
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue