mirror of
https://github.com/penpot/penpot.git
synced 2026-05-24 16:38:40 -04:00
✨ Add nitrate advanced permissions for invite to teams
* ✨ Add nitrate advanced permissions for invite to teams * 📎 Code review
This commit is contained in:
@@ -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))))))
|
||||
|
||||
@@ -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}})))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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*)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user