diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index bd00d52359..4f8a854d01 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -516,3 +516,36 @@ team-member-ids (into #{} (map :profile-id team-members))] (every? #(contains? team-member-ids %) org-member-ids))) false)) + +(def ^:private schema:all-team-members-in-orgs-params + [:map {:title "CheckTeamMembersInOrgsParams"} + [:team-id ::sm/uuid] + [:organization-ids [:vector ::sm/uuid]]]) + +(sv/defmethod ::all-team-members-in-orgs + {::rpc/auth true + ::doc/added "2.17" + ::sm/params schema:all-team-members-in-orgs-params + ::sm/result [:map-of ::sm/uuid ::sm/boolean]} + [cfg {:keys [::rpc/profile-id team-id organization-ids]}] + (if (contains? cf/flags :nitrate) + (let [perms (teams/get-permissions cfg profile-id team-id)] + (when-not (or (:is-admin perms) (:is-owner perms)) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (let [team-members (db/query cfg :team-profile-rel {:team-id team-id}) + team-member-ids (into #{} (map :profile-id team-members))] + ;; Validate requester membership in all orgs before fetching members. + (run! #(assert-membership cfg profile-id %) organization-ids) + + (into {} + (map (fn [organization-id] + (let [org-members (nitrate/call cfg :get-org-members {:organization-id organization-id}) + org-member-ids (into #{} org-members)] + [organization-id + (every? #(contains? org-member-ids %) team-member-ids)]))) + organization-ids))) + {})) + + diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index eeadccf687..371adb9548 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -7,6 +7,7 @@ (ns backend-tests.rpc-nitrate-test (:require [app.common.uuid :as uuid] + [app.config :as cf] [app.db :as-alias db] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] @@ -715,6 +716,71 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) +(t/deftest all-team-members-in-orgs-returns-org-id->boolean-map + (let [profile-user (th/create-profile* 201 {:is-active true}) + profile-other (th/create-profile* 202 {:is-active true}) + team (th/create-team* 201 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team) + :profile-id (:id profile-other) + :role :editor}) + team-member-ids (->> (th/db-query :team-profile-rel {:team-id (:id team)}) + (map :profile-id) + (into #{})) + org-id-1 (uuid/random) + org-id-2 (uuid/random) + calls (atom [])] + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (fn [_cfg method params] + (swap! calls conj [method params]) + (case method + :get-org-membership {:is-member true + :organization-id (:organization-id params)} + :get-org-members (get {org-id-1 (vec team-member-ids) + org-id-2 [(:id profile-user)]} + (:organization-id params) + []) + nil))] + (let [out (th/command! {::th/type :all-team-members-in-orgs + ::rpc/profile-id (:id profile-user) + :team-id (:id team) + :organization-ids [org-id-1 org-id-2]}) + methods (map first @calls) + membership-calls (count (filter #(= :get-org-membership %) methods)) + get-members-calls (count (filter #(= :get-org-members %) methods))] + (t/is (th/success? out)) + (t/is (= {org-id-1 true + org-id-2 false} + (:result out))) + (t/is (= 2 membership-calls)) + (t/is (= 2 get-members-calls)))))) + +(t/deftest all-team-members-in-orgs-fails-before-fetching-org-members + (let [profile-user (th/create-profile* 203 {:is-active true}) + team (th/create-team* 203 {:profile-id (:id profile-user)}) + org-id-1 (uuid/random) + org-id-2 (uuid/random) + calls (atom [])] + (with-redefs [cf/flags (conj cf/flags :nitrate) + nitrate/call (fn [_cfg method params] + (swap! calls conj [method params]) + (case method + :get-org-membership (if (= (:organization-id params) org-id-2) + {:is-member false + :organization-id (:organization-id params)} + {:is-member true + :organization-id (:organization-id params)}) + :get-org-members [] + nil))] + (let [out (th/command! {::th/type :all-team-members-in-orgs + ::rpc/profile-id (:id profile-user) + :team-id (:id team) + :organization-ids [org-id-1 org-id-2]}) + methods (map first @calls)] + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :user-doesnt-belong-organization (th/ex-code (:error out)))) + (t/is (= 0 (count (filter #(= :get-org-members %) methods)))))))) + (t/deftest leave-org-error-reassign-on-non-owned-team (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index d08a8e90ce..37539d49d8 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -247,25 +247,69 @@ is-own? (= profile-id (:owner-id org))] (or (= perm "any") is-own?))) all-orgs) team (first (filter #(= (:id %) team-id) teams)) + add-anybody-to-team-orgs + (filterv #(nitrate-perms/allowed? :add-anybody-to-team + {:org-perms %}) + orgs) + orgs-to-check + (filterv #(not (nitrate-perms/allowed? :add-anybody-to-team + {:org-perms %})) + orgs) + org-ids-to-check (mapv :id orgs-to-check) on-confirm (fn [organization-id] (st/emit! (add-team-to-org {:team-id team-id - :organization-id organization-id})))] - (rx/of (dt/teams-fetched teams) - (if (empty? orgs) - (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"})] - (modal/show :select-organization-modal - (merge {:organizations orgs - :current-organization-id (dm/get-in team [:organization :id]) - :on-confirm on-confirm - :title-key "dashboard.select-org-modal.title" - :choose-key "dashboard.select-org-modal.choose" - :placeholder-key "dashboard.select-org-modal.select" - :accept-key "dashboard.select-org-modal.accept" - :cancel-key "labels.cancel"} - extra-props))))))))))))) + :organization-id organization-id}))) + show-select-modal + (fn [orgs-allowed] + (let [has-filtered? (< (count orgs) (count all-orgs)) + extra-props (when has-filtered? + {:info-message-key "dashboard.select-org-modal.permission-info"})] + (modal/show :select-organization-modal + (merge {:organizations orgs + :orgs-allowed orgs-allowed + :current-organization-id (dm/get-in team [:organization :id]) + :on-confirm on-confirm + :title-key "dashboard.select-org-modal.title" + :choose-key "dashboard.select-org-modal.choose" + :placeholder-key "dashboard.select-org-modal.select" + :accept-key "dashboard.select-org-modal.accept" + :cancel-key "labels.cancel"} + extra-props))))] + (cond + (empty? orgs) + (rx/of (dt/teams-fetched teams) + (modal/show :no-permission-modal {:type :no-orgs-create})) + + (empty? org-ids-to-check) + (let [orgs-allowed (into {} + (map (fn [org] [(:id org) true])) + orgs)] + (rx/of (dt/teams-fetched teams) + (show-select-modal orgs-allowed))) + + :else + (->> (rp/cmd! :all-team-members-in-orgs + {:team-id team-id + :organization-ids org-ids-to-check}) + (rx/mapcat + (fn [checked-orgs] + (let [orgs-allowed + (merge (into {} + (map (fn [org] [(:id org) true])) + add-anybody-to-team-orgs) + checked-orgs) + valid-orgs + (filterv #(true? (get orgs-allowed (:id %))) orgs)] + (rx/of + (dt/teams-fetched teams) + (if (empty? valid-orgs) + (modal/show + {:type :alert + :message (tr "dashboard.team-organization.add.no-valid-orgs") + :accept-label (tr "labels.accept") + :accept-style :primary + :title (tr "dashboard.select-org-modal.title")}) + (show-select-modal orgs-allowed)))))))))))))))) (defn show-change-team-org-modal "Fetches fresh team/org data, then shows the change-org modal diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index c8a50ab13f..6281a863c9 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -883,16 +883,24 @@ (mf/defc select-organization-modal {::mf/register modal/components ::mf/register-as :select-organization-modal} - [{:keys [organizations current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}] + [{:keys [organizations orgs-allowed current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key info-message-key]}] (let [valid-organizations (mf/with-memo [organizations] (remove #(= (:id %) current-organization-id) organizations)) - options (mf/with-memo [valid-organizations] + options (mf/with-memo [valid-organizations orgs-allowed] (mapv (fn [organization] - {:id (str (:id organization)) - :label (:name organization) - :avatar {:render-fn render-org-combobox-avatar* - :organization organization - :size "xl"}}) + (let [org-id (:id organization) + ;; orgs-allowed is a map of org-id and a boolean indicating if it is allowed + enabled? (or (nil? orgs-allowed) + (true? (get orgs-allowed org-id)))] + (cond-> {:id (str org-id) + :label (:name organization) + :disabled (not enabled?) + :dimmed (not enabled?) + :avatar {:render-fn render-org-combobox-avatar* + :organization organization + :size "xl"}} + (not enabled?) + (assoc :title (tr "dashboard.team-organization.disabled-org-tooltip"))))) valid-organizations)) form (fm/use-form :schema schema:organization-form :initial {}) diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index 14ade592a2..127f61ce67 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -93,16 +93,18 @@ on-option-click (mf/use-fn - (mf/deps on-change) + (mf/deps on-change options) (fn [event] (dom/stop-propagation event) (let [node (dom/get-current-target event) - id (dom/get-data node "id")] - (reset! selected-id* id) - (reset! is-open* false) - (reset! focused-id* nil) - (when (fn? on-change) - (on-change id))))) + id (dom/get-data node "id") + option (d/seek #(= id (get % :id)) options)] + (when-not (true? (:disabled option)) + (reset! selected-id* id) + (reset! is-open* false) + (reset! focused-id* nil) + (when (fn? on-change) + (on-change id)))))) on-click (mf/use-fn @@ -192,14 +194,15 @@ (handle-focus-change options focused-id* new-index nodes)) (kbd/enter? event) - (do - (reset! selected-id* focused-id) - (reset! is-open* false) - (reset! focused-id* nil) - (dom/blur! (mf/ref-val input-ref)) - (when (and (fn? on-change) - (some? focused-id)) - (on-change focused-id))) + (let [focused-option (d/seek #(= focused-id (get % :id)) options)] + (when-not (true? (:disabled focused-option)) + (reset! selected-id* focused-id) + (reset! is-open* false) + (reset! focused-id* nil) + (dom/blur! (mf/ref-val input-ref)) + (when (and (fn? on-change) + (some? focused-id)) + (on-change focused-id)))) (kbd/esc? event) (do (reset! is-open* false) diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.cljs b/frontend/src/app/main/ui/ds/controls/shared/option.cljs index b313bf4263..cc99a925f1 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/option.cljs @@ -20,8 +20,10 @@ [:icon {:optional true} [:maybe :string]] [:selected {:optional true} :boolean] [:focused {:optional true} :boolean] + [:disabled {:optional true} :boolean] [:dimmed {:optional true} :boolean] [:label {:optional true} :string] + [:title {:optional true} [:maybe :string]] [:avatar {:optional true} [:maybe [:map @@ -39,23 +41,26 @@ (mf/defc option* {::mf/schema schema:option} - [{:keys [id ref label icon avatar aria-label on-click selected focused dimmed] :rest props}] + [{:keys [id ref label icon avatar aria-label on-click selected focused disabled dimmed title] :rest props}] (let [render-avatar-fn (when avatar (get avatar :render-fn)) class (stl/css-case :option true :option-with-icon (some? icon) :option-with-avatar (fn? render-avatar-fn) + :option-disabled disabled :option-selected selected :option-current focused)] [:li {:value id :class class :aria-selected selected + :aria-disabled disabled :ref ref :role "option" :id id - :on-click on-click + :title title + :on-click (when-not disabled on-click) :data-id id :data-testid "dropdown-option"} diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss index 03f765af20..c4b72e8ec7 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss @@ -71,6 +71,10 @@ --options-icon-fg-color: var(--color-accent-primary); } +.option-disabled { + opacity: 0.6; +} + .option-check { color: var(--token-options-icon-fg-color); min-width: var(--sp-l); diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index 3ed3d6b3a3..aca4178de4 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -37,11 +37,14 @@ [:value {:optional true} :keyword] [:icon {:optional true} schema:icon-list] [:label {:optional true} :string] + [:title {:optional true} :string] [:avatar {:optional true} [:map [:size {:optional true} :string] [:organization {:optional true} :any] [:render-fn {:optional true} fn?]]] + [:disabled {:optional true} :boolean] + [:dimmed {:optional true} :boolean] [:aria-label {:optional true} :string]]) (def ^:private schema:options-dropdown diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs index ef015467be..39020f88a5 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs @@ -59,9 +59,11 @@ :key (weak-key option) :id id :label (get option :label) + :title (get option :title) :aria-label (get option :aria-label) :icon (get option :icon) :avatar (get option :avatar) + :disabled (true? (:disabled option)) :ref ref :role "option" :focused (= id focused) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 8383975a62..a28e60343c 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1150,6 +1150,9 @@ msgstr "This team is not part of any organization" msgid "dashboard.team-organization.add" msgstr "Add to an organization" +msgid "dashboard.team-organization.add.no-valid-orgs" +msgstr "You are not allowed to add this team to any of your organizations." + msgid "dashboard.select-org-modal.title" msgstr "Add team to an organization" @@ -1183,6 +1186,9 @@ msgstr "Change team organization" msgid "dashboard.team-organization.remove" msgstr "Remove team from organization" +msgid "dashboard.team-organization.disabled-org-tooltip" +msgstr "Only people within your organization can be invited." + #: src/app/main/ui/dashboard/team.cljs:1344 msgid "dashboard.team-projects" msgstr "Team projects" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index ee6f9cbaa1..4a22fb7a9b 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -1154,6 +1154,9 @@ msgstr "Este equipo no pertenece a ninguna organización" msgid "dashboard.team-organization.add" msgstr "Añadir a una organización" +msgid "dashboard.team-organization.add.no-valid-orgs" +msgstr "No tienes permiso para añadir este equipo a ninguna de tus organizaciones." + msgid "dashboard.select-org-modal.title" msgstr "Añadir el equipo a una organización" @@ -1187,6 +1190,9 @@ msgstr "Cambiar el equipo de organización" msgid "dashboard.team-organization.remove" msgstr "Eliminar equipo de la organización" +msgid "dashboard.team-organization.disabled-org-tooltip" +msgstr "Solo se puede invitar a personas que estén dentro de tu organización." + #: src/app/main/ui/dashboard/team.cljs:1344 msgid "dashboard.team-projects" msgstr "Proyectos del equipo"