diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 1c51365a9c..0c04c1aa95 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -441,7 +441,10 @@ :fn (mg/resource "app/migrations/sql/0139-mod-file-change-table.sql")} {:name "0140-mod-file-change-table.sql" - :fn (mg/resource "app/migrations/sql/0140-mod-file-change-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0140-mod-file-change-table.sql")} + + {:name "0140-add-locked-by-column-to-file-change-table" + :fn (mg/resource "app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql b/backend/src/app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql new file mode 100644 index 0000000000..d9052b105f --- /dev/null +++ b/backend/src/app/migrations/sql/0140-add-locked-by-column-to-file-change-table.sql @@ -0,0 +1,11 @@ +-- Add locked_by column to file_change table for version locking feature +-- This allows users to lock their own saved versions to prevent deletion by others + +ALTER TABLE file_change + ADD COLUMN locked_by uuid NULL REFERENCES profile(id) ON DELETE SET NULL DEFERRABLE; + +-- Create index for locked versions queries +CREATE INDEX file_change__locked_by__idx ON file_change (locked_by) WHERE locked_by IS NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN file_change.locked_by IS 'Profile ID of user who has locked this version. Only the creator can lock/unlock their own versions. Locked versions cannot be deleted by others.'; \ No newline at end of file diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index e45769b533..43c59def96 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -38,7 +38,7 @@ (def sql:get-file-snapshots "WITH changes AS ( - SELECT id, label, revn, created_at, created_by, profile_id + SELECT id, label, revn, created_at, created_by, profile_id, locked_by FROM file_change WHERE file_id = ? AND data IS NOT NULL @@ -284,7 +284,7 @@ [conn id] (db/get conn :file-change {:id id} - {::sql/columns [:id :file-id :created-by :deleted-at] + {::sql/columns [:id :file-id :created-by :deleted-at :profile-id :locked-by] ::db/for-update true})) (sv/defmethod ::update-file-snapshot @@ -324,4 +324,111 @@ :snapshot-id id :profile-id profile-id)) + ;; Check if version is locked by someone else + (when (and (:locked-by snapshot) + (not= (:locked-by snapshot) profile-id)) + (ex/raise :type :validation + :code :snapshot-is-locked + :hint "Cannot delete a locked version" + :snapshot-id id + :profile-id profile-id + :locked-by (:locked-by snapshot))) + (delete-file-snapshot! conn id))))) + +;;; Lock/unlock version endpoints + +(def ^:private schema:lock-file-snapshot + [:map {:title "lock-file-snapshot"} + [:id ::sm/uuid]]) + +(defn- lock-file-snapshot! + [conn snapshot-id profile-id] + (db/update! conn :file-change + {:locked-by profile-id} + {:id snapshot-id} + {::db/return-keys false}) + nil) + +(sv/defmethod ::lock-file-snapshot + {::doc/added "1.20" + ::sm/params schema:lock-file-snapshot} + [cfg {:keys [::rpc/profile-id id]}] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [snapshot (get-snapshot conn id)] + (files/check-edition-permissions! conn profile-id (:file-id snapshot)) + + (when (not= (:created-by snapshot) "user") + (ex/raise :type :validation + :code :system-snapshots-cant-be-locked + :hint "Only user-created versions can be locked" + :snapshot-id id + :profile-id profile-id)) + + ;; Only the creator can lock their own version + (when (not= (:profile-id snapshot) profile-id) + (ex/raise :type :validation + :code :only-creator-can-lock + :hint "Only the version creator can lock it" + :snapshot-id id + :profile-id profile-id + :creator-id (:profile-id snapshot))) + + ;; Check if already locked + (when (:locked-by snapshot) + (ex/raise :type :validation + :code :snapshot-already-locked + :hint "Version is already locked" + :snapshot-id id + :profile-id profile-id + :locked-by (:locked-by snapshot))) + + (lock-file-snapshot! conn id profile-id))))) + +(def ^:private schema:unlock-file-snapshot + [:map {:title "unlock-file-snapshot"} + [:id ::sm/uuid]]) + +(defn- unlock-file-snapshot! + [conn snapshot-id] + (db/update! conn :file-change + {:locked-by nil} + {:id snapshot-id} + {::db/return-keys false}) + nil) + +(sv/defmethod ::unlock-file-snapshot + {::doc/added "1.20" + ::sm/params schema:unlock-file-snapshot} + [cfg {:keys [::rpc/profile-id id]}] + (db/tx-run! cfg + (fn [{:keys [::db/conn]}] + (let [snapshot (get-snapshot conn id)] + (files/check-edition-permissions! conn profile-id (:file-id snapshot)) + + (when (not= (:created-by snapshot) "user") + (ex/raise :type :validation + :code :system-snapshots-cant-be-unlocked + :hint "Only user-created versions can be unlocked" + :snapshot-id id + :profile-id profile-id)) + + ;; Only the creator can unlock their own version + (when (not= (:profile-id snapshot) profile-id) + (ex/raise :type :validation + :code :only-creator-can-unlock + :hint "Only the version creator can unlock it" + :snapshot-id id + :profile-id profile-id + :creator-id (:profile-id snapshot))) + + ;; Check if not locked + (when (not (:locked-by snapshot)) + (ex/raise :type :validation + :code :snapshot-not-locked + :hint "Version is not locked" + :snapshot-id id + :profile-id profile-id)) + + (unlock-file-snapshot! conn id))))) diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index c3373bcba9..ce17291f7a 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -148,6 +148,23 @@ (fetch-versions) (ptk/event ::ev/event {::ev/name "pin-version"}))))))))) +(defn lock-version + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::lock-version + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :lock-file-snapshot {:id id}) + (rx/map fetch-versions))))) + +(defn unlock-version + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::unlock-version + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :unlock-file-snapshot {:id id}) + (rx/map fetch-versions))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PLUGINS SPECIFIC EVENTS diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index e206c79258..2b9a54d39f 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -148,6 +148,38 @@ (= code :vern-conflict) (st/emit! (ptk/event ::dw/reload-current-file)) + (= code :snapshot-is-locked) + (let [message (tr "errors.version-locked")] + (st/async-emit! + (ntf/show {:content message + :type :toast + :level :error + :timeout 3000}))) + + (= code :only-creator-can-lock) + (let [message (tr "errors.only-creator-can-lock")] + (st/async-emit! + (ntf/show {:content message + :type :toast + :level :error + :timeout 3000}))) + + (= code :only-creator-can-unlock) + (let [message (tr "errors.only-creator-can-unlock")] + (st/async-emit! + (ntf/show {:content message + :type :toast + :level :error + :timeout 3000}))) + + (= code :snapshot-already-locked) + (let [message (tr "errors.version-already-locked")] + (st/async-emit! + (ntf/show {:content message + :type :toast + :level :error + :timeout 3000}))) + :else (st/async-emit! (rt/assign-exception error)))) diff --git a/frontend/src/app/main/ui/ds/product/user_milestone.cljs b/frontend/src/app/main/ui/ds/product/user_milestone.cljs index 3ce0a73ad3..94ba862b3a 100644 --- a/frontend/src/app/main/ui/ds/product/user_milestone.cljs +++ b/frontend/src/app/main/ui/ds/product/user_milestone.cljs @@ -11,6 +11,7 @@ [app.common.time :as ct] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.controls.input :refer [input*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.foundations.typography :as t] [app.main.ui.ds.foundations.typography.text :refer [text*]] [app.main.ui.ds.product.avatar :refer [avatar*]] @@ -24,6 +25,7 @@ [:class {:optional true} :string] [:active {:optional true} :boolean] [:editing {:optional true} :boolean] + [:locked {:optional true} :boolean] [:user [:map [:name {:optional true} [:maybe :string]] @@ -38,7 +40,7 @@ (mf/defc user-milestone* {::mf/schema schema:milestone} - [{:keys [class active editing user label date + [{:keys [class active editing locked user label date onOpenMenu onFocusInput onBlurInput onKeyDownInput] :rest props}] (let [class' (stl/css-case :milestone true :is-selected active) @@ -64,10 +66,10 @@ :on-focus onFocusInput :on-blur onBlurInput :on-key-down onKeyDownInput}] - [:> text* {:as "span" - :typography t/body-small - :class (stl/css :name)} - label]) + [:div {:class (stl/css :name-wrapper)} + [:> text* {:as "span" :typography t/body-small :class (stl/css :name)} label] + (when locked + [:> i/icon* {:icon-id i/lock :class (stl/css :lock-icon)}])]) [:* [:time {:date-time (ct/format-inst date :iso) diff --git a/frontend/src/app/main/ui/ds/product/user_milestone.scss b/frontend/src/app/main/ui/ds/product/user_milestone.scss index 4505b93aa8..016bc14ce6 100644 --- a/frontend/src/app/main/ui/ds/product/user_milestone.scss +++ b/frontend/src/app/main/ui/ds/product/user_milestone.scss @@ -42,11 +42,23 @@ justify-self: flex-end; } +.name-wrapper { + display: flex; + align-items: baseline; +} + .name { grid-area: name; color: var(--color-foreground-primary); } +.lock-icon { + margin-left: 8px; + transform: scale(0.8); + color: var(--color-foreground-secondary); + align-self: anchor-center; +} + .date { @include t.use-typography("body-small"); grid-area: content; diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index 0cf83cbda0..a832ef92a0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -75,7 +75,7 @@ (reverse))) (mf/defc version-entry - [{:keys [entry profile on-restore-version on-delete-version on-rename-version editing?]}] + [{:keys [entry profile current-profile on-restore-version on-delete-version on-rename-version on-lock-version on-unlock-version editing?]}] (let [show-menu? (mf/use-state false) handle-open-menu @@ -108,6 +108,20 @@ (when on-delete-version (on-delete-version (:id entry))))) + handle-lock-version + (mf/use-callback + (mf/deps entry on-lock-version) + (fn [] + (when on-lock-version + (on-lock-version (:id entry))))) + + handle-unlock-version + (mf/use-callback + (mf/deps entry on-unlock-version) + (fn [] + (when on-unlock-version + (on-unlock-version (:id entry))))) + handle-name-input-focus (mf/use-fn (fn [event] @@ -141,22 +155,44 @@ :color (:color profile)} :editing editing? :date (:created-at entry) + :locked (boolean (:locked-by entry)) :onOpenMenu handle-open-menu :onFocusInput handle-name-input-focus :onBlurInput handle-name-input-blur :onKeyDownInput handle-name-input-key-down}] [:& dropdown {:show @show-menu? :on-close handle-close-menu} - [:ul {:class (stl/css :version-options-dropdown)} - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-rename-version} (tr "labels.rename")] - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-restore-version} (tr "labels.restore")] - [:li {:class (stl/css :menu-option) - :role "button" - :on-click handle-delete-version} (tr "labels.delete")]]]])) + (let [current-user-id (:id current-profile) + version-creator-id (:profile-id entry) + locked-by-id (:locked-by entry) + is-version-creator? (= current-user-id version-creator-id) + is-locked? (some? locked-by-id) + is-locked-by-me? (= current-user-id locked-by-id) + can-rename? is-version-creator? + can-lock? (and is-version-creator? (not is-locked?)) + can-unlock? (and is-version-creator? is-locked-by-me?) + can-delete? (or (not is-locked?) (and is-locked? is-locked-by-me?))] + [:ul {:class (stl/css :version-options-dropdown)} + (when can-rename? + [:li {:class (stl/css :menu-option) + :role "button" + :on-click handle-rename-version} (tr "labels.rename")]) + [:li {:class (stl/css :menu-option) + :role "button" + :on-click handle-restore-version} (tr "labels.restore")] + (cond + can-unlock? + [:li {:class (stl/css :menu-option) + :role "button" + :on-click handle-unlock-version} (tr "labels.unlock")] + can-lock? + [:li {:class (stl/css :menu-option) + :role "button" + :on-click handle-lock-version} (tr "labels.lock")]) + (when can-delete? + [:li {:class (stl/css :menu-option) + :role "button" + :on-click handle-delete-version} (tr "labels.delete")])])]])) (mf/defc snapshot-entry [{:keys [index is-expanded entry on-toggle-expand on-pin-snapshot on-restore-snapshot]}] @@ -310,6 +346,16 @@ (fn [id] (st/emit! (dwv/pin-version id)))) + handle-lock-version + (mf/use-fn + (fn [id] + (st/emit! (dwv/lock-version id)))) + + handle-unlock-version + (mf/use-fn + (fn [id] + (st/emit! (dwv/unlock-version id)))) + handle-change-filter (mf/use-fn (fn [filter] @@ -367,9 +413,12 @@ :entry entry :editing? (= (:id entry) editing) :profile (get profiles (:profile-id entry)) + :current-profile profile :on-rename-version handle-rename-version :on-restore-version handle-restore-version-pinned - :on-delete-version handle-delete-version}] + :on-delete-version handle-delete-version + :on-lock-version handle-lock-version + :on-unlock-version handle-unlock-version}] :snapshot [:& snapshot-entry {:key idx-entry diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 1d95b37123..f51600177d 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1380,6 +1380,22 @@ msgstr "Password should at least be 8 characters" msgid "errors.paste-data-validation" msgstr "Invalid data in clipboard" +#: src/app/main/errors.cljs:152 +msgid "errors.version-locked" +msgstr "This version is locked and cannot be deleted by others" + +#: src/app/main/errors.cljs:160 +msgid "errors.only-creator-can-lock" +msgstr "Only the version creator can lock it" + +#: src/app/main/errors.cljs:168 +msgid "errors.only-creator-can-unlock" +msgstr "Only the version creator can unlock it" + +#: src/app/main/errors.cljs:176 +msgid "errors.version-already-locked" +msgstr "This version is already locked" + #: src/app/main/data/auth.cljs:312, src/app/main/ui/auth/login.cljs:103, src/app/main/ui/auth/login.cljs:111 msgid "errors.profile-blocked" msgstr "The profile is blocked" @@ -2133,6 +2149,10 @@ msgstr "Libraries & Templates" msgid "labels.loading" msgstr "Loading…" +#: src/app/main/ui/workspace/sidebar/versions.cljs:179 +msgid "labels.lock" +msgstr "Lock" + #: src/app/main/ui/viewer/header.cljs:208 msgid "labels.log-or-sign" msgstr "Log in or sign up" @@ -2453,6 +2473,10 @@ msgstr "Tutorials" msgid "labels.unknown-error" msgstr "Unknown error" +#: src/app/main/ui/workspace/sidebar/versions.cljs:176 +msgid "labels.unlock" +msgstr "Unlock" + #: src/app/main/ui/dashboard/file_menu.cljs:264 msgid "labels.unpublish-multi-files" msgstr "Unpublish %s files" @@ -7881,6 +7905,15 @@ msgstr "History" msgid "workspace.versions.version-menu" msgstr "Open version menu" +msgid "workspace.versions.locked-by-other" +msgstr "This version is locked by %s and cannot be modified" + +msgid "workspace.versions.locked-by-you" +msgstr "This version is locked by you" + +msgid "workspace.versions.tooltip.locked-version" +msgstr "Locked version - only the creator can modify it" + #: src/app/main/ui/workspace/sidebar/versions.cljs:372 #, markdown msgid "workspace.versions.warning.subtext"