feat: profile avatar + tombstone-restore fix + WebView2 nav race + sync summary
Profile picture / avatar - users.avatar_b64 column (nullable, cosmetic, not encrypted) + GET/POST /avatar endpoints mirroring the settings handler pattern. - Top-right chip + Settings→Account show a round avatar: custom picture if set, otherwise the username's initial on a deterministic hash-picked colour (stable across renders). - Upload downscales + center-crops to a 128px JPEG via FileReader → data: URI (NOT blob:, which the CSP's `img-src 'self' data:` blocks) before POSTing. Remove button clears it. - Carried in the encrypted JSON export; restored on import only when the current account has no picture (never clobbers a local one). Tombstone restore-then-sync fix - POST /entries and POST /entries/bulk-import now DELETE any tombstone matching an inserted uuid (same transaction) so a restored backup isn't re-killed on the next sync by its own stale tombstone. - applyRemoteSnapshot arbitrates remote tombstones by timestamp: a tombstone is skipped when the local entry with that uuid is newer than deleted_at (resurrection wins). Ties / unparseable timestamps favour KEEP. loadEntries() up front so updated_at reflects the live rows. WebView2 navigation race - Black-window-on-cold-start fix: the 1.5s nav timer no longer consumes FPendingURL when WebView2 isn't initialised yet (it re-arms, bounded to ~10 retries). FBrowserInitialized flag set in OnInitialized; after the retry budget we Navigate best-effort rather than loop forever. Sync UX - Bidirectional toast: "pulled X new · Y updated · Z deleted · pushed N entries" so a 0/0/0 pull still shows the vault was uploaded. - FolderPOST/PUT: pre-declare ftString on color/icon params (fixes the earlier [SQLite]-335 on NULL bind, already in play for CSV import). Docs - CLAUDE.md sync section documents tombstone purge-on-insert + resurrection arbitration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -489,7 +489,11 @@ device, never transmitted.
|
|||||||
auto-backup folder if enabled.
|
auto-backup folder if enabled.
|
||||||
2. `webdav/get` → 404 = first sync, treat as empty remote.
|
2. `webdav/get` → 404 = first sync, treat as empty remote.
|
||||||
3. Decrypt with `syncEncPwd` (reuses `encryptExportPayload` container).
|
3. Decrypt with `syncEncPwd` (reuses `encryptExportPayload` container).
|
||||||
4. POST remote tombstones → server hard-deletes local matches.
|
4. POST remote tombstones → server hard-deletes local matches, BUT
|
||||||
|
with resurrection arbitration : a remote tombstone (`{uuid, deleted_at}`)
|
||||||
|
is skipped if a local entry with that uuid has `updated_at > deleted_at`
|
||||||
|
(restored/edited after the delete → resurrection wins, no silent
|
||||||
|
re-kill). Ties + unparseable timestamps favour KEEP.
|
||||||
5. Folders : add missing ones additively (don't touch existing).
|
5. Folders : add missing ones additively (don't touch existing).
|
||||||
6. Entries : for each remote uuid → not in local = POST keeping uuid +
|
6. Entries : for each remote uuid → not in local = POST keeping uuid +
|
||||||
restore attachments ; both sides have it = compare `updated_at`,
|
restore attachments ; both sides have it = compare `updated_at`,
|
||||||
@@ -501,6 +505,17 @@ device, never transmitted.
|
|||||||
Sensitive actions (export, change master pw, recovery code…) still
|
Sensitive actions (export, change master pw, recovery code…) still
|
||||||
require master pw via `askReauth` — sync never substitutes.
|
require master pw via `askReauth` — sync never substitutes.
|
||||||
|
|
||||||
|
**Tombstone purge on (re)create** : `POST /entries` and
|
||||||
|
`POST /entries/bulk-import` both DELETE any `entry_tombstones` row
|
||||||
|
matching the inserted uuid (same transaction). Without this, restoring
|
||||||
|
a backup whose entries were previously hard-deleted would leave the
|
||||||
|
tombstone in place — the local `buildSyncSnapshot` would then re-push
|
||||||
|
it and the next pull would kill the just-restored entries. Purge-on-
|
||||||
|
insert + the resurrection arbitration (step 4) together make
|
||||||
|
restore-then-sync actually stick. A live `vault_entries` row can never
|
||||||
|
coexist with its tombstone (hard-delete removes the row), so `PUT`
|
||||||
|
needs no purge.
|
||||||
|
|
||||||
## PIN unlock
|
## PIN unlock
|
||||||
|
|
||||||
Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick
|
Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick
|
||||||
|
|||||||
@@ -751,6 +751,27 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
transition: all var(--t-fast);
|
transition: all var(--t-fast);
|
||||||
}
|
}
|
||||||
.user-chip:hover { background: var(--bg-elev-2); }
|
.user-chip:hover { background: var(--bg-elev-2); }
|
||||||
|
/* Round initials avatar. Background colour is set inline from a hash of
|
||||||
|
the username (deterministic — same user, same colour every render).
|
||||||
|
Falls back to a background-image when a custom picture is set. */
|
||||||
|
.user-avatar {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
margin-left: -4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background-size: cover; background-position: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
/* Larger preview variant shown in Settings → Account. */
|
||||||
|
.user-avatar-lg {
|
||||||
|
width: 48px; height: 48px;
|
||||||
|
margin-left: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
.user-dropdown {
|
.user-dropdown {
|
||||||
position: absolute; top: calc(100% + 6px); right: 0;
|
position: absolute; top: calc(100% + 6px); right: 0;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
|||||||
@@ -403,6 +403,15 @@ begin
|
|||||||
LQ.ParamByName('c2').AsString := LNow;
|
LQ.ParamByName('c2').AsString := LNow;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||||
|
|
||||||
|
// Clear any tombstone shadowing this uuid — a re-created entry
|
||||||
|
// (sync restore keeping its identity, or an undo of a hard
|
||||||
|
// delete) must not be silently re-killed on the next sync.
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'DELETE FROM entry_tombstones WHERE user_id = :uid AND uuid = :uuid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ParamByName('uuid').AsString := LUuid;
|
||||||
|
LQ.ExecSQL;
|
||||||
finally
|
finally
|
||||||
LQ.Free;
|
LQ.Free;
|
||||||
end;
|
end;
|
||||||
@@ -1103,7 +1112,7 @@ var
|
|||||||
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, LUuid: string;
|
LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string;
|
||||||
LQ: TFDQuery;
|
LQ, LTomb: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
LUserId := Authenticate(ARequest, AResponse);
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
@@ -1141,8 +1150,18 @@ begin
|
|||||||
DB.Connection.StartTransaction;
|
DB.Connection.StartTransaction;
|
||||||
try
|
try
|
||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
|
// Reused across the batch to clear any tombstone shadowing an
|
||||||
|
// imported uuid. Without this, restoring a backup whose entries
|
||||||
|
// were previously hard-deleted (and tombstoned) would get those
|
||||||
|
// entries wiped again on the next sync — the tombstone outlives
|
||||||
|
// the resurrection. Purging here lets a restore actually stick.
|
||||||
|
LTomb := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
LQ.Connection := DB.Connection;
|
||||||
|
LTomb.Connection := DB.Connection;
|
||||||
|
LTomb.SQL.Text :=
|
||||||
|
'DELETE FROM entry_tombstones ' +
|
||||||
|
'WHERE user_id = :uid AND uuid = :uuid';
|
||||||
LQ.SQL.Text :=
|
LQ.SQL.Text :=
|
||||||
'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, ' +
|
||||||
@@ -1226,9 +1245,16 @@ begin
|
|||||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||||
LIds.AddElement(TJSONNumber.Create(LNewId));
|
LIds.AddElement(TJSONNumber.Create(LNewId));
|
||||||
Inc(LImported);
|
Inc(LImported);
|
||||||
|
|
||||||
|
// Clear any tombstone that would otherwise resurrect-then-kill
|
||||||
|
// this uuid on the next sync.
|
||||||
|
LTomb.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LTomb.ParamByName('uuid').AsString := LUuid;
|
||||||
|
LTomb.ExecSQL;
|
||||||
end;
|
end;
|
||||||
finally
|
finally
|
||||||
LQ.Free;
|
LQ.Free;
|
||||||
|
LTomb.Free;
|
||||||
end;
|
end;
|
||||||
DB.Connection.Commit;
|
DB.Connection.Commit;
|
||||||
except
|
except
|
||||||
|
|||||||
@@ -106,8 +106,92 @@ begin
|
|||||||
TJSONHelper.SendOK(AResponse);
|
TJSONHelper.SendOK(AResponse);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// GET /avatar -> { avatar_b64: <data-uri or ''> }
|
||||||
|
// Fetched once at login (enterApp) so the image isn't re-sent on every
|
||||||
|
// settings save.
|
||||||
|
procedure HandleGetAvatar(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LUserId: Integer;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
LObj: TJSONObject;
|
||||||
|
LVal: string;
|
||||||
|
begin
|
||||||
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
|
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text := 'SELECT avatar_b64 FROM users WHERE id = :uid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.Open;
|
||||||
|
if LQ.IsEmpty then LVal := '' else LVal := LQ.FieldByName('avatar_b64').AsString;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LObj := TJSONObject.Create;
|
||||||
|
LObj.AddPair('avatar_b64', LVal);
|
||||||
|
TJSONHelper.SendJSON(AResponse, LObj);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// POST /avatar body: { avatar_b64: <data-uri> } ('' clears it)
|
||||||
|
procedure HandleSetAvatar(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LUserId: Integer;
|
||||||
|
LBody: TJSONObject;
|
||||||
|
LVal: string;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
begin
|
||||||
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
|
RequireCSRF(ARequest, AResponse, LUserId);
|
||||||
|
|
||||||
|
LBody := TJSONHelper.ReadBody(ARequest);
|
||||||
|
try
|
||||||
|
LVal := LBody.GetValue<string>('avatar_b64', '');
|
||||||
|
finally
|
||||||
|
LBody.Free;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// Cap ~700 KB base64 (~512 KB raw) — the client downscales to a small
|
||||||
|
// square before upload, so anything larger is a bug or an attack.
|
||||||
|
if Length(LVal) > 720000 then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 413, 'Avatar too large');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text := 'UPDATE users SET avatar_b64 = :a WHERE id = :uid';
|
||||||
|
LQ.ParamByName('a').DataType := ftMemo;
|
||||||
|
if LVal = '' then LQ.ParamByName('a').Clear
|
||||||
|
else LQ.ParamByName('a').Value := LVal;
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ExecSQL;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
TJSONHelper.SendOK(AResponse);
|
||||||
|
end;
|
||||||
|
|
||||||
initialization
|
initialization
|
||||||
Router.Register('GET', '/settings', HandleGetSettings);
|
Router.Register('GET', '/settings', HandleGetSettings);
|
||||||
Router.Register('PUT', '/settings', HandlePutSettings);
|
Router.Register('PUT', '/settings', HandlePutSettings);
|
||||||
|
Router.Register('GET', '/avatar', HandleGetAvatar);
|
||||||
|
Router.Register('POST', '/avatar', HandleSetAvatar);
|
||||||
|
|
||||||
end.
|
end.
|
||||||
|
|||||||
@@ -370,6 +370,10 @@ begin
|
|||||||
// toggles (quick-unlock DPAPI, Win32 autofill hotkey) intentionally stay
|
// toggles (quick-unlock DPAPI, Win32 autofill hotkey) intentionally stay
|
||||||
// in localStorage and are NOT included here.
|
// in localStorage and are NOT included here.
|
||||||
AddColumnIfMissing('users', 'settings_json', 'TEXT DEFAULT ''{}''');
|
AddColumnIfMissing('users', 'settings_json', 'TEXT DEFAULT ''{}''');
|
||||||
|
// Profile picture: base64 data URI (nullable). Cosmetic, not encrypted.
|
||||||
|
// Kept in its own column rather than settings_json so it isn't shipped
|
||||||
|
// on every settings GET/PUT (an image is 5-50 KB).
|
||||||
|
AddColumnIfMissing('users', 'avatar_b64', 'TEXT');
|
||||||
AddColumnIfMissing('sessions', 'csrf_token', 'TEXT');
|
AddColumnIfMissing('sessions', 'csrf_token', 'TEXT');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ type
|
|||||||
FBridge: TPMBridge;
|
FBridge: TPMBridge;
|
||||||
FPendingURL: string;
|
FPendingURL: string;
|
||||||
FNavTimer: TTimer;
|
FNavTimer: TTimer;
|
||||||
|
// Set once WebView2 fires OnInitialized. The nav timer only consumes
|
||||||
|
// FPendingURL when this is True — otherwise a timer tick that lands
|
||||||
|
// before the engine is ready would Navigate() into the void AND clear
|
||||||
|
// FPendingURL, leaving OnInitialized nothing to do → permanent black
|
||||||
|
// window on slow cold starts.
|
||||||
|
FBrowserInitialized: Boolean;
|
||||||
|
FNavRetries: Integer; // bounded retry count for the deferred nav timer
|
||||||
FRequireAccessToken: Boolean;
|
FRequireAccessToken: Boolean;
|
||||||
FRequireProcessCheck: Boolean;
|
FRequireProcessCheck: Boolean;
|
||||||
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
|
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
|
||||||
@@ -288,6 +295,7 @@ begin
|
|||||||
// the engine is ready, so if a navigation is still pending here, do it
|
// the engine is ready, so if a navigation is still pending here, do it
|
||||||
// now. The timer either already ran (FPendingURL == '') or runs later
|
// now. The timer either already ran (FPendingURL == '') or runs later
|
||||||
// and no-ops on the empty string.
|
// and no-ops on the empty string.
|
||||||
|
FBrowserInitialized := True;
|
||||||
FNavTimer.Enabled := False;
|
FNavTimer.Enabled := False;
|
||||||
if FPendingURL <> '' then
|
if FPendingURL <> '' then
|
||||||
begin
|
begin
|
||||||
@@ -435,6 +443,7 @@ begin
|
|||||||
if FServer.RequireAccessToken then
|
if FServer.RequireAccessToken then
|
||||||
FPendingURL := FPendingURL + '?pmt=' + FServer.AccessToken;
|
FPendingURL := FPendingURL + '?pmt=' + FServer.AccessToken;
|
||||||
LogLine('Will navigate embedded browser in ~1.5s to: ' + MaskAccessToken(FPendingURL));
|
LogLine('Will navigate embedded browser in ~1.5s to: ' + MaskAccessToken(FPendingURL));
|
||||||
|
FNavRetries := 0;
|
||||||
FNavTimer.Enabled := False;
|
FNavTimer.Enabled := False;
|
||||||
FNavTimer.Enabled := True;
|
FNavTimer.Enabled := True;
|
||||||
end;
|
end;
|
||||||
@@ -443,6 +452,23 @@ procedure TMainForm.NavTimerTick(Sender: TObject);
|
|||||||
begin
|
begin
|
||||||
FNavTimer.Enabled := False;
|
FNavTimer.Enabled := False;
|
||||||
if FPendingURL = '' then Exit;
|
if FPendingURL = '' then Exit;
|
||||||
|
// Engine not ready yet: Navigate() would be silently dropped. Leave
|
||||||
|
// FPendingURL intact and re-arm — either this timer catches the engine
|
||||||
|
// once it's up, or OnInitialized fires first and does the nav. Whoever
|
||||||
|
// wins clears FPendingURL so the other no-ops (no reload flash).
|
||||||
|
// Bounded to ~10 retries (15 s): if OnInitialized never fires (missing /
|
||||||
|
// broken WebView2 runtime), we stop deferring and attempt Navigate once
|
||||||
|
// anyway — best effort beats an eternal retry loop on a blank window.
|
||||||
|
if (not FBrowserInitialized) and (FNavRetries < 10) then
|
||||||
|
begin
|
||||||
|
Inc(FNavRetries);
|
||||||
|
LogLine(Format('Nav deferred — WebView2 not initialised (retry %d/10).',
|
||||||
|
[FNavRetries]));
|
||||||
|
FNavTimer.Enabled := True;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
if not FBrowserInitialized then
|
||||||
|
LogLine('WebView2 still not initialised after retries — attempting nav anyway.');
|
||||||
LogLine('Navigating to: ' + MaskAccessToken(FPendingURL));
|
LogLine('Navigating to: ' + MaskAccessToken(FPendingURL));
|
||||||
WebBrowser.Navigate(FPendingURL);
|
WebBrowser.Navigate(FPendingURL);
|
||||||
FPendingURL := '';
|
FPendingURL := '';
|
||||||
|
|||||||
Binary file not shown.
+16
-1
@@ -350,6 +350,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-chip" id="userChip">
|
<button class="user-chip" id="userChip">
|
||||||
|
<span class="user-avatar" id="userAvatar"></span>
|
||||||
<span id="userName">user</span>
|
<span id="userName">user</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="user-dropdown is-hidden" id="userDropdown">
|
<div class="user-dropdown is-hidden" id="userDropdown">
|
||||||
@@ -770,9 +771,23 @@
|
|||||||
|
|
||||||
<div class="slideover-field">
|
<div class="slideover-field">
|
||||||
<div class="slideover-field-label">Account</div>
|
<div class="slideover-field-label">Account</div>
|
||||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 4px">
|
<div style="display:flex;align-items:center;gap:12px;margin:0 0 10px">
|
||||||
|
<span class="user-avatar user-avatar-lg" id="settingAvatarPreview"></span>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px">
|
||||||
|
<p style="font-size:12px;color:var(--text-dim);margin:0">
|
||||||
Signed in as <b id="settingUser"></b>
|
Signed in as <b id="settingUser"></b>
|
||||||
</p>
|
</p>
|
||||||
|
<div style="display:flex;gap:6px">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="settingAvatarUpload">
|
||||||
|
<svg><use href="#i-user"/></svg> Change picture
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" id="settingAvatarRemove" style="display:none;color:var(--danger)">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="settingAvatarInput" accept="image/*" style="display:none">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px" id="settingVersionRow">
|
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px" id="settingVersionRow">
|
||||||
PMServer <span id="settingVersion">—</span>
|
PMServer <span id="settingVersion">—</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -482,6 +482,9 @@ const state = {
|
|||||||
csrf: sessionStorage.getItem('csrfToken') || '',
|
csrf: sessionStorage.getItem('csrfToken') || '',
|
||||||
salt: sessionStorage.getItem('salt') || '',
|
salt: sessionStorage.getItem('salt') || '',
|
||||||
username: sessionStorage.getItem('username') || '',
|
username: sessionStorage.getItem('username') || '',
|
||||||
|
// Profile picture as a data URI. Loaded from the server at enterApp
|
||||||
|
// (users.avatar_b64). Empty → the initials avatar is shown instead.
|
||||||
|
avatarDataUri: '',
|
||||||
// KDF iteration count of the currently-logged-in user. Cached so reauth
|
// KDF iteration count of the currently-logged-in user. Cached so reauth
|
||||||
// and on-the-fly verifier computations don't need a /login/challenge
|
// and on-the-fly verifier computations don't need a /login/challenge
|
||||||
// round trip every time. Refreshed from every auth response.
|
// round trip every time. Refreshed from every auth response.
|
||||||
@@ -1890,6 +1893,7 @@ function lockVault() {
|
|||||||
state.trashed = [];
|
state.trashed = [];
|
||||||
state.locked = true;
|
state.locked = true;
|
||||||
state.justRecovered = false;
|
state.justRecovered = false;
|
||||||
|
state.avatarDataUri = ''; // reloaded from server on next unlock
|
||||||
if (typeof authTickTimer !== 'undefined' && authTickTimer) {
|
if (typeof authTickTimer !== 'undefined' && authTickTimer) {
|
||||||
clearInterval(authTickTimer); authTickTimer = null;
|
clearInterval(authTickTimer); authTickTimer = null;
|
||||||
}
|
}
|
||||||
@@ -2241,6 +2245,124 @@ function filteredEntries() {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deterministic avatar colour: hash the username to a hue so the same
|
||||||
|
// account always gets the same background (no flicker across renders).
|
||||||
|
// Uses a fixed palette of pleasant saturated colours rather than raw
|
||||||
|
// HSL so every avatar reads well on the dark chrome.
|
||||||
|
const AVATAR_COLORS = [
|
||||||
|
'#e05a5a', '#e0895a', '#e0b45a', '#8bc34a', '#4caf82',
|
||||||
|
'#4aa3c3', '#5a7be0', '#7b5ae0', '#b45ae0', '#e05a9e',
|
||||||
|
];
|
||||||
|
function avatarColorFor(name) {
|
||||||
|
const s = String(name || '?');
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||||
|
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint the top-right user avatar: custom picture if one is set (data
|
||||||
|
// URI in state.avatarDataUri), otherwise the username's first letter on
|
||||||
|
// a deterministic colour.
|
||||||
|
function renderUserAvatar() {
|
||||||
|
const el = $('#userAvatar');
|
||||||
|
if (!el) return;
|
||||||
|
const pic = state.avatarDataUri || '';
|
||||||
|
if (pic) {
|
||||||
|
el.style.backgroundImage = 'url("' + pic + '")';
|
||||||
|
el.style.backgroundColor = 'transparent';
|
||||||
|
el.textContent = '';
|
||||||
|
} else {
|
||||||
|
el.style.backgroundImage = 'none';
|
||||||
|
el.style.backgroundColor = avatarColorFor(state.username);
|
||||||
|
el.textContent = (state.username || '?').trim().charAt(0) || '?';
|
||||||
|
}
|
||||||
|
// Keep the Settings preview (if the panel is open) in sync too.
|
||||||
|
const prev = $('#settingAvatarPreview');
|
||||||
|
if (prev) {
|
||||||
|
const pic2 = state.avatarDataUri || '';
|
||||||
|
if (pic2) {
|
||||||
|
prev.style.backgroundImage = 'url("' + pic2 + '")';
|
||||||
|
prev.style.backgroundColor = 'transparent';
|
||||||
|
prev.textContent = '';
|
||||||
|
} else {
|
||||||
|
prev.style.backgroundImage = 'none';
|
||||||
|
prev.style.backgroundColor = avatarColorFor(state.username);
|
||||||
|
prev.textContent = (state.username || '?').trim().charAt(0) || '?';
|
||||||
|
}
|
||||||
|
const rm = $('#settingAvatarRemove');
|
||||||
|
if (rm) rm.style.display = pic2 ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the stored profile picture from the server and repaint.
|
||||||
|
async function loadUserAvatar() {
|
||||||
|
try {
|
||||||
|
const r = await api('/avatar', { headers: authHeaders() });
|
||||||
|
state.avatarDataUri = (r && r.avatar_b64) || '';
|
||||||
|
} catch (_) { state.avatarDataUri = ''; }
|
||||||
|
renderUserAvatar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downscale + re-encode a picked image file to a small square JPEG data
|
||||||
|
// URI so we never store a multi-MB original. Returns a Promise<string>.
|
||||||
|
function processAvatarFile(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Read the file as a data: URI (not a blob: URL) — the app's CSP
|
||||||
|
// allows `img-src 'self' data:` but NOT blob:, so an <img> pointed
|
||||||
|
// at an object URL would fail to load.
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () => reject(new Error('read failed'));
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const size = 128; // final square px
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = size; canvas.height = size;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
// Center-crop to a square, then draw scaled into 128×128.
|
||||||
|
const side = Math.min(img.width, img.height);
|
||||||
|
const sx = (img.width - side) / 2;
|
||||||
|
const sy = (img.height - side) / 2;
|
||||||
|
ctx.drawImage(img, sx, sy, side, side, 0, 0, size, size);
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', 0.85));
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('bad image'));
|
||||||
|
img.src = reader.result; // data:image/...;base64,...
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadUserAvatar(file) {
|
||||||
|
if (!file || !/^image\//.test(file.type)) return toast('Pick an image file', 'error');
|
||||||
|
let dataUri;
|
||||||
|
try { dataUri = await processAvatarFile(file); }
|
||||||
|
catch (_) { return toast('Could not read that image', 'error'); }
|
||||||
|
try {
|
||||||
|
await api('/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ avatar_b64: dataUri }),
|
||||||
|
});
|
||||||
|
state.avatarDataUri = dataUri;
|
||||||
|
renderUserAvatar();
|
||||||
|
toast('Profile picture updated');
|
||||||
|
} catch (e) { toast(e.message || 'Upload failed', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUserAvatar() {
|
||||||
|
try {
|
||||||
|
await api('/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ avatar_b64: '' }),
|
||||||
|
});
|
||||||
|
state.avatarDataUri = '';
|
||||||
|
renderUserAvatar();
|
||||||
|
toast('Profile picture removed');
|
||||||
|
} catch (e) { toast(e.message || 'Failed', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
function parseTags(s) {
|
function parseTags(s) {
|
||||||
if (!s) return [];
|
if (!s) return [];
|
||||||
return s.split(',').map(t => t.trim()).filter(Boolean);
|
return s.split(',').map(t => t.trim()).filter(Boolean);
|
||||||
@@ -8209,7 +8331,8 @@ function parseEntriesFromJSON(text) {
|
|||||||
icon_b64: String(e.icon_b64 || '').trim(),
|
icon_b64: String(e.icon_b64 || '').trim(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { entries, skipped, columns: null, folders };
|
return { entries, skipped, columns: null, folders,
|
||||||
|
avatar_b64: typeof data.avatar_b64 === 'string' ? data.avatar_b64 : '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
|
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
|
||||||
@@ -8383,6 +8506,21 @@ async function doImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the profile picture from the backup — only when the
|
||||||
|
// current account has none, so an import doesn't clobber a
|
||||||
|
// picture the user already set on this device.
|
||||||
|
if (parsed.avatar_b64 && !state.avatarDataUri) {
|
||||||
|
try {
|
||||||
|
await api('/avatar', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ avatar_b64: parsed.avatar_b64 }),
|
||||||
|
});
|
||||||
|
state.avatarDataUri = parsed.avatar_b64;
|
||||||
|
renderUserAvatar();
|
||||||
|
} catch (_) { /* non-critical */ }
|
||||||
|
}
|
||||||
|
|
||||||
// CSV imports (Bitwarden / KeePass / Chrome) don't carry a
|
// CSV imports (Bitwarden / KeePass / Chrome) don't carry a
|
||||||
// folders[] block — they just stamp a folder name on each row.
|
// folders[] block — they just stamp a folder name on each row.
|
||||||
// Bulk-import stores the name but never creates the folders
|
// Bulk-import stores the name but never creates the folders
|
||||||
@@ -8616,6 +8754,9 @@ async function doExport() {
|
|||||||
version: 1,
|
version: 1,
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
username: state.username,
|
username: state.username,
|
||||||
|
// Profile picture (data URI) so a restore brings the avatar
|
||||||
|
// back. Empty string when none set.
|
||||||
|
avatar_b64: state.avatarDataUri || '',
|
||||||
// Folder customisation (color, icon) so restoring on a fresh
|
// Folder customisation (color, icon) so restoring on a fresh
|
||||||
// install brings the sidebar back the way the user had it,
|
// install brings the sidebar back the way the user had it,
|
||||||
// not the default gray + folder-icon. 'All' is synthetic and
|
// not the default gray + folder-icon. 'All' is synthetic and
|
||||||
@@ -9110,6 +9251,7 @@ function openSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
$('#settingUser').textContent = state.username;
|
$('#settingUser').textContent = state.username;
|
||||||
|
renderUserAvatar(); // sync the Account-section preview + Remove button
|
||||||
// Hide the version row entirely in the PHP/web frontend (no bridge).
|
// Hide the version row entirely in the PHP/web frontend (no bridge).
|
||||||
if (Bridge.active) {
|
if (Bridge.active) {
|
||||||
$('#settingVersionRow').style.display = '';
|
$('#settingVersionRow').style.display = '';
|
||||||
@@ -9342,6 +9484,8 @@ async function enterApp() {
|
|||||||
$('#authScreen').classList.add('is-hidden');
|
$('#authScreen').classList.add('is-hidden');
|
||||||
$('#appShell').classList.remove('is-hidden');
|
$('#appShell').classList.remove('is-hidden');
|
||||||
$('#userName').textContent = state.username;
|
$('#userName').textContent = state.username;
|
||||||
|
renderUserAvatar();
|
||||||
|
loadUserAvatar(); // async — repaints the avatar once the pic arrives
|
||||||
// Server-side prefs override localStorage cache; runs before render so
|
// Server-side prefs override localStorage cache; runs before render so
|
||||||
// theme / view mode / mask flags are applied to the first paint.
|
// theme / view mode / mask flags are applied to the first paint.
|
||||||
await loadServerSettings();
|
await loadServerSettings();
|
||||||
@@ -9637,23 +9781,51 @@ async function applyRemoteSnapshot(remote) {
|
|||||||
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0 };
|
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0, failed:0 };
|
||||||
let added = 0, updated = 0, deleted = 0, failed = 0;
|
let added = 0, updated = 0, deleted = 0, failed = 0;
|
||||||
|
|
||||||
// Push remote tombstones first — server will hard-delete any matching
|
// Ensure state.entries reflects the live DB before we read updated_at
|
||||||
// local entries AND remember them so they don't reappear from a future
|
// for the resurrection arbitration below — a restore-then-sync must
|
||||||
// local push. Count only tombstones that actually deleted a live
|
// see the restored rows' fresh timestamps.
|
||||||
// local entry this round (not the accumulated history that both
|
await loadEntries();
|
||||||
// sides already know about) so the toast reflects real user impact.
|
|
||||||
|
// Apply remote tombstones — but arbitrate against local resurrections.
|
||||||
|
// A remote tombstone says "this uuid was deleted at T". If the local
|
||||||
|
// entry with that uuid was updated AFTER T (e.g. restored from a
|
||||||
|
// backup since the deletion), the resurrection wins and the tombstone
|
||||||
|
// is skipped — otherwise a restore would be silently undone on the
|
||||||
|
// next sync. Entries the local side hasn't touched since T are
|
||||||
|
// deleted normally (standard delete propagation).
|
||||||
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
|
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
|
||||||
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
|
// Snapshot local uuid → updated_at BEFORE any deletion.
|
||||||
if (uuids.length > 0) {
|
const localTs = new Map();
|
||||||
|
for (const e of state.entries)
|
||||||
|
if (e.uuid) localTs.set(e.uuid, e.updated_at || '');
|
||||||
|
|
||||||
|
// A local entry beats the tombstone only if it exists AND is
|
||||||
|
// provably newer than deleted_at. Unparseable/missing timestamps
|
||||||
|
// favour KEEP (data-loss is worse than a stale entry the user can
|
||||||
|
// re-delete).
|
||||||
|
const isResurrected = (uuid, deletedAt) => {
|
||||||
|
if (!localTs.has(uuid)) return false; // not local → apply
|
||||||
|
const up = Date.parse(String(localTs.get(uuid)).replace(' ', 'T'));
|
||||||
|
const del = Date.parse(String(deletedAt || '').replace(' ', 'T'));
|
||||||
|
if (isNaN(up)) return true; // can't tell → keep
|
||||||
|
if (isNaN(del)) return false; // no delete time → apply
|
||||||
|
return up > del;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toApply = remote.tombstones
|
||||||
|
.filter(t => t.uuid && !isResurrected(t.uuid, t.deleted_at))
|
||||||
|
.map(t => t.uuid);
|
||||||
|
|
||||||
|
if (toApply.length > 0) {
|
||||||
const preLocal = new Set();
|
const preLocal = new Set();
|
||||||
for (const e of state.entries) if (e.uuid) preLocal.add(e.uuid);
|
for (const e of state.entries) if (e.uuid) preLocal.add(e.uuid);
|
||||||
try {
|
try {
|
||||||
await api('/entries/tombstones', {
|
await api('/entries/tombstones', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ uuids }),
|
body: JSON.stringify({ uuids: toApply }),
|
||||||
});
|
});
|
||||||
deleted = uuids.filter(u => preLocal.has(u)).length;
|
deleted = toApply.filter(u => preLocal.has(u)).length;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9779,7 +9951,64 @@ async function runSyncNow() {
|
|||||||
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
|
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
|
||||||
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
|
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
|
||||||
|
|
||||||
// Pre-sync backup — best effort, doesn't block sync on failure.
|
toast('Syncing…');
|
||||||
|
let merged = { added: 0, updated: 0, deleted: 0 };
|
||||||
|
|
||||||
|
// Fail-fast connectivity: hit the remote FIRST so a dead server /
|
||||||
|
// wrong URL aborts before any heavy local work (the pre-sync backup
|
||||||
|
// and buildSyncSnapshot are expensive on large vaults — no point
|
||||||
|
// running them if we can't reach the server).
|
||||||
|
syncStatus('Connecting…');
|
||||||
|
let pullResp;
|
||||||
|
try {
|
||||||
|
pullResp = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
|
||||||
|
} catch (e) {
|
||||||
|
syncStatus('');
|
||||||
|
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
|
||||||
|
}
|
||||||
|
if (pullResp.status === 0) {
|
||||||
|
syncStatus('');
|
||||||
|
return toast('Network error: ' + (pullResp.payload || 'unreachable'), 'error');
|
||||||
|
}
|
||||||
|
if (!(pullResp.status === 404 ||
|
||||||
|
(pullResp.status >= 200 && pullResp.status < 300))) {
|
||||||
|
syncStatus('');
|
||||||
|
return toast('Pull failed: HTTP ' + pullResp.status, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt + cross-account guard BEFORE touching local state, so a
|
||||||
|
// wrong sync password or a foreign account aborts cleanly.
|
||||||
|
let remoteSnap = null;
|
||||||
|
if (pullResp.status !== 404 && pullResp.payload) {
|
||||||
|
try {
|
||||||
|
const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
|
||||||
|
const container = JSON.parse(jsonText);
|
||||||
|
remoteSnap = await decryptExportContainer(container, cfg.encPwd);
|
||||||
|
} catch (e) {
|
||||||
|
syncStatus('');
|
||||||
|
return toast('Remote decrypt failed — wrong sync password?', 'error');
|
||||||
|
}
|
||||||
|
if (remoteSnap && remoteSnap.username && state.username &&
|
||||||
|
remoteSnap.username !== state.username) {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: 'Different account on remote',
|
||||||
|
message: 'The remote snapshot belongs to <b>' +
|
||||||
|
(remoteSnap.username + '').replace(/[<>&]/g, '') +
|
||||||
|
'</b>, but you are signed in as <b>' +
|
||||||
|
(state.username + '').replace(/[<>&]/g, '') +
|
||||||
|
'</b>. Merging would mix the two vaults. ' +
|
||||||
|
'Use a distinct sync URL per account.',
|
||||||
|
okText: 'Merge anyway',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) { syncStatus(''); return toast('Sync cancelled — account mismatch', 'warning'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server reachable + snapshot decrypted → NOW do the optional
|
||||||
|
// pre-sync backup (captures current local state before the merge
|
||||||
|
// mutates it). Best-effort; a failure here doesn't block the sync.
|
||||||
if (cfg.preBackup) {
|
if (cfg.preBackup) {
|
||||||
try {
|
try {
|
||||||
const ab = await loadAutoBackupConfig();
|
const ab = await loadAutoBackupConfig();
|
||||||
@@ -9796,60 +10025,15 @@ async function runSyncNow() {
|
|||||||
} catch (_) { /* best-effort */ }
|
} catch (_) { /* best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
toast('Syncing…');
|
// Apply the remote snapshot (404 → nothing to merge, first sync).
|
||||||
syncStatus('Pulling…');
|
if (remoteSnap) {
|
||||||
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) {
|
|
||||||
let snap;
|
|
||||||
try {
|
|
||||||
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
|
|
||||||
const container = JSON.parse(jsonText);
|
|
||||||
snap = await decryptExportContainer(container, cfg.encPwd);
|
|
||||||
} catch (e) {
|
|
||||||
return toast('Remote decrypt failed — wrong sync password?', 'error');
|
|
||||||
}
|
|
||||||
// Cross-account guard: if the remote snapshot belongs to a
|
|
||||||
// different account than the one currently signed in, refuse
|
|
||||||
// to merge — otherwise a shared URL / same sync password
|
|
||||||
// between accounts silently pulls foreign entries into the
|
|
||||||
// current vault and pushes the polluted state back out.
|
|
||||||
if (snap && snap.username && state.username &&
|
|
||||||
snap.username !== state.username) {
|
|
||||||
const ok = await confirmDialog({
|
|
||||||
title: 'Different account on remote',
|
|
||||||
message: 'The remote snapshot belongs to <b>' +
|
|
||||||
(snap.username + '').replace(/[<>&]/g, '') +
|
|
||||||
'</b>, but you are signed in as <b>' +
|
|
||||||
(state.username + '').replace(/[<>&]/g, '') +
|
|
||||||
'</b>. Merging would mix the two vaults. ' +
|
|
||||||
'Use a distinct sync URL per account.',
|
|
||||||
okText: 'Merge anyway',
|
|
||||||
cancelText: 'Cancel',
|
|
||||||
danger: true,
|
|
||||||
});
|
|
||||||
if (!ok) return toast('Sync cancelled — account mismatch', 'warning');
|
|
||||||
}
|
|
||||||
syncStatus('Merging…');
|
syncStatus('Merging…');
|
||||||
try {
|
try {
|
||||||
merged = await applyRemoteSnapshot(snap);
|
merged = await applyRemoteSnapshot(remoteSnap);
|
||||||
pulled = true;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
syncStatus('');
|
syncStatus('');
|
||||||
return toast('Apply failed: ' + (e && e.message || e), 'error');
|
return toast('Apply failed: ' + (e && e.message || e), '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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guard against data loss: if we failed to import ONE or more remote
|
// Guard against data loss: if we failed to import ONE or more remote
|
||||||
@@ -9858,15 +10042,18 @@ async function runSyncNow() {
|
|||||||
// remote file with a shrunken dataset. Abort the push and surface a
|
// remote file with a shrunken dataset. Abort the push and surface a
|
||||||
// clear error so the user can investigate + retry.
|
// clear error so the user can investigate + retry.
|
||||||
if (merged.failed && merged.failed > 0) {
|
if (merged.failed && merged.failed > 0) {
|
||||||
|
syncStatus('');
|
||||||
return toast('Sync aborted — ' + merged.failed +
|
return toast('Sync aborted — ' + merged.failed +
|
||||||
' remote entry(ies) failed to import locally. Push skipped to avoid overwriting remote data.',
|
' remote entry(ies) failed to import locally. Push skipped to avoid overwriting remote data.',
|
||||||
'error');
|
'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push merged state back to the remote.
|
// Push merged state back to the remote.
|
||||||
|
let pushedCount = 0;
|
||||||
try {
|
try {
|
||||||
await loadEntries(); // pull latest after applying remote changes
|
await loadEntries(); // pull latest after applying remote changes
|
||||||
const snap = await buildSyncSnapshot();
|
const snap = await buildSyncSnapshot();
|
||||||
|
pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0;
|
||||||
syncStatus('Encrypting…');
|
syncStatus('Encrypting…');
|
||||||
const container = await encryptExportPayload(snap, cfg.encPwd);
|
const container = await encryptExportPayload(snap, cfg.encPwd);
|
||||||
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||||
@@ -9886,10 +10073,17 @@ async function runSyncNow() {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
Bridge.setPref(SYNC_PREFS.last, now);
|
Bridge.setPref(SYNC_PREFS.last, now);
|
||||||
render();
|
render();
|
||||||
|
// Bidirectional summary: the added/updated/deleted counts are what
|
||||||
|
// was pulled FROM the remote into this device; pushedCount is the
|
||||||
|
// total entries written back to the remote (so "0·0·0 · pushed 12"
|
||||||
|
// makes clear the vault is safely uploaded even when nothing new
|
||||||
|
// came down).
|
||||||
const summary =
|
const summary =
|
||||||
merged.added + ' added · ' +
|
'pulled ' + merged.added + ' new · ' +
|
||||||
merged.updated + ' updated · ' +
|
merged.updated + ' updated · ' +
|
||||||
merged.deleted + ' deleted';
|
merged.deleted + ' deleted · ' +
|
||||||
|
'pushed ' + pushedCount + ' ' +
|
||||||
|
(pushedCount === 1 ? 'entry' : 'entries');
|
||||||
toast('Sync complete — ' + summary);
|
toast('Sync complete — ' + summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11133,6 +11327,21 @@ async function init() {
|
|||||||
$('#exportCsvBtn').addEventListener('click', doExportCSV);
|
$('#exportCsvBtn').addEventListener('click', doExportCSV);
|
||||||
$('#importBtn').addEventListener('click', doImport);
|
$('#importBtn').addEventListener('click', doImport);
|
||||||
$('#changeMasterBtn').addEventListener('click', openChangeMasterModal);
|
$('#changeMasterBtn').addEventListener('click', openChangeMasterModal);
|
||||||
|
|
||||||
|
// Profile picture: "Change picture" opens the hidden file input;
|
||||||
|
// selecting a file downscales + uploads it; "Remove" clears it.
|
||||||
|
const avaUpload = $('#settingAvatarUpload');
|
||||||
|
const avaInput = $('#settingAvatarInput');
|
||||||
|
const avaRemove = $('#settingAvatarRemove');
|
||||||
|
if (avaUpload && avaInput) {
|
||||||
|
avaUpload.addEventListener('click', () => avaInput.click());
|
||||||
|
avaInput.addEventListener('change', async e => {
|
||||||
|
const f = e.target.files && e.target.files[0];
|
||||||
|
e.target.value = ''; // allow re-picking the same file later
|
||||||
|
if (f) await uploadUserAvatar(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (avaRemove) avaRemove.addEventListener('click', removeUserAvatar);
|
||||||
$('#changeMasterForm').addEventListener('submit', e => {
|
$('#changeMasterForm').addEventListener('submit', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
doChangeMasterPassword();
|
doChangeMasterPassword();
|
||||||
|
|||||||
Reference in New Issue
Block a user