From 03981628b8c1de78189dfbf8543e8696e93b3a67 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 5 Oct 2020 18:17:55 +0200 Subject: [PATCH] :sparkles: Add additional impl for teams administration. --- backend/src/app/http/handlers.clj | 2 +- backend/src/app/media.clj | 33 ++- backend/src/app/services/init.clj | 3 +- backend/src/app/services/mutations/media.clj | 14 +- .../src/app/services/mutations/profile.clj | 193 +++++--------- .../src/app/services/mutations/projects.clj | 15 +- backend/src/app/services/mutations/teams.clj | 251 +++++++++++++++++- .../app/services/mutations/verify_token.clj | 143 ++++++++++ backend/src/app/services/queries/profile.clj | 18 +- .../src/app/services/queries/recent_files.clj | 1 + backend/src/app/services/queries/teams.clj | 49 +++- 11 files changed, 562 insertions(+), 160 deletions(-) create mode 100644 backend/src/app/services/mutations/verify_token.clj diff --git a/backend/src/app/http/handlers.clj b/backend/src/app/http/handlers.clj index 1f887fea3..f859b7a36 100644 --- a/backend/src/app/http/handlers.clj +++ b/backend/src/app/http/handlers.clj @@ -20,7 +20,7 @@ #{:create-demo-profile :logout :profile - :verify-profile-token + :verify-token :recover-profile :register-profile :request-profile-recovery diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 6e0a77c36..91a59d611 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -10,19 +10,19 @@ (ns app.media "Media postprocessing." (:require + [app.common.data :as d] + [app.common.exceptions :as ex] + [app.common.media :as cm] + [app.common.spec :as us] + [app.config :as cfg] + [app.media-storage :as mst] + [app.util.http :as http] + [app.util.storage :as ust] [clojure.core.async :as a] [clojure.java.io :as io] [clojure.spec.alpha :as s] [datoteka.core :as fs] - [mount.core :refer [defstate]] - [app.config :as cfg] - [app.common.data :as d] - [app.common.media :as cm] - [app.common.exceptions :as ex] - [app.common.spec :as us] - [app.media-storage :as mst] - [app.util.storage :as ust] - [app.util.http :as http]) + [mount.core :refer [defstate]]) (:import java.io.ByteArrayInputStream java.io.InputStream @@ -34,6 +34,21 @@ (defstate semaphore :start (Semaphore. (:image-process-max-threads cfg/config 1))) + +;; --- Generic specs + +(s/def :internal.http.upload/filename ::us/string) +(s/def :internal.http.upload/size ::us/integer) +(s/def :internal.http.upload/content-type cm/valid-media-types) +(s/def :internal.http.upload/tempfile any?) + +(s/def ::upload + (s/keys :req-un [:internal.http.upload/filename + :internal.http.upload/size + :internal.http.upload/tempfile + :internal.http.upload/content-type])) + + ;; --- Thumbnails Generation (s/def ::cmd keyword?) diff --git a/backend/src/app/services/init.clj b/backend/src/app/services/init.clj index e08137926..455f8481a 100644 --- a/backend/src/app/services/init.clj +++ b/backend/src/app/services/init.clj @@ -28,7 +28,8 @@ (require 'app.services.mutations.projects) (require 'app.services.mutations.files) (require 'app.services.mutations.profile) - (require 'app.services.mutations.viewer)) + (require 'app.services.mutations.viewer) + (require 'app.services.mutations.verify-token)) (defstate query-services :start (load-query-services)) diff --git a/backend/src/app/services/mutations/media.clj b/backend/src/app/services/mutations/media.clj index f26bc7ede..1495656d3 100644 --- a/backend/src/app/services/mutations/media.clj +++ b/backend/src/app/services/mutations/media.clj @@ -46,19 +46,7 @@ (declare persist-media-object-on-fs) (declare persist-media-thumbnail-on-fs) -(s/def :app$upload/filename ::us/string) -(s/def :app$upload/size ::us/integer) -(s/def :app$upload/content-type cm/valid-media-types) -(s/def :app$upload/tempfile any?) - -(s/def ::upload - (s/keys :req-un [:app$upload/filename - :app$upload/size - :app$upload/tempfile - :app$upload/content-type])) - -(s/def ::content ::upload) - +(s/def ::content ::media/upload) (s/def ::is-local ::us/boolean) (s/def ::add-media-object-from-url diff --git a/backend/src/app/services/mutations/profile.clj b/backend/src/app/services/mutations/profile.clj index 6ce7a878d..a2f587cac 100644 --- a/backend/src/app/services/mutations/profile.clj +++ b/backend/src/app/services/mutations/profile.clj @@ -20,31 +20,28 @@ [app.media-storage :as mst] [app.http.session :as session] [app.services.mutations :as sm] - [app.services.mutations.media :as media-mutations] [app.services.mutations.projects :as projects] [app.services.mutations.teams :as teams] [app.services.queries.profile :as profile] [app.services.tokens :as tokens] + [app.services.mutations.verify-token :refer [process-token]] [app.tasks :as tasks] [app.util.blob :as blob] [app.util.storage :as ust] [app.util.time :as dt] - [buddy.core.codecs :as bc] - [buddy.core.nonce :as bn] [buddy.hashers :as hashers] [clojure.spec.alpha :as s] - [cuerdas.core :as str] - [datoteka.core :as fs])) + [cuerdas.core :as str])) ;; --- Helpers & Specs (s/def ::email ::us/email) -(s/def ::fullname ::us/string) -(s/def ::lang ::us/string) +(s/def ::fullname ::us/not-empty-string) +(s/def ::lang ::us/not-empty-string) (s/def ::path ::us/string) (s/def ::profile-id ::us/uuid) -(s/def ::password ::us/string) -(s/def ::old-password ::us/string) +(s/def ::password ::us/not-empty-string) +(s/def ::old-password ::us/not-empty-string) (s/def ::theme ::us/string) ;; --- Mutation: Register Profile @@ -52,22 +49,15 @@ (declare check-profile-existence!) (declare create-profile) (declare create-profile-relations) +(declare email-domain-in-whitelist?) +(s/def ::token ::us/not-empty-string) (s/def ::register-profile - (s/keys :req-un [::email ::password ::fullname])) - -(defn email-domain-in-whitelist? - "Returns true if email's domain is in the given whitelist or if given - whitelist is an empty string." - [whitelist email] - (if (str/blank? whitelist) - true - (let [domains (str/split whitelist #",\s*") - email-domain (second (str/split email #"@"))] - (contains? (set domains) email-domain)))) + (s/keys :req-un [::email ::password ::fullname] + :opt-un [::token])) (sm/defmutation ::register-profile - [params] + [{:keys [token] :as params}] (when-not (:registration-enabled cfg/config) (ex/raise :type :restriction :code :registration-disabled)) @@ -80,25 +70,68 @@ (db/with-atomic [conn db/pool] (check-profile-existence! conn params) (let [profile (->> (create-profile conn params) - (create-profile-relations conn)) - token (tokens/generate - {:iss :verify-email - :exp (dt/in-future "48h") - :profile-id (:id profile) - :email (:email profile)})] + (create-profile-relations conn))] - (emails/send! conn emails/register - {:to (:email profile) - :name (:fullname profile) - :token token}) - profile))) + (if token + ;; If token comes in params, this is because the user comes + ;; from team-invitation process; in this case we revalidate + ;; the token and process the token claims again with the new + ;; profile data. + (let [claims (tokens/verify token {:iss :team-invitation}) + claims (assoc claims :member-id (:id profile)) + params (assoc params :profile-id (:id profile))] + (process-token conn params claims) + + ;; Automatically mark the created profile as active because + ;; we already have the verification of email with the + ;; team-invitation token. + (db/update! conn :profile + {:is-active true} + {:id (:id profile)}) + + ;; Return profile data and create http session for + ;; automatically login the profile. + (with-meta (assoc profile + :is-active true + :claims claims) + {:transform-response + (fn [request response] + (let [uagent (get-in request [:headers "user-agent"]) + id (session/create (:id profile) uagent)] + (assoc response + :cookies (session/cookies id))))})) + + ;; If no token is provided, send a verification email + (let [token (tokens/generate + {:iss :verify-email + :exp (dt/in-future "48h") + :profile-id (:id profile) + :email (:email profile)})] + + (emails/send! conn emails/register + {:to (:email profile) + :name (:fullname profile) + :token token}) + + profile))))) + + +(defn email-domain-in-whitelist? + "Returns true if email's domain is in the given whitelist or if given + whitelist is an empty string." + [whitelist email] + (if (str/blank? whitelist) + true + (let [domains (str/split whitelist #",\s*") + email-domain (second (str/split email #"@"))] + (contains? (set domains) email-domain)))) (def ^:private sql:profile-existence "select exists (select * from profile where email = ? and deleted_at is null) as val") -(defn- check-profile-existence! +(defn check-profile-existence! [conn {:keys [email] :as params}] (let [email (str/lower email) result (db/exec-one! conn [sql:profile-existence email])] @@ -152,8 +185,6 @@ ;; --- Mutation: Login -(declare retrieve-profile-by-email) - (s/def ::email ::us/email) (s/def ::scope ::us/string) @@ -182,22 +213,12 @@ profile)] (db/with-atomic [conn db/pool] - (let [prof (-> (retrieve-profile-by-email conn email) + (let [prof (-> (profile/retrieve-profile-data-by-email conn email) (validate-profile) (profile/strip-private-attrs)) addt (profile/retrieve-additional-data conn (:id prof))] (merge prof addt))))) -(def sql:profile-by-email - "select * from profile - where email=? - and deleted_at is null") - -(defn- retrieve-profile-by-email - [conn email] - (let [email (str/lower email)] - (db/exec-one! conn [sql:profile-by-email email]))) - ;; --- Mutation: Register if not exists @@ -222,7 +243,7 @@ (create-profile-relations conn)))] (db/with-atomic [conn db/pool] - (let [profile (retrieve-profile-by-email conn email) + (let [profile (profile/retrieve-profile-data-by-email conn email) profile (if profile (populate-additional-data conn profile) (register-profile conn params))] @@ -273,10 +294,9 @@ ;; --- Mutation: Update Photo -(declare upload-photo) (declare update-profile-photo) -(s/def ::file ::media-mutations/upload) +(s/def ::file ::media/upload) (s/def ::update-profile-photo (s/keys :req-un [::profile-id ::file])) @@ -287,7 +307,7 @@ (let [profile (profile/retrieve-profile conn profile-id) _ (media/run {:cmd :info :input {:path (:tempfile file) :mtype (:content-type file)}}) - photo (upload-photo conn params)] + photo (teams/upload-photo conn params)] ;; Schedule deletion of old photo (when (and (string? (:photo profile)) @@ -297,22 +317,6 @@ ;; Save new photo (update-profile-photo conn profile-id photo)))) -(defn- upload-photo - [conn {:keys [file profile-id]}] - (let [prefix (-> (bn/random-bytes 8) - (bc/bytes->b64u) - (bc/bytes->str)) - thumb (media/run - {:cmd :profile-thumbnail - :format :jpeg - :quality 85 - :width 256 - :height 256 - :input {:path (fs/path (:tempfile file)) - :mtype (:content-type file)}}) - name (str prefix (cm/format->extension (:format thumb)))] - (ust/save! mst/media-storage name (:data thumb)))) - (defn- update-profile-photo [conn profile-id path] (db/update! conn :profile @@ -346,63 +350,10 @@ :token token}) nil))) -(defn- select-profile-for-update +(defn select-profile-for-update [conn id] (db/get-by-id conn :profile id {:for-update true})) - -;; --- Mutation: Verify Profile Token - -;; Generic mutation for perform token based verification for auth -;; domain. - -(defmulti process-token (fn [conn claims] (:iss claims))) - -(s/def ::verify-profile-token - (s/keys :req-un [::token])) - -(sm/defmutation ::verify-profile-token - [{:keys [token] :as params}] - (db/with-atomic [conn db/pool] - (let [claims (tokens/verify token)] - (process-token conn claims)))) - -(defmethod process-token :change-email - [conn {:keys [profile-id email] :as claims}] - (let [profile (select-profile-for-update conn profile-id)] - (check-profile-existence! conn {:email email}) - (db/update! conn :profile - {:email email} - {:id profile-id}) - claims)) - -(defmethod process-token :verify-email - [conn {:keys [profile-id] :as claims}] - (let [profile (select-profile-for-update conn profile-id)] - (when (:is-active profile) - (ex/raise :type :validation - :code :email-already-validated)) - (when (not= (:email profile) - (:email claims)) - (ex/raise :type :validation - :code :invalid-token)) - - (db/update! conn :profile - {:is-active true} - {:id (:id profile)}) - claims)) - -(defmethod process-token :auth - [conn {:keys [profile-id] :as claims}] - (let [profile (profile/retrieve-profile conn profile-id)] - (assoc claims :profile profile))) - -(defmethod process-token :default - [conn claims] - (ex/raise :type :validation - :code :invalid-token)) - - ;; --- Mutation: Request Profile Recovery (s/def ::request-profile-recovery @@ -425,7 +376,7 @@ (db/with-atomic [conn db/pool] (some->> email - (retrieve-profile-by-email conn) + (profile/retrieve-profile-data-by-email conn) (create-recovery-token conn) (send-email-notification conn)) nil))) diff --git a/backend/src/app/services/mutations/projects.clj b/backend/src/app/services/mutations/projects.clj index 2375721e5..d0332d212 100644 --- a/backend/src/app/services/mutations/projects.clj +++ b/backend/src/app/services/mutations/projects.clj @@ -107,18 +107,23 @@ ;; --- Mutation: Toggle Project Pin +(def ^:private + sql:update-project-pin + "insert into team_project_profile_rel (team_id, project_id, profile_id, is_pinned) + values (?, ?, ?, ?) + on conflict (team_id, project_id, profile_id) + do update set is_pinned=?") + (s/def ::is-pinned ::us/boolean) +(s/def ::project-id ::us/uuid) + (s/def ::update-project-pin (s/keys :req-un [::profile-id ::id ::team-id ::is-pinned])) (sm/defmutation ::update-project-pin [{:keys [id profile-id team-id is-pinned] :as params}] (db/with-atomic [conn db/pool] - (db/update! conn :team-project-profile-rel - {:is-pinned is-pinned} - {:profile-id profile-id - :project-id id - :team-id team-id}) + (db/exec-one! conn [sql:update-project-pin team-id id profile-id is-pinned is-pinned]) nil)) diff --git a/backend/src/app/services/mutations/teams.clj b/backend/src/app/services/mutations/teams.clj index b7615592b..305b70275 100644 --- a/backend/src/app/services/mutations/teams.clj +++ b/backend/src/app/services/mutations/teams.clj @@ -9,14 +9,28 @@ (ns app.services.mutations.teams (:require - [clojure.spec.alpha :as s] + [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.media :as cm] [app.common.spec :as us] [app.common.uuid :as uuid] [app.db :as db] + [app.emails :as emails] + [app.media :as media] + [app.media-storage :as mst] [app.services.mutations :as sm] [app.services.mutations.projects :as projects] - [app.util.blob :as blob])) + [app.services.queries.teams :as teams] + [app.services.tokens :as tokens] + [app.services.queries.profile :as profile] + [app.tasks :as tasks] + [app.util.storage :as ust] + [app.util.time :as dt] + [buddy.core.codecs :as bc] + [buddy.core.nonce :as bn] + [clojure.spec.alpha :as s] + [cuerdas.core :as str] + [datoteka.core :as fs])) ;; --- Helpers & Specs @@ -69,3 +83,236 @@ :default? true})] (projects/create-project-profile conn {:project-id (:id proj) :profile-id profile-id}))) + + +;; --- Mutation: Update Team + +(s/def ::update-team + (s/keys :req-un [::profile-id ::name ::id])) + +(sm/defmutation ::update-team + [{:keys [id name profile-id] :as params}] + (db/with-atomic [conn db/pool] + (teams/check-edition-permissions! conn profile-id id) + (db/update! conn :team + {:name name} + {:id id}) + nil)) + + +;; --- Mutation: Leave Team + +(s/def ::leave-team + (s/keys :req-un [::profile-id ::id])) + +(sm/defmutation ::leave-team + [{:keys [id profile-id] :as params}] + (db/with-atomic [conn db/pool] + (let [perms (teams/check-read-permissions! conn profile-id id) + members (teams/retrieve-team-members conn id)] + + (when (:is-owner perms) + (ex/raise :type :validation + :code :owner-cant-leave-team + :hint "reasing owner before leave")) + + (when-not (> (count members) 1) + (ex/raise :type :validation + :code :cant-leave-team + :context {:members (count members)})) + + (db/delete! conn :team-profile-rel + {:profile-id profile-id + :team-id id}) + + nil))) + + +;; --- Mutation: Delete Team + +(s/def ::delete-team + (s/keys :req-un [::profile-id ::id])) + +(sm/defmutation ::delete-team + [{:keys [id profile-id] :as params}] + (db/with-atomic [conn db/pool] + (let [perms (teams/check-edition-permissions! conn profile-id id)] + (when-not (:is-owner perms) + (ex/raise :type :validation + :code :only-owner-can-delete-team)) + + (db/delete! conn :team {:id id}) + nil))) + +;; --- Mutation: Tean Update Role + +(declare retrieve-team-member) +(declare role->params) + +(s/def ::team-id ::us/uuid) +(s/def ::member-id ::us/uuid) +(s/def ::role #{:owner :admin :editor :viewer}) + +(s/def ::update-team-member-role + (s/keys :req-un [::profile-id ::team-id ::member-id ::role])) + +(sm/defmutation ::update-team-member-role + [{:keys [team-id profile-id member-id role] :as params}] + (db/with-atomic [conn db/pool] + (let [perms (teams/check-read-permissions! conn profile-id team-id) + + ;; We retrieve all team members instead of query the + ;; database for a single member. This is just for + ;; convenience, if this bocomes a bottleneck or problematic, + ;; we will change it to more efficient fetch mechanims. + members (teams/retrieve-team-members conn team-id) + member (d/seek #(= member-id (:id %)) members)] + + ;; If no member is found, just 404 + (when-not member + (ex/raise :type :not-found + :code :member-does-not-exist)) + + ;; First check if we have permissions to change roles + (when-not (or (:is-owner perms) + (:is-admin perms)) + (ex/raise :type :validation + :code :insufficient-permissions)) + + ;; Don't allow change role of owner member + (when (:is-owner member) + (ex/raise :type :validation + :code :cant-change-role-to-owner)) + + ;; Don't allow promote to owner to admin users. + (when (and (= role :owner) + (not (:is-owner perms))) + (ex/raise :type :validation + :code :cant-promote-to-owner)) + + (let [params (role->params role)] + ;; Only allow single owner on team + (when (and (= role :owner) + (:is-owner perms)) + (db/update! conn :team-profile-rel + {:is-owner false} + {:team-id team-id + :profile-id profile-id})) + + (db/update! conn :team-profile-rel params + {:team-id team-id + :profile-id member-id}) + nil)))) + +(defn role->params + [role] + (case role + :admin {:is-owner false :is-admin true :can-edit true} + :editor {:is-owner false :is-admin false :can-edit true} + :owner {:is-owner true :is-admin true :can-edit true} + :viewer {:is-owner false :is-admin false :can-edit false})) + + +;; --- Mutation: Team Update Role + +(s/def ::delete-team-member + (s/keys :req-un [::profile-id ::team-id ::member-id])) + +(sm/defmutation ::delete-team-member + [{:keys [team-id profile-id member-id] :as params}] + (db/with-atomic [conn db/pool] + (let [perms (teams/check-read-permissions! conn profile-id team-id)] + (when-not (or (:is-owner perms) + (:is-admin perms)) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (when (= member-id profile-id) + (ex/raise :type :validation + :code :cant-remove-yourself)) + + (db/delete! conn :team-profile-rel {:profile-id member-id + :team-id team-id}) + + nil))) + + +;; --- Mutation: Update Team Photo + +(declare upload-photo) + +(s/def ::file ::media/upload) +(s/def ::update-team-photo + (s/keys :req-un [::profile-id ::team-id ::file])) + +(sm/defmutation ::update-team-photo + [{:keys [profile-id file team-id] :as params}] + (media/validate-media-type (:content-type file)) + (db/with-atomic [conn db/pool] + (teams/check-edition-permissions! conn profile-id team-id) + (let [team (teams/retrieve-team conn profile-id team-id) + _ (media/run {:cmd :info :input {:path (:tempfile file) + :mtype (:content-type file)}}) + photo (upload-photo conn params)] + + ;; Schedule deletion of old photo + (when (and (string? (:photo team)) + (not (str/blank? (:photo team)))) + (tasks/submit! conn {:name "remove-media" + :props {:path (:photo team)}})) + ;; Save new photo + (db/update! conn :team + {:photo (str photo)} + {:id team-id}) + + (assoc team :photo (str photo))))) + +(defn upload-photo + [conn {:keys [file profile-id]}] + (let [prefix (-> (bn/random-bytes 8) + (bc/bytes->b64u) + (bc/bytes->str)) + thumb (media/run + {:cmd :profile-thumbnail + :format :jpeg + :quality 85 + :width 256 + :height 256 + :input {:path (fs/path (:tempfile file)) + :mtype (:content-type file)}}) + name (str prefix (cm/format->extension (:format thumb)))] + (ust/save! mst/media-storage name (:data thumb)))) + + +;; --- Mutation: Invite Member + +(s/def ::email ::us/email) +(s/def ::invite-team-member + (s/keys :req-un [::profile-id ::team-id ::email ::role])) + +(sm/defmutation ::invite-team-member + [{:keys [profile-id team-id email role] :as params}] + (db/with-atomic [conn db/pool] + (let [perms (teams/check-edition-permissions! conn profile-id team-id) + profile (db/get-by-id conn :profile profile-id) + member (profile/retrieve-profile-data-by-email conn email) + team (db/get-by-id conn :team team-id) + token (tokens/generate + {:iss :team-invitation + :exp (dt/in-future "24h") + :profile-id (:id profile) + :role role + :team-id team-id + :member-email (:email member email) + :member-id (:id member)})] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (emails/send! conn emails/invite-to-team + {:to email + :invited-by (:fullname profile) + :team (:name team) + :token token}) + nil))) diff --git a/backend/src/app/services/mutations/verify_token.clj b/backend/src/app/services/mutations/verify_token.clj new file mode 100644 index 000000000..638a04879 --- /dev/null +++ b/backend/src/app/services/mutations/verify_token.clj @@ -0,0 +1,143 @@ +;; 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) 2020 UXBOX Labs SL + +(ns app.services.mutations.verify-token + (:require + [app.common.exceptions :as ex] + [app.common.media :as cm] + [app.common.spec :as us] + [app.common.uuid :as uuid] + [app.config :as cfg] + [app.db :as db] + [app.emails :as emails] + [app.http.session :as session] + [app.media :as media] + [app.media-storage :as mst] + [app.services.mutations :as sm] + [app.services.mutations.teams :as teams] + [app.services.queries.profile :as profile] + [app.services.tokens :as tokens] + [app.tasks :as tasks] + [app.util.blob :as blob] + [app.util.storage :as ust] + [app.util.time :as dt] + [buddy.hashers :as hashers] + [clojure.spec.alpha :as s] + [cuerdas.core :as str])) + +(defmulti process-token (fn [conn params claims] (:iss claims))) + +(s/def ::verify-token + (s/keys :req-un [::token] + :opt-un [::profile-id])) + +(sm/defmutation ::verify-token + [{:keys [token] :as params}] + (db/with-atomic [conn db/pool] + (let [claims (tokens/verify token)] + (process-token conn params claims)))) + +(defmethod process-token :change-email + [conn params {:keys [profile-id email] :as claims}] + (let [profile (db/get-by-id conn :profile profile-id {:for-update true})] + (when (profile/retrieve-profile-data-by-email conn email) + (ex/raise :type :validation + :code :email-already-exists)) + (db/update! conn :profile + {:email email} + {:id profile-id}) + claims)) + +(defmethod process-token :verify-email + [conn params {:keys [profile-id] :as claims}] + (let [profile (db/get-by-id conn :profile profile-id {:for-update true})] + (when (:is-active profile) + (ex/raise :type :validation + :code :email-already-validated)) + (when (not= (:email profile) + (:email claims)) + (ex/raise :type :validation + :code :invalid-token)) + + (db/update! conn :profile + {:is-active true} + {:id (:id profile)}) + claims)) + +(defmethod process-token :auth + [conn params {:keys [profile-id] :as claims}] + (let [profile (profile/retrieve-profile conn profile-id)] + (assoc claims :profile profile))) + + +;; --- Team Invitation + +(s/def ::iss keyword?) +(s/def ::exp ::us/inst) + +(s/def :internal.tokens.team-invitation/profile-id ::us/uuid) +(s/def :internal.tokens.team-invitation/role ::us/keyword) +(s/def :internal.tokens.team-invitation/team-id ::us/uuid) +(s/def :internal.tokens.team-invitation/member-email ::us/email) +(s/def :internal.tokens.team-invitation/member-id (s/nilable ::us/uuid)) + +(s/def ::team-invitation-claims + (s/keys :req-un [::iss ::exp + :internal.tokens.team-invitation/profile-id + :internal.tokens.team-invitation/role + :internal.tokens.team-invitation/team-id + :internal.tokens.team-invitation/member-email] + :opt-un [:internal.tokens.team-invitation/member-id])) + +(defmethod process-token :team-invitation + [conn {:keys [profile-id token]} {:keys [member-id team-id role] :as claims}] + (us/assert ::team-invitation-claims claims) + (if (uuid? member-id) + (let [params (merge {:team-id team-id + :profile-id member-id} + (teams/role->params role)) + claims (assoc claims :state :created)] + (db/insert! conn :team-profile-rel params) + (if (and (uuid? profile-id) + (= member-id profile-id)) + ;; If the current session is already matches the invited + ;; member, then just return the token and leave the frontend + ;; app redirect to correct team. + claims + + ;; If the session does not matches the invited member id, + ;; replace the session with a new one matching the invited + ;; member. This techinique should be considered secure because + ;; the user clicking the link he already has access to the + ;; email account. + (with-meta claims + {:transform-response + (fn [request response] + (let [uagent (get-in request [:headers "user-agent"]) + id (session/create member-id uagent)] + (assoc response + :cookies (session/cookies id))))}))) + + ;; In this case, we waint until frontend app redirect user to + ;; registeration page, the user is correctly registered and the + ;; register mutation call us again with the same token to finally + ;; create the corresponding team-profile relation from the first + ;; condition of this if. + (assoc claims + :token token + :state :pending))) + + +;; --- Default + +(defmethod process-token :default + [conn params claims] + (ex/raise :type :validation + :code :invalid-token)) + diff --git a/backend/src/app/services/queries/profile.clj b/backend/src/app/services/queries/profile.clj index cbbee35b6..29b64583e 100644 --- a/backend/src/app/services/queries/profile.clj +++ b/backend/src/app/services/queries/profile.clj @@ -2,11 +2,15 @@ ;; 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 Andrey Antukh +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns app.services.queries.profile (:require [clojure.spec.alpha :as s] + [cuerdas.core :as str] [app.common.exceptions :as ex] [app.common.spec :as us] [app.db :as db] @@ -87,6 +91,18 @@ profile)) + +(def sql:profile-by-email + "select * from profile + where email=? + and deleted_at is null") + +(defn retrieve-profile-data-by-email + [conn email] + (let [email (str/lower email)] + (db/exec-one! conn [sql:profile-by-email email]))) + + ;; --- Attrs Helpers (defn strip-private-attrs diff --git a/backend/src/app/services/queries/recent_files.clj b/backend/src/app/services/queries/recent_files.clj index 26937821b..159bd3025 100644 --- a/backend/src/app/services/queries/recent_files.clj +++ b/backend/src/app/services/queries/recent_files.clj @@ -25,6 +25,7 @@ join project as p on (p.id = f.project_id) where p.team_id = ? and p.deleted_at is null + and f.deleted_at is null window w as (partition by f.project_id order by f.modified_at desc) order by f.modified_at desc ) diff --git a/backend/src/app/services/queries/teams.clj b/backend/src/app/services/queries/teams.clj index 5efb6ee63..cddd8b5ab 100644 --- a/backend/src/app/services/queries/teams.clj +++ b/backend/src/app/services/queries/teams.clj @@ -35,7 +35,8 @@ (:is-admin row) (:is-owner row)) (ex/raise :type :validation - :code :not-authorized)))) + :code :not-authorized)) + row)) (defn check-read-permissions! [conn profile-id team-id] @@ -43,7 +44,8 @@ ;; when row is found this means that read permission is granted. (when-not row (ex/raise :type :validation - :code :not-authorized)))) + :code :not-authorized)) + row)) ;; --- Query: Teams @@ -76,9 +78,8 @@ (let [defaults (profile/retrieve-additional-data conn profile-id)] (db/exec! conn [sql:teams (:default-team-id defaults) profile-id]))) -;; --- Query: Projec by ID +;; --- Query: Team (by ID) -(declare retrieve-team-projects) (declare retrieve-team) (s/def ::id ::us/uuid) @@ -90,8 +91,42 @@ (with-open [conn (db/open)] (retrieve-team conn profile-id id))) -(defn- retrieve-team +(defn retrieve-team [conn profile-id team-id] (let [defaults (profile/retrieve-additional-data conn profile-id) - sql (str "WITH teams AS (" sql:teams ") SELECT * FROM teams WHERE id=?")] - (db/exec-one! conn [sql (:default-team-id defaults) profile-id team-id]))) + sql (str "WITH teams AS (" sql:teams ") SELECT * FROM teams WHERE id=?") + result (db/exec-one! conn [sql (:default-team-id defaults) profile-id team-id])] + (when-not result + (ex/raise :type :not-found + :code :object-does-not-exists)) + result)) + + +;; --- Query: Team Members + +(declare retrieve-team-members) + +(s/def ::team-id ::us/uuid) +(s/def ::team-members + (s/keys :req-un [::profile-id ::team-id])) + +(sq/defquery ::team-members + [{:keys [profile-id team-id]}] + (with-open [conn (db/open)] + (check-edition-permissions! conn profile-id team-id) + (retrieve-team-members conn team-id))) + +(def sql:team-members + "select tp.*, + p.id, + p.email, + p.fullname as name, + p.photo, + p.is_active + from team_profile_rel as tp + join profile as p on (p.id = tp.profile_id) + where tp.team_id = ?") + +(defn retrieve-team-members + [conn team-id] + (db/exec! conn [sql:team-members team-id]))