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:
parent
8fb032da7a
commit
570a4ea9e2
56 changed files with 733 additions and 337 deletions
12
.changeset/good-stingrays-perform.md
Normal file
12
.changeset/good-stingrays-perform.md
Normal 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`
|
7
.changeset/strange-seals-poke.md
Normal file
7
.changeset/strange-seals-poke.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@logto/connector-sendgrid-email": minor
|
||||
"@logto/connector-aliyun-dm": minor
|
||||
"@logto/connector-aws-ses": minor
|
||||
---
|
||||
|
||||
support subject handlebars
|
5
.changeset/thin-bags-admire.md
Normal file
5
.changeset/thin-bags-admire.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/connector-kit": minor
|
||||
---
|
||||
|
||||
add `replaceSendMessageHandlebars()` for replacing `SendMessagePayload` handlebars in a message template
|
10
.changeset/tidy-phones-warn.md
Normal file
10
.changeset/tidy-phones-warn.md
Normal 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`.
|
5
.changeset/twelve-carrots-do.md
Normal file
5
.changeset/twelve-carrots-do.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/connector-mailgun": patch
|
||||
---
|
||||
|
||||
remove `supportTemplateGuard`, support dynamic templates
|
|
@ -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()
|
||||
);
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -38,7 +38,3 @@ export const awsSesConfigGuard = z.object({
|
|||
});
|
||||
|
||||
export type AwsSesConfig = z.infer<typeof awsSesConfigGuard>;
|
||||
|
||||
export type Payload = {
|
||||
code: string | number;
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 } },
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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)),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -9,4 +9,6 @@ describe('SendGrid connector', () => {
|
|||
it('init without throwing errors', async () => {
|
||||
await expect(createConnector({ getConfig })).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
// TODO: add test cases
|
||||
});
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
90
packages/core/src/libraries/organization-invitation.ts
Normal file
90
packages/core/src/libraries/organization-invitation.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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> => {
|
||||
|
|
19
packages/core/src/queries/magic-link.ts
Normal file
19
packages/core/src/queries/magic-link.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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 };
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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' }],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}": {
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue