diff --git a/packages/builder/src/pages/builder/portal/manage/users/_components/InvitedModal.svelte b/packages/builder/src/pages/builder/portal/manage/users/_components/InvitedModal.svelte new file mode 100644 index 0000000000..9cc66a1385 --- /dev/null +++ b/packages/builder/src/pages/builder/portal/manage/users/_components/InvitedModal.svelte @@ -0,0 +1,75 @@ + + + + {#if hasSuccess} + + Your users should now receive an email invite to get access to their + Budibase account + + {/if} + {#if hasFailure} + + {failureMessage} + + + {/if} + + + diff --git a/packages/builder/src/pages/builder/portal/manage/users/index.svelte b/packages/builder/src/pages/builder/portal/manage/users/index.svelte index 1e763240c6..e7d8497b46 100644 --- a/packages/builder/src/pages/builder/portal/manage/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/manage/users/index.svelte @@ -7,7 +7,6 @@ Table, Layout, Modal, - ModalContent, Search, notifications, Pagination, @@ -23,6 +22,7 @@ import { goto } from "@roxi/routify" import OnboardingTypeModal from "./_components/OnboardingTypeModal.svelte" import PasswordModal from "./_components/PasswordModal.svelte" + import InvitedModal from "./_components/InvitedModal.svelte" import DeletionFailureModal from "./_components/DeletionFailureModal.svelte" import ImportUsersModal from "./_components/ImportUsersModal.svelte" import { createPaginationStore } from "helpers/pagination" @@ -59,6 +59,7 @@ $: userData = [] $: createUsersResponse = { successful: [], unsuccessful: [] } $: deleteUsersResponse = { successful: [], unsuccessful: [] } + $: inviteUsersResponse = { successful: [], unsuccessful: [] } $: page = $pageInfo.page $: fetchUsers(page, searchEmail) $: { @@ -96,8 +97,7 @@ admin: user.role === Constants.BudibaseRoles.Admin, })) try { - const res = await users.invite(payload) - notifications.success(res.message) + inviteUsersResponse = await users.invite(payload) inviteConfirmationModal.show() } catch (error) { notifications.error("Error inviting user") @@ -281,16 +281,7 @@ - - Your users should now recieve an email invite to get access to their - Budibase account + diff --git a/packages/types/src/api/web/user.ts b/packages/types/src/api/web/user.ts index 0351680f98..b2c17575c2 100644 --- a/packages/types/src/api/web/user.ts +++ b/packages/types/src/api/web/user.ts @@ -29,3 +29,15 @@ export interface BulkDeleteUsersResponse { successful: UserDetails[] unsuccessful: { _id: string; email: string; reason: string }[] } + +export interface InviteUserRequest { + email: string + userInfo: any +} + +export type InviteUsersRequest = InviteUserRequest[] + +export interface InviteUsersResponse { + successful: { email: string }[] + unsuccessful: { email: string; reason: string }[] +} diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index 97ee34b8f4..d5e8eb8e62 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -1,9 +1,13 @@ -import { EmailTemplatePurpose } from "../../../constants" import { checkInviteCode } from "../../../utilities/redis" -import { sendEmail } from "../../../utilities/email" import { users } from "../../../sdk" import env from "../../../environment" -import { BulkDeleteUsersRequest, CloudAccount, User } from "@budibase/types" +import { + BulkDeleteUsersRequest, + CloudAccount, + InviteUserRequest, + InviteUsersRequest, + User, +} from "@budibase/types" import { accounts, cache, @@ -191,58 +195,27 @@ export const tenantUserLookup = async (ctx: any) => { } export const invite = async (ctx: any) => { - let { email, userInfo } = ctx.request.body - const existing = await usersCore.getGlobalUserByEmail(email) - if (existing) { - ctx.throw(400, "Email address already in use.") + const request = ctx.request.body as InviteUserRequest + const response = await users.invite([request]) + + // explicitly throw for single user invite + if (response.unsuccessful.length) { + const reason = response.unsuccessful[0].reason + if (reason === "Unavailable") { + ctx.throw(400, reason) + } else { + ctx.throw(500, reason) + } } - if (!userInfo) { - userInfo = {} - } - userInfo.tenantId = tenancy.getTenantId() - const opts: any = { - subject: "{{ company }} platform invitation", - info: userInfo, - } - await sendEmail(email, EmailTemplatePurpose.INVITATION, opts) + ctx.body = { message: "Invitation has been sent.", } - await events.user.invited() } export const inviteMultiple = async (ctx: any) => { - let users = ctx.request.body - let existing = false - let existingEmail - for (let user of users) { - if (await usersCore.getGlobalUserByEmail(user.email)) { - existing = true - existingEmail = user.email - break - } - } - - if (existing) { - ctx.throw(400, `${existingEmail} already exists`) - } - - for (let i = 0; i < users.length; i++) { - let userInfo = users[i].userInfo - if (!userInfo) { - userInfo = {} - } - userInfo.tenantId = tenancy.getTenantId() - const opts: any = { - subject: "{{ company }} platform invitation", - info: userInfo, - } - await sendEmail(users[i].email, EmailTemplatePurpose.INVITATION, opts) - } - - ctx.body = { - message: "Invitations have been sent.", - } + const request = ctx.request.body as InviteUsersRequest + ctx.body = await users.invite(request) } export const inviteAccept = async (ctx: any) => { diff --git a/packages/worker/src/api/routes/global/tests/users.spec.ts b/packages/worker/src/api/routes/global/tests/users.spec.ts index 8c2808eeee..a4e5591844 100644 --- a/packages/worker/src/api/routes/global/tests/users.spec.ts +++ b/packages/worker/src/api/routes/global/tests/users.spec.ts @@ -1,3 +1,5 @@ +import { InviteUsersResponse } from "@budibase/types" + jest.mock("nodemailer") import { TestConfiguration, @@ -27,7 +29,8 @@ describe("/api/global/users", () => { describe("invite", () => { it("should be able to generate an invitation", async () => { - const { code, res } = await api.users.sendUserInvite(sendMailMock) + const email = structures.users.newEmail() + const { code, res } = await api.users.sendUserInvite(sendMailMock, email) expect(res.body).toEqual({ message: "Invitation has been sent." }) expect(sendMailMock).toHaveBeenCalled() @@ -35,13 +38,27 @@ describe("/api/global/users", () => { expect(events.user.invited).toBeCalledTimes(1) }) + it("should be able to generate an invitation for existing user", async () => { + const { code, res } = await api.users.sendUserInvite( + sendMailMock, + config.defaultUser!.email, + 400 + ) + + expect(res.body.message).toBe("Unavailable") + expect(sendMailMock).toHaveBeenCalledTimes(0) + expect(code).toBeUndefined() + expect(events.user.invited).toBeCalledTimes(0) + }) + it("should be able to create new user from invite", async () => { - const { code } = await api.users.sendUserInvite(sendMailMock) + const email = structures.users.newEmail() + const { code } = await api.users.sendUserInvite(sendMailMock, email) const res = await api.users.acceptInvite(code) expect(res.body._id).toBeDefined() - const user = await config.getUser("invite@test.com") + const user = await config.getUser(email) expect(user).toBeDefined() expect(user._id).toEqual(res.body._id) expect(events.user.inviteAccepted).toBeCalledTimes(1) @@ -49,6 +66,37 @@ describe("/api/global/users", () => { }) }) + describe("inviteMultiple", () => { + it("should be able to generate an invitation", async () => { + const newUserInvite = () => ({ + email: structures.users.newEmail(), + userInfo: {}, + }) + const request = [newUserInvite(), newUserInvite()] + + const res = await api.users.sendMultiUserInvite(request) + + const body = res.body as InviteUsersResponse + expect(body.successful.length).toBe(2) + expect(body.unsuccessful.length).toBe(0) + expect(sendMailMock).toHaveBeenCalledTimes(2) + expect(events.user.invited).toBeCalledTimes(2) + }) + + it("should not be able to generate an invitation for existing user", async () => { + const request = [{ email: config.defaultUser!.email, userInfo: {} }] + + const res = await api.users.sendMultiUserInvite(request) + + const body = res.body as InviteUsersResponse + expect(body.successful.length).toBe(0) + expect(body.unsuccessful.length).toBe(1) + expect(body.unsuccessful[0].reason).toBe("Unavailable") + expect(sendMailMock).toHaveBeenCalledTimes(0) + expect(events.user.invited).toBeCalledTimes(0) + }) + }) + describe("bulkCreate", () => { it("should ignore users existing in the same tenant", async () => { const user = await config.createUser() diff --git a/packages/worker/src/sdk/users/users.ts b/packages/worker/src/sdk/users/users.ts index 3e084ded55..0ea16bf670 100644 --- a/packages/worker/src/sdk/users/users.ts +++ b/packages/worker/src/sdk/users/users.ts @@ -16,12 +16,12 @@ import { migrations, StaticDatabases, ViewName, + events, } from "@budibase/backend-core" import { MigrationType, PlatformUserByEmail, User, - Account, BulkCreateUsersResponse, CreateUserResponse, BulkDeleteUsersResponse, @@ -30,8 +30,12 @@ import { RowResponse, BulkDocsResponse, AccountMetadata, + InviteUsersRequest, + InviteUsersResponse, } from "@budibase/types" import { groups as groupUtils } from "@budibase/pro" +import { sendEmail } from "../../utilities/email" +import { EmailTemplatePurpose } from "../../constants" const PAGE_LIMIT = 8 @@ -551,3 +555,53 @@ const bulkDeleteProcessing = async (dbUser: User) => { // let server know to sync user await apps.syncUserInApps(userId) } + +export const invite = async ( + users: InviteUsersRequest +): Promise => { + const response: InviteUsersResponse = { + successful: [], + unsuccessful: [], + } + + const matchedEmails = await searchExistingEmails(users.map(u => u.email)) + const newUsers = [] + + // separate duplicates from new users + for (let user of users) { + if (matchedEmails.includes(user.email)) { + response.unsuccessful.push({ email: user.email, reason: "Unavailable" }) + } else { + newUsers.push(user) + } + } + // overwrite users with new only + users = newUsers + + // send the emails for new users + const tenantId = tenancy.getTenantId() + for (let user of users) { + try { + let userInfo = user.userInfo + if (!userInfo) { + userInfo = {} + } + userInfo.tenantId = tenantId + const opts: any = { + subject: "{{ company }} platform invitation", + info: userInfo, + } + await sendEmail(user.email, EmailTemplatePurpose.INVITATION, opts) + response.successful.push({ email: user.email }) + await events.user.invited() + } catch (e) { + console.error(`Failed to send email invitation email=${user.email}`, e) + response.unsuccessful.push({ + email: user.email, + reason: "Failed to send email", + }) + } + } + + return response +} diff --git a/packages/worker/src/tests/api/users.ts b/packages/worker/src/tests/api/users.ts index 6f29b39da3..c5f338016e 100644 --- a/packages/worker/src/tests/api/users.ts +++ b/packages/worker/src/tests/api/users.ts @@ -3,6 +3,7 @@ import { BulkCreateUsersResponse, BulkDeleteUsersRequest, CreateUserResponse, + InviteUsersRequest, User, UserDetails, } from "@budibase/types" @@ -19,17 +20,21 @@ export class UserAPI { // INVITE - sendUserInvite = async (sendMailMock: any) => { + sendUserInvite = async (sendMailMock: any, email: string, status = 200) => { await this.config.saveSmtpConfig() await this.config.saveSettingsConfig() const res = await this.request .post(`/api/global/users/invite`) .send({ - email: "invite@test.com", + email, }) .set(this.config.defaultHeaders()) .expect("Content-Type", /json/) - .expect(200) + .expect(status) + + if (status !== 200) { + return { code: undefined, res } + } const emailCall = sendMailMock.mock.calls[0][0] // after this URL there should be a code @@ -51,6 +56,17 @@ export class UserAPI { .expect(200) } + sendMultiUserInvite = async (request: InviteUsersRequest, status = 200) => { + await this.config.saveSmtpConfig() + await this.config.saveSettingsConfig() + return this.request + .post(`/api/global/users/multi/invite`) + .send(request) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(status) + } + // BULK bulkCreateUsers = async (users: User[], groups: any[] = []) => {