♻️ Refactor profile registration flow.

This commit is contained in:
Andrey Antukh 2021-06-15 17:24:00 +02:00 committed by Andrés Moya
parent c82d936e96
commit 9e3ba85b72
30 changed files with 717 additions and 581 deletions

View file

@ -6,10 +6,13 @@
(ns app.http.oauth (ns app.http.oauth
(:require (:require
[app.common.data :as d]
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.uri :as u] [app.common.uri :as u]
[app.config :as cf] [app.config :as cf]
[app.db :as db]
[app.rpc.queries.profile :as profile]
[app.util.http :as http] [app.util.http :as http]
[app.util.logging :as l] [app.util.logging :as l]
[app.util.time :as dt] [app.util.time :as dt]
@ -25,11 +28,12 @@
:headers {"location" (str uri)} :headers {"location" (str uri)}
:body ""}) :body ""})
(defn generate-error-redirect-uri (defn generate-error-redirect
[cfg] [cfg error]
(-> (u/uri (:public-uri cfg)) (let [uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/login") (assoc :path "/#/auth/login")
(assoc :query (u/map->query-string {:error "unable-to-auth"})))) (assoc :query (u/map->query-string {:error "unable-to-auth" :hint (ex-message error)})))]
(redirect-response uri)))
(defn register-profile (defn register-profile
[{:keys [rpc] :as cfg} info] [{:keys [rpc] :as cfg} info]
@ -39,15 +43,33 @@
(some? (:invitation-token info)) (some? (:invitation-token info))
(assoc :invitation-token (:invitation-token info))))) (assoc :invitation-token (:invitation-token info)))))
(defn generate-redirect-uri (defn generate-redirect
[{:keys [tokens] :as cfg} profile] [{:keys [tokens session] :as cfg} request info profile]
(let [token (or (:invitation-token profile) (if profile
(tokens :generate {:iss :auth (let [sxf ((:create session) (:id profile))
:exp (dt/in-future "15m") token (or (:invitation-token info)
:profile-id (:id profile)}))] (tokens :generate {:iss :auth
(-> (u/uri (:public-uri cfg)) :exp (dt/in-future "15m")
(assoc :path "/#/auth/verify-token") :profile-id (:id profile)}))
(assoc :query (u/map->query-string {:token token}))))) params {:token token}
uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/verify-token")
(assoc :query (u/map->query-string params)))]
(->> (redirect-response uri)
(sxf request)))
(let [info (assoc info
:iss :prepared-register
:exp (dt/in-future {:hours 48}))
token (tokens :generate info)
params (d/without-nils
{:token token
:fullname (:fullname info)})
uri (-> (u/uri (:public-uri cfg))
(assoc :path "/#/auth/register/validate")
(assoc :query (u/map->query-string params)))]
(redirect-response uri))))
(defn- build-redirect-uri (defn- build-redirect-uri
[{:keys [provider] :as cfg}] [{:keys [provider] :as cfg}]
@ -146,6 +168,7 @@
(string? roles) (into #{} (str/words roles)) (string? roles) (into #{} (str/words roles))
(vector? roles) (into #{} roles) (vector? roles) (into #{} roles)
:else #{}))] :else #{}))]
;; check if profile has a configured set of roles ;; check if profile has a configured set of roles
(when-not (set/subset? provider-roles profile-roles) (when-not (set/subset? provider-roles profile-roles)
(ex/raise :type :internal (ex/raise :type :internal
@ -188,18 +211,23 @@
{:status 200 {:status 200
:body {:redirect-uri uri}})) :body {:redirect-uri uri}}))
(defn- retrieve-profile
[{:keys [pool] :as cfg} info]
(with-open [conn (db/open pool)]
(some->> (:email info)
(profile/retrieve-profile-data-by-email conn)
(profile/populate-additional-data conn))))
(defn- callback-handler (defn- callback-handler
[{:keys [session] :as cfg} request] [cfg request]
(try (try
(let [info (retrieve-info cfg request) (let [info (retrieve-info cfg request)
profile (register-profile cfg info) profile (retrieve-profile cfg info)]
uri (generate-redirect-uri cfg profile) (generate-redirect cfg request info profile))
sxf ((:create session) (:id profile))] (catch Exception e
(->> (redirect-response uri) (l/warn :hint "error on oauth process"
(sxf request))) :cause e)
(catch Exception _e (generate-error-redirect cfg e))))
(-> (generate-error-redirect-uri cfg)
(redirect-response)))))
;; --- INIT ;; --- INIT
@ -211,7 +239,7 @@
(s/def ::rpc map?) (s/def ::rpc map?)
(defmethod ig/pre-init-spec :app.http.oauth/handlers [_] (defmethod ig/pre-init-spec :app.http.oauth/handlers [_]
(s/keys :req-un [::public-uri ::session ::tokens ::rpc])) (s/keys :req-un [::public-uri ::session ::tokens ::rpc ::db/pool]))
(defn wrap-handler (defn wrap-handler
[cfg handler] [cfg handler]

View file

@ -110,6 +110,7 @@
:app.http.oauth/handlers :app.http.oauth/handlers
{:rpc (ig/ref :app.rpc/rpc) {:rpc (ig/ref :app.rpc/rpc)
:session (ig/ref :app.http.session/session) :session (ig/ref :app.http.session/session)
:pool (ig/ref :app.db/pool)
:tokens (ig/ref :app.tokens/tokens) :tokens (ig/ref :app.tokens/tokens)
:public-uri (cf/get :public-uri)} :public-uri (cf/get :public-uri)}

View file

@ -9,7 +9,10 @@
[app.common.exceptions :as ex] [app.common.exceptions :as ex]
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cfg] [app.config :as cfg]
[app.rpc.mutations.profile :refer [login-or-register]] [app.db :as db]
[app.loggers.audit :as audit]
[app.rpc.mutations.profile :as profile-m]
[app.rpc.queries.profile :as profile-q]
[app.util.services :as sv] [app.util.services :as sv]
[clj-ldap.client :as ldap] [clj-ldap.client :as ldap]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
@ -34,6 +37,7 @@
;; --- Mutation: login-with-ldap ;; --- Mutation: login-with-ldap
(declare authenticate) (declare authenticate)
(declare login-or-register)
(s/def ::email ::us/email) (s/def ::email ::us/email)
(s/def ::password ::us/string) (s/def ::password ::us/string)
@ -45,30 +49,36 @@
(sv/defmethod ::login-with-ldap {:auth false :rlimit :password} (sv/defmethod ::login-with-ldap {:auth false :rlimit :password}
[{:keys [pool session tokens] :as cfg} {:keys [email password invitation-token] :as params}] [{:keys [pool session tokens] :as cfg} {:keys [email password invitation-token] :as params}]
(let [info (authenticate params) (db/with-atomic [conn pool]
cfg (assoc cfg :conn pool)] (let [info (authenticate params)
(when-not info cfg (assoc cfg :conn conn)]
(ex/raise :type :validation
:code :wrong-credentials))
(let [profile (login-or-register cfg {:email (:email info)
:backend (:backend info)
:fullname (:fullname info)})]
(if-let [token (:invitation-token params)]
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged).
(let [claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims
:member-id (:id profile)
:member-email (:email profile))
token (tokens :generate claims)]
(with-meta
{:invitation-token token}
{:transform-response ((:create session) (:id profile))}))
(with-meta profile (when-not info
{:transform-response ((:create session) (:id profile))}))))) (ex/raise :type :validation
:code :wrong-credentials))
(let [profile (login-or-register cfg {:email (:email info)
:backend (:backend info)
:fullname (:fullname info)})]
(if-let [token (:invitation-token params)]
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged).
(let [claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims
:member-id (:id profile)
:member-email (:email profile))
token (tokens :generate claims)]
(with-meta {:invitation-token token}
{:transform-response ((:create session) (:id profile))
::audit/props (:props profile)
::audit/profile-id (:id profile)}))
(with-meta profile
{:transform-response ((:create session) (:id profile))
::audit/props (:props profile)
::audit/profile-id (:id profile)}))))))
(defn- replace-several [s & {:as replacements}] (defn- replace-several [s & {:as replacements}]
(reduce-kv clojure.string/replace s replacements)) (reduce-kv clojure.string/replace s replacements))
@ -88,11 +98,25 @@
(first (ldap/search cpool base-dn params)))) (first (ldap/search cpool base-dn params))))
(defn- authenticate (defn- authenticate
[{:keys [password] :as params}] [{:keys [password email] :as params}]
(with-open [conn (connect)] (with-open [conn (connect)]
(when-let [{:keys [dn] :as luser} (get-ldap-user conn params)] (when-let [{:keys [dn] :as luser} (get-ldap-user conn params)]
(when (ldap/bind? conn dn password) (when (ldap/bind? conn dn password)
{:photo (get luser (keyword (cfg/get :ldap-attrs-photo))) {:photo (get luser (keyword (cfg/get :ldap-attrs-photo)))
:fullname (get luser (keyword (cfg/get :ldap-attrs-fullname))) :fullname (get luser (keyword (cfg/get :ldap-attrs-fullname)))
:email (get luser (keyword (cfg/get :ldap-attrs-email))) :email email
:backend "ldap"})))) :backend "ldap"}))))
(defn- login-or-register
[{:keys [conn] :as cfg} info]
(or (some->> (:email info)
(profile-q/retrieve-profile-data-by-email conn)
(profile-q/populate-additional-data conn)
(profile-q/decode-profile-row))
(let [params (-> info
(assoc :is-active true)
(assoc :is-demo false))]
(->> params
(profile-m/create-profile conn)
(profile-m/create-profile-relations conn)
(profile-q/strip-private-attrs)))))

