0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-01-04 13:50:12 -05:00

Merge pull request #5290 from penpot/niwinz-team-request-access-bugfix

🐛 Several bugfixes related to the request-access feature
This commit is contained in:
Pablo Alba 2024-11-13 14:37:48 +01:00 committed by GitHub
commit 8ed508012e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 776 additions and 716 deletions

View file

@ -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)

View file

@ -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)))

View file

@ -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}})))))))

View file

@ -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}})))

View file

@ -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]])

View file

@ -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))

View file

@ -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

View file

@ -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])

View file

@ -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"

View file

@ -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]}]

View file

@ -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)

View file

@ -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}])])]]]))

View file

@ -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})

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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"