Add nitrate advanced permissions for invite to teams

*  Add nitrate advanced permissions for invite to teams

* 📎 Code review
This commit is contained in:
María Valderrama 2026-05-20 16:02:37 +02:00 committed by GitHub
parent 371bd58878
commit a157ecdc5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 187 additions and 54 deletions

View File

@ -9,7 +9,8 @@
(def ^:private defaults (def ^:private defaults
{:create-teams "any" {:create-teams "any"
:delete-teams "onlyOwners" :delete-teams "onlyOwners"
:move-teams "always"}) :move-teams "always"
:send-invitations "ownersAndAdmins"})
(defn- can-create-team? (defn- can-create-team?
[{:keys [is-org-owner? permission-value]}] [{:keys [is-org-owner? permission-value]}]
@ -36,13 +37,27 @@
(true? target-org-same-owner?) (true? target-org-same-owner?)
:else false)) :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 (def ^:private action-rules
{:create-team {:permission-key :create-teams {:create-team {:permission-key :create-teams
:check-fn can-create-team?} :check-fn can-create-team?}
:delete-team {:permission-key :delete-teams :delete-team {:permission-key :delete-teams
:check-fn can-delete-team?} :check-fn can-delete-team?}
:move-team {:permission-key :move-teams :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 (defn- normalize-org-permissions
[org-perms] [org-perms]
@ -67,3 +82,15 @@
:team-perms team-perms :team-perms team-perms
:allow-org-owner-delete? allow-org-owner-delete? :allow-org-owner-delete? allow-org-owner-delete?
:target-org-same-owner? target-org-same-owner?}))))) :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))))))

View File

@ -12,7 +12,8 @@
(def org-perms (def org-perms
{:owner-id :owner {:owner-id :owner
:permissions {:create-teams "any" :permissions {:create-teams "any"
:delete-teams "onlyOwners"}}) :delete-teams "onlyOwners"
:send-invitations "ownersAndAdmins"}})
(t/deftest unknown-action-is-denied (t/deftest unknown-action-is-denied
(t/is (false? (nitrate-perms/allowed? :unknown (t/is (false? (nitrate-perms/allowed? :unknown
@ -138,3 +139,45 @@
{:org-perms default-org {:org-perms default-org
:profile-id :member :profile-id :member
:team-perms {}}))))) :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}})))))

View File