View file

@ -36,106 +36,14 @@
(s/def ::password ::us/not-empty-string) (s/def ::password ::us/not-empty-string)
(s/def ::old-password ::us/not-empty-string) (s/def ::old-password ::us/not-empty-string)
(s/def ::theme ::us/string) (s/def ::theme ::us/string)
(s/def ::invitation-token ::us/not-empty-string)
;; --- Mutation: Register Profile
(declare annotate-profile-register) (declare annotate-profile-register)
(declare check-profile-existence!) (declare check-profile-existence!)
(declare create-profile) (declare create-profile)
(declare create-profile-relations) (declare create-profile-relations)
(declare email-domain-in-whitelist?)
(declare register-profile) (declare register-profile)
(s/def ::invitation-token ::us/not-empty-string)
(s/def ::terms-privacy ::us/boolean)
(s/def ::register-profile
(s/keys :req-un [::email ::password ::fullname ::terms-privacy]
:opt-un [::invitation-token]))
(sv/defmethod ::register-profile {:auth false :rlimit :password}
[{:keys [pool tokens session] :as cfg} params]
(when-not (cfg/get :registration-enabled)
(ex/raise :type :restriction
:code :registration-disabled))
(when-let [domains (cfg/get :registration-domain-whitelist)]
(when-not (email-domain-in-whitelist? domains (:email params))
(ex/raise :type :validation
:code :email-domain-is-not-allowed)))
(when-not (:terms-privacy params)
(ex/raise :type :validation
:code :invalid-terms-and-privacy))
(db/with-atomic [conn pool]
(let [cfg (assoc cfg :conn conn)]
(register-profile cfg params))))
(defn- annotate-profile-register
"A helper for properly increase the profile-register metric once the
transaction is completed."
[metrics profile]
(fn []
(when (::created profile)
((get-in metrics [:definitions :profile-register]) :inc))))
(defn- register-profile
[{:keys [conn tokens session metrics] :as cfg} params]
(check-profile-existence! conn params)
(let [profile (->> (create-profile conn params)
(create-profile-relations conn))
profile (assoc profile ::created true)]
(sid/load-initial-project! conn profile)
(if-let [token (:invitation-token params)]
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged).
(let [claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims
:member-id (:id profile)
:member-email (:email profile))
token (tokens :generate claims)
resp {:invitation-token token}]
(with-meta resp
{:transform-response ((:create session) (:id profile))
:before-complete (annotate-profile-register metrics profile)
::audit/props (:props profile)
::audit/profile-id (:id profile)}))
;; If no token is provided, send a verification email
(let [vtoken (tokens :generate
{:iss :verify-email
:exp (dt/in-future "48h")
:profile-id (:id profile)
:email (:email profile)})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
;; Don't allow proceed in register page if the email is
;; already reported as permanent bounced
(when (eml/has-bounce-reports? conn (:email profile))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email has one or many bounces reported"))
(eml/send! {::eml/conn conn
::eml/factory eml/register
:public-uri (:public-uri cfg)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken})
(with-meta profile
{:before-complete (annotate-profile-register metrics profile)
::audit/props (:props profile)
::audit/profile-id (:id profile)})))))
(defn email-domain-in-whitelist? (defn email-domain-in-whitelist?
"Returns true if email's domain is in the given whitelist or if "Returns true if email's domain is in the given whitelist or if
given whitelist is an empty string." given whitelist is an empty string."
@ -177,28 +85,171 @@
{:update false {:update false
:valid false}))) :valid false})))
;; --- MUTATION: Prepare Register
(s/def ::prepare-register-profile
(s/keys :req-un [::email ::password]
:opt-un [::invitation-token]))
(sv/defmethod ::prepare-register-profile {:auth false}
[{:keys [pool tokens] :as cfg} params]
(when-not (cfg/get :registration-enabled)
(ex/raise :type :restriction
:code :registration-disabled))
(when-let [domains (cfg/get :registration-domain-whitelist)]
(when-not (email-domain-in-whitelist? domains (:email params))
(ex/raise :type :validation
:code :email-domain-is-not-allowed)))
;; Don't allow proceed in preparing registration if the profile is
;; already reported as spamer.
(when (eml/has-bounce-reports? pool (:email params))
(ex/raise :type :validation
:code :email-has-permanent-bounces
:hint "looks like the email has one or many bounces reported"))
(check-profile-existence! pool params)
(let [params (assoc params
:backend "penpot"
:iss :prepared-register
:exp (dt/in-future "48h"))
token (tokens :generate params)]
{:token token}))
;; --- MUTATION: Register Profile
(s/def ::accept-terms-and-privacy ::us/boolean)
(s/def ::accept-newsletter-subscription ::us/boolean)
(s/def ::token ::us/not-empty-string)
(s/def ::register-profile
(s/keys :req-un [::token ::fullname
::accept-terms-and-privacy]
:opt-un [::accept-newsletter-subscription]))
(sv/defmethod ::register-profile {:auth false :rlimit :password}
[{:keys [pool tokens session] :as cfg} params]
(when-not (:accept-terms-and-privacy params)
(ex/raise :type :validation
:code :invalid-terms-and-privacy))
(db/with-atomic [conn pool]
(let [cfg (assoc cfg :conn conn)]
(register-profile cfg params))))
(defn- annotate-profile-register
"A helper for properly increase the profile-register metric once the
transaction is completed."
[metrics]
(fn []
((get-in metrics [:definitions :profile-register]) :inc)))
(defn register-profile
[{:keys [conn tokens session metrics] :as cfg} {:keys [token] :as params}]
(let [claims (tokens :verify {:token token :iss :prepared-register})
params (merge params claims)]
(check-profile-existence! conn params)
(let [profile (->> params
(create-profile conn)
(create-profile-relations conn))]
(sid/load-initial-project! conn profile)
(cond
;; If invitation token comes in params, this is because the
;; user comes from team-invitation process; in this case,
;; regenerate token and send back to the user a new invitation
;; token (and mark current session as logged).
(some? (:invitation-token params))
(let [token (:invitation-token params)
claims (tokens :verify {:token token :iss :team-invitation})
claims (assoc claims
:member-id (:id profile)
:member-email (:email profile))
token (tokens :generate claims)
resp {:invitation-token token}]
(with-meta resp
{:transform-response ((:create session) (:id profile))
:before-complete (annotate-profile-register metrics)
::audit/props (:props profile)
::audit/profile-id (:id profile)}))
;; If auth backend is different from "penpot" means user is
;; registring using third party auth mechanism; in this case
;; we need to mark this session as logged.
(not= "penpot" (:auth-backend profile))
(with-meta (profile/strip-private-attrs profile)
{:transform-response ((:create session) (:id profile))
:before-complete (annotate-profile-register metrics)
::audit/props (:props profile)
::audit/profile-id (:id profile)})
;; In all other cases, send a verification email.
:else
(let [vtoken (tokens :generate
{:iss :verify-email
:exp (dt/in-future "48h")
:profile-id (:id profile)
:email (:email profile)})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(eml/send! {::eml/conn conn
::eml/factory eml/register
:public-uri (:public-uri cfg)
:to (:email profile)
:name (:fullname profile)
:token vtoken
:extra-data ptoken})
(with-meta profile
{:before-complete (annotate-profile-register metrics)
::audit/props (:props profile)
::audit/profile-id (:id profile)}))))))
(defn create-profile (defn create-profile
"Create the profile entry on the database with limited input filling "Create the profile entry on the database with limited input filling
all the other fields with defaults." all the other fields with defaults."
[conn {:keys [id fullname email password is-active is-muted is-demo opts deleted-at] [conn params]
:or {is-active false is-muted false is-demo false} (let [id (or (:id params) (uuid/next))
:as params}]
(let [id (or id (uuid/next)) props (-> (extract-props params)
is-active (if is-demo true is-active) (merge (:props params))
props (-> params extract-props db/tjson) (assoc :accept-terms-and-privacy (:accept-terms-and-privacy params true))
password (derive-password password) (assoc :accept-newsletter-subscription (:accept-newsletter-subscription params false))
(db/tjson))
password (if-let [password (:password params)]
(derive-password password)
"!")
locale (as-> (:locale params) locale
(and (string? locale) (not (str/blank? locale)) locale))
backend (:backend params "penpot")
is-demo (:is-demo params false)
is-muted (:is-muted params false)
is-active (:is-active params (or (not= "penpot" backend) is-demo))
email (str/lower (:email params))
params {:id id params {:id id
:fullname fullname :fullname (:fullname params)
:email (str/lower email) :email email
:auth-backend "penpot" :auth-backend backend
:lang locale
:password password :password password
:deleted-at deleted-at :deleted-at (:deleted-at params)
:props props :props props
:is-active is-active :is-active is-active
:is-muted is-muted :is-muted is-muted
:is-demo is-demo}] :is-demo is-demo}]
(try (try
(-> (db/insert! conn :profile params opts) (-> (db/insert! conn :profile params)
(update :props db/decode-transit-pgobject)) (update :props db/decode-transit-pgobject))
(catch org.postgresql.util.PSQLException e (catch org.postgresql.util.PSQLException e
(let [state (.getSQLState e)] (let [state (.getSQLState e)]
@ -231,7 +282,7 @@
(assoc :default-team-id (:id team)) (assoc :default-team-id (:id team))
(assoc :default-project-id (:id project))))) (assoc :default-project-id (:id project)))))
;; --- Mutation: Login ;; --- MUTATION: Login
(s/def ::email ::us/email) (s/def ::email ::us/email)
(s/def ::scope ::us/string) (s/def ::scope ::us/string)
@ -286,7 +337,7 @@
{:transform-response ((:create session) (:id profile)) {:transform-response ((:create session) (:id profile))
::audit/profile-id (:id profile)})))))) ::audit/profile-id (:id profile)}))))))
;; --- Mutation: Logout ;; --- MUTATION: Logout
(s/def ::logout (s/def ::logout
(s/keys :req-un [::profile-id])) (s/keys :req-un [::profile-id]))
@ -296,74 +347,7 @@
(with-meta {} (with-meta {}
{:transform-response (:delete session)})) {:transform-response (:delete session)}))
;; --- MUTATION: Update Profile (own)
;; --- Mutation: Register if not exists
(declare login-or-register)
(s/def ::backend ::us/string)
(s/def ::login-or-register
(s/keys :req-un [::email ::fullname ::backend]))
(sv/defmethod ::login-or-register {:auth false}
[{:keys [pool metrics] :as cfg} params]
(db/with-atomic [conn pool]
(let [profile (-> (assoc cfg :conn conn)
(login-or-register params))
props (merge
(select-keys profile [:backend :fullname :email])
(:props profile))]
(with-meta profile
{:before-complete (annotate-profile-register metrics profile)
::audit/name (if (::created profile) "register" "login")
::audit/props props
::audit/profile-id (:id profile)}))))
(defn login-or-register
[{:keys [conn] :as cfg} {:keys [email] :as params}]
(letfn [(info->lang [{:keys [locale] :as info}]
(when (and (string? locale)
(not (str/blank? locale)))
locale))
(create-profile [conn {:keys [fullname backend email props] :as info}]
(let [params {:id (uuid/next)
:fullname fullname
:email (str/lower email)
:lang (info->lang props)
:auth-backend backend
:is-active true
:password "!"
:props (db/tjson props)
:is-demo false}]
(-> (db/insert! conn :profile params)
(update :props db/decode-transit-pgobject))))
(update-profile [conn info profile]
(let [props (merge (:props profile)
(:props info))]
(db/update! conn :profile
{:props (db/tjson props)
:modified-at (dt/now)}
{:id (:id profile)})
(assoc profile :props props)))
(register-profile [conn params]
(let [profile (->> (create-profile conn params)
(create-profile-relations conn))]
(sid/load-initial-project! conn profile)
(assoc profile ::created true)))]
(let [profile (profile/retrieve-profile-data-by-email conn email)
profile (if profile
(->> profile
(update-profile conn params)
(profile/populate-additional-data conn))
(register-profile conn params))]
(profile/strip-private-attrs profile))))
;; --- Mutation: Update Profile (own)
(defn- update-profile (defn- update-profile
[conn {:keys [id fullname lang theme] :as params}] [conn {:keys [id fullname lang theme] :as params}]
@ -383,7 +367,7 @@
(update-profile conn params) (update-profile conn params)
nil)) nil))
;; --- Mutation: Update Password ;; --- MUTATION: Update Password
(declare validate-password!) (declare validate-password!)
(declare update-profile-password!) (declare update-profile-password!)
@ -412,7 +396,7 @@
{:password (derive-password password)} {:password (derive-password password)}
{:id id})) {:id id}))
;; --- Mutation: Update Photo ;; --- MUTATION: Update Photo
(declare update-profile-photo) (declare update-profile-photo)
@ -447,7 +431,7 @@
nil) nil)
;; --- Mutation: Request Email Change ;; --- MUTATION: Request Email Change
(declare request-email-change) (declare request-email-change)
(declare change-email-inmediatelly) (declare change-email-inmediatelly)
@ -515,7 +499,7 @@
[conn id] [conn id]
(db/get-by-id conn :profile id {:for-update true})) (db/get-by-id conn :profile id {:for-update true}))
;; --- Mutation: Request Profile Recovery ;; --- MUTATION: Request Profile Recovery
(s/def ::request-profile-recovery (s/def ::request-profile-recovery
(s/keys :req-un [::email])) (s/keys :req-un [::email]))
@ -564,7 +548,7 @@
(send-email-notification conn)))))) (send-email-notification conn))))))
;; --- Mutation: Recover Profile ;; --- MUTATION: Recover Profile
(s/def ::token ::us/not-empty-string) (s/def ::token ::us/not-empty-string)
(s/def ::recover-profile (s/def ::recover-profile
@ -585,7 +569,7 @@
(update-password conn)) (update-password conn))
nil))) nil)))
;; --- Mutation: Update Profile Props ;; --- MUTATION: Update Profile Props
(s/def ::props map?) (s/def ::props map?)
(s/def ::update-profile-props (s/def ::update-profile-props
@ -607,7 +591,7 @@
nil))) nil)))
;; --- Mutation: Delete Profile ;; --- MUTATION: Delete Profile
(declare check-can-delete-profile!) (declare check-can-delete-profile!)
(declare mark-profile-as-deleted!) (declare mark-profile-as-deleted!)

