diff --git a/front/lib/api/invitation.ts b/front/lib/api/invitation.ts index 45f15be03967..477be1cf15bd 100644 --- a/front/lib/api/invitation.ts +++ b/front/lib/api/invitation.ts @@ -7,6 +7,7 @@ import type { import { Err, sanitizeString } from "@dust-tt/types"; import sgMail from "@sendgrid/mail"; import { sign } from "jsonwebtoken"; +import { Op } from "sequelize"; import config from "@app/lib/api/config"; import type { Authenticator } from "@app/lib/auth"; @@ -160,6 +161,11 @@ export async function getPendingInvitations( if (!owner) { return []; } + if (!auth.isAdmin()) { + throw new Error( + "Only users that are `admins` for the current workspace can see membership invitations or modify it." + ); + } const invitations = await MembershipInvitation.findAll({ where: { @@ -178,3 +184,45 @@ export async function getPendingInvitations( }; }); } + +/** + * Returns the pending or revoked inviations that were created today + * associated with the authenticator's owner workspace. + * @param auth Authenticator + * @returns MenbershipInvitation[] members of the workspace + */ + +export async function getRecentPendingOrRevokedInvitations( + auth: Authenticator +): Promise { + const owner = auth.workspace(); + if (!owner) { + return []; + } + if (!auth.isAdmin()) { + throw new Error( + "Only users that are `admins` for the current workspace can see membership invitations or modify it." + ); + } + const oneDayAgo = new Date(); + oneDayAgo.setDate(oneDayAgo.getDate() - 1); + const invitations = await MembershipInvitation.findAll({ + where: { + workspaceId: owner.id, + status: ["pending", "revoked"], + createdAt: { + [Op.gt]: oneDayAgo, + }, + }, + }); + + return invitations.map((i) => { + return { + sId: i.sId, + id: i.id, + status: i.status, + inviteEmail: i.inviteEmail, + initialRole: i.initialRole, + }; + }); +} diff --git a/front/lib/invitations.ts b/front/lib/invitations.ts new file mode 100644 index 000000000000..2b72b9c39261 --- /dev/null +++ b/front/lib/invitations.ts @@ -0,0 +1,2 @@ +// Maxmimum allowed number of unconsumed invitations per workspace per day. +export const MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY = 50; diff --git a/front/pages/api/w/[wId]/invitations/index.ts b/front/pages/api/w/[wId]/invitations/index.ts index 2c9a1a008104..bf325b40d4f2 100644 --- a/front/pages/api/w/[wId]/invitations/index.ts +++ b/front/pages/api/w/[wId]/invitations/index.ts @@ -9,12 +9,14 @@ import * as reporter from "io-ts-reporters"; import type { NextApiRequest, NextApiResponse } from "next"; import { + getRecentPendingOrRevokedInvitations, sendWorkspaceInvitationEmail, updateOrCreateInvitation, } from "@app/lib/api/invitation"; import { getPendingInvitations } from "@app/lib/api/invitation"; import { getMembers } from "@app/lib/api/workspace"; import { Authenticator, getSession } from "@app/lib/auth"; +import { MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY } from "@app/lib/invitations"; import { MembershipResource } from "@app/lib/resources/membership_resource"; import { isEmailValid } from "@app/lib/utils"; import logger from "@app/logger/logger"; @@ -152,7 +154,39 @@ async function handler( }); } const existingMembers = await getMembers(auth); - + const unconsumedInvitations = await getRecentPendingOrRevokedInvitations( + auth + ); + if ( + unconsumedInvitations.length >= + MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY + ) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: `Too many unconsumed invitations. Please ask your members to consume their invitations before sending more.`, + }, + }); + } + const emailsWithRecentUnconsumedInvitations = new Set( + unconsumedInvitations.map((i) => i.inviteEmail.toLowerCase().trim()) + ); + if ( + invitationRequests.some((r) => + emailsWithRecentUnconsumedInvitations.has( + r.email.toLowerCase().trim() + ) + ) + ) { + return apiError(req, res, { + status_code: 400, + api_error: { + type: "invalid_request_error", + message: `Some of the emails have already received an invitation in the last 24 hours. Please wait before sending another invitation.`, + }, + }); + } const invitationResults = await Promise.all( invitationRequests.map(async ({ email, role }) => { if (existingMembers.find((m) => m.email === email)) { diff --git a/front/pages/w/[wId]/members/index.tsx b/front/pages/w/[wId]/members/index.tsx index 81d8462c2c8b..77a07cebfe8e 100644 --- a/front/pages/w/[wId]/members/index.tsx +++ b/front/pages/w/[wId]/members/index.tsx @@ -53,6 +53,7 @@ import { PRO_PLAN_29_COST, } from "@app/lib/client/subscription"; import { withDefaultUserAuthRequirements } from "@app/lib/iam/session"; +import { MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY } from "@app/lib/invitations"; import { isUpgraded, PRO_PLAN_SEAT_29_CODE } from "@app/lib/plans/plan_codes"; import { useMembers, useWorkspaceInvitations } from "@app/lib/swr"; import { classNames, isEmailValid } from "@app/lib/utils"; @@ -484,6 +485,17 @@ function InviteEmailModal({ async function handleSendInvitations( inviteEmailsList: string[] ): Promise { + if ( + inviteEmailsList.length > MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY + ) { + sendNotification({ + type: "error", + title: "Too many invitations", + description: `Your cannot send more than ${MAX_UNCONSUMED_INVITATIONS_PER_WORKSPACE_PER_DAY} invitations per day.`, + }); + return; + } + const invitesByCase = { activeSameRole: members.filter((m) => inviteEmailsList.find(