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:
commit
8ed508012e
18 changed files with 776 additions and 716 deletions
|
@ -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)
|
||||
|
|
|
@ -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)))
|
||||
|
|
|
@ -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}})))))))
|
||||
|
|
573
backend/src/app/rpc/commands/teams_invitations.clj
Normal file
573
backend/src/app/rpc/commands/teams_invitations.clj
Normal 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}})))
|
||||
|
||||
|
|
@ -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]])
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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]}]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}])])]]]))
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue