diff --git a/packages/core/src/libraries/organization-invitation.ts b/packages/core/src/libraries/organization-invitation.ts index 5677c2fac..ad27fb2df 100644 --- a/packages/core/src/libraries/organization-invitation.ts +++ b/packages/core/src/libraries/organization-invitation.ts @@ -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, diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 8d4798112..82aa3b882 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -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> { - return this.pool.one(this.#findEntity(id)); + override async findById(invitationId: string): Promise> { + return this.pool.one(this.#findEntity({ invitationId })); } - override async findAll( - limit: number, - offset: number, - search?: SearchOptions - ): Promise<[totalNumber: number, rows: Readonly]> { - return Promise.all([ - this.findTotalNumber(search), - this.pool.any(this.#findEntity(undefined, limit, offset, search)), - ]); + /** @deprecated Use `findEntities` instead. */ + override async findAll(): Promise { + throw new Error('Use `findEntities` instead.'); } - #findEntity( - invitationId?: string, - limit = 1, - offset = 0, - search?: SearchOptions - ) { + // We don't override `.findAll()` since the function signature is different from the base class. + async findEntities( + options: Omit + ): Promise> { + 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} `; } } diff --git a/packages/core/src/queries/organization/relations.ts b/packages/core/src/queries/organization/relations.ts index d5c787c17..95e2f9b01 100644 --- a/packages/core/src/queries/organization/relations.ts +++ b/packages/core/src/queries/organization/relations.ts @@ -28,6 +28,20 @@ export class UserRelationQueries extends TwoRelationsQueries { + 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[]]> { diff --git a/packages/core/src/routes/organization/invitations.openapi.json b/packages/core/src/routes/organization/invitations.openapi.json index b854aa8a7..52847d25d 100644 --- a/packages/core/src/routes/organization/invitations.openapi.json +++ b/packages/core/src/routes/organization/invitations.openapi.json @@ -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." + } + } + } } } } diff --git a/packages/core/src/routes/organization/invitations.ts b/packages/core/src/routes/organization/invitations.ts index 75f21dc34..49e5627a8 100644 --- a/packages/core/src/routes/organization/invitations.ts +++ b/packages/core/src/routes/organization/invitations.ts @@ -29,12 +29,26 @@ export default function organizationInvitationRoutes( 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( 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( } ); + 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({ diff --git a/packages/integration-tests/src/api/api.ts b/packages/integration-tests/src/api/api.ts index 25d38aaee..ca0a903b1 100644 --- a/packages/integration-tests/src/api/api.ts +++ b/packages/integration-tests/src/api/api.ts @@ -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'), }); diff --git a/packages/integration-tests/src/api/organization-invitation.ts b/packages/integration-tests/src/api/organization-invitation.ts index b3d9b541c..89e5f4376 100644 --- a/packages/integration-tests/src/api/organization-invitation.ts +++ b/packages/integration-tests/src/api/organization-invitation.ts @@ -38,4 +38,12 @@ export class OrganizationInvitationApi extends ApiFactory< }) .json(); } + + async resendMessage(id: string, messagePayload: SendMessagePayload) { + return authedAdminApi + .post(`${this.path}/${id}/message`, { + json: messagePayload, + }) + .json(); + } } diff --git a/packages/integration-tests/src/tests/api/organization/organization-invitation.creation.test.ts b/packages/integration-tests/src/tests/api/organization/organization-invitation.creation.test.ts index a1489abd8..4df4c7b88 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-invitation.creation.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-invitation.creation.test.ts @@ -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 diff --git a/packages/integration-tests/src/tests/api/organization/organization-invitation.get.test.ts b/packages/integration-tests/src/tests/api/organization/organization-invitation.get.test.ts new file mode 100644 index 000000000..c54215496 --- /dev/null +++ b/packages/integration-tests/src/tests/api/organization/organization-invitation.get.test.ts @@ -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); + }); +});