mirror of
https://github.com/penpot/penpot.git
synced 2025-12-12 06:24:17 +01:00
✨ Implement version locking functionality for file snapshots
Signed-off-by: Laurie Crean <lmcrean@gmail.com>
This commit is contained in:
committed by
Andrey Antukh
parent
1892fa6782
commit
0b47a366ab
@@ -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]
|
||||
|
||||
@@ -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.';
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user