diff --git a/js/app.js b/js/app.js index 94351d2..ccd66f8 100644 --- a/js/app.js +++ b/js/app.js @@ -4712,6 +4712,45 @@ function soDirtyCheck() { if (btn) btn.style.display = isSoDirty() ? '' : 'none'; } +// The editor holds the payload it loaded on open and re-ships it whole, so a +// sync / import that rewrote the same entry meanwhile would be clobbered by +// Save. Compare the live entry against the baseline the editor loaded: any +// difference means someone else wrote it, and the user gets to decide. +// +// Compares ciphertext, not plaintext — no decryption needed, and a re-encrypt +// of identical content yields a different blob only if the writer actually +// re-encrypted, which is exactly the case worth flagging. Changes the editor +// already absorbed (editorAbsorbChange updates `original` too) read as equal, +// so local tag/folder actions never trip this. +// Returns false when the user backs out — the caller then saves nothing. +async function confirmOverwriteIfChangedElsewhere() { + const live = state.entries.find(x => x.id === soState.id); + if (!live) return true; // trashed/purged elsewhere — the delete gate owns that + const o = soState.original; + const same = + live.encrypted_password === soState.originalEncrypted && + live.iv === soState.originalIV && + (live.totp_secret || null) === (soState.originalTotpEncrypted || null) && + (live.custom_fields|| null) === (soState.originalCustomEncrypted || null) && + (live.title || '') === o.title && + (live.site || '') === o.site && + (live.username || '') === o.username && + (live.folder || 'All') === o.folder && + parseTags(live.tags).join(',') === o.tags; + if (same) return true; + return await confirmDialog({ + title: 'Entry changed elsewhere', + message: 'This entry was updated after you opened it — by a sync, an ' + + 'import, or another device. Saving replaces that version with ' + + 'what is in the editor.' + + '
' + + 'Cancel to keep the other version — copy anything you need out ' + + 'of the editor first, then reopen the entry to see it.
', + okText: 'Overwrite', + danger: true, + }); +} + // Re-entrancy latch: a double-click on Save (or Enter+click) while the first // run awaits encryption/POST would fire a SECOND full save → duplicate entry. // Same async-race class as the sync-button guard. @@ -4759,6 +4798,8 @@ async function soSaveInner() { const isNew = (soState.id == null); + if (!isNew && !(await confirmOverwriteIfChangedElsewhere())) return; + // Only re-encrypt if password changed; otherwise reuse stored ciphertext. // For new entries there's nothing stored, always encrypt fresh. let enc;