mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
refactor: update invitation api payload for email
This commit is contained in:
parent
6d0f95739c
commit
46c550bf43
7 changed files with 83 additions and 131 deletions
|
@ -1,4 +1,4 @@
|
|||
import { ConnectorType, TemplateType } from '@logto/connector-kit';
|
||||
import { ConnectorType, type SendMessagePayload, TemplateType } from '@logto/connector-kit';
|
||||
import {
|
||||
OrganizationInvitationStatus,
|
||||
type CreateOrganizationInvitation,
|
||||
|
@ -46,14 +46,15 @@ export class OrganizationInvitationLibrary {
|
|||
* @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`.
|
||||
* @param messagePayload The payload to send in the email. If it is `false`, the email will be
|
||||
* skipped.
|
||||
*/
|
||||
async insert(
|
||||
data: Pick<
|
||||
CreateOrganizationInvitation,
|
||||
'inviterId' | 'invitee' | 'organizationId' | 'expiresAt'
|
||||
> & { organizationRoleIds?: string[] },
|
||||
skipEmail = false
|
||||
messagePayload: SendMessagePayload | false
|
||||
) {
|
||||
const { inviterId, invitee, organizationId, expiresAt, organizationRoleIds } = data;
|
||||
|
||||
|
@ -74,8 +75,8 @@ export class OrganizationInvitationLibrary {
|
|||
);
|
||||
}
|
||||
|
||||
if (!skipEmail) {
|
||||
await this.sendEmail(invitee);
|
||||
if (messagePayload) {
|
||||
await this.sendEmail(invitee, messagePayload);
|
||||
}
|
||||
|
||||
// Additional query to get the full invitation data
|
||||
|
@ -185,14 +186,12 @@ export class OrganizationInvitationLibrary {
|
|||
});
|
||||
}
|
||||
|
||||
protected async sendEmail(to: string) {
|
||||
protected async sendEmail(to: string, payload: SendMessagePayload) {
|
||||
const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email);
|
||||
return emailConnector.sendMessage({
|
||||
to,
|
||||
type: TemplateType.OrganizationInvitation,
|
||||
payload: {
|
||||
link: 'TODO',
|
||||
},
|
||||
payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,24 +13,13 @@
|
|||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"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."
|
||||
"description": "A list of organization invitations, each item also contains the organization roles to be assigned to the user when they accept the invitation."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,
|
||||
|
@ -52,6 +41,9 @@
|
|||
},
|
||||
"organizationRoleIds": {
|
||||
"description": "The IDs of the organization roles to assign to the user when they accept the invitation."
|
||||
},
|
||||
"messagePayload": {
|
||||
"description": "The message payload for the \"OrganizationInvitation\" template to use when sending the invitation via email. If it is `false`, the invitation will not be sent via email."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -60,7 +52,7 @@
|
|||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "The organization invitation was created successfully, and the corresponding magic link data."
|
||||
"description": "The organization invitation was created successfully."
|
||||
},
|
||||
"501": {
|
||||
"description": "No email connector is configured for the tenant."
|
||||
|
@ -74,7 +66,7 @@
|
|||
"description": "Get an organization invitation by ID.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The organization invitation, also contains the organization roles to be assigned to the user when they accept the invitation, and the corresponding magic link data."
|
||||
"description": "The organization invitation, also contains the organization roles to be assigned to the user when they accept the invitation."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { sendMessagePayloadGuard } from '@logto/connector-kit';
|
||||
import {
|
||||
OrganizationInvitationStatus,
|
||||
OrganizationInvitations,
|
||||
organizationInvitationEntityGuard,
|
||||
} from '@logto/schemas';
|
||||
import { yes } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -38,9 +38,6 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
|
|||
router.post(
|
||||
'/',
|
||||
koaGuard({
|
||||
query: z.object({
|
||||
skipEmail: z.string().optional(),
|
||||
}),
|
||||
body: OrganizationInvitations.createGuard
|
||||
.pick({
|
||||
inviterId: true,
|
||||
|
@ -51,12 +48,15 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
|
|||
.extend({
|
||||
invitee: z.string().email(),
|
||||
organizationRoleIds: z.string().array().optional(),
|
||||
messagePayload: sendMessagePayloadGuard.or(z.literal(false)).default(false),
|
||||
}),
|
||||
response: organizationInvitationEntityGuard,
|
||||
status: [201],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { query, body } = ctx.guard;
|
||||
const {
|
||||
body: { messagePayload, ...body },
|
||||
} = ctx.guard;
|
||||
|
||||
assertThat(
|
||||
body.expiresAt > Date.now(),
|
||||
|
@ -66,7 +66,7 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
|
|||
})
|
||||
);
|
||||
|
||||
ctx.body = await organizationInvitations.insert(body, yes(query.skipEmail));
|
||||
ctx.body = await organizationInvitations.insert(body, messagePayload);
|
||||
ctx.status = 201;
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { type SendMessagePayload } from '@logto/connector-kit';
|
||||
import {
|
||||
type OrganizationInvitationStatus,
|
||||
type OrganizationInvitationEntity,
|
||||
|
@ -12,6 +13,7 @@ export type PostOrganizationInvitationData = {
|
|||
organizationId: string;
|
||||
expiresAt: number;
|
||||
organizationRoleIds?: string[];
|
||||
emailPayload?: SendMessagePayload | false;
|
||||
};
|
||||
|
||||
export class OrganizationInvitationApi extends ApiFactory<
|
||||
|
@ -22,15 +24,8 @@ export class OrganizationInvitationApi extends ApiFactory<
|
|||
super('organization-invitations');
|
||||
}
|
||||
|
||||
override async create(data: PostOrganizationInvitationData, skipEmail = false) {
|
||||
return authedAdminApi
|
||||
.post(this.path, {
|
||||
searchParams: {
|
||||
skipEmail: skipEmail.toString(),
|
||||
},
|
||||
json: data,
|
||||
})
|
||||
.json<OrganizationInvitationEntity>();
|
||||
override async create(json: PostOrganizationInvitationData) {
|
||||
return authedAdminApi.post(this.path, { json }).json<OrganizationInvitationEntity>();
|
||||
}
|
||||
|
||||
async updateStatus(id: string, status: OrganizationInvitationStatus, acceptedUserId?: string) {
|
||||
|
|
|
@ -27,10 +27,9 @@ export class OrganizationInvitationApiTest extends OrganizationInvitationApi {
|
|||
}
|
||||
|
||||
override async create(
|
||||
data: PostOrganizationInvitationData,
|
||||
skipEmail = false
|
||||
data: PostOrganizationInvitationData
|
||||
): Promise<OrganizationInvitationEntity> {
|
||||
const created = await super.create(data, skipEmail);
|
||||
const created = await super.create(data);
|
||||
this.invitations.push(created);
|
||||
return created;
|
||||
}
|
||||
|
|
|
@ -29,27 +29,21 @@ describe('organization invitation creation', () => {
|
|||
|
||||
it('should be able to create an invitation without sending email', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
);
|
||||
await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not be able to create invitations with the same email', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const email = `${randomId()}@example.com`;
|
||||
await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: email,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
);
|
||||
await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: email,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
});
|
||||
const error = await invitationApi
|
||||
.create({
|
||||
organizationId: organization.id,
|
||||
|
@ -68,22 +62,16 @@ describe('organization invitation creation', () => {
|
|||
]);
|
||||
const email = `${randomId()}@example.com`;
|
||||
await Promise.all([
|
||||
invitationApi.create(
|
||||
{
|
||||
organizationId: organization1.id,
|
||||
invitee: email,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
),
|
||||
invitationApi.create(
|
||||
{
|
||||
organizationId: organization2.id,
|
||||
invitee: email,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
),
|
||||
invitationApi.create({
|
||||
organizationId: organization1.id,
|
||||
invitee: email,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
}),
|
||||
invitationApi.create({
|
||||
organizationId: organization2.id,
|
||||
invitee: email,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -128,15 +116,12 @@ describe('organization invitation creation', () => {
|
|||
it('should be able to create invitations with organization role ids', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const role = await organizationApi.roleApi.create({ name: 'test' });
|
||||
const invitation = await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
organizationRoleIds: [role.id],
|
||||
},
|
||||
true
|
||||
);
|
||||
const invitation = await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
organizationRoleIds: [role.id],
|
||||
});
|
||||
expect(invitation.organizationRoles.map((role) => role.id)).toEqual([role.id]);
|
||||
|
||||
// Test if get invitation by id works
|
||||
|
|
|
@ -33,14 +33,11 @@ describe('organization invitation status update', () => {
|
|||
|
||||
it('should expire invitations and disable update after the expiration date', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const invitation = await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 100,
|
||||
},
|
||||
true
|
||||
);
|
||||
const invitation = await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 100,
|
||||
});
|
||||
expect(invitation.status).toBe('Pending');
|
||||
|
||||
await new Promise((resolve) => {
|
||||
|
@ -58,14 +55,11 @@ describe('organization invitation status update', () => {
|
|||
|
||||
it('should be able to accept an invitation', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const invitation = await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
);
|
||||
const invitation = await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
});
|
||||
expect(invitation.status).toBe('Pending');
|
||||
|
||||
const user = await userApi.create({
|
||||
|
@ -91,15 +85,12 @@ describe('organization invitation status update', () => {
|
|||
it('should be able to accept an invitation with roles', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const role = await organizationApi.roleApi.create({ name: 'test' });
|
||||
const invitation = await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
organizationRoleIds: [role.id],
|
||||
},
|
||||
true
|
||||
);
|
||||
const invitation = await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
organizationRoleIds: [role.id],
|
||||
});
|
||||
expect(invitation.status).toBe('Pending');
|
||||
|
||||
const user = await userApi.create({
|
||||
|
@ -125,14 +116,11 @@ describe('organization invitation status update', () => {
|
|||
|
||||
it('should not be able to accept an invitation with a different email', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const invitation = await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
);
|
||||
const invitation = await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
});
|
||||
expect(invitation.status).toBe('Pending');
|
||||
|
||||
const user = await userApi.create({
|
||||
|
@ -147,14 +135,11 @@ describe('organization invitation status update', () => {
|
|||
|
||||
it('should not be able to accept an invitation with an invalid user id', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const invitation = await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
);
|
||||
const invitation = await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
});
|
||||
expect(invitation.status).toBe('Pending');
|
||||
|
||||
const error = await invitationApi
|
||||
|
@ -166,14 +151,11 @@ describe('organization invitation status update', () => {
|
|||
|
||||
it('should not be able to update the status of an ended invitation', async () => {
|
||||
const organization = await organizationApi.create({ name: 'test' });
|
||||
const invitation = await invitationApi.create(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
},
|
||||
true
|
||||
);
|
||||
const invitation = await invitationApi.create({
|
||||
organizationId: organization.id,
|
||||
invitee: `${randomId()}@example.com`,
|
||||
expiresAt: Date.now() + 1_000_000,
|
||||
});
|
||||
expect(invitation.status).toBe('Pending');
|
||||
|
||||
await invitationApi.updateStatus(invitation.id, OrganizationInvitationStatus.Revoked);
|
||||
|
|
Loading…
Add table
Reference in a new issue