0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-09 21:41:23 -05:00

🐛 Fix incorrect handling request access with deleted profiles

* 📎 Add minor improvements to team tests

* 🐛 Fix incorrect handling request access with deleted profiles

* 🐛 Fix redirect loop on empty route

Happens when the current profile is deleted from team

* 🐛 Fix urls on request access emails

* 📎 Revert url changes on emails
This commit is contained in:
Andrey Antukh 2025-02-19 11:04:19 +01:00 committed by GitHub
parent 19bae05f41
commit 4744085426
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 166 additions and 62 deletions

View file

@ -67,6 +67,7 @@ is a number of cores)
- Fix problem with grid layout crashing [Taiga #10127](https://tree.taiga.io/project/penpot/issue/10127)
- Fix rename locked boards [Taiga #10174](https://tree.taiga.io/project/penpot/issue/10174)
- Fix update-libraries dialog disappear when clicking outside [Taiga #10238](https://tree.taiga.io/project/penpot/issue/10238)
- Fix incorrect handling of team access requests with deleted/recreated users
## 2.4.3

View file

@ -207,7 +207,7 @@
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}&section=interactions&index=0&share=true"
<a href="{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}&section=interactions&index=0&share=true"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> SEND A VIEW-ONLY LINK </a>
</td>
@ -251,4 +251,4 @@
</div>
</body>
</html>
</html>

View file

@ -6,7 +6,7 @@ Since this file is in your Penpot team, you can provide access by sending a view
To proceed, please click the link below to generate and send the view-only link:
{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}&section=interactions&index=0&share=true
{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}&section=interactions&index=0&share=true

View file

@ -230,9 +230,9 @@
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}&section=interactions&index=0&share=true"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> SEND A VIEW-ONLY LINK </a>
<a href="{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}&section=interactions&index=0&share=true"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> SEND A VIEW-ONLY LINK </a>
</td>
</tr>
</table>
@ -274,4 +274,4 @@
</div>
</body>
</html>
</html>

View file

@ -19,7 +19,7 @@ Alternatively, you can create and share a view-only link to the file. This will
Click the link below to generate and send the link:
{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}&section=interactions&index=0&share=true
{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}&section=interactions&index=0&share=true

View file

@ -214,7 +214,7 @@
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape }}"
<a href="{{ public-uri }}/#/dashboard/members?team-id={{team-id}}&invite-email={{requested-by-email|urlescape }}"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> GIVE ACCESS TO “{{team-name|abbreviate:25}}” TEAM </a>
</td>
@ -247,9 +247,9 @@
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}&section=interactions&index=0&share=true"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> SEND A VIEW-ONLY LINK </a>
<a href="{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}&section=interactions&index=0&share=true"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> SEND A VIEW-ONLY LINK </a>
</td>
</tr>
</table>
@ -292,4 +292,4 @@
</div>
</body>
</html>
</html>

View file

@ -13,7 +13,7 @@ This will automatically include {{requested-by|abbreviate:25}} in the team, so t
Click the link below to provide team access:
{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}}
{{ public-uri }}/#/dashboard/members?team-id{{team-id}}&invite-email={{requested-by-email|urlescape}}
@ -23,8 +23,7 @@ Alternatively, you can create and share a view-only link to the file. This will
Click the link below to generate and send the link:
{{ public-uri }}/#/view/{{file-id}}?page-id={{page-id}}&section=interactions&index=0&share=true
{{ public-uri }}/#/view?file-id={{file-id}}&page-id={{page-id}}&section=interactions&index=0&share=true
If you do not wish to grant access at this time, you can simply disregard this email.

View file

@ -205,7 +205,7 @@
<td align="center" bgcolor="#6911d4" role="presentation"
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
valign="middle">
<a href="{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}}"
<a href="{{ public-uri }}/#/dashboard/members?team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}"
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
target="_blank"> GIVE ACCESS TO “{{team-name|abbreviate:25}}” TEAM </a>
</td>
@ -249,4 +249,4 @@
</div>
</body>
</html>
</html>

View file

