mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
✨ Add option to leave a nitrate organization
This commit is contained in:
parent
a5055af538
commit
3c639f41c4
@ -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 {}))
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"))))))))
|
||||
|
||||
|
||||
444
backend/test/backend_tests/rpc_nitrate_test.clj
Normal file
444
backend/test/backend_tests/rpc_nitrate_test.clj
Normal 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))))))))
|
||||
@ -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
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@ -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))))))
|
||||
|
||||
@ -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))))
|
||||
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -65,3 +65,7 @@
|
||||
|
||||
color: var(--modal-text-foreground-color);
|
||||
}
|
||||
|
||||
.modal-error-msg {
|
||||
margin: var(--sp-xxl) 0;
|
||||
}
|
||||
|
||||
@ -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}]]]]]))
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user