mirror of
https://github.com/penpot/penpot.git
synced 2026-11-11 04:20:42 +00:00
* 🐛 Restrict the team invitation token and list to admins The token of a team invitation is a bearer credential: whoever holds it accepts the invitation with the role of the invitation row. The RPC that mints it only required edition permissions, while the rest of the invitation commands require admin, so a team editor could get the token of any pending invitation, including the ones that grant the owner role. The token now requires admin, and the token of an owner invitation is restricted to the team owner: an admin cannot create nor promote an owner invitation, so it cannot keep its token either. It reuses the error code already used for that rule. Minting the token now leaves an audit event named as an action, carrying the granted role and the id of the invitation, so the extraction does not go unnoticed. The list of pending invitations is admin only as well: it carries the email of the invited person and the role offered, and the dashboard was painting those emails to any member. The frontend catches the permission error and shows the permission notice instead, and the copy action is not offered for an owner invitation, which the backend rejects for an admin. The five invitation commands now answer with the same permission error, so they speak one language. Relates to GHSA-4mmc-j8rh-74pf AI-assisted-by: minimax-m3.1-flash * 🐛 Clear the cached invitations and gate the list on team admin The pending invitations are a team admin only list. The dashboard still offered the entry to every member and keyed the section on the invite policy of the organization, so a team admin blocked by that policy saw the no-permission notice although the backend lets them list. - `fetch-invitations` answers a denied refetch with an `invitations-fetched` event carrying an empty list, so a demoted admin does not keep the cached emails and the header no longer paints the invite button from a stale list. - The header tabs and the team options dropdown hide the invitations entry without `:is-admin`, which already includes the team owner. - `invitations-section-content` keys the section on `:is-admin`, and the invite policy only gates the invite actions: resending keeps the `can-invite?` check, deleting and row selection only need team admin. AI-assisted-by: deepseek-v4.1-flash
82 lines
4.1 KiB
Clojure
82 lines
4.1 KiB
Clojure
;; 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/.
|
|
|
|
(ns frontend-tests.data.team-test
|
|
(:require
|
|
[app.common.uuid :as uuid]
|
|
[app.main.data.team :as dtm]
|
|
[app.main.repo :as rp]
|
|
[app.main.ui.dashboard.team :as dtui]
|
|
[beicon.v2.core :as rx]
|
|
[cljs.test :as t :include-macros true]
|
|
[frontend-tests.helpers.async :as async]
|
|
[frontend-tests.helpers.mock :as mock]
|
|
[potok.v2.core :as ptk]))
|
|
|
|
(defn- fetch-invitations-events
|
|
"Runs the watch of `fetch-invitations` with `rp/cmd!` answering with
|
|
`response` and resolves to the team id and the events it emits."
|
|
[response]
|
|
(let [team-id (uuid/next)
|
|
state {:current-team-id team-id}
|
|
emitted (atom [])]
|
|
(-> (mock/with-mocks*
|
|
{rp/cmd! (mock/stub (fn [_cmd _params] response))}
|
|
(await (async/observe (ptk/watch (dtm/fetch-invitations) state nil)
|
|
:on-next #(swap! emitted conj %))))
|
|
(.then (fn [_] {:team-id team-id :events @emitted})))))
|
|
|
|
(t/deftest ^:async fetch-invitations-clears-the-list-on-permission-error
|
|
;; The pending invitations are an admin only list: a member that cannot
|
|
;; invite gets an insufficient permissions error. That must not become an
|
|
;; error toast, and the cached list must be dropped so the stale emails do
|
|
;; not stay around.
|
|
(let [{:keys [team-id events]}
|
|
(await (fetch-invitations-events
|
|
(rx/throw (ex-info "insufficient permissions"
|
|
{:type :validation
|
|
:code :insufficient-permissions}))))
|
|
event (first events)
|
|
state (ptk/update event {:teams {team-id {:id team-id
|
|
:invitations [{:email "stale@example.com"
|
|
:role :editor}]}}})]
|
|
(t/is (= 1 (count events)))
|
|
(t/is (= ::dtm/invitations-fetched (ptk/type event)))
|
|
(t/is (= [] (get-in state [:teams team-id :invitations])))))
|
|
|
|
(t/deftest ^:async fetch-invitations-propagates-other-errors
|
|
;; Only the permission failure is swallowed: any other error is a real
|
|
;; problem and must reach the error reporting.
|
|
(let [outcome (await (mock/with-mocks*
|
|
{rp/cmd! (mock/stub (fn [_cmd _params]
|
|
(rx/throw (ex-info "boom" {:type :server}))))}
|
|
(try
|
|
(await (async/observe (ptk/watch (dtm/fetch-invitations)
|
|
{:current-team-id (uuid/next)}
|
|
nil)))
|
|
:resolved
|
|
(catch js/Error _ :rejected))))]
|
|
(t/is (= :rejected outcome))))
|
|
|
|
(t/deftest ^:async fetch-invitations-emits-the-update-event
|
|
(let [{:keys [events]} (await (fetch-invitations-events
|
|
(rx/of [{:email "invited@example.com" :role :editor}])))]
|
|
(t/is (= 1 (count events)))
|
|
(t/is (= ::dtm/invitations-fetched (ptk/type (first events))))))
|
|
|
|
(t/deftest invitations-section-shows-the-list-only-to-the-team-admins
|
|
;; The list is admin only, and the backend normalizes `:is-admin` to
|
|
;; include the team owner. The organization invite policy does not gate it.
|
|
(t/is (= :list (dtui/invitations-section-content {:is-owner false :is-admin true})))
|
|
(t/is (= :list (dtui/invitations-section-content {:is-owner true :is-admin true})))
|
|
(t/is (= :no-permission (dtui/invitations-section-content {:is-owner false :is-admin false}))))
|
|
|
|
(t/deftest owner-invitation-link-is-copyable-only-by-the-team-owner
|
|
;; An admin cannot create nor promote an owner invitation, so the backend
|
|
;; does not mint its token for them: the action is not offered either.
|
|
(t/is (true? (dtui/invitation-link-copyable? true :owner)))
|
|
(t/is (false? (dtui/invitation-link-copyable? false :owner)))
|
|
(t/is (true? (dtui/invitation-link-copyable? false :editor)))
|
|
(t/is (true? (dtui/invitation-link-copyable? false :viewer))))
|