diff --git a/CHANGES.md b/CHANGES.md index b2d836bb83..548b9c0ab3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,8 @@ ### :sparkles: New features +- Viewer role for team members [Taiga #1056 & #6590](https://tree.taiga.io/project/penpot/us/1056 & https://tree.taiga.io/project/penpot/us/6590) + ### :bug: Bugs fixed ## 2.3.0 diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 62a426b022..b41279c7e1 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -791,7 +791,7 @@ [:map [:id ::sm/uuid] [:fullname :string]]] - [:role [::sm/one-of valid-roles]] + [:role [::sm/one-of tt/valid-roles]] [:email ::sm/email]]) (def ^:private check-create-invitation-params! diff --git a/common/src/app/common/test_helpers/files.cljc b/common/src/app/common/test_helpers/files.cljc index 59b1665553..7d384b0efb 100644 --- a/common/src/app/common/test_helpers/files.cljc +++ b/common/src/app/common/test_helpers/files.cljc @@ -22,7 +22,7 @@ ;; ----- Files (defn sample-file - [label & {:keys [page-label name] :as params}] + [label & {:keys [page-label name view-only?] :as params}] (binding [ffeat/*current* #{"components/v2"}] (let [params (cond-> params label @@ -35,7 +35,8 @@ (assoc :name "Test file")) file (-> (ctf/make-file (dissoc params :page-label)) - (assoc :features #{"components/v2"})) + (assoc :features #{"components/v2"}) + (assoc :permissions {:can-edit (not (true? view-only?))})) page (-> file :data diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 080f193fa4..cfc6a41fa1 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -178,19 +178,23 @@ (ptk/reify ::commit-changes ptk/WatchEvent (watch [_ state _] - (let [file-id (or file-id (:current-file-id state)) - uchg (vec undo-changes) - rchg (vec redo-changes) - features (features/get-team-enabled-features state)] + (let [file-id (or file-id (:current-file-id state)) + uchg (vec undo-changes) + rchg (vec redo-changes) + features (features/get-team-enabled-features state) + user-viewer? (not (get-in state [:workspace-file :permissions :can-edit]))] - (rx/of (-> params - (assoc :undo-group undo-group) - (assoc :features features) - (assoc :tags tags) - (assoc :stack-undo? stack-undo?) - (assoc :save-undo? save-undo?) - (assoc :file-id file-id) - (assoc :file-revn (resolve-file-revn state file-id)) - (assoc :undo-changes uchg) - (assoc :redo-changes rchg) - (commit))))))) + ;; Prevent commit changes by a viewer team member (it really should never happen) + (if user-viewer? + (rx/empty) + (rx/of (-> params + (assoc :undo-group undo-group) + (assoc :features features) + (assoc :tags tags) + (assoc :stack-undo? stack-undo?) + (assoc :save-undo? save-undo?) + (assoc :file-id file-id) + (assoc :file-revn (resolve-file-revn state file-id)) + (assoc :undo-changes uchg) + (assoc :redo-changes rchg) + (commit)))))))) diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index 3a00faff9d..7cdc00f206 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -7,7 +7,9 @@ (ns app.main.data.common "A general purpose events." (:require + [app.common.data.macros :as dm] [app.common.types.components-list :as ctkl] + [app.common.types.team :as tt] [app.config :as cf] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] @@ -171,25 +173,47 @@ (rx/tap on-success) (rx/catch on-error)))))) + (defn change-team-permissions - [team-id role] + [{:keys [team-id role workspace?]}] + (dm/assert! (uuid? team-id)) + (dm/assert! (contains? tt/valid-roles role)) (ptk/reify ::change-team-permissions + ptk/WatchEvent + (watch [_ _ _] + (let [msg (case role + :viewer + (tr "dashboard.permissions-change.viewer") + + :editor + (tr "dashboard.permissions-change.editor") + + :admin + (tr "dashboard.permissions-change.admin") + + :owner + (tr "dashboard.permissions-change.owner"))] + (rx/of (ntf/info msg)))) + ptk/UpdateEvent (update [_ state] - (update-in state [:teams team-id :permissions] - (fn [permissions] - (cond - (= role :viewer) - (assoc permissions :can-edit false :is-admin false :is-owner false) + (let [route (if workspace? + [:workspace-file :permissions] + [:teams team-id :permissions])] + (update-in state route + (fn [permissions] + (cond + (= role :viewer) + (assoc permissions :can-edit false :is-admin false :is-owner false) - (= role :editor) - (assoc permissions :can-edit true :is-admin false :is-owner false) + (= role :editor) + (assoc permissions :can-edit true :is-admin false :is-owner false) - (= role :admin) - (assoc permissions :can-edit true :is-admin true :is-owner false) + (= role :admin) + (assoc permissions :can-edit true :is-admin true :is-owner false) - (= role :owner) - (assoc permissions :can-edit true :is-admin true :is-owner true) + (= role :owner) + (assoc permissions :can-edit true :is-admin true :is-owner true) - :else - permissions)))))) \ No newline at end of file + :else + permissions))))))) \ No newline at end of file diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index 98ed715f5f..88614547b0 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -20,12 +20,10 @@ [app.main.data.events :as ev] [app.main.data.fonts :as df] [app.main.data.media :as di] - [app.main.data.notifications :as ntf] [app.main.data.users :as du] [app.main.data.websocket :as dws] [app.main.features :as features] [app.main.repo :as rp] - [app.main.store :as st] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.router :as rt] @@ -480,27 +478,6 @@ :team-id team-id})))) (rx/catch on-error)))))) -(defn handle-team-permissions-change - [{:keys [role team-id]}] - (dm/assert! (uuid? team-id)) - (dm/assert! (contains? tt/valid-roles role)) - - (let [msg (case role - :viewer - (tr "dashboard.permissions-change.viewer") - - :editor - (tr "dashboard.permissions-change.editor") - - :admin - (tr "dashboard.permissions-change.admin") - - :owner - (tr "dashboard.permissions-change.owner"))] - - (st/emit! (ntf/info msg) - (dc/change-team-permissions team-id role)))) - (defn update-team-member-role [{:keys [role member-id] :as params}] (dm/assert! (uuid? member-id)) @@ -1237,5 +1214,5 @@ [{:keys [type] :as msg}] (case type :notification (dc/handle-notification msg) - :team-permissions-change (handle-team-permissions-change msg) + :team-permissions-change (dc/change-team-permissions (assoc msg :workspace? false)) nil)) \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace/notifications.cljs b/frontend/src/app/main/data/workspace/notifications.cljs index e602618e19..1b5a012036 100644 --- a/frontend/src/app/main/data/workspace/notifications.cljs +++ b/frontend/src/app/main/data/workspace/notifications.cljs @@ -12,8 +12,10 @@ [app.common.schema :as sm] [app.common.uuid :as uuid] [app.main.data.changes :as dch] - [app.main.data.common :refer [handle-notification]] + [app.main.data.common :refer [handle-notification change-team-permissions]] [app.main.data.websocket :as dws] + [app.main.data.workspace.edition :as dwe] + [app.main.data.workspace.layout :as dwly] [app.main.data.workspace.libraries :as dwl] [app.util.globals :refer [global]] [app.util.mouse :as mse] @@ -92,17 +94,39 @@ (rx/concat stream (rx/of (dws/send endmsg))))))) + +(defn- handle-change-team-permissions + [{:keys [role] :as msg}] + (ptk/reify ::handle-change-team-permissions + ptk/WatchEvent + (watch [_ _ _] + (let [viewer? (= :viewer role)] + + (rx/concat + (->> (rx/of :interrupt + (dwe/clear-edition-mode)) + ;; Delay so anything that launched :interrupt can finish + (rx/delay 500)) + + (if viewer? + (rx/of (dwly/set-options-mode :design)) + (rx/empty)) + + (rx/of (change-team-permissions msg))))))) + + (defn- process-message [{:keys [type] :as msg}] (case type - :join-file (handle-presence msg) - :leave-file (handle-presence msg) - :presence (handle-presence msg) - :disconnect (handle-presence msg) - :pointer-update (handle-pointer-update msg) - :file-change (handle-file-change msg) - :library-change (handle-library-change msg) - :notification (handle-notification msg) + :join-file (handle-presence msg) + :leave-file (handle-presence msg) + :presence (handle-presence msg) + :disconnect (handle-presence msg) + :pointer-update (handle-pointer-update msg) + :file-change (handle-file-change msg) + :library-change (handle-library-change msg) + :notification (handle-notification msg) + :team-permissions-change (handle-change-team-permissions (assoc msg :workspace? true)) nil)) (defn- handle-pointer-send @@ -257,3 +281,7 @@ (when (contains? (:workspace-libraries state) file-id) (rx/of (dwl/ext-library-changed file-id modified-at revn changes) (dwl/notify-sync-file file-id)))))) + + + + diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index fd045067a4..444ed499e1 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -44,8 +44,12 @@ (defn emit-when-no-readonly [& events] - (when-not (deref refs/workspace-read-only?) - (run! st/emit! events))) + (let [file (deref refs/workspace-file) + user-viewer? (not (get-in file [:permissions :can-edit])) + read-only? (or (deref refs/workspace-read-only?) + user-viewer?)] + (when-not read-only? + (run! st/emit! events)))) (def esc-pressed (ptk/reify ::esc-pressed diff --git a/frontend/src/app/main/data/workspace/text/shortcuts.cljs b/frontend/src/app/main/data/workspace/text/shortcuts.cljs index 0970fca8bb..d67b327d3c 100644 --- a/frontend/src/app/main/data/workspace/text/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/text/shortcuts.cljs @@ -189,7 +189,10 @@ (defn- update-attrs-when-no-readonly [props] (let [undo-id (js/Symbol) - read-only? (deref refs/workspace-read-only?) + file (deref refs/workspace-file) + user-viewer? (not (get-in file [:permissions :can-edit])) + read-only? (or (deref refs/workspace-read-only?) + user-viewer?) shapes-with-children (deref refs/selected-shapes-with-children) text-shapes (filter #(= (:type %) :text) shapes-with-children) props (if (> (count text-shapes) 1) diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index c007896b9f..9323171ce7 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -31,3 +31,5 @@ (def workspace-read-only? (mf/create-context nil)) (def is-component? (mf/create-context false)) (def sidebar (mf/create-context nil)) + +(def user-viewer? (mf/create-context nil)) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index a284ec28ea..75d4221377 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -164,7 +164,7 @@ (let [layout (mf/deref refs/workspace-layout) wglobal (mf/deref refs/workspace-global) - read-only? (mf/deref refs/workspace-read-only?) + file (mf/deref refs/workspace-file) project (mf/deref refs/workspace-project) @@ -172,6 +172,10 @@ team-id (:team-id project) file-name (:name file) + user-viewer? (not (get-in file [:permissions :can-edit])) + read-only? (or (mf/deref refs/workspace-read-only?) + user-viewer?) + file-ready* (mf/with-memo [file-id] (make-file-ready-ref file-id)) file-ready? (mf/deref file-ready*) @@ -210,13 +214,14 @@ [:& (mf/provider ctx/current-page-id) {:value page-id} [:& (mf/provider ctx/components-v2) {:value components-v2?} [:& (mf/provider ctx/workspace-read-only?) {:value read-only?} - [:section {:class (stl/css :workspace) - :style {:background-color background-color - :touch-action "none"}} - [:& context-menu] - (if ^boolean file-ready? - [:& workspace-page {:page-id page-id - :file file - :wglobal wglobal - :layout layout}] - [:& workspace-loader])]]]]]]])) + [:& (mf/provider ctx/user-viewer?) {:value user-viewer?} + [:section {:class (stl/css :workspace) + :style {:background-color background-color + :touch-action "none"}} + [:& context-menu] + (if ^boolean file-ready? + [:& workspace-page {:page-id page-id + :file file + :wglobal wglobal + :layout layout}] + [:& workspace-loader])]]]]]]]])) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index fb2ba7148f..ec25163627 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -466,13 +466,13 @@ (mf/defc file-menu {::mf/wrap-props false} - [{:keys [on-close file]}] - (let [file-id (:id file) - shared? (:is-shared file) + [{:keys [on-close file user-viewer?]}] + (let [file-id (:id file) + shared? (:is-shared file) - objects (mf/deref refs/workspace-page-objects) - frames (->> (cfh/get-immediate-children objects uuid/zero) - (filterv cfh/frame-shape?)) + objects (mf/deref refs/workspace-page-objects) + frames (->> (cfh/get-immediate-children objects uuid/zero) + (filterv cfh/frame-shape?)) on-remove-shared (mf/use-fn @@ -565,11 +565,12 @@ :id "file-menu-remove-shared"} [:span {:class (stl/css :item-name)} (tr "dashboard.unpublish-shared")]] - [:> dropdown-menu-item* {:class (stl/css :submenu-item) - :on-click on-add-shared - :on-key-down on-add-shared-key-down - :id "file-menu-add-shared"} - [:span {:class (stl/css :item-name)} (tr "dashboard.add-shared")]]) + (when-not user-viewer? + [:> dropdown-menu-item* {:class (stl/css :submenu-item) + :on-click on-add-shared + :on-key-down on-add-shared-key-down + :id "file-menu-add-shared"} + [:span {:class (stl/css :item-name)} (tr "dashboard.add-shared")]])) [:> dropdown-menu-item* {:class (stl/css :submenu-item) :on-click on-export-shapes @@ -657,6 +658,8 @@ sub-menu* (mf/use-state false) sub-menu (deref sub-menu*) + user-viewer? (mf/use-ctx ctx/user-viewer?) + open-menu (mf/use-fn (fn [event] @@ -732,16 +735,17 @@ [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.file")] [:span {:class (stl/css :open-arrow)} i/arrow]] - [:> dropdown-menu-item* {:class (stl/css :menu-item) - :on-click on-menu-click - :on-key-down (fn [event] - (when (kbd/enter? event) - (on-menu-click event))) - :on-pointer-enter on-menu-click - :data-testid "edit" - :id "file-menu-edit"} - [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.edit")] - [:span {:class (stl/css :open-arrow)} i/arrow]] + (when-not user-viewer? + [:> dropdown-menu-item* {:class (stl/css :menu-item) + :on-click on-menu-click + :on-key-down (fn [event] + (when (kbd/enter? event) + (on-menu-click event))) + :on-pointer-enter on-menu-click + :data-testid "edit" + :id "file-menu-edit"} + [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.edit")] + [:span {:class (stl/css :open-arrow)} i/arrow]]) [:> dropdown-menu-item* {:class (stl/css :menu-item) :on-click on-menu-click @@ -793,7 +797,8 @@ :file [:& file-menu {:file file - :on-close close-sub-menu}] + :on-close close-sub-menu + :user-viewer? user-viewer?}] :edit [:& edit-menu diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs index 7f7e67d926..06e12e3f07 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs @@ -184,8 +184,8 @@ on-click (mf/use-fn - (mf/deps color-id apply-color on-asset-click) - (do + (mf/deps color-id apply-color on-asset-click read-only?) + (when-not read-only? (dwl/add-recent-color color) (partial on-asset-click color-id apply-color)))] diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs index 022bbebc9f..5b8f2a3a8d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs @@ -272,9 +272,10 @@ apply-typography (mf/use-fn - (mf/deps file-id) + (mf/deps file-id read-only?) (fn [typography _event] - (st/emit! (dwt/apply-typography typography file-id)))) + (when-not read-only? + (st/emit! (dwt/apply-typography typography file-id))))) create-group (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index 91edbe9863..e10fa59a5f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -134,6 +134,8 @@ [{:keys [selected shapes shapes-with-children page-id file-id on-change-section on-expand]}] (let [objects (mf/deref refs/workspace-page-objects) + user-viewer? (mf/use-ctx ctx/user-viewer?) + selected-shapes (into [] (keep (d/getf objects)) selected) first-selected-shape (first selected-shapes) shape-parent-frame (cfh/get-frame objects (:frame-id first-selected-shape)) @@ -173,17 +175,21 @@ tabs - #js [#js {:label (tr "workspace.options.design") - :id "design" - :content design-content} + (if user-viewer? + #js [#js {:label (tr "workspace.options.inspect") + :id "inspect" + :content inspect-content}] + #js [#js {:label (tr "workspace.options.design") + :id "design" + :content design-content} - #js {:label (tr "workspace.options.prototype") - :id "prototype" - :content interactions-content} + #js {:label (tr "workspace.options.prototype") + :id "prototype" + :content interactions-content} #js {:label (tr "workspace.options.inspect") :id "inspect" - :content inspect-content}]] + :content inspect-content}])] [:div {:class (stl/css :tool-window)} [:> tab-switcher* {:tabs tabs diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index f8ca6f90cf..f734178033 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -208,6 +208,7 @@ read-only? (mf/use-ctx ctx/workspace-read-only?) user-viewer? (mf/use-ctx ctx/user-viewer?)] + [:div {:class (stl/css :sitemap) :style #js {"--height" (str size "px")}} @@ -221,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/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index 7040c2c1e5..98d4d2f9b0 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -96,6 +96,7 @@ vbox' (mf/use-debounce 100 vbox) ;; DEREFS + user-viewer? (mf/use-ctx ctx/user-viewer?) drawing (mf/deref refs/workspace-drawing) focus (mf/deref refs/workspace-focus-selected) @@ -277,7 +278,8 @@ (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} - [:& top-bar/top-bar {:layout layout}] + (when-not user-viewer? + [:& top-bar/top-bar {:layout layout}]) [:div {:class (stl/css :viewport-overlays)} ;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap ;; inside a foreign object "dummy" so this awkward behaviour is take into account @@ -286,12 +288,13 @@ [:div {:style {:pointer-events (when-not (dbg/enabled? :html-text) "none") ;; some opacity because to debug auto-width events will fill the screen :opacity 0.6}} - [:& stvh/viewport-texts - {:key (dm/str "texts-" page-id) - :page-id page-id - :objects objects - :modifiers modifiers - :edition edition}]]]] + (when-not workspace-read-only? + [:& stvh/viewport-texts + {:key (dm/str "texts-" page-id) + :page-id page-id + :objects objects + :modifiers modifiers + :edition edition}])]]] (when show-comments? [:& comments/comments-layer {:vbox vbox