diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index ddb5f307a2..eb9735c751 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -360,7 +360,7 @@ [:map [:organization-id ::sm/uuid] [:owner-id ::sm/uuid] - [:create-teams [:enum "any" "onlyMe"]]] + [:permissions [:map-of :keyword :string]]] params))) (defn- redeem-activation-code-api diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index cc77878edb..8cf5a9bba2 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -12,6 +12,7 @@ [app.common.exceptions :as ex] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.nitrate-permissions :as nitrate-perms] [app.config :as cf] [app.db :as db] [app.nitrate :as nitrate] @@ -313,12 +314,12 @@ (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions") - (let [create-perm (:create-teams org-perms) - is-owner? (= profile-id (:owner-id org-perms))] - (when (and (= create-perm "onlyMe") (not is-owner?)) - (ex/raise :type :validation - :code :not-allowed - :hint "You are not allowed to add teams in this organization")))))) + (when-not (nitrate-perms/allowed? :create-team + {:org-perms org-perms + :profile-id profile-id}) + (ex/raise :type :validation + :code :not-allowed + :hint "You are not allowed to add teams in this organization"))))) (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] ;; Add teammates to the org if needed diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 7c84657086..3f142f1ce7 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -12,6 +12,7 @@ [app.common.features :as cfeat] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.nitrate-permissions :as nitrate-perms] [app.common.types.team :as types.team] [app.common.uuid :as uuid] [app.config :as cf] @@ -520,12 +521,12 @@ (ex/raise :type :validation :code :not-allowed :hint "Unable to verify organization permissions") - (let [create-perm (:create-teams org-perms) - is-owner? (= profile-id (:owner-id org-perms))] - (when (and (= create-perm "onlyMe") (not is-owner?)) - (ex/raise :type :validation - :code :not-allowed - :hint "You are not allowed to create teams in this organization")))))) + (when-not (nitrate-perms/allowed? :create-team + {:org-perms org-perms + :profile-id profile-id}) + (ex/raise :type :validation + :code :not-allowed + :hint "You are not allowed to create teams in this organization"))))) (let [features (-> (cfeat/get-enabled-features cf/flags) (set/difference cfeat/frontend-only-features) @@ -773,20 +774,35 @@ (defn delete-team "Mark a team for deletion" - [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id] :as params}] (let [team (get-team conn :profile-id profile-id :team-id team-id) + team (if (contains? cf/flags :nitrate) + (nitrate/add-org-info-to-team cfg team params) + team) perms (get team :permissions)] - (when-not (:is-owner perms) - (ex/raise :type :validation - :code :only-owner-can-delete-team)) - (when (:is-default team) (ex/raise :type :validation :code :non-deletable-team :hint "impossible to delete default team")) + ;; Check delete permissions based on organization settings. + ;; For non-org teams or when nitrate is disabled, only owners can delete. + (if (and (:organization-id team) (contains? cf/flags :nitrate)) + (let [org-perms {:owner-id (:organization-owner-id team) + :permissions (:organization-permissions team)}] + (when-not (nitrate-perms/allowed? :delete-team + {:org-perms org-perms + :profile-id profile-id + :team-perms perms}) + (ex/raise :type :validation + :code :not-allowed + :hint "You are not allowed to delete teams in this organization"))) + (when-not (:is-owner perms) + (ex/raise :type :validation + :code :only-owner-can-delete-team))) + (let [delay (ldel/get-deletion-delay team) team (db/update! conn :team {:deleted-at (ct/in-future delay)} diff --git a/common/src/app/common/types/nitrate_permissions.cljc b/common/src/app/common/types/nitrate_permissions.cljc new file mode 100644 index 0000000000..bcf8c675eb --- /dev/null +++ b/common/src/app/common/types/nitrate_permissions.cljc @@ -0,0 +1,41 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.types.nitrate-permissions) + +(def ^:private defaults + {:create-teams "any" + :delete-teams "ownersAndAdmins"}) + +(def ^:private action-rules + {:create-team {:permission-key :create-teams + :allowed-values #{"any"} + :requires-admin? false} + :delete-team {:permission-key :delete-teams + :allowed-values #{"ownersAndAdmins"} + :requires-admin? true}}) + +(defn- normalize-org-permissions + [org-perms] + (merge defaults (or (:permissions org-perms) {}))) + +(defn- owner? + [org-perms profile-id] + (= profile-id (:owner-id org-perms))) + +(defn allowed? + "Returns true only for explicitly allowed actions (fail-closed)." + [action {:keys [org-perms profile-id team-perms]}] + (let [{:keys [permission-key allowed-values requires-admin?] :as rule} + (get action-rules action) + permissions (normalize-org-permissions org-perms) + is-owner? (owner? org-perms profile-id) + is-admin? (boolean (:is-admin team-perms))] + (cond + (nil? rule) false + is-owner? true + (and requires-admin? (not is-admin?)) false + :else (contains? allowed-values (get permissions permission-key))))) diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc index 9205da9246..41d5dbe949 100644 --- a/common/src/app/common/types/organization.cljc +++ b/common/src/app/common/types/organization.cljc @@ -16,7 +16,10 @@ [:owner-id ::sm/uuid] [:avatar-bg-url ::sm/uri] [:logo-id {:optional true} [:maybe ::sm/uuid]] - [:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]]]) + [:permissions {:optional true} + [:maybe [:map + [:create-teams {:optional true} [:maybe [:enum "any" "onlyMe"]]] + [:delete-teams {:optional true} [:maybe [:enum "ownersAndAdmins" "onlyOwners"]]]]]]]) (def schema:team-with-organization @@ -33,7 +36,7 @@ [:slug :organization-slug] [:avatar-bg-url :organization-avatar-bg-url] [:owner-id :organization-owner-id] - [:create-teams :organization-create-teams]]) + [:permissions :organization-permissions]]) (defn apply-organization "Updates a team map with organization fields sourced from org. diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 29540525db..902f1d1aef 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -67,6 +67,7 @@ [common-tests.types.container-test] [common-tests.types.fill-test] [common-tests.types.modifiers-test] + [common-tests.types.nitrate-permissions-test] [common-tests.types.objects-map-test] [common-tests.types.path-data-test] [common-tests.types.shape-decode-encode-test] @@ -147,6 +148,7 @@ 'common-tests.types.container-test 'common-tests.types.fill-test 'common-tests.types.modifiers-test + 'common-tests.types.nitrate-permissions-test 'common-tests.types.objects-map-test 'common-tests.types.path-data-test 'common-tests.types.shape-decode-encode-test diff --git a/common/test/common_tests/types/nitrate_permissions_test.cljc b/common/test/common_tests/types/nitrate_permissions_test.cljc new file mode 100644 index 0000000000..be0ca77d12 --- /dev/null +++ b/common/test/common_tests/types/nitrate_permissions_test.cljc @@ -0,0 +1,57 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.types.nitrate-permissions-test + (:require + [app.common.types.nitrate-permissions :as nitrate-perms] + [clojure.test :as t])) + +(def org-perms + {:owner-id :owner + :permissions {:create-teams "any" + :delete-teams "ownersAndAdmins"}}) + +(t/deftest unknown-action-is-denied + (t/is (false? (nitrate-perms/allowed? :unknown + {:org-perms org-perms + :profile-id :member + :team-perms {:is-admin true}})))) + +(t/deftest owner-is-always-allowed + (t/is (true? (nitrate-perms/allowed? :create-team + {:org-perms org-perms + :profile-id :owner + :team-perms {:is-admin false}}))) + (t/is (true? (nitrate-perms/allowed? :delete-team + {:org-perms org-perms + :profile-id :owner + :team-perms {:is-admin false}})))) + +(t/deftest create-team-permission-rules + (t/is (true? (nitrate-perms/allowed? :create-team + {:org-perms org-perms + :profile-id :member + :team-perms {:is-admin false}}))) + (t/is (false? (nitrate-perms/allowed? :create-team + {:org-perms (assoc org-perms :permissions {:create-teams "none" + :delete-teams "ownersAndAdmins"}) + :profile-id :member + :team-perms {:is-admin false}})))) + +(t/deftest delete-team-requires-admin-and-policy + (t/is (false? (nitrate-perms/allowed? :delete-team + {:org-perms org-perms + :profile-id :member + :team-perms {:is-admin false}}))) + (t/is (true? (nitrate-perms/allowed? :delete-team + {:org-perms org-perms + :profile-id :member + :team-perms {:is-admin true}}))) + (t/is (false? (nitrate-perms/allowed? :delete-team + {:org-perms (assoc org-perms :permissions {:create-teams "any" + :delete-teams "onlyOwners"}) + :profile-id :member + :team-perms {:is-admin true}})))) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 9f89218084..7f24c77cb8 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -189,7 +189,7 @@ (let [all-orgs (map dt/team->organization (filter #(and (:is-default %) (:organization-id %)) teams)) orgs (filter (fn [org] - (let [perm (:create-teams org) + (let [perm (get-in org [:permissions :create-teams]) is-own? (= profile-id (:owner-id org))] (or (= perm "any") is-own?))) all-orgs) team (first (filter #(= (:id %) team-id) teams)) @@ -198,7 +198,7 @@ :organization-id organization-id})))] (rx/of (dt/teams-fetched teams) (if (empty? orgs) - (modal/show :no-org-allows-create-team {}) + (modal/show :no-permission-modal {:type :no-orgs-create}) (let [has-filtered? (< (count orgs) (count all-orgs)) extra-props (when has-filtered? {:info-message-key "dashboard.select-org-modal.permission-info"})] @@ -228,7 +228,7 @@ (let [all-orgs (map dt/team->organization (filter #(and (:is-default %) (:organization-id %)) teams)) orgs (filter (fn [org] - (let [perm (:create-teams org) + (let [perm (get-in org [:permissions :create-teams]) is-own? (= profile-id (:owner-id org))] (or (= perm "any") is-own?))) all-orgs) team (first (filter #(= (:id %) team-id) teams)) @@ -237,7 +237,7 @@ :organization-id organization-id})))] (rx/of (dt/teams-fetched teams) (if (empty? orgs) - (modal/show :no-org-allows-create-team {}) + (modal/show :no-permission-modal {:type :no-orgs-change}) (let [has-filtered? (< (count orgs) (count all-orgs)) extra-props (when has-filtered? {:info-message-key "dashboard.select-org-modal.permission-info"})] diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 337e853ba6..55803cedfd 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -11,6 +11,7 @@ [app.common.exceptions :as ex] [app.common.logging :as log] [app.common.schema :as sm] + [app.common.types.nitrate-permissions :as nitrate-perms] [app.common.types.team :as ctt] [app.common.uri :as u] [app.config :as cf] @@ -22,6 +23,7 @@ [app.main.repo :as rp] [app.main.router :as rt] [app.util.clipboard :as clipboard] + [app.util.i18n :refer [tr]] [app.util.storage :as storage] [beicon.v2.core :as rx] [clojure.string :as str] @@ -52,7 +54,7 @@ :organization-slug :organization-owner-id :organization-avatar-bg-url - :organization-create-teams))] + :organization-permissions))] (update state :teams assoc id team-updated))) state teams))))) @@ -78,16 +80,55 @@ (fn [teams] (let [team (d/seek #(= (:id %) team-id) teams) in-org? (and (contains? cf/flags :nitrate) (:organization-id team)) - create-perm (:organization-create-teams team) - is-owner? (= profile-id (:organization-owner-id team)) - can-create? (or (not in-org?) (= create-perm "any") is-owner?)] + can-create? (if in-org? + (nitrate-perms/allowed? :create-team + {:org-perms {:owner-id (:organization-owner-id team) + :permissions (:organization-permissions team)} + :profile-id profile-id + :team-perms (:permissions team)}) + true)] (rx/of (teams-fetched teams) (if can-create? (modal/show :team-form (if in-org? {:organization-id (:organization-id team) :organization-name (:organization-name team)} {})) - (modal/show :no-permission-create-team {:organization-name (:organization-name team)}))))))))))) + (modal/show :no-permission-modal {:type :create-team + :organization-name (:organization-name team)}))))))))))) + +(defn check-and-delete-team + "Fetches fresh team data from the server to ensure up-to-date org + permissions, then shows the confirmation modal or a no-permission modal." + [{:keys [team-id delete-fn]}] + (ptk/reify ::check-and-delete-team + 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) + in-org? (and (contains? cf/flags :nitrate) (:organization-id team)) + can-delete? (if in-org? + (nitrate-perms/allowed? :delete-team + {:org-perms {:owner-id (:organization-owner-id team) + :permissions (:organization-permissions team)} + :profile-id profile-id + :team-perms (:permissions team)}) + (boolean (dm/get-in team [:permissions :is-owner]))) + message (if in-org? + (tr "modals.delete-org-team-confirm.message" (:organization-name team)) + (tr "modals.delete-team-confirm.message"))] + (rx/of (teams-fetched teams) + (if can-delete? + (modal/show + {:type :confirm + :title (tr "modals.delete-team-confirm.title") + :message message + :accept-label (tr "modals.delete-team-confirm.accept") + :on-accept delete-fn}) + (modal/show :no-permission-modal {:type :delete-team + :organization-name (:organization-name team)}))))))))))) ;; --- EVENT: fetch-members @@ -625,5 +666,5 @@ :custom-photo (:organization-custom-photo team) :name (:organization-name team) :default-team-id (:id team) - :create-teams (:organization-create-teams team)}) + :permissions (:organization-permissions team)}) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index e7bd42ba53..cba9c78fe7 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -468,6 +468,10 @@ :owner-cant-leave-team (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) + :not-allowed + (rx/of (modal/show :no-permission-modal {:type :delete-team + :organization-name (:organization-name team)})) + (rx/throw error)))) leave-fn @@ -526,17 +530,9 @@ (mf/use-fn (mf/deps team delete-fn) (fn [] - (let [is-org-team? (some? (:organization-id team)) - message (if is-org-team? - (tr "modals.delete-org-team-confirm.message" (:organization-name team)) - (tr "modals.delete-team-confirm.message"))] - (st/emit! - (modal/show - {:type :confirm - :title (tr "modals.delete-team-confirm.title") - :message message - :accept-label (tr "modals.delete-team-confirm.accept") - :on-accept delete-fn})))))] + ;; Fetch fresh team data to check current permission, then show appropriate modal + (st/emit! (dtm/check-and-delete-team {:team-id (:id team) + :delete-fn delete-fn}))))] [:> dropdown-menu* props [:> dropdown-menu-item* {:on-click go-members @@ -583,11 +579,19 @@ :class (stl/css :team-options-item)} (tr "dashboard.leave-team")]) - (when (get-in team [:permissions :is-owner]) - [:> dropdown-menu-item* {:on-click on-delete-clicked - :class (stl/css :team-options-item :warning) - :data-testid "delete-team"} - (tr "dashboard.delete-team")])])) + (let [is-owner? (get-in team [:permissions :is-owner]) + is-admin? (get-in team [:permissions :is-admin]) + is-org-team? (some? (:organization-id team)) + in-org? (and (contains? cf/flags :nitrate) is-org-team?) + show-delete? (if in-org? + (or is-owner? is-admin?) + is-owner?)] + + (when show-delete? + [:> dropdown-menu-item* {:on-click on-delete-clicked + :class (stl/css :team-options-item :warning) + :data-testid "delete-team"} + (tr "dashboard.delete-team")]))])) (mf/defc org-options-dropdown* {::mf/private true} diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index be0a257ac3..106a01d3de 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -1381,7 +1381,7 @@ organizations (mf/with-memo [all-organizations profile-id] (->> all-organizations (filter (fn [org] - (let [perm (:create-teams org) + (let [perm (get-in org [:permissions :create-teams]) is-owner? (= profile-id (:owner-id org))] (or (= perm "any") is-owner?)))))) diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index 603c680984..b79cc518e3 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -46,7 +46,8 @@ (let [id (get-in @form [:clean-data :id]) code (-> response ex-data :code)] (if (= code :not-allowed) - (rx/of (modal/show :no-permission-create-team {:organization-name organization-name})) + (rx/of (modal/show :no-permission-modal {:type :create-team + :organization-name organization-name})) (if id (rx/of (ntf/error "Error on updating team.")) (rx/of (ntf/error "Error on creating team.")))))) @@ -139,31 +140,25 @@ :class (stl/css :accept-btn)}]]]]]])) -(mf/defc no-permission-create-team-modal* +(mf/defc no-permission-modal* + "Generic modal for displaying permission-related messages based on error type" {::mf/register modal/components - ::mf/register-as :no-permission-create-team} - [{:keys [organization-name]}] - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} - (tr "labels.create-team")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] - [:div {:class (stl/css :modal-content)} - [:div (tr "dashboard.no-permission-create-team.message" organization-name)]]]]) - - -(mf/defc no-org-allows-create-team-modal* - {::mf/register modal/components - ::mf/register-as :no-org-allows-create-team} - [_props] - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-container)} - [:div {:class (stl/css :modal-header)} - [:h2 {:class (stl/css :modal-title)} - (tr "dashboard.select-org-modal.title")] - [:button {:class (stl/css :modal-close-btn) - :on-click modal/hide!} deprecated-icon/close]] - [:div {:class (stl/css :modal-content)} - [:div (tr "dashboard.no-org-allows-create-team.message")]]]]) + ::mf/register-as :no-permission-modal} + [{:keys [type organization-name]}] + (let [[title message] (case type + :create-team [(tr "labels.create-team") + (tr "dashboard.no-permission-create-team.message" organization-name)] + :delete-team [(tr "dashboard.delete-team") + (tr "dashboard.no-permission-delete-team.message" organization-name)] + :no-orgs-create [(tr "dashboard.select-org-modal.title") + (tr "dashboard.no-org-allows-create-team.message")] + :no-orgs-change [(tr "dashboard.change-org-modal.title") + (tr "dashboard.no-org-allows-create-team.message")])] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-title)} title] + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + [:div {:class (stl/css :modal-content)} + [:div message]]]])) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 6b14173adc..f19fec8c73 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -9423,3 +9423,6 @@ msgstr "You don't have permission to add teams to any of your organizations." msgid "dashboard.select-org-modal.permission-info" msgstr "Here you find all your organizations where you are allowed to create or add teams." + +msgid "dashboard.no-permission-delete-team.message" +msgstr "You are not allowed to delete teams that are part of %s organization. If you need more information, contact the owner." diff --git a/frontend/translations/es.po b/frontend/translations/es.po index f81279149f..ef20c2721f 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9128,3 +9128,6 @@ msgstr "No tienes permiso para añadir equipos a ninguna de tus organizaciones." msgid "dashboard.select-org-modal.permission-info" msgstr "Aquí encontrarás todas las organizaciones en las que tienes permiso para crear o añadir equipos." + +msgid "dashboard.no-permission-delete-team.message" +msgstr "No tienes permiso para eliminar equipos que pertenecen a la organización %s. Si necesitas más información, contacta con el propietario."