diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 5bd604711..d0c3bbcff 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -250,6 +250,7 @@ 'app.rpc.commands.projects 'app.rpc.commands.search 'app.rpc.commands.teams + 'app.rpc.commands.teams-invitations 'app.rpc.commands.verify-token 'app.rpc.commands.viewer 'app.rpc.commands.webhooks) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index b129ccd76..3b746f8ab 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -36,7 +36,8 @@ [app.util.services :as sv] [app.util.time :as dt] [app.worker :as wrk] - [cuerdas.core :as str])) + [cuerdas.core :as str] + [promesa.exec :as px])) ;; --- FEATURES @@ -245,16 +246,16 @@ file))) (defn get-file - [{:keys [::db/conn] :as cfg} id & {:keys [project-id - migrate? - include-deleted? - lock-for-update?] - :or {include-deleted? false - lock-for-update? false - migrate? true}}] - (dm/assert! - "expected cfg with valid connection" - (db/connection-map? cfg)) + [{:keys [::db/conn ::wrk/executor] :as cfg} id + & {:keys [project-id + migrate? + include-deleted? + lock-for-update?] + :or {include-deleted? false + lock-for-update? false + migrate? true}}] + + (assert (db/connection? conn) "expected cfg with valid connection") (let [params (merge {:id id} (when (some? project-id) @@ -263,8 +264,14 @@ {::db/check-deleted (not include-deleted?) ::db/remove-deleted (not include-deleted?) ::sql/for-update lock-for-update?}) - (feat.fdata/resolve-file-data cfg) - (decode-row))] + (feat.fdata/resolve-file-data cfg)) + + ;; NOTE: we perform the file decoding in a separate thread + ;; because it has heavy and synchronous operations for + ;; decoding file body that are not very friendly with virtual + ;; threads. + file (px/invoke! executor #(decode-row file))] + (if (and migrate? (fmg/need-migration? file)) (migrate-file cfg file) file))) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 5bb92c5df..a9850a6e5 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -10,7 +10,6 @@ [app.common.data.macros :as dm] [app.common.exceptions :as ex] [app.common.features :as cfeat] - [app.common.logging :as l] [app.common.schema :as sm] [app.common.types.team :as tt] [app.common.uuid :as uuid] @@ -25,17 +24,13 @@ [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.doc :as-alias doc] - [app.rpc.helpers :as rph] [app.rpc.permissions :as perms] [app.rpc.quotes :as quotes] [app.setup :as-alias setup] [app.storage :as sto] - [app.tokens :as tokens] - [app.util.blob :as blob] [app.util.services :as sv] [app.util.time :as dt] - [app.worker :as wrk] - [cuerdas.core :as str])) + [app.worker :as wrk])) ;; --- Helpers & Specs @@ -84,7 +79,9 @@ (cond-> row (some? features) (assoc :features (db/decode-pgarray features #{})))) -(defn- check-profile-muted +;; FIXME: move + +(defn check-profile-muted "Check if the member's email is part of the global bounce report" [conn member] (let [email (profile/clean-email (:email member))] @@ -94,7 +91,7 @@ :email email :hint "the profile has reported repeatedly as spam or has bounces")))) -(defn- check-email-bounce +(defn check-email-bounce "Check if the email is part of the global complain report" [conn email show?] (when (eml/has-bounce-reports? conn email) @@ -103,7 +100,7 @@ :email (if show? email "private") :hint "this email has been repeatedly reported as bounce"))) -(defn- check-email-spam +(defn check-email-spam "Check if the member email is part of the global complain report" [conn email show?] (when (eml/has-complaint-reports? conn email) @@ -267,6 +264,8 @@ [:fn #(or (contains? % :team-id) (contains? % :file-id))]]) +;; FIXME: split in two separated requests + (sv/defmethod ::get-team-users "Get team users by team-id or by file-id" {::doc/added "1.17" @@ -304,20 +303,29 @@ inner join project as p on (f.project_id = p.id) where p.team_id = ?") -(def sql:team-by-file - "select p.team_id as id - from project as p - join file as f on (p.id = f.project_id) - where f.id = ?") - (defn get-users [conn team-id] (db/exec! conn [sql:team-users team-id team-id team-id])) +(def sql:get-team-by-file + "SELECT t.* + FROM team AS t + JOIN project AS p ON (p.team_id = t.id) + JOIN file AS f ON (f.project_id = p.id) + WHERE f.id = ?") + (defn get-team-for-file [conn file-id] - (->> [sql:team-by-file file-id] - (db/exec-one! conn))) + (let [team (->> (db/exec! conn [sql:get-team-by-file file-id]) + (remove db/is-row-deleted?) + (map decode-row) + (first))] + (when-not team + (ex/raise :type :not-found + :code :object-not-found + :hint "database object not found")) + + team)) ;; --- Query: Team Stats @@ -505,8 +513,6 @@ ;; --- Mutation: Leave Team -(declare role->params) - (defn leave-team [conn {:keys [profile-id id reassign-to]}] (let [perms (get-permissions conn profile-id id) @@ -536,7 +542,7 @@ ;; assign owner role to new profile (db/update! conn :team-profile-rel - (role->params :owner) + (get tt/permissions-for-role :owner) {:team-id id :profile-id reassign-to})) ;; and finally, if all other conditions does not match and the @@ -607,16 +613,6 @@ nil))) ;; --- Mutation: Team Update Role -(def schema:role - [::sm/one-of tt/valid-roles]) - -(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})) (defn update-team-member-role [{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id team-id member-id role] :as params}] @@ -657,7 +653,7 @@ :team-id team-id :role role}) - (let [params (role->params role)] + (let [params (get tt/permissions-for-role role)] ;; Only allow single owner on team (when (= role :owner) (db/update! conn :team-profile-rel @@ -675,7 +671,7 @@ [:map {:title "update-team-member-role"} [:team-id ::sm/uuid] [:member-id ::sm/uuid] - [:role schema:role]]) + [:role ::tt/role]]) (sv/defmethod ::update-team-member-role {::doc/added "1.17" @@ -755,536 +751,3 @@ {:id team-id}) (assoc team :photo-id (:id photo))))) - -;; --- Mutation: Create Team Invitation - -(def sql:upsert-team-invitation - "insert into team_invitation(id, team_id, email_to, role, valid_until, created_by) - values (?, ?, ?, ?, ?, ?) - on conflict(team_id, email_to) do - update set role = ?, valid_until = ?, updated_at = now() - returning *") - -(defn- create-invitation-token - [cfg {:keys [profile-id valid-until team-id member-id member-email role]}] - (tokens/generate (::setup/props cfg) - {:iss :team-invitation - :exp valid-until - :profile-id profile-id - :role role - :team-id team-id - :member-email member-email - :member-id member-id})) - -(defn- create-profile-identity-token - [cfg profile-id] - - (dm/assert! - "expected valid uuid for profile-id" - (uuid? profile-id)) - - (tokens/generate (::setup/props cfg) - {:iss :profile-identity - :profile-id profile-id - :exp (dt/in-future {:days 30})})) - -(def ^:private schema:create-invitation - [:map {:title "params:create-invitation"} - [::rpc/profile-id ::sm/uuid] - [:team - [:map - [:id ::sm/uuid] - [:name :string]]] - [:profile - [:map - [:id ::sm/uuid] - [:fullname :string]]] - [:role [::sm/one-of tt/valid-roles]] - [:email ::sm/email]]) - -(def ^:private check-create-invitation-params! - (sm/check-fn schema:create-invitation)) - -(defn- create-invitation - [{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}] - - (dm/assert! - "expected valid connection on cfg parameter" - (db/connection? conn)) - - (dm/assert! - "expected valid params for `create-invitation` fn" - (check-create-invitation-params! params)) - - (let [email (profile/clean-email email) - member (profile/get-profile-by-email conn email)] - - (check-profile-muted conn member) - (check-email-bounce conn email true) - (check-email-spam conn email true) - - ;; When we have email verification disabled and invitation user is - ;; already present in the database, we proceed to add it to the - ;; team as-is, without email roundtrip. - - ;; TODO: if member does not exists and email verification is - ;; disabled, we should proceed to create the profile (?) - (if (and (not (contains? cf/flags :email-verification)) - (some? member)) - (let [params (merge {:team-id (:id team) - :profile-id (:id member)} - (role->params role))] - - ;; Insert the invited member to the team - (db/insert! conn :team-profile-rel params - {::db/on-conflict-do-nothing? true}) - - ;; If profile is not yet verified, mark it as verified because - ;; accepting an invitation link serves as verification. - (when-not (:is-active member) - (db/update! conn :profile - {:is-active true} - {:id (:id member)})) - - nil) - - (let [id (uuid/next) - expire (dt/in-future "168h") ;; 7 days - invitation (db/exec-one! conn [sql:upsert-team-invitation id - (:id team) (str/lower email) - (name role) expire - (:id profile) - (name role) expire]) - updated? (not= id (:id invitation)) - profile-id (:id profile) - tprops {:profile-id profile-id - :invitation-id (:id invitation) - :valid-until expire - :team-id (:id team) - :member-email (:email-to invitation) - :member-id (:id member) - :role role} - itoken (create-invitation-token cfg tprops) - ptoken (create-profile-identity-token cfg profile-id)] - - (when (contains? cf/flags :log-invitation-tokens) - (l/info :hint "invitation token" :token itoken)) - - (let [props (-> (dissoc tprops :profile-id) - (audit/clean-props)) - evname (if updated? - "update-team-invitation" - "create-team-invitation") - event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name evname) - (assoc ::audit/props props))] - (audit/submit! cfg event)) - - (eml/send! {::eml/conn conn - ::eml/factory eml/invite-to-team - :public-uri (cf/get :public-uri) - :to email - :invited-by (:fullname profile) - :team (:name team) - :token itoken - :extra-data ptoken}) - - itoken)))) - -(defn- add-user-to-team - [conn profile team role email] - - (let [team-id (:id team) - member (db/get* conn :profile - {:email (str/lower email)} - {::sql/columns [:id :email]}) - params (merge - {:team-id team-id - :profile-id (:id member)} - (role->params role))] - - ;; Do not allow blocked users to join teams. - (when (:is-blocked member) - (ex/raise :type :restriction - :code :profile-blocked)) - - (quotes/check! - {::db/conn conn - ::quotes/id ::quotes/profiles-per-team - ::quotes/profile-id (:id member) - ::quotes/team-id team-id}) - - ;; Insert the member to the team - (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) - - ;; Delete any request - (db/delete! conn :team-access-request - {:team-id team-id :requester-id (:id member)}) - - ;; Delete any invitation - (db/delete! conn :team-invitation - {:team-id team-id :email-to (:email member)}) - - (eml/send! {::eml/conn conn - ::eml/factory eml/join-team - :public-uri (cf/get :public-uri) - :to email - :invited-by (:fullname profile) - :team (:name team) - :team-id (:id team)}))) - -(def sql:valid-requests-email - "SELECT p.email - FROM team_access_request AS tr - JOIN profile AS p ON (tr.requester_id = p.id) - WHERE tr.team_id = ? - AND tr.auto_join_until > now()") - -(defn- get-valid-requests-email - [conn team-id] - (db/exec! conn [sql:valid-requests-email team-id])) - -(def ^:private xf:map-email - (map :email)) - -(defn- create-team-invitations - [{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}] - (let [join-requests (into #{} xf:map-email - (get-valid-requests-email conn (:id team))) - team-members (into #{} xf:map-email - (get-team-members conn (:id team))) - - invitations (into #{} - (comp - ;; We don't re-send inviation to - ;; already existing members - (remove team-members) - ;; We don't send invitations to - ;; join-requested members - (remove join-requests) - (map (fn [email] (assoc params :email email))) - (keep (partial create-invitation cfg))) - emails)] - - ;; For requested invitations, do not send invitation emails, add - ;; the user directly to the team - (->> (filter join-requests emails) - (run! (partial add-user-to-team conn profile team role))) - - invitations)) - -(def ^:private schema:create-team-invitations - [:map {:title "create-team-invitations"} - [:team-id ::sm/uuid] - [:role schema:role] - [:emails [::sm/set ::sm/email]]]) - -(def ^:private max-invitations-by-request-threshold - "The number of invitations can be sent in a single rpc request" - 25) - -(sv/defmethod ::create-team-invitations - "A rpc call that allow to send a single or multiple invitations to - join the team." - {::doc/added "1.17" - ::sm/params schema:create-team-invitations} - [cfg {:keys [::rpc/profile-id team-id emails] :as params}] - (let [perms (get-permissions cfg profile-id team-id) - profile (db/get-by-id cfg :profile profile-id) - emails (into #{} (map profile/clean-email) emails)] - - (when-not (:is-admin perms) - (ex/raise :type :validation - :code :insufficient-permissions)) - - (when (> (count emails) max-invitations-by-request-threshold) - (ex/raise :type :validation - :code :max-invitations-by-request - :hint "the maximum of invitation on single request is reached" - :threshold max-invitations-by-request-threshold)) - - (-> cfg - (assoc ::quotes/profile-id profile-id) - (assoc ::quotes/team-id team-id) - (assoc ::quotes/incr (count emails)) - (quotes/check! {::quotes/id ::quotes/invitations-per-team} - {::quotes/id ::quotes/profiles-per-team})) - - ;; Check if the current profile is allowed to send emails - (check-profile-muted cfg profile) - - (let [team (db/get-by-id cfg :team team-id) - ;; NOTE: Is important pass RPC method params down to the - ;; `create-team-invitations` because it uses the implicit - ;; RPC properties from params for fill necessary data on - ;; emiting an entry to the audit-log - invitations (db/tx-run! cfg create-team-invitations - (-> params - (assoc :profile profile) - (assoc :team team) - (assoc :emails emails)))] - - (with-meta {:total (count invitations) - :invitations invitations} - {::audit/props {:invitations (count invitations)}})))) - -;; --- Mutation: Create Team & Invite Members - -(def ^:private schema:create-team-with-invitations - [:map {:title "create-team-with-invitations"} - [:name [:string {:max 250}]] - [:features {:optional true} ::cfeat/features] - [:id {:optional true} ::sm/uuid] - [:emails [::sm/set ::sm/email]] - [:role schema:role]]) - -(sv/defmethod ::create-team-with-invitations - {::doc/added "1.17" - ::sm/params schema:create-team-with-invitations - ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id emails role name] :as params}] - (let [features (-> (cfeat/get-enabled-features cf/flags) - (cfeat/check-client-features! (:features params))) - - params (-> params - (assoc :profile-id profile-id) - (assoc :features features)) - - team (create-team cfg params) - emails (into #{} (map profile/clean-email) emails)] - - (-> cfg - (assoc ::quotes/profile-id profile-id) - (assoc ::quotes/team-id (:id team)) - (assoc ::quotes/incr (count emails)) - (quotes/check! {::quotes/id ::quotes/teams-per-profile} - {::quotes/id ::quotes/invitations-per-team} - {::quotes/id ::quotes/profiles-per-team})) - - (when (> (count emails) max-invitations-by-request-threshold) - (ex/raise :type :validation - :code :max-invitations-by-request - :hint "the maximum of invitation on single request is reached" - :threshold max-invitations-by-request-threshold)) - - (let [props {:name name :features features} - event (-> (audit/event-from-rpc-params params) - (assoc ::audit/name "create-team") - (assoc ::audit/props props))] - (audit/submit! cfg event)) - - ;; Create invitations for all provided emails. - (let [profile (db/get-by-id conn :profile profile-id) - params (-> params - (assoc :team team) - (assoc :profile profile) - (assoc :role role)) - invitations (->> emails - (map (fn [email] (assoc params :email email))) - (map (partial create-invitation cfg)))] - - (vary-meta team assoc ::audit/props {:invitations (count invitations)})))) - -;; --- Query: get-team-invitation-token - -(def ^:private schema:get-team-invitation-token - [:map {:title "get-team-invitation-token"} - [:team-id ::sm/uuid] - [:email ::sm/email]]) - -(sv/defmethod ::get-team-invitation-token - {::doc/added "1.17" - ::sm/params schema:get-team-invitation-token} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (check-read-permissions! pool profile-id team-id) - (let [email (profile/clean-email email) - invit (-> (db/get pool :team-invitation - {:team-id team-id - :email-to email}) - (update :role keyword)) - - member (profile/get-profile-by-email pool (:email-to invit)) - token (create-invitation-token cfg {:team-id (:team-id invit) - :profile-id profile-id - :valid-until (:valid-until invit) - :role (:role invit) - :member-id (:id member) - :member-email (or (:email member) - (profile/clean-email (:email-to invit)))})] - {:token token})) - -;; --- Mutation: Update invitation role - -(def ^:private schema:update-team-invitation-role - [:map {:title "update-team-invitation-role"} - [:team-id ::sm/uuid] - [:email ::sm/email] - [:role schema:role]]) - -(sv/defmethod ::update-team-invitation-role - {::doc/added "1.17" - ::sm/params schema:update-team-invitation-role} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email role] :as params}] - (db/with-atomic [conn pool] - (let [perms (get-permissions conn profile-id team-id)] - - (when-not (:is-admin perms) - (ex/raise :type :validation - :code :insufficient-permissions)) - - (db/update! conn :team-invitation - {:role (name role) :updated-at (dt/now)} - {:team-id team-id :email-to (profile/clean-email email)}) - - nil))) - -;; --- Mutation: Delete invitation - -(def ^:private schema:delete-team-invition - [:map {:title "delete-team-invitation"} - [:team-id ::sm/uuid] - [:email ::sm/email]]) - -(sv/defmethod ::delete-team-invitation - {::doc/added "1.17" - ::sm/params schema:delete-team-invition} - [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] - (db/with-atomic [conn pool] - (let [perms (get-permissions conn profile-id team-id)] - - (when-not (:is-admin perms) - (ex/raise :type :validation - :code :insufficient-permissions)) - - (let [invitation (db/delete! conn :team-invitation - {:team-id team-id - :email-to (profile/clean-email email)} - {::db/return-keys true})] - (rph/wrap nil {::audit/props {:invitation-id (:id invitation)}}))))) - - - - -;; --- Mutation: Request Team Invitation - -(def sql:upsert-team-access-request - "INSERT INTO team_access_request (id, team_id, requester_id, valid_until, auto_join_until) - VALUES (?, ?, ?, ?, ?) - ON conflict(id) - DO UPDATE SET valid_until = ?, auto_join_until = ?, updated_at = now() - RETURNING *") - - -(def sql:team-access-request - "SELECT id, (valid_until < now()) AS expired - FROM team_access_request - WHERE team_id = ? - AND requester_id = ?") - -(def sql:team-owner - "SELECT profile_id - FROM team_profile_rel - WHERE team_id = ? - AND is_owner = true") - - -(defn- create-team-access-request - [{:keys [::db/conn] :as cfg} {:keys [team requester team-owner file is-viewer] :as params}] - (let [old-request (->> (db/exec-one! conn [sql:team-access-request (:id team) (:id requester)]) - (decode-row))] - (when (false? (:expired old-request)) - (ex/raise :type :validation - :code :request-already-sent - :hint "you have already made a request to join this team less than 24 hours ago")) - - (let [id (or (:id old-request) (uuid/next)) - valid_until (dt/in-future "24h") - auto_join_until (dt/in-future "168h") ;; 7 days - request (db/exec-one! conn [sql:upsert-team-access-request - id (:id team) (:id requester) valid_until auto_join_until - valid_until auto_join_until]) - factory (cond - (and (some? file) (:is-default team) is-viewer) - eml/request-file-access-yourpenpot-view - (and (some? file) (:is-default team)) - eml/request-file-access-yourpenpot - (some? file) - eml/request-file-access - :else - eml/request-team-access) - page-id (when (some? file) - (-> file :data :pages first))] - - ;; TODO needs audit? - - (eml/send! {::eml/conn conn - ::eml/factory factory - :public-uri (cf/get :public-uri) - :to (:email team-owner) - :requested-by (:fullname requester) - :requested-by-email (:email requester) - :team-name (:name team) - :team-id (:id team) - :file-name (:name file) - :file-id (:id file) - :page-id page-id}) - - request))) - - -(def ^:private schema:create-team-access-request - [:and - [:map {:title "create-team-access-request"} - [:file-id {:optional true} ::sm/uuid] - [:team-id {:optional true} ::sm/uuid] - [:is-viewer {:optional true} ::sm/boolean]] - - [:fn (fn [params] - (or (contains? params :file-id) - (contains? params :team-id)))]]) - - -(sv/defmethod ::create-team-access-request - "A rpc call that allow to request for an invitations to join the team." - {::doc/added "2.2.0" - ::sm/params schema:create-team-access-request} - [cfg {:keys [::rpc/profile-id file-id team-id is-viewer] :as params}] - - (db/tx-run! cfg - (fn [{:keys [::db/conn] :as cfg}] - - (let [requester (db/get-by-id conn :profile profile-id) - team-id (if (some? team-id) - team-id - (:id (get-team-for-file conn file-id))) - team (db/get-by-id conn :team team-id) - owner-id (->> (db/exec! conn [sql:team-owner (:id team)]) - (map decode-row) - (first) - :profile-id) - team-owner (db/get-by-id conn :profile owner-id) - file (when (some? file-id) - (db/get* conn :file - {:id file-id} - {::sql/columns [:id :name :data]})) - file (when (some? file) - (assoc file :data (blob/decode (:data file))))] - - ;;TODO needs quotes? - - (when (or (nil? requester) (nil? team) (nil? team-owner) (and (some? file-id) (nil? file))) - (ex/raise :type :validation - :code :invalid-parameters)) - - ;; Check that the requester is not muted - (check-profile-muted conn requester) - - ;; Check that the owner is not marked as bounce nor spam - (check-email-bounce conn (:email team-owner) false) - (check-email-spam conn (:email team-owner) true) - - (let [request (create-team-access-request - cfg {:team team :requester requester :team-owner team-owner :file file :is-viewer is-viewer})] - (when request - (with-meta {:request request} - {::audit/props {:request 1}}))))))) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj new file mode 100644 index 000000000..d8ae02d1c --- /dev/null +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -0,0 +1,573 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.rpc.commands.teams-invitations + (:require + [app.common.data.macros :as dm] + [app.common.exceptions :as ex] + [app.common.features :as cfeat] + [app.common.logging :as l] + [app.common.schema :as sm] + [app.common.types.team :as types.team] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as db] + [app.db.sql :as sql] + [app.email :as eml] + [app.loggers.audit :as audit] + [app.main :as-alias main] + [app.rpc :as-alias rpc] + [app.rpc.commands.files :as files] + [app.rpc.commands.profile :as profile] + [app.rpc.commands.teams :as teams] + [app.rpc.doc :as-alias doc] + [app.rpc.helpers :as rph] + [app.rpc.quotes :as quotes] + [app.setup :as-alias setup] + [app.tokens :as tokens] + [app.util.services :as sv] + [app.util.time :as dt] + [cuerdas.core :as str])) + +;; --- Mutation: Create Team Invitation + + +(def sql:upsert-team-invitation + "insert into team_invitation(id, team_id, email_to, created_by, role, valid_until) + values (?, ?, ?, ?, ?, ?) + on conflict(team_id, email_to) do + update set role = ?, valid_until = ?, updated_at = now() + returning *") + +(defn- create-invitation-token + [cfg {:keys [profile-id valid-until team-id member-id member-email role]}] + (tokens/generate (::setup/props cfg) + {:iss :team-invitation + :exp valid-until + :profile-id profile-id + :role role + :team-id team-id + :member-email member-email + :member-id member-id})) + +(defn- create-profile-identity-token + [cfg profile-id] + + (dm/assert! + "expected valid uuid for profile-id" + (uuid? profile-id)) + + (tokens/generate (::setup/props cfg) + {:iss :profile-identity + :profile-id profile-id + :exp (dt/in-future {:days 30})})) + +(def ^:private schema:create-invitation + [:map {:title "params:create-invitation"} + [::rpc/profile-id ::sm/uuid] + [:team + [:map + [:id ::sm/uuid] + [:name :string]]] + [:profile + [:map + [:id ::sm/uuid] + [:fullname :string]]] + [:role ::types.team/role] + [:email ::sm/email]]) + +(def ^:private check-create-invitation-params! + (sm/check-fn schema:create-invitation)) + +(defn- create-invitation + [{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}] + + (dm/assert! + "expected valid connection on cfg parameter" + (db/connection? conn)) + + (dm/assert! + "expected valid params for `create-invitation` fn" + (check-create-invitation-params! params)) + + (let [email (profile/clean-email email) + member (profile/get-profile-by-email conn email)] + + (teams/check-profile-muted conn member) + (teams/check-email-bounce conn email true) + (teams/check-email-spam conn email true) + + ;; When we have email verification disabled and invitation user is + ;; already present in the database, we proceed to add it to the + ;; team as-is, without email roundtrip. + + ;; TODO: if member does not exists and email verification is + ;; disabled, we should proceed to create the profile (?) + (if (and (not (contains? cf/flags :email-verification)) + (some? member)) + (let [params (merge {:team-id (:id team) + :profile-id (:id member)} + (get types.team/permissions-for-role role))] + + ;; Insert the invited member to the team + (db/insert! conn :team-profile-rel params + {::db/on-conflict-do-nothing? true}) + + ;; If profile is not yet verified, mark it as verified because + ;; accepting an invitation link serves as verification. + (when-not (:is-active member) + (db/update! conn :profile + {:is-active true} + {:id (:id member)})) + + nil) + + (let [id (uuid/next) + expire (dt/in-future "168h") ;; 7 days + invitation (db/exec-one! conn [sql:upsert-team-invitation id + (:id team) (str/lower email) + (:id profile) + (name role) expire + (name role) expire]) + updated? (not= id (:id invitation)) + profile-id (:id profile) + tprops {:profile-id profile-id + :invitation-id (:id invitation) + :valid-until expire + :team-id (:id team) + :member-email (:email-to invitation) + :member-id (:id member) + :role role} + itoken (create-invitation-token cfg tprops) + ptoken (create-profile-identity-token cfg profile-id)] + + (when (contains? cf/flags :log-invitation-tokens) + (l/info :hint "invitation token" :token itoken)) + + (let [props (-> (dissoc tprops :profile-id) + (audit/clean-props)) + evname (if updated? + "update-team-invitation" + "create-team-invitation") + event (-> (audit/event-from-rpc-params params) + (assoc ::audit/name evname) + (assoc ::audit/props props))] + (audit/submit! cfg event)) + + (eml/send! {::eml/conn conn + ::eml/factory eml/invite-to-team + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :team (:name team) + :token itoken + :extra-data ptoken}) + + itoken)))) + +(defn- add-user-to-team + [conn profile team role email] + + (let [team-id (:id team) + member (db/get* conn :profile + {:email (str/lower email)} + {::sql/columns [:id :email]}) + params (merge + {:team-id team-id + :profile-id (:id member)} + (get types.team/permissions-for-role role))] + + ;; Do not allow blocked users to join teams. + (when (:is-blocked member) + (ex/raise :type :restriction + :code :profile-blocked)) + + (quotes/check! + {::db/conn conn + ::quotes/id ::quotes/profiles-per-team + ::quotes/profile-id (:id member) + ::quotes/team-id team-id}) + + ;; Insert the member to the team + (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) + + ;; Delete any request + (db/delete! conn :team-access-request + {:team-id team-id :requester-id (:id member)}) + + ;; Delete any invitation + (db/delete! conn :team-invitation + {:team-id team-id :email-to (:email member)}) + + (eml/send! {::eml/conn conn + ::eml/factory eml/join-team + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :team (:name team) + :team-id (:id team)}))) + +(def sql:valid-requests-email + "SELECT p.email + FROM team_access_request AS tr + JOIN profile AS p ON (tr.requester_id = p.id) + WHERE tr.team_id = ? + AND tr.auto_join_until > now()") + +(defn- get-valid-requests-email + [conn team-id] + (db/exec! conn [sql:valid-requests-email team-id])) + +(def ^:private xf:map-email + (map :email)) + +(defn- create-team-invitations + [{:keys [::db/conn] :as cfg} {:keys [profile team role emails] :as params}] + (let [join-requests (into #{} xf:map-email + (get-valid-requests-email conn (:id team))) + team-members (into #{} xf:map-email + (teams/get-team-members conn (:id team))) + + invitations (into #{} + (comp + ;; We don't re-send inviation to + ;; already existing members + (remove team-members) + ;; We don't send invitations to + ;; join-requested members + (remove join-requests) + (map (fn [email] (assoc params :email email))) + (keep (partial create-invitation cfg))) + emails)] + + ;; For requested invitations, do not send invitation emails, add + ;; the user directly to the team + (->> (filter join-requests emails) + (run! (partial add-user-to-team conn profile team role))) + + invitations)) + +(def ^:private schema:create-team-invitations + [:map {:title "create-team-invitations"} + [:team-id ::sm/uuid] + [:role ::types.team/role] + [:emails [::sm/set ::sm/email]]]) + +(def ^:private max-invitations-by-request-threshold + "The number of invitations can be sent in a single rpc request" + 25) + +(sv/defmethod ::create-team-invitations + "A rpc call that allow to send a single or multiple invitations to + join the team." + {::doc/added "1.17" + ::doc/module :teams + ::sm/params schema:create-team-invitations} + [cfg {:keys [::rpc/profile-id team-id emails] :as params}] + (let [perms (teams/get-permissions cfg profile-id team-id) + profile (db/get-by-id cfg :profile profile-id) + emails (into #{} (map profile/clean-email) emails)] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) + + (-> cfg + (assoc ::quotes/profile-id profile-id) + (assoc ::quotes/team-id team-id) + (assoc ::quotes/incr (count emails)) + (quotes/check! {::quotes/id ::quotes/invitations-per-team} + {::quotes/id ::quotes/profiles-per-team})) + + ;; Check if the current profile is allowed to send emails + (teams/check-profile-muted cfg profile) + + (let [team (db/get-by-id cfg :team team-id) + ;; NOTE: Is important pass RPC method params down to the + ;; `create-team-invitations` because it uses the implicit + ;; RPC properties from params for fill necessary data on + ;; emiting an entry to the audit-log + invitations (db/tx-run! cfg create-team-invitations + (-> params + (assoc :profile profile) + (assoc :team team) + (assoc :emails emails)))] + + (with-meta {:total (count invitations) + :invitations invitations} + {::audit/props {:invitations (count invitations)}})))) + +;; --- Mutation: Create Team & Invite Members + +(def ^:private schema:create-team-with-invitations + [:map {:title "create-team-with-invitations"} + [:name [:string {:max 250}]] + [:features {:optional true} ::cfeat/features] + [:id {:optional true} ::sm/uuid] + [:emails [::sm/set ::sm/email]] + [:role ::types.team/role]]) + +(sv/defmethod ::create-team-with-invitations + {::doc/added "1.17" + ::doc/module :teams + ::sm/params schema:create-team-with-invitations + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id emails role name] :as params}] + (let [features (-> (cfeat/get-enabled-features cf/flags) + (cfeat/check-client-features! (:features params))) + + params (-> params + (assoc :profile-id profile-id) + (assoc :features features)) + + team (teams/create-team cfg params) + emails (into #{} (map profile/clean-email) emails)] + + (-> cfg + (assoc ::quotes/profile-id profile-id) + (assoc ::quotes/team-id (:id team)) + (assoc ::quotes/incr (count emails)) + (quotes/check! {::quotes/id ::quotes/teams-per-profile} + {::quotes/id ::quotes/invitations-per-team} + {::quotes/id ::quotes/profiles-per-team})) + + (when (> (count emails) max-invitations-by-request-threshold) + (ex/raise :type :validation + :code :max-invitations-by-request + :hint "the maximum of invitation on single request is reached" + :threshold max-invitations-by-request-threshold)) + + (let [props {:name name :features features} + event (-> (audit/event-from-rpc-params params) + (assoc ::audit/name "create-team") + (assoc ::audit/props props))] + (audit/submit! cfg event)) + + ;; Create invitations for all provided emails. + (let [profile (db/get-by-id conn :profile profile-id) + params (-> params + (assoc :team team) + (assoc :profile profile) + (assoc :role role)) + invitations (->> emails + (map (fn [email] (assoc params :email email))) + (map (partial create-invitation cfg)))] + + (vary-meta team assoc ::audit/props {:invitations (count invitations)})))) + +;; --- Query: get-team-invitation-token + +(def ^:private schema:get-team-invitation-token + [:map {:title "get-team-invitation-token"} + [:team-id ::sm/uuid] + [:email ::sm/email]]) + +(sv/defmethod ::get-team-invitation-token + {::doc/added "1.17" + ::doc/module :teams + ::sm/params schema:get-team-invitation-token} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] + (teams/check-read-permissions! pool profile-id team-id) + (let [email (profile/clean-email email) + invit (-> (db/get pool :team-invitation + {:team-id team-id + :email-to email}) + (update :role keyword)) + + member (profile/get-profile-by-email pool (:email-to invit)) + token (create-invitation-token cfg {:team-id (:team-id invit) + :profile-id profile-id + :valid-until (:valid-until invit) + :role (:role invit) + :member-id (:id member) + :member-email (or (:email member) + (profile/clean-email (:email-to invit)))})] + {:token token})) + +;; --- Mutation: Update invitation role + +(def ^:private schema:update-team-invitation-role + [:map {:title "update-team-invitation-role"} + [:team-id ::sm/uuid] + [:email ::sm/email] + [:role ::types.team/role]]) + +(sv/defmethod ::update-team-invitation-role + {::doc/added "1.17" + ::doc/module :teams + ::sm/params schema:update-team-invitation-role} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email role] :as params}] + (db/with-atomic [conn pool] + (let [perms (teams/get-permissions conn profile-id team-id)] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (db/update! conn :team-invitation + {:role (name role) :updated-at (dt/now)} + {:team-id team-id :email-to (profile/clean-email email)}) + + nil))) + +;; --- Mutation: Delete invitation + +(def ^:private schema:delete-team-invition + [:map {:title "delete-team-invitation"} + [:team-id ::sm/uuid] + [:email ::sm/email]]) + +(sv/defmethod ::delete-team-invitation + {::doc/added "1.17" + ::sm/params schema:delete-team-invition} + [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id email] :as params}] + (db/with-atomic [conn pool] + (let [perms (teams/get-permissions conn profile-id team-id)] + + (when-not (:is-admin perms) + (ex/raise :type :validation + :code :insufficient-permissions)) + + (let [invitation (db/delete! conn :team-invitation + {:team-id team-id + :email-to (profile/clean-email email)} + {::db/return-keys true})] + (rph/wrap nil {::audit/props {:invitation-id (:id invitation)}}))))) + + +;; --- Mutation: Request Team Invitation + +(def ^:private sql:get-team-owner + "SELECT p.* + FROM profile AS p + JOIN team_profile_rel AS tpr ON (tpr.profile_id = p.id) + WHERE tpr.team_id = ? + AND tpr.is_owner IS TRUE") + +(defn- get-team-owner + "Return a complete profile of the team owner" + [conn team-id] + (->> (db/exec! conn [sql:get-team-owner team-id]) + (remove db/is-row-deleted?) + (map profile/decode-row) + (first))) + +(defn- check-existing-team-access-request + "Checks if an existing team access request is still valid" + [conn team-id profile-id] + (when-let [request (db/get* conn :team-access-request + {:team-id team-id + :requester-id profile-id})] + (when (dt/is-after? (:valid-until request) (dt/now)) + (ex/raise :type :validation + :code :request-already-sent + :hint "you have already made a request to join this team less than 24 hours ago")))) + +(def ^:private sql:upsert-team-access-request + "INSERT INTO team_access_request (id, team_id, requester_id, valid_until, auto_join_until) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (team_id, requester_id) + DO UPDATE SET valid_until = ?, auto_join_until = ?, updated_at = now() + RETURNING *") + +(defn- upsert-team-access-request + "Create or update team access request for provided team and profile-id" + [conn team-id requester-id] + (check-existing-team-access-request conn team-id requester-id) + (let [valid-until (dt/in-future {:hours 24}) + auto-join-until (dt/in-future {:days 7}) + request-id (uuid/next)] + (db/exec-one! conn [sql:upsert-team-access-request + request-id team-id requester-id + valid-until auto-join-until + valid-until auto-join-until]))) + +(defn- get-file-for-team-access-request + "A specific method for obtain a file with name and page-id used for + team request access procediment" + [cfg file-id] + (let [file (files/get-file cfg file-id :migrate? false)] + (-> file + (dissoc :data) + (dissoc :deleted-at) + (assoc :page-id (-> file :data :pages first))))) + +(def ^:private schema:create-team-access-request + [:and + [:map {:title "create-team-access-request"} + [:file-id {:optional true} ::sm/uuid] + [:team-id {:optional true} ::sm/uuid] + [:is-viewer {:optional true} ::sm/boolean]] + + [:fn (fn [params] + (or (contains? params :file-id) + (contains? params :team-id)))]]) + +(sv/defmethod ::create-team-access-request + "A rpc call that allow to request for an invitations to join the team." + {::doc/added "2.2.0" + ::doc/module :teams + ::sm/params schema:create-team-access-request + ::db/transaction true} + [{:keys [::db/conn] :as cfg} + {:keys [::rpc/profile-id file-id team-id is-viewer] :as params}] + + (let [requester (profile/get-profile conn profile-id) + team (if team-id + (->> (db/get-by-id conn :team team-id) + (teams/decode-row)) + (teams/get-team-for-file conn file-id)) + + team-id (:id team) + + team-owner (get-team-owner conn team-id) + + file (when (some? file-id) + (get-file-for-team-access-request cfg file-id)) + + request (upsert-team-access-request conn team-id profile-id)] + + ;; FIXME missing quotes + + (teams/check-profile-muted conn requester) + (teams/check-email-bounce conn (:email team-owner) false) + (teams/check-email-spam conn (:email team-owner) true) + + (let [factory (cond + (and (some? file) (:is-default team) is-viewer) + eml/request-file-access-yourpenpot-view + + (and (some? file) (:is-default team)) + eml/request-file-access-yourpenpot + + (some? file) + eml/request-file-access + + :else + eml/request-team-access)] + + (eml/send! {::eml/conn conn + ::eml/factory factory + :public-uri (cf/get :public-uri) + :to (:email team-owner) + :requested-by (:fullname requester) + :requested-by-email (:email requester) + :team-name (:name team) + :team-id team-id + :file-name (:name file) + :file-id file-id + :page-id (:page-id file)})) + + (with-meta {:request request} + {::audit/props {:request 1}}))) + + diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index 5dcd2a3e5..d725ceda2 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -8,6 +8,7 @@ (:require [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.types.team :as types.team] [app.config :as cf] [app.db :as db] [app.db.sql :as-alias sql] @@ -16,7 +17,6 @@ [app.main :as-alias main] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] - [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.rpc.quotes :as quotes] @@ -92,7 +92,7 @@ params (merge {:team-id team-id :profile-id (:id member)} - (teams/role->params role))] + (get types.team/permissions-for-role role))] ;; Do not allow blocked users accept invitations. (when (:is-blocked member) @@ -128,7 +128,7 @@ [:iss :keyword] [:exp ::dt/instant] [:profile-id ::sm/uuid] - [:role teams/schema:role] + [:role ::types.team/role] [:team-id ::sm/uuid] [:member-email ::sm/email] [:member-id {:optional true} ::sm/uuid]]) diff --git a/backend/src/app/util/time.clj b/backend/src/app/util/time.clj index c1526bfb4..c451ef742 100644 --- a/backend/src/app/util/time.clj +++ b/backend/src/app/util/time.clj @@ -158,6 +158,7 @@ :iso8601 (Instant/from (.parse DateTimeFormatter/ISO_INSTANT ^String s))))) (defn is-after? + "Analgous to: da > db" [da db] (.isAfter ^Instant da ^Instant db)) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 93b88f87e..791be1f52 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -12,6 +12,7 @@ (def default "A common flags that affects both: backend and frontend." [:enable-registration + :enable-export-file-v3 :enable-login-with-password]) (defn parse diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index aed6f2039..f71c73f50 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -4,7 +4,9 @@ ;; ;; Copyright (c) KALEIDOS INC -(ns app.common.types.team) +(ns app.common.types.team + (:require + [app.common.schema :as sm])) (def valid-roles #{:owner :admin :editor :viewer}) @@ -15,3 +17,4 @@ :admin {:can-edit true :is-admin true :is-owner false} :owner {:can-edit true :is-admin true :is-owner true}}) +(sm/register! ::role [::sm/one-of valid-roles]) diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 685dea15c..795c112e6 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -55,13 +55,14 @@ (let [prev-team-id (:current-team-id state)] (cond-> state (not= prev-team-id id) - (-> (dissoc :current-team-id) + (-> (dissoc :current-team-initialized) (dissoc :dashboard-files) (dissoc :dashboard-projects) (dissoc :dashboard-shared-files) (dissoc :dashboard-recent-files) (dissoc :dashboard-team-members) (dissoc :dashboard-team-stats) + (assoc :current-team-id id) (update :workspace-global dissoc :default-font))))) ptk/WatchEvent @@ -73,9 +74,9 @@ ;; fetch teams must be first in case the team doesn't exist (ptk/watch (du/fetch-teams) state stream) (ptk/watch (df/load-team-fonts id) state stream) - (ptk/watch (fetch-projects id) state stream) - (ptk/watch (fetch-team-members id) state stream) - (ptk/watch (du/fetch-users {:team-id id}) state stream) + (ptk/watch (fetch-projects) state stream) + (ptk/watch (fetch-team-members) state stream) + (ptk/watch (du/fetch-users) state stream) (->> stream (rx/filter (ptk/type? ::dws/message)) @@ -92,7 +93,9 @@ (rx/observe-on :async) (rx/mapcat deref) (rx/filter #(= id (:id %))) - (rx/map du/set-current-team))) + (rx/mapcat (fn [team] + (rx/of (du/set-current-team team) + #(assoc % :current-team-initialized true)))))) (rx/take-until stopper)))))) @@ -114,12 +117,15 @@ (assoc state :dashboard-team-members (d/index-by :id members))))) (defn fetch-team-members - [team-id] - (ptk/reify ::fetch-team-members - ptk/WatchEvent - (watch [_ _ _] - (->> (rp/cmd! :get-team-members {:team-id team-id}) - (rx/map team-members-fetched))))) + ([] (fetch-team-members nil)) + ([team-id] + (ptk/reify ::fetch-team-members + ptk/WatchEvent + (watch [_ state _] + (let [team-id (or team-id (:current-team-id state))] + (assert (uuid? team-id) "expected team-id to be resolved") + (->> (rp/cmd! :get-team-members {:team-id team-id}) + (rx/map team-members-fetched))))))) ;; --- EVENT: fetch-team-stats @@ -185,12 +191,13 @@ (assoc state :dashboard-projects projects))))) (defn fetch-projects - [team-id] + [] (ptk/reify ::fetch-projects ptk/WatchEvent - (watch [_ _ _] - (->> (rp/cmd! :get-projects {:team-id team-id}) - (rx/map projects-fetched))))) + (watch [_ state _] + (let [team-id (:current-team-id state)] + (->> (rp/cmd! :get-projects {:team-id team-id}) + (rx/map projects-fetched)))))) ;; --- EVENT: search @@ -284,15 +291,13 @@ (update :dashboard-files d/merge files)))))) (defn fetch-recent-files - ([] (fetch-recent-files nil)) - ([team-id] - (ptk/reify ::fetch-recent-files - ptk/WatchEvent - (watch [_ state _] - (let [team-id (or team-id (:current-team-id state))] - (->> (rp/cmd! :get-team-recent-files {:team-id team-id}) - (rx/map recent-files-fetched))))))) - + [] + (ptk/reify ::fetch-recent-files + ptk/WatchEvent + (watch [_ state _] + (let [team-id (:current-team-id state)] + (->> (rp/cmd! :get-team-recent-files {:team-id team-id}) + (rx/map recent-files-fetched)))))) ;; --- EVENT: fetch-template-files @@ -491,7 +496,7 @@ params (assoc params :team-id team-id)] (->> (rp/cmd! :update-team-member-role params) (rx/mapcat (fn [_] - (rx/of (fetch-team-members team-id) + (rx/of (fetch-team-members) (du/fetch-teams) (ptk/data-event ::ev/event {::ev/name "update-team-member-role" @@ -509,7 +514,7 @@ params (assoc params :team-id team-id)] (->> (rp/cmd! :delete-team-member params) (rx/mapcat (fn [_] - (rx/of (fetch-team-members team-id) + (rx/of (fetch-team-members) (du/fetch-teams) (ptk/data-event ::ev/event {::ev/name "delete-team-member" diff --git a/frontend/src/app/main/data/users.cljs b/frontend/src/app/main/data/users.cljs index 4ec142222..a3b7e4ad7 100644 --- a/frontend/src/app/main/data/users.cljs +++ b/frontend/src/app/main/data/users.cljs @@ -561,17 +561,17 @@ (rx/catch on-error)))))) (defn fetch-users - [{:keys [team-id]}] - (dm/assert! (uuid? team-id)) + [] (letfn [(fetched [users state] (->> users (d/index-by :id) (assoc state :users)))] (ptk/reify ::fetch-team-users ptk/WatchEvent - (watch [_ _ _] - (->> (rp/cmd! :get-team-users {:team-id team-id}) - (rx/map #(partial fetched %))))))) + (watch [_ state _] + (let [team-id (:current-team-id state)] + (->> (rp/cmd! :get-team-users {:team-id team-id}) + (rx/map #(partial fetched %)))))))) (defn fetch-file-comments-users [{:keys [team-id]}] diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index 2e673e4c0..3ed28eca0 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -420,14 +420,25 @@ (into [] (distinct) (conj coll item))) (mf/defc multi-input - [{:keys [form label class name trim valid-item-fn caution-item-fn on-submit invite-email] :as props}] + [{:keys [form label class name trim valid-item-fn caution-item-fn on-submit] :as props}] (let [form (or form (mf/use-ctx form-ctx)) input-name (get props :name) touched? (get-in @form [:touched input-name]) error (get-in @form [:errors input-name]) focus? (mf/use-state false) - items (mf/use-state []) + items (mf/use-state + (fn [] + (let [initial (get-in @form [:data input-name])] + (if (or (vector? initial) + (set? initial)) + (mapv (fn [val] + {:text val + :valid (valid-item-fn val) + :caution (caution-item-fn val)}) + initial) + [])))) + value (mf/use-state "") result (hooks/use-equal-memo @items) @@ -527,13 +538,8 @@ (let [val (cond-> @value trim str/trim) values (conj-dedup result {:text val :valid (valid-item-fn val)}) values (filterv #(:valid %) values)] - (update-form! values))) - (mf/with-effect [] - (when invite-email - (swap! items conj-dedup {:text (str/trim invite-email) - :valid (valid-item-fn invite-email) - :caution (caution-item-fn invite-email)}))) + (update-form! values))) [:div {:class klass} [:input {:id (name input-name) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 21c160e5f..9efd0635a 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -60,7 +60,7 @@ (assoc :project-id (uuid project-id))))) (mf/defc dashboard-content - [{:keys [team projects project section search-term profile invite-email] :as props}] + [{:keys [team projects project section search-term profile] :as props}] (let [container (mf/use-ref) content-width (mf/use-state 0) project-id (:id project) @@ -143,7 +143,7 @@ [:& libraries-page {:team team}] :dashboard-team-members - [:& team-members-page {:team team :profile profile :invite-email invite-email}] + [:& team-members-page {:team team :profile profile}] :dashboard-team-invitations [:& team-invitations-page {:team team}] @@ -156,8 +156,8 @@ nil)])) -(def dashboard-initialized - (l/derived :current-team-id st/state)) +(def ref:dashboard-initialized + (l/derived :current-team-initialized st/state)) (defn use-plugin-register [plugin-url team-id project-id] @@ -231,16 +231,13 @@ plugin-url (-> route :query-params :plugin) - invite-email (-> route :query-params :invite-email) - team (mf/deref refs/team) - projects (mf/deref refs/dashboard-projects) project (get projects project-id) default-project (->> projects vals (d/seek :is-default)) - initialized? (mf/deref dashboard-initialized)] + initialized? (mf/deref ref:dashboard-initialized)] (hooks/use-shortcuts ::dashboard sc/shortcuts) @@ -287,5 +284,4 @@ :project project :section section :search-term search-term - :team team - :invite-email invite-email}])])]]])) + :team team}])])]]])) diff --git a/frontend/src/app/main/ui/dashboard/file_menu.cljs b/frontend/src/app/main/ui/dashboard/file_menu.cljs index bc539924d..e7dbd3b6e 100644 --- a/frontend/src/app/main/ui/dashboard/file_menu.cljs +++ b/frontend/src/app/main/ui/dashboard/file_menu.cljs @@ -135,7 +135,7 @@ (st/emit! (ntf/success (tr "dashboard.success-move-file")))) (if (or navigate (not= team-id current-team-id)) (st/emit! (dd/go-to-files team-id project-id)) - (st/emit! (dd/fetch-recent-files team-id) + (st/emit! (dd/fetch-recent-files) (dd/clear-selected-files)))) on-move-accept @@ -255,18 +255,20 @@ :id "file-move-multi" :options sub-options}) - {:name (tr "dashboard.export-binary-multi" file-count) - :id "file-binary-export-multi" - :handler on-export-binary-files} + (when-not (contains? cf/flags :export-file-v3) + {:name (tr "dashboard.export-binary-multi" file-count) + :id "file-binary-export-multi" + :handler on-export-binary-files}) (when (contains? cf/flags :export-file-v3) {:name (tr "dashboard.export-binary-multi-v3" file-count) :id "file-binary-export-multi-v3" :handler on-export-binary-files-v3}) - {:name (tr "dashboard.export-standard-multi" file-count) - :id "file-standard-export-multi" - :handler on-export-standard-files} + (when-not (contains? cf/flags :export-file-v3) + {:name (tr "dashboard.export-standard-multi" file-count) + :id "file-standard-export-multi" + :handler on-export-standard-files}) (when (and (:is-shared file) can-edit) {:name (tr "labels.unpublish-multi-files" file-count) @@ -312,18 +314,20 @@ {:name :separator} - {:name (tr "dashboard.download-binary-file") - :id "download-binary-file" - :handler on-export-binary-files} + (when-not (contains? cf/flags :export-file-v3) + {:name (tr "dashboard.download-binary-file") + :id "download-binary-file" + :handler on-export-binary-files}) (when (contains? cf/flags :export-file-v3) {:name (tr "dashboard.download-binary-file-v3") :id "download-binary-file-v3" :handler on-export-binary-files-v3}) - {:name (tr "dashboard.download-standard-file") - :id "download-standard-file" - :handler on-export-standard-files} + (when-not (contains? cf/flags :export-file-v3) + {:name (tr "dashboard.download-standard-file") + :id "download-standard-file" + :handler on-export-standard-files}) (when (and (not is-lib-page?) (not is-search-page?) can-edit) {:name :separator}) diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index d83ae0c66..4c72b6852 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -529,9 +529,8 @@ on-finish-import (mf/use-fn - (mf/deps team-id) (fn [] - (st/emit! (dd/fetch-recent-files (:id team)) + (st/emit! (dd/fetch-recent-files) (dd/clear-selected-files)))) import-files (use-import-file project-id on-finish-import) @@ -571,10 +570,11 @@ (reset! dragging? false)))) on-drop-success - (fn [] - (st/emit! (ntf/success (tr "dashboard.success-move-file")) - (dd/fetch-recent-files (:id team)) - (dd/clear-selected-files))) + (mf/use-fn + (fn [] + (st/emit! (ntf/success (tr "dashboard.success-move-file")) + (dd/fetch-recent-files) + (dd/clear-selected-files)))) on-drop (mf/use-fn diff --git a/frontend/src/app/main/ui/dashboard/projects.cljs b/frontend/src/app/main/ui/dashboard/projects.cljs index 1039daabe..611f205cf 100644 --- a/frontend/src/app/main/ui/dashboard/projects.cljs +++ b/frontend/src/app/main/ui/dashboard/projects.cljs @@ -192,8 +192,8 @@ (mf/deps project-id (:id team)) (fn [] (st/emit! (dd/fetch-files {:project-id project-id}) - (dd/fetch-recent-files (:id team)) - (dd/fetch-projects (:id team)) + (dd/fetch-recent-files) + (dd/fetch-projects) (dd/clear-selected-files)))) handle-create-click @@ -303,17 +303,17 @@ [:span {:class (stl/css :placeholder-label)} (tr "dashboard.show-all-files")] show-more-icon])])) - -(def recent-files-ref +(def ref:recent-files (l/derived :dashboard-recent-files st/state)) (mf/defc projects-section - [{:keys [team projects profile] :as props}] + {::mf/props :obj} + [{:keys [team projects profile]}] (let [projects (->> (vals projects) (sort-by :modified-at) (reverse)) - recent-map (mf/deref recent-files-ref) + recent-map (mf/deref ref:recent-files) permisions (:permissions team) can-edit (:can-edit permisions) @@ -326,8 +326,6 @@ is-my-penpot (= (:default-team-id profile) (:id team)) is-defalt-team? (:is-default team) - team-id (:id team) - on-close (mf/use-fn (fn [] @@ -344,8 +342,8 @@ (:name team))] (dom/set-html-title (tr "title.dashboard.projects" tname)))) - (mf/with-effect [team-id] - (st/emit! (dd/fetch-recent-files team-id) + (mf/with-effect [] + (st/emit! (dd/fetch-recent-files) (dd/clear-selected-files))) (when (seq projects) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index f6fa8561b..431dd2711 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -59,13 +59,16 @@ (mf/defc header {::mf/wrap [mf/memo] - ::mf/wrap-props false} - [{:keys [section team invite-email]}] + ::mf/props :obj} + [{:keys [section team]}] (let [on-nav-members (mf/use-fn #(st/emit! (dd/go-to-team-members))) on-nav-settings (mf/use-fn #(st/emit! (dd/go-to-team-settings))) on-nav-invitations (mf/use-fn #(st/emit! (dd/go-to-team-invitations))) on-nav-webhooks (mf/use-fn #(st/emit! (dd/go-to-team-webhooks))) + route (mf/deref refs/route) + invite-email (-> route :query-params :invite-email) + members-section? (= section :dashboard-team-members) settings-section? (= section :dashboard-team-settings) invitations-section? (= section :dashboard-team-invitations) @@ -74,14 +77,14 @@ on-invite-member (mf/use-fn - (mf/deps team) + (mf/deps team invite-email) (fn [] (st/emit! (modal/show {:type :invite-members :team team :origin :team :invite-email invite-email}))))] - (mf/with-effect [] + (mf/with-effect [team invite-email] (when invite-email (on-invite-member))) @@ -134,7 +137,7 @@ (mf/defc invite-members-modal {::mf/register modal/components ::mf/register-as :invite-members - ::mf/wrap-props false} + ::mf/props :obj} [{:keys [team origin invite-email]}] (let [members-map (mf/deref refs/dashboard-team-members) perms (:permissions team) @@ -143,8 +146,10 @@ (get-available-roles perms)) team-id (:id team) - initial (mf/with-memo [team-id] - {:role "editor" :team-id team-id}) + initial (mf/with-memo [team-id invite-email] + (if invite-email + {:role "editor" :team-id team-id :emails #{invite-email}} + {:role "editor" :team-id team-id})) form (fm/use-form :schema schema:invite-member-form :initial initial) @@ -159,6 +164,7 @@ (st/emit! (ntf/success (tr "notifications.invitation-email-sent")))) (st/emit! (modal/hide) + (dd/fetch-team-members) (dd/fetch-team-invitations))) on-error @@ -230,8 +236,7 @@ :trim true :valid-item-fn sm/parse-email :caution-item-fn current-members-emails - :label (tr "modals.invite-member.emails") - :invite-email invite-email}]] + :label (tr "modals.invite-member.emails")}]] [:div {:class (stl/css :action-buttons)} [:> fm/submit-button* @@ -245,7 +250,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (mf/defc member-info - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [member profile]}] (let [is-you? (= (:id profile) (:id member))] [:* @@ -258,7 +263,7 @@ [:div {:class (stl/css :member-email)} (:email member)]]])) (mf/defc rol-info - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [member team on-set-admin on-set-editor on-set-owner on-set-viewer profile]}] (let [member-is-owner (:is-owner member) member-is-admin (and (:is-admin member) (not member-is-owner)) @@ -309,7 +314,7 @@ (tr "labels.owner")])]]])) (mf/defc member-actions - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [member team on-delete on-leave profile]}] (let [is-owner? (:is-owner member) owner? (dm/get-in team [:permissions :is-owner]) @@ -345,7 +350,7 @@ (mf/defc team-member {::mf/wrap [mf/memo] - ::mf/wrap-props false} + ::mf/props :obj} [{:keys [team member members profile]}] (let [member-id (:id member) @@ -479,7 +484,7 @@ :on-leave on-leave'}]]])) (mf/defc team-members - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [members-map team profile]}] (let [members (mf/with-memo [members-map] (->> (vals members-map) @@ -510,8 +515,8 @@ :members members-map}])]])) (mf/defc team-members-page - {::mf/wrap-props false} - [{:keys [team profile invite-email]}] + {::mf/props :obj} + [{:keys [team profile]}] (let [members-map (mf/deref refs/dashboard-team-members)] (mf/with-effect [team] @@ -525,7 +530,7 @@ (st/emit! (dd/fetch-team-members (:id team)))) [:* - [:& header {:section :dashboard-team-members :team team :invite-email invite-email}] + [:& header {:section :dashboard-team-members :team team}] [:section {:class (stl/css :dashboard-container :dashboard-team-members)} [:& team-members {:profile profile @@ -536,9 +541,9 @@ ;; INVITATIONS SECTION ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(mf/defc invitation-role-selector - {::mf/wrap-props false} - [{:keys [can-invite? role status on-change]}] +(mf/defc invitation-role-selector* + {::mf/props :obj} + [{:keys [can-invite role status on-change]}] (let [show? (mf/use-state false) label (cond (= role :owner) (tr "labels.owner") @@ -559,7 +564,7 @@ (on-change role event))))] [:* - (if (and can-invite? (= status :pending)) + (if (and can-invite (= status :pending)) [:div {:class (stl/css :rol-selector :has-priv) :on-click on-show} [:span {:class (stl/css :rol-label)} label] @@ -583,7 +588,7 @@ (tr "labels.viewer")]]]])) (mf/defc invitation-actions - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [invitation team-id]}] (let [show? (mf/use-state false) @@ -678,10 +683,11 @@ :class (stl/css :action-dropdown-item)} (tr "labels.delete-invitation")]]]])) -(mf/defc invitation-row +(mf/defc invitation-row* {::mf/wrap [mf/memo] - ::mf/wrap-props false} - [{:keys [invitation can-invite? team-id] :as props}] + ::mf/private true + ::mf/props :obj} + [{:keys [invitation can-invite team-id]}] (let [expired? (:expired invitation) email (:email invitation) @@ -704,8 +710,8 @@ [:div {:class (stl/css :table-field :field-email)} email] [:div {:class (stl/css :table-field :field-roles)} - [:& invitation-role-selector - {:can-invite? can-invite? + [:> invitation-role-selector* + {:can-invite can-invite :role role :status status :on-change on-change-role}]] @@ -714,16 +720,16 @@ [:& badge-notification {:type type :content badge-content}]] [:div {:class (stl/css :table-field :field-actions)} - (when can-invite? + (when can-invite [:& invitation-actions {:invitation invitation :team-id team-id}])]])) (mf/defc empty-invitation-table - [{:keys [can-invite?] :as props}] + [{:keys [can-invite] :as props}] [:div {:class (stl/css :empty-invitations)} [:span (tr "labels.no-invitations")] - (when can-invite? + (when can-invite [:> i18n/tr-html* {:content (tr "labels.no-invitations-hint") :tag-name "span"}])]) @@ -731,7 +737,7 @@ [{:keys [team invitations] :as props}] (let [owner? (dm/get-in team [:permissions :is-owner]) admin? (dm/get-in team [:permissions :is-admin]) - can-invite? (or owner? admin?) + can-invite (or owner? admin?) team-id (:id team)] [:div {:class (stl/css :invitations)} @@ -740,17 +746,17 @@ [:div {:class (stl/css :title-field-role)} (tr "labels.role")] [:div {:class (stl/css :title-field-status)} (tr "labels.status")]] (if (empty? invitations) - [:& empty-invitation-table {:can-invite? can-invite?}] + [:& empty-invitation-table {:can-invite can-invite}] [:div {:class (stl/css :table-rows)} (for [invitation invitations] - [:& invitation-row + [:> invitation-row* {:key (:email invitation) :invitation invitation - :can-invite? can-invite? + :can-invite can-invite :team-id team-id}])])])) (mf/defc team-invitations-page - [{:keys [team] :as props}] + [{:keys [team]}] (let [invitations (mf/deref refs/dashboard-team-invitations)] (mf/with-effect [team] @@ -794,7 +800,7 @@ (mf/defc webhook-modal {::mf/register modal/components ::mf/register-as :webhook} - [{:keys [webhook] :as props}] + [{:keys [webhook]}] (let [initial (mf/with-memo [] (or (some-> webhook (update :uri str)) @@ -905,7 +911,7 @@ (tr "modals.create-webhook.submit-label"))}]]]]]])) (mf/defc webhooks-hero - {::mf/wrap-props false} + {::mf/props :obj} [] [:div {:class (stl/css :webhooks-hero-container)} [:h2 {:class (stl/css :hero-title)} @@ -939,18 +945,9 @@ :class (stl/css :menu-disabled)} [:> icon* {:id "menu"}]]))) -(mf/defc last-delivery-icon - {::mf/wrap-props false} - [{:keys [success? text]}] - [:div {:class (stl/css :last-delivery-icon) - :title text} - (if success? - success-icon - warning-icon)]) - (mf/defc webhook-item {::mf/wrap [mf/memo]} - [{:keys [webhook permissions] :as props}] + [{:keys [webhook permissions]}] (let [error-code (:error-code webhook) id (:id webhook) creator-id (:profile-id webhook) @@ -1014,14 +1011,14 @@ :can-edit can-edit}]]])) (mf/defc webhooks-list - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [webhooks permissions]}] [:div {:class (stl/css :table-rows :webhook-table)} (for [webhook webhooks] [:& webhook-item {:webhook webhook :key (:id webhook) :permissions permissions}])]) (mf/defc team-webhooks-page - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [team]}] (let [webhooks (mf/deref refs/dashboard-team-webhooks)] @@ -1051,7 +1048,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (mf/defc team-settings-page - {::mf/wrap-props false} + {::mf/props :obj} [{:keys [team]}] (let [finput (mf/use-ref) diff --git a/frontend/src/app/util/forms.cljs b/frontend/src/app/util/forms.cljs index a3997e7c1..e5b710aba 100644 --- a/frontend/src/app/util/forms.cljs +++ b/frontend/src/app/util/forms.cljs @@ -91,6 +91,8 @@ (defn- create-form-mutator [internal-state rerender-fn wrap-update-fn initial opts] + (mf/set-ref-val! internal-state initial) + (reify IDeref (-deref [_] @@ -128,6 +130,12 @@ [& {:keys [initial] :as opts}] (let [rerender-fn (use-rerender-fn) + initial + (mf/with-memo [initial] + {:data (if (fn? initial) (initial) initial) + :errors {} + :touched {}}) + internal-state (mf/use-ref nil) @@ -136,11 +144,8 @@ (create-form-mutator internal-state rerender-fn wrap-update-schema-fn initial opts))] ;; Initialize internal state once - (mf/with-effect [] - (mf/set-ref-val! internal-state - {:data {} - :errors {} - :touched {}})) + (mf/with-layout-effect [] + (mf/set-ref-val! internal-state initial)) (mf/with-effect [initial] (if (fn? initial) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index e44b01166..2518c84a9 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -421,7 +421,7 @@ msgid "dashboard.download-binary-file" msgstr "Download Penpot file (.penpot)" msgid "dashboard.download-binary-file-v3" -msgstr "Download Penpot file (.zip) (BETA)" +msgstr "Download (.zip)" #: src/app/main/ui/dashboard/file_menu.cljs:300, src/app/main/ui/workspace/main_menu.cljs:597 msgid "dashboard.download-standard-file"