mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 13:29:07 +00:00
🐛 Add cooldown to prevent duplicate invitation emails (#11063)
This commit is contained in:
parent
5906312dff
commit
bf62e59f73
@ -46,10 +46,29 @@
|
|||||||
|
|
||||||
(def sql:upsert-organization-invitation
|
(def sql:upsert-organization-invitation
|
||||||
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
"insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until)
|
||||||
values (?, null, ?, ?, ?, ?, ?)
|
values (?, null, ?, ?, ?, ?, ?)
|
||||||
on conflict(org_id, email_to) where team_id is null do
|
on conflict(org_id, email_to) where team_id is null do
|
||||||
update set role = ?, valid_until = ?, updated_at = now()
|
update set role = ?, valid_until = ?, updated_at = now()
|
||||||
returning *")
|
returning *")
|
||||||
|
|
||||||
|
(def ^:private sql:check-recent-invitation
|
||||||
|
"SELECT 1 FROM team_invitation
|
||||||
|
WHERE team_id = ? AND email_to = ?
|
||||||
|
AND updated_at > now() - interval '5 minutes'
|
||||||
|
LIMIT 1")
|
||||||
|
|
||||||
|
(def ^:private sql:check-recent-org-invitation
|
||||||
|
"SELECT 1 FROM team_invitation
|
||||||
|
WHERE org_id = ? AND email_to = ?
|
||||||
|
AND updated_at > now() - interval '5 minutes'
|
||||||
|
LIMIT 1")
|
||||||
|
|
||||||
|
(defn- recently-invited?
|
||||||
|
[{:keys [::db/conn]} team-id org-id email]
|
||||||
|
(let [query (if org-id
|
||||||
|
[sql:check-recent-org-invitation org-id email]
|
||||||
|
[sql:check-recent-invitation team-id email])]
|
||||||
|
(some? (db/exec-one! conn query))))
|
||||||
|
|
||||||
(defn- create-invitation-token
|
(defn- create-invitation-token
|
||||||
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
|
||||||
@ -185,35 +204,36 @@
|
|||||||
(teams/check-email-bounce conn email true)
|
(teams/check-email-bounce conn email true)
|
||||||
(teams/check-email-spam conn email true)
|
(teams/check-email-spam conn email true)
|
||||||
|
|
||||||
(let [id (uuid/next)
|
(let [id (uuid/next)
|
||||||
expire (if organization
|
expire (if organization
|
||||||
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
(ct/in-future "876000h") ;; Organization invitations doesn't expire
|
||||||
(ct/in-future "168h")) ;; 7 days
|
(ct/in-future "168h")) ;; 7 days
|
||||||
invitation (db/exec-one! conn (if organization
|
recent? (recently-invited? cfg (:id team) (:id organization) email)
|
||||||
[sql:upsert-organization-invitation id
|
invitation (db/exec-one! conn (if organization
|
||||||
(:id organization)
|
[sql:upsert-organization-invitation id
|
||||||
(str/lower email)
|
(:id organization)
|
||||||
(:id profile)
|
(str/lower email)
|
||||||
(name role) expire
|
(:id profile)
|
||||||
(name role) expire]
|
(name role) expire
|
||||||
[sql:upsert-team-invitation id
|
(name role) expire]
|
||||||
(:id team)
|
[sql:upsert-team-invitation id
|
||||||
(str/lower email)
|
(:id team)
|
||||||
(:id profile)
|
(str/lower email)
|
||||||
(name role) expire
|
(:id profile)
|
||||||
(name role) expire]))
|
(name role) expire
|
||||||
updated? (not= id (:id invitation))
|
(name role) expire]))
|
||||||
profile-id (:id profile)
|
updated? (not= id (:id invitation))
|
||||||
|
profile-id (:id profile)
|
||||||
team-organization-id (get-in team [:organization :id])
|
team-organization-id (get-in team [:organization :id])
|
||||||
tprops {:profile-id profile-id
|
tprops {:profile-id profile-id
|
||||||
:invitation-id (:id invitation)
|
:invitation-id (:id invitation)
|
||||||
:valid-until expire
|
:valid-until expire
|
||||||
:team-id (:id team)
|
:team-id (:id team)
|
||||||
:organization-id (:id organization)
|
:organization-id (:id organization)
|
||||||
:organization-name (:name organization)
|
:organization-name (:name organization)
|
||||||
:member-email (:email-to invitation)
|
:member-email (:email-to invitation)
|
||||||
:member-id (:id member)
|
:member-id (:id member)
|
||||||
:role role}
|
:role role}
|
||||||
audit-props
|
audit-props
|
||||||
(cond-> {:invitation-id (:id invitation)
|
(cond-> {:invitation-id (:id invitation)
|
||||||
:valid-until expire
|
:valid-until expire
|
||||||
@ -234,8 +254,8 @@
|
|||||||
(and team-organization-id
|
(and team-organization-id
|
||||||
member
|
member
|
||||||
(contains? all-organization-member-ids (:id member))))))
|
(contains? all-organization-member-ids (:id member))))))
|
||||||
itoken (create-invitation-token cfg tprops)
|
itoken (create-invitation-token cfg tprops)
|
||||||
ptoken (create-profile-identity-token cfg profile-id)]
|
ptoken (create-profile-identity-token cfg profile-id)]
|
||||||
|
|
||||||
(when (contains? cf/flags :log-invitation-tokens)
|
(when (contains? cf/flags :log-invitation-tokens)
|
||||||
(l/info :hint "invitation token" :token itoken))
|
(l/info :hint "invitation token" :token itoken))
|
||||||
@ -251,7 +271,8 @@
|
|||||||
(assoc :props props))]
|
(assoc :props props))]
|
||||||
(audit/submit cfg event))
|
(audit/submit cfg event))
|
||||||
|
|
||||||
(when (allow-invitation-emails? member)
|
(when (and (allow-invitation-emails? member)
|
||||||
|
(not recent?))
|
||||||
(if organization
|
(if organization
|
||||||
(when (contains? cf/flags :admin-console)
|
(when (contains? cf/flags :admin-console)
|
||||||
(eml/send! {::eml/conn conn
|
(eml/send! {::eml/conn conn
|
||||||
|
|||||||
@ -1015,6 +1015,46 @@
|
|||||||
out (th/command! data)]
|
out (th/command! data)]
|
||||||
(t/is (th/success? out)))))
|
(t/is (th/success? out)))))
|
||||||
|
|
||||||
|
(t/deftest create-team-invitations-email-cooldown
|
||||||
|
(with-mocks [mock {:target 'app.email/send! :return nil}]
|
||||||
|
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||||
|
team (th/create-team* 1 {:profile-id (:id profile1)})
|
||||||
|
|
||||||
|
data {::th/type :create-team-invitations
|
||||||
|
::rpc/profile-id (:id profile1)
|
||||||
|
:team-id (:id team)
|
||||||
|
:role :editor
|
||||||
|
:emails ["cooldown-test@example.com"]}]
|
||||||
|
|
||||||
|
;; First invitation sends email
|
||||||
|
(let [out (th/command! data)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (:call-count @mock))))
|
||||||
|
|
||||||
|
;; Resending immediately should NOT send email (cooldown active)
|
||||||
|
(th/reset-mock! mock)
|
||||||
|
(let [out (th/command! data)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 0 (:call-count @mock))))
|
||||||
|
|
||||||
|
;; Resending to a different email should send email
|
||||||
|
(th/reset-mock! mock)
|
||||||
|
(let [data (assoc data :emails ["different@example.com"])
|
||||||
|
out (th/command! data)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (:call-count @mock))))
|
||||||
|
|
||||||
|
;; After cooldown expires, resending should send email
|
||||||
|
(th/reset-mock! mock)
|
||||||
|
(th/db-update! :team-invitation
|
||||||
|
{:updated-at (ct/in-past "10m")}
|
||||||
|
{:team-id (:id team)
|
||||||
|
:email-to "cooldown-test@example.com"})
|
||||||
|
(let [data (assoc data :emails ["cooldown-test@example.com"])
|
||||||
|
out (th/command! data)]
|
||||||
|
(t/is (th/success? out))
|
||||||
|
(t/is (= 1 (:call-count @mock)))))))
|
||||||
|
|
||||||
(t/deftest update-team-with-invalid-name
|
(t/deftest update-team-with-invalid-name
|
||||||
(let [profile (th/create-profile* 1 {:is-active true})
|
(let [profile (th/create-profile* 1 {:is-active true})
|
||||||
team (th/create-team* 1 {:profile-id (:id profile)})]
|
team (th/create-team* 1 {:profile-id (:id profile)})]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user