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
9 changed files with 187 additions and 54 deletions

View File

@@ -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))))))

View File

@@ -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}})))))

View File

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

View File

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

View File

@@ -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*)

View File

@@ -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))

View File

@@ -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?

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