@ -1,6 +1,7 @@
(ns app.main.data.nitrate (ns app.main.data.nitrate
(:require (:require
[app.common.data.macros :as dm] [app.common.data.macros :as dm]
[app.common.types.nitrate-permissions :as nitrate-perms]
[app.common.uri :as u] [app.common.uri :as u]
[app.config :as cf] [app.config :as cf]
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
@ -68,6 +69,14 @@
[] []
(st/emit! (rt/nav-raw :href "/admin-console/?action=create-org"))) (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")) (def go-to-subscription-url (u/join cf/public-uri "#/settings/subscriptions"))
(defn go-to-nitrate-billing (defn go-to-nitrate-billing

View File

@ -130,6 +130,33 @@
:on-accept delete-fn}) :on-accept delete-fn})
(modal/show :no-permission-modal {:type :delete-team}))))))))))) (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 ;; --- EVENT: fetch-members
(defn- members-fetched (defn- members-fetched

View File

@ -13,8 +13,9 @@
[app.main.data.dashboard :as dd] [app.main.data.dashboard :as dd]
[app.main.data.dashboard.shortcuts :as sc] [app.main.data.dashboard.shortcuts :as sc]
[app.main.data.event :as ev] [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.project :as dpj]
[app.main.data.team :as dtm]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.main.ui.dashboard.deleted :as deleted] [app.main.ui.dashboard.deleted :as deleted]
@ -68,9 +69,8 @@
(mf/use-fn (mf/use-fn
(mf/deps team) (mf/deps team)
(fn [] (fn []
(st/emit! (modal/show {:type :invite-members (st/emit! (dtm/check-and-invite-members {:team-id (:id team)
:team team :origin :hero}))))
:origin :hero}))))
on-close' on-close'
(mf/use-fn (mf/use-fn
(mf/deps on-close) (mf/deps on-close)
@ -317,8 +317,10 @@
permisions (:permissions team) permisions (:permissions team)
can-edit (:can-edit permisions) can-edit (:can-edit permisions)
can-invite (or (:is-owner permisions) can-invite (dnt/can-send-invitations?
(:is-admin permisions)) {: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* (mf/use-state #(get storage/global ::show-team-hero true))
show-team-hero? (deref show-team-hero*) show-team-hero? (deref show-team-hero*)

View File

@ -73,7 +73,7 @@
(mf/defc header (mf/defc header
{::mf/wrap [mf/memo] {::mf/wrap [mf/memo]
::mf/props :obj} ::mf/props :obj}
[{:keys [section team]}] [{:keys [section team profile]}]
(let [on-nav-members (mf/use-fn #(st/emit! (dcm/go-to-dashboard-members))) (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-settings (mf/use-fn #(st/emit! (dcm/go-to-dashboard-settings)))
on-nav-invitations (mf/use-fn #(st/emit! (dcm/go-to-dashboard-invitations))) on-nav-invitations (mf/use-fn #(st/emit! (dcm/go-to-dashboard-invitations)))
@ -87,16 +87,19 @@
invitations-section? (= section :dashboard-team-invitations) invitations-section? (= section :dashboard-team-invitations)
webhooks-section? (= section :dashboard-team-webhooks) webhooks-section? (= section :dashboard-team-webhooks)
permissions (:permissions team) permissions (:permissions team)
can-invite? (dnt/can-send-invitations?
{:organization (:organization team)
:profile-id (:id profile)
:team-permissions permissions})
invitations (:invitations team) invitations (:invitations team)
on-invite-member on-invite-member
(mf/use-fn (mf/use-fn
(mf/deps team invite-email) (mf/deps team invite-email)
(fn [] (fn []
(st/emit! (modal/show {:type :invite-members (st/emit! (dtm/check-and-invite-members {:team-id (:id team)
:team team :origin :team
:origin :team :invite-email invite-email}))))]
:invite-email invite-email}))))]
(mf/with-effect [team invite-email] (mf/with-effect [team invite-email]
(when invite-email (when invite-email
@ -122,9 +125,11 @@
[:li {:class (when settings-section? (stl/css :active))} [:li {:class (when settings-section? (stl/css :active))}
[:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:a {:on-click on-nav-settings} (tr "labels.settings")]]]]
[:div {:class (stl/css :dashboard-buttons)} [:div {:class (stl/css :dashboard-buttons)}
(if (and (or invitations-section? members-section?) (:is-admin permissions) (not-empty invitations)) (if (and (or invitations-section? members-section?) (not-empty invitations))
[:a [:button
{:class (stl/css :btn-secondary :btn-small) {:class (stl/css :btn-secondary :btn-small)
:type "button"
:disabled (not can-invite?)
:on-click on-invite-member :on-click on-invite-member
:data-testid "invite-member"} :data-testid "invite-member"}
(tr "dashboard.invite-profile")] (tr "dashboard.invite-profile")]
@ -551,7 +556,9 @@
(st/emit! (dtm/fetch-members))) (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)} [:section {:class (stl/css :dashboard-container :dashboard-team-members)}
[:> team-members* [:> team-members*
@ -685,11 +692,12 @@
on-change on-change
(mf/use-fn (mf/use-fn
(mf/deps on-select-change) (mf/deps can-invite on-select-change)
(fn [event] (fn [event]
(let [email (-> (dom/get-current-target event) (when can-invite
(dom/get-data "attr"))] (let [email (-> (dom/get-current-target event)
(on-select-change email)))) (dom/get-data "attr"))]
(on-select-change email)))))
on-change-role on-change-role
(mf/use-fn (mf/use-fn
@ -701,19 +709,21 @@
[:div {:class (stl/css :table-row :table-row-invitations)} [:div {:class (stl/css :table-row :table-row-invitations)}
[:div {:class (stl/css :table-field :field-email)} [:div {:class (stl/css :table-field :field-email)}
[:div {:class (stl/css :input-wrapper)} (if can-invite
[:label [:div {:class (stl/css :input-wrapper)}
[:span {:class (stl/css-case :input-checkbox true [:label
:global/checked (is-selected? email))} [:span {:class (stl/css-case :input-checkbox true
deprecated-icon/status-tick] :global/checked (is-selected? email))}
deprecated-icon/status-tick]
[:input {:type "checkbox" [:input {:type "checkbox"
:id (dm/str "email-" email) :id (dm/str "email-" email)
:data-attr email :data-attr email
:value email :value email
:checked (is-selected? email) :checked (is-selected? email)
:on-change on-change}] :on-change on-change}]
email]]] email]]
[:div email])]
[:div {:class (stl/css :table-field :field-roles)} [:div {:class (stl/css :table-field :field-roles)}
[:> invitation-role-selector* [:> invitation-role-selector*
@ -740,20 +750,20 @@
on-invite-member (mf/use-fn on-invite-member (mf/use-fn
(mf/deps team invite-email) (mf/deps team invite-email)
(fn [] (fn []
(st/emit! (modal/show {:type :invite-members (st/emit! (dtm/check-and-invite-members {:team-id (:id team)
:team team :origin :team
:origin :team :invite-email invite-email}))))]
:invite-email invite-email}))))]
[:div {:class (stl/css :empty-invitations)} [:div {:class (stl/css :empty-invitations)}
[:span (tr "labels.no-invitations")] [:span (tr "labels.no-invitations")]
(when ^boolean can-invite (if ^boolean can-invite
[[:span (tr "labels.no-invitations-gather-people")] [[:span (tr "labels.no-invitations-gather-people")]
[:a [:a
{:class (stl/css :btn-empty-invitations) {:class (stl/css :btn-empty-invitations)
:on-click on-invite-member :on-click on-invite-member
:data-testid "invite-member"} :data-testid "invite-member"}
(tr "dashboard.invite-profile")] (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/defc invitation-modal
{::mf/register modal/components {::mf/register modal/components
@ -880,15 +890,16 @@
(mf/defc invitation-section* (mf/defc invitation-section*
{::mf/private true} {::mf/private true}
[{:keys [team]}] [{:keys [team profile]}]
(let [permissions (get team :permissions) (let [permissions (get team :permissions)
invitations (mf/use-state (get team :invitations)) invitations (mf/use-state (get team :invitations))
team-id (get team :id) team-id (get team :id)
owner? (get permissions :is-owner) can-invite? (dnt/can-send-invitations?
admin? (get permissions :is-admin) {:organization (:organization team)
can-invite? (or owner? admin?) :profile-id (:id profile)
:team-permissions permissions})
selected (mf/use-state #{}) selected (mf/use-state #{})
@ -900,11 +911,12 @@
on-select-change on-select-change
(mf/use-fn (mf/use-fn
(mf/deps selected) (mf/deps can-invite? selected)
(fn [email] (fn [email]
(if (contains? @selected email) (when can-invite?
(swap! selected disj email) (if (contains? @selected email)
(swap! selected conj email)))) (swap! selected disj email)
(swap! selected conj email)))))
on-confirm-delete on-confirm-delete
(mf/use-fn (mf/use-fn
@ -1014,7 +1026,12 @@
(reset! sort-state {:field nil :direction :asc})) (reset! sort-state {:field nil :direction :asc}))
[:div {:class (stl/css :invitations)} [: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 {:class (stl/css :invitations-actions)}
[:div [:div
@ -1075,10 +1092,11 @@
[:* [:*
[:& header {:section :dashboard-team-invitations [:& header {:section :dashboard-team-invitations
:team team}] :team team
:profile profile}]
[:section {:class (stl/css :dashboard-team-invitations)} [:section {:class (stl/css :dashboard-team-invitations)}
[:> invitation-section* {:team team}] [:> invitation-section* {:team team :profile profile}]
(when (and (contains? cfg/flags :subscriptions) (when (and (contains? cfg/flags :subscriptions)
(show-subscription-members-banner? team profile)) (show-subscription-members-banner? team profile))

View File

@ -9,8 +9,8 @@
(:require (:require
[app.main.data.common :as dcm] [app.main.data.common :as dcm]
[app.main.data.event :as ev] [app.main.data.event :as ev]
[app.main.data.modal :as modal]
[app.main.data.shortcuts :as scd] [app.main.data.shortcuts :as scd]
[app.main.data.team :as dtm]
[app.main.data.workspace :as dw] [app.main.data.workspace :as dw]
[app.main.data.workspace.drawing.common :as dwc] [app.main.data.workspace.drawing.common :as dwc]
[app.main.data.workspace.history :as dwh] [app.main.data.workspace.history :as dwh]
@ -186,9 +186,8 @@
(mf/use-fn (mf/use-fn
(mf/deps team) (mf/deps team)
(fn [] (fn []
(st/emit! (modal/show {:type :invite-members (st/emit! (dtm/check-and-invite-members {:team-id (:id team)
:team team :origin :workspace}))))]
:origin :workspace}))))]
(mf/with-effect [editing?] (mf/with-effect [editing?]
(when ^boolean editing? (when ^boolean editing?

View File

@ -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" 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." 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."

View File

@ -9237,3 +9237,7 @@ msgstr "No tienes permiso para eliminar equipos que pertenecen a la organizació
msgid "dashboard.no-permission-move-team.message" 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." 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."