View file

@ -73,7 +73,8 @@
(defn decode-profile-row (defn decode-profile-row
[{:keys [props] :as row}] [{:keys [props] :as row}]
(cond-> row (cond-> row
(db/pgobject? props) (assoc :props (db/decode-transit-pgobject props)))) (db/pgobject? props "jsonb")
(assoc :props (db/decode-transit-pgobject props))))
(defn retrieve-profile-data (defn retrieve-profile-data
[conn id] [conn id]

View file

@ -168,126 +168,95 @@
(t/testing "not allowed email domain" (t/testing "not allowed email domain"
(t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com")))))) (t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com"))))))
(t/deftest test-register-with-no-terms-and-privacy (t/deftest prepare-register-and-register-profile
(let [data {::th/type :register-profile (let [data {::th/type :prepare-register-profile
:email "user@example.com" :email "user@example.com"
:password "foobar" :password "foobar"}
:fullname "foobar"
:terms-privacy nil}
out (th/mutation! data) out (th/mutation! data)
error (:error out) token (get-in out [:result :token])]
edata (ex-data error)] (t/is (string? token))
(t/is (th/ex-info? error))
(t/is (= (:type edata) :validation))
(t/is (= (:code edata) :spec-validation))))
(t/deftest test-register-with-bad-terms-and-privacy
(let [data {::th/type :register-profile
:email "user@example.com"
:password "foobar"
:fullname "foobar"
:terms-privacy false}
out (th/mutation! data)
error (:error out)
edata (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type edata) :validation))
(t/is (= (:code edata) :invalid-terms-and-privacy))))
(t/deftest test-register-when-registration-disabled ;; try register without accepting terms
(let [data {::th/type :register-profile
:token token
:fullname "foobar"
:accept-terms-and-privacy false}
out (th/mutation! data)]
(let [error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :invalid-terms-and-privacy))))
;; try register without token
(let [data {::th/type :register-profile
:fullname "foobar"
:accept-terms-and-privacy true}
out (th/mutation! data)]
(let [error (:error out)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :validation))
(t/is (th/ex-of-code? error :spec-validation))))
;; try correct register
(let [data {::th/type :register-profile
:token token
:fullname "foobar"
:accept-terms-and-privacy true
:accept-newsletter-subscription true}]
(let [{:keys [result error]} (th/mutation! data)]
(t/is (nil? error))
(t/is (true? (get-in result [:props :accept-newsletter-subscription])))
(t/is (true? (get-in result [:props :accept-terms-and-privacy])))))
))
(t/deftest prepare-register-with-registration-disabled
(with-mocks [mock {:target 'app.config/get (with-mocks [mock {:target 'app.config/get
:return (th/mock-config-get-with :return (th/mock-config-get-with
{:registration-enabled false})}] {:registration-enabled false})}]
(let [data {::th/type :register-profile
:email "user@example.com"
:password "foobar"
:fullname "foobar"
:terms-privacy true}
out (th/mutation! data)
error (:error out)
edata (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type edata) :restriction))
(t/is (= (:code edata) :registration-disabled)))))
(t/deftest test-register-existing-profile (let [data {::th/type :prepare-register-profile
:email "user@example.com"
:password "foobar"}]
(let [{:keys [result error] :as out} (th/mutation! data)]
(t/is (th/ex-info? error))
(t/is (th/ex-of-type? error :restriction))
(t/is (th/ex-of-code? error :registration-disabled))))))
(t/deftest prepare-register-with-existing-user
(let [profile (th/create-profile* 1) (let [profile (th/create-profile* 1)
data {::th/type :register-profile data {::th/type :prepare-register-profile
:email (:email profile) :email (:email profile)
:password "foobar" :password "foobar"}]
:fullname "foobar" (let [{:keys [result error] :as out} (th/mutation! data)]
:terms-privacy true}
out (th/mutation! data)
error (:error out)
edata (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type edata) :validation))
(t/is (= (:code edata) :email-already-exists))))
(t/deftest test-register-profile
(with-mocks [mock {:target 'app.emails/send!
:return nil}]
(let [pool (:app.db/pool th/*system*)
data {::th/type :register-profile
:email "user@example.com"
:password "foobar"
:fullname "foobar"
:terms-privacy true}
out (th/mutation! data)]
;; (th/print-result! out) ;; (th/print-result! out)
(let [mock (deref mock) (t/is (th/ex-info? error))
[params] (:call-args mock)] (t/is (th/ex-of-type? error :validation))
;; (clojure.pprint/pprint params) (t/is (th/ex-of-code? error :email-already-exists)))))
(t/is (:called? mock))
(t/is (= (:email data) (:to params)))
(t/is (contains? params :extra-data))
(t/is (contains? params :token)))
(let [result (:result out)]
(t/is (false? (:is-demo result)))
(t/is (= (:email data) (:email result)))
(t/is (= "penpot" (:auth-backend result)))
(t/is (= "foobar" (:fullname result)))
(t/is (not (contains? result :password)))))))
(t/deftest test-register-profile-with-bounced-email (t/deftest test-register-profile-with-bounced-email
(with-mocks [mock {:target 'app.emails/send! (let [pool (:app.db/pool th/*system*)
:return nil}] data {::th/type :prepare-register-profile
(let [pool (:app.db/pool th/*system*) :email "user@example.com"
data {::th/type :register-profile :password "foobar"}]
:email "user@example.com"
:password "foobar"
:fullname "foobar"
:terms-privacy true}
_ (th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
out (th/mutation! data)]
;; (th/print-result! out)
(let [mock (deref mock)] (th/create-global-complaint-for pool {:type :bounce :email "user@example.com"})
(t/is (false? (:called? mock))))
(let [error (:error out) (let [{:keys [result error] :as out} (th/mutation! data)]
edata (ex-data error)] (t/is (th/ex-info? error))
(t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :validation))
(t/is (= (:type edata) :validation)) (t/is (th/ex-of-code? error :email-has-permanent-bounces)))))
(t/is (= (:code edata) :email-has-permanent-bounces))))))
(t/deftest test-register-profile-with-complained-email (t/deftest test-register-profile-with-complained-email
(with-mocks [mock {:target 'app.emails/send! :return nil}] (let [pool (:app.db/pool th/*system*)
(let [pool (:app.db/pool th/*system*) data {::th/type :prepare-register-profile
data {::th/type :register-profile :email "user@example.com"
:email "user@example.com" :password "foobar"}]
:password "foobar"
:fullname "foobar"
:terms-privacy true}
_ (th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
out (th/mutation! data)]
(let [mock (deref mock)] (th/create-global-complaint-for pool {:type :complaint :email "user@example.com"})
(t/is (true? (:called? mock)))) (let [{:keys [result error] :as out} (th/mutation! data)]
(t/is (nil? error))
(let [result (:result out)] (t/is (string? (:token result))))))
(t/is (= (:email data) (:email result)))))))
(t/deftest test-email-change-request (t/deftest test-email-change-request
(with-mocks [email-send-mock {:target 'app.emails/send! :return nil} (with-mocks [email-send-mock {:target 'app.emails/send! :return nil}

View file

@ -10,3 +10,4 @@
//var penpotLoginWithLDAP = <true|false>; //var penpotLoginWithLDAP = <true|false>;
//var penpotRegistrationEnabled = <true|false>; //var penpotRegistrationEnabled = <true|false>;
//var penpotAnalyticsEnabled = <true|false>; //var penpotAnalyticsEnabled = <true|false>;
//var penpotFlags = "";

View file

@ -105,6 +105,14 @@ update_analytics_enabled() {
fi fi
} }
update_flags() {
if [ -n "$PENPOT_FLAGS" ]; then
sed -i \
-e "s|^//var penpotFlags = .*;|var penpotFlags = \"$PENPOT_FLAGS\";|g" \
"$1"
fi
}
update_public_uri /var/www/app/js/config.js update_public_uri /var/www/app/js/config.js
update_demo_warning /var/www/app/js/config.js update_demo_warning /var/www/app/js/config.js
update_allow_demo_users /var/www/app/js/config.js update_allow_demo_users /var/www/app/js/config.js
@ -115,5 +123,5 @@ update_oidc_client_id /var/www/app/js/config.js
update_login_with_ldap /var/www/app/js/config.js update_login_with_ldap /var/www/app/js/config.js
update_registration_enabled /var/www/app/js/config.js update_registration_enabled /var/www/app/js/config.js
update_analytics_enabled /var/www/app/js/config.js update_analytics_enabled /var/www/app/js/config.js
update_flags /var/www/app/js/config.js
exec "$@"; exec "$@";

View file

@ -54,6 +54,20 @@
justify-content: center; justify-content: center;
position: relative; position: relative;
input {
margin-bottom: 0px;
}
.buttons-stack {
display: flex;
flex-direction: column;
width: 100%;
*:not(:last-child) {
margin-bottom: $medium;
}
}
.form-container { .form-container {
width: 412px; width: 412px;
@ -83,15 +97,47 @@
} }
.btn-github-auth { .btn-github-auth {
margin-bottom: $medium; margin-bottom: $medium;
text-decoration: none; text-decoration: none;
.logo { .logo {
width: 20px; width: 20px;
height: 20px; height: 20px;
margin-right: 1rem; margin-right: 1rem;
}
}
.separator {
display: flex;
justify-content: center;
width: 100%;
text-transform: uppercase;
}
.links {
display: flex;
font-size: $fs14;
flex-direction: column;
justify-content: space-between;
margin-top: $medium;
margin-bottom: $medium;
&.demo {
justify-content: center;
margin-top: $big;
}
.link-entry {
font-size: $fs14;
color: $color-gray-40;
margin-bottom: 10px;
a {
font-size: $fs14;
color: $color-primary-dark;
} }
} }
}
} }
.terms-login { .terms-login {

View file

@ -109,30 +109,6 @@ textarea {
hr { hr {
border-color: $color-gray-20; border-color: $color-gray-20;
} }
.links {
display: flex;
font-size: $fs14;
flex-direction: column;
justify-content: space-between;
margin-bottom: $medium;
&.demo {
justify-content: center;
margin-top: $big;
}
}
.link-entry {
font-size: $fs14;
color: $color-gray-40;
margin-bottom: 10px;
}
.link-entry a {
font-size: $fs14;
color: $color-primary-dark;
}
} }
.custom-input { .custom-input {

View file

@ -54,6 +54,11 @@
:browser :browser
:webworker)) :webworker))
(defn- parse-flags
[global]
(let [flags (obj/get global "penpotFlags" "")]
(into #{} (map keyword) (str/words flags))))
(defn- parse-version (defn- parse-version
[global] [global]
(-> (obj/get global "penpotVersion") (-> (obj/get global "penpotVersion")
@ -78,6 +83,8 @@
(def themes (obj/get global "penpotThemes")) (def themes (obj/get global "penpotThemes"))
(def analytics (obj/get global "penpotAnalyticsEnabled" false)) (def analytics (obj/get global "penpotAnalyticsEnabled" false))
(def flags (delay (parse-flags global)))
(def version (delay (parse-version global))) (def version (delay (parse-version global)))
(def target (delay (parse-target global))) (def target (delay (parse-target global)))
(def browser (delay (parse-browser))) (def browser (delay (parse-browser)))

View file

@ -221,6 +221,7 @@
;; --- EVENT: register ;; --- EVENT: register
;; TODO: remove
(s/def ::invitation-token ::us/not-empty-string) (s/def ::invitation-token ::us/not-empty-string)
(s/def ::register (s/def ::register

View file

@ -62,6 +62,8 @@
["/login" :auth-login] ["/login" :auth-login]
(when cfg/registration-enabled (when cfg/registration-enabled
["/register" :auth-register]) ["/register" :auth-register])
(when cfg/registration-enabled
["/register/validate" :auth-register-validate])
(when cfg/registration-enabled (when cfg/registration-enabled
["/register/success" :auth-register-success]) ["/register/success" :auth-register-success])
["/recovery/request" :auth-recovery-request] ["/recovery/request" :auth-recovery-request]
@ -112,6 +114,7 @@
(case (:name data) (case (:name data)
(:auth-login (:auth-login
:auth-register :auth-register
:auth-register-validate
:auth-register-success :auth-register-success
:auth-recovery-request :auth-recovery-request
:auth-recovery) :auth-recovery)

View file

@ -14,7 +14,7 @@
[app.main.ui.auth.login :refer [login-page]] [app.main.ui.auth.login :refer [login-page]]
[app.main.ui.auth.recovery :refer [recovery-page]] [app.main.ui.auth.recovery :refer [recovery-page]]
[app.main.ui.auth.recovery-request :refer [recovery-request-page]] [app.main.ui.auth.recovery-request :refer [recovery-request-page]]
[app.main.ui.auth.register :refer [register-page register-success-page]] [app.main.ui.auth.register :refer [register-page register-success-page register-validate-page]]
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.forms :as fm] [app.util.forms :as fm]
@ -36,13 +36,16 @@
[:div.auth [:div.auth
[:section.auth-sidebar [:section.auth-sidebar
[:a.logo {:href "https://penpot.app"} i/logo] [:a.logo {:href "#/"} i/logo]
[:span.tagline (t locale "auth.sidebar-tagline")]] [:span.tagline (t locale "auth.sidebar-tagline")]]
[:section.auth-content [:section.auth-content
(case section (case section
:auth-register :auth-register
[:& register-page {:locale locale :params params}] [:& register-page {:params params}]
:auth-register-validate
[:& register-validate-page {:params params}]
:auth-register-success :auth-register-success
[:& register-success-page {:params params}] [:& register-success-page {:params params}]
@ -55,6 +58,7 @@
:auth-recovery :auth-recovery
[:& recovery-page {:locale locale :params params}]) [:& recovery-page {:locale locale :params params}])
[:div.terms-login [:div.terms-login
[:a {:href "https://penpot.app/terms.html" :target "_blank"} "Terms of service"] [:a {:href "https://penpot.app/terms.html" :target "_blank"} "Terms of service"]
[:span "and"] [:span "and"]

View file

@ -23,6 +23,12 @@
[cljs.spec.alpha :as s] [cljs.spec.alpha :as s]
[rumext.alpha :as mf])) [rumext.alpha :as mf]))
(def show-alt-login-buttons?
(or cfg/google-client-id
cfg/gitlab-client-id
cfg/github-client-id
cfg/oidc-client-id))
(s/def ::email ::us/email) (s/def ::email ::us/email)
(s/def ::password ::us/not-empty-string) (s/def ::password ::us/not-empty-string)
@ -103,13 +109,15 @@
:tab-index "3" :tab-index "3"
:help-icon i/eye :help-icon i/eye
:label (tr "auth.password")}]] :label (tr "auth.password")}]]
[:& fm/submit-button
{:label (tr "auth.login-submit")}]
(when cfg/login-with-ldap [:div.buttons-stack
[:& fm/submit-button [:& fm/submit-button
{:label (tr "auth.login-with-ldap-submit") {:label (tr "auth.login-submit")}]
:on-click on-submit-ldap}])]]))
(when cfg/login-with-ldap
[:& fm/submit-button
{:label (tr "auth.login-with-ldap-submit")
:on-click on-submit-ldap}])]]]))
(mf/defc login-buttons (mf/defc login-buttons
[{:keys [params] :as props}] [{:keys [params] :as props}]
@ -147,6 +155,13 @@
[:& login-form {:params params}] [:& login-form {:params params}]
(when show-alt-login-buttons?
[:*
[:span.separator (tr "labels.or")]
[:div.buttons
[:& login-buttons {:params params}]]])
[:div.links [:div.links
[:div.link-entry [:div.link-entry
[:a {:on-click #(st/emit! (rt/nav :auth-recovery-request))} [:a {:on-click #(st/emit! (rt/nav :auth-recovery-request))}
@ -158,7 +173,6 @@
[:a {:on-click #(st/emit! (rt/nav :auth-register {} params))} [:a {:on-click #(st/emit! (rt/nav :auth-register {} params))}
(tr "auth.register-submit")]])] (tr "auth.register-submit")]])]
[:& login-buttons {:params params}]
(when cfg/allow-demo-users (when cfg/allow-demo-users
[:div.links.demo [:div.links.demo

View file

@ -86,4 +86,4 @@
[:div.links [:div.links
[:div.link-entry [:div.link-entry
[:a {:on-click #(st/emit! (rt/nav :auth-login))} [:a {:on-click #(st/emit! (rt/nav :auth-login))}
(tr "auth.go-back-to-login")]]]]]) (tr "labels.go-back")]]]]])

View file

@ -7,10 +7,11 @@
(ns app.main.ui.auth.register (ns app.main.ui.auth.register
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cfg] [app.config :as cf]
[app.main.data.users :as du] [app.main.data.users :as du]
[app.main.data.messages :as dm] [app.main.data.messages :as dm]
[app.main.store :as st] [app.main.store :as st]
[app.main.repo :as rp]
[app.main.ui.components.forms :as fm] [app.main.ui.components.forms :as fm]
[app.main.ui.icons :as i] [app.main.ui.icons :as i]
[app.main.ui.messages :as msgs] [app.main.ui.messages :as msgs]
@ -30,6 +31,8 @@
{:type :warning {:type :warning
:content (tr "auth.demo-warning")}]) :content (tr "auth.demo-warning")}])
;; --- PAGE: Register
(defn- validate (defn- validate
[data] [data]
(let [password (:password data) (let [password (:password data)
@ -48,9 +51,29 @@
(s/def ::terms-privacy ::us/boolean) (s/def ::terms-privacy ::us/boolean)
(s/def ::register-form (s/def ::register-form
(s/keys :req-un [::password ::fullname ::email ::terms-privacy] (s/keys :req-un [::password ::email]
:opt-un [::invitation-token])) :opt-un [::invitation-token]))
(defn- handle-prepare-register-error
[form error]
(case (:code error)
:registration-disabled
(st/emit! (dm/error (tr "errors.registration-disabled")))
:email-has-permanent-bounces
(let [email (get @form [:data :email])]
(st/emit! (dm/error (tr "errors.email-has-permanent-bounces" email))))
:email-already-exists
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
(st/emit! (dm/error (tr "errors.generic")))))
(defn- handle-prepare-register-success
[form {:keys [token] :as result}]
(st/emit! (rt/nav :auth-register-validate {} {:token token})))
(mf/defc register-form (mf/defc register-form
[{:keys [params] :as props}] [{:keys [params] :as props}]
(let [initial (mf/use-memo (mf/deps params) (constantly params)) (let [initial (mf/use-memo (mf/deps params) (constantly params))
@ -59,49 +82,20 @@
:initial initial) :initial initial)
submitted? (mf/use-state false) submitted? (mf/use-state false)
on-error
(mf/use-callback
(fn [form error]
(reset! submitted? false)
(case (:code error)
:registration-disabled
(rx/of (dm/error (tr "errors.registration-disabled")))
:email-has-permanent-bounces
(let [email (get @form [:data :email])]
(rx/of (dm/error (tr "errors.email-has-permanent-bounces" email))))
:email-already-exists
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
(rx/throw error))))
on-success
(mf/use-callback
(fn [form data]
(reset! submitted? false)
(if-let [token (:invitation-token data)]
(st/emit! (rt/nav :auth-verify-token {} {:token token}))
(st/emit! (rt/nav :auth-register-success {} {:email (:email data)})))))
on-submit on-submit
(mf/use-callback (mf/use-callback
(fn [form event] (fn [form event]
(reset! submitted? true) (reset! submitted? true)
(let [data (with-meta (:clean-data @form) (let [params (:clean-data @form)]
{:on-error (partial on-error form) (->> (rp/mutation :prepare-register-profile params)
:on-success (partial on-success form)})] (rx/finalize #(reset! submitted? false))
(st/emit! (du/register data)))))] (rx/subs (partial handle-prepare-register-success form)
(partial handle-prepare-register-error form))))))
]
[:& fm/form {:on-submit on-submit [:& fm/form {:on-submit on-submit
:form form} :form form}
[:div.fields-row
[:& fm/input {:name :fullname
:tab-index "1"
:label (tr "auth.fullname")
:type "text"}]]
[:div.fields-row [:div.fields-row
[:& fm/input {:type "email" [:& fm/input {:type "email"
:name :email :name :email
@ -115,18 +109,145 @@
:label (tr "auth.password") :label (tr "auth.password")
:type "password"}]] :type "password"}]]
[:& fm/submit-button
{:label (tr "auth.register-submit")
:disabled @submitted?}]]))
(mf/defc register-page
[{:keys [params] :as props}]
[:div.form-container
[:h1 (tr "auth.register-title")]
[:div.subtitle (tr "auth.register-subtitle")]
(when cf/demo-warning
[:& demo-warning])
[:& register-form {:params params}]
(when login/show-alt-login-buttons?
[:*
[:span.separator (tr "labels.or")]
[:div.buttons
[:& login/login-buttons {:params params}]]])
[:div.links
[:div.link-entry
[:span (tr "auth.already-have-account") " "]
[:a {:on-click #(st/emit! (rt/nav :auth-login {} params))
:tab-index "4"}
(tr "auth.login-here")]]
(when cf/allow-demo-users
[:div.link-entry
[:span (tr "auth.create-demo-profile") " "]
[:a {:on-click #(st/emit! (du/create-demo-profile))
:tab-index "5"}
(tr "auth.create-demo-account")]])]])
;; --- PAGE: register validation
(defn- handle-register-error
[form error]
(case (:code error)
:registration-disabled
(st/emit! (dm/error (tr "errors.registration-disabled")))
:email-has-permanent-bounces
(let [email (get @form [:data :email])]
(st/emit! (dm/error (tr "errors.email-has-permanent-bounces" email))))
:email-already-exists
(swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"})
(do
(println (:explain error))
(st/emit! (dm/error (tr "errors.generic"))))))
(defn- handle-register-success
[form data]
(cond
(some? (:invitation-token data))
(let [token (:invitation-token data)]
(st/emit! (rt/nav :auth-verify-token {} {:token token})))
(not= "penpot" (:auth-backend data))
(st/emit!
(du/fetch-profile)
(rt/nav :dashboard-projects {:team-id (:default-team-id data)}))
:else
(st/emit! (rt/nav :auth-register-success {} {:email (:email data)}))))
(s/def ::accept-terms-and-privacy ::us/boolean)
(s/def ::accept-newsletter-subscription ::us/boolean)
(s/def ::register-validate-form
(s/keys :req-un [::token ::fullname ::accept-terms-and-privacy]
:opt-un [::accept-newsletter-subscription]))
(mf/defc register-validate-form
[{:keys [params] :as props}]
(let [initial (mf/use-memo
(mf/deps params)
(fn []
(assoc params :accept-newsletter-subscription false)))
form (fm/use-form :spec ::register-validate-form
:initial initial)
submitted? (mf/use-state false)
on-submit
(mf/use-callback
(fn [form event]
(reset! submitted? true)
(let [params (:clean-data @form)]
(->> (rp/mutation :register-profile params)
(rx/finalize #(reset! submitted? false))
(rx/subs (partial handle-register-success form)
(partial handle-register-error form))))))
]
[:& fm/form {:on-submit on-submit
:form form}
[:div.fields-row [:div.fields-row
[:& fm/input {:name :terms-privacy [:& fm/input {:name :fullname
:tab-index "1"
:label (tr "auth.fullname")
:type "text"}]]
[:div.fields-row
[:& fm/input {:name :accept-terms-and-privacy
:class "check-primary" :class "check-primary"
:tab-index "4"
:label (tr "auth.terms-privacy-agreement") :label (tr "auth.terms-privacy-agreement")
:type "checkbox"}]] :type "checkbox"}]]
(when (contains? @cf/flags :show-newsletter-check-on-register-validation)
[:div.fields-row
[:& fm/input {:name :accept-newsletter-subscription
:class "check-primary"
:label (tr "auth.terms-privacy-agreement")
:type "checkbox"}]])
[:& fm/submit-button [:& fm/submit-button
{:label (tr "auth.register-submit") {:label (tr "auth.register-submit")
:disabled @submitted?}]])) :disabled @submitted?}]]))
;; --- Register Page
(mf/defc register-validate-page
[{:keys [params] :as props}]
(prn "register-validate-page" params)
[:div.form-container
[:h1 (tr "auth.register-title")]
[:div.subtitle (tr "auth.register-subtitle")]
[:& register-validate-form {:params params}]
[:div.links
[:div.link-entry
[:a {:on-click #(st/emit! (rt/nav :auth-register {} {}))
:tab-index "4"}
(tr "labels.go-back")]]]])
(mf/defc register-success-page (mf/defc register-success-page
[{:keys [params] :as props}] [{:keys [params] :as props}]
@ -136,32 +257,3 @@
[:div.notification-text-email (:email params "")] [:div.notification-text-email (:email params "")]
[:div.notification-text (tr "auth.check-your-email")]]) [:div.notification-text (tr "auth.check-your-email")]])
(mf/defc register-page
[{:keys [params] :as props}]
[:div.form-container
[:h1 (tr "auth.register-title")]
[:div.subtitle (tr "auth.register-subtitle")]
(when cfg/demo-warning
[:& demo-warning])
[:& register-form {:params params}]
[:div.links
[:div.link-entry
[:span (tr "auth.already-have-account") " "]
[:a {:on-click #(st/emit! (rt/nav :auth-login {} params))
:tab-index "4"}
(tr "auth.login-here")]]
(when cfg/allow-demo-users
[:div.link-entry
[:span (tr "auth.create-demo-profile") " "]
[:a {:on-click #(st/emit! (du/create-demo-profile))
:tab-index "5"}
(tr "auth.create-demo-account")]])
[:& login/login-buttons {:params params}]]])

View file

@ -48,10 +48,6 @@ msgstr "هل نسيت كلمة السر؟"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "الاسم بالكامل" msgstr "الاسم بالكامل"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "الرجوع للخلف!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "تسجيل الدخول هنا" msgstr "تسجيل الدخول هنا"
@ -190,6 +186,9 @@ msgstr "نمط"
msgid "labels.fonts" msgid "labels.fonts"
msgstr "الخطوط" msgstr "الخطوط"
msgid "labels.go-back"
msgstr "الرجوع للخلف"
msgid "labels.images" msgid "labels.images"
msgstr "الصور" msgstr "الصور"

View file

@ -46,10 +46,6 @@ msgstr "Has oblidat la contrasenya?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Nom complet" msgstr "Nom complet"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Tornar"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Inicia sessió aquí" msgstr "Inicia sessió aquí"
@ -467,6 +463,9 @@ msgstr "S'ha produït un error"
msgid "labels.accept" msgid "labels.accept"
msgstr "Acceptar" msgstr "Acceptar"
msgid "labels.go-back"
msgstr "Tornar"
msgid "labels.recent" msgid "labels.recent"
msgstr "Recent" msgstr "Recent"

View file

@ -51,10 +51,6 @@ msgstr "Glemt adgangskode?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Fulde Navn" msgstr "Fulde Navn"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Gå tilbage!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Log på her" msgstr "Log på her"
@ -452,6 +448,9 @@ msgstr "Stil"
msgid "labels.fonts" msgid "labels.fonts"
msgstr "Skrifttyper" msgstr "Skrifttyper"
msgid "labels.go-back"
msgstr "Gå tilbage!"
msgid "labels.installed-fonts" msgid "labels.installed-fonts"
msgstr "Installeret skrifttyper" msgstr "Installeret skrifttyper"

View file

@ -51,10 +51,6 @@ msgstr "Passwort vergessen?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Vollständiger Name" msgstr "Vollständiger Name"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Zurück!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Hier einloggen" msgstr "Hier einloggen"
@ -823,6 +819,9 @@ msgstr "Feedback gesendet"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Feedback geben" msgstr "Feedback geben"
msgid "labels.go-back"
msgstr "Zurück!"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "Erledigte Kommentare ausblenden" msgstr "Erledigte Kommentare ausblenden"
@ -1306,6 +1305,10 @@ msgstr "Seite bearbeiten"
msgid "viewer.header.fullscreen" msgid "viewer.header.fullscreen"
msgstr "Vollbildmodus" msgstr "Vollbildmodus"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interaktionen"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.share.copy-link" msgid "viewer.header.share.copy-link"
msgstr "Link kopieren" msgstr "Link kopieren"
@ -1330,10 +1333,6 @@ msgstr "Jeder mit dem Link hat Zugriff"
msgid "viewer.header.share.title" msgid "viewer.header.share.title"
msgstr "Prototyp teilen" msgstr "Prototyp teilen"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interaktionen"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.show-interactions" msgid "viewer.header.show-interactions"
msgstr "Interaktionen anzeigen" msgstr "Interaktionen anzeigen"

View file

@ -46,10 +46,6 @@ msgstr "Ξεχάσατε τον κωδικό;"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Πλήρες όνομα" msgstr "Πλήρες όνομα"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Πίσω"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Συνδεθείτε εδώ" msgstr "Συνδεθείτε εδώ"
@ -823,6 +819,9 @@ msgstr "Εστάλη γνώμη"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Δώστε μας τη γνώμη σας" msgstr "Δώστε μας τη γνώμη σας"
msgid "labels.go-back"
msgstr "Πίσω"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "Απόκρυψη επιλυμένων σχολίων" msgstr "Απόκρυψη επιλυμένων σχολίων"

View file

@ -49,10 +49,6 @@ msgstr "Forgot password?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Full Name" msgstr "Full Name"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Go back!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Login here" msgstr "Login here"
@ -206,6 +202,12 @@ msgstr "Duplicate %s files"
msgid "dashboard.empty-files" msgid "dashboard.empty-files"
msgstr "You still have no files here" msgstr "You still have no files here"
msgid "dashboard.export-multi"
msgstr "Export %s files"
msgid "dashboard.export-single"
msgstr "Export file"
msgid "dashboard.fonts.deleted-placeholder" msgid "dashboard.fonts.deleted-placeholder"
msgstr "Font deleted" msgstr "Font deleted"
@ -229,6 +231,9 @@ msgstr ""
"Service](https://penpot.app/terms.html). You also might want to read about " "Service](https://penpot.app/terms.html). You also might want to read about "
"[font licensing](https://www.typography.com/faq)." "[font licensing](https://www.typography.com/faq)."
msgid "dashboard.import"
msgstr "Import files"
#: src/app/main/ui/dashboard/team.cljs #: src/app/main/ui/dashboard/team.cljs
msgid "dashboard.invite-profile" msgid "dashboard.invite-profile"
msgstr "Invite to team" msgstr "Invite to team"
@ -304,6 +309,9 @@ msgstr "%s members"
msgid "dashboard.open-in-new-tab" msgid "dashboard.open-in-new-tab"
msgstr "Open file in a new tab" msgstr "Open file in a new tab"
msgid "dashboard.options"
msgstr "Options"
#: src/app/main/ui/settings/password.cljs #: src/app/main/ui/settings/password.cljs
msgid "dashboard.password-change" msgid "dashboard.password-change"
msgstr "Change password" msgstr "Change password"
@ -913,6 +921,9 @@ msgstr "Fonts"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Give feedback" msgstr "Give feedback"
msgid "labels.go-back"
msgstr "Go back"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "Hide resolved comments" msgstr "Hide resolved comments"
@ -1000,6 +1011,9 @@ msgstr "Old password"
msgid "labels.only-yours" msgid "labels.only-yours"
msgstr "Only yours" msgstr "Only yours"
msgid "labels.or"
msgstr "or"
#: src/app/main/ui/dashboard/team.cljs, src/app/main/ui/dashboard/team.cljs #: src/app/main/ui/dashboard/team.cljs, src/app/main/ui/dashboard/team.cljs
msgid "labels.owner" msgid "labels.owner"
msgstr "Owner" msgstr "Owner"
@ -1461,6 +1475,10 @@ msgstr "Edit file"
msgid "viewer.header.fullscreen" msgid "viewer.header.fullscreen"
msgstr "Full Screen" msgstr "Full Screen"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interactions"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.share.copy-link" msgid "viewer.header.share.copy-link"
msgstr "Copy link" msgstr "Copy link"
@ -1485,10 +1503,6 @@ msgstr "Anyone with the link will have access"
msgid "viewer.header.share.title" msgid "viewer.header.share.title"
msgstr "Share prototype" msgstr "Share prototype"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interactions"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.show-interactions" msgid "viewer.header.show-interactions"
msgstr "Show interactions" msgstr "Show interactions"
@ -1889,20 +1903,28 @@ msgid "workspace.options.constraints"
msgstr "Constraints" msgstr "Constraints"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.left" msgid "workspace.options.constraints.bottom"
msgstr "Left" msgstr "Bottom"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.right" msgid "workspace.options.constraints.center"
msgstr "Right" msgstr "Center"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.fix-when-scrolling"
msgstr "Fix when scrolling"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.left"
msgstr "Left"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.leftright" msgid "workspace.options.constraints.leftright"
msgstr "Left & Right" msgstr "Left & Right"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.center" msgid "workspace.options.constraints.right"
msgstr "Center" msgstr "Right"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.scale" msgid "workspace.options.constraints.scale"
@ -1912,26 +1934,10 @@ msgstr "Scale"
msgid "workspace.options.constraints.top" msgid "workspace.options.constraints.top"
msgstr "Top" msgstr "Top"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.bottom"
msgstr "Bottom"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.topbottom" msgid "workspace.options.constraints.topbottom"
msgstr "Top & Bottom" msgstr "Top & Bottom"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.center"
msgstr "Center"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.scale"
msgstr "Scale"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.fix-when-scrolling"
msgstr "Fix when scrolling"
#: src/app/main/ui/workspace/sidebar/options.cljs #: src/app/main/ui/workspace/sidebar/options.cljs
msgid "workspace.options.design" msgid "workspace.options.design"
msgstr "Design" msgstr "Design"
@ -2686,16 +2692,4 @@ msgid "workspace.updates.update"
msgstr "Update" msgstr "Update"
msgid "workspace.viewport.click-to-close-path" msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the path" msgstr "Click to close the path"
msgid "dashboard.export-single"
msgstr "Export file"
msgid "dashboard.export-multi"
msgstr "Export %s files"
msgid "dashboard.import"
msgstr "Import files"
msgid "dashboard.options"
msgstr "Options"

View file

@ -51,10 +51,6 @@ msgstr "¿Olvidaste tu contraseña?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Nombre completo" msgstr "Nombre completo"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Volver"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Entra aquí" msgstr "Entra aquí"
@ -915,6 +911,9 @@ msgstr "Fuentes"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Danos tu opinión" msgstr "Danos tu opinión"
msgid "labels.go-back"
msgstr "Volver"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "Ocultar comentarios resueltos" msgstr "Ocultar comentarios resueltos"
@ -1002,6 +1001,9 @@ msgstr "Contraseña anterior"
msgid "labels.only-yours" msgid "labels.only-yours"
msgstr "Sólo los tuyos" msgstr "Sólo los tuyos"
msgid "labels.or"
msgstr "o"
#: src/app/main/ui/dashboard/team.cljs, src/app/main/ui/dashboard/team.cljs #: src/app/main/ui/dashboard/team.cljs, src/app/main/ui/dashboard/team.cljs
msgid "labels.owner" msgid "labels.owner"
msgstr "Dueño" msgstr "Dueño"
@ -1451,6 +1453,10 @@ msgstr "Editar archivo"
msgid "viewer.header.fullscreen" msgid "viewer.header.fullscreen"
msgstr "Pantalla completa" msgstr "Pantalla completa"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interacciones"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.share.copy-link" msgid "viewer.header.share.copy-link"
msgstr "Copiar enlace" msgstr "Copiar enlace"
@ -1475,10 +1481,6 @@ msgstr "Cualquiera con el enlace podrá acceder"
msgid "viewer.header.share.title" msgid "viewer.header.share.title"
msgstr "Compartir prototipo" msgstr "Compartir prototipo"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interacciones"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.show-interactions" msgid "viewer.header.show-interactions"
msgstr "Mostrar interacciones" msgstr "Mostrar interacciones"
@ -1881,20 +1883,28 @@ msgid "workspace.options.constraints"
msgstr "Restricciones" msgstr "Restricciones"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.left" msgid "workspace.options.constraints.bottom"
msgstr "Izquierda" msgstr "Abajo"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.right" msgid "workspace.options.constraints.center"
msgstr "Derecha" msgstr "Centro"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.fix-when-scrolling"
msgstr "Fijo al desplazar"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.left"
msgstr "Izquierda"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.leftright" msgid "workspace.options.constraints.leftright"
msgstr "Izq. y Der." msgstr "Izq. y Der."
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.center" msgid "workspace.options.constraints.right"
msgstr "Centro" msgstr "Derecha"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.scale" msgid "workspace.options.constraints.scale"
@ -1904,26 +1914,10 @@ msgstr "Escalar"
msgid "workspace.options.constraints.top" msgid "workspace.options.constraints.top"
msgstr "Arriba" msgstr "Arriba"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.bottom"
msgstr "Abajo"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.topbottom" msgid "workspace.options.constraints.topbottom"
msgstr "Arriba y Abajo" msgstr "Arriba y Abajo"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.center"
msgstr "Centro"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.scale"
msgstr "Escalar"
#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs
msgid "workspace.options.constraints.fix-when-scrolling"
msgstr "Fijo al desplazar"
#: src/app/main/ui/workspace/sidebar/options.cljs #: src/app/main/ui/workspace/sidebar/options.cljs
msgid "workspace.options.design" msgid "workspace.options.design"
msgstr "Diseño" msgstr "Diseño"
@ -2680,4 +2674,4 @@ msgid "workspace.updates.update"
msgstr "Actualizar" msgstr "Actualizar"
msgid "workspace.viewport.click-to-close-path" msgid "workspace.viewport.click-to-close-path"
msgstr "Pulsar para cerrar la ruta" msgstr "Pulsar para cerrar la ruta"

View file

@ -51,10 +51,6 @@ msgstr "Mot de passe oublié?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Nom complet" msgstr "Nom complet"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Retour!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Se connecter ici" msgstr "Se connecter ici"
@ -737,6 +733,9 @@ msgstr "Adresse email"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Donnez votre avis" msgstr "Donnez votre avis"
msgid "labels.go-back"
msgstr "Retour"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "Masquer les commentaires résolus" msgstr "Masquer les commentaires résolus"
@ -1202,6 +1201,10 @@ msgstr "Modifier la page"
msgid "viewer.header.fullscreen" msgid "viewer.header.fullscreen"
msgstr "Plein écran" msgstr "Plein écran"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interactions"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.share.copy-link" msgid "viewer.header.share.copy-link"
msgstr "Copier le lien" msgstr "Copier le lien"
@ -1226,10 +1229,6 @@ msgstr "Toute personne disposant du lien aura accès"
msgid "viewer.header.share.title" msgid "viewer.header.share.title"
msgstr "Partager le prototype" msgstr "Partager le prototype"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interactions"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.show-interactions" msgid "viewer.header.show-interactions"
msgstr "Afficher les interactions" msgstr "Afficher les interactions"

View file

@ -51,10 +51,6 @@ msgstr "Esqueceu a senha?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Nome completo" msgstr "Nome completo"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Voltar!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Entrar aqui" msgstr "Entrar aqui"
@ -697,6 +693,9 @@ msgstr "Fontes"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Enviar feedback" msgstr "Enviar feedback"
msgid "labels.go-back"
msgstr "Voltar"
msgid "labels.icons" msgid "labels.icons"
msgstr "Ícones" msgstr "Ícones"

View file

@ -52,10 +52,6 @@ msgstr "Ai uitat parola?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Numele complet" msgstr "Numele complet"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Întoarce-te!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Conectează-te" msgstr "Conectează-te"
@ -907,6 +903,9 @@ msgstr "Fonturi"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Lasă un feedback" msgstr "Lasă un feedback"
msgid "labels.go-back"
msgstr "Întoarce-te"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "Ascunde comentariile rezolvate" msgstr "Ascunde comentariile rezolvate"
@ -1441,6 +1440,10 @@ msgstr "Editează pagina"
msgid "viewer.header.fullscreen" msgid "viewer.header.fullscreen"
msgstr "Ecran complet" msgstr "Ecran complet"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interacţiunile"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.share.copy-link" msgid "viewer.header.share.copy-link"
msgstr "Copiază link" msgstr "Copiază link"
@ -1469,10 +1472,6 @@ msgstr "Distribuie link"
msgid "viewer.header.show-interactions" msgid "viewer.header.show-interactions"
msgstr "Afişează interacţiunile" msgstr "Afişează interacţiunile"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.interactions"
msgstr "Interacţiunile"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.show-interactions-on-click" msgid "viewer.header.show-interactions-on-click"
msgstr "Afişează interacţiunile la click" msgstr "Afişează interacţiunile la click"

View file

@ -40,10 +40,6 @@ msgstr "Забыли пароль?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Полное имя" msgstr "Полное имя"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Назад!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Войти здесь" msgstr "Войти здесь"
@ -359,6 +355,9 @@ msgstr "Email"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Дать обратную связь" msgstr "Дать обратную связь"
msgid "labels.go-back"
msgstr "Назад"
msgid "labels.icons" msgid "labels.icons"
msgstr "Иконки" msgstr "Иконки"
@ -563,6 +562,10 @@ msgstr "На странице не найдено ни одного кадра"
msgid "viewer.frame-not-found" msgid "viewer.frame-not-found"
msgstr "Кадры не найдены." msgstr "Кадры не найдены."
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header-interactions"
msgstr "взаимодействия"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.dont-show-interactions" msgid "viewer.header.dont-show-interactions"
msgstr "Не показывать взаимодействия" msgstr "Не показывать взаимодействия"
@ -599,10 +602,6 @@ msgstr "Любой, у кого есть ссылка будет иметь до
msgid "viewer.header.share.title" msgid "viewer.header.share.title"
msgstr "Поделиться ссылкой" msgstr "Поделиться ссылкой"
#: src/app/main/ui/viewer/header.cljs
msgid "viewer.header-interactions"
msgstr "взаимодействия"
#: src/app/main/ui/viewer/header.cljs #: src/app/main/ui/viewer/header.cljs
msgid "viewer.header.show-interactions" msgid "viewer.header.show-interactions"
msgstr "Показывать взаимодействия" msgstr "Показывать взаимодействия"

View file

@ -51,10 +51,6 @@ msgstr "Parolanı mı unuttun?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "Tam Adın" msgstr "Tam Adın"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "Geri dön!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "Buradan giriş yap" msgstr "Buradan giriş yap"
@ -739,6 +735,9 @@ msgstr "Fontlar"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "Geri bildirimde bulun" msgstr "Geri bildirimde bulun"
msgid "labels.go-back"
msgstr "Geri dön"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "Çözülmüş yorumları gizle" msgstr "Çözülmüş yorumları gizle"

View file

@ -42,10 +42,6 @@ msgstr "忘记密码?"
msgid "auth.fullname" msgid "auth.fullname"
msgstr "全名" msgstr "全名"
#: src/app/main/ui/auth/recovery_request.cljs
msgid "auth.go-back-to-login"
msgstr "返回!"
#: src/app/main/ui/auth/register.cljs #: src/app/main/ui/auth/register.cljs
msgid "auth.login-here" msgid "auth.login-here"
msgstr "在这里登录" msgstr "在这里登录"
@ -751,6 +747,9 @@ msgstr "反馈已发出"
msgid "labels.give-feedback" msgid "labels.give-feedback"
msgstr "提交反馈" msgstr "提交反馈"
msgid "labels.go-back"
msgstr "返回"
#: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs #: src/app/main/ui/workspace/comments.cljs, src/app/main/ui/viewer/header.cljs
msgid "labels.hide-resolved-comments" msgid "labels.hide-resolved-comments"
msgstr "隐藏已决定的评论" msgstr "隐藏已决定的评论"