diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 6bd5a7f855..f02c36197f 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -57,6 +57,7 @@ (def ^:private sql:get-member-teams-info "SELECT t.id, + t.is_default, tpr.is_owner, (SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS num_members, (SELECT array_agg(profile_id) FROM team_profile_rel WHERE team_id = t.id) AS member_ids @@ -76,6 +77,7 @@ (def ^:private schema:leave-org [:map [:org-id ::sm/uuid] + [:org-name ::sm/text] [:default-team-id ::sm/uuid] [:teams-to-delete [:vector ::sm/uuid]] @@ -85,62 +87,72 @@ [:id ::sm/uuid] [:reassign-to {:optional true} ::sm/uuid]]]]]) -(sv/defmethod ::leave-org - {::rpc/auth true - ::doc/added "2.15" - ::sm/params schema:leave-org - ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id org-id default-team-id teams-to-delete teams-to-leave] :as params}] + +(defn- get-organization-teams-for-user + [{:keys [::db/conn] :as cfg} org-summary profile-id] + (let [org-team-ids (->> (:teams org-summary) + (map :id)) + ids-array (db/create-array conn "uuid" org-team-ids)] + (db/exec! conn [sql:get-member-teams-info profile-id ids-array]))) + +(defn- calculate-valid-teams + ([org-teams default-team-id] + (let [;; valid default team is the one which id is default-team-id + valid-default-team (d/seek #(= default-team-id (:id %)) org-teams) + + ;; Remove your-penpot for the rest of validations + org-teams (remove #(= default-team-id (:id %)) org-teams) + + ;; valid teams to delete are those that the user is owner, and only have one member + valid-teams-to-delete-ids (->> org-teams + (filter #(and (:is-owner %) + (= (:num-members %) 1))) + (map :id) + (into #{})) + ;; valid teams to transfer are those that the user is owner, and have more than one member + valid-teams-to-transfer (->> org-teams + (filter #(and (:is-owner %) + (> (:num-members %) 1)))) + + ;; valid teams to exit are those that the user isn't owner, and have more than one member + valid-teams-to-exit (->> org-teams + (filter #(and (not (:is-owner %)) + (> (:num-members %) 1))))] + {:valid-teams-to-delete-ids valid-teams-to-delete-ids + :valid-teams-to-transfer valid-teams-to-transfer + :valid-teams-to-exit valid-teams-to-exit + :valid-default-team valid-default-team}))) + +(defn get-valid-teams [cfg org-id profile-id default-team-id] (let [org-summary (nitrate/call cfg :get-org-summary {:org-id org-id}) + org-teams (get-organization-teams-for-user cfg org-summary profile-id)] + (calculate-valid-teams org-teams default-team-id))) - org-name (:name org-summary) - org-prefix (str "[" (d/sanitize-string org-name) "] ") +(defn- assert-valid-teams [cfg profile-id org-id default-team-id teams-to-delete teams-to-leave] + (let [org-summary (nitrate/call cfg :get-org-summary {:org-id org-id}) + org-teams (get-organization-teams-for-user cfg org-summary profile-id) + {:keys [valid-teams-to-delete-ids + valid-teams-to-transfer + valid-teams-to-exit + valid-default-team]} (calculate-valid-teams org-teams default-team-id) - your-penpot-ids (->> (:teams org-summary) - (filter :is-your-penpot) - (map :id) - (into #{})) - valid-default-team-id? (contains? your-penpot-ids default-team-id) - org-team-ids (->> (:teams org-summary) - (remove :is-your-penpot) - (map :id)) - ids-array (db/create-array conn "uuid" org-team-ids) - teams (db/exec! conn [sql:get-member-teams-info profile-id ids-array]) - teams-by-id (d/index-by :id teams) - - ;; valid teams to delete are those that the user is owner, and only have one member - valid-teams-to-delete-ids (->> teams - (filter #(and (:is-owner %) - (= (:num-members %) 1))) - (map :id) - (into #{})) - - valid-teams-to-delete? (= valid-teams-to-delete-ids (into #{} teams-to-delete)) - - ;; valid teams to transfer are those that the user is owner, and have more than one member - valid-teams-to-transfer (->> teams - (filter #(and (:is-owner %) - (> (:num-members %) 1)))) - valid-teams-to-transfer-ids (->> valid-teams-to-transfer (map :id) (into #{})) - - ;; valid teams to exit are those that the user isn't owner, and have more than one member - valid-teams-to-exit (->> teams - (filter #(and (not (:is-owner %)) - (> (:num-members %) 1)))) valid-teams-to-exit-ids (->> valid-teams-to-exit (map :id) (into #{})) - + valid-teams-to-transfer-ids (->> valid-teams-to-transfer (map :id) (into #{})) valid-teams-to-leave-ids (into valid-teams-to-transfer-ids valid-teams-to-exit-ids) - default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id]) - :total) - delete-default-team? (= default-team-files-count 0) + valid-default-team-id? (some? valid-default-team) + + + + valid-teams-to-delete? (= valid-teams-to-delete-ids (into #{} teams-to-delete)) ;; for every team in teams-to-leave, check that: ;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and ;; the reassign-to is a member of the team and not the current user; ;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit + teams-by-id (d/index-by :id org-teams) valid-teams-to-leave? (and (= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{}))) (every? (fn [{:keys [id reassign-to]}] @@ -151,8 +163,7 @@ (contains? members reassign-to))) (contains? valid-teams-to-exit-ids id))) teams-to-leave))] - - + ;; the org owner cannot leave (when (= (:owner-id org-summary) profile-id) (ex/raise :type :validation :code :org-owner-cannot-leave)) @@ -162,7 +173,22 @@ (not valid-teams-to-leave?) (not valid-default-team-id?)) (ex/raise :type :validation - :code :not-valid-teams)) + :code :not-valid-teams)))) + + +(defn leave-org [{:keys [::db/conn] :as cfg} {:keys [profile-id org-id org-name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}] + (let [org-prefix (str "[" (d/sanitize-string org-name) "] ") + + default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id]) + :total) + delete-default-team? (= default-team-files-count 0)] + + + + + ;; assert that the received teams are valid, checking the different constraints + (when-not skip-validation + (assert-valid-teams cfg profile-id org-id default-team-id teams-to-delete teams-to-leave)) (assert-membership cfg profile-id org-id) @@ -187,6 +213,15 @@ nil)) +(sv/defmethod ::leave-org + {::rpc/auth true + ::doc/added "2.15" + ::sm/params schema:leave-org + ::db/transaction true} + [cfg {:keys [::rpc/profile-id] :as params}] + (leave-org cfg (assoc params :profile-id profile-id))) + + (def ^:private schema:remove-team-from-org [:map [:team-id ::sm/uuid] diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 722b48bb61..8638252338 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -685,7 +685,7 @@ ;; --- Mutation: Leave Team (defn leave-team - [{:keys [::db/conn]} {:keys [profile-id id reassign-to]}] + [{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id id reassign-to]}] (let [perms (get-permissions conn profile-id id) members (get-team-members conn id)] @@ -716,7 +716,15 @@ ;; assign owner role to new profile (db/update! conn :team-profile-rel (get types.team/permissions-for-role :owner) - {:team-id id :profile-id reassign-to})) + {:team-id id :profile-id reassign-to}) + + ;; notify new owner + (mbus/pub! msgbus + :topic reassign-to + :message {:type :team-role-change + :topic reassign-to + :team-id id + :role :owner})) ;; and finally, if all other conditions does not match and the ;; current profile is owner, we dont allow it because there diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 59a442e9c7..e173b1a0a6 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -17,6 +17,7 @@ [app.db :as db] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] + [app.rpc.commands.nitrate :as cnit] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] [app.rpc.commands.teams-invitations :as ti] @@ -340,3 +341,55 @@ RETURNING id, name;") (db/tx-run! cfg ti/create-org-invitation params) nil) + + +;; API: remove-from-org + +(def ^:private sql:get-reassign-to + "SELECT tpr.profile_id + FROM team_profile_rel AS tpr + WHERE tpr.team_id = ? + AND tpr.profile_id <> ? + AND tpr.is_owner IS NOT TRUE + ORDER BY CASE + WHEN tpr.is_admin IS TRUE THEN 1 + ELSE 2 + END, + tpr.created_at, + tpr.profile_id + LIMIT 1;") + +(defn add-reassign-to [cfg profile-id team-to-transfer] + (let [reassign-to (-> (db/exec-one! cfg [sql:get-reassign-to (:id team-to-transfer) profile-id]) + :profile-id)] + (when-not reassign-to + (ex/raise :type :validation + :code :nobody-to-reassign-team)) + + (assoc team-to-transfer :reassign-to reassign-to))) + +(sv/defmethod ::remove-from-org + "Remove an user from an organization" + {::doc/added "2.16" + ::sm/params [:map + [:profile-id ::sm/uuid] + [:org-id ::sm/uuid] + [:org-name ::sm/text] + [:default-team-id ::sm/uuid]] + ::db/transaction true} + [cfg {:keys [profile-id org-id org-name default-team-id] :as params}] + (let [{:keys [valid-teams-to-delete-ids + valid-teams-to-transfer + valid-teams-to-exit]} (cnit/get-valid-teams cfg org-id profile-id default-team-id) + add-reassign-to (partial add-reassign-to cfg profile-id) + + valid-teams-to-leave (into valid-teams-to-exit + (map add-reassign-to valid-teams-to-transfer))] + + (cnit/leave-org cfg (assoc params + :teams-to-delete valid-teams-to-delete-ids + :teams-to-leave valid-teams-to-leave + :skip-validation true)) + (notifications/notify-user-removed-from-org cfg profile-id org-id org-name "dashboard.user-no-longer-belong-org") + nil)) + diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj index fc3b4b1752..05f69f9781 100644 --- a/backend/src/app/rpc/notifications.clj +++ b/backend/src/app/rpc/notifications.clj @@ -21,4 +21,16 @@ :team-name team-name :organization-id organization-id :organization-name organization-name + :notification notification}))) + + +(defn notify-user-removed-from-org + [cfg profile-id organization-id organization-name notification] + (let [msgbus (::mbus/msgbus cfg)] + (mbus/pub! msgbus + :topic profile-id + :message {:type :user-org-change + :topic profile-id + :organization-id organization-id + :organization-name organization-name :notification notification}))) \ No newline at end of file diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj index bceafbc72e..1a938bee22 100644 --- a/backend/test/backend_tests/rpc_management_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -13,6 +13,7 @@ [app.db :as-alias db] [app.email :as email] [app.msgbus :as mbus] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [backend-tests.helpers :as th] [clojure.set :as set] @@ -218,3 +219,222 @@ (t/is (not (th/success? ko-out))) (t/is (= :not-found (th/ex-type (:error ko-out)))) (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Tests: remove-from-org +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-org-summary + [& {:keys [org-id org-name owner-id your-penpot-teams org-teams] + :or {your-penpot-teams [] org-teams []}}] + {:id org-id + :name org-name + :owner-id owner-id + :teams (into + (mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams) + (mapv (fn [id] {:id id :is-your-penpot false}) org-teams))}) + +(defn- nitrate-call-mock + [org-summary] + (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))) + +(t/deftest remove-from-org-happy-path-no-extra-teams + ;; User is only in its default team (which has files); it should be + ;; kept, renamed and unset as default. A notification must be sent. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + org-team (th/create-team* 1 {:profile-id (:id user)}) + project (th/create-project* 1 {:profile-id (:id user) + :team-id (:id org-team)}) + _ (th/create-file* 1 {:profile-id (:id user) + :project-id (:id project)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams []) + calls (atom []) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [_bus & {:keys [topic message]}] + (swap! calls conj {:topic topic :message message}))] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :org-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; default team preserved, renamed and unset as default + (let [team (th/db-get :team {:id (:id org-team)})] + (t/is (false? (:is-default team))) + (t/is (str/starts-with? (:name team) "[Acme Org] "))) + + ;; exactly one notification sent to the user + (t/is (= 1 (count @calls))) + (let [msg (-> @calls first :message)] + (t/is (= :user-org-change (:type msg))) + (t/is (= (:id user) (:topic msg))) + (t/is (= org-id (:organization-id msg))) + (t/is (= "Acme Org" (:organization-name msg))) + (t/is (= "dashboard.user-no-longer-belong-org" (:notification msg)))))) + +(t/deftest remove-from-org-deletes-empty-default-team + ;; When the default team has no files it should be soft-deleted. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + org-team (th/create-team* 2 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams []) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :org-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))) + +(t/deftest remove-from-org-deletes-sole-owner-team + ;; When the user is the sole member of an org team it should be deleted. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 3 {:profile-id (:id user)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :org-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))) + +(t/deftest remove-from-org-transfers-ownership-of-multi-member-team + ;; When the user owns a team that has another non-owner member, ownership + ;; is transferred to that member by the endpoint automatically. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + candidate (th/create-profile* 3 {:is-active true}) + extra-team (th/create-team* 4 {:profile-id (:id user)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id candidate) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :org-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + ;; user no longer in extra-team + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (nil? rel))) + ;; candidate promoted to owner + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})] + (t/is (true? (:is-owner rel)))))) + +(t/deftest remove-from-org-exits-non-owned-team + ;; When the user is a non-owner member of an org team, they simply leave. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 5 {:profile-id (:id org-owner)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id user) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :org-id org-id + :org-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + ;; user no longer a member of extra-team + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (nil? rel))) + ;; team still exists for the owner + (let [team (th/db-get :team {:id (:id extra-team)})] + (t/is (some? team))))) + +(t/deftest remove-from-org-error-nobody-to-reassign + ;; When the user owns a multi-member team but every other member is + ;; also an owner, the auto-selection query finds nobody and raises. + (let [other-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 6 {:profile-id (:id user)}) + ;; add other-owner to the team and make them co-owner directly in DB + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id other-owner) + :role :editor}) + _ (th/db-update! :team-profile-rel + {:is-owner true :is-admin false} + {:team-id (:id extra-team) :profile-id (:id other-owner)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + org-id (uuid/random) + org-summary (make-org-summary + :org-id org-id + :org-name "Acme Org" + :owner-id (:id other-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id other-owner) + :profile-id (:id user) + :org-id org-id + :org-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :nobody-to-reassign-team (th/ex-code (:error out)))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj index 084c8417a8..dd8dd20ae2 100644 --- a/backend/test/backend_tests/rpc_nitrate_test.clj +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -10,6 +10,7 @@ [app.db :as-alias db] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.rpc.commands.nitrate] [backend-tests.helpers :as th] [clojure.test :as t] [cuerdas.core :as str])) @@ -72,6 +73,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave []} @@ -106,6 +108,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave []} @@ -140,6 +143,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave []} @@ -174,6 +178,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [(:id team1)] :teams-to-leave []} @@ -210,6 +215,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]} @@ -254,6 +260,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave [{:id (:id team1)}]} @@ -290,6 +297,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-owner) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave []} @@ -318,6 +326,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id (uuid/random) :teams-to-delete [] :teams-to-leave []} @@ -327,6 +336,147 @@ (t/is (= :validation (th/ex-type (:error out)))) (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Unit Tests for calculate-valid-teams + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private calculate-valid-teams + (or (ns-resolve 'app.rpc.commands.nitrate 'calculate-valid-teams) + (throw (ex-info "Unable to resolve calculate-valid-teams" + {:ns 'app.rpc.commands.nitrate + :symbol 'calculate-valid-teams})))) + +(defn- make-team [id & {:keys [is-owner num-members member-ids] + :or {is-owner false num-members 1 member-ids []}}] + {:id id :is-owner is-owner :num-members num-members :member-ids member-ids}) + +(t/deftest calculate-valid-teams-no-org-teams + (let [default-id (uuid/random) + default-team (make-team default-id) + result (calculate-valid-teams [default-team] default-id)] + (t/is (= default-team (:valid-default-team result))) + (t/is (empty? (:valid-teams-to-delete-ids result))) + (t/is (empty? (:valid-teams-to-transfer result))) + (t/is (empty? (:valid-teams-to-exit result))))) + +(t/deftest calculate-valid-teams-default-not-found + (let [default-id (uuid/random) + other-id (uuid/random) + other-team (make-team other-id) + ;; default-id is not in org-teams at all + result (calculate-valid-teams [other-team] default-id)] + (t/is (nil? (:valid-default-team result))))) + +(t/deftest calculate-valid-teams-sole-owner-team + (let [default-id (uuid/random) + team-id (uuid/random) + default (make-team default-id) + solo-team (make-team team-id :is-owner true :num-members 1) + result (calculate-valid-teams [default solo-team] default-id)] + (t/is (contains? (:valid-teams-to-delete-ids result) team-id)) + (t/is (empty? (:valid-teams-to-transfer result))) + (t/is (empty? (:valid-teams-to-exit result))))) + +(t/deftest calculate-valid-teams-owned-multi-member-team + (let [default-id (uuid/random) + team-id (uuid/random) + default (make-team default-id) + ;; owner of a team with 3 members — must be transferred + multi-team (make-team team-id :is-owner true :num-members 3) + result (calculate-valid-teams [default multi-team] default-id)] + (t/is (empty? (:valid-teams-to-delete-ids result))) + (t/is (= [team-id] (map :id (:valid-teams-to-transfer result)))) + (t/is (empty? (:valid-teams-to-exit result))))) + +(t/deftest calculate-valid-teams-non-owner-multi-member-team + (let [default-id (uuid/random) + team-id (uuid/random) + default (make-team default-id) + ;; non-owner member of a team with 2 members — can just exit + exit-team (make-team team-id :is-owner false :num-members 2) + result (calculate-valid-teams [default exit-team] default-id)] + (t/is (empty? (:valid-teams-to-delete-ids result))) + (t/is (empty? (:valid-teams-to-transfer result))) + (t/is (= [team-id] (map :id (:valid-teams-to-exit result)))))) + +(t/deftest calculate-valid-teams-mixed + (let [default-id (uuid/random) + solo-id (uuid/random) + transfer-id (uuid/random) + exit-id (uuid/random) + default (make-team default-id) + solo-team (make-team solo-id :is-owner true :num-members 1) + transfer-team (make-team transfer-id :is-owner true :num-members 2) + exit-team (make-team exit-id :is-owner false :num-members 3) + result (calculate-valid-teams [default solo-team transfer-team exit-team] default-id)] + (t/is (= #{solo-id} (:valid-teams-to-delete-ids result))) + (t/is (= [transfer-id] (map :id (:valid-teams-to-transfer result)))) + (t/is (= [exit-id] (map :id (:valid-teams-to-exit result)))) + (t/is (= default-id (:id (:valid-default-team result)))))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Integration: combined delete + leave + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest leave-org-combined-delete-and-leave + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; team1: profile-user is sole owner — must delete + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + ;; team2: profile-user owns it, profile-owner is also member — must transfer + team2 (th/create-team* 2 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team2) + :profile-id (:id profile-owner) + :role :editor}) + ;; team3: profile-owner owns it, profile-user is non-owner member — can exit + team3 (th/create-team* 3 {:profile-id (:id profile-owner)}) + _ (th/create-team-role* {:team-id (:id team3) + :profile-id (:id profile-user) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + org-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :org-id org-id + :org-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1) (:id team2) (:id team3)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :org-name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave [{:id (:id team2) :reassign-to (:id profile-owner)} + {:id (:id team3)}]} + out (th/command! data)] + + (t/is (th/success? out)) + + ;; team1 should be soft-deleted + (let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))) + + ;; profile-user should no longer be a member of team2 + (let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; profile-owner should now own team2 + (let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-owner)})] + (t/is (true? (:is-owner rel)))) + + ;; profile-user should no longer be a member of team3 + (let [rel (th/db-get :team-profile-rel {:team-id (:id team3) :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; team3 itself should still exist (profile-owner is still there) + (let [team (th/db-get :team {:id (:id team3)})] + (t/is (some? team))))))) (t/deftest leave-org-error-teams-to-delete-incomplete (let [profile-owner (th/create-profile* 1 {:is-active true}) profile-user (th/create-profile* 2 {:is-active true}) @@ -350,6 +500,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [(:id team1)] :teams-to-leave []} @@ -384,6 +535,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [(:id team1)] :teams-to-leave []} @@ -418,6 +570,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave []} @@ -451,6 +604,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-user)}]} @@ -486,6 +640,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-other)}]} @@ -520,6 +675,7 @@ (let [data {::th/type :leave-org ::rpc/profile-id (:id profile-user) :org-id org-id + :org-name "Test Org" :default-team-id your-penpot-id :teams-to-delete [] :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]} diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index cecb34d2ad..3ac4f1eee6 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -199,8 +199,10 @@ (ptk/reify ::change-team-role ptk/WatchEvent - (watch [_ _ _] - (rx/of (ntf/info (get-change-role-msg role)))) + (watch [_ state _] + (let [current-team-id (:current-team-id state)] + (when (= team-id current-team-id) + (rx/of (ntf/info (get-change-role-msg role)))))) ptk/UpdateEvent (update [_ state] diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 7e98a0eaa4..eddcb5a503 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -23,6 +23,7 @@ [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.data.team :as dtm] [app.main.data.websocket :as dws] [app.main.repo :as rp] [app.main.store :as st] @@ -710,6 +711,22 @@ team-name (assoc :name team-name)))) state)))) +(defn- handle-user-org-change + [{:keys [organization-id organization-name notification]}] + (ptk/reify ::handle-user-org-change + ptk/WatchEvent + (watch [_ state _] + (when (and notification (contains? cf/flags :nitrate)) + (let [team-id (:current-team-id state) + team (dm/get-in state [:teams team-id])] + (rx/of (ntf/show {:content (tr notification organization-name) + :type :toast + :level :info + :timeout nil}) + (dtm/fetch-teams) + ;; When the user is currently on a team of the org + (when (= organization-id (:organization-id team)) + (dcm/go-to-dashboard-recent {:team-id :default})))))))) (defn- process-message [{:keys [type] :as msg}] @@ -718,6 +735,7 @@ :team-role-change (handle-change-team-role msg) :team-membership-change (dcm/team-membership-change msg) :team-org-change (handle-change-team-org msg) + :user-org-change (handle-user-org-change msg) nil)) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 57fb1299ca..f3bb5207bf 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -41,16 +41,19 @@ ptk/UpdateEvent (update [_ state] - (reduce (fn [state {:keys [id organization-id] :as team}] - (let [team-updated (cond-> (merge (dm/get-in state [:teams id]) team) - (not organization-id) (dissoc :organization-id - :organization-name - :organization-slug - :organization-owner-id - :organization-avatar-bg-url))] - (update state :teams assoc id team-updated))) - state - teams)))) + (let [team-ids (map :id teams) + ;; Delete old teams from state + state (update state :teams #(select-keys % team-ids))] + (reduce (fn [state {:keys [id organization-id] :as team}] + (let [team-updated (cond-> (merge (dm/get-in state [:teams id]) team) + (not organization-id) (dissoc :organization-id + :organization-name + :organization-slug + :organization-owner-id + :organization-avatar-bg-url))] + (update state :teams assoc id team-updated))) + state + teams))))) (defn fetch-teams [] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 1813fa819a..32e4b012c1 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -544,7 +544,7 @@ (tr "dashboard.your-penpot") (:name team))))) - (mf/with-effect [] + (mf/with-effect [team] (st/emit! (dtm/fetch-members))) [:* @@ -1063,7 +1063,7 @@ (tr "dashboard.your-penpot") (:name team))))) - (mf/with-effect [] + (mf/with-effect [team] (st/emit! (dtm/fetch-invitations))) [:* diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 88d8069247..e211bdd349 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -350,6 +350,9 @@ msgstr "This team no longer belongs to the organization %s" msgid "dashboard.team-belong-org" msgstr "This team now belongs to %s" +msgid "dashboard.user-no-longer-belong-org" +msgstr "You are no longer a member of the organization %s" + #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Add file" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 0c319b4277..96a033f662 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -359,6 +359,9 @@ msgstr "Este equipo ya no pertenece a la organización %s" msgid "dashboard.team-belong-org" msgstr "Este equipo ahora pertenece a la organización %s" +msgid "dashboard.user-no-longer-belong-org" +msgstr "Ya no perteneces a la organización %s" + #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Añadir archivo"