0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

feat: create invitation (#5245)

* feat: create invitation

* refactor: update test imports

* refactor: update unit tests

* refactor: update docs

* refactor: update api tests

* chore: add changesets

* refactor: add comments

* refactor: fix swagger check

* refactor: keep compatibility
This commit is contained in:
Gao Sun 2024-01-25 19:44:20 +08:00 committed by GitHub
parent 8fb032da7a
commit 570a4ea9e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 733 additions and 337 deletions

View file

@ -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`

View file

@ -0,0 +1,7 @@
---
"@logto/connector-sendgrid-email": minor
"@logto/connector-aliyun-dm": minor
"@logto/connector-aws-ses": minor
---
support subject handlebars

View file

@ -0,0 +1,5 @@
---
"@logto/connector-kit": minor
---
add `replaceSendMessageHandlebars()` for replacing `SendMessagePayload` handlebars in a message template

View file

@ -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`.

View file

@ -0,0 +1,5 @@
---
"@logto/connector-mailgun": patch
---
remove `supportTemplateGuard`, support dynamic templates

View file

@ -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()
);

View file

@ -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
);

View file

@ -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',
},
],
};

View file

@ -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(

View file

@ -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,
},
})
);
});
});

View file

@ -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',
},
],
};

View file

@ -38,7 +38,3 @@ export const awsSesConfigGuard = z.object({
});
export type AwsSesConfig = z.infer<typeof awsSesConfigGuard>;
export type Payload = {
code: string | number;
};

View file

@ -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),
},
},
},

View file

@ -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();

View file

@ -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 } },
},
});

View file

@ -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();

View file

@ -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: '<p>Your verification code is {{code}}</p>',
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: '<p>Your link is https://example.com</p>',
'h:Reply-To': 'baz@example.com',
});
getConfig.mockResolvedValue({
...baseConfig,
deliveries: {
[TemplateType.OrganizationInvitation]: {
subject: 'Organization invitation',
html: '<p>Your link is {{link}}</p>',
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',
},

View file

@ -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<string, unknown>) =>
Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));
const getDataFromDeliveryConfig = (
{ subject, replyTo, ...rest }: DeliveryConfig,
code: string
payload: SendMessagePayload
): Record<string, string | undefined> => {
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)),
},
}
);

View file

@ -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',
},

View file

@ -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<typeof supportTemplateGuard>;
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<Record<SupportTemplate, DeliveryConfig>>;
deliveries: Record<string, DeliveryConfig>;
};
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<MailgunConfig>;

View file

@ -9,4 +9,6 @@ describe('SendGrid connector', () => {
it('init without throwing errors', async () => {
await expect(createConnector({ getConfig })).resolves.not.toThrow();
});
// TODO: add test cases
});

View file

@ -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],
};

View file

@ -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
});

View file

@ -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');

View file

@ -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: '<notice@test.smtp>',
subject: 'Organization invitation',
text: 'This is for organization invitation. Your link is https://example.com.',
to: 'baz',
});
});
});
describe('Test config guard', () => {

View file

@ -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,
};

View file

@ -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',
},
],
};

View file

@ -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(

View file

@ -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,

View file

@ -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 {

View file

@ -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,

View file

@ -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<EmailConnector>;
[ConnectorType.Sms]: LogtoConnector<SmsConnector>;
};
const getMessageConnector = async <Type extends keyof MappedConnectorType>(
type: Type
): Promise<MappedConnectorType[Type]> => {
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,
};
};

View file

@ -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,
},
});
}
}

View file

@ -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);

View file

@ -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<SmsConnector | EmailConnector> =>
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<void> => {

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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 };

View file

@ -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<Passcode>(buildSqlForFindByJtiAndType(jti, type));
const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: VerificationCodeType) =>
const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: TemplateType) =>
pool.any<Passcode>(buildSqlForFindByJtiAndType(jti, type));
const findUnconsumedPasscodeByIdentifierAndType = async (

View file

@ -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<T extends AuthedMeRouter>(
...[router, tenant]: RouterInitArgs<T>
) {
const codeType = VerificationCodeType.Generic;
const codeType = TemplateType.Generic;
const {
queries: {
users: { findUserById },

View file

@ -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',
},

View file

@ -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<T extends AuthedRouter>(
await sendMessage(
{
to: subject,
type: VerificationCodeType.Generic,
type: TemplateType.Generic,
payload: {
code: '000000',
},

View file

@ -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' }],
},
];

View file

@ -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<InteractionEvent, VerificationCodeType> = {
SignIn: VerificationCodeType.SignIn,
Register: VerificationCodeType.Register,
ForgotPassword: VerificationCodeType.ForgotPassword,
const eventToTemplateTypeMap: Record<InteractionEvent, TemplateType> = {
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);

View file

@ -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}": {

View file

@ -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<T extends AuthedRouter>(
...[
originalRouter,
@ -11,15 +16,51 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
queries: {
organizations: { invitations },
},
libraries: { organizationInvitations },
},
]: RouterInitArgs<T>
) {
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());
}

View file

@ -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();

View file

@ -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<T extends AuthedRouter>(
...[router, { libraries }]: RouterInitArgs<T>

View file

@ -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,

View file

@ -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,
}
);
});

View file

@ -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();
});
});

View file

@ -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(

View file

@ -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<typeof testingTypeGuard>;
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<typeof testingTypeGuard>;
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'
);
});
});

View file

@ -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<typeof parseJson>) => {
};
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
);
};

View file

@ -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',

View file

@ -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<SendMessagePayload>;
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<typeof emailServiceBrandingGuard>;
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<SendMessageData>;