Add option to leave a nitrate organization

This commit is contained in:
Pablo Alba 2026-04-07 11:26:57 +02:00 committed by GitHub
parent a5055af538
commit 3c639f41c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1194 additions and 97 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,3 +65,7 @@
color: var(--modal-text-foreground-color);
}
.modal-error-msg {
margin: var(--sp-xxl) 0;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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