diff --git a/backend/src/app/rpc/commands/audit.clj b/backend/src/app/rpc/commands/audit.clj index 0f4bf1c320..69777a1ddd 100644 --- a/backend/src/app/rpc/commands/audit.clj +++ b/backend/src/app/rpc/commands/audit.clj @@ -183,6 +183,7 @@ (sv/defmethod ::get-enabled-flags {::audit/skip true + ::rpc/auth false ::doc/skip true ::doc/added "1.20"} [_cfg _params] diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 69bead539d..323ddf6c3f 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -245,6 +245,10 @@ [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}] (db/run! cfg (fn [cfg] (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)] + (when (= :share-link (:type perms)) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")) (check-read-permissions! perms) (-> (get-file-fragment cfg file-id fragment-id) (rph/with-http-cache long-cache-duration)))))) @@ -392,6 +396,14 @@ (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id) file (bfc/get-file cfg file-id :read-only? true) + resolved-page-id (or page-id (-> file :data :pages first)) + + _ (when (and (= :share-link (:type perms)) + (not (contains? (:pages perms) resolved-page-id))) + (ex/raise :type :not-found + :code :object-not-found + :hint "object not found")) + proj (db/get conn :project {:id (:project-id file)}) team (-> (db/get conn :team {:id (:team-id proj)}) @@ -402,8 +414,7 @@ (cfeat/check-file-features! (:features file))) page (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] - (let [page-id (or page-id (-> file :data :pages first)) - page (dm/get-in file [:data :pages-index page-id])] + (let [page (dm/get-in file [:data :pages-index resolved-page-id])] (if (pmap/pointer-map? page) (deref page) page)))] diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 27e881dc45..da8fbbb1f7 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2532,6 +2532,74 @@ share-links (:share-links (:result check))] (t/is (some #(= slink2-id (:id %)) share-links))))))) +(t/deftest share-link-page-scope-enforcement + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + page-b (uuid/random) + + ;; Add a second page to the file + _ (th/command! {::th/type :update-file + ::rpc/profile-id (:id owner) + :id (:id file) + :session-id (uuid/random) + :revn 0 + :vern 0 + :changes [{:type :add-page + :id page-b + :page {:id page-b + :name "Page B" + :options {} + :objects {}}}]}) + + ;; Create share-link scoped to page A only + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder can access authorized page" + (let [out (th/command! {::th/type :get-page + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :page-id page-a + :share-id share-id})] + (t/is (nil? (:error out))) + (t/is (some? (:result out))))) + + (t/testing "share-link holder cannot access out-of-scope page" + (let [out (th/command! {::th/type :get-page + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :page-id page-b + :share-id share-id}) + err (:error out) + edata (ex-data err)] + (t/is (th/ex-info? err)) + (t/is (= :not-found (:type edata))) + (t/is (= :object-not-found (:code edata))))) + + (t/testing "team member can access all pages" + (let [out-a (th/command! {::th/type :get-page + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-a}) + out-b (th/command! {::th/type :get-page + ::rpc/profile-id (:id owner) + :file-id (:id file) + :page-id page-b})] + (t/is (nil? (:error out-a))) + (t/is (nil? (:error out-b))))))) + (t/deftest share-link-deletion-escape-hatches (let [owner (th/create-profile* 1 {:is-active true}) editor (th/create-profile* 2 {:is-active true}) @@ -2594,3 +2662,35 @@ ::rpc/profile-id (:id owner) :id slink-id})] (t/is (nil? (:error out))))))) + +(t/deftest share-link-fragment-access-denied + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + proj-id (:default-project-id owner) + + file (th/create-file* 1 {:profile-id (:id owner) + :project-id proj-id + :is-shared false}) + + page-a (get-in file [:data :pages 0]) + + ;; Create share-link + share (th/command! {::th/type :create-share-link + ::rpc/profile-id (:id owner) + :file-id (:id file) + :pages #{page-a} + :who-comment "team" + :who-inspect "all"}) + share-id (get-in share [:result :id])] + + (t/testing "share-link holder cannot access file fragments" + (let [out (th/command! {::th/type :get-file-fragment + ::rpc/profile-id (:id viewer) + :file-id (:id file) + :fragment-id (uuid/random) + :share-id share-id}) + err (:error out) + edata (ex-data err)] + (t/is (th/ex-info? err)) + (t/is (= :not-found (:type edata))) + (t/is (= :object-not-found (:code edata))))))) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index d250543807..f6173b3876 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -61,13 +61,18 @@ ;; Delete old teams from state state (update state :teams #(select-keys % team-ids))] (reduce (fn [state {:keys [id organization-id] :as team}] - (let [team-updated (cond-> (merge (dm/get-in state [:teams id]) team) - (not organization-id) (dissoc :organization-id - :organization-name - :organization-slug - :organization-owner-id - :organization-avatar-bg-url - :organization-permissions))] + (let [team-merged (merge (dm/get-in state [:teams id]) team) + has-org? (or (some? organization-id) (some? (:organization team))) + team-updated (if has-org? + team-merged + (dissoc team-merged + :organization + :organization-id + :organization-name + :organization-slug + :organization-owner-id + :organization-avatar-bg-url + :organization-permissions))] (update state :teams assoc id team-updated))) state teams))))) diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 730cf08f66..80d010c75b 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -140,23 +140,16 @@ [:a {:on-click on-nav-settings} (tr "labels.settings")]]]] [:div {:class (stl/css :dashboard-buttons)} (when (and (or invitations-section? members-section?) (not-empty invitations)) - (let [organization (:organization team) - owners-only-invites? (and (contains? cfg/flags :admin-console) - organization - (= (get-in organization [:permissions :send-invitations]) "owners")) - title-text (if owners-only-invites? - (tr "dashboard.invite-profile-disabled.owners-only" (:name organization)) - (tr "dashboard.invite-profile-disabled")) - invite-button (mf/html - [:> button* {:class (stl/css :invite-button) - :variant "secondary" - :on-click on-invite-member - :disabled (not can-invite?) - :data-testid "invite-member"} - (tr "dashboard.invite-profile")])] + (let [invite-button (mf/html + [:> button* {:class (stl/css :invite-button) + :variant "secondary" + :on-click on-invite-member + :disabled (not can-invite?) + :data-testid "invite-member"} + (tr "dashboard.invite-profile")])] (if can-invite? invite-button - [:> tooltip* {:content title-text + [:> tooltip* {:content (tr "dashboard.invite-profile-disabled") :id "invite-member-disabled-tooltip" :tab-index 0} invite-button])))]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index 5b983ef888..c9c34bfb73 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -121,9 +121,7 @@ [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} "sales@penpot.app"]] [:div {:class (stl/css :activation-code)} - [:p {:class (stl/css :modal-text-large)} - (tr "nitrate.form.subscribe-with-code")] [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) :on-click on-activate-click} - (tr "nitrate.form.enter-code")]]]])]]]])) + (tr "nitrate.form.subscribe-with-code")]]]])]]]])) diff --git a/frontend/src/app/main/ui/notifications.cljs b/frontend/src/app/main/ui/notifications.cljs index 47c038f835..a7cead63cc 100644 --- a/frontend/src/app/main/ui/notifications.cljs +++ b/frontend/src/app/main/ui/notifications.cljs @@ -35,6 +35,7 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) + :is-html (:is-html notification) :detail (:detail notification) :on-close on-close} content] @@ -57,5 +58,6 @@ [:> toast* {:level (or (:level notification) :info) :type (:type notification) + :is-html (:is-html notification) :detail (:detail notification) :on-close on-close} content])))) diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 828353e3c9..b85b0f4cc0 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -10354,7 +10354,4 @@ msgid "labels.sso-error.retry" msgstr "Try again" msgid "dashboard.invite-profile-disabled" -msgstr "You don't have permission to invite people to this team" - -msgid "dashboard.invite-profile-disabled.owners-only" -msgstr "Only team owners can invite within %s" +msgstr "You don't have permission to invite people to this team" \ No newline at end of file diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 53cdbdc91e..acfeeb8fab 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -9999,7 +9999,4 @@ msgid "labels.sso-error.retry" msgstr "Intentar de nuevo" msgid "dashboard.invite-profile-disabled" -msgstr "No tienes permiso para invitar a personas a este equipo" - -msgid "dashboard.invite-profile-disabled.owners-only" -msgstr "Solo los propietarios del equipo pueden invitar dentro de %s" \ No newline at end of file +msgstr "No tienes permiso para invitar a personas a este equipo" \ No newline at end of file