diff --git a/backend/resources/migrations/0002.users.sql b/backend/resources/migrations/0002.users.sql index ee2f1953e7..1e7ded169f 100644 --- a/backend/resources/migrations/0002.users.sql +++ b/backend/resources/migrations/0002.users.sql @@ -6,7 +6,6 @@ CREATE TABLE users ( deleted_at timestamptz NULL, fullname text NOT NULL DEFAULT '', - username text NOT NULL, email text NOT NULL, photo text NOT NULL, password text NOT NULL, @@ -15,10 +14,6 @@ CREATE TABLE users ( is_demo boolean NOT NULL DEFAULT false ); -CREATE UNIQUE INDEX users__username__idx - ON users (username) - WHERE deleted_at IS null; - CREATE UNIQUE INDEX users__email__idx ON users (email) WHERE deleted_at IS null; @@ -87,10 +82,9 @@ CREATE INDEX sessions__user_id__idx -- Insert a placeholder system user. -INSERT INTO users (id, fullname, username, email, photo, password) +INSERT INTO users (id, fullname, email, photo, password) VALUES ('00000000-0000-0000-0000-000000000000'::uuid, 'System User', - '00000000-0000-0000-0000-000000000000', 'system@uxbox.io', '', '!'); diff --git a/backend/src/uxbox/images.clj b/backend/src/uxbox/images.clj index 158d04aa5c..9af8636de1 100644 --- a/backend/src/uxbox/images.clj +++ b/backend/src/uxbox/images.clj @@ -55,8 +55,8 @@ width 200 height 200} :as opts}] - ;; (us/verify ::thumbnail-opts opts) - (us/verify fs/path? input) + (us/assert ::thumbnail-opts opts) + (us/assert fs/path? input) (let [ext (format->extension format) tmp (fs/create-tempfile :suffix ext) opr (doto (IMOperation.) @@ -80,6 +80,33 @@ (fs/delete tmp) (ByteArrayInputStream. thumbnail-data))))) +(defn generate-thumbnail2 + ([input] (generate-thumbnail input nil)) + ([input {:keys [quality format width height] + :or {format "jpeg" + quality 92 + width 200 + height 200} + :as opts}] + (us/assert ::thumbnail-opts opts) + (us/assert fs/path? input) + (let [ext (format->extension format) + tmp (fs/create-tempfile :suffix ext) + opr (doto (IMOperation.) + (.addImage) + (.autoOrient) + (.strip) + (.thumbnail (int width) (int height) "^") + (.gravity "center") + (.extent (int width) (int height)) + (.quality (double quality)) + (.addImage))] + (doto (ConvertCmd.) + (.run opr (into-array (map str [input tmp])))) + (let [thumbnail-data (fs/slurp-bytes tmp)] + (fs/delete tmp) + (ByteArrayInputStream. thumbnail-data))))) + (defn info [path] (let [instance (Info. (str path))] @@ -96,3 +123,19 @@ row (let [url (ust/public-uri media/media-storage value)] (assoc-in row dst (str url)))))) + +(defn- resolve-uri + [storage row src dst] + (let [src (if (vector? src) src [src]) + dst (if (vector? dst) dst [dst]) + value (get-in row src)] + (if (empty? value) + row + (let [url (ust/public-uri media/media-storage value)] + (assoc-in row dst (str url)))))) + +(defn resolve-media-uris + [row & pairs] + (us/assert map? row) + (us/assert (s/coll-of vector?) pairs) + (reduce #(resolve-uri media/media-storage %1 (nth %2 0) (nth %2 1)) row pairs)) diff --git a/backend/src/uxbox/services/mutations/demo.clj b/backend/src/uxbox/services/mutations/demo.clj index 37b398ea32..f11f64b3d7 100644 --- a/backend/src/uxbox/services/mutations/demo.clj +++ b/backend/src/uxbox/services/mutations/demo.clj @@ -33,8 +33,8 @@ [vertx.core :as vc])) (def sql:insert-user - "insert into users (id, fullname, username, email, password, photo, is_demo) - values ($1, $2, $3, $4, $5, '', true) returning *") + "insert into users (id, fullname, email, password, photo, is_demo) + values ($1, $2, $3, $4, '', true) returning *") (def sql:insert-email "insert into user_emails (user_id, email, is_main) @@ -44,14 +44,13 @@ [params] (let [id (uuid/next) sem (System/currentTimeMillis) - username (str "demo-" sem) - email (str username ".demo@uxbox.io") + email (str "demo-" sem ".demo@nodomain.com") fullname (str "Demo User " sem) password (-> (sodi.prng/random-bytes 12) (sodi.util/bytes->b64s)) password' (sodi.pwhash/derive password)] (db/with-atomic [conn db/pool] - (db/query-one conn [sql:insert-user id fullname username email password']) + (db/query-one conn [sql:insert-user id fullname email password']) (db/query-one conn [sql:insert-email id email]) - {:username username + {:email email :password password}))) diff --git a/backend/src/uxbox/services/mutations/profile.clj b/backend/src/uxbox/services/mutations/profile.clj index bdfc8179ce..2f3ee267ae 100644 --- a/backend/src/uxbox/services/mutations/profile.clj +++ b/backend/src/uxbox/services/mutations/profile.clj @@ -27,8 +27,10 @@ [uxbox.services.mutations :as sm] [uxbox.services.util :as su] [uxbox.services.queries.profile :as profile] + [uxbox.services.mutations.images :as imgs] [uxbox.util.blob :as blob] [uxbox.util.uuid :as uuid] + [uxbox.util.storage :as ust] [vertx.core :as vc])) ;; --- Helpers & Specs @@ -36,36 +38,25 @@ (s/def ::email ::us/email) (s/def ::fullname ::us/string) (s/def ::lang ::us/string) -(s/def ::old-password ::us/string) -(s/def ::password ::us/string) (s/def ::path ::us/string) (s/def ::user ::us/uuid) -(s/def ::username ::us/string) +(s/def ::password ::us/string) +(s/def ::old-password ::us/string) -;; --- Utilities - -(def sql:user-by-username-or-email - "select u.* - from users as u - where u.username=$1 or u.email=$1 - and u.deleted_at is null") - -(defn- retrieve-user - [conn username] - (db/query-one conn [sql:user-by-username-or-email username])) ;; --- Mutation: Login -(s/def ::username ::us/string) -(s/def ::password ::us/string) +(declare retrieve-user) + +(s/def ::email ::us/email) (s/def ::scope ::us/string) (s/def ::login - (s/keys :req-un [::username ::password] + (s/keys :req-un [::email ::password] :opt-un [::scope])) (sm/defmutation ::login - [{:keys [username password scope] :as params}] + [{:keys [email password scope] :as params}] (letfn [(check-password [user password] (let [result (sodi.pwhash/verify password (:password user))] (:valid result))) @@ -79,9 +70,20 @@ :code ::wrong-credentials)) {:id (:id user)})] - (-> (retrieve-user db/pool username) + (-> (retrieve-user db/pool email) (p/then' check-user)))) +(def sql:user-by-email + "select u.* + from users as u + where u.email=$1 + and u.deleted_at is null") + +(defn- retrieve-user + [conn email] + (db/query-one conn [sql:user-by-email email])) + + ;; --- Mutation: Add additional email ;; TODO @@ -93,65 +95,39 @@ ;; --- Mutation: Update Profile (own) -(defn- check-username-and-email! - [conn {:keys [id username email] :as params}] - (let [sql1 "select exists - (select * from users - where username = $2 - and id != $1 - ) as val" - sql2 "select exists - (select * from users - where email = $2 - and id != $1 - ) as val"] - (p/let [res1 (db/query-one conn [sql1 id username]) - res2 (db/query-one conn [sql2 id email])] - (when (:val res1) - (ex/raise :type :validation - :code ::username-already-exists)) - (when (:val res2) - (ex/raise :type :validation - :code ::email-already-exists)) - params))) - -(def sql:update-profile +(def ^:private sql:update-profile "update users - set username = $2, - fullname = $3, - lang = $4 + set fullname = $2, + lang = $3 where id = $1 and deleted_at is null returning *") (defn- update-profile - [conn {:keys [id username fullname lang] :as params}] - (let [sqlv [sql:update-profile - id username fullname lang]] + [conn {:keys [id fullname lang] :as params}] + (let [sqlv [sql:update-profile id fullname lang]] (-> (db/query-one conn sqlv) (p/then' su/raise-not-found-if-nil) (p/then' profile/strip-private-attrs)))) (s/def ::update-profile - (s/keys :req-un [::id ::username ::fullname ::lang])) + (s/keys :req-un [::id ::fullname ::lang])) (sm/defmutation ::update-profile [params] (db/with-atomic [conn db/pool] - (-> (p/resolved params) - (p/then (partial check-username-and-email! conn)) - (p/then (partial update-profile conn))))) + (update-profile conn params))) + ;; --- Mutation: Update Password -(defn- validate-password +(defn- validate-password! [conn {:keys [user old-password] :as params}] (p/let [profile (profile/retrieve-profile conn user) result (sodi.pwhash/verify old-password (:password profile))] (when-not (:valid result) (ex/raise :type :validation - :code ::old-password-not-match)) - params)) + :code ::old-password-not-match)))) (defn update-password [conn {:keys [user password]}] @@ -159,99 +135,112 @@ set password = $2 where id = $1 and deleted_at is null - returning id"] + returning id" + password (sodi.pwhash/derive password)] (-> (db/query-one conn [sql user password]) (p/then' su/raise-not-found-if-nil) (p/then' su/constantly-nil)))) -(s/def ::update-password - (s/keys :req-un [::user ::us/password ::old-password])) +(s/def ::update-profile-password + (s/keys :req-un [::user ::password ::old-password])) -(sm/defmutation :update-password - {:doc "Update self password." - :spec ::update-password} +(sm/defmutation ::update-profile-password [params] (db/with-atomic [conn db/pool] - (-> (p/resolved params) - (p/then (partial validate-password conn)) - (p/then (partial update-password conn))))) + (validate-password! conn params) + (update-password conn params))) + ;; --- Mutation: Update Photo -;; (s/def :uxbox$upload/name ::us/string) -;; (s/def :uxbox$upload/size ::us/integer) -;; (s/def :uxbox$upload/mtype ::us/string) -;; (s/def ::upload -;; (s/keys :req-un [:uxbox$upload/name -;; :uxbox$upload/size -;; :uxbox$upload/mtype])) +(declare upload-photo) +(declare update-profile-photo) -;; (s/def ::file ::upload) -;; (s/def ::update-profile-photo -;; (s/keys :req-un [::user ::file])) +(s/def ::file ::imgs/upload) +(s/def ::update-profile-photo + (s/keys :req-un [::user ::file])) -;; (def valid-image-types? -;; #{"image/jpeg", "image/png", "image/webp"}) +(sm/defmutation ::update-profile-photo + [{:keys [user file] :as params}] + (db/with-atomic [conn db/pool] + ;; TODO: send task for delete old photo + (-> (upload-photo conn params) + (p/then (partial update-profile-photo conn user))))) -;; (sm/defmutation ::update-profile-photo -;; [{:keys [user file] :as params}] -;; (letfn [(store-photo [{:keys [name path] :as upload}] -;; (let [filename (fs/name name) -;; storage media/media-storage] -;; (-> (ds/save storage filename path) -;; #_(su/handle-on-context)))) +(defn- upload-photo + [conn {:keys [file user]}] + (when-not (imgs/valid-image-types? (:mtype file)) + (ex/raise :type :validation + :code :image-type-not-allowed + :hint "Seems like you are uploading an invalid image.")) + (vc/blocking + (let [thumb-opts {:width 256 + :height 256 + :quality 75 + :format "webp"} + prefix (-> (sodi.prng/random-bytes 8) + (sodi.util/bytes->b64s)) + name (str prefix ".webp") + photo (images/generate-thumbnail2 (fs/path (:path file)) thumb-opts)] + (ust/save! media/media-storage name photo)))) -;; (update-user-photo [path] -;; (let [sql "update users -;; set photo = $1 -;; where id = $2 -;; and deleted_at is null -;; returning id, photo"] -;; (-> (db/query-one db/pool [sql (str path) user]) -;; (p/then' su/raise-not-found-if-nil) -;; (p/then profile/resolve-thumbnail))))] +(defn- update-profile-photo + [conn user path] + (let [sql "update users set photo=$1 where id=$2 and deleted_at is null returning id"] + (-> (db/query-one conn [sql (str path) user]) + (p/then' su/raise-not-found-if-nil)))) -;; (when-not (valid-image-types? (:mtype file)) -;; (ex/raise :type :validation -;; :code :image-type-not-allowed -;; :hint "Seems like you are uploading an invalid image.")) - -;; (-> (store-photo file) -;; (p/then update-user-photo)))) ;; --- Mutation: Register Profile -(def sql:insert-user - "insert into users (id, fullname, username, email, password, photo) - values ($1, $2, $3, $4, $5, '') returning *") +(declare check-profile-existence!) +(declare register-profile) -(def sql:insert-email +(s/def ::register-profile + (s/keys :req-un [::email ::password ::fullname])) + +(sm/defmutation ::register-profile + [params] + (when-not (:registration-enabled cfg/config) + (ex/raise :type :restriction + :code :registration-disabled)) + (db/with-atomic [conn db/pool] + (check-profile-existence! conn params) + (register-profile conn params))) + +(def ^:private sql:insert-user + "insert into users (id, fullname, email, password, photo) + values ($1, $2, $3, $4, '') returning *") + +(def ^:private sql:insert-email "insert into user_emails (user_id, email, is_main) values ($1, $2, true)") +(def ^:private sql:profile-existence + "select exists (select * from users + where email = $1 + and deleted_at is null) as val") + (defn- check-profile-existence! - [conn {:keys [username email] :as params}] - (let [sql "select exists - (select * from users - where username = $1 - or email = $2 - ) as val"] - (-> (db/query-one conn [sql username email]) - (p/then (fn [result] - (when (:val result) - (ex/raise :type :validation - :code ::username-or-email-already-exists)) - params))))) + [conn {:keys [email] :as params}] + (-> (db/query-one conn [sql:profile-existence email]) + (p/then' (fn [result] + (when (:val result) + (ex/raise :type :validation + :code ::email-already-exists)) + params)))) (defn create-profile "Create the user entry on the database with limited input filling all the other fields with defaults." - [conn {:keys [id username fullname email password] :as params}] + [conn {:keys [id fullname email password] :as params}] (let [id (or id (uuid/next)) password (sodi.pwhash/derive password) - sqlv1 [sql:insert-user id - fullname username - email password] + sqlv1 [sql:insert-user + id + fullname + email + password] sqlv2 [sql:insert-email id email]] (p/let [profile (db/query-one conn sqlv1)] (db/query-one conn sqlv2) @@ -263,34 +252,22 @@ (p/then' profile/strip-private-attrs) (p/then (fn [profile] ;; TODO: send a correct link for email verification - (p/let [data {:to (:email params) - :name (:fullname params)}] - (emails/send! emails/register data) - profile))))) - -(s/def ::register-profile - (s/keys :req-un [::username ::email ::password ::fullname])) - -(sm/defmutation ::register-profile - [params] - (when-not (:registration-enabled cfg/config) - (ex/raise :type :restriction - :code :registration-disabled)) - (db/with-atomic [conn db/pool] - (-> (p/resolved params) - (p/then (partial check-profile-existence! conn)) - (p/then (partial register-profile conn))))) + (let [data {:to (:email params) + :name (:fullname params)}] + (p/do! + (emails/send! emails/register data) + profile)))))) ;; --- Mutation: Request Profile Recovery (s/def ::request-profile-recovery - (s/keys :req-un [::username])) + (s/keys :req-un [::email])) (def sql:insert-recovery-token "insert into tokens (user_id, token) values ($1, $2)") (sm/defmutation ::request-profile-recovery - [{:keys [username] :as params}] + [{:keys [email] :as params}] (letfn [(create-recovery-token [conn {:keys [id] :as user}] (let [token (-> (sodi.prng/random-bytes 32) (sodi.util/bytes->b64s)) @@ -298,12 +275,13 @@ (-> (db/query-one conn [sql id token]) (p/then (constantly (assoc user :token token)))))) (send-email-notification [conn user] - (emails/send! emails/password-recovery + (emails/send! conn + emails/password-recovery {:to (:email user) :token (:token user) :name (:fullname user)}))] (db/with-atomic [conn db/pool] - (-> (retrieve-user conn username) + (-> (retrieve-user conn email) (p/then' su/raise-not-found-if-nil) (p/then #(create-recovery-token conn %)) (p/then #(send-email-notification conn %)) diff --git a/backend/src/uxbox/services/queries/profile.clj b/backend/src/uxbox/services/queries/profile.clj index 4142c18580..f5bb486ff1 100644 --- a/backend/src/uxbox/services/queries/profile.clj +++ b/backend/src/uxbox/services/queries/profile.clj @@ -31,16 +31,6 @@ ;; --- Query: Profile (own) -;; (defn resolve-thumbnail -;; [user] -;; (let [opts {:src :photo -;; :dst :photo -;; :size [100 100] -;; :quality 90 -;; :format "jpg"}] -;; (-> (px/submit! #(images/populate-thumbnails user opts)) -;; (su/handle-on-context)))) - (defn retrieve-profile [conn id] (let [sql "select * from users where id=$1 and deleted_at is null"] @@ -52,12 +42,12 @@ (sq/defquery ::profile [{:keys [user] :as params}] (-> (retrieve-profile db/pool user) - (p/then' strip-private-attrs))) + (p/then' strip-private-attrs) + (p/then' #(images/resolve-media-uris % [:photo :photo-uri])))) ;; --- Attrs Helpers (defn strip-private-attrs "Only selects a publicy visible user attrs." [profile] - (select-keys profile [:id :username :fullname :metadata - :email :created-at :photo])) + (select-keys profile [:id :fullname :lang :email :created-at :photo])) diff --git a/backend/src/uxbox/tasks/impl.clj b/backend/src/uxbox/tasks/impl.clj index ee75406baf..2b4b062d42 100644 --- a/backend/src/uxbox/tasks/impl.clj +++ b/backend/src/uxbox/tasks/impl.clj @@ -79,10 +79,10 @@ (p/then' (constantly nil)))) (defn- handle-task - [handlers {:keys [name] :as task}] - (let [task-fn (get handlers name)] + [tasks {:keys [name] :as item}] + (let [task-fn (get tasks name)] (if task-fn - (task-fn task) + (task-fn item) (do (log/warn "no task handler found for" (pr-str name)) nil)))) @@ -103,7 +103,7 @@ props (assoc :props (blob/decode props))))) (defn- event-loop - [{:keys [handlers] :as options}] + [{:keys [tasks] :as options}] (let [queue (:queue options "default") max-retries (:max-retries options 3)] (db/with-atomic [conn db/pool] @@ -111,7 +111,7 @@ (p/then decode-task-row) (p/then (fn [item] (when item - (-> (p/do! (handle-task handlers item)) + (-> (p/do! (handle-task tasks item)) (p/handle (fn [v e] (if e (if (>= (:retry-num item) max-retries) diff --git a/backend/tests/uxbox/tests/helpers.clj b/backend/tests/uxbox/tests/helpers.clj index 49c46bada5..d6d4e9abd3 100644 --- a/backend/tests/uxbox/tests/helpers.clj +++ b/backend/tests/uxbox/tests/helpers.clj @@ -66,11 +66,10 @@ (defn create-user [conn i] (profile/create-profile conn {:id (mk-uuid "user" i) - :fullname (str "User " i) - :username (str "user" i) - :email (str "user" i ".test@uxbox.io") - :password "123123" - :metadata {}})) + :fullname (str "User " i) + :email (str "user" i ".test@nodomain.com") + :password "123123" + :metadata {}})) (defn create-project [conn user-id i] diff --git a/backend/tests/uxbox/tests/test_services_auth.clj b/backend/tests/uxbox/tests/test_services_auth.clj deleted file mode 100644 index 660f799c50..0000000000 --- a/backend/tests/uxbox/tests/test_services_auth.clj +++ /dev/null @@ -1,46 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) 2019 Andrey Antukh - -(ns uxbox.tests.test-services-auth - (:require - [clojure.test :as t] - [promesa.core :as p] - [uxbox.db :as db] - [uxbox.services.mutations :as sm] - [uxbox.tests.helpers :as th])) - -(t/use-fixtures :once th/state-init) -(t/use-fixtures :each th/database-reset) - -(t/deftest failed-auth - (let [user @(th/create-user db/pool 1) - event {:username "user1" - ::sm/type :login - :password "foobar" - :metadata "1" - :scope "foobar"} - out (th/try-on! (sm/handle event))] - ;; (th/print-result! out) - (let [error (:error out)] - (t/is (th/ex-info? error)) - (t/is (th/ex-of-type? error :service-error))) - - (let [error (ex-cause (:error out))] - (t/is (th/ex-info? error)) - (t/is (th/ex-of-type? error :validation)) - (t/is (th/ex-of-code? error :uxbox.services.mutations.profile/wrong-credentials))))) - -(t/deftest success-auth - (let [user @(th/create-user db/pool 1) - event {:username "user1" - ::sm/type :login - :password "123123" - :metadata "1" - :scope "foobar"} - out (th/try-on! (sm/handle event))] - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (t/is (= (get-in out [:result :id]) (:id user))))) diff --git a/backend/tests/uxbox/tests/test_services_profile.clj b/backend/tests/uxbox/tests/test_services_profile.clj new file mode 100644 index 0000000000..85ccfaf06b --- /dev/null +++ b/backend/tests/uxbox/tests/test_services_profile.clj @@ -0,0 +1,151 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2019-2020 Andrey Antukh + +(ns uxbox.tests.test-services-profile + (:require + [clojure.test :as t] + [clojure.java.io :as io] + [promesa.core :as p] + [cuerdas.core :as str] + [datoteka.core :as fs] + [uxbox.db :as db] + [uxbox.services.mutations :as sm] + [uxbox.services.queries :as sq] + [uxbox.tests.helpers :as th])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(t/deftest login-with-failed-auth + (let [user @(th/create-user db/pool 1) + event {::sm/type :login + :email "user1.test@nodomain.com" + :password "foobar" + :scope "foobar"} + out (th/try-on! (sm/handle event))] + + ;; (th/print-result! out) + (let [error (:error out)] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :service-error))) + + (let [error (ex-cause (:error out))] + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :uxbox.services.mutations.profile/wrong-credentials))))) + +(t/deftest login-with-success-auth + (let [user @(th/create-user db/pool 1) + event {::sm/type :login + :email "user1.test@nodomain.com" + :password "123123" + :scope "foobar"} + out (th/try-on! (sm/handle event))] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (= (get-in out [:result :id]) (:id user))))) + +(t/deftest query-profile + (let [user @(th/create-user db/pool 1) + data {::sq/type :profile + :user (:id user)} + + out (th/try-on! (sq/handle data))] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (= "User 1" (get-in out [:result :fullname]))) + (t/is (= "user1.test@nodomain.com" (get-in out [:result :email]))) + (t/is (not (contains? (:result out) :password))))) + +(t/deftest mutation-update-profile + (let [user @(th/create-user db/pool 1) + data (assoc user + ::sm/type :update-profile + :fullname "Full Name" + :username "user222" + :lang "en") + out (th/try-on! (sm/handle data))] + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (= (:fullname data) (get-in out [:result :fullname]))) + (t/is (= (:email data) (get-in out [:result :email]))) + (t/is (= (:metadata data) (get-in out [:result :metadata]))) + (t/is (not (contains? (:result out) :password))))) + +(t/deftest mutation-update-profile-photo + (let [user @(th/create-user db/pool 1) + data {::sm/type :update-profile-photo + :user (:id user) + :file {:name "sample.jpg" + :path "tests/uxbox/tests/_files/sample.jpg" + :size 123123 + :mtype "image/jpeg"}} + out (th/try-on! (sm/handle data))] + + ;; (th/print-result! out) + (t/is (nil? (:error out))) + (t/is (= (:id user) (get-in out [:result :id]))))) + +;; (t/deftest test-mutation-register-profile +;; (let[data {:fullname "Full Name" +;; :username "user222" +;; :email "user222@uxbox.io" +;; :password "user222" +;; ::sv/type :register-profile} +;; [err rsp] (th/try-on (sm/handle data))] +;; (println "RESPONSE:" err rsp))) + +;; (t/deftest test-http-validate-recovery-token +;; (with-open [conn (db/connection)] +;; (let [user (th/create-user conn 1)] +;; (with-server {:handler (uft/routes)} +;; (let [token (#'usu/request-password-recovery conn "user1") +;; uri1 (str th/+base-url+ "/api/auth/recovery/not-existing") +;; uri2 (str th/+base-url+ "/api/auth/recovery/" token) +;; [status1 data1] (th/http-get user uri1) +;; [status2 data2] (th/http-get user uri2)] +;; ;; (println "RESPONSE:" status1 data1) +;; ;; (println "RESPONSE:" status2 data2) +;; (t/is (= 404 status1)) +;; (t/is (= 204 status2))))))) + +;; (t/deftest test-http-request-password-recovery +;; (with-open [conn (db/connection)] +;; (let [user (th/create-user conn 1) +;; sql "select * from user_pswd_recovery" +;; res (sc/fetch-one conn sql)] + +;; ;; Initially no tokens exists +;; (t/is (nil? res)) + +;; (with-server {:handler (uft/routes)} +;; (let [uri (str th/+base-url+ "/api/auth/recovery") +;; data {:username "user1"} +;; [status data] (th/http-post user uri {:body data})] +;; ;; (println "RESPONSE:" status data) +;; (t/is (= 204 status))) + +;; (let [res (sc/fetch-one conn sql)] +;; (t/is (not (nil? res))) +;; (t/is (= (:user res) (:id user)))))))) + +;; (t/deftest test-http-validate-recovery-token +;; (with-open [conn (db/connection)] +;; (let [user (th/create-user conn 1)] +;; (with-server {:handler (uft/routes)} +;; (let [token (#'usu/request-password-recovery conn (:username user)) +;; uri (str th/+base-url+ "/api/auth/recovery") +;; data {:token token :password "mytestpassword"} +;; [status data] (th/http-put user uri {:body data}) + +;; user' (usu/find-full-user-by-id conn (:id user))] +;; (t/is (= status 204)) +;; (t/is (hashers/check "mytestpassword" (:password user')))))))) + + diff --git a/backend/tests/uxbox/tests/test_services_users.clj b/backend/tests/uxbox/tests/test_services_users.clj deleted file mode 100644 index aefff8d126..0000000000 --- a/backend/tests/uxbox/tests/test_services_users.clj +++ /dev/null @@ -1,115 +0,0 @@ -(ns uxbox.tests.test-services-users - (:require - [clojure.test :as t] - [clojure.java.io :as io] - [promesa.core :as p] - [cuerdas.core :as str] - [datoteka.core :as fs] - [uxbox.db :as db] - [uxbox.services.mutations :as sm] - [uxbox.services.queries :as sq] - [uxbox.tests.helpers :as th])) - -(t/use-fixtures :once th/state-init) -(t/use-fixtures :each th/database-reset) - -(t/deftest test-query-profile - (let [user @(th/create-user db/pool 1) - data {::sq/type :profile - :user (:id user)} - - out (th/try-on! (sq/handle data))] - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (t/is (= "User 1" (get-in out [:result :fullname]))) - (t/is (= "user1" (get-in out [:result :username]))) - (t/is (= "user1.test@uxbox.io" (get-in out [:result :email]))) - (t/is (not (contains? (:result out) :password))))) - -(t/deftest test-mutation-update-profile - (let [user @(th/create-user db/pool 1) - data (assoc user - ::sm/type :update-profile - :fullname "Full Name" - :username "user222" - :lang "en") - out (th/try-on! (sm/handle data))] - ;; (th/print-result! out) - (t/is (nil? (:error out))) - (t/is (= (:fullname data) (get-in out [:result :fullname]))) - (t/is (= (:username data) (get-in out [:result :username]))) - (t/is (= (:email data) (get-in out [:result :email]))) - (t/is (= (:metadata data) (get-in out [:result :metadata]))) - (t/is (not (contains? (:result out) :password))))) - -;; (t/deftest test-mutation-update-profile-photo -;; (let [user @(th/create-user db/pool 1) -;; data {::sm/type :update-profile-photo -;; :user (:id user) -;; :file {:name "sample.jpg" -;; :path (fs/path "test/uxbox/tests/_files/sample.jpg") -;; :size 123123 -;; :mtype "image/jpeg"}} - -;; out (th/try-on! (sm/handle data))] -;; ;; (th/print-result! out) -;; (t/is (nil? (:error out))) -;; (t/is (= (:id user) (get-in out [:result :id]))) -;; (t/is (str/starts-with? (get-in out [:result :photo]) "http")))) - -;; (t/deftest test-mutation-register-profile -;; (let[data {:fullname "Full Name" -;; :username "user222" -;; :email "user222@uxbox.io" -;; :password "user222" -;; ::sv/type :register-profile} -;; [err rsp] (th/try-on (sm/handle data))] -;; (println "RESPONSE:" err rsp))) - -;; ;; (t/deftest test-http-validate-recovery-token -;; ;; (with-open [conn (db/connection)] -;; ;; (let [user (th/create-user conn 1)] -;; ;; (with-server {:handler (uft/routes)} -;; ;; (let [token (#'usu/request-password-recovery conn "user1") -;; ;; uri1 (str th/+base-url+ "/api/auth/recovery/not-existing") -;; ;; uri2 (str th/+base-url+ "/api/auth/recovery/" token) -;; ;; [status1 data1] (th/http-get user uri1) -;; ;; [status2 data2] (th/http-get user uri2)] -;; ;; ;; (println "RESPONSE:" status1 data1) -;; ;; ;; (println "RESPONSE:" status2 data2) -;; ;; (t/is (= 404 status1)) -;; ;; (t/is (= 204 status2))))))) - -;; ;; (t/deftest test-http-request-password-recovery -;; ;; (with-open [conn (db/connection)] -;; ;; (let [user (th/create-user conn 1) -;; ;; sql "select * from user_pswd_recovery" -;; ;; res (sc/fetch-one conn sql)] - -;; ;; ;; Initially no tokens exists -;; ;; (t/is (nil? res)) - -;; ;; (with-server {:handler (uft/routes)} -;; ;; (let [uri (str th/+base-url+ "/api/auth/recovery") -;; ;; data {:username "user1"} -;; ;; [status data] (th/http-post user uri {:body data})] -;; ;; ;; (println "RESPONSE:" status data) -;; ;; (t/is (= 204 status))) - -;; ;; (let [res (sc/fetch-one conn sql)] -;; ;; (t/is (not (nil? res))) -;; ;; (t/is (= (:user res) (:id user)))))))) - -;; ;; (t/deftest test-http-validate-recovery-token -;; ;; (with-open [conn (db/connection)] -;; ;; (let [user (th/create-user conn 1)] -;; ;; (with-server {:handler (uft/routes)} -;; ;; (let [token (#'usu/request-password-recovery conn (:username user)) -;; ;; uri (str th/+base-url+ "/api/auth/recovery") -;; ;; data {:token token :password "mytestpassword"} -;; ;; [status data] (th/http-put user uri {:body data}) - -;; ;; user' (usu/find-full-user-by-id conn (:id user))] -;; ;; (t/is (= status 204)) -;; ;; (t/is (hashers/check "mytestpassword" (:password user')))))))) - diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 680e423c4b..d5595397ab 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -34,8 +34,7 @@ "fr" : "+ Nouvelle couleur" } }, - "ds.colors" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/header.cljs:48" ], + "dashboard.header.colors" : { "translations" : { "en" : "COLORS", "fr" : "COULEURS" @@ -112,8 +111,7 @@ "fr" : "+ Nouvel icône" } }, - "ds.icons" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/header.cljs:42" ], + "dashboard.header.icons" : { "translations" : { "en" : "ICONS", "fr" : "ICÔNES" @@ -133,8 +131,7 @@ "fr" : "+ Nouvelle image" } }, - "ds.images" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/header.cljs:45" ], + "dashboard.header.images" : { "translations" : { "en" : "IMAGES", "fr" : "IMAGES" @@ -210,8 +207,7 @@ }, "unused" : true }, - "ds.projects" : { - "used-in" : [ "src/uxbox/main/ui/dashboard/header.cljs:39" ], + "dashboard.header.projects" : { "translations" : { "en" : "PROJECTS", "fr" : "PROJETS" @@ -287,8 +283,7 @@ "fr" : "Mise en ligne : %s" } }, - "ds.user.exit" : { - "used-in" : [ "src/uxbox/main/ui/users.cljs:43" ], + "dashboard.header.user-menu.logout" : { "translations" : { "en" : "Exit", "fr" : "Quitter" @@ -301,15 +296,13 @@ "fr" : "Notifications" } }, - "ds.user.password" : { - "used-in" : [ "src/uxbox/main/ui/users.cljs:37" ], + "dashboard.header.user-menu.password" : { "translations" : { "en" : "Password", "fr" : "Mot de passe" } }, - "ds.user.profile" : { - "used-in" : [ "src/uxbox/main/ui/users.cljs:34" ], + "dashboard.header.user-menu.profile" : { "translations" : { "en" : "Profile", "fr" : "Profil" @@ -441,11 +434,11 @@ "fr" : null } }, - "login.email-or-username" : { + "login.email" : { "used-in" : [ "src/uxbox/main/ui/login.cljs:63" ], "translations" : { - "en" : "Email or Username", - "fr" : "adresse email ou nom d'utilisateur" + "en" : "Email", + "fr" : "adresse email" } }, "login.forgot-password" : { @@ -532,11 +525,11 @@ "fr" : null } }, - "profile.recovery.username-or-email" : { + "profile.recovery.email" : { "used-in" : [ "src/uxbox/main/ui/profile/recovery_request.cljs:54" ], "translations" : { - "en" : "Username or Email Address", - "fr" : "adresse email ou nom d'utilisateur" + "en" : "Email Address", + "fr" : "adresse email" } }, "profile.register.already-have-account" : { @@ -574,13 +567,6 @@ "fr" : "Mot de passe" } }, - "profile.register.username" : { - "used-in" : [ "src/uxbox/main/ui/profile/register.cljs:87" ], - "translations" : { - "en" : "Your username", - "fr" : "Votre nom d'utilisateur" - } - }, "settings.exit" : { "used-in" : [ "src/uxbox/main/ui/settings/header.cljs:46" ], "translations" : { @@ -693,7 +679,7 @@ "fr" : "Nom, nom d'utilisateur et adresse email" } }, - "settings.profile.section-i18n-data" : { + "settings.profile.lang" : { "used-in" : [ "src/uxbox/main/ui/settings/profile.cljs:117" ], "translations" : { "en" : "Default language", diff --git a/frontend/src/uxbox/main/data/auth.cljs b/frontend/src/uxbox/main/data/auth.cljs index 3a30ec5bf6..06fd1a4bba 100644 --- a/frontend/src/uxbox/main/data/auth.cljs +++ b/frontend/src/uxbox/main/data/auth.cljs @@ -21,10 +21,9 @@ [uxbox.util.i18n :as i18n :refer [tr]] [uxbox.util.storage :refer [storage]])) -(s/def ::username string?) +(s/def ::email ::us/email) (s/def ::password string?) (s/def ::fullname string?) -(s/def ::email ::us/email) ;; --- Logged In @@ -44,10 +43,10 @@ ;; --- Login (s/def ::login-params - (s/keys :req-un [::username ::password])) + (s/keys :req-un [::email ::password])) (defn login - [{:keys [username password] :as data}] + [{:keys [email password] :as data}] (us/verify ::login-params data) (ptk/reify ::login ptk/UpdateEvent @@ -56,7 +55,7 @@ ptk/WatchEvent (watch [this state s] - (let [params {:username username + (let [params {:email email :password password :scope "webapp"} on-error #(rx/of (um/error (tr "errors.auth.unauthorized")))] @@ -93,7 +92,6 @@ (s/def ::register (s/keys :req-un [::fullname - ::username ::password ::email])) @@ -115,7 +113,7 @@ ;; --- Recovery Request (s/def ::recovery-request - (s/keys :req-un [::username])) + (s/keys :req-un [::email])) (defn request-profile-recovery [data on-success] diff --git a/frontend/src/uxbox/main/data/users.cljs b/frontend/src/uxbox/main/data/users.cljs index 447acf2496..f12dd16561 100644 --- a/frontend/src/uxbox/main/data/users.cljs +++ b/frontend/src/uxbox/main/data/users.cljs @@ -19,7 +19,6 @@ ;; --- Common Specs (s/def ::id uuid?) -(s/def ::username string?) (s/def ::fullname string?) (s/def ::email ::us/email) (s/def ::password string?) @@ -30,19 +29,18 @@ (s/def ::password-2 string?) (s/def ::password-old string?) -;; --- Profile Fetched - -(s/def ::profile-fetched +(s/def ::profile (s/keys :req-un [::id - ::username ::fullname ::email ::created-at ::photo])) +;; --- Profile Fetched + (defn profile-fetched [data] - (us/verify ::profile-fetched data) + (us/verify ::profile data) (ptk/reify ::profile-fetched ptk/UpdateEvent (update [_ state] @@ -51,7 +49,7 @@ ptk/EffectEvent (effect [_ state stream] (swap! storage assoc :profile data) - (when-let [lang (get-in data [:metadata :language])] + (when-let [lang (:lang data)] (i18n/set-current-locale! lang))))) ;; --- Fetch Profile @@ -65,73 +63,56 @@ ;; --- Update Profile -(s/def ::update-profile-params - (s/keys :req-un [::fullname - ::email - ::username - ::language])) - -(defn form->update-profile - [data on-success on-error] - (us/verify ::update-profile-params data) - (us/verify fn? on-error) - (us/verify fn? on-success) - (reify +(defn update-profile + [data] + (us/assert ::profile data) + (ptk/reify ::update-profile ptk/WatchEvent (watch [_ state s] - (letfn [(handle-error [{payload :payload}] - (on-error payload) - (rx/empty))] - (let [data (-> (:profile state) - (assoc :fullname (:fullname data)) - (assoc :email (:email data)) - (assoc :username (:username data)) - (assoc-in [:metadata :language] (:language data)))] - #_(->> (rp/req :update/profile data) - (rx/map :payload) - (rx/do on-success) - (rx/map profile-fetched) - (rx/catch rp/client-error? handle-error))))))) + (let [mdata (meta data) + on-success (:on-success mdata identity) + on-error (:on-error mdata identity) + handle-error #(do (on-error (:payload %)) + (rx/empty))] + (->> (rp/mutation :update-profile data) + (rx/do on-success) + (rx/map profile-fetched) + (rx/catch rp/client-error? handle-error)))))) ;; --- Update Password (Form) -(s/def ::update-password-params +(s/def ::update-password (s/keys :req-un [::password-1 ::password-2 ::password-old])) (defn update-password - [data {:keys [on-success on-error]}] - (us/verify ::update-password-params data) - (us/verify fn? on-success) - (us/verify fn? on-error) - (reify + [data] + (us/verify ::update-password data) + (ptk/reify ::update-password ptk/WatchEvent (watch [_ state s] - (let [params {:old-password (:password-old data) + (let [mdata (meta data) + on-success (:on-success mdata identity) + on-error (:on-error mdata identity) + params {:old-password (:password-old data) :password (:password-1 data)}] - #_(->> (rp/req :update/profile-password params) - (rx/catch rp/client-error? (fn [e] - (on-error (:payload e)) - (rx/empty))) + (->> (rp/mutation :update-profile-password params) + (rx/catch rp/client-error? #(do (on-error (:payload %)) + (rx/empty))) (rx/do on-success) (rx/ignore)))))) -;; --- Update Photo - -(deftype UpdatePhoto [file done] - ptk/WatchEvent - (watch [_ state stream] - #_(->> (rp/req :update/profile-photo {:file file}) - (rx/do done) - (rx/map (constantly fetch-profile))))) +;; --- Update Photoo (s/def ::file #(instance? js/File %)) (defn update-photo - ([file] (update-photo file (constantly nil))) - ([file done] - (us/verify ::file file) - (us/verify fn? done) - (UpdatePhoto. file done))) + [{:keys [file] :as params}] + (us/verify ::file file) + (ptk/reify ::update-photo + ptk/WatchEvent + (watch [_ state stream] + (->> (rp/mutation :update-profile-photo {:file file}) + (rx/map (constantly fetch-profile)))))) diff --git a/frontend/src/uxbox/main/repo.cljs b/frontend/src/uxbox/main/repo.cljs index 0057822d3c..ae28ed36de 100644 --- a/frontend/src/uxbox/main/repo.cljs +++ b/frontend/src/uxbox/main/repo.cljs @@ -128,6 +128,14 @@ (seq params)) (send-mutation! id form))) +(defmethod mutation :update-profile-photo + [id params] + (let [form (js/FormData.)] + (run! (fn [[key val]] + (.append form (name key) val)) + (seq params)) + (send-mutation! id form))) + (defmethod mutation :login [id params] (let [url (str url "/api/login")] diff --git a/frontend/src/uxbox/main/ui.cljs b/frontend/src/uxbox/main/ui.cljs index 01621c908f..9f95572123 100644 --- a/frontend/src/uxbox/main/ui.cljs +++ b/frontend/src/uxbox/main/ui.cljs @@ -6,7 +6,7 @@ ;; defined by the Mozilla Public License, v. 2.0. ;; ;; Copyright (c) 2015-2017 Juan de la Cruz -;; Copyright (c) 2015-2019 Andrey Antukh +;; Copyright (c) 2015-2020 Andrey Antukh (ns uxbox.main.ui (:require @@ -44,15 +44,13 @@ (def routes [["/login" :login] - ["/profile" - ["/register" :profile-register] - ["/recovery/request" :profile-recovery-request] - ["/recovery" :profile-recovery]] + ["/register" :profile-register] + ["/recovery/request" :profile-recovery-request] + ["/recovery" :profile-recovery] ["/settings" - ["/profile" :settings/profile] - ["/password" :settings/password] - ["/notifications" :settings/notifications]] + ["/profile" :settings-profile] + ["/password" :settings-password]] ["/dashboard" ["/projects" :dashboard-projects] @@ -72,9 +70,8 @@ :profile-recovery-request (mf/element profile-recovery-request-page) :profile-recovery (mf/element profile-recovery-page) - (:settings/profile - :settings/password - :settings/notifications) + (:settings-profile + :settings-password) (mf/element settings/settings #js {:route route}) :dashboard-projects diff --git a/frontend/src/uxbox/main/ui/dashboard/header.cljs b/frontend/src/uxbox/main/ui/dashboard/header.cljs index 80c797031e..a19f890d41 100644 --- a/frontend/src/uxbox/main/ui/dashboard/header.cljs +++ b/frontend/src/uxbox/main/ui/dashboard/header.cljs @@ -2,22 +2,28 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2015-2016 Andrey Antukh -;; Copyright (c) 2015-2016 Juan de la Cruz +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2015-2020 Andrey Antukh +;; Copyright (c) 2015-2020 Juan de la Cruz (ns uxbox.main.ui.dashboard.header (:require + [cuerdas.core :as str] [lentes.core :as l] - [rumext.core :as mx] [rumext.alpha :as mf] [uxbox.builtins.icons :as i] + [uxbox.main.data.auth :as da] [uxbox.main.data.projects :as dp] [uxbox.main.store :as st] [uxbox.main.ui.navigation :as nav] - [uxbox.main.ui.users :refer [user]] - [uxbox.util.i18n :refer (tr)] + [uxbox.util.dom :as dom] + [uxbox.util.i18n :as i18n :refer [t]] [uxbox.util.router :as rt])) +(declare user) + (mf/defc header-link [{:keys [section content] :as props}] (let [on-click #(st/emit! (rt/nav section))] @@ -25,7 +31,8 @@ (mf/defc header [{:keys [section] :as props}] - (let [projects? (= section :dashboard-projects) + (let [locale (i18n/use-locale) + projects? (= section :dashboard-projects) icons? (= section :dashboard-icons) images? (= section :dashboard-images) colors? (= section :dashboard-colors)] @@ -36,16 +43,61 @@ [:ul.main-nav [:li {:class (when projects? "current")} [:& header-link {:section :dashboard-projects - :content (tr "ds.projects")}]] + :content (t locale "dashboard.header.projects")}]] [:li {:class (when icons? "current")} [:& header-link {:section :dashboard-icons - :content (tr "ds.icons")}]] + :content (t locale "dashboard.header.icons")}]] [:li {:class (when images? "current")} [:& header-link {:section :dashboard-images - :content (tr "ds.images")}]] + :content (t locale "dashboard.header.images")}]] [:li {:class (when colors? "current")} [:& header-link {:section :dashboard-colors - :content (tr "ds.colors")}]]] + :content (t locale "dashboard.header.colors")}]]] [:& user]])) +;; --- User Widget + +(declare user-menu) +(def profile-ref + (-> (l/key :profile) + (l/derive st/state))) + +(mf/defc user + [props] + (let [open (mf/use-state false) + profile (mf/deref profile-ref) + photo (:photo-uri profile "") + photo (if (str/empty? photo) + "/images/avatar.jpg" + photo)] + [:div.user-zone {:on-click #(st/emit! (rt/nav :settings-profile)) + :on-mouse-enter #(reset! open true) + :on-mouse-leave #(reset! open false)} + [:span (:fullname profile)] + [:img {:src photo}] + (when @open + [:& user-menu])])) + +;; --- User Menu + +(mf/defc user-menu + [props] + (let [locale (i18n/use-locale) + on-click + (fn [event section] + (dom/stop-propagation event) + (if (keyword? section) + (st/emit! (rt/nav section)) + (st/emit! section)))] + [:ul.dropdown + [:li {:on-click #(on-click % :settings-profile)} + i/user + [:span (t locale "dashboard.header.user-menu.profile")]] + [:li {:on-click #(on-click % :settings-password)} + i/lock + [:span (t locale "dashboard.header.user-menu.password")]] + [:li {:on-click #(on-click % da/logout)} + i/exit + [:span (t locale "dashboard.header.user-menu.logout")]]])) + diff --git a/frontend/src/uxbox/main/ui/login.cljs b/frontend/src/uxbox/main/ui/login.cljs index 6aa5b87e26..2ba8241a03 100644 --- a/frontend/src/uxbox/main/ui/login.cljs +++ b/frontend/src/uxbox/main/ui/login.cljs @@ -23,18 +23,17 @@ [uxbox.util.i18n :refer [tr]] [uxbox.util.router :as rt])) -(s/def ::username ::us/not-empty-string) +(s/def ::email ::us/email) (s/def ::password ::us/not-empty-string) (s/def ::login-form - (s/keys :req-un [::username ::password])) + (s/keys :req-un [::email ::password])) (defn- on-submit [event form] (dom/prevent-default event) - (let [{:keys [username password]} (:clean-data form)] - (st/emit! (da/login {:username username - :password password})))) + (let [{:keys [email password]} (:clean-data form)] + (st/emit! (da/login {:email email :password password})))) (mf/defc demo-warning [_] @@ -54,13 +53,13 @@ [:& demo-warning]) [:input.input-text - {:name "username" + {:name "email" :tab-index "2" - :value (:username data "") - :class (fm/error-class form :username) - :on-blur (fm/on-input-blur form :username) - :on-change (fm/on-input-change form :username) - :placeholder (tr "login.email-or-username") + :value (:email data "") + :class (fm/error-class form :email) + :on-blur (fm/on-input-blur form :email) + :on-change (fm/on-input-change form :email) + :placeholder (tr "login.email") :type "text"}] [:input.input-text {:name "password" diff --git a/frontend/src/uxbox/main/ui/profile/recovery_request.cljs b/frontend/src/uxbox/main/ui/profile/recovery_request.cljs index 134c2a791c..3f654c3aec 100644 --- a/frontend/src/uxbox/main/ui/profile/recovery_request.cljs +++ b/frontend/src/uxbox/main/ui/profile/recovery_request.cljs @@ -26,8 +26,8 @@ [uxbox.util.i18n :as i18n :refer [t]] [uxbox.util.router :as rt])) -(s/def ::username ::us/not-empty-string) -(s/def ::recovery-request-form (s/keys :req-un [::username])) +(s/def ::email ::us/email) +(s/def ::recovery-request-form (s/keys :req-un [::email])) (mf/defc recovery-form [] @@ -46,12 +46,12 @@ [:form {:on-submit on-submit} [:div.login-content [:input.input-text - {:name "username" - :value (:username data "") - :class (fm/error-class form :username) - :on-blur (fm/on-input-blur form :username) - :on-change (fm/on-input-change form :username) - :placeholder (t locale "profile.recovery.username-or-email") + {:name "email" + :value (:email data "") + :class (fm/error-class form :email) + :on-blur (fm/on-input-blur form :email) + :on-change (fm/on-input-change form :email) + :placeholder (t locale "profile.recovery.email") :type "text"}] [:input.btn-primary {:name "login" diff --git a/frontend/src/uxbox/main/ui/profile/register.cljs b/frontend/src/uxbox/main/ui/profile/register.cljs index a111d0ad01..d657e737f6 100644 --- a/frontend/src/uxbox/main/ui/profile/register.cljs +++ b/frontend/src/uxbox/main/ui/profile/register.cljs @@ -21,14 +21,12 @@ [uxbox.util.i18n :refer [tr]] [uxbox.util.router :as rt])) -(s/def ::username ::fm/not-empty-string) (s/def ::fullname ::fm/not-empty-string) (s/def ::password ::fm/not-empty-string) (s/def ::email ::fm/email) (s/def ::register-form - (s/keys :req-un [::username - ::password + (s/keys :req-un [::password ::fullname ::email])) @@ -43,11 +41,6 @@ {:type ::api :message "errors.api.form.email-already-exists"}) - :uxbox.services.users/username-already-exists - (swap! form assoc-in [:errors :username] - {:type ::api - :message "errors.api.form.username-already-exists"}) - (st/emit! (tr "errors.api.form.unexpected-error")))) (defn- on-submit @@ -76,20 +69,6 @@ :type #{::api} :field :fullname}] - [:input.input-text - {:type "text" - :name "username" - :tab-index "2" - :class (fm/error-class form :username) - :on-blur (fm/on-input-blur form :username) - :on-change (fm/on-input-change form :username) - :value (:username data "") - :placeholder (tr "profile.register.username")}] - - [:& fm/field-error {:form form - :type #{::api} - :field :username}] - [:input.input-text {:type "email" :name "email" diff --git a/frontend/src/uxbox/main/ui/settings.cljs b/frontend/src/uxbox/main/ui/settings.cljs index 62b4073d9e..00fe86e0b9 100644 --- a/frontend/src/uxbox/main/ui/settings.cljs +++ b/frontend/src/uxbox/main/ui/settings.cljs @@ -13,9 +13,8 @@ [uxbox.builtins.icons :as i] [uxbox.main.ui.messages :refer [messages-widget]] [uxbox.main.ui.settings.header :refer [header]] - [uxbox.main.ui.settings.notifications :as notifications] - [uxbox.main.ui.settings.password :as password] - [uxbox.main.ui.settings.profile :as profile])) + [uxbox.main.ui.settings.password :refer [password-page]] + [uxbox.main.ui.settings.profile :refer [profile-page]])) (mf/defc settings {:wrap [mf/wrap-memo]} @@ -25,9 +24,8 @@ [:& messages-widget] [:& header {:section section}] (case section - :settings/profile (mf/element profile/profile-page) - :settings/password (mf/element password/password-page) - :settings/notifications (mf/element notifications/notifications-page))])) + :settings-profile (mf/element profile-page) + :settings-password (mf/element password-page))])) diff --git a/frontend/src/uxbox/main/ui/settings/header.cljs b/frontend/src/uxbox/main/ui/settings/header.cljs index bad3063810..557d2bea0f 100644 --- a/frontend/src/uxbox/main/ui/settings/header.cljs +++ b/frontend/src/uxbox/main/ui/settings/header.cljs @@ -13,8 +13,8 @@ [uxbox.main.data.auth :as da] [uxbox.main.data.projects :as dp] [uxbox.main.store :as st] - [uxbox.main.ui.users :refer [user]] - [uxbox.util.i18n :refer [tr]] + [uxbox.main.ui.dashboard.header :refer [user]] + [uxbox.util.i18n :as i18n :refer [tr t]] [uxbox.util.router :as rt])) (mf/defc header-link @@ -24,25 +24,18 @@ (mf/defc header [{:keys [section] :as props}] - (let [profile? (= section :settings/profile) - password? (= section :settings/password) - notifications? (= section :settings/notifications)] + (let [profile? (= section :settings-profile) + password? (= section :settings-password)] [:header#main-bar.main-bar [:div.main-logo - [:& header-link {:section :dashboard/projects + [:& header-link {:section :dashboard-projects :content i/logo}]] [:ul.main-nav [:li {:class (when profile? "current")} - [:& header-link {:section :settings/profile + [:& header-link {:section :settings-profile :content (tr "settings.profile")}]] [:li {:class (when password? "current")} - [:& header-link {:section :settings/password - :content (tr "settings.password")}]] - [:li {:class (when notifications? "current")} - [:& header-link {:section :settings/notifications - :content (tr "settings.notifications")}]] - #_[:li {:on-click #(st/emit! (da/logout))} - [:& header-link {:section :logout - :content (tr "settings.exit")}]]] + [:& header-link {:section :settings-password + :content (tr "settings.password")}]]] [:& user]])) diff --git a/frontend/src/uxbox/main/ui/settings/password.cljs b/frontend/src/uxbox/main/ui/settings/password.cljs index fa286f9c1d..e2099bd5ce 100644 --- a/frontend/src/uxbox/main/ui/settings/password.cljs +++ b/frontend/src/uxbox/main/ui/settings/password.cljs @@ -2,8 +2,11 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2016-2019 Andrey Antukh -;; Copyright (c) 2016-2019 Juan de la Cruz +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2016-2020 Andrey Antukh +;; Copyright (c) 2016-2020 Juan de la Cruz (ns uxbox.main.ui.settings.password (:require @@ -30,14 +33,23 @@ [event form] (dom/prevent-default event) (let [data (:clean-data form) - opts {:on-success #(st/emit! (um/info (tr "settings.password.password-saved"))) + mdata {:on-success #(st/emit! (um/info (tr "settings.password.password-saved"))) :on-error #(on-error form %)}] - (st/emit! (udu/update-password data opts)))) + (st/emit! (udu/update-password (with-meta data mdata))))) (s/def ::password-1 ::fm/not-empty-string) (s/def ::password-2 ::fm/not-empty-string) (s/def ::password-old ::fm/not-empty-string) +(defn password-equality + [data] + (let [password-1 (:password-1 data) + password-2 (:password-2 data)] + (when (and password-1 password-2 + (not= password-1 password-2)) + {:password-2 {:code ::password-not-equal + :message "profile.password.not-equal"}}))) + (s/def ::password-form (s/keys :req-un [::password-1 ::password-2 @@ -45,7 +57,9 @@ (mf/defc password-form [props] - (let [{:keys [data] :as form} (fm/use-form ::password-form {})] + (let [{:keys [data] :as form} (fm/use-form2 :spec ::password-form + :validators [password-equality] + :initial {})] [:form.password-form {:on-submit #(on-submit % form)} [:span.user-settings-label (tr "settings.password.change-password")] [:input.input-text @@ -67,7 +81,8 @@ :on-blur (fm/on-input-blur form :password-1) :on-change (fm/on-input-change form :password-1) :placeholder (tr "settings.password.new-password")}] - ;; [:& fm/field-error {:form form :field :password-1}] + + [:& fm/field-error {:form form :field :password-1}] [:input.input-text {:type "password" @@ -77,7 +92,8 @@ :on-blur (fm/on-input-blur form :password-2) :on-change (fm/on-input-change form :password-2) :placeholder (tr "settings.password.confirm-password")}] - ;; [:& fm/field-error {:form form :field :password-2}] + + [:& fm/field-error {:form form :field :password-2}] [:input.btn-primary {:type "submit" diff --git a/frontend/src/uxbox/main/ui/settings/profile.cljs b/frontend/src/uxbox/main/ui/settings/profile.cljs index 0897eec680..7f741d3fe8 100644 --- a/frontend/src/uxbox/main/ui/settings/profile.cljs +++ b/frontend/src/uxbox/main/ui/settings/profile.cljs @@ -17,31 +17,19 @@ [uxbox.util.data :refer [read-string]] [uxbox.util.dom :as dom] [uxbox.util.forms :as fm] - [uxbox.util.i18n :as i18n :refer [tr]] - [uxbox.util.interop :refer [iterable->seq]] + [uxbox.util.i18n :as i18n :refer [tr t]] [uxbox.util.messages :as um])) - -(defn- profile->form - [profile] - (let [language (get-in profile [:metadata :language])] - (-> (select-keys profile [:fullname :username :email]) - (cond-> language (assoc :language language))))) - -(def ^:private profile-ref +(def ^:private profile-iref (-> (l/key :profile) (l/derive st/state))) (s/def ::fullname ::fm/not-empty-string) -(s/def ::username ::fm/not-empty-string) -(s/def ::language ::fm/not-empty-string) +(s/def ::lang ::fm/not-empty-string) (s/def ::email ::fm/email) (s/def ::profile-form - (s/keys :req-un [::fullname - ::username - ::language - ::email])) + (s/keys :req-un [::fullname ::lang ::email])) (defn- on-error [error form] @@ -56,26 +44,24 @@ {:type ::api :message "errors.api.form.username-already-exists"}))) -(defn- initial-data - [] - (merge {:language @i18n/locale} - (profile->form (deref profile-ref)))) - (defn- on-submit [event form] (dom/prevent-default event) (let [data (:clean-data form) on-success #(st/emit! (um/info (tr "settings.profile.profile-saved"))) on-error #(on-error % form)] - (st/emit! (udu/form->update-profile data on-success on-error)))) + (st/emit! (udu/update-profile (with-meta data + {:on-success on-success + :on-error on-error}))))) ;; --- Profile Form (mf/defc profile-form [props] - (let [{:keys [data] :as form} (fm/use-form ::profile-form initial-data)] + (let [locale (i18n/use-locale) + {:keys [data] :as form} (fm/use-form ::profile-form #(deref profile-iref))] [:form.profile-form {:on-submit #(on-submit % form)} - [:span.user-settings-label (tr "settings.profile.section-basic-data")] + [:span.user-settings-label (t locale "settings.profile.section-basic-data")] [:input.input-text {:type "text" :name "fullname" @@ -83,23 +69,11 @@ :on-blur (fm/on-input-blur form :fullname) :on-change (fm/on-input-change form :fullname) :value (:fullname data "") - :placeholder (tr "settings.profile.your-name")}] + :placeholder (t locale "settings.profile.your-name")}] [:& fm/field-error {:form form :type #{::api} :field :fullname}] - [:input.input-text - {:type "text" - :name "username" - :class (fm/error-class form :username) - :on-blur (fm/on-input-blur form :username) - :on-change (fm/on-input-change form :username) - :value (:username data "") - :placeholder (tr "settings.profile.your-username")}] - - [:& fm/field-error {:form form - :type #{::api} - :field :username}] [:input.input-text {:type "email" @@ -108,18 +82,18 @@ :on-blur (fm/on-input-blur form :email) :on-change (fm/on-input-change form :email) :value (:email data "") - :placeholder (tr "settings.profile.your-email")}] + :placeholder (t locale "settings.profile.your-email")}] [:& fm/field-error {:form form :type #{::api} :field :email}] - [:span.user-settings-label (tr "settings.profile.section-i18n-data")] - [:select.input-select {:value (:language data) - :name "language" - :class (fm/error-class form :language) - :on-blur (fm/on-input-blur form :language) - :on-change (fm/on-input-change form :language)} + [:span.user-settings-label (t locale "settings.profile.lang")] + [:select.input-select {:value (:lang data) + :name "lang" + :class (fm/error-class form :lang) + :on-blur (fm/on-input-blur form :lang) + :on-change (fm/on-input-change form :lang)} [:option {:value "en"} "English"] [:option {:value "fr"} "Français"]] @@ -127,28 +101,30 @@ {:type "submit" :class (when-not (:valid form) "btn-disabled") :disabled (not (:valid form)) - :value (tr "settings.update-settings")}]])) + :value (t locale "settings.update-settings")}]])) ;; --- Profile Photo Form (mf/defc profile-photo-form - [] - (letfn [(on-change [event] - (let [target (dom/get-target event) - file (-> (dom/get-files target) - (iterable->seq) - (first))] - (st/emit! (udu/update-photo file)) - (dom/clean-value! target)))] - (let [{:keys [photo] :as profile} (mf/deref profile-ref) - photo (if (or (str/empty? photo) (nil? photo)) - "images/avatar.jpg" - photo)] - [:form.avatar-form - [:img {:src photo}] - [:input {:type "file" - :value "" - :on-change on-change}]]))) + [props] + (let [photo (:photo-uri (mf/deref profile-iref)) + photo (if (or (str/empty? photo) (nil? photo)) + "images/avatar.jpg" + photo) + + on-change + (fn [event] + (let [target (dom/get-target event) + file (-> (dom/get-files target) + (array-seq) + (first))] + (st/emit! (udu/update-photo {:file file})) + (dom/clean-value! target)))] + [:form.avatar-form + [:img {:src photo}] + [:input {:type "file" + :value "" + :on-change on-change}]])) ;; --- Profile Page diff --git a/frontend/src/uxbox/main/ui/users.cljs b/frontend/src/uxbox/main/ui/users.cljs deleted file mode 100644 index bf2004f8c9..0000000000 --- a/frontend/src/uxbox/main/ui/users.cljs +++ /dev/null @@ -1,64 +0,0 @@ -;; This Source Code Form is subject to the terms of the Mozilla Public -;; License, v. 2.0. If a copy of the MPL was not distributed with this -;; file, You can obtain one at http://mozilla.org/MPL/2.0/. -;; -;; Copyright (c) 2016-2019 Andrey Antukh - -(ns uxbox.main.ui.users - (:require - [cuerdas.core :as str] - [lentes.core :as l] - [potok.core :as ptk] - [rumext.alpha :as mf] - [uxbox.builtins.icons :as i] - [uxbox.main.data.auth :as da] - [uxbox.main.data.lightbox :as udl] - [uxbox.main.store :as st] - [uxbox.main.ui.navigation :as nav] - [uxbox.util.dom :as dom] - [uxbox.util.i18n :refer (tr)] - [uxbox.util.router :as rt])) - -;; --- User Menu - -(mf/defc user-menu - [props] - (letfn [(on-click [event section] - (dom/stop-propagation event) - (if (keyword? section) - (st/emit! (rt/nav section)) - (st/emit! section)))] - [:ul.dropdown - [:li {:on-click #(on-click % :settings/profile)} - i/user - [:span (tr "ds.user.profile")]] - [:li {:on-click #(on-click % :settings/password)} - i/lock - [:span (tr "ds.user.password")]] - [:li {:on-click #(on-click % :settings/notifications)} - i/mail - [:span (tr "ds.user.notifications")]] - [:li {:on-click #(on-click % da/logout)} - i/exit - [:span (tr "ds.user.exit")]]])) - -;; --- User Widget - -(def profile-ref - (-> (l/key :profile) - (l/derive st/state))) - -(mf/defc user - [props] - (let [open (mf/use-state false) - profile (mf/deref profile-ref) - photo (if (str/empty? (:photo profile "")) - "/images/avatar.jpg" - (:photo profile))] - [:div.user-zone {:on-click #(st/emit! (rt/navigate :settings/profile)) - :on-mouse-enter #(reset! open true) - :on-mouse-leave #(reset! open false)} - [:span (:fullname profile)] - [:img {:src photo}] - (when @open - [:& user-menu])])) diff --git a/frontend/src/uxbox/main/ui/workspace/header.cljs b/frontend/src/uxbox/main/ui/workspace/header.cljs index 7d5876d953..65e679c9cb 100644 --- a/frontend/src/uxbox/main/ui/workspace/header.cljs +++ b/frontend/src/uxbox/main/ui/workspace/header.cljs @@ -2,6 +2,9 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; ;; Copyright (c) 2015-2017 Andrey Antukh ;; Copyright (c) 2015-2017 Juan de la Cruz @@ -17,7 +20,6 @@ [uxbox.main.refs :as refs] [uxbox.main.store :as st] [uxbox.main.ui.modal :as modal] - [uxbox.main.ui.users :refer [user]] [uxbox.main.ui.workspace.images :refer [import-image-modal]] [uxbox.util.i18n :refer [tr]] [uxbox.util.math :as mth] diff --git a/frontend/src/uxbox/util/forms.cljs b/frontend/src/uxbox/util/forms.cljs index a27054547f..7e77d11e1a 100644 --- a/frontend/src/uxbox/util/forms.cljs +++ b/frontend/src/uxbox/util/forms.cljs @@ -33,10 +33,6 @@ ([self f x y] (update-fn #(f % x y))) ([self f x y more] (update-fn #(apply f % x y more)))))) -(defn- translate-error-type - [name] - "errors.undefined-error") - (defn- interpret-problem [acc {:keys [path pred val via in] :as problem}] ;; (prn "interpret-problem" problem) @@ -45,11 +41,11 @@ (list? pred) (= (first (last pred)) 'cljs.core/contains?)) (let [path (conj path (last (last pred)))] - (assoc-in acc path {:name ::missing :type :builtin})) + (assoc-in acc path {:code ::missing :type :builtin})) (and (not (empty? path)) (not (empty? via))) - (assoc-in acc path {:name (last via) :type :builtin}) + (assoc-in acc path {:code (last via) :type :builtin}) :else acc)) @@ -72,6 +68,28 @@ (not= clean-data ::s/invalid))) (impl-mutator update-state)))) +(defn use-form2 + [& {:keys [spec validators initial]}] + (let [[state update-state] (mf/useState {:data (if (fn? initial) (initial) initial) + :errors {} + :touched {}}) + clean-data (s/conform spec (:data state)) + problems (when (= ::s/invalid clean-data) + (::s/problems (s/explain-data spec (:data state)))) + + errors (merge (reduce interpret-problem {} problems) + (when (not= clean-data ::s/invalid) + (reduce (fn [errors vf] + (merge errors (vf clean-data))) + {} validators)) + (:errors state))] + (-> (assoc state + :errors errors + :clean-data (when (not= clean-data ::s/invalid) clean-data) + :valid (and (empty? errors) + (not= clean-data ::s/invalid))) + (impl-mutator update-state)))) + (defn on-input-change [{:keys [data] :as form} field] (fn [event] @@ -95,17 +113,17 @@ [{:keys [form field type] :or {only (constantly true)} :as props}] - (let [touched? (get-in form [:touched field]) - {:keys [message code] :as error} (get-in form [:errors field])] - (when (and touched? error - (cond - (nil? type) true - (keyword? type) (= (:type error) type) - (ifn? type) (type (:type error)) - :else false)) - (prn "field-error" error) + (let [{:keys [code message] :as error} (get-in form [:errors field]) + touched? (get-in form [:touched field]) + show? (and touched? error message + (cond + (nil? type) true + (keyword? type) (= (:type error) type) + (ifn? type) (type (:type error)) + :else false))] + (when show? [:ul.form-errors - [:li {:key code} (tr message)]]))) + [:li {:key (:code error)} (tr (:message error))]]))) (defn error-class [form field] @@ -115,7 +133,6 @@ ;; --- Form Specs and Conformers -;; TODO: migrate to uxbox.util.spec (s/def ::email ::us/email) (s/def ::not-empty-string ::us/not-empty-string) (s/def ::color ::us/color)