From a157ecdc5b2823cdc5fbf79ab953fc7fb71c0f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Wed, 20 May 2026 16:02:37 +0200 Subject: [PATCH] :sparkles: Add nitrate advanced permissions for invite to teams * :sparkles: Add nitrate advanced permissions for invite to teams * :paperclip: Code review --- .../app/common/types/nitrate_permissions.cljc | 31 +++++- .../types/nitrate_permissions_test.cljc | 45 +++++++- frontend/src/app/main/data/nitrate.cljs | 9 ++ frontend/src/app/main/data/team.cljs | 27 +++++ .../src/app/main/ui/dashboard/projects.cljs | 14 +-- frontend/src/app/main/ui/dashboard/team.cljs | 100 +++++++++++------- .../app/main/ui/workspace/right_header.cljs | 7 +- frontend/translations/en.po | 4 + frontend/translations/es.po | 4 + 9 files changed, 187 insertions(+), 54 deletions(-) diff --git a/common/src/app/common/types/nitrate_permissions.cljc b/common/src/app/common/types/nitrate_permissions.cljc index ab1fa7ca75..fa7f72e741 100644 --- a/common/src/app/common/types/nitrate_permissions.cljc +++ b/common/src/app/common/types/nitrate_permissions.cljc @@ -9,7 +9,8 @@ (def ^:private defaults {:create-teams "any" :delete-teams "onlyOwners" - :move-teams "always"}) + :move-teams "always" + :send-invitations "ownersAndAdmins"}) (defn- can-create-team? [{:keys [is-org-owner? permission-value]}] @@ -36,13 +37,27 @@ (true? target-org-same-owner?) :else false)) +(defn- can-invite-to-team? + [{:keys [permission-value team-perms]}] + (cond + (= permission-value "ownersAndAdmins") + (or (boolean (:is-owner team-perms)) + (boolean (:is-admin team-perms))) + + (= permission-value "owners") + (boolean (:is-owner team-perms)) + + :else false)) + (def ^:private action-rules {:create-team {:permission-key :create-teams :check-fn can-create-team?} :delete-team {:permission-key :delete-teams :check-fn can-delete-team?} :move-team {:permission-key :move-teams - :check-fn can-move-team?}}) + :check-fn can-move-team?} + :send-invitations {:permission-key :send-invitations + :check-fn can-invite-to-team?}}) (defn- normalize-org-permissions [org-perms] @@ -67,3 +82,15 @@ :team-perms team-perms :allow-org-owner-delete? allow-org-owner-delete? :target-org-same-owner? target-org-same-owner?}))))) + +(defn can-send-invitations? + [{:keys [nitrate-enabled? organization profile-id team-permissions]}] + (let [in-org? (and nitrate-enabled? organization)] + (if in-org? + (allowed? :send-invitations + {:org-perms {:owner-id (:owner-id organization) + :permissions (:permissions organization)} + :profile-id profile-id + :team-perms team-permissions}) + (or (boolean (:is-owner team-permissions)) + (boolean (:is-admin team-permissions)))))) diff --git a/common/test/common_tests/types/nitrate_permissions_test.cljc b/common/test/common_tests/types/nitrate_permissions_test.cljc index 01957122dd..b5832b3793 100644 --- a/common/test/common_tests/types/nitrate_permissions_test.cljc +++ b/common/test/common_tests/types/nitrate_permissions_test.cljc @@ -12,7 +12,8 @@ (def org-perms {:owner-id :owner :permissions {:create-teams "any" - :delete-teams "onlyOwners"}}) + :delete-teams "onlyOwners" + :send-invitations "ownersAndAdmins"}}) (t/deftest unknown-action-is-denied (t/is (false? (nitrate-perms/allowed? :unknown @@ -138,3 +139,45 @@ {:org-perms default-org :profile-id :member :team-perms {}}))))) + +(t/deftest send-invitations-defaults-to-owners-and-admins + (let [default-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"})] + (t/is (true? (nitrate-perms/allowed? :send-invitations + {:org-perms default-org + :profile-id :owner + :team-perms {:is-owner true :is-admin false}}))) + (t/is (true? (nitrate-perms/allowed? :send-invitations + {:org-perms default-org + :profile-id :member + :team-perms {:is-owner false :is-admin true}}))) + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms default-org + :profile-id :member + :team-perms {:is-owner false :is-admin false}}))))) + +(t/deftest send-invitations-owners-allows-only-team-owners + (let [only-owners-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "owners"})] + (t/is (true? (nitrate-perms/allowed? :send-invitations + {:org-perms only-owners-org + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))) + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms only-owners-org + :profile-id :owner + :team-perms {:is-owner false :is-admin false}}))) + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms only-owners-org + :profile-id :member + :team-perms {:is-owner false :is-admin true}}))))) + +(t/deftest send-invitations-invalid-value-is-denied + (let [invalid-org (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners" + :send-invitations "invalid-value"})] + (t/is (false? (nitrate-perms/allowed? :send-invitations + {:org-perms invalid-org + :profile-id :member + :team-perms {:is-owner true :is-admin true}}))))) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 6a08930238..d62d0ea330 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -1,6 +1,7 @@ (ns app.main.data.nitrate (:require [app.common.data.macros :as dm] + [app.common.types.nitrate-permissions :as nitrate-perms] [app.common.uri :as u] [app.config :as cf] [app.main.data.common :as dcm] @@ -68,6 +69,14 @@ [] (st/emit! (rt/nav-raw :href "/admin-console/?action=create-org"))) +(defn can-send-invitations? + [{:keys [organization profile-id team-permissions]}] + (nitrate-perms/can-send-invitations? + {:nitrate-enabled? (contains? cf/flags :nitrate) + :organization organization + :profile-id profile-id + :team-permissions team-permissions})) + (def go-to-subscription-url (u/join cf/public-uri "#/settings/subscriptions")) (defn go-to-nitrate-billing diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 4e4e55525d..c4dfcc50b2 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -130,6 +130,33 @@ :on-accept delete-fn}) (modal/show :no-permission-modal {:type :delete-team}))))))))))) +(defn check-and-invite-members + "Fetches fresh team data from the server to ensure up-to-date org + permissions, then shows invite-members modal or a permission error." + [{:keys [team-id origin invite-email] + :or {origin :team}}] + (ptk/reify ::check-and-invite-members + ptk/WatchEvent + (watch [_ state _] + (let [profile-id (dm/get-in state [:profile :id])] + (->> (rp/cmd! :get-teams) + (rx/mapcat + (fn [teams] + (let [team (d/seek #(= (:id %) team-id) teams) + org (:organization team) + can-invite? (nitrate-perms/can-send-invitations? + {:nitrate-enabled? (contains? cf/flags :nitrate) + :organization org + :profile-id profile-id + :team-permissions (:permissions team)})] + (rx/of (teams-fetched teams) + (if can-invite? + (modal/show {:type :invite-members + :team team + :origin origin + :invite-email invite-email}) + (modal/show :no-permission-modal {:type :invite-members}))))))))))) + ;; --- EVENT: fetch-members (defn- members-fetched diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 73d9748b46..0fe141608c 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -13,8 +13,9 @@ [app.main.data.dashboard :as dd] [app.main.data.dashboard.shortcuts :as sc] [app.main.data.event :as ev] - [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] [app.main.data.project :as dpj] + [app.main.data.team :as dtm] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.dashboard.deleted :as deleted] @@ -68,9 +69,8 @@ (mf/use-fn (mf/deps team) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :hero})))) + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :hero})))) on-close' (mf/use-fn (mf/deps on-close) @@ -317,8 +317,10 @@ permisions (:permissions team) can-edit (:can-edit permisions) - can-invite (or (:is-owner permisions) - (:is-admin permisions)) + can-invite (dnt/can-send-invitations? + {:organization (:organization team) + :profile-id (:id profile) + :team-permissions permisions}) show-team-hero* (mf/use-state #(get storage/global ::show-team-hero true)) show-team-hero? (deref show-team-hero*) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index dd528c2fbb..a6908edf82 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -73,7 +73,7 @@ (mf/defc header {::mf/wrap [mf/memo] ::mf/props :obj} - [{:keys [section team]}] + [{:keys [section team profile]}] (let [on-nav-members (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members))) on-nav-settings (mf/use-fn #(st/emit! (dcm/go-to-dashboard-settings))) on-nav-invitations (mf/use-fn #(st/emit! (dcm/go-to-dashboard-invitations))) @@ -87,16 +87,19 @@ invitations-section? (= section :dashboard-team-invitations) webhooks-section? (= section :dashboard-team-webhooks) permissions (:permissions team) + can-invite? (dnt/can-send-invitations? + {:organization (:organization team) + :profile-id (:id profile) + :team-permissions permissions}) invitations (:invitations team) on-invite-member (mf/use-fn (mf/deps team invite-email) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :team - :invite-email invite-email}))))] + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :team + :invite-email invite-email}))))] (mf/with-effect [team invite-email] (when invite-email @@ -122,9 +125,11 @@ [:li {:class (when settings-section? (stl/css :active))} [:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} - (if (and (or invitations-section? members-section?) (:is-admin permissions) (not-empty invitations)) - [:a + (if (and (or invitations-section? members-section?) (not-empty invitations)) + [:button {:class (stl/css :btn-secondary :btn-small) + :type "button" + :disabled (not can-invite?) :on-click on-invite-member :data-testid "invite-member"} (tr "dashboard.invite-profile")] @@ -551,7 +556,9 @@ (st/emit! (dtm/fetch-members))) [:* - [:& header {:section :dashboard-team-members :team team}] + [:& header {:section :dashboard-team-members + :team team + :profile profile}] [:section {:class (stl/css :dashboard-container :dashboard-team-members)} [:> team-members* @@ -685,11 +692,12 @@ on-change (mf/use-fn - (mf/deps on-select-change) + (mf/deps can-invite on-select-change) (fn [event] - (let [email (-> (dom/get-current-target event) - (dom/get-data "attr"))] - (on-select-change email)))) + (when can-invite + (let [email (-> (dom/get-current-target event) + (dom/get-data "attr"))] + (on-select-change email))))) on-change-role (mf/use-fn @@ -701,19 +709,21 @@ [:div {:class (stl/css :table-row :table-row-invitations)} [:div {:class (stl/css :table-field :field-email)} - [:div {:class (stl/css :input-wrapper)} - [:label - [:span {:class (stl/css-case :input-checkbox true - :global/checked (is-selected? email))} - deprecated-icon/status-tick] + (if can-invite + [:div {:class (stl/css :input-wrapper)} + [:label + [:span {:class (stl/css-case :input-checkbox true + :global/checked (is-selected? email))} + deprecated-icon/status-tick] - [:input {:type "checkbox" - :id (dm/str "email-" email) - :data-attr email - :value email - :checked (is-selected? email) - :on-change on-change}] - email]]] + [:input {:type "checkbox" + :id (dm/str "email-" email) + :data-attr email + :value email + :checked (is-selected? email) + :on-change on-change}] + email]] + [:div email])] [:div {:class (stl/css :table-field :field-roles)} [:> invitation-role-selector* @@ -740,20 +750,20 @@ on-invite-member (mf/use-fn (mf/deps team invite-email) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :team - :invite-email invite-email}))))] + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :team + :invite-email invite-email}))))] [:div {:class (stl/css :empty-invitations)} [:span (tr "labels.no-invitations")] - (when ^boolean can-invite + (if ^boolean can-invite [[:span (tr "labels.no-invitations-gather-people")] [:a {:class (stl/css :btn-empty-invitations) :on-click on-invite-member :data-testid "invite-member"} (tr "dashboard.invite-profile")] - [:div {:class (stl/css :blank-space)}]])])) + [:div {:class (stl/css :blank-space)}]] + [:span (tr "dashboard.invitations.no-permission")])])) (mf/defc invitation-modal {::mf/register modal/components @@ -880,15 +890,16 @@ (mf/defc invitation-section* {::mf/private true} - [{:keys [team]}] + [{:keys [team profile]}] (let [permissions (get team :permissions) invitations (mf/use-state (get team :invitations)) team-id (get team :id) - owner? (get permissions :is-owner) - admin? (get permissions :is-admin) - can-invite? (or owner? admin?) + can-invite? (dnt/can-send-invitations? + {:organization (:organization team) + :profile-id (:id profile) + :team-permissions permissions}) selected (mf/use-state #{}) @@ -900,11 +911,12 @@ on-select-change (mf/use-fn - (mf/deps selected) + (mf/deps can-invite? selected) (fn [email] - (if (contains? @selected email) - (swap! selected disj email) - (swap! selected conj email)))) + (when can-invite? + (if (contains? @selected email) + (swap! selected disj email) + (swap! selected conj email))))) on-confirm-delete (mf/use-fn @@ -1014,7 +1026,12 @@ (reset! sort-state {:field nil :direction :asc})) [:div {:class (stl/css :invitations)} - (when (> (count @selected) 0) + (when (and (not can-invite?) + (seq @invitations)) + [:div {:class (stl/css :empty-invitations)} + [:span (tr "dashboard.invitations.no-permission")]]) + (when (and can-invite? + (> (count @selected) 0)) [:* [:div {:class (stl/css :invitations-actions)} [:div @@ -1075,10 +1092,11 @@ [:* [:& header {:section :dashboard-team-invitations - :team team}] + :team team + :profile profile}] [:section {:class (stl/css :dashboard-team-invitations)} - [:> invitation-section* {:team team}] + [:> invitation-section* {:team team :profile profile}] (when (and (contains? cfg/flags :subscriptions) (show-subscription-members-banner? team profile)) diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index c719aae349..f4801f021d 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -9,8 +9,8 @@ (:require [app.main.data.common :as dcm] [app.main.data.event :as ev] - [app.main.data.modal :as modal] [app.main.data.shortcuts :as scd] + [app.main.data.team :as dtm] [app.main.data.workspace :as dw] [app.main.data.workspace.drawing.common :as dwc] [app.main.data.workspace.history :as dwh] @@ -186,9 +186,8 @@ (mf/use-fn (mf/deps team) (fn [] - (st/emit! (modal/show {:type :invite-members - :team team - :origin :workspace}))))] + (st/emit! (dtm/check-and-invite-members {:team-id (:id team) + :origin :workspace}))))] (mf/with-effect [editing?] (when ^boolean editing? diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 127693ca6b..3214d56025 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9550,3 +9550,7 @@ msgstr "You are not allowed to delete teams that are part of %s organization. If msgid "dashboard.no-permission-move-team.message" msgstr "You are not allowed to move teams that are part of %s organization. If you need more information, contact the owner." + +msgid "dashboard.invitations.no-permission" +msgstr "You do not have permission to invite people to join or to edit or delete invitations in this team." + diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f1ee42fc1c..6073a4a736 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9237,3 +9237,7 @@ msgstr "No tienes permiso para eliminar equipos que pertenecen a la organizació msgid "dashboard.no-permission-move-team.message" msgstr "No tienes permiso para mover equipos que son parte de la organización %s. Si necesitas más información, contacta con el propietario." + +msgid "dashboard.invitations.no-permission" +msgstr "No tienes permiso para invitar a personas a unirse ni para editar o eliminar invitaciones en este equipo." +