From 3c639f41c41da1a43c7f1cedc761228df5ebc99c Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 7 Apr 2026 11:26:57 +0200 Subject: [PATCH] :sparkles: Add option to leave a nitrate organization --- backend/src/app/nitrate.clj | 57 ++- backend/src/app/rpc/commands/nitrate.clj | 130 ++++- backend/src/app/rpc/commands/teams.clj | 63 +-- backend/src/app/rpc/management/nitrate.clj | 25 +- .../test/backend_tests/rpc_nitrate_test.clj | 444 ++++++++++++++++++ common/src/app/common/data.cljc | 10 + frontend/src/app/main/data/nitrate.cljs | 25 + frontend/src/app/main/data/team.cljs | 10 +- frontend/src/app/main/ui/confirm.cljs | 4 + frontend/src/app/main/ui/confirm.scss | 4 + .../app/main/ui/dashboard/change_owner.cljs | 110 +++++ .../app/main/ui/dashboard/change_owner.scss | 45 ++ .../src/app/main/ui/dashboard/sidebar.cljs | 217 +++++++-- .../src/app/main/ui/dashboard/sidebar.scss | 59 ++- frontend/translations/en.po | 42 ++ frontend/translations/es.po | 46 ++ 16 files changed, 1194 insertions(+), 97 deletions(-) create mode 100644 backend/test/backend_tests/rpc_nitrate_test.clj diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 4d7aa5c230..92c0745670 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -56,7 +56,8 @@ :uri uri) ;; TODO decide what to do when Nitrate is inaccesible nil) - (if (>= status 400) + (cond + (>= status 400) ;; For error status codes (4xx, 5xx), fail immediately without validation (do (when (not= status 404) ;; Don't need to log 404 @@ -65,7 +66,9 @@ :status status :body (:body response))) nil) - ;; For success status codes, validate the response + (= status 204) ;; 204 doesn't return any body + nil + :else ;; For success status codes, validate the response (let [coercer-http (sm/coercer schema :type :validation :hint (str "invalid data received calling " uri)) @@ -107,6 +110,17 @@ [:owner-id ::sm/uuid] [:avatar-bg-url [::sm/text]]]) +(def ^:private schema:org-summary + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:owner-id ::sm/uuid] + [:teams + [:vector + [:map + [:id ::sm/uuid] + [:is-your-penpot :boolean]]]]]) + (def ^:private schema:team [:map [:id ::sm/uuid] @@ -118,6 +132,7 @@ [:is-member :boolean] [:organization-id ::sm/uuid]]) + ;; TODO Unify with schemas on backend/src/app/http/management.clj (def ^:private schema:timestamp (sm/type-schema @@ -210,6 +225,18 @@ profile-id) schema:profile-org params))) + +(defn- get-org-summary-api + [cfg {:keys [org-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/organizations/" + org-id + "/summary") + schema:org-summary params))) + + (defn- set-team-org-api [cfg {:keys [organization-id team-id is-default] :as params}] (let [baseuri (cf/get :nitrate-backend-uri) @@ -233,6 +260,26 @@ "/add-user") schema:profile-org params))) +(defn- remove-profile-from-org-api + [cfg {:keys [profile-id org-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri) + params (assoc params :request-params {:user-id profile-id})] + (request-to-nitrate cfg :post + (str baseuri + "/api/organizations/" + org-id + "/remove-user") + nil params))) + +(defn- delete-team-api + [cfg {:keys [team-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :delete + (str baseuri + "/api/teams/" + team-id) + nil params))) + (defn- get-subscription-api [cfg {:keys [profile-id] :as params}] (let [baseuri (cf/get :nitrate-backend-uri)] @@ -260,7 +307,10 @@ {:get-team-org (partial get-team-org-api cfg) :set-team-org (partial set-team-org-api cfg) :get-org-membership-by-team (partial get-org-membership-by-team-api cfg) + :get-org-summary (partial get-org-summary-api cfg) :add-profile-to-org (partial add-profile-to-org-api cfg) + :remove-profile-from-org (partial remove-profile-from-org-api cfg) + :delete-team (partial delete-team-api cfg) :get-subscription (partial get-subscription-api cfg) :connectivity (partial get-connectivity-api cfg)})) @@ -324,7 +374,4 @@ -(defn connectivity - [cfg] - (call cfg :connectivity {})) diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 5313817fd3..d16bd8cd22 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -1,8 +1,12 @@ (ns app.rpc.commands.nitrate (:require + [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.db :as db] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.util.services :as sv])) @@ -12,9 +16,129 @@ [:licenses ::sm/boolean]]) (sv/defmethod ::get-nitrate-connectivity - {::rpc/auth false - ::doc/added "1.18" + {::rpc/auth true + ::doc/added "2.14" ::sm/params [:map] ::sm/result schema:connectivity} [cfg _params] - (nitrate/connectivity cfg)) + (nitrate/call cfg :connectivity {})) + +(def ^:private sql:prefix-team-name-and-unset-default + "UPDATE team + SET name = ? || name, + is_default = FALSE + WHERE id = ?;") + +(def ^:private sql:get-member-teams-info + "SELECT t.id, + 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 + FROM team AS t + JOIN team_profile_rel AS tpr ON (tpr.team_id = t.id) + WHERE tpr.profile_id = ? + AND t.id = ANY(?) + AND t.deleted_at IS NULL") + +(def ^:private schema:leave-org + [:map + [:org-id ::sm/uuid] + [:default-team-id ::sm/uuid] + [:teams-to-delete + [:vector ::sm/uuid]] + [:teams-to-leave + [:vector + [:map + [: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}] + (let [org-summary (nitrate/call cfg :get-org-summary {:org-id org-id}) + + org-name (:name org-summary) + org-prefix (str "[" (d/sanitize-string org-name) "] ") + + 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-leave-ids (into valid-teams-to-transfer-ids valid-teams-to-exit-ids) + + ;; 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 + valid-teams-to-leave? (and + (= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{}))) + (every? (fn [{:keys [id reassign-to]}] + (if reassign-to + (let [members (db/pgarray->set (:member-ids (get teams-by-id id)))] + (and (contains? valid-teams-to-transfer-ids id) + (not= reassign-to profile-id) + (contains? members reassign-to))) + (contains? valid-teams-to-exit-ids id))) + teams-to-leave))] + + + (when (= (:owner-id org-summary) profile-id) + (ex/raise :type :validation + :code :org-owner-cannot-leave)) + + (when (or + (not valid-teams-to-delete?) + (not valid-teams-to-leave?) + (not valid-default-team-id?)) + (ex/raise :type :validation + :code :not-valid-teams)) + + ;; delete the teams-to-delete + (doseq [id teams-to-delete] + (teams/delete-team cfg {:profile-id profile-id :team-id id})) + + ;; leave the teams-to-leave + (doseq [{:keys [id reassign-to]} teams-to-leave] + (teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to})) + + ;; Rename default-team-id + (db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id]) + + ;; Api call to nitrate + (nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :org-id org-id}) + + nil)) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 854b36bb32..b849ba0aa7 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -532,7 +532,7 @@ (set/difference cfeat/frontend-only-features) (set/difference cfeat/no-team-inheritable-features)) params {:profile-id profile-id - :name "Default" + :name "Your Penpot" :features features :organization-id organization-id :is-default true} @@ -674,7 +674,7 @@ ;; --- Mutation: Leave Team (defn leave-team - [conn {:keys [profile-id id reassign-to]}] + [{:keys [::db/conn]} {:keys [profile-id id reassign-to]}] (let [perms (get-permissions conn profile-id id) members (get-team-members conn id)] @@ -689,7 +689,9 @@ ;; if the `reassign-to` is filled and has a different value ;; than the current profile-id, we proceed to reassing the ;; owner role to profile identified by the `reassign-to`. - (and reassign-to (not= reassign-to profile-id)) + ;; Ignore the reasignation if the current profile is not + ;; the owner + (and reassign-to (not= reassign-to profile-id) (:is-owner perms)) (let [member (d/seek #(= reassign-to (:id %)) members)] (when-not member (ex/raise :type :not-found :code :member-does-not-exist)) @@ -728,32 +730,44 @@ {::doc/added "1.17" ::sm/params schema:leave-team ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id] :as params}] - (leave-team conn (assoc params :profile-id profile-id))) + [cfg {:keys [::rpc/profile-id] :as params}] + (leave-team cfg (assoc params :profile-id profile-id))) + ;; --- Mutation: Delete Team -(defn- delete-team +(defn delete-team "Mark a team for deletion" - [conn {:keys [id] :as team}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}] - (let [delay (ldel/get-deletion-delay team) - team (db/update! conn :team - {:deleted-at (ct/in-future delay)} - {:id id} - {::db/return-keys true})] + (let [team (get-team conn :profile-id profile-id :team-id team-id) + 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")) - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :team - :deleted-at (:deleted-at team) - :id id}}) - team)) + (let [delay (ldel/get-deletion-delay team) + team (db/update! conn :team + {:deleted-at (ct/in-future delay)} + {:id team-id} + {::db/return-keys true})] + + ;; Api call to nitrate + (when (contains? cf/flags :nitrate) + (nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id})) + + (wrk/submit! {::db/conn conn + ::wrk/task :delete-object + ::wrk/params {:object :team + :deleted-at (:deleted-at team) + :id team-id}}) + team))) (def ^:private schema:delete-team [:map {:title "delete-team"} @@ -763,16 +777,9 @@ {::doc/added "1.17" ::sm/params schema:delete-team ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}] - (let [team (get-team conn :profile-id profile-id :team-id id) - perms (get team :permissions)] - - (when-not (:is-owner perms) - (ex/raise :type :validation - :code :only-owner-can-delete-team)) - - (delete-team conn team) - nil)) + [cfg {:keys [::rpc/profile-id id] :as params}] + (delete-team cfg {:team-id id :profile-id profile-id}) + nil) ;; --- Mutation: Team Update Role diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index beba802848..62c0070364 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -8,6 +8,7 @@ "Internal Nitrate HTTP RPC API. Provides authenticated access to organization management and token validation endpoints." (:require + [app.common.data :as d] [app.common.schema :as sm] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] @@ -20,8 +21,7 @@ [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] [app.rpc.doc :as doc] - [app.util.services :as sv] - [cuerdas.core :as str])) + [app.util.services :as sv])) ;; ---- API: authenticate @@ -211,9 +211,10 @@ ;; ---- API: delete-teams-keeping-your-penpot-projects -(def ^:private sql:add-prefix-to-teams +(def ^:private sql:prefix-teams-name-and-unset-default "UPDATE team - SET name = ? || name + SET name = ? || name, + is_default = FALSE WHERE id = ANY(?) RETURNING id, name;") @@ -230,23 +231,15 @@ RETURNING id, name;") ::sm/params schema:notify-org-deletion} [cfg {:keys [teams org-name]}] (when (seq teams) - (let [cleaned-org-name (if org-name - (-> org-name - str - str/trim - (str/replace #"[^\w\s\-_()]+" "") - (str/replace #"\s+" " ") - str/trim) - "") - org-prefix (str "[" cleaned-org-name "] ")] + (let [org-prefix (str "[" (d/sanitize-string org-name) "] ")] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (let [ids-array (db/create-array conn "uuid" teams) - ;; ---- Rename projects ---- - updated-teams (db/exec! conn [sql:add-prefix-to-teams org-prefix ids-array])] + ;; Rename projects + updated-teams (db/exec! conn [sql:prefix-teams-name-and-unset-default org-prefix ids-array])] - ;; ---- Notify users ---- + ;; Notify users (doseq [team updated-teams] (notify-team-change cfg (:id team) (:name team) nil org-name "dashboard.org-deleted")))))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj new file mode 100644 index 0000000000..0a0c35296f --- /dev/null +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -0,0 +1,444 @@ +;; 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 backend-tests.rpc-nitrate-test + (:require + [app.common.uuid :as uuid] + [app.db :as-alias db] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(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 + "Creates a mock for nitrate/call that returns the given org-summary for + :get-org-summary and nil for any other method." + [org-summary] + (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest leave-org-happy-path-no-extra-teams + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + + org-id (uuid/random) + ;; The user's personal penpot team in the org context + your-penpot-id (:default-team-id profile-user) + + 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 [])] + + (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 + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; The personal team must be renamed with the org prefix and + ;; unset as a default team. + (let [team (th/db-get :team {:id your-penpot-id})] + (t/is (str/starts-with? (:name team) "[Test Org] ")) + (t/is (false? (:is-default team)))))))) + +(t/deftest leave-org-with-teams-to-delete + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-user is the sole owner/member of team1 + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (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 + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave []} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + + ;; team1 should be scheduled for deletion (deleted-at set) + (let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))))) + +(t/deftest leave-org-with-ownership-transfer + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-user owns team1; profile-owner is also a member + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (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 + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + + ;; profile-user should no longer be a member of team1 + (let [rel (th/db-get :team-profile-rel + {:team-id (:id team1) + :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; profile-owner should have been promoted to owner + (let [rel (th/db-get :team-profile-rel + {:team-id (:id team1) + :profile-id (:id profile-owner)})] + (t/is (true? (:is-owner rel)))))))) + +(t/deftest leave-org-exit-as-non-owner + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-owner owns team1; profile-user is a non-owner member + team1 (th/create-team* 1 {:profile-id (:id profile-owner)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-user) + :role :editor}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (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 + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1)}]} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + + ;; profile-user should no longer be a member of team1 + (let [rel (th/db-get :team-profile-rel + {:team-id (:id team1) + :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; The team itself should still exist + (let [team (th/db-get :team {:id (:id team1)})] + (t/is (nil? (:deleted-at team)))))))) + +(t/deftest leave-org-error-org-owner-cannot-leave + (let [profile-owner (th/create-profile* 1 {:is-active true}) + org-id (uuid/random) + your-penpot-id (:default-team-id profile-owner) + + ;; profile-owner IS the org owner in the org-summary + 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 [])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-owner) + :org-id org-id + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :org-owner-cannot-leave (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-invalid-default-team-id + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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 [])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; Pass a random UUID that is not in the your-penpot-teams list + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :default-team-id (uuid/random) + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(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}) + ;; profile-user is the sole owner/member of both team1 and team2 + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + team2 (th/create-team* 2 {:profile-id (:id profile-user)}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; Only team1 is listed; team2 is also a sole-owner team and must be included + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-cannot-delete-multi-member-team + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; team1 has two members: profile-user (owner) and profile-owner (editor) + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; team1 has 2 members so it is not a valid deletion candidate + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-teams-to-leave-incomplete + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-user owns team1, which also has profile-owner as editor + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; team1 must be transferred (owner + multiple members) but is absent + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-reassign-to-self + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; reassign-to points to the profile that is leaving — not allowed + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-user)}]} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-reassign-to-non-member + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + profile-other (th/create-profile* 3 {:is-active true}) + ;; team1 has profile-user (owner) and profile-owner (editor) — NOT profile-other + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; profile-other is not a member of team1 + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-other)}]} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(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}) + ;; profile-owner owns team1; profile-user is just a non-owner member + team1 (th/create-team* 1 {:profile-id (:id profile-owner)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-user) + :role :editor}) + + org-id (uuid/random) + your-penpot-id (:default-team-id profile-user) + + 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)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; profile-user is not the owner so providing reassign-to is invalid + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :org-id org-id + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 2b9748183d..aa06e14e9d 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1171,6 +1171,16 @@ [key coll] (sort-by key natural-compare coll)) +(defn sanitize-string [s] + (if s + (-> s + str + str/trim + (str/replace #"[^\w\s\-_()]+" "") + (str/replace #"\s+" " ") + str/trim) + "")) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Util protocols ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index 8670833bdf..b65d0ad264 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -3,10 +3,14 @@ [app.common.data.macros :as dm] [app.common.uri :as u] [app.config :as cf] + [app.main.data.common :as dcm] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] + [app.main.data.team :as dt] [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] + [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -55,4 +59,25 @@ (contains? #{"active" "past_due" "trialing"} (dm/get-in profile [:subscription :status])))) +(defn leave-org + [{:keys [org-id org-name default-team-id teams-to-delete teams-to-leave on-error] :as params}] + (ptk/reify ::leave-org + ptk/WatchEvent + (watch [_ state _] + (let [profile-team-id (dm/get-in state [:profile :default-team-id])] + (->> (rp/cmd! ::leave-org {:org-id org-id + :org-name org-name + :default-team-id default-team-id + :teams-to-delete teams-to-delete + :teams-to-leave teams-to-leave}) + (rx/mapcat + (fn [_] + (rx/of + (dt/fetch-teams) + (dcm/go-to-dashboard-recent :team-id profile-team-id) + (modal/hide) + (ntf/show {:content (tr "dasboard.leave-org.toast" org-name) + :type :toast + :level :success})))) + (rx/catch on-error)))))) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 2f6c03f68e..0756e71823 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -41,8 +41,14 @@ ptk/UpdateEvent (update [_ state] - (reduce (fn [state {:keys [id] :as team}] - (update-in state [:teams id] merge team)) + (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)))) diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index d2c068ebf2..f9ba97b7e2 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -30,6 +30,7 @@ on-accept on-cancel hint + error-msg items cancel-label accept-label @@ -86,6 +87,9 @@ [:> context-notification* {:level :info :appearance :ghost} hint]) + (when (string? error-msg) + [:> context-notification* {:level :error :class (stl/css :modal-error-msg)} + error-msg]) (when (> (count items) 0) [:* [:p {:class (stl/css :modal-subtitle)} diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index f5261af38d..e3106e528a 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -65,3 +65,7 @@ color: var(--modal-text-foreground-color); } + +.modal-error-msg { + margin: var(--sp-xxl) 0; +} diff --git a/frontend/src/app/main/ui/dashboard/change_owner.cljs b/frontend/src/app/main/ui/dashboard/change_owner.cljs index fc4ae33cc9..31bb3b3b0a 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.cljs +++ b/frontend/src/app/main/ui/dashboard/change_owner.cljs @@ -7,11 +7,14 @@ (ns app.main.ui.dashboard.change-owner (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.schema :as sm] + [app.common.uuid :as uuid] [app.main.data.modal :as modal] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] + [cuerdas.core :as str] [rumext.v2 :as mf])) (def ^:private schema:leave-modal-form @@ -72,3 +75,110 @@ :disabled (not (:valid @form)) :value (tr "modals.leave-and-reassign.promote-and-leave") :on-click on-accept}]]]]])) + + + +(mf/defc ^:private team-member-select* + [{:keys [team profile form field-name default-member-id]}] + (let [members (get team :members) + filtered-members (->> members + (filter #(not= (:email %) (:email profile)))) + options (->> filtered-members + (map #(hash-map :label (:name %) :value (str (:id %)))))] + [:div {:class (stl/css :team-select-container)} + [:div {:class (stl/css :team-name)} (:name team)] + (if (empty? filtered-members) + [:p {:class (stl/css :modal-msg)} + (tr "modals.leave-and-reassign.forbidden")] + [:& fm/select {:name field-name + :select-class (stl/css :team-member) + :dropdown-class (stl/css :team-member) + :options options + :form form + :default default-member-id}])])) + +(defn- make-leave-org-modal-form-schema [teams] + (into + [:map {:title "LeaveOrgModalForm"}] + (for [team teams] + [(keyword (str "member-id-" (:id team))) ::sm/text]))) + + +(mf/defc leave-and-reassign-org-modal + {::mf/register modal/components + ::mf/register-as :leave-and-reassign-org + ::mf/wrap [mf/memo]} + [{:keys [profile teams-to-transfer num-teams-to-delete accept] :as props}] + (let [schema (mf/with-memo [teams-to-transfer] + (make-leave-org-modal-form-schema teams-to-transfer)) + ;; Compute initial values for each team select + team-fields (mf/with-memo [teams-to-transfer] + (for [team teams-to-transfer] + (let [members (get team :members) + filtered-members (filter #(not= (:email %) (:email profile)) members) + first-admin (first (filter :is-admin filtered-members)) + first-member (first filtered-members) + default-member-id (cond + first-admin (str (:id first-admin)) + first-member (str (:id first-member)) + :else "") + field-name (keyword (str "member-id-" (:id team)))] + {:team team + :field-name field-name + :default-member-id default-member-id}))) + + initial-values (mf/with-memo [team-fields] + (d/index-by :field-name :default-member-id team-fields)) + + form (fm/use-form :schema schema :initial initial-values) + + all-valid? (every? + (fn [{:keys [field-name]}] + (let [val (get-in @form [:clean-data field-name])] + (not (str/blank? val)))) + team-fields) + + on-accept (fn [_] + (let [teams-to-transfer (mapv (fn [{:keys [team field-name]}] + (let [val (get-in @form [:clean-data field-name])] + {:id (:id team) + :reassign-to (uuid/parse val)})) + team-fields)] + (accept {:teams-to-transfer teams-to-transfer})))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-org-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-org-title)} (tr "modals.before-leave-org.title")] + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + + [:div {:class (stl/css :modal-content)} + (if (zero? num-teams-to-delete) + [:p {:class (stl/css :modal-org-msg)} + (tr "modals.leave-org-and-reassign.hint")] + [:* + [:p {:class (stl/css :modal-org-msg)} + (tr "modals.leave-org-and-reassign.hint-delete")] + [:p {:class (stl/css :modal-org-msg)} + (tr "modals.leave-org-and-reassign.hint-promote")]]) + [:& fm/form {:form form} + [:div {:class (stl/css :teams-container)} + (for [{:keys [team field-name default-member-id]} team-fields] + ^{:key (:id team)} + [:> team-member-select* {:team team :profile profile :form form :field-name field-name :default-member-id default-member-id}])]]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + [:input {:class (stl/css :cancel-button) + :type "button" + :value (tr "labels.cancel") + :on-click modal/hide!}] + + [:input.accept-button + {:type "button" + :class (stl/css-case :accept-btn true + :danger all-valid? + :global/disabled (not all-valid?)) + :disabled (not all-valid?) + :value (tr "modals.leave-and-reassign.promote-and-leave") + :on-click on-accept}]]]]])) diff --git a/frontend/src/app/main/ui/dashboard/change_owner.scss b/frontend/src/app/main/ui/dashboard/change_owner.scss index fadf30e3f6..6fa30819ca 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.scss +++ b/frontend/src/app/main/ui/dashboard/change_owner.scss @@ -5,6 +5,8 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; .modal-overlay { @extend %modal-overlay-base; @@ -58,3 +60,46 @@ .modal-msg { color: var(--modal-text-foreground-color); } + +.teams-container { + display: flex; + flex-direction: column; + gap: var(--sp-s); + margin: var(--sp-xxxl) 0; +} + +.team-select-container { + display: grid; + grid-template-columns: 1fr 2fr; + align-items: center; + width: 100%; +} + +.modal-org-container { + @extend %modal-container-base; + + overflow-y: auto; + max-height: $sz-512; +} + +.modal-org-title { + @include t.use-typography("headline-large"); + + color: var(--modal-title-foreground-color); +} + +.modal-org-msg { + @include t.use-typography("body-large"); + + color: var(--modal-text-foreground-color); +} + +.team-name { + @include t.use-typography("body-medium"); + + color: var(--modal-text-foreground-color); +} + +.team-member { + @include t.use-typography("body-medium"); +} diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index fb52064251..2d3ddeb915 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -73,6 +73,12 @@ (def ^:private menu-icon (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) +(def ^:private org-menu-icon + (deprecated-icon/icon-xref :menu (stl/css :org-menu-icon))) + +(def ^:private org-menu-icon-open + (deprecated-icon/icon-xref :menu (stl/css :org-menu-icon-open))) + (def ^:private pin-icon (deprecated-icon/icon-xref :pin (stl/css :pin-icon))) @@ -336,10 +342,10 @@ [:> dropdown-menu-item* {:on-click on-org-click :data-value default-team-id :class (stl/css :org-dropdown-item)} - [:span {:class (stl/css :nitrate-org-icon)} + [:span {:class (stl/css :org-icon)} [:> raw-svg* {:id penpot-logo-icon}]] "Penpot" - (when (= default-team-id (:id organization)) + (when (= default-team-id (:default-team-id organization)) tick-icon)] (for [org-item (remove :is-default (vals organizations))] @@ -350,7 +356,7 @@ [:> org-avatar* {:org org-item :size "xxl"}] [:span {:class (stl/css :team-text) :title (:name org-item)} (:name org-item)] - (when (= (:id org-item) (:id organization)) + (when (= (:id org-item) (:default-team-id organization)) tick-icon)]) [:hr {:role "separator" :class (stl/css :team-separator)}] @@ -449,18 +455,22 @@ (modal/hide)))) on-error - (fn [{:keys [code] :as error}] - (condp = code - :no-enough-members-for-leave - (rx/of (ntf/error (tr "errors.team-leave.insufficient-members"))) + (fn [error] + (let [code (-> error ex-data :code)] + (condp = code + :only-owner-can-delete-team + (rx/of (ntf/error (tr "errors.team-leave.only-owner-can-delete"))) - :member-does-not-exist - (rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists"))) + :no-enough-members-for-leave + (rx/of (ntf/error (tr "errors.team-leave.insufficient-members"))) - :owner-cant-leave-team - (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) + :member-does-not-exist + (rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists"))) - (rx/throw error))) + :owner-cant-leave-team + (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) + + (rx/throw error)))) leave-fn (mf/use-fn @@ -581,10 +591,110 @@ :data-testid "delete-team"} (tr "dashboard.delete-team")])])) +(mf/defc org-options-dropdown* + {::mf/private true} + [{:keys [organization profile teams] :rest props}] + (let [default-team-id (mf/with-memo [teams] + (->> teams + (filter :is-default) + first + :id)) + non-default-teams (mf/with-memo [teams] + (remove :is-default teams)) + owned-teams (mf/with-memo [non-default-teams] + (filter #(dm/get-in % [:permissions :is-owner]) non-default-teams)) + not-owned-teams (mf/with-memo [non-default-teams] + (remove #(dm/get-in % [:permissions :is-owner]) non-default-teams)) + teams-to-delete (mf/with-memo [owned-teams] + (filter #(= (count (:members %)) 1) owned-teams)) + teams-to-transfer (mf/with-memo [owned-teams] + (filter #(> (count (:members %)) 1) owned-teams)) + num-teams-to-leave (+ (count teams-to-transfer) (count not-owned-teams)) + num-teams-to-delete (count teams-to-delete) + num-teams-to-transfer (count teams-to-transfer) + + on-error + (mf/use-fn + (fn [error] + (let [code (-> error ex-data :code) + ;; Map error codes to their translation keys + error-map {:not-valid-teams "errors.org-leave.no-valid-teams" + :org-owner-cannot-leave "errors.org-leave.org-owner-cannot-leave" + :only-owner-can-delete-team "errors.team-leave.only-owner-can-delete" + :no-enough-members-for-leave "errors.team-leave.insufficient-members" + :member-does-not-exist "errors.team-leave.member-does-not-exists" + :owner-cant-leave-team "errors.team-leave.owner-cant-leave"}] + + (if-let [tr-key (get error-map code)] + (rx/of (dtm/fetch-teams) + (modal/hide) + (ntf/error (tr tr-key))) + (rx/throw error))))) + + leave-fn + (mf/use-fn + (mf/deps on-error organization default-team-id not-owned-teams teams-to-delete) + (fn [{:keys [teams-to-transfer]}] + (let [teams-to-leave (cond->> not-owned-teams + :always + (map #(select-keys % [:id])) + (seq teams-to-transfer) + (concat teams-to-transfer)) + teams-to-delete (map :id teams-to-delete)] + + (st/emit! (dnt/leave-org {:org-id (:organization-id organization) + :default-team-id default-team-id + :teams-to-delete teams-to-delete + :teams-to-leave teams-to-leave + :on-error on-error}))))) + + on-leave-clicked + (mf/use-fn + (mf/deps leave-fn profile organization teams-to-transfer num-teams-to-leave num-teams-to-delete num-teams-to-transfer) + (cond + (and (pos? num-teams-to-delete) + (zero? num-teams-to-transfer)) + #(st/emit! (modal/show + {:type :confirm + :title (tr "modals.before-leave-org.title" (:name organization)) + :message (tr "modals.before-leave-org.message") + :accept-label (tr "modals.leave-org-confirm.accept") + :on-accept leave-fn + :error-msg (tr "modals.before-leave-org.warning")})) + (pos? num-teams-to-transfer) + #(st/emit! + (modal/show + {:type :leave-and-reassign-org + :profile profile + :teams-to-transfer teams-to-transfer + :num-teams-to-delete num-teams-to-delete + :accept leave-fn})) + + :else + #(st/emit! (modal/show + {:type :confirm + :title (tr "modals.leave-org-confirm.title" (:name organization)) + :message (tr "modals.leave-org-confirm.message") + :accept-label (tr "modals.leave-org-confirm.accept") + :on-accept leave-fn}))))] + (mf/use-effect + (fn [] + ;; We need all the team members of the owned teams + ;; TODO this will re-render once for each owned team, not very performance-wise + (do + (doseq [team owned-teams] + (st/emit! (dtm/fetch-members (:id team))))))) + [:> dropdown-menu* props + + [:> dropdown-menu-item* {:on-click on-leave-clicked + :class (stl/css :team-options-item)} + (tr "dashboard.leave-org")]])) + (defn- team->org [team] (assoc (dm/select-keys team [:id :organization-id :organization-slug :organization-owner-id :organization-avatar-bg-url]) - :name (:organization-name team))) + :name (:organization-name team) + :default-team-id (:id team))) (mf/defc sidebar-org-switch* [{:keys [team profile]}] @@ -603,6 +713,11 @@ current-org (team->org team) + org-teams (mf/with-memo [teams current-org] + (->> teams + vals + (filter #(= (:organization-id %) (:organization-id current-org))))) + default-org? (nil? (:organization-id current-org)) show-orgs-menu* @@ -611,6 +726,21 @@ show-orgs-menu? (deref show-orgs-menu*) + show-org-options-menu* + (mf/use-state false) + + show-org-options-menu? + (deref show-org-options-menu*) + + on-show-options-click + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-org-options-menu* not))) + + close-org-options-menu + (mf/use-fn #(reset! show-org-options-menu* false)) + on-show-orgs-click (mf/use-fn (fn [event] @@ -638,24 +768,33 @@ (st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] (if show-dropdown? [:div {:class (stl/css :sidebar-org-switch)} + [:div {:class (stl/css :org-switch-content)} + [:button {:class (stl/css :current-org) + :on-click on-show-orgs-click + :on-key-down on-show-orgs-keydown + :aria-expanded show-orgs-menu? + :aria-haspopup "menu"} + [:div {:class (stl/css :team-name)} + (if default-org? + [:* + [:span {:class (stl/css :org-penpot-icon)} + [:> raw-svg* {:id penpot-logo-icon}]] + [:span {:class (stl/css :team-text)} + "Penpot"]] + [:* + [:> org-avatar* {:org current-org :size "xxxl"}] + [:span {:class (stl/css :team-text)} + (:name current-org)]])] + arrow-icon] + (if (or default-org? + (= (:id profile) (:organization-owner-id current-org))) + [:div {:class (stl/css :org-options)}] + [:> button* {:variant "ghost" + :type "button" + :class (stl/css :org-options-btn) + :on-click on-show-options-click} + (if show-org-options-menu? org-menu-icon-open org-menu-icon)])] - [:button {:class (stl/css :current-org) - :on-click on-show-orgs-click - :on-key-down on-show-orgs-keydown - :aria-expanded show-orgs-menu? - :aria-haspopup "menu"} - [:div {:class (stl/css :team-name)} - (if default-org? - [:* - [:span {:class (stl/css :nitrate-penpot-icon)} - [:> raw-svg* {:id penpot-logo-icon}]] - [:span {:class (stl/css :team-text)} - "Penpot"]] - [:* - [:> org-avatar* {:org current-org :size "xxxl"}] - [:span {:class (stl/css :team-text)} - (:name current-org)]])] - arrow-icon] ;; Orgs Dropdown [:> organizations-selector-dropdown* {:show show-orgs-menu? @@ -664,14 +803,22 @@ :class (stl/css :dropdown :teams-dropdown) :organization current-org :profile profile - :organizations orgs}]] - [:div {:class (stl/css :nitrate-selected-org)} - [:span {:class (stl/css :nitrate-penpot-icon)} + :organizations orgs}] + ;; Orgs options + [:> org-options-dropdown* {:show show-org-options-menu? + :on-close close-org-options-menu + :id "team-options" + :class (stl/css :dropdown :options-dropdown) + :organization current-org + :profile profile + :teams org-teams}]] + [:div {:class (stl/css :selected-org)} + [:span {:class (stl/css :org-penpot-icon)} [:> raw-svg* {:id penpot-logo-icon}]] "Penpot" [:> button* {:variant "ghost" :type "button" - :class (stl/css :nitrate-create-org) + :class (stl/css :create-org) :on-click on-create-org-click} (tr "dashboard.plus-create-new-org")]]))) (mf/defc sidebar-team-switch* @@ -914,7 +1061,7 @@ [:* [:div {:ref container} (when nitrate? - [:div {:class (stl/css :nitrate-orgs-container)} + [:div {:class (stl/css :orgs-container)} [:> sidebar-org-switch* {:team team :profile profile}]]) [:div {:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)} [:> sidebar-team-switch* {:team team :profile profile}] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 52a17762e3..f2191747b4 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -576,16 +576,16 @@ color: var(--color-accent-tertiary); } -.nitrate-orgs-container { +.orgs-container { align-items: center; display: flex; height: calc(2 * var(--sp-xxxl)); max-height: calc(2 * var(--sp-xxxl)); justify-content: space-between; - padding: 0 var(--sp-l); + padding: 0 var(--sp-xl); } -.nitrate-selected-org { +.selected-org { @include t.use-typography("body-medium"); color: var(--color-foreground-primary); @@ -596,12 +596,12 @@ gap: var(--sp-s); } -.nitrate-create-org { +.create-org { margin-inline-start: auto; text-transform: uppercase; } -.nitrate-penpot-icon { +.org-penpot-icon { display: flex; justify-content: center; align-items: center; @@ -617,7 +617,7 @@ } } -.nitrate-org-icon { +.org-icon { display: flex; justify-content: center; align-items: center; @@ -641,15 +641,58 @@ .current-org { @include deprecated.buttonStyle; + text-transform: none; display: grid; align-items: center; - grid-template-columns: 1fr auto; + grid-template-columns: 1fr auto auto; gap: var(--sp-s); height: 100%; width: 100%; - padding: 0 var(--sp-s); } .current-org .arrow-icon { margin-inline-end: var(--sp-xs); } + +.org-options { + display: flex; + justify-content: center; + align-items: center; + max-width: var(--sp-xxl); + min-width: $sz-28; + height: 100%; +} + +.org-switch-content { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + height: $sz-48; + width: 100%; +} + +.org-options-btn { + --icon-stroke: var(--icon-foreground); + + display: flex; + justify-content: center; + align-items: center; + width: $sz-32; + height: $sz-32; + + &:hover { + --icon-stroke: var(--color-accent-primary); + } +} + +.org-menu-icon { + @extend %button-icon; + + stroke: var(--icon-stroke); +} + +.org-menu-icon-open { + @extend %button-icon; + + stroke: var(--color-accent-primary); +} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 10851f8f6b..b73799b790 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -767,6 +767,9 @@ msgstr "Invite people" msgid "dashboard.leave-team" msgstr "Leave team" +msgid "dashboard.leave-org" +msgstr "Leave org" + #: src/app/main/ui/dashboard/templates.cljs:84, src/app/main/ui/dashboard/templates.cljs:169 msgid "dashboard.libraries-and-templates" msgstr "Libraries & Templates" @@ -1551,6 +1554,15 @@ msgstr "SVG is invalid or malformed" msgid "errors.team-feature-mismatch" msgstr "Detected incompatible feature '%s'" +msgid "errors.org-leave.org-owner-cannot-leave" +msgstr "The organization owner can't leave the organization." + +msgid "errors.org-leave.no-valid-teams" +msgstr "There was a problem leaving the organization. Please try again." + +msgid "errors.team-leave.only-owner-can-delete" +msgstr "Only the owner of a team can delete it." + #: src/app/main/ui/dashboard/sidebar.cljs:373, src/app/main/ui/dashboard/team.cljs:393 msgid "errors.team-leave.insufficient-members" msgstr "Insufficient members to leave team, you probably want to delete it." @@ -3593,6 +3605,15 @@ msgstr "" "You are the owner of this team. Please select another member to promote to " "owner before you leave." +msgid "modals.leave-org-and-reassign.hint" +msgstr "You are the owner of some organization's teams. Please promote another member to become an owner." + +msgid "modals.leave-org-and-reassign.hint-delete" +msgstr "You are the only member of some of the teams. Those teams will be deleted along with its projects and files." + +msgid "modals.leave-org-and-reassign.hint-promote" +msgstr "Also, you are the owner of some organization's teams. Please promote another member to become an owner." + #: src/app/main/ui/dashboard/change_owner.cljs:73 msgid "modals.leave-and-reassign.promote-and-leave" msgstr "Promote and leave" @@ -3609,14 +3630,35 @@ msgstr "Before you leave" msgid "modals.leave-confirm.accept" msgstr "Leave team" +msgid "modals.leave-org-confirm.accept" +msgstr "Leave organization" + #: src/app/main/ui/dashboard/sidebar.cljs:409, src/app/main/ui/dashboard/team.cljs:449 msgid "modals.leave-confirm.message" msgstr "Are you sure you want to leave this team?" +msgid "modals.leave-org-confirm.message" +msgstr "You will permanently lose access to all teams, projects, and files within it." + #: src/app/main/ui/dashboard/sidebar.cljs:408, src/app/main/ui/dashboard/sidebar.cljs:429, src/app/main/ui/dashboard/team.cljs:425, src/app/main/ui/dashboard/team.cljs:448 msgid "modals.leave-confirm.title" msgstr "Leaving team" +msgid "modals.leave-org-confirm.title" +msgstr "Leaving %s organization?" + +msgid "modals.before-leave-org.title" +msgstr "BEFORE YOU LEAVE THE ORGANIZATION" + +msgid "modals.before-leave-org.message" +msgstr "You are the only member of some of the teams. Those teams will be deleted along with its projects and files." + +msgid "modals.before-leave-org.warning" +msgstr "Any team where you’re the only member will be deleted." + +msgid "dasboard.leave-org.toast" +msgstr "You're no longer part of the %s organization." + #: src/app/main/ui/delete_shared.cljs:56 msgid "modals.move-shared-confirm.accept" msgid_plural "modals.move-shared-confirm.accept" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 74d8ef2b66..e56c446312 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -771,6 +771,9 @@ msgstr "Invitar a la gente" msgid "dashboard.leave-team" msgstr "Abandonar equipo" +msgid "dashboard.leave-org" +msgstr "Abandonar organización" + #: src/app/main/ui/dashboard/templates.cljs:84, src/app/main/ui/dashboard/templates.cljs:169 msgid "dashboard.libraries-and-templates" msgstr "Bibliotecas y plantillas" @@ -1522,6 +1525,15 @@ msgstr "El SVG no es válido o está mal formado" msgid "errors.team-feature-mismatch" msgstr "Detectada funcionalidad incompatible '%s'" +msgid "errors.org-leave.org-owner-cannot-leave" +msgstr "El dueño de la organización no puede abandonarla." + +msgid "errors.org-leave.no-valid-teams" +msgstr "Ha habido un problema abandonando la organización. Intentalo de nuevo, por favor." + +msgid "errors.team-leave.only-owner-can-delete" +msgstr "Sólo puede borrar un equipo su propietario." + #: src/app/main/ui/dashboard/sidebar.cljs:373, src/app/main/ui/dashboard/team.cljs:393 msgid "errors.team-leave.insufficient-members" msgstr "" @@ -3544,6 +3556,19 @@ msgstr "" "Tienes la propiedad de este equipo. Por favor selecciona otra persona " "integrante para promover al rol Propiedad." +msgid "modals.leave-org-and-reassign.hint" +msgstr "" +"Tienes la propiedad de algunos equipos de esta organización. " +"Por favor selecciona otra persona integrante para promover al rol Propiedad." + +msgid "modals.leave-org-and-reassign.hint-delete" +msgstr "Eres el único miembro de algunos equipos. Esos equipos se van a borrar, junto con sus proyectos y ficheros." + +msgid "modals.leave-org-and-reassign.hint-promote" +msgstr "" +"Además, tienes la propiedad de algunos equipos de esta organización. " +"Por favor selecciona otra persona integrante para promover al rol Propiedad." + #: src/app/main/ui/dashboard/change_owner.cljs:73 msgid "modals.leave-and-reassign.promote-and-leave" msgstr "Promocionar y abandonar" @@ -3560,14 +3585,35 @@ msgstr "Antes de que abandones" msgid "modals.leave-confirm.accept" msgstr "Abandonar el equipo" +msgid "modals.leave-org-confirm.accept" +msgstr "Abandonar organización" + #: src/app/main/ui/dashboard/sidebar.cljs:409, src/app/main/ui/dashboard/team.cljs:449 msgid "modals.leave-confirm.message" msgstr "¿Seguro que quieres abandonar este equipo?" +msgid "modals.leave-org-confirm.message" +msgstr "Perderás permanentemente el acceso a todos los equipos, proyectos y archivos en ella." + #: src/app/main/ui/dashboard/sidebar.cljs:408, src/app/main/ui/dashboard/sidebar.cljs:429, src/app/main/ui/dashboard/team.cljs:425, src/app/main/ui/dashboard/team.cljs:448 msgid "modals.leave-confirm.title" msgstr "Abandonando el equipo" +msgid "modals.leave-org-confirm.title" +msgstr "¿Abandonando la organización %s?" + +msgid "modals.before-leave-org.title" +msgstr "ANTES DE ABANDONAR LA ORGANIZACIÓN" + +msgid "modals.before-leave-org.message" +msgstr "Eres el único miembro de algunos equipos. Esos equipos se van a borrar, junto con sus proyectos y ficheros." + +msgid "modals.before-leave-org.warning" +msgstr "Se van a borrar todos los equipos en los que eres el único miembro." + +msgid "dasboard.leave-org.toast" +msgstr "Ya no eres miembro de la organización %s." + #: src/app/main/ui/delete_shared.cljs:56 msgid "modals.move-shared-confirm.accept" msgid_plural "modals.move-shared-confirm.accept"