0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-02-02 12:28:54 -05:00

🎉 Add the ability to copy team invitation link

This commit is contained in:
Andrey Antukh 2022-12-13 15:29:43 +01:00
parent 7d2e3a0864
commit 842463ed1b
9 changed files with 262 additions and 135 deletions

View file

@ -322,7 +322,7 @@
::http.client/client (ig/ref ::http.client/client) ::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool) ::db/pool (ig/ref ::db/pool)
::wrk/executor (ig/ref ::wrk/executor) ::wrk/executor (ig/ref ::wrk/executor)
::props (ig/ref :app.setup/props)
:pool (ig/ref ::db/pool) :pool (ig/ref ::db/pool)
:session (ig/ref :app.http.session/manager) :session (ig/ref :app.http.session/manager)
:sprops (ig/ref :app.setup/props) :sprops (ig/ref :app.setup/props)

View file

@ -15,6 +15,7 @@
[app.db :as db] [app.db :as db]
[app.emails :as eml] [app.emails :as eml]
[app.loggers.audit :as audit] [app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media] [app.media :as media]
[app.rpc.climit :as climit] [app.rpc.climit :as climit]
[app.rpc.doc :as-alias doc] [app.rpc.doc :as-alias doc]
@ -260,7 +261,7 @@
(def sql:team-invitations (def sql:team-invitations
"select email_to as email, role, (valid_until < now()) as expired "select email_to as email, role, (valid_until < now()) as expired
from team_invitation where team_id = ? order by valid_until desc") from team_invitation where team_id = ? order by valid_until desc, created_at desc")
(defn get-team-invitations (defn get-team-invitations
[conn team-id] [conn team-id]
@ -628,25 +629,37 @@
"insert into team_invitation(team_id, email_to, role, valid_until) "insert into team_invitation(team_id, email_to, role, valid_until)
values (?, ?, ?, ?) values (?, ?, ?, ?)
on conflict(team_id, email_to) do on conflict(team_id, email_to) do
update set role = ?, valid_until = ?, updated_at = now();") update set role = ?, updated_at = now();")
(defn- create-invitation-token
[cfg {:keys [expire profile-id team-id member-id member-email role]}]
(tokens/generate (::main/props cfg)
{:iss :team-invitation
:exp expire
:profile-id profile-id
:role role
:team-id team-id
:member-email member-email
:member-id member-id}))
(defn- create-profile-identity-token
[cfg profile]
(tokens/generate (::main/props cfg)
{:iss :profile-identity
:profile-id (:id profile)
:exp (dt/in-future {:days 30})}))
(defn- create-invitation (defn- create-invitation
[{:keys [conn sprops team profile role email] :as cfg}] [{:keys [::conn] :as cfg} {:keys [team profile role email] :as params}]
(let [member (profile/retrieve-profile-data-by-email conn email) (let [member (profile/retrieve-profile-data-by-email conn email)
token-exp (dt/in-future "168h") ;; 7 days expire (dt/in-future "168h") ;; 7 days
email (str/lower email) itoken (create-invitation-token cfg {:profile-id (:id profile)
itoken (tokens/generate sprops :expire expire
{:iss :team-invitation :team-id (:id team)
:exp token-exp :member-email (or (:email member) email)
:profile-id (:id profile) :member-id (:id member)
:role role :role role})
:team-id (:id team) ptoken (create-profile-identity-token cfg profile)]
:member-email (:email member email)
:member-id (:id member)})
ptoken (tokens/generate sprops
{:iss :profile-identity
:profile-id (:id profile)
:exp (dt/in-future {:days 30})})]
(when (and member (not (eml/allow-send-emails? conn member))) (when (and member (not (eml/allow-send-emails? conn member)))
(ex/raise :type :validation (ex/raise :type :validation
@ -687,11 +700,10 @@
{:id (:id member)}))) {:id (:id member)})))
(do (do
(db/exec-one! conn [sql:upsert-team-invitation (db/exec-one! conn [sql:upsert-team-invitation
(:id team) (str/lower email) (name role) (:id team) (str/lower email) (name role) expire (name role)])
token-exp (name role) token-exp])
(eml/send! {::eml/conn conn (eml/send! {::eml/conn conn
::eml/factory eml/invite-to-team ::eml/factory eml/invite-to-team
:public-uri (:public-uri cfg) :public-uri (cf/get :public-uri)
:to email :to email
:invited-by (:fullname profile) :invited-by (:fullname profile)
:team (:name team) :team (:name team)
@ -727,15 +739,14 @@
:code :profile-is-muted :code :profile-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) :hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
(let [invitations (->> emails (let [cfg (assoc cfg ::conn conn)
invitations (->> emails
(map (fn [email] (map (fn [email]
(assoc cfg {:email (str/lower email)
:email email :team team
:conn conn :profile profile
:team team :role role}))
:profile profile (map (partial create-invitation cfg)))]
:role role)))
(map create-invitation))]
(with-meta (vec invitations) (with-meta (vec invitations)
{::audit/props {:invitations (count invitations)}}))))) {::audit/props {:invitations (count invitations)}})))))
@ -743,26 +754,26 @@
;; --- Mutation: Create Team & Invite Members ;; --- Mutation: Create Team & Invite Members
(s/def ::emails ::us/set-of-valid-emails) (s/def ::emails ::us/set-of-valid-emails)
(s/def ::create-team-and-invitations (s/def ::create-team-with-invitations
(s/merge ::create-team (s/merge ::create-team
(s/keys :req-un [::emails ::role]))) (s/keys :req-un [::emails ::role])))
(sv/defmethod ::create-team-and-invitations (sv/defmethod ::create-team-with-invitations
{::doc/added "1.17"} {::doc/added "1.17"}
[{:keys [pool] :as cfg} {:keys [profile-id emails role] :as params}] [{:keys [pool] :as cfg} {:keys [profile-id emails role] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [team (create-team conn params) (let [team (create-team conn params)
profile (db/get-by-id conn :profile profile-id)] profile (db/get-by-id conn :profile profile-id)
cfg (assoc cfg ::conn conn)]
;; Create invitations for all provided emails. ;; Create invitations for all provided emails.
(doseq [email emails] (->> emails
(create-invitation (map (fn [email]
(assoc cfg {:team team
:conn conn :profile profile
:team team :email (str/lower email)
:profile profile :role role}))
:email email (run! (partial create-invitation cfg)))
:role role)))
(-> team (-> team
(vary-meta assoc ::audit/props {:invitations (count emails)}) (vary-meta assoc ::audit/props {:invitations (count emails)})
@ -777,6 +788,28 @@
:profile-id profile-id :profile-id profile-id
:invitations (count emails)}}))))))) :invitations (count emails)}})))))))
;; --- Query: get-team-invitation-token
(s/def ::get-team-invitation-token
(s/keys :req-un [::profile-id ::team-id ::email]))
(sv/defmethod ::get-team-invitation-token
{::doc/added "1.17"}
[{:keys [::db/pool] :as cfg} {:keys [profile-id team-id email] :as params}]
(check-read-permissions! pool profile-id team-id)
(let [invit (-> (db/get pool :team-invitation
{:team-id team-id
:email-to (str/lower email)})
(update :role keyword))
member (profile/retrieve-profile-data-by-email pool (:email invit))
token (create-invitation-token cfg {:team-id (:team-id invit)
:profile-id profile-id
:expire (:expire invit)
:role (:role invit)
:member-id (:id member)
:member-email (or (:email member) (:email-to invit))})]
{:token token}))
;; --- Mutation: Update invitation role ;; --- Mutation: Update invitation role
(s/def ::update-team-invitation-role (s/def ::update-team-invitation-role

View file

@ -129,7 +129,7 @@
[{:keys [conn session] :as cfg} {:keys [profile-id token]} [{:keys [conn session] :as cfg} {:keys [profile-id token]}
{:keys [member-id team-id member-email] :as claims}] {:keys [member-id team-id member-email] :as claims}]
(us/assert ::team-invitation-claims claims) (us/verify! ::team-invitation-claims claims)
(let [invitation (db/get* conn :team-invitation (let [invitation (db/get* conn :team-invitation
{:team-id team-id :email-to member-email}) {:team-id team-id :email-to member-email})

View file

@ -153,21 +153,20 @@
:code :profile-is-muted :code :profile-is-muted
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) :hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
(let [invitations (->> emails (let [cfg (assoc cfg ::cmd.teams/conn conn)
invitations (->> emails
(map (fn [email] (map (fn [email]
(assoc cfg {:email (str/lower email)
:email email :team team
:conn conn :profile profile
:team team :role role}))
:profile profile (map (partial #'cmd.teams/create-invitation cfg)))]
:role role)))
(map #'cmd.teams/create-invitation))]
(with-meta (vec invitations) (with-meta (vec invitations)
{::audit/props {:invitations (count invitations)}}))))) {::audit/props {:invitations (count invitations)}})))))
;; --- Mutation: Create Team & Invite Members ;; --- Mutation: Create Team & Invite Members
(s/def ::create-team-and-invite-members ::cmd.teams/create-team-and-invitations) (s/def ::create-team-and-invite-members ::cmd.teams/create-team-with-invitations)
(sv/defmethod ::create-team-and-invite-members (sv/defmethod ::create-team-and-invite-members
{::doc/added "1.0" {::doc/added "1.0"
@ -175,17 +174,17 @@
[{:keys [::db/pool] :as cfg} {:keys [profile-id emails role] :as params}] [{:keys [::db/pool] :as cfg} {:keys [profile-id emails role] :as params}]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(let [team (cmd.teams/create-team conn params) (let [team (cmd.teams/create-team conn params)
profile (db/get-by-id conn :profile profile-id)] profile (db/get-by-id conn :profile profile-id)
cfg (assoc cfg ::cmd.teams/conn conn)]
;; Create invitations for all provided emails. ;; Create invitations for all provided emails.
(doseq [email emails] (->> emails
(#'cmd.teams/create-invitation (map (fn [email]
(assoc cfg {:team team
:conn conn :profile profile
:team team :email (str/lower email)
:profile profile :role role}))
:email email (run! (partial #'cmd.teams/create-invitation cfg)))
:role role)))
(-> team (-> team
(vary-meta assoc ::audit/props {:invitations (count emails)}) (vary-meta assoc ::audit/props {:invitations (count emails)})

View file

@ -63,6 +63,16 @@
(t/is (th/success? out)) (t/is (th/success? out))
(t/is (= 1 (:call-count (deref mock))))) (t/is (= 1 (:call-count (deref mock)))))
;; get invitation token
(let [params {::th/type :get-team-invitation-token
:profile-id (:id profile1)
:team-id (:id team)
:email "foo@bar.com"}
out (th/command! params)]
(t/is (th/success? out))
(let [result (:result out)]
(contains? result :token)))
;; invite user with bounce ;; invite user with bounce
(th/reset-mock! mock) (th/reset-mock! mock)
@ -235,8 +245,6 @@
))) )))
(t/deftest invite-team-member-with-email-verification-disabled (t/deftest invite-team-member-with-email-verification-disabled
(with-mocks [mock {:target 'app.emails/send! :return nil}] (with-mocks [mock {:target 'app.emails/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true}) (let [profile1 (th/create-profile* 1 {:is-active true})

View file

@ -9,6 +9,7 @@
[app.common.data :as d] [app.common.data :as d]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uuid :as uuid] [app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.events :as ev] [app.main.data.events :as ev]
[app.main.data.fonts :as df] [app.main.data.fonts :as df]
[app.main.data.media :as di] [app.main.data.media :as di]
@ -18,6 +19,7 @@
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[app.util.router :as rt] [app.util.router :as rt]
[app.util.timers :as tm] [app.util.timers :as tm]
[app.util.webapi :as wapi]
[beicon.core :as rx] [beicon.core :as rx]
[cljs.spec.alpha :as s] [cljs.spec.alpha :as s]
[potok.core :as ptk])) [potok.core :as ptk]))
@ -403,7 +405,7 @@
params {:name name params {:name name
:emails #{emails} :emails #{emails}
:role role}] :role role}]
(->> (rp/cmd! :create-team-and-invitations params) (->> (rp/cmd! :create-team-with-invitations params)
(rx/tap on-success) (rx/tap on-success)
(rx/map team-created) (rx/map team-created)
(rx/catch on-error)))))) (rx/catch on-error))))))
@ -509,6 +511,36 @@
(rx/tap on-success) (rx/tap on-success)
(rx/catch on-error)))))) (rx/catch on-error))))))
(defn copy-invitation-link
[{:keys [email team-id] :as params}]
(us/assert! ::us/email email)
(us/assert! ::us/uuid team-id)
(ptk/reify ::copy-invitation-link
IDeref
(-deref [_] {:email email :team-id team-id})
ptk/WatchEvent
(watch [_ state _]
(let [{:keys [on-success on-error]
:or {on-success identity
on-error rx/throw}} (meta params)
router (:router state)]
(->> (rp/cmd! :get-team-invitation-token params)
(rx/map (fn [params]
(rt/resolve router :auth-verify-token {} params)))
(rx/map (fn [fragment]
(assoc @cf/public-uri :fragment fragment)))
(rx/tap (fn [uri]
(wapi/write-to-clipboard (str uri))))
(rx/tap on-success)
(rx/ignore)
(rx/catch on-error))))))
(defn update-team-invitation-role (defn update-team-invitation-role
[{:keys [email team-id role] :as params}] [{:keys [email team-id role] :as params}]
(us/assert! ::us/email email) (us/assert! ::us/email email)

View file

@ -448,81 +448,113 @@
:pending (= status :pending))} :pending (= status :pending))}
[:span.status-label (tr status-label)]])) [:span.status-label (tr status-label)]]))
(mf/defc invitation-actions [{:keys [can-modify? delete resend] :as props}] (mf/defc invitation-actions
(let [show? (mf/use-state false)] [{:keys [invitation team] :as props}]
(when can-modify? (let [show? (mf/use-state false)
[:*
[:span.icon {:on-click #(reset! show? true)} [i/actions]] team-id (:id team)
[:& dropdown {:show @show? email (:email invitation)
:on-close #(reset! show? false)} role (:role invitation)
[:ul.dropdown.actions-dropdown
[:li {:on-click resend} (tr "labels.resend-invitation")] on-resend-success
[:li {:on-click delete} (tr "labels.delete-invitation")]]]]))) (mf/use-fn
(fn []
(st/emit! (msg/success (tr "notifications.invitation-email-sent"))
(modal/hide))))
on-copy-success
(mf/use-fn
(fn []
(st/emit! (msg/success (tr "notifications.invitation-link-copied"))
(modal/hide))))
on-error
(mf/use-fn
(mf/deps email)
(fn [{:keys [type code] :as error}]
(cond
(and (= :validation type)
(= :profile-is-muted code))
(rx/of (msg/error (tr "errors.profile-is-muted")))
(and (= :validation type)
(= :member-is-muted code))
(rx/of (msg/error (tr "errors.member-is-muted")))
(and (= :validation type)
(= :email-has-permanent-bounces code))
(rx/of (msg/error (tr "errors.email-has-permanent-bounces" email)))
:else
(rx/throw error))))
delete-fn
(mf/use-fn
(mf/deps email team-id)
(fn []
(let [params {:email email :team-id team-id}
mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}]
(st/emit! (dd/delete-team-invitation (with-meta params mdata))))))
resend-fn
(mf/use-fn
(mf/deps email team-id)
(fn []
(let [params (with-meta {:emails [email]
:team-id team-id
:resend? true
:role role}
{:on-success on-resend-success
:on-error on-error})]
(st/emit!
(-> (dd/invite-team-members params)
(with-meta {::ev/origin :team}))))))
copy-fn
(mf/use-fn
(mf/deps email team-id)
(fn []
(let [params (with-meta {:email email :team-id team-id}
{:on-success on-copy-success
:on-error on-error})]
(prn "KKK1")
(st/emit!
(-> (dd/copy-invitation-link params)
(with-meta {::ev/origin :team}))))))]
[:*
[:span.icon {:on-click #(reset! show? true)} [i/actions]]
[:& dropdown {:show @show?
:on-close #(reset! show? false)}
[:ul.dropdown.actions-dropdown
[:li {:on-click copy-fn} (tr "labels.copy-invitation-link")]
[:li {:on-click resend-fn} (tr "labels.resend-invitation")]
[:li {:on-click delete-fn} (tr "labels.delete-invitation")]]]]))
(mf/defc invitation-row (mf/defc invitation-row
{::mf/wrap [mf/memo]} {::mf/wrap [mf/memo]}
[{:keys [invitation can-invite? team] :as props}] [{:keys [invitation can-invite? team] :as props}]
(let [expired? (:expired invitation) (let [expired? (:expired invitation)
email (:email invitation) email (:email invitation)
invitation-role (:role invitation) role (:role invitation)
status (if expired? status (if expired? :expired :pending)
:expired
:pending)
on-success
#(st/emit! (msg/success (tr "notifications.invitation-email-sent"))
(modal/hide)
(dd/fetch-team-invitations))
on-error
(fn [email {:keys [type code] :as error}]
(cond
(and (= :validation type)
(= :profile-is-muted code))
(msg/error (tr "errors.profile-is-muted"))
(and (= :validation type)
(= :member-is-muted code))
(msg/error (tr "errors.member-is-muted"))
(and (= :validation type)
(= :email-has-permanent-bounces code))
(msg/error (tr "errors.email-has-permanent-bounces" email))
:else
(msg/error (tr "errors.generic"))))
change-rol change-rol
(fn [role] (mf/use-fn
(let [params {:email email :team-id (:id team) :role role} (mf/deps team email)
mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}] (fn [role]
(st/emit! (dd/update-team-invitation-role (with-meta params mdata))))) (let [params {:email email :team-id (:id team) :role role}
mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}]
(st/emit! (dd/update-team-invitation-role (with-meta params mdata))))))]
delete-invitation
(fn []
(let [params {:email email :team-id (:id team)}
mdata {:on-success #(st/emit! (dd/fetch-team-invitations))}]
(st/emit! (dd/delete-team-invitation (with-meta params mdata)))))
resend-invitation
(fn []
(let [params {:emails [email]
:team-id (:id team)
:resend? true
:role invitation-role}
mdata {:on-success on-success
:on-error (partial on-error email)}]
(st/emit! (-> (dd/invite-team-members (with-meta params mdata))
(with-meta {::ev/origin :team}))
(dd/fetch-team-invitations))))]
[:div.table-row [:div.table-row
[:div.table-field.mail email] [:div.table-field.mail email]
[:div.table-field.roles [:div.table-field.roles
[:& invitation-role-selector [:& invitation-role-selector
{:can-invite? can-invite? {:can-invite? can-invite?
:role invitation-role :role role
:status status :status status
:change-to-editor (partial change-rol :editor) :change-to-editor (partial change-rol :editor)
:change-to-admin (partial change-rol :admin)}]] :change-to-admin (partial change-rol :admin)}]]
@ -530,20 +562,22 @@
[:div.table-field.status [:div.table-field.status
[:& invitation-status-badge {:status status}]] [:& invitation-status-badge {:status status}]]
[:div.table-field.actions [:div.table-field.actions
[:& invitation-actions (when can-invite?
{:can-modify? can-invite? [:& invitation-actions
:delete delete-invitation {:invitation invitation
:resend resend-invitation}]]])) :team team}])]]))
(mf/defc empty-invitation-table [can-invite?] (mf/defc empty-invitation-table
[{:keys [can-invite?] :as props}]
[:div.empty-invitations [:div.empty-invitations
[:span (tr "labels.no-invitations")] [:span (tr "labels.no-invitations")]
(when (:can-invite? can-invite?) [:span (tr "labels.no-invitations-hint")])]) (when can-invite?
[:span (tr "labels.no-invitations-hint")])])
(mf/defc invitation-section (mf/defc invitation-section
[{:keys [team invitations] :as props}] [{:keys [team invitations] :as props}]
(let [owner? (get-in team [:permissions :is-owner]) (let [owner? (dm/get-in team [:permissions :is-owner])
admin? (get-in team [:permissions :is-admin]) admin? (dm/get-in team [:permissions :is-admin])
can-invite? (or owner? admin?)] can-invite? (or owner? admin?)]
[:div.dashboard-table.invitations [:div.dashboard-table.invitations
@ -555,7 +589,11 @@
[:& empty-invitation-table {:can-invite? can-invite?}] [:& empty-invitation-table {:can-invite? can-invite?}]
[:div.table-rows [:div.table-rows
(for [invitation invitations] (for [invitation invitations]
[:& invitation-row {:key (:email invitation) :invitation invitation :can-invite? can-invite? :team team}])])])) [:& invitation-row
{:key (:email invitation)
:invitation invitation
:can-invite? can-invite?
:team team}])])]))
(mf/defc team-invitations-page (mf/defc team-invitations-page
[{:keys [team] :as props}] [{:keys [team] :as props}]
@ -568,7 +606,7 @@
(tr "dashboard.your-penpot") (tr "dashboard.your-penpot")
(:name team))))) (:name team)))))
(mf/with-effect (mf/with-effect []
(st/emit! (dd/fetch-team-invitations))) (st/emit! (dd/fetch-team-invitations)))
[:* [:*

View file

@ -1436,6 +1436,10 @@ msgstr "Rename team"
msgid "labels.resend-invitation" msgid "labels.resend-invitation"
msgstr "Resend invitation" msgstr "Resend invitation"
#: src/app/main/ui/dashboard/team.cljs
msgid "labels.copy-invitation-link"
msgstr "Copy invitation link"
#: src/app/main/ui/static.cljs, src/app/main/ui/static.cljs, src/app/main/ui/static.cljs #: src/app/main/ui/static.cljs, src/app/main/ui/static.cljs, src/app/main/ui/static.cljs
msgid "labels.retry" msgid "labels.retry"
msgstr "Retry" msgstr "Retry"
@ -1957,6 +1961,10 @@ msgstr "Update a component in a shared library"
msgid "notifications.invitation-email-sent" msgid "notifications.invitation-email-sent"
msgstr "Invitation sent successfully" msgstr "Invitation sent successfully"
#: src/app/main/ui/dashboard/team.cljs
msgid "notifications.invitation-link-copied"
msgstr "Invitation link copied"
#: src/app/main/ui/settings/delete_account.cljs #: src/app/main/ui/settings/delete_account.cljs
msgid "notifications.profile-deletion-not-allowed" msgid "notifications.profile-deletion-not-allowed"
msgstr "You can't delete you profile. Reassign your teams before proceed." msgstr "You can't delete you profile. Reassign your teams before proceed."

View file

@ -1606,6 +1606,10 @@ msgstr "Renombra el equipo"
msgid "labels.resend-invitation" msgid "labels.resend-invitation"
msgstr "Reenviar invitacion" msgstr "Reenviar invitacion"
#: src/app/main/ui/dashboard/team.cljs
msgid "labels.copy-invitation-link"
msgstr "Copiar link de invitación"
#: src/app/main/ui/static.cljs, src/app/main/ui/static.cljs #: src/app/main/ui/static.cljs, src/app/main/ui/static.cljs
msgid "labels.retry" msgid "labels.retry"
msgstr "Reintentar" msgstr "Reintentar"
@ -2172,6 +2176,11 @@ msgstr "Actualizar un componente en biblioteca"
msgid "notifications.invitation-email-sent" msgid "notifications.invitation-email-sent"
msgstr "Invitación enviada con éxito" msgstr "Invitación enviada con éxito"
#: src/app/main/ui/dashboard/team.cljs
msgid "notifications.invitation-link-copied"
msgstr "Enlace de invitacion copiado"
#: src/app/main/ui/settings/delete_account.cljs #: src/app/main/ui/settings/delete_account.cljs
msgid "notifications.profile-deletion-not-allowed" msgid "notifications.profile-deletion-not-allowed"
msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir." msgstr "No puedes borrar tu perfil. Reasigna tus equipos antes de seguir."