feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle

- Sync (WebDAV, auto-merge): UUID + tombstones foundations (server +
  JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with
  pull/merge/push flow, Settings UI, pre-sync backup option. Test
  connection now treats 404 as OK (snapshot not yet created) and 401/
  403 as auth failure with dedicated toast.
- Batch drag-drop: cards + table rows carry checked-set ids (CSV) when
  dragged from an active selection; folder + trash drop handlers parse
  and apply in batch via new moveEntriesToFolder helper that preserves
  TOTP / custom_fields / kind in the full PUT payload.
- Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the
  bridge message-only window; FormCloseQuery bypasses the tray-minimize
  intercept on system shutdown / restart / logoff so FireDAC closes the
  SQLite WAL cleanly instead of leaving -shm / -wal residue after a
  force-kill.
- Center-mode modal: blur+dim backdrop via body::before pseudo-element
  in editor-position=center, swallows clicks below the panel so the
  existing outside-click handlers reliably dismiss the slideover /
  settings panel.
- Batch bar state fixes: state.checked cleared before render in
  moveEntriesToFolder, emptyTrash, and per-card restoreEntry /
  permanentDelete / deleteEntry so the action bar disappears once the
  selection is fully processed.
- Save-then-discard duplicate fix: soState reset to null before
  openSlideOver re-opens the freshly saved entry, otherwise the dirty
  check fired on the soState.id=null → newId switch and a Cancel left
  the form in new-entry mode (second Save → POST duplicate).
- TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync
  with 2 real instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-30 00:32:12 +01:00
parent b00da43ab0
commit 6869b7c692
10 changed files with 1132 additions and 25 deletions
+47
View File
@@ -454,6 +454,53 @@ hotkeys autofill, etc. **Device-only** (localStorage seulement) :
`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled` `quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled`
(toggle hotkey Win32), `rememberedUsername` (auth screen autofill local). (toggle hotkey Win32), `rememberedUsername` (auth screen autofill local).
## Sync (WebDAV, auto-merge)
Multi-device sync via a user-hosted WebDAV server (Nextcloud, ownCloud,
Apache mod_dav). Auto-merge strategy: last-write-wins per entry on
`updated_at`, tombstones propagate hard-deletes. No conflict UI — solo
personal use rarely produces simultaneous edits.
Foundations :
- `vault_entries.uuid` (TEXT, indexed) — stable cross-device identity.
Migration backfills existing rows via `hex(randomblob)` → RFC 4122 v4.
- `entry_tombstones (user_id, uuid, deleted_at)` — UNIQUE(user_id, uuid),
written on hard-delete (`DELETE permanent=1`, trash empty, auto-purge).
- GET `/entries` returns uuid ; POST/bulk-import accept it (mint fresh
if absent) ; PUT keeps it immutable.
- GET `/entries/tombstones` lists local tombstones.
- POST `/entries/tombstones {uuids:[...]}` adds tombstones + hard-deletes
any local rows matching those uuids (idempotent via INSERT OR IGNORE).
Transport ([UMainForm.pas](delphi-backend/UMainForm.pas)) :
- `cmd://webdav/get|put|test?reqId=&url=&user=&pwd=[&data=]` → async via
`THTTPClient` (WinHTTP under the hood, no OpenSSL DLLs required).
Basic auth, 10s connect / 30s response timeout. Callback
`Bridge.onWebdavResult(reqId, status, payload)`.
Settings : all config in DPAPI prefs (`syncEnabled`, `syncUrl`,
`syncUser`, `syncPwd`, `syncEncPwd`, `syncPreBackup`, `syncLast`).
**`syncEncPwd` MUST be the same on every device** — it's the secret
that encrypts the WebDAV-stored snapshot. User sets it once per
device, never transmitted.
`runSyncNow()` flow :
1. (Optional) Write `vault-presync-yyyymmdd-HHMMSS.json` to the
auto-backup folder if enabled.
2. `webdav/get` → 404 = first sync, treat as empty remote.
3. Decrypt with `syncEncPwd` (reuses `encryptExportPayload` container).
4. POST remote tombstones → server hard-deletes local matches.
5. Folders : add missing ones additively (don't touch existing).
6. Entries : for each remote uuid → not in local = POST keeping uuid +
restore attachments ; both sides have it = compare `updated_at`,
PUT if remote newer.
7. `loadEntries()` + `buildSyncSnapshot()` for the post-merge state.
8. `webdav/put` push the merged snapshot.
9. Toast `X added · Y updated · Z deleted`.
Sensitive actions (export, change master pw, recovery code…) still
require master pw via `askReauth` — sync never substitutes.
## PIN unlock ## PIN unlock
Optional shortcut unlock with a 412 digit PIN, complementary to Quick Optional shortcut unlock with a 412 digit PIN, complementary to Quick
+135
View File
@@ -0,0 +1,135 @@
# Test plan — WebDAV sync (auto-merge)
Server : `wsgidav --host=127.0.0.1 --port=8080 --root=E:\webdav-test --auth=anonymous`
URL Settings : `http://127.0.0.1:8080/vault-sync.json`
User/pwd Settings : vides (anonymous)
Sync password : choisir une fois, **identique sur les 2 devices**
---
## 0. Setup deux devices RÉELLEMENT séparés
Le sync est par-vault. Un même `vault.db` avec 2 comptes user ≠ 2 devices.
- [ ] **Device A** : exe actuel `Z:\password-manager\delphi-backend\Win32\Debug\PMServer.exe`
- [ ] **Device B** :
- Copier **uniquement le `.exe`** dans `D:\PMServer-B\` (assets.res est déjà embarqué dedans)
- Lancer → crée un user `userB` avec un nouveau master pw
- `vault.db` de B est créé à côté de l'exe B (séparé de A)
- [ ] Vérifier les 2 lancent sur des ports différents (chacun écrit son port au boot dans le log)
## 1. Connexion server
- [ ] wsgidav tourne (logs visibles, "Serving on http://127.0.0.1:8080")
- [ ] A : Settings → Sync → URL + Set sync password (`testsync`) → Test connection → toast `Connection OK · snapshot not created yet`
- [ ] B : idem mais **même sync password** `testsync` → Test connection → toast OK aussi
## 2. Premier push (A vide → server)
- [ ] A : créer 3 entries distinctes (`gmail`, `bank`, `github` par ex.) + 1 note + 1 folder custom `Work`
- [ ] A : **Sync now** → wsgidav log `GET 404` + `PUT 201` → toast `Sync complete — 0 added · 0 updated · 0 deleted`
- [ ] Vérifier `Get-ChildItem E:\webdav-test``vault-sync.json` existe (~quelques KB)
## 3. Premier pull (B vide ← server)
- [ ] B : **Sync now** → wsgidav log `GET 200` + `PUT 201` → toast `Sync complete — 5 added · 0 updated · 0 deleted` (3 logins + note + ??? folder ne compte pas dans `added`)
- [ ] B : vérifier que les 3 entries + la note sont visibles dans la grille
- [ ] B : ouvrir `gmail` → password déchiffrable
- [ ] B : ouvrir la note → texte lisible
- [ ] B : sidebar Folders → `Work` présent avec sa couleur+icône
## 4. Auto-merge ajout des deux côtés
- [ ] A : créer entry `slack`
- [ ] B : créer entry `discord`
- [ ] A : Sync now → toast `1 added` (récupère `discord`)
- [ ] B : Sync now → toast `1 added` (récupère `slack`)
- [ ] Les deux devices ont maintenant 5 + 2 = 7 entries
## 5. Last-write-wins
- [ ] A : éditer `gmail` → changer username en `user-from-A` → Save
- [ ] B (sans sync entre temps) : éditer `gmail` → changer username en `user-from-B` → Save (B a `updated_at` plus récent)
- [ ] A : Sync now → toast `0 added · 1 updated · 0 deleted``gmail.username` devient `user-from-B`
- [ ] Inverse pour confirmer : édit A puis édit B puis sync B en premier → B garde sa version (rien à update côté B), puis sync A → A bascule sur B
## 6. Tombstones (delete propagation)
- [ ] A : delete `slack` (soft) → Trash → Empty trash (hard-delete = tombstone créé)
- [ ] A : Sync now → push tombstone
- [ ] B : Sync now → toast inclut `1 deleted``slack` disparait côté B
- [ ] Recréer `slack` côté B → Sync now → vérifier qu'il ne ressuscite **pas** côté A (tombstone réutilisé sauf si nouveau uuid mint → vérifier ce comportement)
## 7. Custom fields + TOTP préservés
- [ ] A : créer entry `aws` avec TOTP secret valide + 2 custom fields (`access_key`, `secret_key` is_secret=true)
- [ ] A : Sync now
- [ ] B : Sync now → `aws` apparaît
- [ ] B : ouvrir `aws` → TOTP code visible et tick · les 2 custom fields visibles · `secret_key` masqué (is_secret)
- [ ] B : éditer un custom field → Save → Sync now
- [ ] A : Sync now → modif reflétée
## 8. Attachments round-trip
- [ ] A : ouvrir une entry → Attach file → upload PDF < 1 MB
- [ ] A : Sync now (wsgidav log → file size augmente sensiblement)
- [ ] B : Sync now → ouvrir la même entry → attachment visible → Download → fichier décrypté identique
## 9. Pre-sync backup
- [ ] A : Settings → cocher "Create a local backup before each sync"
- [ ] A : Sync now → `Get-ChildItem "C:\Users\zakar\Desktop\backup test\vault-presync-*.json"` → fichier daté du jour existe
- [ ] Tester restore : Import vault → choisir le `.json` → tape `testsync` → entries restaurées
## 10. Auth WebDAV (optionnel)
Si tu veux tester avec un vrai user/pwd (au lieu d'anonymous) :
```
wsgidav --host=127.0.0.1 --port=8080 --root=E:\webdav-test ^
--auth=basic --user-mapping={"/":{"alice":{"password":"s3cret","roles":["editor"]}}}
```
- [ ] A : Username `alice` + Password `s3cret` → Test connection OK
- [ ] A : vider user/pwd → Test connection → toast `Auth failed (401)`
## 11. Erreurs réseau
- [ ] Tuer wsgidav (Ctrl+C dans son terminal)
- [ ] A : Test connection → toast `Network error: ...`
- [ ] A : Sync now → toast `Sync pull failed: ...` (pas d'écrasement local)
- [ ] Relancer wsgidav → re-Sync now → reprend normalement
## 12. Conflict — édit + delete sur le même entry
- [ ] A : delete `github` → hard-delete (Empty trash) → Sync now (push tombstone)
- [ ] B (sans sync entre) : édite `github` (la version locale a un `updated_at` plus récent que la deletion)
- [ ] B : Sync now → comportement attendu = entry deletée (tombstones gagnent toujours sur update — vérifier que c'est bien ça)
## 13. Vault locked pendant sync
- [ ] A : Lock vault
- [ ] A : (pas accès Settings — locked) — vérifier qu'il n'y a pas d'auto-sync silencieux qui tenterait quand même
- [ ] Unlock → Sync now → fonctionne
## 14. Master pw rotation + sync
- [ ] A : change master password → re-encrypte toutes les entries localement avec nouvelle clé
- [ ] A : Sync now → push avec **même** sync password (encPwd indépendant du master) → toast OK
- [ ] B : Sync now → toast `0 added · 0 updated · 0 deleted` (les uuids n'ont pas changé, les `updated_at` non plus pour la plupart)
---
## Critères de réussite
- ✅ Pull récupère systématiquement les entries manquantes (added > 0 quand attendu)
- ✅ Push ne supprime **jamais** silencieusement de données (sauf via tombstone explicite)
- ✅ Pre-sync backup créé si dossier configuré (sinon skip silencieux, OK)
- ✅ Erreur réseau ou auth → toast clair, **pas** d'écrasement
- ✅ Sync password divergent → toast `Remote decrypt failed — wrong sync password?` + pas de push
- ✅ Tombstones propagent les deletes hard
## Gotchas connus
- **Tester avec 2 vraies instances séparées**, pas 2 users dans la même DB
- Sync password ≠ master password ; doit être identique sur tous les devices
- Pre-sync backup réutilise le dossier auto-backup (pas de dossier dédié actuellement)
- Le snapshot WebDAV contient TOUTES les entries en plaintext sous le sync password — ne pas confondre avec la sécurité par master (qui reste pour les blobs DB locaux)
+14 -4
View File
@@ -2059,10 +2059,20 @@ body[data-editor-position="center"] #settingsPanel.is-open {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
/* No dim/blur in center mode the panel doesn't actually block /* Center mode = true modal: dim + blur backdrop, click anywhere
interaction (cards, sidebar, topbar stay clickable without outside the panel dismisses it (handled in JS). The backdrop is
dismissing it), so painting a modal-style backdrop would lie about painted via a body pseudo-element so it covers everything but
the behaviour. The panel just floats above the page. */ the active panel. */
body[data-editor-position="center"]:has(#slideover.is-open)::before,
body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
content: '';
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 45; /* above topbar (40), below .slideover (50) */
pointer-events: auto; /* swallow clicks so cards/sidebar don't see them */
}
.slideover-header { .slideover-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; padding: 16px 20px;
+174 -7
View File
@@ -28,6 +28,21 @@ begin
if Result = '' then Result := ADefault; if Result = '' then Result := ADefault;
end; end;
// RFC 4122 v4 UUID — lowercase canonical hex with dashes, no braces.
// Used as the cross-device stable identity for vault_entries.
function NewUUIDv4: string;
var
G: TGUID;
S: string;
begin
CreateGUID(G);
S := GUIDToString(G);
// Strip surrounding braces RTL adds, lowercase the rest.
if (Length(S) > 0) and (S[1] = '{') then
S := Copy(S, 2, Length(S) - 2);
Result := LowerCase(S);
end;
// SQLite DATETIME columns: FireDAC parses to TDateTime internally, then AsString // SQLite DATETIME columns: FireDAC parses to TDateTime internally, then AsString
// would format in system locale (DD/MM/YYYY in French). Force ISO format // would format in system locale (DD/MM/YYYY in French). Force ISO format
// 'yyyy-mm-dd hh:nn:ss' which is what api.php / SQLite text storage uses and // 'yyyy-mm-dd hh:nn:ss' which is what api.php / SQLite text storage uses and
@@ -132,6 +147,8 @@ begin
LObj.AddPair('template', TJSONNull.Create) LObj.AddPair('template', TJSONNull.Create)
else else
LObj.AddPair('template', LQ.FieldByName('template').AsString); LObj.AddPair('template', LQ.FieldByName('template').AsString);
// Stable cross-device identity (always populated post-migration).
LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString);
// Custom fields: opaque ciphertext + IV, treated identically to // Custom fields: opaque ciphertext + IV, treated identically to
// password / totp_secret. NULL → JSON null so the client can // password / totp_secret. NULL → JSON null so the client can
// distinguish "never set" from "empty array stored". // distinguish "never set" from "empty array stored".
@@ -165,6 +182,117 @@ begin
TJSONHelper.SendJSON(AResponse, LArr); TJSONHelper.SendJSON(AResponse, LArr);
end; end;
// ===== GET /entries/tombstones ==============================================
// Sync helper — returns the uuid + deleted_at of every permanently-removed
// entry so the merge engine can propagate deletes to other devices.
procedure HandleGetTombstones(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LQ: TFDQuery;
LArr: TJSONArray;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
except
on ESessionRejected do Exit;
end;
LArr := TJSONArray.Create;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT uuid, deleted_at FROM entry_tombstones ' +
'WHERE user_id = :uid ORDER BY deleted_at DESC';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
while not LQ.Eof do
begin
LObj := TJSONObject.Create;
LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString);
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
LArr.Add(LObj);
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendJSON(AResponse, LArr);
end;
// ===== POST /entries/tombstones =============================================
// Sync helper — body {uuids: ["x","y", ...]} adds tombstones for entries
// deleted on another device. Idempotent (UNIQUE constraint).
procedure HandlePostTombstones(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, I, LAdded: Integer;
LBody: TJSONObject;
LArr: TJSONArray;
LQ: TFDQuery;
LUuid: string;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LBody := TJSONHelper.ReadBody(ARequest);
LAdded := 0;
try
LArr := LBody.GetValue<TJSONArray>('uuids');
if (LArr = nil) or (LArr.Count = 0) then
begin
TJSONHelper.SendOK(AResponse, 'No tombstones');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'VALUES (:uid, :u)';
for I := 0 to LArr.Count - 1 do
begin
LUuid := Trim(LArr.Items[I].Value);
if LUuid = '' then Continue;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('u').AsString := LUuid;
LQ.ExecSQL;
if LQ.RowsAffected > 0 then Inc(LAdded);
end;
// Hard-delete any local entries whose UUID just received a
// tombstone — propagates remote deletes during sync pull.
LQ.SQL.Text :=
'DELETE FROM vault_entries WHERE user_id = :uid AND uuid IN ' +
' (SELECT uuid FROM entry_tombstones WHERE user_id = :uid)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
finally
LBody.Free;
end;
TJSONHelper.SendOK(AResponse, IntToStr(LAdded) + ' tombstones added');
end;
// ===== POST /entries ========================================================= // ===== POST /entries =========================================================
procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo; procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
@@ -173,7 +301,7 @@ var
LUserId, LNewId: Integer; LUserId, LNewId: Integer;
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
LKind, LCf, LCfIv, LIcon, LTemplate: string; LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -200,6 +328,10 @@ begin
LCfIv := LBody.GetValue<string>('custom_fields_iv', ''); LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
LIcon := LBody.GetValue<string>('icon_b64', ''); LIcon := LBody.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LBody.GetValue<string>('template', '')); LTemplate:= Trim(LBody.GetValue<string>('template', ''));
// Caller may bring its own UUID (sync restore / import preserving
// identity). Otherwise the server mints a fresh one.
LUuid := Trim(LBody.GetValue<string>('uuid', ''));
if LUuid = '' then LUuid := NewUUIDv4;
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -229,9 +361,9 @@ begin
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, created_at, updated_at, password_changed_at) ' + ' icon_b64, template, uuid, created_at, updated_at, password_changed_at) ' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tpl, :c, :c2, :c)'; ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2, :c)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('tt').AsString := LTitle;
@@ -266,6 +398,7 @@ begin
LQ.ParamByName('tpl').DataType := ftString; LQ.ParamByName('tpl').DataType := ftString;
if LTemplate = '' then LQ.ParamByName('tpl').Clear if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate; else LQ.ParamByName('tpl').AsString := LTemplate;
LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL; LQ.ExecSQL;
@@ -280,6 +413,7 @@ begin
LogAudit(LUserId, 'add_entry', GetClientIP(ARequest)); LogAudit(LUserId, 'add_entry', GetClientIP(ARequest));
LObj := TJSONObject.Create; LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LNewId)); LObj.AddPair('id', TJSONNumber.Create(LNewId));
LObj.AddPair('uuid', LUuid);
LObj.AddPair('site', LSite); LObj.AddPair('site', LSite);
LObj.AddPair('title', LTitle); LObj.AddPair('title', LTitle);
LObj.AddPair('username', LUser); LObj.AddPair('username', LUser);
@@ -475,7 +609,19 @@ begin
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
if LPermanent then if LPermanent then
LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid' begin
// Record a tombstone BEFORE the delete so the sync engine can
// propagate this removal to other devices. UPSERT semantics —
// re-deleting an already-tombstoned uuid is a no-op.
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'SELECT user_id, uuid FROM vault_entries ' +
'WHERE id = :id AND user_id = :uid AND uuid IS NOT NULL';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid';
end
else else
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' + 'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' +
@@ -870,6 +1016,16 @@ begin
LQ := TFDQuery.Create(nil); LQ := TFDQuery.Create(nil);
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
// Tombstones FIRST so the sync engine can propagate the purge.
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'SELECT user_id, uuid FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 AND uuid IS NOT NULL ' +
' AND deleted_at IS NOT NULL ' +
' AND (julianday(''now'') - julianday(deleted_at)) >= :d';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('d').AsInteger := LDays;
LQ.ExecSQL;
LQ.SQL.Text := LQ.SQL.Text :=
'DELETE FROM vault_entries ' + 'DELETE FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 ' + 'WHERE user_id = :uid AND deleted = 1 ' +
@@ -914,6 +1070,12 @@ begin
LQ := TFDQuery.Create(nil); LQ := TFDQuery.Create(nil);
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' +
'SELECT user_id, uuid FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 AND uuid IS NOT NULL';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
LQ.SQL.Text := 'DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1'; LQ.SQL.Text := 'DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL; LQ.ExecSQL;
@@ -940,7 +1102,7 @@ var
LBody, LObj, LEntry: TJSONObject; LBody, LObj, LEntry: TJSONObject;
LArr, LIds: TJSONArray; LArr, LIds: TJSONArray;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow, LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow,
LKind, LCf, LCfIv, LIcon, LTemplate: string; LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -985,9 +1147,9 @@ begin
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, created_at, updated_at) ' + ' icon_b64, template, uuid, created_at, updated_at) ' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tpl, :c, :c2)'; ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2)';
// Declare optional param types ONCE — the prepared statement is // Declare optional param types ONCE — the prepared statement is
// reused across every imported entry, and FireDAC needs the // reused across every imported entry, and FireDAC needs the
// type set before the first .Clear call would otherwise fail // type set before the first .Clear call would otherwise fail
@@ -1021,6 +1183,8 @@ begin
LCfIv := LEntry.GetValue<string>('custom_fields_iv', ''); LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
LIcon := LEntry.GetValue<string>('icon_b64', ''); LIcon := LEntry.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LEntry.GetValue<string>('template', '')); LTemplate:= Trim(LEntry.GetValue<string>('template', ''));
LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
if LUuid = '' then LUuid := NewUUIDv4;
// Ciphertext is always required. Site is required only for // Ciphertext is always required. Site is required only for
// logins — notes legitimately have no site (their body lives // logins — notes legitimately have no site (their body lives
@@ -1055,6 +1219,7 @@ begin
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
if LTemplate = '' then LQ.ParamByName('tpl').Clear if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate; else LQ.ParamByName('tpl').AsString := LTemplate;
LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL; LQ.ExecSQL;
@@ -1136,6 +1301,8 @@ initialization
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash);
Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash); Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash);
Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons); Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons);
Router.Register('GET', '/entries/tombstones', HandleGetTombstones);
Router.Register('POST', '/entries/tombstones', HandlePostTombstones);
Router.Register('POST', '/entries/bulk-import', HandleBulkImport); Router.Register('POST', '/entries/bulk-import', HandleBulkImport);
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
+21
View File
@@ -92,6 +92,12 @@ type
FSavedPlacement: TWindowPlacement; FSavedPlacement: TWindowPlacement;
FHasSavedPlacement: Boolean; FHasSavedPlacement: Boolean;
FOnSystemLock: TProc; FOnSystemLock: TProc;
// Set TRUE the moment Windows tells us the session is ending
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
// to bypass the "minimize to tray" intercept so the form closes
// normally and the DB connection is checkpointed instead of being
// force-killed (which leaves -shm / -wal files behind).
FShutdownPending: Boolean;
FOnTrayRestore: TProc; FOnTrayRestore: TProc;
FOnLockRequest: TProc; FOnLockRequest: TProc;
FOnQuit: TProc; FOnQuit: TProc;
@@ -158,6 +164,9 @@ type
property AutofillRegistered: Boolean read FAutofillRegistered; property AutofillRegistered: Boolean read FAutofillRegistered;
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK). // Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock; property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
// True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received.
// FormCloseQuery uses this to allow normal close during shutdown.
property ShutdownPending: Boolean read FShutdownPending;
// Fired on main thread when the user clicks the tray icon. // Fired on main thread when the user clicks the tray icon.
property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore; property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore;
// Fired when the user picks "Lock vault" from the tray menu. Handler // Fired when the user picks "Lock vault" from the tray menu. Handler
@@ -735,6 +744,18 @@ begin
if Assigned(FOnSystemLock) then FOnSystemLock(); if Assigned(FOnSystemLock) then FOnSystemLock();
end end
else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then
begin
// Windows is logging off / shutting down / restarting. Flip the flag
// so FormCloseQuery lets the form actually close instead of
// minimizing to tray — otherwise Windows force-kills us after the
// shutdown timeout and SQLite's WAL/SHM never get checkpointed.
// Return TRUE (do not block shutdown). DefWindowProc returns TRUE
// by default for WM_QUERYENDSESSION, so we just don't assign Result.
FShutdownPending := True;
AMsg.Result := 1;
end
else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then
begin begin
// A second instance was launched and PostMessage'd HWND_BROADCAST. // A second instance was launched and PostMessage'd HWND_BROADCAST.
+36
View File
@@ -314,6 +314,42 @@ begin
// Drives the card/table label so notes-with-fields read as "Credit card" // Drives the card/table label so notes-with-fields read as "Credit card"
// instead of the generic "Encrypted note" placeholder. // instead of the generic "Encrypted note" placeholder.
AddColumnIfMissing('vault_entries', 'template', 'TEXT'); AddColumnIfMissing('vault_entries', 'template', 'TEXT');
// Stable identity that survives export/import + cross-device sync.
// SQLite `id` is autoincrement local-only — useless to match the same
// logical entry across two installs. Populate existing rows with a
// fresh UUID v4 below so the migration is non-destructive.
AddColumnIfMissing('vault_entries', 'uuid', 'TEXT');
FConn.ExecSQL(
'CREATE INDEX IF NOT EXISTS idx_entries_uuid ' +
' ON vault_entries(uuid)');
// Backfill UUIDs for legacy rows that landed before the column existed.
// SQLite has no native uuid() — emit one via hex(randomblob) + manual
// dashes (RFC 4122 v4 = 8-4-4-4-12 hex, version nibble forced to 4,
// variant nibble high bits 10).
FConn.ExecSQL(
'UPDATE vault_entries SET uuid = ' +
' lower(hex(randomblob(4))) || ''-'' || ' +
' lower(hex(randomblob(2))) || ''-4'' || ' +
' substr(lower(hex(randomblob(2))), 2) || ''-'' || ' +
' substr(''89ab'', 1 + (abs(random()) % 4), 1) || ' +
' substr(lower(hex(randomblob(2))), 2) || ''-'' || ' +
' lower(hex(randomblob(6))) ' +
'WHERE uuid IS NULL OR uuid = ''''');
// Tombstones: every hard-delete inserts a row here so the sync engine
// can propagate deletes to other devices without leaving deleted
// entries to silently reappear at next pull.
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS entry_tombstones (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER NOT NULL,' +
' uuid TEXT NOT NULL,' +
' deleted_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' +
' UNIQUE(user_id, uuid)' +
')');
FConn.ExecSQL(
'CREATE INDEX IF NOT EXISTS idx_tombstones_user ' +
' ON entry_tombstones(user_id, deleted_at DESC)');
// Per-folder customisation. NULL = no override → JS uses the default // Per-folder customisation. NULL = no override → JS uses the default
// accent + i-folder symbol. // accent + i-folder symbol.
AddColumnIfMissing('folders', 'color', 'TEXT'); AddColumnIfMissing('folders', 'color', 'TEXT');
+126
View File
@@ -18,6 +18,7 @@ interface
uses uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON, System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
System.Net.HttpClient, System.Net.URLClient,
Winapi.Windows, Winapi.ShellAPI, Winapi.Windows, Winapi.ShellAPI,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
@@ -370,6 +371,18 @@ begin
// so we bypass the minimize-to-tray intercept in that case. // so we bypass the minimize-to-tray intercept in that case.
if FQuitting then Exit; if FQuitting then Exit;
// Windows shutdown / logoff / restart: WM_QUERYENDSESSION flipped the
// bridge's flag. Let the form close normally so FServer.Free and the
// FireDAC connection get a chance to checkpoint the WAL — otherwise
// Windows force-kills us at the shutdown timeout and we leave
// -shm / -wal files next to vault.db.
if Assigned(FBridge) and FBridge.ShutdownPending then
begin
FQuitting := True;
LogLine('System shutdown detected — closing normally.');
Exit;
end;
// Otherwise: minimize to tray on close instead of quitting, so the vault // Otherwise: minimize to tray on close instead of quitting, so the vault
// stays available without the dev-panel being visible. // stays available without the dev-panel being visible.
// When the server is stopped, allow normal close — there's no vault to // When the server is stopped, allow normal close — there's no vault to
@@ -932,6 +945,119 @@ begin
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')'); BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
end end
// ---- WebDAV remote sync (THTTPClient → WinHTTP, async) --------------
// cmd://webdav/get | put | test ?reqId=&url=&user=&pwd=[&data=]
// Callback: Bridge.onWebdavResult(reqId, status, bodyOrError)
// - GET ok → status=200, body=base64 of response bytes
// - GET 404 → status=404, body='' (caller treats as "no remote yet")
// - PUT ok → status=200/201/204, body=''
// - test → status=200..399 means reachable, body=''
// Network errors → status=0, body=exception message.
else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test') then
begin
var LMethod := ACmd;
var LReqId := GetParam('reqId');
var LUrl := GetParam('url');
var LUser := GetParam('user');
var LPwd := GetParam('pwd');
var LData := GetParam('data');
TThread.CreateAnonymousThread(
procedure
var
LHttp: System.Net.HttpClient.THTTPClient;
LResp: System.Net.HttpClient.IHTTPResponse;
LBodyStream: TBytesStream;
LReqStream: TBytesStream;
LBytes: TBytes;
LBodyB64: string;
LStatus: Integer;
LErr: string;
begin
LStatus := 0;
LBodyB64 := '';
LErr := '';
try
LHttp := System.Net.HttpClient.THTTPClient.Create;
try
LHttp.ConnectionTimeout := 10000;
LHttp.ResponseTimeout := 30000;
if (LUser <> '') then
begin
LHttp.CredentialsStorage.AddCredential(
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
end;
if LMethod = 'webdav/get' then
begin
LBodyStream := TBytesStream.Create;
try
LResp := LHttp.Get(LUrl, LBodyStream);
LStatus := LResp.StatusCode;
if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then
begin
SetLength(LBytes, LBodyStream.Size);
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
LBodyB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
LBodyB64 := StringReplace(LBodyB64, #13, '', [rfReplaceAll]);
LBodyB64 := StringReplace(LBodyB64, #10, '', [rfReplaceAll]);
end;
finally
LBodyStream.Free;
end;
end
else if LMethod = 'webdav/put' then
begin
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
LReqStream := TBytesStream.Create(LBytes);
try
LResp := LHttp.Put(LUrl, LReqStream);
LStatus := LResp.StatusCode;
finally
LReqStream.Free;
end;
end
else // webdav/test — HEAD is widely supported even when PROPFIND isn't
begin
LResp := LHttp.Head(LUrl);
LStatus := LResp.StatusCode;
end;
finally
LHttp.Free;
end;
except
on E: Exception do
begin
LStatus := 0;
LErr := E.Message;
end;
end;
TThread.Queue(nil,
procedure
var
LEscReq, LEscPayload: string;
begin
LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
// GET success path → ship body base64. Otherwise the field
// carries either the empty string or the exception message
// (for status=0 network errors).
if (LMethod = 'webdav/get') and (LStatus >= 200) and (LStatus < 300) then
LEscPayload := LBodyB64
else
LEscPayload := LErr;
LEscPayload := StringReplace(LEscPayload, '\', '\\', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]);
LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onWebdavResult)' +
'Bridge.onWebdavResult("' + LEscReq + '",' +
IntToStr(LStatus) + ',"' + LEscPayload + '")');
LogLine(Format('%s %s → %d (%d bytes payload)',
[LMethod, LUrl, LStatus, Length(LEscPayload)]));
end);
end).Start;
end
// ---- Native file save (bypasses WebView2's browser download UI) ------ // ---- Native file save (bypasses WebView2's browser download UI) ------
// JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id> // JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id>
// Delphi opens GetSaveFileName, writes the decoded bytes, then calls // Delphi opens GetSaveFileName, writes the decoded bytes, then calls
Binary file not shown.
+51
View File
@@ -646,6 +646,57 @@
</div> </div>
</div> </div>
<div class="slideover-field" id="syncField" style="display:none">
<div class="slideover-field-label">Sync (WebDAV)</div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
Bidirectional sync with a WebDAV server (Nextcloud,
ownCloud, Apache mod_dav). Auto-merge by entry
timestamp. <b>Master password is never sent to the
remote</b> — the snapshot is encrypted with a separate
sync password you set once per device (same on each).
</p>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<input type="checkbox" id="settingSyncEnabled">
<span>Enable sync</span>
</label>
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
<div style="margin-bottom:6px">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">WebDAV URL (full path to the snapshot file)</div>
<input type="text" id="syncUrl" class="so-input"
placeholder="https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json"
style="width:100%;font-size:12px">
</div>
<div style="display:flex;gap:8px;margin-bottom:6px">
<label style="flex:1">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username</div>
<input type="text" id="syncUser" class="so-input" style="width:100%">
</label>
<label style="flex:1">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / app token</div>
<input type="password" id="syncPwd" class="so-input" style="width:100%">
</label>
</div>
<label style="display:flex;align-items:center;gap:8px;margin:8px 0">
<input type="checkbox" id="syncPreBackup">
<span style="font-size:12px">Create a local backup before each sync</span>
</label>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
Sync password ≠ master password. <b>Use the same one
on every device</b> — that's how the encrypted snapshot
round-trips. Stored DPAPI-protected locally.
</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:6px">
<button class="btn btn-ghost btn-sm" id="syncSetPwdBtn">Set sync password</button>
<span id="syncPwdStatus" style="font-size:11px;color:var(--text-faint)">Not set.</span>
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:6px">
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
<button class="btn btn-primary btn-sm" id="syncNowBtn">Sync now</button>
<span id="syncLast" style="font-size:11px;color:var(--text-faint)"></span>
</div>
</div>
</div>
<div class="slideover-field" id="autoBackupField" style="display:none"> <div class="slideover-field" id="autoBackupField" style="display:none">
<div class="slideover-field-label">Auto-backup</div> <div class="slideover-field-label">Auto-backup</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5"> <p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
+523 -9
View File
@@ -2451,8 +2451,12 @@ function renderSidebar() {
await reorderFolderTo(draggedFolder, name, before); await reorderFolderTo(draggedFolder, name, before);
return; return;
} }
const id = e.dataTransfer.getData('text/plain'); const raw = e.dataTransfer.getData('text/plain') || '';
if (id) await moveEntryToFolder(parseInt(id), name); const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) {
await moveEntriesToFolder(ids, name);
state.checked.clear();
}
}); });
const delBtn = el('button', { const delBtn = el('button', {
@@ -2496,8 +2500,12 @@ function renderSidebar() {
item.addEventListener('drop', async e => { item.addEventListener('drop', async e => {
e.preventDefault(); e.preventDefault();
item.classList.remove('drag-over'); item.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain'); const raw = e.dataTransfer.getData('text/plain') || '';
if (id) await moveEntryToFolder(parseInt(id), 'All'); const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) {
await moveEntriesToFolder(ids, 'All');
state.checked.clear();
}
}); });
fList.appendChild(item); fList.appendChild(item);
@@ -3385,7 +3393,16 @@ function renderCard(e) {
if (!inTrash) { if (!inTrash) {
card.addEventListener('dragstart', ev => { card.addEventListener('dragstart', ev => {
ev.dataTransfer.setData('text/plain', String(e.id)); // If the dragged card is part of an active selection, carry
// ALL checked ids so a drop on a folder / trash moves the
// whole batch in one gesture. Otherwise carry just this one.
let ids;
if (state.checked.size > 1 && state.checked.has(e.id)) {
ids = Array.from(state.checked).join(',');
} else {
ids = String(e.id);
}
ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = 'move';
}); });
} }
@@ -3849,8 +3866,21 @@ function renderTableRow(e) {
+ (state.selectedId === e.id ? ' is-selected' : '') + (state.selectedId === e.id ? ' is-selected' : '')
+ (checked ? ' is-checked' : ''), + (checked ? ' is-checked' : ''),
'data-id': String(e.id), 'data-id': String(e.id),
draggable: !inTrash ? 'true' : 'false',
on: { click: ev => handleCardClick(ev, e, inTrash) }, on: { click: ev => handleCardClick(ev, e, inTrash) },
}); });
if (!inTrash) {
tr.addEventListener('dragstart', ev => {
let ids;
if (state.checked.size > 1 && state.checked.has(e.id)) {
ids = Array.from(state.checked).join(',');
} else {
ids = String(e.id);
}
ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move';
});
}
// Cells are emitted in EXACTLY the same order as getTableColumns() // Cells are emitted in EXACTLY the same order as getTableColumns()
// returns headers, otherwise THs and TDs drift apart and clicks land // returns headers, otherwise THs and TDs drift apart and clicks land
@@ -5293,6 +5323,13 @@ async function soSave() {
if (typeof healthCache !== 'undefined') healthCache = null; if (typeof healthCache !== 'undefined') healthCache = null;
const updated = state.entries.find(x => x.id === targetId); const updated = state.entries.find(x => x.id === targetId);
// Clear soState BEFORE re-opening so openSlideOver doesn't mistake
// the re-open for a "switch entry while dirty" — the form still
// holds the just-saved values but soState.original is stale, which
// would falsely trigger the discard-confirm modal and (on Cancel)
// leave soState at id=null, causing a second Save to POST again
// and create a duplicate.
soState = null;
if (updated) openSlideOver(updated.id); if (updated) openSlideOver(updated.id);
else closeSlideOver(); else closeSlideOver();
render(); render();
@@ -5586,6 +5623,7 @@ async function restoreEntry(id) {
try { try {
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
toast('Restored'); toast('Restored');
state.checked.delete(id);
await loadEntries(); await loadEntries();
await loadTrash(); await loadTrash();
render(); render();
@@ -5603,6 +5641,7 @@ async function permanentDelete(id) {
try { try {
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
toast('Deleted permanently'); toast('Deleted permanently');
state.checked.delete(id);
await loadTrash(); await loadTrash();
render(); render();
} catch (err) { toast(err.message, 'error'); } } catch (err) { toast(err.message, 'error'); }
@@ -5620,6 +5659,7 @@ async function emptyTrash() {
try { try {
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() }); await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
toast('Trash emptied'); toast('Trash emptied');
state.checked.clear();
await loadTrash(); await loadTrash();
render(); render();
} catch (err) { toast(err.message, 'error'); } } catch (err) { toast(err.message, 'error'); }
@@ -5727,6 +5767,7 @@ async function deleteEntry(id) {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash'); toast('Moved to trash');
closeSlideOver(); closeSlideOver();
state.checked.delete(id);
await loadEntries(); await loadEntries();
state.trashedCount = (state.trashedCount || 0) + 1; state.trashedCount = (state.trashedCount || 0) + 1;
render(); render();
@@ -5752,6 +5793,42 @@ async function togglePin(id) {
} catch (e) { toast(e.message, 'error'); } } catch (e) { toast(e.message, 'error'); }
} }
async function moveEntriesToFolder(ids, folder) {
let ok = 0, skipped = 0;
for (const id of ids) {
const e = state.entries.find(x => x.id === id);
if (!e) continue;
if (e.folder === folder) { skipped++; continue; }
try {
await api('/entries/' + id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
site: e.site,
title: e.title || '',
username: e.username,
encrypted_password: e.encrypted_password,
iv: e.iv,
folder,
tags: e.tags || '',
kind: e.kind || 'login',
totp_secret: e.totp_secret || '',
totp_iv: e.totp_iv || '',
custom_fields: e.custom_fields || '',
custom_fields_iv: e.custom_fields_iv || '',
}),
});
e.folder = folder;
ok++;
} catch (err) { /* try the rest */ }
}
state.checked.clear();
render();
if (ok === 0 && skipped > 0) return;
if (ok === 1) toast('Moved to ' + folder);
else if (ok > 1) toast('Moved ' + ok + ' entries to ' + folder);
}
async function moveEntryToFolder(id, folder) { async function moveEntryToFolder(id, folder) {
const e = state.entries.find(x => x.id === id); const e = state.entries.find(x => x.id === id);
if (!e || e.folder === folder) return; if (!e || e.folder === folder) return;
@@ -7998,6 +8075,7 @@ function parseEntriesFromJSON(text) {
a && typeof a === 'object' && a.filename && a.content_b64); a && typeof a === 'object' && a.filename && a.content_b64);
} }
entries.push({ entries.push({
uuid: String(e.uuid || '').trim(),
site: site, site: site,
title: String(e.title || '').trim(), title: String(e.title || '').trim(),
username: String(e.username || e.user || e.login || '').trim(), username: String(e.username || e.user || e.login || '').trim(),
@@ -8043,6 +8121,7 @@ async function encryptImportEntry(plain) {
} catch (e) { /* drop silently */ } } catch (e) { /* drop silently */ }
} }
return { return {
uuid: plain.uuid || '',
site: plain.site, site: plain.site,
title: plain.title || '', title: plain.title || '',
username: plain.username || '', username: plain.username || '',
@@ -8366,6 +8445,7 @@ async function doExport() {
} catch (_) { /* partial export beats a failed one */ } } catch (_) { /* partial export beats a failed one */ }
payload.entries.push({ payload.entries.push({
uuid: e.uuid || '',
site: e.site, site: e.site,
title: e.title || '', title: e.title || '',
username: e.username, username: e.username,
@@ -8845,6 +8925,12 @@ function openSettings() {
} else { } else {
refreshAutoBackupUI(null); refreshAutoBackupUI(null);
} }
// Sync: same gate (Bridge required for WebDAV HTTP + DPAPI prefs).
const syncField = document.getElementById('syncField');
if (syncField) {
syncField.style.display = Bridge.active ? '' : 'none';
if (Bridge.active) loadSyncConfig().then(refreshSyncUI);
}
$('#settingsPanel').classList.add('is-open'); $('#settingsPanel').classList.add('is-open');
} }
@@ -9027,6 +9113,399 @@ async function autoPurgeTrashIfNeeded() {
} }
} }
// ============================================================
// SYNC (WebDAV, auto-merge with timestamps)
// ============================================================
// Multi-device sync via a remote WebDAV server (Nextcloud, ownCloud,
// Apache mod_dav, any compatible). The remote file is an encrypted
// JSON snapshot (same crypto container as the export, separate
// password — the sync password is device-local DPAPI and the user must
// configure the same one on each device they want to sync).
//
// Strategy: auto-merge with last-write-wins on per-entry updated_at,
// tombstones for delete propagation. No conflict UI in v1 — silent
// resolution because solo personal use rarely produces simultaneous
// edits across devices. Toast reports added / updated / deleted counts.
//
// Sensitive actions (export, change master pw…) keep their own reauth
// path. Sync only touches entries + folders + tombstones.
const SYNC_PREFS = {
enabled: 'syncEnabled',
url: 'syncUrl', // e.g. https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json
user: 'syncUser',
pwd: 'syncPwd', // WebDAV password / app token
encPwd: 'syncEncPwd', // secret for the encrypted JSON container
preBackup: 'syncPreBackup', // 'on' / '' — write a local copy before each sync
last: 'syncLast', // ISO timestamp of last successful run
};
let _webdavResolvers = {};
Bridge.onWebdavResult = function(reqId, status, payload) {
const r = _webdavResolvers[reqId];
if (!r) return;
delete _webdavResolvers[reqId];
r({ status: status | 0, payload: payload || '' });
};
function _webdavCall(method, url, user, pwd, dataB64) {
return new Promise(resolve => {
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
_webdavResolvers[reqId] = resolve;
let q = 'cmd://webdav/' + method
+ '?reqId=' + encodeURIComponent(reqId)
+ '&url=' + encodeURIComponent(url)
+ '&user=' + encodeURIComponent(user || '')
+ '&pwd=' + encodeURIComponent(pwd || '');
if (dataB64) q += '&data=' + encodeURIComponent(dataB64);
window.location.href = q;
setTimeout(() => {
if (_webdavResolvers[reqId]) {
delete _webdavResolvers[reqId];
resolve({ status: 0, payload: 'timeout' });
}
}, 60000);
});
}
function refreshSyncUI(cfg) {
if (!cfg) return;
const cb = document.getElementById('settingSyncEnabled');
const cfg2 = document.getElementById('syncConfig');
if (cb) cb.checked = !!cfg.enabled;
if (cfg2) cfg2.style.display = cfg.enabled ? '' : 'none';
const url = document.getElementById('syncUrl');
const user = document.getElementById('syncUser');
const pwd = document.getElementById('syncPwd');
const pre = document.getElementById('syncPreBackup');
if (url) url.value = cfg.url || '';
if (user) user.value = cfg.user || '';
if (pwd) pwd.value = cfg.pwd || '';
if (pre) pre.checked = !!cfg.preBackup;
const pwdStatus = document.getElementById('syncPwdStatus');
if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? 'Set.' : 'Not set.';
const last = document.getElementById('syncLast');
if (last) last.textContent = cfg.last
? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16)
: '';
}
async function syncSetEncPwdFlow() {
let err = '';
let n = 0;
for (;;) {
const v = await promptDialog({
title: 'Set sync password',
message: 'Use the SAME password on every device that syncs with this remote. ' +
'Stored DPAPI-protected on this device only — never transmitted.',
placeholder: 'At least 8 characters',
password: true,
okText: 'Save',
error: err,
});
if (!v) return;
if (v.length >= 8) {
Bridge.setPref(SYNC_PREFS.encPwd, v);
const pwdStatus = document.getElementById('syncPwdStatus');
if (pwdStatus) pwdStatus.textContent = 'Set.';
toast('Sync password saved');
return;
}
n++;
if (n >= 5) return toast('Too many invalid attempts', 'error');
err = 'Use at least 8 characters (attempt ' + n + ' / 5).';
}
}
async function loadSyncConfig() {
if (!Bridge.active) return null;
const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([
Bridge.getPref(SYNC_PREFS.enabled),
Bridge.getPref(SYNC_PREFS.url),
Bridge.getPref(SYNC_PREFS.user),
Bridge.getPref(SYNC_PREFS.pwd),
Bridge.getPref(SYNC_PREFS.encPwd),
Bridge.getPref(SYNC_PREFS.preBackup),
Bridge.getPref(SYNC_PREFS.last),
]);
return {
enabled: enabled === '1',
url: url || '',
user: user || '',
pwd: pwd || '',
encPwd: encPwd || '',
preBackup: pre === '1',
last: last || '',
};
}
async function syncTestConnection() {
const cfg = await loadSyncConfig();
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
toast('Testing connection…');
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
if (r.status >= 200 && r.status < 400) {
toast('Connection OK (' + r.status + ')');
} else if (r.status === 404) {
// Server reachable, snapshot file just doesn't exist yet — normal
// before the first sync. Treat as success.
toast('Connection OK · snapshot not created yet');
} else if (r.status === 401 || r.status === 403) {
toast('Auth failed (' + r.status + ') — check user/password', 'error');
} else if (r.status === 0) {
toast('Network error: ' + (r.payload || 'unreachable'), 'error');
} else {
toast('Server returned ' + r.status, 'error');
}
}
// Build the snapshot payload that gets encrypted + pushed to the remote.
// Includes entries (decrypted plaintext, then re-encrypted under the
// sync key), folders metadata, and tombstones. Mirrors doExport's shape
// so a sync snapshot is also importable via "Import vault".
async function buildSyncSnapshot() {
const payload = {
version: 1,
snapshot_at: new Date().toISOString(),
username: state.username,
folders: (state.folders || [])
.filter(f => f && f.name && f.name !== 'All')
.map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })),
entries: [],
tombstones: [],
};
for (const e of state.entries) {
if (!e.uuid) continue; // legacy row that missed the backfill — skip
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
let plainCustom = [];
if (e.custom_fields && e.custom_fields_iv) {
try { plainCustom = await decryptCustomFields(
e.custom_fields, e.custom_fields_iv); }
catch (_) {}
}
let attachments = [];
try {
const metas = await api('/entries/' + e.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id,
{ headers: authHeaders() });
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
attachments.push({
filename: m.filename, mime: m.mime,
size_bytes: m.size_bytes,
content_b64: bytesToBase64(bytes),
});
}
} catch (_) {}
payload.entries.push({
uuid: e.uuid,
site: e.site || '',
title: e.title || '',
username: e.username || '',
password: plain === '[ERROR]' ? '' : plain,
folder: e.folder || 'All',
tags: parseTags(e.tags),
favorite: !!e.favorite,
totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
attachments,
icon_b64: e.icon_b64 || '',
created_at: e.created_at,
updated_at: e.updated_at,
});
}
try {
const ts = await api('/entries/tombstones', { headers: authHeaders() });
payload.tombstones = (Array.isArray(ts) ? ts : []).map(t => ({
uuid: t.uuid, deleted_at: t.deleted_at,
}));
} catch (_) {}
return payload;
}
async function applyRemoteSnapshot(remote) {
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0 };
let added = 0, updated = 0, deleted = 0;
// Push remote tombstones first — server will hard-delete any matching
// local entries AND remember them so they don't reappear from a future
// local push.
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
if (uuids.length > 0) {
try {
await api('/entries/tombstones', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ uuids }),
});
deleted = uuids.length;
} catch (_) {}
}
}
// Refresh local view AFTER tombstones so the maps reflect the cull.
await loadEntries();
const byUuid = new Map();
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
// Folders — add missing ones with the remote's color/icon. Existing
// folders are left untouched (user's local customisation wins).
if (Array.isArray(remote.folders)) {
const localNames = new Set((state.folders || []).map(f => f.name));
for (const f of remote.folders) {
if (!f.name || localNames.has(f.name)) continue;
try {
await api('/folders', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
name: f.name, color: f.color || '', icon: f.icon || '',
}),
});
} catch (_) {}
}
await loadFolders();
}
// Per-entry merge.
for (const r of remote.entries) {
if (!r.uuid) continue;
const local = byUuid.get(r.uuid);
if (!local) {
// New entry on remote — encrypt locally + POST keeping the uuid.
try {
const enc = await encryptImportEntry(Object.assign({}, r, { uuid: r.uuid }));
const created = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(enc),
});
added++;
// Restore attachments for this new entry.
if (Array.isArray(r.attachments) && created && created.id) {
for (const a of r.attachments) {
try {
const bytes = base64ToBytes(a.content_b64 || '');
const blob = await encryptBlobBytes(bytes);
await api('/entries/' + created.id + '/attachments', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filename: a.filename,
mime: a.mime || 'application/octet-stream',
encrypted_blob: blob.encrypted,
iv: blob.iv,
size_bytes: a.size_bytes || bytes.length,
}),
});
} catch (_) {}
}
}
} catch (_) {}
} else {
// Both sides have it — keep the newer one (lexical ISO sort).
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
if (!remoteWins) continue;
try {
const enc = await encryptImportEntry(r);
// PUT keeps the existing id but accepts the encrypted blobs.
// template + uuid not touched here (server preserves columns
// when fields aren't in body — uuid is immutable anyway).
await api('/entries/' + local.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(enc),
});
updated++;
} catch (_) {}
}
}
return { added, updated, deleted };
}
async function runSyncNow() {
const cfg = await loadSyncConfig();
if (!cfg) return toast('Bridge not available', 'error');
if (!cfg.url) return toast('Sync not configured', 'warning');
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
// Pre-sync backup — best effort, doesn't block sync on failure.
if (cfg.preBackup) {
try {
const ab = await loadAutoBackupConfig();
const dir = (ab && ab.dir) ? ab.dir : null;
if (dir) {
const snap = await buildSyncSnapshot();
const container = await encryptExportPayload(snap, cfg.encPwd);
const ts = new Date().toISOString()
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
const path = dir.replace(/[\\/]+$/, '') + '\\vault-presync-' + ts + '.json';
await Bridge.writeFile(path, JSON.stringify(container, null, 2));
}
} catch (_) { /* best-effort */ }
}
toast('Syncing…');
let merged = { added: 0, updated: 0, deleted: 0 };
let pulled = false;
try {
const r = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
if (r.status >= 200 && r.status < 300 && r.payload) {
try {
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
const container = JSON.parse(jsonText);
const snap = await decryptExportContainer(container, cfg.encPwd);
merged = await applyRemoteSnapshot(snap);
pulled = true;
} catch (e) {
return toast('Remote decrypt failed — wrong sync password?', 'error');
}
} else if (r.status === 404) {
// First sync — no remote yet, we'll just upload our state.
pulled = true;
} else if (r.status === 0) {
return toast('Network error: ' + (r.payload || 'unreachable'), 'error');
} else {
return toast('Pull failed: HTTP ' + r.status, 'error');
}
} catch (e) {
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
}
// Push merged state back to the remote.
try {
await loadEntries(); // pull latest after applying remote changes
const snap = await buildSyncSnapshot();
const container = await encryptExportPayload(snap, cfg.encPwd);
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
bytesToBase64(bodyBytes));
if (!(r.status >= 200 && r.status < 300)) {
return toast('Push failed: HTTP ' + r.status, 'error');
}
} catch (e) {
return toast('Sync push failed: ' + (e && e.message || e), 'error');
}
const now = new Date().toISOString();
Bridge.setPref(SYNC_PREFS.last, now);
render();
const summary =
merged.added + ' added · ' +
merged.updated + ' updated · ' +
merged.deleted + ' deleted';
toast('Sync complete — ' + summary);
}
// ============================================================ // ============================================================
// AUTO-BACKUP (silent encrypted exports on a schedule) // AUTO-BACKUP (silent encrypted exports on a schedule)
// ============================================================ // ============================================================
@@ -9194,6 +9673,7 @@ async function runAutoBackupNow() {
} }
} catch (_) {} } catch (_) {}
payload.entries.push({ payload.entries.push({
uuid: e.uuid || '',
site: e.site, title: e.title || '', username: e.username, site: e.site, title: e.title || '', username: e.username,
password: plain, folder: e.folder, tags: parseTags(e.tags), password: plain, folder: e.folder, tags: parseTags(e.tags),
favorite: !!e.favorite, totp_secret: plainTotp, favorite: !!e.favorite, totp_secret: plainTotp,
@@ -9620,15 +10100,21 @@ async function init() {
trashItem.addEventListener('drop', async ev => { trashItem.addEventListener('drop', async ev => {
ev.preventDefault(); ev.preventDefault();
trashItem.classList.remove('drag-over'); trashItem.classList.remove('drag-over');
const id = parseInt(ev.dataTransfer.getData('text/plain')); const raw = ev.dataTransfer.getData('text/plain') || '';
if (!id) return; const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (!ids.length) return;
let ok = 0;
for (const id of ids) {
try { try {
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
toast('Moved to trash'); ok++;
} catch (err) { /* keep going for the rest */ }
}
toast(ok === 1 ? 'Moved to trash' : 'Moved ' + ok + ' entries to trash');
state.checked.clear();
await loadEntries(); await loadEntries();
await loadTrash(); await loadTrash();
render(); render();
} catch (err) { toast(err.message, 'error'); }
}); });
} }
@@ -10002,6 +10488,34 @@ async function init() {
saveServerSettings(); saveServerSettings();
toast('Unlock method updated'); toast('Unlock method updated');
}); });
// Sync (WebDAV) listeners
const syncCb = document.getElementById('settingSyncEnabled');
const syncCfgBox = document.getElementById('syncConfig');
const syncUrlEl = document.getElementById('syncUrl');
const syncUserEl = document.getElementById('syncUser');
const syncPwdEl = document.getElementById('syncPwd');
const syncPreEl = document.getElementById('syncPreBackup');
const syncSetPwd = document.getElementById('syncSetPwdBtn');
const syncTest = document.getElementById('syncTestBtn');
const syncNow = document.getElementById('syncNowBtn');
if (syncCb) syncCb.addEventListener('change', e => {
const on = !!e.target.checked;
Bridge.setPref(SYNC_PREFS.enabled, on ? '1' : '');
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
toast(on ? 'Sync enabled' : 'Sync disabled');
});
if (syncUrlEl) syncUrlEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim()));
if (syncUserEl) syncUserEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.pwd, e.target.value));
if (syncPreEl) syncPreEl.addEventListener('change', e =>
Bridge.setPref(SYNC_PREFS.preBackup, e.target.checked ? '1' : ''));
if (syncSetPwd) syncSetPwd.addEventListener('click', syncSetEncPwdFlow);
if (syncTest) syncTest.addEventListener('click', syncTestConnection);
if (syncNow) syncNow.addEventListener('click', runSyncNow);
$('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup); $('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup);
$('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder); $('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder);
$('#settingAutoBackupInterval').addEventListener('change', e => { $('#settingAutoBackupInterval').addEventListener('change', e => {