@ -4,7 +4,7 @@ Hello!
To provide access, please click the link below:
{{ public-uri }}/#/dashboard/team/{{team-id}}/members?invite-email={{requested-by-email|urlescape}}
{{ public-uri }}/#/dashboard/members?team-id={{team-id}}&invite-email={{requested-by-email|urlescape}}
If you do not wish to grant access at this time, you can simply disregard this email.

View file

@ -6,6 +6,7 @@
(ns app.rpc.commands.teams-invitations
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
@ -15,7 +16,6 @@
[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]
@ -168,19 +168,16 @@
itoken))))
(defn- add-user-to-team
[conn profile team role email]
(defn- add-member-to-team
[conn profile team role member]
(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.
;; Do not allow blocked users to join teams.
(when (:is-blocked member)
(ex/raise :type :restriction
:code :profile-blocked))
@ -205,29 +202,33 @@
(eml/send! {::eml/conn conn
::eml/factory eml/join-team
:public-uri (cf/get :public-uri)
:to email
:to (:email member)
:invited-by (:fullname profile)
:team (:name team)
:team-id (:id team)})))
(def sql:valid-requests-email
"SELECT p.email
(def ^:private sql:valid-access-request-profiles
"SELECT p.id, p.email, p.is_blocked
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()")
AND tr.auto_join_until > now()
AND (p.deleted_at IS NULL OR
p.deleted_at > now())")
(defn- get-valid-requests-email
(defn- get-valid-access-request-profiles
[conn team-id]
(db/exec! conn [sql:valid-requests-email team-id]))
(db/exec! conn [sql:valid-access-request-profiles team-id]))
(def ^:private xf:map-email
(map :email))
(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)))
(let [emails (set emails)
join-requests (->> (get-valid-access-request-profiles conn (:id team))
(d/index-by :email))
team-members (into #{} xf:map-email
(teams/get-team-members conn (:id team)))
@ -245,8 +246,10 @@
;; 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)))
(->> join-requests
(filter #(contains? emails (key %)))
(map val)
(run! (partial add-member-to-team conn profile team role)))
invitations))
@ -572,5 +575,3 @@
(with-meta {:request request}
{::audit/props {:request 1}}))))

View file

