Add nitrate api to remove user from org

This commit is contained in:
Pablo Alba 2026-04-17 18:09:21 +02:00 committed by Pablo Alba
parent b2c9e08d42
commit ae66317d6c
12 changed files with 575 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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