diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 5226e5152..0f24a6d77 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -412,7 +412,10 @@ :fn (mg/resource "app/migrations/sql/0129-mod-file-change-table.sql")} {:name "0130-mod-file-change-table" - :fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0130-mod-file-change-table.sql")} + + {:name "0131-mod-webhook-table" + :fn (mg/resource "app/migrations/sql/0131-mod-webhook-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0131-mod-webhook-table.sql b/backend/src/app/migrations/sql/0131-mod-webhook-table.sql new file mode 100644 index 000000000..67995d60d --- /dev/null +++ b/backend/src/app/migrations/sql/0131-mod-webhook-table.sql @@ -0,0 +1,6 @@ +ALTER TABLE webhook + ADD COLUMN profile_id uuid NULL REFERENCES profile (id) ON DELETE SET NULL; + +CREATE INDEX webhook__profile_id__idx + ON webhook (profile_id) + WHERE profile_id IS NOT NULL; \ No newline at end of file diff --git a/backend/src/app/rpc/commands/webhooks.clj b/backend/src/app/rpc/commands/webhooks.clj index e2a56691e..1eb999c3e 100644 --- a/backend/src/app/rpc/commands/webhooks.clj +++ b/backend/src/app/rpc/commands/webhooks.clj @@ -15,12 +15,27 @@ [app.http.client :as http] [app.loggers.webhooks :as webhooks] [app.rpc :as-alias rpc] - [app.rpc.commands.teams :refer [check-edition-permissions! check-read-permissions!]] + [app.rpc.commands.teams :refer [check-read-permissions!] :as t] [app.rpc.doc :as-alias doc] + [app.rpc.permissions :as perms] [app.util.services :as sv] [app.util.time :as dt] [cuerdas.core :as str])) +(defn get-webhooks-permissions + [conn profile-id team-id creator-id] + (let [permissions (t/get-permissions conn profile-id team-id) + + can-edit (boolean (or (:can-edit permissions) + (= profile-id creator-id)))] + (assoc permissions :can-edit can-edit))) + +(def has-webhook-edit-permissions? + (perms/make-edition-predicate-fn get-webhooks-permissions)) + +(def check-webhook-edition-permissions! + (perms/make-check-fn has-webhook-edit-permissions?)) + (defn decode-row [{:keys [uri] :as row}] (cond-> row @@ -65,11 +80,12 @@ max-hooks-for-team))))) (defn- insert-webhook! - [{:keys [::db/pool]} {:keys [team-id uri mtype is-active] :as params}] + [{:keys [::db/pool]} {:keys [team-id uri mtype is-active ::rpc/profile-id] :as params}] (-> (db/insert! pool :webhook {:id (uuid/next) :team-id team-id :uri (str uri) + :profile-id profile-id :is-active is-active :mtype mtype}) (decode-row))) @@ -101,7 +117,7 @@ {::doc/added "1.17" ::sm/params schema:create-webhook} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}] - (check-edition-permissions! pool profile-id team-id) + (check-webhook-edition-permissions! pool profile-id team-id profile-id) (validate-quotes! cfg params) (validate-webhook! cfg nil params) (insert-webhook! cfg params)) @@ -118,7 +134,7 @@ ::sm/params schema:update-webhook} [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id] :as params}] (let [whook (-> (db/get pool :webhook {:id id}) (decode-row))] - (check-edition-permissions! pool profile-id (:team-id whook)) + (check-webhook-edition-permissions! pool profile-id (:team-id whook) (:profile-id whook)) (validate-webhook! cfg whook params) (update-webhook! cfg whook params))) @@ -132,15 +148,17 @@ [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id id]}] (db/with-atomic [conn pool] (let [whook (-> (db/get conn :webhook {:id id}) decode-row)] - (check-edition-permissions! conn profile-id (:team-id whook)) + (check-webhook-edition-permissions! conn profile-id (:team-id whook) (:profile-id whook)) (db/delete! conn :webhook {:id id}) nil))) ;; --- Query: Webhooks (def sql:get-webhooks - "select id, uri, mtype, is_active, error_code, error_count - from webhook where team_id = ? order by uri") + "SELECT id, uri, mtype, is_active, error_code, error_count, profile_id + FROM webhook + WHERE team_id = ? + ORDER BY uri") (def ^:private schema:get-webhooks [:map {:title "get-webhooks"} diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index e77b51d6a..1e26d9810 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -332,6 +332,7 @@ (t/is (nil? (:error out))) (:result out))) + (defn create-webhook* ([params] (create-webhook* *system* params)) ([system {:keys [team-id id uri mtype is-active] diff --git a/backend/test/backend_tests/rpc_webhooks_test.clj b/backend/test/backend_tests/rpc_webhooks_test.clj index c020c5485..bc1da4c64 100644 --- a/backend/test/backend_tests/rpc_webhooks_test.clj +++ b/backend/test/backend_tests/rpc_webhooks_test.clj @@ -19,6 +19,23 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(defn create-webhook-params [id team] + {::th/type :create-webhook + ::rpc/profile-id id + :team-id team + :uri (u/uri "http://example.com") + :mtype "application/json"}) + +(defn check-webhook-format + ([result] + (t/is (contains? result :id)) + (t/is (contains? result :team-id)) + (t/is (contains? result :created-at)) + (t/is (contains? result :profile-id)) + (t/is (contains? result :updated-at)) + (t/is (contains? result :uri)) + (t/is (contains? result :mtype)))) + (t/deftest webhook-crud (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 200}}] @@ -39,15 +56,8 @@ (t/is (nil? (:error out))) (t/is (= 1 (:call-count @http-mock))) - ;; (th/print-result! out) - (let [result (:result out)] - (t/is (contains? result :id)) - (t/is (contains? result :team-id)) - (t/is (contains? result :created-at)) - (t/is (contains? result :updated-at)) - (t/is (contains? result :uri)) - (t/is (contains? result :mtype)) + (check-webhook-format result) (t/is (= (:uri params) (:uri result))) (t/is (= (:team-id params) (:team-id result))) @@ -69,12 +79,7 @@ (t/is (= 0 (:call-count @http-mock))) (let [result (:result out)] - (t/is (contains? result :id)) - (t/is (contains? result :team-id)) - (t/is (contains? result :created-at)) - (t/is (contains? result :updated-at)) - (t/is (contains? result :uri)) - (t/is (contains? result :mtype)) + (check-webhook-format result) (t/is (= (:id params) (:id result))) (t/is (= (:id @whook) (:id result))) @@ -130,13 +135,14 @@ (let [rows (th/db-exec! ["select * from webhook"])] (t/is (= 0 (count rows)))))) - (t/testing "delete webhook (unauthorozed)" + (th/reset-mock! http-mock) + + (t/testing "delete webhook (unauthorized)" (let [params {::th/type :delete-webhook ::rpc/profile-id uuid/zero :id (:id @whook)} out (th/command! params)] - ;; (th/print-result! out) (t/is (= 0 (:call-count @http-mock))) (let [error (:error out) error-data (ex-data error)] @@ -144,6 +150,124 @@ (t/is (= (:type error-data) :not-found)) (t/is (= (:code error-data) :object-not-found)))))))) +(t/deftest webhooks-permissions-crud-viewer-only + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 200}}] + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + whook (volatile! nil)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + ;; Assert all roles for team + (let [roles (th/db-query :team-profile-rel {:team-id (:id team)})] + (t/is (= 2 (count roles)))) + + (t/testing "viewer creates a webhook" + (let [viewers-webhook (create-webhook-params (:id viewer) (:id team)) + out (th/command! viewers-webhook)] + (t/is (nil? (:error out))) + (t/is (= 1 (:call-count @http-mock))) + + (let [result (:result out)] + (check-webhook-format result) + (t/is (= (:uri viewers-webhook) (:uri result))) + (t/is (= (:team-id viewers-webhook) (:team-id result))) + (t/is (= (::rpc/profile-id viewers-webhook) (:profile-id result))) + (t/is (= (:mtype viewers-webhook) (:mtype result))) + (vreset! whook result)))) + + (th/reset-mock! http-mock) + + (t/testing "viewer updates it's own webhook (success)" + (let [params {::th/type :update-webhook + ::rpc/profile-id (:id viewer) + :id (:id @whook) + :uri (:uri @whook) + :mtype "application/transit+json" + :is-active false} + out (th/command! params) + result (:result out)] + + (t/is (nil? (:error out))) + (t/is (= 0 (:call-count @http-mock))) + (check-webhook-format result) + (t/is (= (:is-active params) (:is-active result))) + (t/is (= (:team-id @whook) (:team-id result))) + (t/is (= (:mtype params) (:mtype result))) + (vreset! whook result))) + + (th/reset-mock! http-mock) + + (t/testing "viewer deletes it's own webhook (success)" + (let [params {::th/type :delete-webhook + ::rpc/profile-id (:id viewer) + :id (:id @whook)} + out (th/command! params)] + (t/is (= 0 (:call-count @http-mock))) + (t/is (nil? (:error out))) + (t/is (nil? (:result out))) + (let [rows (th/db-exec! ["select * from webhook"])] + (t/is (= 0 (count rows)))))) + + (th/reset-mock! http-mock)))) + +(t/deftest webhooks-permissions-crud-viewer-owner + (with-mocks [http-mock {:target 'app.http.client/req! + :return {:status 200}}] + (let [owner (th/create-profile* 1 {:is-active true}) + viewer (th/create-profile* 2 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id owner)}) + whook (volatile! nil)] + (th/create-team-role* {:team-id (:id team) + :profile-id (:id viewer) + :role :viewer}) + (t/testing "owner creates a wehbook" + (let [owners-webhook (create-webhook-params (:id owner) (:id team)) + out (th/command! owners-webhook) + result (:result out)] + (t/is (nil? (:error out))) + (t/is (= 1 (:call-count @http-mock))) + (check-webhook-format result) + (t/is (= (:uri owners-webhook) (:uri result))) + (t/is (= (:team-id owners-webhook) (:team-id result))) + (t/is (= (:mtype owners-webhook) (:mtype result))) + (vreset! whook result))) + + (th/reset-mock! http-mock) + + (t/testing "viewer updates owner's webhook (unauthorized)" + (let [params {::th/type :update-webhook + ::rpc/profile-id (:id viewer) + :id (:id @whook) + :uri (str (:uri @whook) "/test") + :mtype "application/transit+json" + :is-active false} + out (th/command! params)] + + (t/is (= 0 (:call-count @http-mock))) + + (let [error (:error out) + error-data (ex-data error)] + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))) + + (th/reset-mock! http-mock) + + (t/testing "viewer deletes owner's webhook (unauthorized)" + (let [params {::th/type :delete-webhook + ::rpc/profile-id (:id viewer) + :id (:id @whook)} + out (th/command! params) + error (:error out) + error-data (ex-data error)] + (t/is (= 0 (:call-count @http-mock))) + (t/is (th/ex-info? error)) + (t/is (= (:type error-data) :not-found)) + (t/is (= (:code error-data) :object-not-found))))))) + (t/deftest webhooks-quotes (with-mocks [http-mock {:target 'app.http.client/req! :return {:status 200}}] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index ace323cd1..185c727d8 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -23,6 +23,7 @@ [app.main.ui.components.forms :as fm] [app.main.ui.dashboard.change-owner] [app.main.ui.dashboard.team-form] + [app.main.ui.ds.foundations.assets.icon :refer [icon*]] [app.main.ui.icons :as i] [app.main.ui.notifications.badge :refer [badge-notification]] [app.main.ui.notifications.context-notification :refer [context-notification]] @@ -143,7 +144,7 @@ team-id (:id team) initial (mf/with-memo [team-id] - {:role "viewer" :team-id team-id}) + {:role "editor" :team-id team-id}) form (fm/use-form :schema schema:invite-member-form :initial initial) @@ -908,22 +909,25 @@ (mf/defc webhook-actions {::mf/wrap-props false} - [{:keys [on-edit on-delete]}] + [{:keys [on-edit on-delete can-edit?]}] (let [show? (mf/use-state false) on-show (mf/use-fn #(reset! show? true)) on-hide (mf/use-fn #(reset! show? false))] + (if can-edit? + [:* + [:button {:class (stl/css :menu-btn) + :on-click on-show} + menu-icon] + [:& dropdown {:show @show? :on-close on-hide} + [:ul {:class (stl/css :webhook-actions-dropdown)} + [:li {:on-click on-edit + :class (stl/css :webhook-dropdown-item)} (tr "labels.edit")] + [:li {:on-click on-delete + :class (stl/css :webhook-dropdown-item)} (tr "labels.delete")]]]] - - [:* - [:button {:class (stl/css :menu-btn) - :on-click on-show} - menu-icon] - [:& dropdown {:show @show? :on-close on-hide} - [:ul {:class (stl/css :webhook-actions-dropdown)} - [:li {:on-click on-edit - :class (stl/css :webhook-dropdown-item)} (tr "labels.edit")] - [:li {:on-click on-delete - :class (stl/css :webhook-dropdown-item)} (tr "labels.delete")]]]])) + [:span {:title (tr "dashboard.webhooks.cant-edit") + :class (stl/css :menu-disabled)} + [:> icon* {:id "menu"}]]))) (mf/defc last-delivery-icon {::mf/wrap-props false} @@ -936,10 +940,14 @@ (mf/defc webhook-item {::mf/wrap [mf/memo]} - [{:keys [webhook] :as props}] + [{:keys [webhook permissions] :as props}] (let [error-code (:error-code webhook) id (:id webhook) - + creator-id (:profile-id webhook) + profile (mf/deref refs/profile) + user-id (:id profile) + can-edit? (or (:can-edit permissions) + (= creator-id user-id)) on-edit (mf/use-fn (mf/deps webhook) @@ -992,14 +1000,15 @@ [:div {:class (stl/css :table-field :actions)} [:& webhook-actions {:on-edit on-edit + :can-edit? can-edit? :on-delete on-delete}]]])) (mf/defc webhooks-list {::mf/wrap-props false} - [{:keys [webhooks]}] + [{:keys [webhooks permissions]}] [:div {:class (stl/css :table-rows :webhook-table)} (for [webhook webhooks] - [:& webhook-item {:webhook webhook :key (:id webhook)}])]) + [:& webhook-item {:webhook webhook :key (:id webhook) :permissions permissions}])]) (mf/defc team-webhooks-page {::mf/wrap-props false} @@ -1025,7 +1034,7 @@ [:div {:class (stl/css :webhooks-empty)} [:div (tr "dashboard.webhooks.empty.no-webhooks")] [:div (tr "dashboard.webhooks.empty.add-one")]] - [:& webhooks-list {:webhooks webhooks}])]]])) + [:& webhooks-list {:webhooks webhooks :permissions (:permissions team)}])]]])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; SETTINGS SECTION diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index d914ea773..f22e4e85f 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -252,7 +252,7 @@ // MEMBER ACTIONS .menu-icon { @extend .button-icon; - stroke: var(--icon-foreground); + stroke: var(--color-foreground-primary); } .menu-btn { @@ -405,6 +405,14 @@ position: relative; } +.menu-disabled { + color: var(--icon-foreground); + width: $s-28; + display: flex; + justify-content: center; + align-items: center; +} + .webhook-actions-dropdown { @extend .menu-dropdown; right: calc(-1 * $s-16); diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index f54d4d48e..4c22e6dcd 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -69,7 +69,7 @@ {::mf/props :obj} [{:keys [name on-back go-to-team?]}] (let [initial (mf/use-memo - #(do {:role "viewer" + #(do {:role "editor" :name name})) form (fm/use-form :schema schema:invite-form diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index f73417803..fb2c6bc37 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -222,8 +222,8 @@ (if ^boolean read-only? (when (not ^boolean user-viewer?) [:& badge-notification {:is-focus true - :size :small - :content (tr "labels.view-only")}]) + :size :small + :content (tr "labels.view-only")}]) [:button {:class (stl/css :add-page) :on-click on-create} i/add])] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 53979c6b1..cc3f836a4 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -952,6 +952,10 @@ msgstr "No webhooks created so far." msgid "dashboard.webhooks.update.success" msgstr "Webhook updated successfully." +#: src/app/main/ui/dashboard/team.cljs +msgid "dashboard.webhooks.cant-edit" +msgstr "You only can delete or modify webhooks created by you." + #: src/app/main/ui/settings.cljs:31 msgid "dashboard.your-account-title" msgstr "Your account" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 53ca42684..9b79b3e67 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -962,6 +962,10 @@ msgstr "No hay ningún webhook aún." msgid "dashboard.webhooks.update.success" msgstr "Webhook modificado con éxito." +#: src/app/main/ui/dashboard/team.cljs +msgid "dashboard.webhooks.cant-edit" +msgstr "Sólo puedes borrar o modificar webhooks creados por ti." + #: src/app/main/ui/settings.cljs:31 msgid "dashboard.your-account-title" msgstr "Tu cuenta"