diff --git a/.changeset/good-stingrays-perform.md b/.changeset/good-stingrays-perform.md new file mode 100644 index 000000000..36cdf5664 --- /dev/null +++ b/.changeset/good-stingrays-perform.md @@ -0,0 +1,12 @@ +--- +"@logto/connector-logto-email": patch +"@logto/connector-tencent-sms": patch +"@logto/connector-aliyun-sms": patch +"@logto/connector-aliyun-dm": patch +"@logto/connector-logto-sms": patch +"@logto/connector-aws-ses": patch +"@logto/connector-mailgun": patch +"@logto/connector-smtp": patch +--- + +support `TemplateType` diff --git a/.changeset/strange-seals-poke.md b/.changeset/strange-seals-poke.md new file mode 100644 index 000000000..988e9871b --- /dev/null +++ b/.changeset/strange-seals-poke.md @@ -0,0 +1,7 @@ +--- +"@logto/connector-sendgrid-email": minor +"@logto/connector-aliyun-dm": minor +"@logto/connector-aws-ses": minor +--- + +support subject handlebars diff --git a/.changeset/thin-bags-admire.md b/.changeset/thin-bags-admire.md new file mode 100644 index 000000000..2e9946358 --- /dev/null +++ b/.changeset/thin-bags-admire.md @@ -0,0 +1,5 @@ +--- +"@logto/connector-kit": minor +--- + +add `replaceSendMessageHandlebars()` for replacing `SendMessagePayload` handlebars in a message template diff --git a/.changeset/tidy-phones-warn.md b/.changeset/tidy-phones-warn.md new file mode 100644 index 000000000..0120e1567 --- /dev/null +++ b/.changeset/tidy-phones-warn.md @@ -0,0 +1,10 @@ +--- +"@logto/connector-kit": minor +--- + +support magic link feature + +- Removed `VerificationCodeType`: Since we are adding the magic link feature, `VerificationCodeType` is no longer precise for our use cases. +- Replaced `VerificationCodeType` with `TemplateType`. +- Removed `TemplateNotSupported` error code since it is useless for dynamic template checking. +- Added `link` property to `SendMessagePayload`. diff --git a/.changeset/twelve-carrots-do.md b/.changeset/twelve-carrots-do.md new file mode 100644 index 000000000..fe796fdca --- /dev/null +++ b/.changeset/twelve-carrots-do.md @@ -0,0 +1,5 @@ +--- +"@logto/connector-mailgun": patch +--- + +remove `supportTemplateGuard`, support dynamic templates diff --git a/packages/connectors/connector-aliyun-dm/src/index.test.ts b/packages/connectors/connector-aliyun-dm/src/index.test.ts index a283de41b..9c8d0ea1a 100644 --- a/packages/connectors/connector-aliyun-dm/src/index.test.ts +++ b/packages/connectors/connector-aliyun-dm/src/index.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { mockedConfigWithAllRequiredTemplates } from './mock.js'; @@ -22,16 +22,33 @@ describe('sendMessage()', () => { jest.clearAllMocks(); }); - it('should call singleSendMail() and replace code in content', async () => { + it('should call singleSendMail() with correct template and content', async () => { const connector = await createConnector({ getConfig }); await connector.sendMessage({ to: 'to@email.com', - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, payload: { code: '1234' }, }); expect(singleSendMail).toHaveBeenCalledWith( expect.objectContaining({ - HtmlBody: 'Your code is 1234, 1234 is your code', + HtmlBody: 'Your sign-in code is 1234, 1234 is your code', + Subject: 'Sign-in code 1234', + }), + expect.anything() + ); + }); + + it('should call singleSendMail() with correct template and content (2)', async () => { + const connector = await createConnector({ getConfig }); + await connector.sendMessage({ + to: 'to@email.com', + type: TemplateType.OrganizationInvitation, + payload: { code: '1234', link: 'https://example.com' }, + }); + expect(singleSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + HtmlBody: 'Your link is https://example.com', + Subject: 'Organization invitation', }), expect.anything() ); diff --git a/packages/connectors/connector-aliyun-dm/src/index.ts b/packages/connectors/connector-aliyun-dm/src/index.ts index 25bb9c513..4fbd8865c 100644 --- a/packages/connectors/connector-aliyun-dm/src/index.ts +++ b/packages/connectors/connector-aliyun-dm/src/index.ts @@ -13,6 +13,7 @@ import { ConnectorType, validateConfig, parseJson, + replaceSendMessageHandlebars, } from '@logto/connector-kit'; import { defaultMetadata } from './constant.js'; @@ -49,11 +50,8 @@ const sendMessage = AddressType: '1', ToAddress: to, FromAlias: fromAlias, - Subject: template.subject, - HtmlBody: - typeof payload.code === 'string' - ? template.content.replaceAll('{{code}}', payload.code) - : template.content, + Subject: replaceSendMessageHandlebars(template.subject, payload), + HtmlBody: replaceSendMessageHandlebars(template.content, payload), }, accessKeySecret ); diff --git a/packages/connectors/connector-aliyun-dm/src/mock.ts b/packages/connectors/connector-aliyun-dm/src/mock.ts index 18d4dec27..891f3f689 100644 --- a/packages/connectors/connector-aliyun-dm/src/mock.ts +++ b/packages/connectors/connector-aliyun-dm/src/mock.ts @@ -35,23 +35,28 @@ export const mockedConfigWithAllRequiredTemplates = { templates: [ { usageType: 'SignIn', - content: 'Your code is {{code}}, {{code}} is your code', - subject: 'subject', + content: 'Your sign-in code is {{code}}, {{code}} is your code', + subject: 'Sign-in code {{code}}', }, { usageType: 'Register', - content: 'Your code is {{code}}, {{code}} is your code', + content: 'Your register code is {{code}}, {{code}} is your code', subject: 'subject', }, { usageType: 'ForgotPassword', - content: 'Your code is {{code}}, {{code}} is your code', + content: 'Your forgot password code is {{code}}, {{code}} is your code', subject: 'subject', }, { usageType: 'Generic', - content: 'Your code is {{code}}, {{code}} is your code', + content: 'Your generic code is {{code}}, {{code}} is your code', subject: 'subject', }, + { + usageType: 'OrganizationInvitation', + content: 'Your link is {{link}}', + subject: 'Organization invitation', + }, ], }; diff --git a/packages/connectors/connector-aliyun-sms/src/index.test.ts b/packages/connectors/connector-aliyun-sms/src/index.test.ts index 13e776804..10dba3ed0 100644 --- a/packages/connectors/connector-aliyun-sms/src/index.test.ts +++ b/packages/connectors/connector-aliyun-sms/src/index.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { mockedConnectorConfig, phoneTest, codeTest } from './mock.js'; @@ -26,7 +26,7 @@ describe('sendMessage()', () => { const connector = await createConnector({ getConfig }); await connector.sendMessage({ to: phoneTest, - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, payload: { code: codeTest }, }); expect(sendSms).toHaveBeenCalledWith( @@ -48,7 +48,7 @@ describe('sendMessage()', () => { // eslint-disable-next-line no-await-in-loop await connector.sendMessage({ to, - type: VerificationCodeType.Register, + type: TemplateType.Register, payload: { code: codeTest }, }); expect(sendSms).toHaveBeenCalledWith( @@ -63,7 +63,7 @@ describe('sendMessage()', () => { await connector.sendMessage({ to: '+1123123123', - type: VerificationCodeType.Register, + type: TemplateType.Register, payload: { code: codeTest }, }); expect(sendSms).toHaveBeenCalledWith( diff --git a/packages/connectors/connector-aws-ses/src/index.test.ts b/packages/connectors/connector-aws-ses/src/index.test.ts index d2df1df2d..f95720a70 100644 --- a/packages/connectors/connector-aws-ses/src/index.test.ts +++ b/packages/connectors/connector-aws-ses/src/index.test.ts @@ -1,5 +1,5 @@ import { SESv2Client } from '@aws-sdk/client-sesv2'; -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import createConnector from './index.js'; import { mockedConfig } from './mock.js'; @@ -20,13 +20,13 @@ describe('sendMessage()', () => { jest.clearAllMocks(); }); - it('should call SendMail() and replace code in content', async () => { + it('should call SendMail() with correct template and content', async () => { const connector = await createConnector({ getConfig }); const toMail = 'to@email.com'; const { emailAddress } = mockedConfig; await connector.sendMessage({ to: toMail, - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, payload: { code: '1234' }, }); const toExpected = [toMail]; @@ -37,7 +37,7 @@ describe('sendMessage()', () => { Destination: { ToAddresses: toExpected }, Content: { Simple: { - Subject: { Data: 'subject', Charset: 'utf8' }, + Subject: { Data: 'Sign-in code 1234', Charset: 'utf8' }, Body: { Html: { Data: 'Your code is 1234, 1234 is your code', @@ -53,4 +53,38 @@ describe('sendMessage()', () => { }) ); }); + + it('should call SendMail() with correct template and content (2)', async () => { + const connector = await createConnector({ getConfig }); + const toMail = 'to@email.com'; + const { emailAddress } = mockedConfig; + await connector.sendMessage({ + to: toMail, + type: TemplateType.OrganizationInvitation, + payload: { code: '1234', link: 'https://logto.dev' }, + }); + const toExpected = [toMail]; + expect(SESv2Client.prototype.send).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + FromEmailAddress: emailAddress, + Destination: { ToAddresses: toExpected }, + Content: { + Simple: { + Subject: { Data: 'Organization invitation', Charset: 'utf8' }, + Body: { + Html: { + Data: 'Your link is https://logto.dev', + }, + }, + }, + }, + FeedbackForwardingEmailAddress: undefined, + FeedbackForwardingEmailAddressIdentityArn: undefined, + FromEmailAddressIdentityArn: undefined, + ConfigurationSetName: undefined, + }, + }) + ); + }); }); diff --git a/packages/connectors/connector-aws-ses/src/mock.ts b/packages/connectors/connector-aws-ses/src/mock.ts index d2577a873..c71398b03 100644 --- a/packages/connectors/connector-aws-ses/src/mock.ts +++ b/packages/connectors/connector-aws-ses/src/mock.ts @@ -7,22 +7,27 @@ export const mockedConfig = { { usageType: 'SignIn', content: 'Your code is {{code}}, {{code}} is your code', - subject: 'subject', + subject: 'Sign-in code {{code}}', }, { usageType: 'Register', - content: 'Your code is {{code}}, {{code}} is your code', + content: 'Your register code is {{code}}, {{code}} is your code', subject: 'subject', }, { usageType: 'ForgotPassword', - content: 'Your code is {{code}}, {{code}} is your code', + content: 'Your forgot password code is {{code}}, {{code}} is your code', subject: 'subject', }, { usageType: 'Generic', - content: 'Your code is {{code}}, {{code}} is your code', + content: 'Your generic code is {{code}}, {{code}} is your code', subject: 'subject', }, + { + usageType: 'OrganizationInvitation', + content: 'Your link is {{link}}', + subject: 'Organization invitation', + }, ], }; diff --git a/packages/connectors/connector-aws-ses/src/types.ts b/packages/connectors/connector-aws-ses/src/types.ts index 8f63eb983..e0c5ac65e 100644 --- a/packages/connectors/connector-aws-ses/src/types.ts +++ b/packages/connectors/connector-aws-ses/src/types.ts @@ -38,7 +38,3 @@ export const awsSesConfigGuard = z.object({ }); export type AwsSesConfig = z.infer; - -export type Payload = { - code: string | number; -}; diff --git a/packages/connectors/connector-aws-ses/src/utils.ts b/packages/connectors/connector-aws-ses/src/utils.ts index c059acc9d..a4c24c8bf 100644 --- a/packages/connectors/connector-aws-ses/src/utils.ts +++ b/packages/connectors/connector-aws-ses/src/utils.ts @@ -1,8 +1,9 @@ import type { EmailContent } from '@aws-sdk/client-sesv2'; import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; import type { AwsCredentialIdentity } from '@aws-sdk/types'; +import { replaceSendMessageHandlebars, type SendMessagePayload } from '@logto/connector-kit'; -import type { AwsSesConfig, Template, Payload } from './types.js'; +import type { AwsSesConfig, Template } from './types.js'; export const makeClient = ( accessKeyId: string, @@ -17,16 +18,13 @@ export const makeClient = ( return new SESv2Client({ credentials, region }); }; -export const makeEmailContent = (template: Template, payload: Payload): EmailContent => { +export const makeEmailContent = (template: Template, payload: SendMessagePayload): EmailContent => { return { Simple: { - Subject: { Data: template.subject, Charset: 'utf8' }, + Subject: { Data: replaceSendMessageHandlebars(template.subject, payload), Charset: 'utf8' }, Body: { Html: { - Data: - typeof payload.code === 'string' - ? template.content.replaceAll('{{code}}', payload.code) - : template.content, + Data: replaceSendMessageHandlebars(template.content, payload), }, }, }, diff --git a/packages/connectors/connector-logto-email/src/index.test.ts b/packages/connectors/connector-logto-email/src/index.test.ts index e230360d4..e38af0d89 100644 --- a/packages/connectors/connector-logto-email/src/index.test.ts +++ b/packages/connectors/connector-logto-email/src/index.test.ts @@ -1,7 +1,7 @@ import { got } from 'got'; import nock from 'nock'; -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { emailEndpoint, usageEndpoint } from './constant.js'; import createConnector from './index.js'; @@ -38,7 +38,7 @@ describe('sendMessage()', () => { await expect( sendMessage({ to: 'wangsijie94@gmail.com', - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, payload: { code: '1234' }, }) ).resolves.not.toThrow(); diff --git a/packages/connectors/connector-logto-email/src/index.ts b/packages/connectors/connector-logto-email/src/index.ts index 1a7451bd6..73fcc7344 100644 --- a/packages/connectors/connector-logto-email/src/index.ts +++ b/packages/connectors/connector-logto-email/src/index.ts @@ -38,6 +38,7 @@ const sendMessage = try { await client.post(`/api${emailEndpoint}`, { body: { + // @ts-expect-error TODO: @gao update cloud types and remove this data: { to, type, payload: { ...payload, senderName, companyInformation, appLogo } }, }, }); diff --git a/packages/connectors/connector-logto-sms/src/index.test.ts b/packages/connectors/connector-logto-sms/src/index.test.ts index 6d73ddbed..740d7b34f 100644 --- a/packages/connectors/connector-logto-sms/src/index.test.ts +++ b/packages/connectors/connector-logto-sms/src/index.test.ts @@ -1,6 +1,6 @@ import nock from 'nock'; -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { smsEndpoint } from './constant.js'; import { mockedAccessTokenResponse, mockedConfig } from './mock.js'; @@ -22,7 +22,7 @@ describe('sendMessage()', () => { await expect( connector.sendMessage({ to: '13000000000', - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, payload: { code: '1234' }, }) ).resolves.not.toThrow(); diff --git a/packages/connectors/connector-mailgun/src/index.test.ts b/packages/connectors/connector-mailgun/src/index.test.ts index 5d000bd0a..50468ce53 100644 --- a/packages/connectors/connector-mailgun/src/index.test.ts +++ b/packages/connectors/connector-mailgun/src/index.test.ts @@ -1,6 +1,6 @@ import nock from 'nock'; -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import createMailgunConnector from './index.js'; import { type MailgunConfig } from './types.js'; @@ -63,7 +63,7 @@ describe('Maligun connector', () => { getConfig.mockResolvedValue({ ...baseConfig, deliveries: { - [VerificationCodeType.Generic]: { + [TemplateType.Generic]: { subject: 'Verification code is {{code}}', html: '

Your verification code is {{code}}

', replyTo: 'baz@example.com', @@ -73,11 +73,38 @@ describe('Maligun connector', () => { await connector.sendMessage({ to: 'bar@example.com', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '123456' }, }); }); + it('should send email with raw data (2)', async () => { + nockMessages({ + from: baseConfig.from, + to: 'bar@example.com', + subject: 'Organization invitation', + html: '

Your link is https://example.com

', + 'h:Reply-To': 'baz@example.com', + }); + + getConfig.mockResolvedValue({ + ...baseConfig, + deliveries: { + [TemplateType.OrganizationInvitation]: { + subject: 'Organization invitation', + html: '

Your link is {{link}}

', + replyTo: 'baz@example.com', + }, + }, + }); + + await connector.sendMessage({ + to: 'bar@example.com', + type: TemplateType.OrganizationInvitation, + payload: { link: 'https://example.com', code: '123456' }, + }); + }); + it('should send email with template', async () => { nockMessages({ from: 'foo@example.com', @@ -90,7 +117,7 @@ describe('Maligun connector', () => { getConfig.mockResolvedValue({ ...baseConfig, deliveries: { - [VerificationCodeType.Generic]: { + [TemplateType.Generic]: { template: 'template', variables: { foo: 'bar' }, subject: 'Verification code is {{code}}', @@ -100,7 +127,7 @@ describe('Maligun connector', () => { await connector.sendMessage({ to: 'bar@example.com', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '123456', }, @@ -123,7 +150,7 @@ describe('Maligun connector', () => { ...baseConfig, endpoint: 'https://api.eu.mailgun.net', deliveries: { - [VerificationCodeType.Generic]: { + [TemplateType.Generic]: { template: 'template', variables: { foo: 'bar' }, subject: 'Verification code is {{code}}', @@ -133,7 +160,7 @@ describe('Maligun connector', () => { await connector.sendMessage({ to: 'bar@example.com', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '123456', }, @@ -152,7 +179,7 @@ describe('Maligun connector', () => { getConfig.mockResolvedValue({ ...baseConfig, deliveries: { - [VerificationCodeType.Generic]: { + [TemplateType.Generic]: { template: 'template', variables: { foo: 'bar' }, subject: 'Verification code is {{code}}', @@ -162,7 +189,7 @@ describe('Maligun connector', () => { await connector.sendMessage({ to: 'bar@example.com', - type: VerificationCodeType.ForgotPassword, + type: TemplateType.ForgotPassword, payload: { code: '123456', }, @@ -178,7 +205,7 @@ describe('Maligun connector', () => { await expect( connector.sendMessage({ to: '', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '123456', }, @@ -194,14 +221,14 @@ describe('Maligun connector', () => { code: '123456', }, }) - ).rejects.toThrowErrorMatchingInlineSnapshot('"ConnectorError: template_not_supported"'); + ).rejects.toThrowErrorMatchingInlineSnapshot('"ConnectorError: template_not_found"'); }); it('should throw error if mailgun returns error', async () => { getConfig.mockResolvedValue({ ...baseConfig, deliveries: { - [VerificationCodeType.Generic]: { + [TemplateType.Generic]: { template: 'template', variables: { foo: 'bar' }, subject: 'Verification code is {{code}}', @@ -214,7 +241,7 @@ describe('Maligun connector', () => { await expect( connector.sendMessage({ to: '', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '123456', }, diff --git a/packages/connectors/connector-mailgun/src/index.ts b/packages/connectors/connector-mailgun/src/index.ts index 4df212ab4..e54082817 100644 --- a/packages/connectors/connector-mailgun/src/index.ts +++ b/packages/connectors/connector-mailgun/src/index.ts @@ -5,27 +5,29 @@ import type { SendMessageFunction, CreateConnector, EmailConnector, + SendMessagePayload, } from '@logto/connector-kit'; import { ConnectorError, ConnectorErrorCodes, ConnectorType, validateConfig, - VerificationCodeType, + TemplateType, + replaceSendMessageHandlebars, } from '@logto/connector-kit'; import { defaultMetadata } from './constant.js'; -import { type DeliveryConfig, mailgunConfigGuard, supportTemplateGuard } from './types.js'; +import { type DeliveryConfig, mailgunConfigGuard } from './types.js'; const removeUndefinedKeys = (object: Record) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)); const getDataFromDeliveryConfig = ( { subject, replyTo, ...rest }: DeliveryConfig, - code: string + payload: SendMessagePayload ): Record => { const commonData = { - subject: subject?.replaceAll('{{code}}', code), + subject: subject && replaceSendMessageHandlebars(subject, payload), 'h:Reply-To': replyTo, }; @@ -33,30 +35,24 @@ const getDataFromDeliveryConfig = ( return { ...commonData, template: rest.template, - 'h:X-Mailgun-Variables': JSON.stringify({ ...rest.variables, code }), + 'h:X-Mailgun-Variables': JSON.stringify({ ...rest.variables, ...payload }), }; } return { ...commonData, - html: rest.html.replaceAll('{{code}}', code), - text: rest.text?.replaceAll('{{code}}', code), + html: replaceSendMessageHandlebars(rest.html, payload), + text: rest.text && replaceSendMessageHandlebars(rest.text, payload), }; }; const sendMessage = (getConfig: GetConnectorConfig): SendMessageFunction => { - return async ({ to, type: typeInput, payload: { code } }, inputConfig) => { + return async ({ to, type, payload }, inputConfig) => { const config = inputConfig ?? (await getConfig(defaultMetadata.id)); validateConfig(config, mailgunConfigGuard); const { endpoint, domain, apiKey, from, deliveries } = config; - const type = supportTemplateGuard.safeParse(typeInput); - - if (!type.success) { - throw new ConnectorError(ConnectorErrorCodes.TemplateNotSupported); - } - - const template = deliveries[type.data] ?? deliveries[VerificationCodeType.Generic]; + const template = deliveries[type] ?? deliveries[TemplateType.Generic]; if (!template) { throw new ConnectorError(ConnectorErrorCodes.TemplateNotFound); @@ -71,7 +67,7 @@ const sendMessage = (getConfig: GetConnectorConfig): SendMessageFunction => { form: { from, to, - ...removeUndefinedKeys(getDataFromDeliveryConfig(template, code)), + ...removeUndefinedKeys(getDataFromDeliveryConfig(template, payload)), }, } ); diff --git a/packages/connectors/connector-mailgun/src/type.test.ts b/packages/connectors/connector-mailgun/src/type.test.ts index 2aa00f830..a5c9a8648 100644 --- a/packages/connectors/connector-mailgun/src/type.test.ts +++ b/packages/connectors/connector-mailgun/src/type.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { mailgunConfigGuard } from './types.js'; @@ -9,21 +9,21 @@ describe('Mailgun config guard', () => { apiKey: 'key', from: 'from', deliveries: { - [VerificationCodeType.SignIn]: { + [TemplateType.SignIn]: { html: 'html', subject: 'subject', }, - [VerificationCodeType.Register]: { + [TemplateType.Register]: { template: 'template', variables: {}, subject: 'subject', }, - [VerificationCodeType.ForgotPassword]: { + [TemplateType.ForgotPassword]: { html: 'html', text: 'text', subject: 'subject', }, - [VerificationCodeType.Generic]: { + [TemplateType.Generic]: { template: 'template', variables: {}, subject: 'subject', @@ -39,7 +39,7 @@ describe('Mailgun config guard', () => { apiKey: 'key', from: 'from', deliveries: { - [VerificationCodeType.SignIn]: { + [TemplateType.SignIn]: { html: 'html', subject: 'subject', }, @@ -54,7 +54,7 @@ describe('Mailgun config guard', () => { apiKey: 'key', from: 'from', deliveries: { - [VerificationCodeType.ForgotPassword]: { + [TemplateType.ForgotPassword]: { text: 'text', subject: 'subject', }, @@ -70,7 +70,7 @@ describe('Mailgun config guard', () => { apiKey: 'key', from: 'from', deliveries: { - [VerificationCodeType.ForgotPassword]: { + [TemplateType.ForgotPassword]: { html: 'html', subject: 'subject', }, diff --git a/packages/connectors/connector-mailgun/src/types.ts b/packages/connectors/connector-mailgun/src/types.ts index 4fd8c50f1..95a6f6025 100644 --- a/packages/connectors/connector-mailgun/src/types.ts +++ b/packages/connectors/connector-mailgun/src/types.ts @@ -1,16 +1,5 @@ import { z } from 'zod'; -import { VerificationCodeType } from '@logto/connector-kit'; - -export const supportTemplateGuard = z.enum([ - VerificationCodeType.SignIn, - VerificationCodeType.Register, - VerificationCodeType.ForgotPassword, - VerificationCodeType.Generic, -]); - -type SupportTemplate = z.infer; - type CommonEmailConfig = { /** Subject of the message. */ subject?: string; @@ -65,7 +54,7 @@ export type MailgunConfig = { * The template config object for each template type, while the key is the template type * and the value is the config object. */ - deliveries: Partial>; + deliveries: Record; }; export const mailgunConfigGuard = z.object({ @@ -73,7 +62,5 @@ export const mailgunConfigGuard = z.object({ domain: z.string(), apiKey: z.string(), from: z.string(), - // Although the type it's expected, this guard should infer required keys. Looks like a mis-implemented in zod. - // See https://github.com/colinhacks/zod/issues/2623 - deliveries: z.record(supportTemplateGuard, templateConfigGuard), + deliveries: z.record(templateConfigGuard), }) satisfies z.ZodType; diff --git a/packages/connectors/connector-sendgrid-email/src/index.test.ts b/packages/connectors/connector-sendgrid-email/src/index.test.ts index 8a5dd446a..9f518865c 100644 --- a/packages/connectors/connector-sendgrid-email/src/index.test.ts +++ b/packages/connectors/connector-sendgrid-email/src/index.test.ts @@ -9,4 +9,6 @@ describe('SendGrid connector', () => { it('init without throwing errors', async () => { await expect(createConnector({ getConfig })).resolves.not.toThrow(); }); + + // TODO: add test cases }); diff --git a/packages/connectors/connector-sendgrid-email/src/index.ts b/packages/connectors/connector-sendgrid-email/src/index.ts index 3b1ec7e44..4ba0c6837 100644 --- a/packages/connectors/connector-sendgrid-email/src/index.ts +++ b/packages/connectors/connector-sendgrid-email/src/index.ts @@ -12,6 +12,7 @@ import { ConnectorErrorCodes, validateConfig, ConnectorType, + replaceSendMessageHandlebars, } from '@logto/connector-kit'; import { defaultMetadata, endpoint } from './constant.js'; @@ -42,17 +43,13 @@ const sendMessage = const personalizations: Personalization = { to: toEmailData }; const content: Content = { type: template.type, - value: - typeof payload.code === 'string' - ? template.content.replaceAll('{{code}}', payload.code) - : template.content, + value: replaceSendMessageHandlebars(template.content, payload), }; - const { subject } = template; const parameters: PublicParameters = { personalizations: [personalizations], from: fromEmailData, - subject, + subject: replaceSendMessageHandlebars(template.subject, payload), content: [content], }; diff --git a/packages/connectors/connector-smsaero/src/index.test.ts b/packages/connectors/connector-smsaero/src/index.test.ts index f2a33ba12..253b7ada7 100644 --- a/packages/connectors/connector-smsaero/src/index.test.ts +++ b/packages/connectors/connector-smsaero/src/index.test.ts @@ -9,4 +9,6 @@ describe('SMSAero SMS connector', () => { it('init without throwing errors', async () => { await expect(createConnector({ getConfig })).resolves.not.toThrow(); }); + + // TODO: add test cases }); diff --git a/packages/connectors/connector-smsaero/src/index.ts b/packages/connectors/connector-smsaero/src/index.ts index 66a6bbc9b..95465b055 100644 --- a/packages/connectors/connector-smsaero/src/index.ts +++ b/packages/connectors/connector-smsaero/src/index.ts @@ -11,6 +11,7 @@ import { ConnectorError, ConnectorErrorCodes, ConnectorType, + replaceSendMessageHandlebars, validateConfig, } from '@logto/connector-kit'; @@ -40,7 +41,7 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction { const parameters: PublicParameters = { number: to, sign: senderName, - text: template.content.replaceAll('{{code}}', payload.code), + text: replaceSendMessageHandlebars(template.content, payload), }; const auth = Buffer.from(`${email}:${apiKey}`).toString('base64'); diff --git a/packages/connectors/connector-smtp/src/index.test.ts b/packages/connectors/connector-smtp/src/index.test.ts index 51326deb4..ad4426045 100644 --- a/packages/connectors/connector-smtp/src/index.test.ts +++ b/packages/connectors/connector-smtp/src/index.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import type { Transporter } from 'nodemailer'; import nodemailer from 'nodemailer'; @@ -38,7 +38,7 @@ describe('SMTP connector', () => { const connector = await createConnector({ getConfig }); await connector.sendMessage({ to: 'foo', - type: VerificationCodeType.Register, + type: TemplateType.Register, payload: { code: '123456' }, }); @@ -50,11 +50,11 @@ describe('SMTP connector', () => { }); }); - it('should send mail with proper subject', async () => { + it('should send mail with proper data', async () => { const connector = await createConnector({ getConfig }); await connector.sendMessage({ to: 'bar', - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, payload: { code: '234567' }, }); @@ -65,6 +65,22 @@ describe('SMTP connector', () => { to: 'bar', }); }); + + it('should send mail with proper data (2)', async () => { + const connector = await createConnector({ getConfig }); + await connector.sendMessage({ + to: 'baz', + type: TemplateType.OrganizationInvitation, + payload: { code: '345678', link: 'https://example.com' }, + }); + + expect(sendMail).toHaveBeenCalledWith({ + from: '', + subject: 'Organization invitation', + text: 'This is for organization invitation. Your link is https://example.com.', + to: 'baz', + }); + }); }); describe('Test config guard', () => { diff --git a/packages/connectors/connector-smtp/src/index.ts b/packages/connectors/connector-smtp/src/index.ts index 0cc2b556b..1517923fd 100644 --- a/packages/connectors/connector-smtp/src/index.ts +++ b/packages/connectors/connector-smtp/src/index.ts @@ -11,6 +11,7 @@ import { ConnectorErrorCodes, validateConfig, ConnectorType, + replaceSendMessageHandlebars, } from '@logto/connector-kit'; import nodemailer from 'nodemailer'; import type SMTPTransport from 'nodemailer/lib/smtp-transport'; @@ -39,9 +40,7 @@ const sendMessage = const transporter = nodemailer.createTransport(configOptions); const contentsObject = parseContents( - typeof payload.code === 'string' - ? template.content.replaceAll(/{{\s*code\s*}}/g, payload.code) - : template.content, + replaceSendMessageHandlebars(template.content, payload), template.contentType ); @@ -49,7 +48,7 @@ const sendMessage = to, from: config.fromEmail, replyTo: config.replyTo, - subject: template.subject.replaceAll(/{{\s*code\s*}}/g, payload.code), + subject: replaceSendMessageHandlebars(template.subject, payload), ...contentsObject, }; diff --git a/packages/connectors/connector-smtp/src/mock.ts b/packages/connectors/connector-smtp/src/mock.ts index 16dae9729..87f2017d3 100644 --- a/packages/connectors/connector-smtp/src/mock.ts +++ b/packages/connectors/connector-smtp/src/mock.ts @@ -28,6 +28,12 @@ export const mockedConfig = { subject: 'Logto Forgot Password with SMTP', usageType: 'ForgotPassword', }, + { + contentType: 'text/plain', + content: 'This is for organization invitation. Your link is {{ link}}.', + subject: 'Organization invitation', + usageType: 'OrganizationInvitation', + }, ], }; diff --git a/packages/connectors/connector-tencent-sms/src/index.test.ts b/packages/connectors/connector-tencent-sms/src/index.test.ts index 4c58dec68..a30b51fed 100644 --- a/packages/connectors/connector-tencent-sms/src/index.test.ts +++ b/packages/connectors/connector-tencent-sms/src/index.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { codeTest, mockedConnectorConfig, mockedTemplateCode, phoneTest } from './mock.js'; @@ -47,7 +47,7 @@ describe('sendMessage()', () => { const connector = await createConnector({ getConfig }); await connector.sendMessage({ to: phoneTest, - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, payload: { code: codeTest }, }); expect(sendSmsRequest).toHaveBeenCalledWith( diff --git a/packages/connectors/connector-tencent-sms/src/index.ts b/packages/connectors/connector-tencent-sms/src/index.ts index 1d6fe34cc..dc072ffcf 100644 --- a/packages/connectors/connector-tencent-sms/src/index.ts +++ b/packages/connectors/connector-tencent-sms/src/index.ts @@ -46,7 +46,7 @@ function sendMessage(getConfig: GetConnectorConfig): SendMessageFunction { ); try { - const httpResponse = await sendSmsRequest(template.templateCode, [payload.code], to, { + const httpResponse = await sendSmsRequest(template.templateCode, Object.values(payload), to, { secretId: accessKeyId, secretKey: accessKeySecret, sdkAppId, diff --git a/packages/connectors/connector-twilio-sms/src/index.ts b/packages/connectors/connector-twilio-sms/src/index.ts index a5e00b192..3036cc10e 100644 --- a/packages/connectors/connector-twilio-sms/src/index.ts +++ b/packages/connectors/connector-twilio-sms/src/index.ts @@ -12,6 +12,7 @@ import { ConnectorErrorCodes, validateConfig, ConnectorType, + replaceSendMessageHandlebars, } from '@logto/connector-kit'; import { defaultMetadata, endpoint } from './constant.js'; @@ -38,10 +39,7 @@ const sendMessage = const parameters: PublicParameters = { To: to, MessagingServiceSid: fromMessagingServiceSID, - Body: - typeof payload.code === 'string' - ? template.content.replaceAll('{{code}}', payload.code) - : template.content, + Body: replaceSendMessageHandlebars(template.content, payload), }; try { diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index e92f3cbad..2d3203a1e 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import type { AdminConsoleData, Application, @@ -178,7 +178,7 @@ export const mockPasscode: Passcode = { interactionJti: 'jti', phone: '888 888 8888', email: 'foo@logto.io', - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, code: 'asdfghjkl', consumed: false, tryCount: 2, diff --git a/packages/core/src/libraries/connector.ts b/packages/core/src/libraries/connector.ts index 4333ed4de..42c725081 100644 --- a/packages/core/src/libraries/connector.ts +++ b/packages/core/src/libraries/connector.ts @@ -1,5 +1,10 @@ import { buildRawConnector, defaultConnectorMethods } from '@logto/cli/lib/connector/index.js'; -import type { AllConnector, ConnectorPlatform } from '@logto/connector-kit'; +import type { + AllConnector, + ConnectorPlatform, + EmailConnector, + SmsConnector, +} from '@logto/connector-kit'; import { validateConfig, ServiceConnector, ConnectorType } from '@logto/connector-kit'; import { type Nullable, conditional, pick, trySafe } from '@silverhand/essentials'; @@ -134,11 +139,44 @@ export const createConnectorLibrary = ( }); }; + /** Type of the connector that can send message of the given type. */ + type MappedConnectorType = { + [ConnectorType.Email]: LogtoConnector; + [ConnectorType.Sms]: LogtoConnector; + }; + + const getMessageConnector = async ( + type: Type + ): Promise => { + const connectors = await getLogtoConnectors(); + const connector = connectors.find( + (connector): connector is MappedConnectorType[Type] => connector.type === type + ); + assertThat( + connector, + // TODO: @gao refactor RequestError and ServerError to share the same base class + new RequestError({ + code: 'connector.not_found', + type, + status: 501, + }) + ); + return connector; + }; + return { getConnectorConfig, getLogtoConnectors, getLogtoConnectorsWellKnown, getLogtoConnectorById, getLogtoConnectorByTargetAndPlatform, + /** + * Get the connector that can send message of the given type. + * + * @param type The type of the connector to get. + * @returns The connector that can send message of the given type. + * @throws {RequestError} If no connector can send message of the given type (status 500). + */ + getMessageConnector, }; }; diff --git a/packages/core/src/libraries/organization-invitation.ts b/packages/core/src/libraries/organization-invitation.ts new file mode 100644 index 000000000..681d81a01 --- /dev/null +++ b/packages/core/src/libraries/organization-invitation.ts @@ -0,0 +1,90 @@ +import { ConnectorType, TemplateType } from '@logto/connector-kit'; +import { OrganizationInvitationStatus, type CreateOrganizationInvitation } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { appendPath } from '@silverhand/essentials'; + +import { EnvSet } from '#src/env-set/index.js'; +import { getTenantEndpoint } from '#src/env-set/utils.js'; +import MagicLinkQueries from '#src/queries/magic-link.js'; +import OrganizationQueries from '#src/queries/organization/index.js'; +import type Queries from '#src/tenants/Queries.js'; + +import { type ConnectorLibrary } from './connector.js'; + +const invitationLinkPath = '/invitation'; + +/** Class for managing organization invitations. */ +export class OrganizationInvitationLibrary { + constructor( + protected readonly tenantId: string, + protected readonly queries: Queries, + protected readonly connector: ConnectorLibrary + ) {} + + /** + * Creates a new organization invitation. + * + * Note: If the invitation email is not skipped, and the email cannot be sent, the transaction + * will be rolled back. + * + * @param data Invitation data. + * @param data.inviterId The user ID of the inviter. + * @param data.invitee The email address of the invitee. + * @param data.organizationId The ID of the organization to invite to. + * @param data.expiresAt The epoch time in milliseconds when the invitation expires. + * @param data.organizationRoleIds The IDs of the organization roles to assign to the invitee. + * @param skipEmail Whether to skip sending the invitation email. Defaults to `false`. + */ + async insert( + data: Pick< + CreateOrganizationInvitation, + 'inviterId' | 'invitee' | 'organizationId' | 'expiresAt' + > & { organizationRoleIds?: string[] }, + skipEmail = false + ) { + const { inviterId, invitee, organizationId, expiresAt, organizationRoleIds } = data; + + return this.queries.pool.transaction(async (connection) => { + const organizationQueries = new OrganizationQueries(connection); + const magicLinkQueries = new MagicLinkQueries(connection); + + const magicLink = await magicLinkQueries.insert({ + id: generateStandardId(), + token: generateStandardId(32), + }); + const invitation = await organizationQueries.invitations.insert({ + id: generateStandardId(), + inviterId, + invitee, + organizationId, + magicLinkId: magicLink.id, + status: OrganizationInvitationStatus.Pending, + expiresAt, + }); + + if (organizationRoleIds?.length) { + await organizationQueries.relations.invitationsRoles.insert( + ...organizationRoleIds.map<[string, string]>((roleId) => [invitation.id, roleId]) + ); + } + + if (!skipEmail) { + await this.sendEmail(invitee, magicLink.token); + } + + return invitation; + }); + } + + protected async sendEmail(to: string, token: string) { + const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email); + return emailConnector.sendMessage({ + to, + type: TemplateType.OrganizationInvitation, + payload: { + link: appendPath(getTenantEndpoint(this.tenantId, EnvSet.values), invitationLinkPath, token) + .href, + }, + }); + } +} diff --git a/packages/core/src/libraries/passcode.test.ts b/packages/core/src/libraries/passcode.test.ts index 5192a20a6..395900820 100644 --- a/packages/core/src/libraries/passcode.test.ts +++ b/packages/core/src/libraries/passcode.test.ts @@ -1,5 +1,5 @@ import { defaultConnectorMethods } from '@logto/cli/lib/connector/index.js'; -import { ConnectorType, VerificationCodeType } from '@logto/connector-kit'; +import { ConnectorType, TemplateType } from '@logto/connector-kit'; import { type Passcode } from '@logto/schemas'; import { any } from 'zod'; @@ -37,12 +37,12 @@ const { consumePasscode, } = passcodeQueries; -const getLogtoConnectors = jest.fn(); +const getMessageConnector = jest.fn(); const { createPasscode, sendPasscode, verifyPasscode } = createPasscodeLibrary( new MockQueries({ passcodes: passcodeQueries }), // @ts-expect-error - { getLogtoConnectors } + { getMessageConnector } ); beforeAll(() => { @@ -67,7 +67,7 @@ afterEach(() => { describe('createPasscode', () => { it('should generate `passcodeLength` digits code for phone with valid session and insert to database', async () => { const phone = '13000000000'; - const passcode = await createPasscode('jti', VerificationCodeType.SignIn, { + const passcode = await createPasscode('jti', TemplateType.SignIn, { phone, }); expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); @@ -76,7 +76,7 @@ describe('createPasscode', () => { it('should generate `passcodeLength` digits code for email with valid session and insert to database', async () => { const email = 'jony@example.com'; - const passcode = await createPasscode('jti', VerificationCodeType.SignIn, { + const passcode = await createPasscode('jti', TemplateType.SignIn, { email, }); expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); @@ -85,7 +85,7 @@ describe('createPasscode', () => { it('should generate `passcodeLength` digits code for phone and insert to database, without session', async () => { const phone = '13000000000'; - const passcode = await createPasscode(undefined, VerificationCodeType.Generic, { + const passcode = await createPasscode(undefined, TemplateType.Generic, { phone, }); expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); @@ -94,7 +94,7 @@ describe('createPasscode', () => { it('should generate `passcodeLength` digits code for email and insert to database, without session', async () => { const email = 'jony@example.com'; - const passcode = await createPasscode(undefined, VerificationCodeType.Generic, { + const passcode = await createPasscode(undefined, TemplateType.Generic, { email, }); expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); @@ -109,7 +109,7 @@ describe('createPasscode', () => { id: 'id', interactionJti: jti, code: '1234', - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, createdAt: Date.now(), phone: '', email, @@ -117,7 +117,7 @@ describe('createPasscode', () => { tryCount: 0, }, ]); - await createPasscode(jti, VerificationCodeType.SignIn, { + await createPasscode(jti, TemplateType.SignIn, { email, }); expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']); @@ -130,7 +130,7 @@ describe('createPasscode', () => { id: 'id', interactionJti: null, code: '123456', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, createdAt: Date.now(), phone, email: null, @@ -138,7 +138,7 @@ describe('createPasscode', () => { tryCount: 0, }, ]); - await createPasscode(undefined, VerificationCodeType.Generic, { + await createPasscode(undefined, TemplateType.Generic, { phone, }); expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']); @@ -153,7 +153,7 @@ describe('sendPasscode', () => { interactionJti: 'jti', phone: null, email: null, - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, code: '1234', consumed: false, tryCount: 0, @@ -164,82 +164,29 @@ describe('sendPasscode', () => { ); }); - it('should throw error when email or sms connector can not be found', async () => { - getLogtoConnectors.mockResolvedValueOnce([ - { - ...defaultConnectorMethods, - dbEntry: { - ...mockConnector, - id: 'id1', - }, - metadata: { - ...mockMetadata, - platform: null, - }, - type: ConnectorType.Email, - sendMessage: jest.fn(), - configGuard: any(), - }, - ]); - const passcode: Passcode = { - tenantId: 'fake_tenant', - id: 'id', - interactionJti: 'jti', - phone: 'phone', - email: null, - type: VerificationCodeType.SignIn, - code: '1234', - consumed: false, - tryCount: 0, - createdAt: Date.now(), - }; - await expect(sendPasscode(passcode)).rejects.toThrowError( - new RequestError({ - code: 'connector.not_found', - type: ConnectorType.Sms, - }) - ); - }); - it('should call sendPasscode with params matching', async () => { const sendMessage = jest.fn(); - getLogtoConnectors.mockResolvedValueOnce([ - { - ...defaultConnectorMethods, - configGuard: any(), - dbEntry: { - ...mockConnector, - id: 'id0', - }, - metadata: { - ...mockMetadata, - platform: null, - }, - type: ConnectorType.Sms, - sendMessage, + getMessageConnector.mockResolvedValueOnce({ + ...defaultConnectorMethods, + configGuard: any(), + dbEntry: { + ...mockConnector, + id: 'id0', }, - { - ...defaultConnectorMethods, - configGuard: any(), - dbEntry: { - ...mockConnector, - id: 'id1', - }, - metadata: { - ...mockMetadata, - platform: null, - }, - type: ConnectorType.Email, - sendMessage, + metadata: { + ...mockMetadata, + platform: null, }, - ]); + type: ConnectorType.Sms, + sendMessage, + }); const passcode: Passcode = { tenantId: 'fake_tenant', id: 'passcode_id', interactionJti: 'jti', phone: 'phone', email: null, - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, code: '1234', consumed: false, tryCount: 0, @@ -263,7 +210,7 @@ describe('verifyPasscode', () => { interactionJti: 'jti', phone: 'phone', email: null, - type: VerificationCodeType.SignIn, + type: TemplateType.SignIn, code: '1234', consumed: false, tryCount: 0, @@ -286,7 +233,7 @@ describe('verifyPasscode', () => { it('should mark as consumed on successful verification without jti', async () => { const passcodeWithoutJti = { ...passcode, - type: VerificationCodeType.Generic, + type: TemplateType.Generic, interactionJti: null, }; findUnconsumedPasscodeByIdentifierAndType.mockResolvedValue(passcodeWithoutJti); diff --git a/packages/core/src/libraries/passcode.ts b/packages/core/src/libraries/passcode.ts index b1534b1e6..fce277a9b 100644 --- a/packages/core/src/libraries/passcode.ts +++ b/packages/core/src/libraries/passcode.ts @@ -1,18 +1,12 @@ -import type { EmailConnector, VerificationCodeType, SmsConnector } from '@logto/connector-kit'; -import { - verificationCodeTypeGuard, - ConnectorError, - ConnectorErrorCodes, -} from '@logto/connector-kit'; +import type { TemplateType } from '@logto/connector-kit'; +import { templateTypeGuard, ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; import type { Passcode } from '@logto/schemas'; import { customAlphabet, nanoid } from 'nanoid'; import RequestError from '#src/errors/RequestError/index.js'; import type { ConnectorLibrary } from '#src/libraries/connector.js'; import type Queries from '#src/tenants/Queries.js'; -import assertThat from '#src/utils/assert-that.js'; import { ConnectorType } from '#src/utils/connectors/types.js'; -import type { LogtoConnector } from '#src/utils/connectors/types.js'; export const passcodeLength = 6; const randomCode = customAlphabet('1234567890', passcodeLength); @@ -33,11 +27,11 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec increasePasscodeTryCount, insertPasscode, } = queries.passcodes; - const { getLogtoConnectors } = connectorLibrary; + const { getMessageConnector } = connectorLibrary; const createPasscode = async ( jti: string | undefined, - type: VerificationCodeType, + type: TemplateType, payload: { phone: string } | { email: string } ) => { // Disable existing passcodes. @@ -68,24 +62,10 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec } const expectType = passcode.phone ? ConnectorType.Sms : ConnectorType.Email; - const connectors = await getLogtoConnectors(); - - const connector = connectors.find( - (connector): connector is LogtoConnector => - connector.type === expectType - ); - - assertThat( - connector, - new RequestError({ - code: 'connector.not_found', - type: expectType, - }) - ); - + const connector = await getMessageConnector(expectType); const { dbEntry, metadata, sendMessage } = connector; - const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type); + const messageTypeResult = templateTypeGuard.safeParse(passcode.type); if (!messageTypeResult.success) { throw new ConnectorError(ConnectorErrorCodes.InvalidConfig); @@ -104,7 +84,7 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec const verifyPasscode = async ( jti: string | undefined, - type: VerificationCodeType, + type: TemplateType, code: string, payload: { phone: string } | { email: string } ): Promise => { diff --git a/packages/core/src/queries/magic-link.ts b/packages/core/src/queries/magic-link.ts new file mode 100644 index 000000000..11c9670e9 --- /dev/null +++ b/packages/core/src/queries/magic-link.ts @@ -0,0 +1,19 @@ +import { + type CreateMagicLink, + type MagicLink, + type MagicLinkKeys, + MagicLinks, +} from '@logto/schemas'; +import { type CommonQueryMethods } from 'slonik'; + +import SchemaQueries from '#src/utils/SchemaQueries.js'; + +export default class MagicLinkQueries extends SchemaQueries< + MagicLinkKeys, + CreateMagicLink, + MagicLink +> { + constructor(pool: CommonQueryMethods) { + super(pool, MagicLinks); + } +} diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index df4f909ee..36d377a2e 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -195,6 +195,12 @@ export default class OrganizationQueries extends SchemaQueries< users: new UserRelationQueries(this.pool), /** Queries for organization - organization role - user relations. */ rolesUsers: new RoleUserRelationQueries(this.pool), + invitationsRoles: new TwoRelationsQueries( + this.pool, + OrganizationInvitationRoleRelations.table, + OrganizationInvitations, + OrganizationRoles + ), }; constructor(pool: CommonQueryMethods) { diff --git a/packages/core/src/queries/passcode.test.ts b/packages/core/src/queries/passcode.test.ts index 94c6c647e..fb83fc9ab 100644 --- a/packages/core/src/queries/passcode.test.ts +++ b/packages/core/src/queries/passcode.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { Passcodes } from '@logto/schemas'; import { convertToIdentifiers, convertToPrimitiveOrSql, excludeAutoSetFields } from '@logto/shared'; import { createMockPool, createMockQueryResult, sql } from 'slonik'; @@ -35,7 +35,7 @@ describe('passcode query', () => { it('findUnconsumedPasscodeByJtiAndType', async () => { const jti = 'foo'; - const type = VerificationCodeType.SignIn; + const type = TemplateType.SignIn; const expectSql = sql` select ${sql.join(Object.values(fields), sql`, `)} @@ -55,7 +55,7 @@ describe('passcode query', () => { it('findUnconsumedPasscodesByJtiAndType', async () => { const jti = 'foo'; - const type = VerificationCodeType.SignIn; + const type = TemplateType.SignIn; const expectSql = sql` select ${sql.join(Object.values(fields), sql`, `)} @@ -74,7 +74,7 @@ describe('passcode query', () => { }); it('findUnconsumedPasscodeByIdentifierAndType', async () => { - const type = VerificationCodeType.Generic; + const type = TemplateType.Generic; const phone = '1234567890'; const mockGenericPasscode = { ...mockPasscode, interactionJti: null, type, phone }; @@ -99,7 +99,7 @@ describe('passcode query', () => { }); it('findUnconsumedPasscodesByIdentifierAndType', async () => { - const type = VerificationCodeType.Generic; + const type = TemplateType.Generic; const email = 'johndoe@example.com'; const mockGenericPasscode = { ...mockPasscode, interactionJti: null, type, email }; diff --git a/packages/core/src/queries/passcode.ts b/packages/core/src/queries/passcode.ts index 1c1ac7e78..557001fb0 100644 --- a/packages/core/src/queries/passcode.ts +++ b/packages/core/src/queries/passcode.ts @@ -1,4 +1,4 @@ -import type { VerificationCodeType } from '@logto/connector-kit'; +import type { TemplateType } from '@logto/connector-kit'; import type { Passcode, RequestVerificationCodePayload } from '@logto/schemas'; import { Passcodes } from '@logto/schemas'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; @@ -11,10 +11,10 @@ import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Passcodes); type FindByIdentifierAndTypeProperties = { - type: VerificationCodeType; + type: TemplateType; } & RequestVerificationCodePayload; -const buildSqlForFindByJtiAndType = (jti: string, type: VerificationCodeType) => sql` +const buildSqlForFindByJtiAndType = (jti: string, type: TemplateType) => sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${fields.consumed} = false @@ -40,10 +40,10 @@ const buildSqlForFindByIdentifierAndType = ({ `; export const createPasscodeQueries = (pool: CommonQueryMethods) => { - const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: VerificationCodeType) => + const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: TemplateType) => pool.maybeOne(buildSqlForFindByJtiAndType(jti, type)); - const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: VerificationCodeType) => + const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: TemplateType) => pool.any(buildSqlForFindByJtiAndType(jti, type)); const findUnconsumedPasscodeByIdentifierAndType = async ( diff --git a/packages/core/src/routes-me/verification-code.ts b/packages/core/src/routes-me/verification-code.ts index 8c2b9833e..0b3d136dd 100644 --- a/packages/core/src/routes-me/verification-code.ts +++ b/packages/core/src/routes-me/verification-code.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { emailRegEx } from '@logto/core-kit'; import { literal, object, string, union } from 'zod'; @@ -12,7 +12,7 @@ import type { AuthedMeRouter } from './types.js'; export default function verificationCodeRoutes( ...[router, tenant]: RouterInitArgs ) { - const codeType = VerificationCodeType.Generic; + const codeType = TemplateType.Generic; const { queries: { users: { findUserById }, diff --git a/packages/core/src/routes/connector/config-testing.test.ts b/packages/core/src/routes/connector/config-testing.test.ts index 64b5fb4dd..c3dd68bc0 100644 --- a/packages/core/src/routes/connector/config-testing.test.ts +++ b/packages/core/src/routes/connector/config-testing.test.ts @@ -1,6 +1,6 @@ import type { ConnectorFactory } from '@logto/cli/lib/connector/index.js'; import type router from '@logto/cloud/routes'; -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import type { EmailConnector, SmsConnector } from '@logto/connector-kit'; import { ConnectorType } from '@logto/schemas'; import { pickDefault, createMockUtils } from '@logto/shared/esm'; @@ -82,7 +82,7 @@ describe('connector services route', () => { expect(sendMessage).toHaveBeenCalledWith( { to: '12345678901', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '000000', }, @@ -109,7 +109,7 @@ describe('connector services route', () => { expect(sendMessage).toHaveBeenCalledWith( { to: 'test@email.com', - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '000000', }, diff --git a/packages/core/src/routes/connector/config-testing.ts b/packages/core/src/routes/connector/config-testing.ts index 64f998a4a..6e18bdac6 100644 --- a/packages/core/src/routes/connector/config-testing.ts +++ b/packages/core/src/routes/connector/config-testing.ts @@ -5,7 +5,7 @@ import { type SmsConnector, type EmailConnector, demoConnectorIds, - VerificationCodeType, + TemplateType, } from '@logto/connector-kit'; import { ServiceConnector } from '@logto/connector-kit'; import { phoneRegEx, emailRegEx } from '@logto/core-kit'; @@ -81,7 +81,7 @@ export default function connectorConfigTestingRoutes( await sendMessage( { to: subject, - type: VerificationCodeType.Generic, + type: TemplateType.Generic, payload: { code: '000000', }, diff --git a/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts b/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts index cfdf925e3..4776605e7 100644 --- a/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts +++ b/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { InteractionEvent } from '@logto/schemas'; import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; @@ -15,27 +15,27 @@ const { sendVerificationCodeToIdentifier } = await import('./verification-code-v const sendVerificationCodeTestCase = [ { payload: { email: 'email', event: InteractionEvent.SignIn }, - createVerificationCodeParams: [VerificationCodeType.SignIn, { email: 'email' }], + createVerificationCodeParams: [TemplateType.SignIn, { email: 'email' }], }, { payload: { email: 'email', event: InteractionEvent.Register }, - createVerificationCodeParams: [VerificationCodeType.Register, { email: 'email' }], + createVerificationCodeParams: [TemplateType.Register, { email: 'email' }], }, { payload: { email: 'email', event: InteractionEvent.ForgotPassword }, - createVerificationCodeParams: [VerificationCodeType.ForgotPassword, { email: 'email' }], + createVerificationCodeParams: [TemplateType.ForgotPassword, { email: 'email' }], }, { payload: { phone: 'phone', event: InteractionEvent.SignIn }, - createVerificationCodeParams: [VerificationCodeType.SignIn, { phone: 'phone' }], + createVerificationCodeParams: [TemplateType.SignIn, { phone: 'phone' }], }, { payload: { phone: 'phone', event: InteractionEvent.Register }, - createVerificationCodeParams: [VerificationCodeType.Register, { phone: 'phone' }], + createVerificationCodeParams: [TemplateType.Register, { phone: 'phone' }], }, { payload: { phone: 'phone', event: InteractionEvent.ForgotPassword }, - createVerificationCodeParams: [VerificationCodeType.ForgotPassword, { phone: 'phone' }], + createVerificationCodeParams: [TemplateType.ForgotPassword, { phone: 'phone' }], }, ]; diff --git a/packages/core/src/routes/interaction/utils/verification-code-validation.ts b/packages/core/src/routes/interaction/utils/verification-code-validation.ts index c0bcc643c..38ea73fc3 100644 --- a/packages/core/src/routes/interaction/utils/verification-code-validation.ts +++ b/packages/core/src/routes/interaction/utils/verification-code-validation.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import type { InteractionEvent, RequestVerificationCodePayload, @@ -10,16 +10,16 @@ import type { LogContext } from '#src/middleware/koa-audit-log.js'; /** * Refactor Needed: - * This is a work around to map the latest interaction event type to old VerificationCodeType + * This is a work around to map the latest interaction event type to old TemplateType * */ -const eventToVerificationCodeTypeMap: Record = { - SignIn: VerificationCodeType.SignIn, - Register: VerificationCodeType.Register, - ForgotPassword: VerificationCodeType.ForgotPassword, +const eventToTemplateTypeMap: Record = { + SignIn: TemplateType.SignIn, + Register: TemplateType.Register, + ForgotPassword: TemplateType.ForgotPassword, }; -const getVerificationCodeTypeByEvent = (event: InteractionEvent): VerificationCodeType => - eventToVerificationCodeTypeMap[event]; +const getTemplateTypeByEvent = (event: InteractionEvent): TemplateType => + eventToTemplateTypeMap[event]; export const sendVerificationCodeToIdentifier = async ( payload: RequestVerificationCodePayload & { event: InteractionEvent }, @@ -28,7 +28,7 @@ export const sendVerificationCodeToIdentifier = async ( { createPasscode, sendPasscode }: PasscodeLibrary ) => { const { event, ...identifier } = payload; - const messageType = getVerificationCodeTypeByEvent(event); + const messageType = getTemplateTypeByEvent(event); const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Create`); log.append(identifier); @@ -46,7 +46,7 @@ export const verifyIdentifierByVerificationCode = async ( passcodeLibrary: PasscodeLibrary ) => { const { event, verificationCode, ...identifier } = payload; - const messageType = getVerificationCodeTypeByEvent(event); + const messageType = getTemplateTypeByEvent(event); const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Submit`); log.append(identifier); diff --git a/packages/core/src/routes/organization/invitations.openapi.json b/packages/core/src/routes/organization/invitations.openapi.json index 12000d2a4..891a765a8 100644 --- a/packages/core/src/routes/organization/invitations.openapi.json +++ b/packages/core/src/routes/organization/invitations.openapi.json @@ -16,6 +16,56 @@ "description": "A list of organization invitations, each item also contains the organization roles to be assigned to the user when they accept the invitation, and the corresponding magic link data." } } + }, + "post": { + "summary": "Create organization invitation", + "description": "Create an organization invitation and optionally send it via email. The tenant should have an email connector configured if you want to send the invitation via email at this point.", + "parameters": [ + { + "in": "query", + "name": "skipEmail", + "description": "If true, the invitation will not be sent via email; otherwise, the invitation will be sent via email when it is created. If the email is failed to send, the invitation will not be created.", + "required": false, + "schema": { + "default": false + } + } + ], + "requestBody": { + "description": "The organization invitation to create.", + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "inviterId": { + "description": "The ID of the user who is inviting the user to join the organization." + }, + "invitee": { + "description": "The email address of the user to invite to join the organization." + }, + "organizationId": { + "description": "The ID of the organization to invite the user to join." + }, + "expiresAt": { + "description": "The epoch time in milliseconds when the invitation expires." + }, + "organizationRoleIds": { + "description": "The IDs of the organization roles to assign to the user when they accept the invitation." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The organization invitation was created successfully, and the corresponding magic link data." + }, + "501": { + "description": "No email connector is configured for the tenant." + } + } } }, "/api/organization-invitations/{id}": { diff --git a/packages/core/src/routes/organization/invitations.ts b/packages/core/src/routes/organization/invitations.ts index 70be05bfa..17d32d89e 100644 --- a/packages/core/src/routes/organization/invitations.ts +++ b/packages/core/src/routes/organization/invitations.ts @@ -1,9 +1,14 @@ import { OrganizationInvitations } from '@logto/schemas'; +import { z } from 'zod'; +import koaGuard from '#src/middleware/koa-guard.js'; import SchemaRouter from '#src/utils/SchemaRouter.js'; +import assertThat from '#src/utils/assert-that.js'; import { type AuthedRouter, type RouterInitArgs } from '../types.js'; +import { errorHandler } from './utils.js'; + export default function organizationInvitationRoutes( ...[ originalRouter, @@ -11,15 +16,51 @@ export default function organizationInvitationRoutes( queries: { organizations: { invitations }, }, + libraries: { organizationInvitations }, }, ]: RouterInitArgs ) { const router = new SchemaRouter(OrganizationInvitations, invitations, { + errorHandler, disabled: { post: true, patchById: true, }, }); + router.post( + '/', + koaGuard({ + query: z.object({ + skipEmail: z.boolean().optional(), + }), + body: OrganizationInvitations.createGuard + .pick({ + inviterId: true, + invitee: true, + organizationId: true, + expiresAt: true, + }) + .extend({ + invitee: z.string().email(), + organizationRoleIds: z.string().array().optional(), + }), + response: OrganizationInvitations.guard, + status: [201], + }), + async (ctx) => { + const { query, body } = ctx.guard; + + assertThat( + body.expiresAt > Date.now(), + // TODO: Throw `RequestError` instead. + new Error('The value of `expiresAt` must be in the future.') + ); + + ctx.body = await organizationInvitations.insert(body, query.skipEmail); + ctx.body = 201; + } + ); + originalRouter.use(router.routes()); } diff --git a/packages/core/src/routes/verification-code.test.ts b/packages/core/src/routes/verification-code.test.ts index 0e9f4e517..91aa14ffc 100644 --- a/packages/core/src/routes/verification-code.test.ts +++ b/packages/core/src/routes/verification-code.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { createMockUtils, pickDefault } from '@logto/shared/esm'; import { MockTenant } from '#src/test-utils/tenant.js'; @@ -30,7 +30,7 @@ describe('Generic verification code flow triggered by management API', () => { authedRoutes: verificationCodeRoutes, tenantContext, }); - const type = VerificationCodeType.Generic; + const type = TemplateType.Generic; afterEach(() => { jest.clearAllMocks(); diff --git a/packages/core/src/routes/verification-code.ts b/packages/core/src/routes/verification-code.ts index 01bfd8bc0..538f5645f 100644 --- a/packages/core/src/routes/verification-code.ts +++ b/packages/core/src/routes/verification-code.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { requestVerificationCodePayloadGuard, verifyVerificationCodePayloadGuard, @@ -8,7 +8,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; -const codeType = VerificationCodeType.Generic; +const codeType = TemplateType.Generic; export default function verificationCodeRoutes( ...[router, { libraries }]: RouterInitArgs diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index c08c9038e..7030b97e4 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -3,6 +3,7 @@ import { type CloudConnectionLibrary } from '#src/libraries/cloud-connection.js' import type { ConnectorLibrary } from '#src/libraries/connector.js'; import { createDomainLibrary } from '#src/libraries/domain.js'; import { createHookLibrary } from '#src/libraries/hook/index.js'; +import { OrganizationInvitationLibrary } from '#src/libraries/organization-invitation.js'; import { createPasscodeLibrary } from '#src/libraries/passcode.js'; import { createPhraseLibrary } from '#src/libraries/phrase.js'; import { createProtectedAppLibrary } from '#src/libraries/protected-app.js'; @@ -34,6 +35,12 @@ export default class Libraries { this.cloudConnection ); + organizationInvitations = new OrganizationInvitationLibrary( + this.tenantId, + this.queries, + this.connectors + ); + constructor( public readonly tenantId: string, private readonly queries: Queries, diff --git a/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts b/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts index 41ddbb6ca..8348dfcc4 100644 --- a/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/api-counter-cases/post-send-verification-code.test.ts @@ -10,7 +10,7 @@ import { generateEmail, generatePhone } from '#src/utils.js'; * cannot be covered within the auth flow. */ describe('POST /interaction/verification/verification-code', () => { - it('Should fail to send email verification code if related connector is not found', async () => { + it('should fail to send email verification code if related connector is not found', async () => { const client = await initClient(); await client.successSend(putInteraction, { @@ -23,12 +23,12 @@ describe('POST /interaction/verification/verification-code', () => { }), { code: 'connector.not_found', - statusCode: 400, + statusCode: 501, } ); }); - it('Should fail to send phone verification code if related connector is not found', async () => { + it('should fail to send phone verification code if related connector is not found', async () => { const client = await initClient(); await client.successSend(putInteraction, { @@ -41,7 +41,7 @@ describe('POST /interaction/verification/verification-code', () => { }), { code: 'connector.not_found', - statusCode: 400, + statusCode: 501, } ); }); diff --git a/packages/integration-tests/src/tests/api/swagger-check.test.ts b/packages/integration-tests/src/tests/api/swagger-check.test.ts index c4d857934..fadd7a8ca 100644 --- a/packages/integration-tests/src/tests/api/swagger-check.test.ts +++ b/packages/integration-tests/src/tests/api/swagger-check.test.ts @@ -13,16 +13,11 @@ describe('Swagger check', () => { expect(response.headers['content-type']).toContain('application/json'); // Use multiple validators to be more confident - expect(async () => { - const object: unknown = JSON.parse(response.body); + const object: unknown = JSON.parse(response.body); - const validator = new OpenApiSchemaValidator({ version: 3 }); - const result = validator.validate(object as OpenAPI.Document); - expect(result.errors).toEqual([]); - - await expect( - SwaggerParser.default.validate(object as OpenAPI.Document) - ).resolves.not.toThrow(); - }).not.toThrow(); + const validator = new OpenApiSchemaValidator({ version: 3 }); + const result = validator.validate(object as OpenAPI.Document); + expect(result.errors).toEqual([]); + await expect(SwaggerParser.default.validate(object as OpenAPI.Document)).resolves.not.toThrow(); }); }); diff --git a/packages/integration-tests/src/tests/api/verification-code.test.ts b/packages/integration-tests/src/tests/api/verification-code.test.ts index 9a18d3a46..3e47f13d3 100644 --- a/packages/integration-tests/src/tests/api/verification-code.test.ts +++ b/packages/integration-tests/src/tests/api/verification-code.test.ts @@ -1,4 +1,4 @@ -import { VerificationCodeType } from '@logto/connector-kit'; +import { TemplateType } from '@logto/connector-kit'; import { ConnectorType, type RequestVerificationCodePayload } from '@logto/schemas'; import { requestVerificationCode, verifyVerificationCode } from '#src/api/verification-code.js'; @@ -36,7 +36,7 @@ describe('Generic verification code through management API', () => { const { code, type, address } = await readVerificationCode(); - expect(type).toBe(VerificationCodeType.Generic); + expect(type).toBe(TemplateType.Generic); expect(address).toBe(mockEmail); expect(code).not.toBeNull(); }); @@ -48,7 +48,7 @@ describe('Generic verification code through management API', () => { const { code, type, phone } = await readVerificationCode(); - expect(type).toBe(VerificationCodeType.Generic); + expect(type).toBe(TemplateType.Generic); expect(phone).toBe(mockPhone); expect(code).not.toBeNull(); }); @@ -67,7 +67,7 @@ describe('Generic verification code through management API', () => { await clearConnectorsByTypes([ConnectorType.Email]); await expectRejects(requestVerificationCode({ email: emailForTestSendCode }), { code: 'connector.not_found', - statusCode: 400, + statusCode: 501, }); await expect( @@ -91,7 +91,7 @@ describe('Generic verification code through management API', () => { await clearConnectorsByTypes([ConnectorType.Sms]); await expectRejects(requestVerificationCode({ phone: phoneForTestSendCode }), { code: 'connector.not_found', - statusCode: 400, + statusCode: 501, }); await expect( diff --git a/packages/toolkit/connector-kit/src/index.test.ts b/packages/toolkit/connector-kit/src/index.test.ts index 3a57b6521..5016b4007 100644 --- a/packages/toolkit/connector-kit/src/index.test.ts +++ b/packages/toolkit/connector-kit/src/index.test.ts @@ -1,50 +1,83 @@ import { z } from 'zod'; -import { parseJson, parseJsonObject, validateConfig } from './index.js'; +import { + parseJson, + parseJsonObject, + replaceSendMessageHandlebars, + validateConfig, +} from './index.js'; -describe('connector-kit', () => { - describe('validateConfig', () => { - it('valid config', () => { - const testingTypeGuard = z.unknown(); - type TestingType = z.infer; - const testingConfig = { foo: 'foo', bar: 1, baz: true }; - expect(() => { - validateConfig(testingConfig, testingTypeGuard); - }).not.toThrow(); - }); - - it('invalid config', () => { - const testingTypeGuard = z.record(z.string()); - type TestingType = z.infer; - const testingConfig = { foo: 'foo', bar: 1 }; - expect(() => { - validateConfig(testingConfig, testingTypeGuard); - }).toThrow(); - }); +describe('validateConfig', () => { + it('valid config', () => { + const testingTypeGuard = z.unknown(); + const testingConfig = { foo: 'foo', bar: 1, baz: true }; + expect(() => { + validateConfig(testingConfig, testingTypeGuard); + }).not.toThrow(); }); - describe('parseJson', () => { - it('should return parsed result', () => { - const literalContent = 'foo'; - expect(parseJson(JSON.stringify(literalContent))).toEqual(literalContent); - - const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] }; - expect(parseJson(JSON.stringify(objectContent))).toEqual(objectContent); - }); - - it('throw error when parsing invalid Json string', () => { - expect(() => parseJson('[1,2,3,"4",]')).toThrow(); - }); - }); - - describe('parseJsonObject', () => { - it('should return parsed object', () => { - const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] }; - expect(parseJsonObject(JSON.stringify(objectContent))).toEqual(objectContent); - }); - - it('throw error when parsing non-object result', () => { - expect(() => parseJsonObject(JSON.stringify('foo'))).toThrow(); - }); + it('invalid config', () => { + const testingTypeGuard = z.record(z.string()); + const testingConfig = { foo: 'foo', bar: 1 }; + expect(() => { + validateConfig(testingConfig, testingTypeGuard); + }).toThrow(); + }); +}); + +describe('parseJson', () => { + it('should return parsed result', () => { + const literalContent = 'foo'; + expect(parseJson(JSON.stringify(literalContent))).toEqual(literalContent); + + const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] }; + expect(parseJson(JSON.stringify(objectContent))).toEqual(objectContent); + }); + + it('throw error when parsing invalid Json string', () => { + expect(() => parseJson('[1,2,3,"4",]')).toThrow(); + }); +}); + +describe('parseJsonObject', () => { + it('should return parsed object', () => { + const objectContent = { foo: 'foo', bar: 1, baz: true, qux: [1, '2', null] }; + expect(parseJsonObject(JSON.stringify(objectContent))).toEqual(objectContent); + }); + + it('throw error when parsing non-object result', () => { + expect(() => parseJsonObject(JSON.stringify('foo'))).toThrow(); + }); +}); + +describe('replaceSendMessageHandlebars', () => { + it('should replace handlebars with payload', () => { + const template = 'Your verification code is {{code}}'; + const payload = { code: '123456' }; + expect(replaceSendMessageHandlebars(template, payload)).toEqual( + 'Your verification code is 123456' + ); + }); + + it('should replace handlebars with empty string if payload does not contain the key', () => { + const template = 'Your verification code is {{code}}'; + const payload = {}; + expect(replaceSendMessageHandlebars(template, payload)).toEqual('Your verification code is '); + }); + + it('should ignore handlebars that are not in the predefined list for both template and payload', () => { + const template = 'Your verification code is {{code}} and {{foo}}'; + const payload = { code: '123456', foo: 'bar' }; + expect(replaceSendMessageHandlebars(template, payload)).toEqual( + 'Your verification code is 123456 and {{foo}}' + ); + }); + + it('should replace handlebars that have extra spaces with payload', () => { + const template = 'Your verification code is {{ code }}'; + const payload = { code: '123456' }; + expect(replaceSendMessageHandlebars(template, payload)).toEqual( + 'Your verification code is 123456' + ); }); }); diff --git a/packages/toolkit/connector-kit/src/index.ts b/packages/toolkit/connector-kit/src/index.ts index 09c24d49a..b95ac72c3 100644 --- a/packages/toolkit/connector-kit/src/index.ts +++ b/packages/toolkit/connector-kit/src/index.ts @@ -1,6 +1,11 @@ import type { ZodType, ZodTypeDef } from 'zod'; -import { ConnectorError, ConnectorErrorCodes } from './types/index.js'; +import { + ConnectorError, + ConnectorErrorCodes, + sendMessagePayloadKeys, + type SendMessagePayload, +} from './types/index.js'; export * from './types/index.js'; @@ -38,3 +43,35 @@ export const parseJsonObject = (...args: Parameters) => { }; export const mockSmsVerificationCodeFileName = 'logto_mock_verification_code_record.txt'; + +/** + * Replace all handlebars that match the keys in {@link SendMessagePayload} with the payload + * values. If the payload does not contain the key, the handlebar will be replaced with an empty + * string. + * + * @param template The template to replace the handlebars with. + * @param payload The payload to replace the handlebars with. + * @returns The replaced template. + * + * @example + * ```ts + * replaceSendMessageKeysWithPayload('Your verification code is {{code}}', { code: '123456' }); + * // 'Your verification code is 123456' + * ``` + * + * @example + * ```ts + * replaceSendMessageKeysWithPayload('Your verification code is {{code}}', {}); + * // 'Your verification code is ' + * ``` + */ +export const replaceSendMessageHandlebars = ( + template: string, + payload: SendMessagePayload +): string => { + return sendMessagePayloadKeys.reduce( + (accumulator, key) => + accumulator.replaceAll(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), payload[key] ?? ''), + template + ); +}; diff --git a/packages/toolkit/connector-kit/src/types/error.ts b/packages/toolkit/connector-kit/src/types/error.ts index 0e976502b..64258eeed 100644 --- a/packages/toolkit/connector-kit/src/types/error.ts +++ b/packages/toolkit/connector-kit/src/types/error.ts @@ -10,7 +10,11 @@ export enum ConnectorErrorCodes { InvalidResponse = 'invalid_response', /** The template is not found for the given type. */ TemplateNotFound = 'template_not_found', - /** The template type is not supported by the connector. */ + /** + * The template type is not supported by the connector. + * + * @deprecated Connector should be able to handle dynamic template type. + */ TemplateNotSupported = 'template_not_supported', RateLimitExceeded = 'rate_limit_exceeded', NotImplemented = 'not_implemented', diff --git a/packages/toolkit/connector-kit/src/types/passwordless.ts b/packages/toolkit/connector-kit/src/types/passwordless.ts index 429312035..688a60a03 100644 --- a/packages/toolkit/connector-kit/src/types/passwordless.ts +++ b/packages/toolkit/connector-kit/src/types/passwordless.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { type BaseConnector, type ConnectorType } from './foundation.js'; +/** @deprecated Use {@link TemplateType} instead. */ export enum VerificationCodeType { SignIn = 'SignIn', Register = 'Register', @@ -13,18 +14,45 @@ export enum VerificationCodeType { Test = 'Test', } +/** @deprecated Use {@link templateTypeGuard} instead. */ export const verificationCodeTypeGuard = z.nativeEnum(VerificationCodeType); +export enum TemplateType { + /** The template for sending verification code when user is signing in. */ + SignIn = 'SignIn', + /** The template for sending verification code when user is registering. */ + Register = 'Register', + /** The template for sending verification code when user is resetting password. */ + ForgotPassword = 'ForgotPassword', + /** The template for sending organization invitation. */ + OrganizationInvitation = 'OrganizationInvitation', + /** The template for generic usage. */ + Generic = 'Generic', +} + +export const templateTypeGuard = z.nativeEnum(TemplateType); + export type SendMessagePayload = { /** - * The dynamic verification code to send. + * The dynamic verification code to send. It will replace the `{{code}}` handlebars in the + * template. * @example '123456' */ - code: string; + code?: string; + /** + * The dynamic link to send. It will replace the `{{link}}` handlebars in the template. + * @example 'https://example.com' + */ + link?: string; }; +export const sendMessagePayloadKeys = ['code', 'link'] as const satisfies Array< + keyof SendMessagePayload +>; + export const sendMessagePayloadGuard = z.object({ - code: z.string(), + code: z.string().optional(), + link: z.string().optional(), }) satisfies z.ZodType; export const urlRegEx = @@ -34,13 +62,10 @@ export const emailServiceBrandingGuard = z .object({ senderName: z .string() - .refine((address) => !urlRegEx.test(address), 'DO NOT include URL in the sender name!'), + .refine((address) => !urlRegEx.test(address), 'URL is not allowed in sender name.'), companyInformation: z .string() - .refine( - (address) => !urlRegEx.test(address), - 'DO NOT include URL in the company information!' - ), + .refine((address) => !urlRegEx.test(address), 'URL is not allowed in company information.'), appLogo: z.string().url(), }) .partial(); @@ -49,13 +74,13 @@ export type EmailServiceBranding = z.infer; export type SendMessageData = { to: string; - type: VerificationCodeType; + type: TemplateType | VerificationCodeType; payload: SendMessagePayload; }; export const sendMessageDataGuard = z.object({ to: z.string(), - type: verificationCodeTypeGuard, + type: templateTypeGuard.or(verificationCodeTypeGuard), payload: sendMessagePayloadGuard, }) satisfies z.ZodType;