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

refactor(core): update organization invitation apis (#5474)

* refactor(core): update organization invitation apis

* chore: add api tests
This commit is contained in:
Gao Sun 2024-03-12 18:35:40 +08:00 committed by GitHub
parent 2c7acb2cdf
commit 1965633bed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 244 additions and 29 deletions

View file

@ -14,8 +14,6 @@ import type Queries from '#src/tenants/Queries.js';
import { type ConnectorLibrary } from './connector.js';
const invitationLinkPath = '/invitation';
/**
* The ending statuses of an organization invitation per RFC 0003. It means that the invitation
* status cannot be changed anymore.
@ -58,6 +56,14 @@ export class OrganizationInvitationLibrary {
) {
const { inviterId, invitee, organizationId, expiresAt, organizationRoleIds } = data;
if (await this.queries.organizations.relations.users.isMember(organizationId, invitee)) {
throw new RequestError({
status: 422,
code: 'request.invalid_input',
details: 'The invitee is already a member of the organization.',
});
}
return this.queries.pool.transaction(async (connection) => {
const organizationQueries = new OrganizationQueries(connection);
const invitation = await organizationQueries.invitations.insert({
@ -186,7 +192,8 @@ export class OrganizationInvitationLibrary {
});
}
protected async sendEmail(to: string, payload: SendMessagePayload) {
/** Send an organization invitation email. */
async sendEmail(to: string, payload: SendMessagePayload) {
const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email);
return emailConnector.sendMessage({
to,

View file

@ -88,32 +88,34 @@ class OrganizationRolesQueries extends SchemaQueries<
}
}
type OrganizationInvitationSearchOptions = {
invitationId?: string;
organizationId?: string;
inviterId?: string;
};
class OrganizationInvitationsQueries extends SchemaQueries<
OrganizationInvitationKeys,
CreateOrganizationInvitation,
OrganizationInvitation
> {
override async findById(id: string): Promise<Readonly<OrganizationInvitationEntity>> {
return this.pool.one(this.#findEntity(id));
override async findById(invitationId: string): Promise<Readonly<OrganizationInvitationEntity>> {
return this.pool.one(this.#findEntity({ invitationId }));
}
override async findAll(
limit: number,
offset: number,
search?: SearchOptions<OrganizationInvitationKeys>
): Promise<[totalNumber: number, rows: Readonly<OrganizationInvitationEntity[]>]> {
return Promise.all([
this.findTotalNumber(search),
this.pool.any(this.#findEntity(undefined, limit, offset, search)),
]);
/** @deprecated Use `findEntities` instead. */
override async findAll(): Promise<never> {
throw new Error('Use `findEntities` instead.');
}
#findEntity(
invitationId?: string,
limit = 1,
offset = 0,
search?: SearchOptions<OrganizationInvitationKeys>
) {
// We don't override `.findAll()` since the function signature is different from the base class.
async findEntities(
options: Omit<OrganizationInvitationSearchOptions, 'invitationId'>
): Promise<Readonly<OrganizationInvitationEntity[]>> {
return this.pool.any(this.#findEntity({ ...options, invitationId: undefined }));
}
#findEntity({ invitationId, organizationId, inviterId }: OrganizationInvitationSearchOptions) {
const { table, fields } = convertToIdentifiers(OrganizationInvitations, true);
const roleRelations = convertToIdentifiers(OrganizationInvitationRoleRelations, true);
const roles = convertToIdentifiers(OrganizationRoles, true);
@ -147,16 +149,20 @@ class OrganizationInvitationsQueries extends SchemaQueries<
on ${roleRelations.fields.organizationInvitationId} = ${fields.id}
left join ${roles.table}
on ${roles.fields.id} = ${roleRelations.fields.organizationRoleId}
where true
${conditionalSql(invitationId, (id) => {
return sql`where ${fields.id} = ${id}`;
return sql`and ${fields.id} = ${id}`;
})}
${conditionalSql(organizationId, (id) => {
return sql`and ${fields.organizationId} = ${id}`;
})}
${conditionalSql(inviterId, (id) => {
return sql`and ${fields.inviterId} = ${id}`;
})}
${buildSearchSql(OrganizationInvitations, search)}
group by ${fields.id}
${conditionalSql(this.orderBy, ({ field, order }) => {
return sql`order by ${fields[field]} ${order === 'desc' ? sql`desc` : sql`asc`}`;
})}
limit ${limit}
offset ${offset}
`;
}
}

View file

@ -28,6 +28,20 @@ export class UserRelationQueries extends TwoRelationsQueries<typeof Organization
super(pool, OrganizationUserRelations.table, Organizations, Users);
}
async isMember(organizationId: string, email: string): Promise<boolean> {
const users = convertToIdentifiers(Users, true);
const relations = convertToIdentifiers(OrganizationUserRelations, true);
return this.pool.exists(sql`
select 1
from ${relations.table}
join ${users.table}
on ${relations.fields.userId} = ${users.fields.id}
where ${relations.fields.organizationId} = ${organizationId}
and ${users.fields.primaryEmail} = ${email}
`);
}
async getFeatured(
organizationId: string
): Promise<[totalNumber: number, users: readonly FeaturedUser[]]> {

View file

@ -114,6 +114,21 @@
}
}
}
},
"/api/organization-invitations/{id}/message": {
"post": {
"summary": "Resend invitation message",
"description": "Resend the invitation message to the invitee.",
"requestBody": {
"description": "The message payload for the \"OrganizationInvitation\" template to use when sending the invitation via email.",
"required": true
},
"responses": {
"204": {
"description": "The invitation message was resent successfully."
}
}
}
}
}
}

View file

@ -29,12 +29,26 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
const router = new SchemaRouter(OrganizationInvitations, invitations, {
errorHandler,
disabled: {
get: true,
post: true,
patchById: true,
},
entityGuard: organizationInvitationEntityGuard,
});
router.get(
'/',
koaGuard({
query: z.object({ organizationId: z.string().optional(), inviterId: z.string().optional() }),
response: organizationInvitationEntityGuard.array(),
status: [200],
}),
async (ctx, next) => {
ctx.body = await invitations.findEntities(ctx.guard.query);
return next();
}
);
router.post(
'/',
koaGuard({
@ -51,7 +65,7 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
messagePayload: sendMessagePayloadGuard.or(z.literal(false)).default(false),
}),
response: organizationInvitationEntityGuard,
status: [201, 400, 501],
status: [201, 400, 422, 501],
}),
async (ctx, next) => {
const {
@ -72,6 +86,26 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
}
);
router.post(
'/:id/message',
koaGuard({
params: z.object({ id: z.string() }),
body: sendMessagePayloadGuard,
status: [204],
}),
async (ctx, next) => {
const {
params: { id },
body,
} = ctx.guard;
const { invitee } = await invitations.findById(id);
await organizationInvitations.sendEmail(invitee, body);
ctx.status = 204;
return next();
}
);
router.put(
'/:id/status',
koaGuard({

View file

@ -1,9 +1,10 @@
import { appendPath } from '@silverhand/essentials';
import { got } from 'got';
import { logtoConsoleUrl, logtoUrl, logtoCloudUrl } from '#src/constants.js';
const api = got.extend({
prefixUrl: new URL('/api', logtoUrl),
prefixUrl: appendPath(new URL(logtoUrl), 'api'),
});
export default api;
@ -16,7 +17,7 @@ export const authedAdminApi = api.extend({
});
export const adminTenantApi = got.extend({
prefixUrl: new URL('/api', logtoConsoleUrl),
prefixUrl: appendPath(new URL(logtoConsoleUrl), 'api'),
});
export const authedAdminTenantApi = adminTenantApi.extend({
@ -26,9 +27,9 @@ export const authedAdminTenantApi = adminTenantApi.extend({
});
export const cloudApi = got.extend({
prefixUrl: new URL('/api', logtoCloudUrl),
prefixUrl: appendPath(new URL(logtoCloudUrl), 'api'),
});
export const oidcApi = got.extend({
prefixUrl: new URL('/oidc', logtoUrl),
prefixUrl: appendPath(new URL(logtoUrl), 'oidc'),
});

View file

@ -38,4 +38,12 @@ export class OrganizationInvitationApi extends ApiFactory<
})
.json<OrganizationInvitationEntity>();
}
async resendMessage(id: string, messagePayload: SendMessagePayload) {
return authedAdminApi
.post(`${this.path}/${id}/message`, {
json: messagePayload,
})
.json();
}
}

View file

@ -4,6 +4,7 @@ import { ConnectorType } from '@logto/connector-kit';
import { generateStandardId } from '@logto/shared';
import { HTTPError } from 'got';
import { createUser } from '#src/api/admin-user.js';
import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js';
import { readConnectorMessage } from '#src/helpers/index.js';
import { OrganizationApiTest, OrganizationInvitationApiTest } from '#src/helpers/organization.js';
@ -59,6 +60,37 @@ describe('organization invitation creation', () => {
});
});
it('should be able to resend an email after creating an invitation', async () => {
await setEmailConnector();
const organization = await organizationApi.create({ name: 'test' });
const email = `${randomId()}@example.com`;
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
messagePayload: {
link: 'https://example.com',
},
});
expect(await readConnectorMessage('Email')).toMatchObject({
type: 'OrganizationInvitation',
payload: {
link: 'https://example.com',
},
});
await invitationApi.resendMessage(invitation.id, {
link: 'https://example1.com',
});
expect(await readConnectorMessage('Email')).toMatchObject({
type: 'OrganizationInvitation',
payload: {
link: 'https://example1.com',
},
});
});
it('should throw error if email connector is not set', async () => {
await clearConnectorsByTypes([ConnectorType.Email]);
const organization = await organizationApi.create({ name: 'test' });
@ -128,6 +160,23 @@ describe('organization invitation creation', () => {
expectErrorResponse(error, 400, 'request.invalid_input');
});
it('should not be able to create invitations if the invitee is already a member of the organization', async () => {
const organization = await organizationApi.create({ name: 'test' });
const email = `${randomId()}@example.com`;
const user = await createUser({ primaryEmail: email });
await organizationApi.addUsers(organization.id, [user.id]);
const error = await invitationApi
.create({
organizationId: organization.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
})
.catch((error: unknown) => error);
expectErrorResponse(error, 422, 'request.invalid_input');
});
it('should not be able to create invitations with an invalid email', async () => {
const organization = await organizationApi.create({ name: 'test' });
const error = await invitationApi

View file

@ -0,0 +1,81 @@
import { generateStandardId } from '@logto/shared';
import { createUser, deleteUser } from '#src/api/admin-user.js';
import { OrganizationApiTest, OrganizationInvitationApiTest } from '#src/helpers/organization.js';
const randomId = () => generateStandardId(4);
describe('organization invitation creation', () => {
const invitationApi = new OrganizationInvitationApiTest();
const organizationApi = new OrganizationApiTest();
afterEach(async () => {
await Promise.all([
organizationApi.cleanUp(),
organizationApi.roleApi.cleanUp(),
invitationApi.cleanUp(),
]);
});
it('should be able to get invitations by organization id and inviter id', async () => {
const organization1 = await organizationApi.create({ name: 'test' });
const organization2 = await organizationApi.create({ name: 'test' });
const inviter = await createUser({ primaryEmail: 'foo@bar.io' });
const inviter2 = await createUser({ primaryEmail: 'bar@baz.io' });
// eslint-disable-next-line unicorn/consistent-function-scoping
const buildPayload = (inviterId: string, organizationId: string) => ({
organizationId,
inviterId,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
await Promise.all([
invitationApi.create(buildPayload(inviter.id, organization1.id)),
invitationApi.create(buildPayload(inviter.id, organization2.id)),
invitationApi.create(buildPayload(inviter2.id, organization1.id)),
]);
const invitationsByOrganization1 = await invitationApi.getList(
new URLSearchParams({ organizationId: organization1.id })
);
expect(invitationsByOrganization1.length).toBe(2);
expect(
invitationsByOrganization1.every(
(invitation) => invitation.organizationId === organization1.id
)
).toBe(true);
const invitationsByInviter = await invitationApi.getList(
new URLSearchParams({ inviterId: inviter.id })
);
expect(invitationsByInviter.length).toBe(2);
expect(invitationsByInviter.every((invitation) => invitation.inviterId === inviter.id)).toBe(
true
);
const allInvitations = await invitationApi.getList();
expect(allInvitations.length).toBe(3);
await Promise.all([deleteUser(inviter.id), deleteUser(inviter2.id)]);
});
it('should have no pagination', async () => {
const organization = await organizationApi.create({ name: 'test' });
await Promise.all(
Array.from({ length: 30 }, async () =>
invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
})
)
);
const invitations = await invitationApi.getList(
new URLSearchParams({ organizationId: organization.id })
);
expect(invitations.length).toBe(30);
});
});