From be30174a496334c7e338c88747638c1b6439ffea Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 2 Oct 2024 13:54:01 +0200 Subject: [PATCH] :sparkles: Add limits for team invitations --- CHANGES.md | 1 + backend/src/app/rpc/commands/teams.clj | 16 ++++++ common/src/app/common/schema.cljc | 2 +- frontend/src/app/main/ui/dashboard/team.cljs | 18 ++---- .../app/main/ui/onboarding/team_choice.cljs | 55 +++++++++++++------ frontend/translations/en.po | 3 + frontend/translations/es.po | 3 + 7 files changed, 68 insertions(+), 30 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 84142bd48..16b2221ce 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ - Fix problem with Ctrl+F shortcut on the dashboard [Taiga #8876](https://tree.taiga.io/project/penpot/issue/8876) - Fix visual problem with the font-size dropdown in assets [Taiga #8872](https://tree.taiga.io/project/penpot/issue/8872) +- Add limits for invitation RPC methods (hard limit 25 emails per request) ## 2.2.0 diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 444b89184..1d9cf98f7 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -908,6 +908,10 @@ [:role schema:role] [:emails [::sm/set ::sm/email]]]) +(def ^:private max-invitations-by-request-threshold + "The number of invitations can be sent in a single rpc request" + 25) + (sv/defmethod ::create-team-invitations "A rpc call that allow to send a single or multiple invitations to join the team." @@ -920,6 +924,12 @@ team (db/get-by-id conn :team team-id) emails (into #{} (map profile/clean-email) emails)] + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) + (run! (partial quotes/check-quote! conn) (list {::quotes/id ::quotes/invitations-per-team ::quotes/profile-id profile-id @@ -994,6 +1004,12 @@ profile (db/get-by-id conn :profile profile-id) emails (into #{} (map profile/clean-email) emails)] + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) + (let [props {:name name :features features} event (-> (audit/event-from-rpc-params params) (assoc ::audit/name "create-team") diff --git a/common/src/app/common/schema.cljc b/common/src/app/common/schema.cljc index 493311af2..b960991f4 100644 --- a/common/src/app/common/schema.cljc +++ b/common/src/app/common/schema.cljc @@ -448,7 +448,7 @@ (defn parse-email [s] (if (string? s) - (re-matches email-re s) + (first (re-seq email-re s)) nil)) (defn email-string? diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index c19f6f495..8cd0bc99f 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -10,7 +10,6 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.schema :as sm] - [app.common.spec :as us] [app.config :as cfg] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] @@ -30,7 +29,6 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] - [cljs.spec.alpha :as s] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -129,17 +127,10 @@ ] (filterv identity))) -(s/def ::emails (s/and ::us/set-of-valid-emails d/not-empty?)) -(s/def ::role ::us/keyword) -(s/def ::team-id ::us/uuid) - -(s/def ::invite-member-form - (s/keys :req-un [::role ::emails ::team-id])) - (def ^:private schema:invite-member-form [:map {:title "InviteMemberForm"} [:role :keyword] - [:emails [::sm/set {:kind ::sm/email :min 1}]] + [:emails [::sm/set {:min 1} ::sm/email]] [:team-id ::sm/uuid]]) (mf/defc invite-members-modal @@ -181,6 +172,10 @@ (st/emit! (ntf/error (tr "errors.profile-is-muted")) (modal/hide)) + (and (= :validation type) + (= :max-invitations-by-request code)) + (swap! error-text (tr "errors.maximum-invitations-by-request-reached" (:threshold error))) + (or (= :member-is-muted code) (= :email-has-permanent-bounces code) (= :email-has-complaints code)) @@ -226,10 +221,9 @@ :name :emails :auto-focus? true :trim true - :valid-item-fn us/parse-email + :valid-item-fn sm/parse-email :caution-item-fn current-members-emails :label (tr "modals.invite-member.emails") - :on-submit on-submit :invite-email invite-email}]] [:div {:class (stl/css :action-buttons)} diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index c7b550b3a..03b01a257 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -11,11 +11,11 @@ [app.common.schema :as sm] [app.main.data.dashboard :as dd] [app.main.data.events :as ev] - [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as i] + [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] [potok.v2.core :as ptk] @@ -57,7 +57,7 @@ (def ^:private schema:invite-form [:map {:title "InviteForm"} [:role :keyword] - [:emails {:optional true} [::sm/set {:kind ::sm/email}]]]) + [:emails {:optional true} [::sm/set ::sm/email]]]) (defn- get-available-roles [] @@ -67,17 +67,14 @@ (mf/defc team-form-step-2 {::mf/props :obj} [{:keys [name on-back go-to-team?]}] - (let [initial (mf/use-memo - #(do {:role "editor" - :name name})) + (let [initial (mf/with-memo [] + {:role "editor" :name name}) form (fm/use-form :schema schema:invite-form :initial initial) - params (:clean-data @form) - emails (:emails params) - roles (mf/use-memo get-available-roles) + error* (mf/use-state nil) on-success (mf/use-fn @@ -90,8 +87,24 @@ on-error (mf/use-fn - (fn [_] - (st/emit! (ntf/error (tr "errors.generic"))))) + (fn [cause] + (let [{:keys [type code] :as error} (ex-data cause)] + (cond + (and (= :validation type) + (= :profile-is-muted code)) + (swap! error* (tr "errors.profile-is-muted")) + + (and (= :validation type) + (= :max-invitations-by-request code)) + (swap! error* (tr "errors.maximum-invitations-by-request-reached" (:threshold error))) + + (or (= :member-is-muted code) + (= :email-has-permanent-bounces code) + (= :email-has-complaints code)) + (swap! error* (tr "errors.email-spam-or-permanent-bounces" (:email error))) + + :else + (swap! error* (tr "errors.generic")))))) on-invite-later (mf/use-fn @@ -111,7 +124,7 @@ on-invite-now (mf/use-fn - (fn [{:keys [name] :as params}] + (fn [{:keys [name emails] :as params}] (let [mdata {:on-success on-success :on-error on-error}] @@ -143,6 +156,10 @@ [:& fm/form {:form form :class (stl/css :modal-form-invitations) :on-submit on-submit} + + (when-let [content (deref error*)] + [:& context-notification {:content content :level :error}]) + [:div {:class (stl/css :role-select)} [:p {:class (stl/css :role-title)} (tr "onboarding.choice.team-up.roles")] [:& fm/select {:name :role :options roles}]] @@ -155,18 +172,22 @@ :valid-item-fn sm/parse-email :caution-item-fn #{} :label (tr "modals.invite-member.emails") - :on-submit on-submit}]] + ;; :on-submit on-submit + }]] [:div {:class (stl/css :action-buttons)} [:button {:class (stl/css :back-button) :on-click on-back} (tr "labels.back")] - [:> fm/submit-button* - {:class (stl/css :accept-button) - :label (if (> (count emails) 0) - (tr "onboarding.choice.team-up.create-team-and-invite") - (tr "onboarding.choice.team-up.create-team-without-invite"))}]] + (let [params (:clean-data @form) + emails (:emails params)] + [:> fm/submit-button* + {:class (stl/css :accept-button) + :label (if (> (count emails) 0) + (tr "onboarding.choice.team-up.create-team-and-invite") + (tr "onboarding.choice.team-up.create-team-without-invite"))}])] + [:div {:class (stl/css :modal-hint)} "(" (tr "onboarding.choice.team-up.create-team-and-send-invites-description") ")"]]] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2036386cd..ea6bd427f 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -923,6 +923,9 @@ msgstr "Are you sure?" msgid "errors.auth-provider-not-allowed" msgstr "Auth provider not allowed for this profile" +msgid "errors.maximum-invitations-by-request-reached" +msgstr "The maximum (%s) number of emails that can be invited in a single request has been reached" + #: src/app/main/ui/auth/login.cljs:61 msgid "errors.auth-provider-not-configured" msgstr "Authentication provider not configured." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6b923117e..d2bad87e4 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -6094,3 +6094,6 @@ msgstr "Actualizar" #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" + +msgid "errors.maximum-invitations-by-request-reached" +msgstr "Se ha alcanzado el número máximo (%s) de correos electrónicos que se pueden invitar en una sola solicitud"