@ -37,18 +37,17 @@
:role :editor}]
;; invite external user without complaints
(let [data (assoc data :emails ["foo@bar.com"])
out (th/command! data)
(let [data (assoc data :emails ["foo@bar.com"])
out (th/command! data)
;; retrieve the value from the database and check its content
invitation (db/exec-one!
th/*pool*
["select count(*) as num from team_invitation where team_id = ? and email_to = ?"
(:team-id data) "foo@bar.com"])]
invitations (th/db-query :team-invitation
{:team-id (:team-id data)
:email-to "foo@bar.com"})]
;; (th/print-result! out)
(t/is (th/success? out))
(t/is (= 1 (:call-count (deref mock))))
(t/is (= 1 (:num invitation))))
(t/is (= 1 (count invitations))))
;; invite internal user without complaints
(th/reset-mock! mock)
@ -102,6 +101,105 @@
(t/is (= :validation (:type edata)))
(t/is (= :member-is-muted (:code edata))))))))
(t/deftest create-team-invitations-with-request-access
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true})
requester (th/create-profile* 2 {:is-active true :email "requester@example.com"})
team (th/create-team* 1 {:profile-id (:id profile1)})
proj (th/create-project* 1 {:profile-id (:id profile1)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile1)
:project-id (:id proj)})]
(let [data {::th/type :create-team-access-request
::rpc/profile-id (:id requester)
:file-id (:id file)}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
(th/reset-mock! mock)
(let [data {::th/type :create-team-invitations
::rpc/profile-id (:id profile1)
:team-id (:id team)
:role :editor
:emails ["requester@example.com"]}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock)))
;; Check that request is properly removed
(let [requests (th/db-query :team-access-request
{:requester-id (:id requester)})]
(t/is (= 0 (count requests))))
(let [rows (th/db-query :team-profile-rel {:team-id (:id team)})]
(t/is (= 2 (count rows))))))))
(t/deftest create-team-invitations-with-request-access-2
(with-mocks [mock {:target 'app.email/send! :return nil}]
(let [profile1 (th/create-profile* 1 {:is-active true})
requester (th/create-profile* 2 {:is-active true
:email "requester@example.com"})
team (th/create-team* 1 {:profile-id (:id profile1)})
proj (th/create-project* 1 {:profile-id (:id profile1)
:team-id (:id team)})
file (th/create-file* 1 {:profile-id (:id profile1)
:project-id (:id proj)})]
;; Create the first access request
(let [data {::th/type :create-team-access-request
::rpc/profile-id (:id requester)
:file-id (:id file)}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
(th/reset-mock! mock)
;; Proceed to delete the requester user
(th/db-update! :profile
{:deleted-at (dt/in-past "1h")}
{:id (:id requester)})
;; Create a new profile with the same email
(let [requester' (th/create-profile* 3 {:is-active true :email "requester@example.com"})]
;; Create a request access with new requester
(let [data {::th/type :create-team-access-request
::rpc/profile-id (:id requester')
:file-id (:id file)}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
(th/reset-mock! mock)
;; Create an invitation for the requester email
(let [data {::th/type :create-team-invitations
::rpc/profile-id (:id profile1)
:team-id (:id team)
:role :editor
:emails ["requester@example.com"]}
out (th/command! data)]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock))))
;; Check that request is properly removed
(let [requests (th/db-query :team-access-request
{:requester-id (:id requester')})]
(t/is (= 0 (count requests))))
(let [[r1 r2 :as rows] (th/db-query :team-profile-rel
{:team-id (:id team)}
{:order-by [:created-at]})]
(t/is (= 2 (count rows)))
(t/is (= (:profile-id r1) (:id profile1)))
(t/is (= (:profile-id r2) (:id requester'))))))))
(t/deftest invitation-tokens
(with-mocks [mock {:target 'app.email/send! :return nil}]
@ -486,14 +584,12 @@
;; request success
(let [out (th/command! data)
;; retrieve the value from the database and check its content
request (db/exec-one!
th/*pool*
["select count(*) as num from team_access_request where team_id = ? and requester_id = ?"
(:id team) (:id requester)])]
requests (th/db-query :team-access-request
{:team-id (:id team)
:requester-id (:id requester)})]
(t/is (th/success? out))
(t/is (= 1 (:call-count @mock)))
(t/is (= 1 (:num request))))
(t/is (= 1 (count requests))))
;; request again fails
(th/reset-mock! mock)
@ -509,10 +605,10 @@
;; request again when is expired success
(th/reset-mock! mock)
(db/exec-one!
th/*pool*
["update team_access_request set valid_until = ? where team_id = ? and requester_id = ?"
(dt/in-past "1h") (:id team) (:id requester)])
(th/db-update! :team-access-request
{:valid-until (dt/in-past "1h")}
{:team-id (:id team)
:requester-id (:id requester)})
(t/is (th/success? (th/command! data)))
(t/is (= 1 (:call-count @mock))))))

View file

@ -72,6 +72,7 @@
(def profile-fetched?
(ptk/type? ::profile-fetched))
;; FIXME: make it as general purpose handler, not only on profile
(defn- on-fetch-profile-exception
[cause]
(let [data (ex-data cause)]

View file

@ -109,7 +109,11 @@
;; avoids some race conditions that causes unexpected redirects
;; on invitations workflows (and probably other cases).
(->> (rp/cmd! :get-profile)
(rx/subs! (fn [{:keys [id] :as profile}]
(rx/mapcat (fn [profile]
(->> (rp/cmd! :get-teams {})
(rx/map (fn [teams]
(assoc profile ::teams (into #{} (map :id) teams)))))))
(rx/subs! (fn [{:keys [id ::teams] :as profile}]
(cond
(= id uuid/zero)
(do
@ -117,10 +121,12 @@
(st/emit! (rt/nav :auth-login)))
empty-path?
(let [team-id (or (dtm/get-last-team-id)
(:default-team-id profile))]
(st/emit! (rt/nav :dashboard-recent
(assoc query-params :team-id team-id))))
(let [team-id (dtm/get-last-team-id)]
(if (contains? teams team-id)
(st/emit! (rt/nav :dashboard-recent
(assoc query-params :team-id team-id)))
(st/emit! (rt/nav :dashboard-recent
(assoc query-params :team-id (:default-team-id profile))))))
:else
(st/emit! (rt/assign-exception {:type :not-found})))))))))