Compare commits

..

10 Commits

Author SHA1 Message Date
r-zakarya 0404fc65a4 style(list): align the password column across rows
List view faked a table with flex+order, but the title used flex:1 so it
absorbed all free space — and since trailing content (tags/folder/actions)
varies per row, the fixed-width pw pill landed at a different x on each row.
Give the title a fixed 240px track so pw starts at the same x everywhere;
move the free-space absorption to margin-left:auto on the trailing group
(entry-meta + order-6 actions) so they still float to the far right. Copy +
globe stay in .entry-pw-row (order 4), pinned next to the pw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:58:46 +01:00
r-zakarya 90a696b1a8 docs(audit): UI is fully English; i18n note was stale
Swept every user-facing surface (index.html, all js strings/toasts/dialogs,
Delphi tray/balloon/dialog text) — no French in the UI. The remaining French
is project docs (CLAUDE.md/CODE_AUDIT.md), which is the doc language, not a
mix to fix. i18n stays a future feature, not a bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:50:46 +01:00
r-zakarya 64a843d23a feat(palette): add the sidebar Tools + Settings as commands
Generator, Authenticator, TOTP generator, Vault health, Audit log, Import,
Export and Settings are now reachable from Ctrl+K. View-based tools reuse
the sidebar buttons' click handlers so their cache invalidation rides along.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 05:22:19 +01:00
r-zakarya a090c64081 feat(security): "Lock vault when Windows locks or sleeps" toggle (default ON)
Explicit opt-out for users without Quick Unlock who don't want to retype the
master password after every sleep. Default ON = exact historical behavior
(lock on WTS lock/suspend, with the documented Quick Unlock exemption —
DPAPI already gates access via the Windows account). Synced setting,
Settings > Security.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 05:17:16 +01:00
r-zakarya 8555661823 fix(esc): palette closes before the info modals it opens over
Cheatsheet open -> Ctrl+K -> Esc closed the cheatsheet UNDER the palette:
the priority chain tested cheatsheet/history before cmdPalette. Palette now
sits right after confirmModal (still the absolute top) in the one-surface-
per-keystroke chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 05:03:31 +01:00
r-zakarya 1751f0534f fix(slideover): opening the editor closes Settings (was hidden underneath)
Ctrl+Shift+A with Settings open created the entry panel BELOW the Settings
panel — invisible. openSlideOver now closes Settings first (root fix: covers
every editor-open path, not just the hotkey), placed after the discard guard
so cancelling keeps Settings. The inverse — opening Settings over the editor
— stays as-is by design (Esc: Settings first, editor second).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 04:57:20 +01:00
r-zakarya a911f2588f fix(sync): preserve created_at/updated_at when sync adds a remote entry
Same class as the reported import bug, other door: POST /entries always
stamped now, so an entry arriving on a device via sync lost its original
creation date (and advertised a fake edit via fresh updated_at).
HandleCreateEntry now honours body timestamps like bulk-import (absent ->
now); applyRemoteSnapshot already ships them through encryptImportEntry,
normalized to the DB format. Regular saves/duplicates send none - unchanged.
password_changed_at shares the created_at param, which is faithful: the
password is at least as old as the entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:18:13 +01:00
r-zakarya 829056f1fa fix(import): normalize timestamps to the DB format at the import door
vault_entries uses SQLite's space-separated UTC format everywhere, and both
sorting and sync last-write-wins compare the strings lexically — so a foreign
JSON import carrying strict-ISO 'T'/millis/Z/offset timestamps would slot in
with a different format and subtly break ordering and merge arbitration.
normalizeImportTimestamp converts any ISO-ish variant to 'YYYY-MM-DD
HH:MM:SS' UTC (bare strings treated as UTC, garbage -> '' = server stamps
now). +1 unit test (69 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:13:41 +01:00
r-zakarya 8cc1599434 fix(slideover): double-click on Save no longer creates a duplicate entry
soSave had no re-entrancy guard: a second click while the first run awaited
encryption/POST ran the whole save again -> two POSTs, two entries. Wrapped
in a soSaving latch (same class as the sync-button guard); body moved to
soSaveInner so every early validation return releases the latch via finally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 23:57:43 +01:00
r-zakarya 51f72e560a fix(ctxmenu): Esc with the context menu open closes only the menu
Same Esc fall-through class: the menu's Esc handler was bubble-phase and
didn't stop the keystroke, so the slideover capture handler fired first and
popped the discard prompt while the menu also hid. Capture + stopPropagation,
gated on the menu being visible; registered before the slideover handler
(installCustomContextMenu runs at the top of init) so ordering is guaranteed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:48:32 +01:00
8 changed files with 135 additions and 20 deletions
+1 -1
View File
@@ -344,7 +344,7 @@ cf. la checklist "Entry payload" de CLAUDE.md).
| **Extension navigateur** | 🟠 Haute | Élevé | Autofill in-page, feature #1 demandée | | **Extension navigateur** | 🟠 Haute | Élevé | Autofill in-page, feature #1 demandée |
| **Windows Hello (biométrie)** | 🟠 Moyenne | Moyen | `Windows.Security.Credentials` | | **Windows Hello (biométrie)** | 🟠 Moyenne | Moyen | `Windows.Security.Credentials` |
| **Import KeePass XML / 1PUX** | 🟡 Moyenne | Moyen | Complète l'écosystème d'import | | **Import KeePass XML / 1PUX** | 🟡 Moyenne | Moyen | Complète l'écosystème d'import |
| **i18n (FR/EN propre)** | 🔵 Basse | Moyen | FR/EN mélangés dans l'UI | | **i18n (multi-langue)** | 🔵 Basse | Moyen | UI 100% EN (vérifié 2026-07-13) ; i18n = feature future, pas un bug |
| **Emergency access / partage** | 🔵 Basse | Élevé | Hors scope "perso offline" | | **Emergency access / partage** | 🔵 Basse | Élevé | Hors scope "perso offline" |
--- ---
+16 -2
View File
@@ -894,7 +894,12 @@ input[type="range"]::-webkit-slider-thumb {
} }
.entry-grid.is-list .entry-title { .entry-grid.is-list .entry-title {
order: 2; order: 2;
flex: 1; /* Fixed track (not flex:1) so the password field that follows starts at
the SAME x on every row — otherwise variable trailing content
(tags/folder/icons) let the title grow by different amounts and the
pw pill drifted left/right per row. Short names leave whitespace;
long names ellipsise. */
flex: 0 0 240px;
min-width: 0; min-width: 0;
display: flex; flex-direction: column; display: flex; flex-direction: column;
} }
@@ -918,7 +923,10 @@ input[type="range"]::-webkit-slider-thumb {
.entry-grid.is-list .entry-meta { .entry-grid.is-list .entry-meta {
order: 5; order: 5;
margin: 0; /* Absorb the free space freed up by the now-fixed title, pushing tags/
folder + the order-6 actions to the far right while pw stays put.
(When a row has no meta, the order-6 rule below carries the auto.) */
margin: 0 0 0 auto;
flex-shrink: 0; flex-shrink: 0;
max-width: 220px; max-width: 220px;
overflow: hidden; overflow: hidden;
@@ -939,6 +947,12 @@ input[type="range"]::-webkit-slider-thumb {
order: 6; order: 6;
flex-shrink: 0; flex-shrink: 0;
} }
/* First trailing action floats right too, so rows WITHOUT an entry-meta
still push their actions to the far edge (margin-left:auto on the first
flex item consumes the free space; later ones are no-ops). */
.entry-grid.is-list .entry-fav,
.entry-grid.is-list .entry-kebab-wrap,
.entry-grid.is-list .entry-head .icon-btn { margin-left: auto; }
/* Selection checkbox overlay — nested INSIDE the avatar (.entry-avatar /* Selection checkbox overlay — nested INSIDE the avatar (.entry-avatar
is position:relative) so it covers the avatar 1:1, no overlap with the is position:relative) so it covers the avatar 1:1, no overlap with the
avatar's footprint. Hidden by default; appears on card hover OR when avatar's footprint. Hidden by default; appears on card hover OR when
+11 -3
View File
@@ -327,7 +327,7 @@ var
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid, LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
LTemplateEnc, LTemplateIv, LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
@@ -373,6 +373,12 @@ begin
// identity). Otherwise the server mints a fresh one. // identity). Otherwise the server mints a fresh one.
LUuid := Trim(LBody.GetValue<string>('uuid', '')); LUuid := Trim(LBody.GetValue<string>('uuid', ''));
if LUuid = '' then LUuid := NewUUIDv4; if LUuid = '' then LUuid := NewUUIDv4;
// Preserve original timestamps when the caller carries them (sync adds
// a remote entry via applyRemoteSnapshot -> encryptImportEntry). Same
// contract as bulk-import: absent/empty -> stamp now. Regular saves
// (soSave, duplicate) don't send them, so nothing changes there.
LCreatedAt := Trim(LBody.GetValue<string>('created_at', ''));
LUpdatedAt := Trim(LBody.GetValue<string>('updated_at', ''));
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -445,8 +451,10 @@ begin
BindNullable(LQ, 'tplenc', LTemplateEnc); BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv); BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LNow; if LCreatedAt = '' then LCreatedAt := LNow;
LQ.ParamByName('c2').AsString := LNow; if LUpdatedAt = '' then LUpdatedAt := LNow;
LQ.ParamByName('c').AsString := LCreatedAt;
LQ.ParamByName('c2').AsString := LUpdatedAt;
LQ.ExecSQL; LQ.ExecSQL;
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
+13
View File
@@ -566,6 +566,19 @@
<option value="60">60</option> <option value="60">60</option>
</select> </select>
</div> </div>
<div class="setting-row">
<span>
Lock vault when Windows locks or sleeps
<small class="setting-hint">
With Quick Unlock enabled the vault stays unlocked
anyway — Windows sign-in already gates access.
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingLockOnSystemLock">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row"> <div class="setting-row">
<span>Ask before moving to trash</span> <span>Ask before moving to trash</span>
<label class="toggle"> <label class="toggle">
+16 -2
View File
@@ -425,6 +425,20 @@ function parseEntriesFromJSON(text) {
// Encrypt one parsed entry (plaintext password + optional TOTP) into the // Encrypt one parsed entry (plaintext password + optional TOTP) into the
// shape the bulk-import endpoint expects. Reuses encryptPwd which already // shape the bulk-import endpoint expects. Reuses encryptPwd which already
// generates a fresh IV per call. // generates a fresh IV per call.
// Normalize any ISO-ish timestamp to the DB's 'YYYY-MM-DD HH:MM:SS' (UTC).
// Imports are the ONE door where a foreign format (T separator, millis, Z,
// offset) could enter vault_entries — and sorting + sync last-write-wins
// compare these strings LEXICALLY, so a mixed format breaks both. Bare
// strings are treated as UTC (our own exports carry UTC without a marker).
function normalizeImportTimestamp(s) {
if (!s) return '';
s = String(s).trim();
const d = new Date(s.replace(' ', 'T') +
(/(Z|[+-]\d\d:?\d\d)$/.test(s) ? '' : 'Z'));
if (isNaN(d)) return '';
return d.toISOString().slice(0, 19).replace('T', ' ');
}
async function encryptImportEntry(plain) { async function encryptImportEntry(plain) {
const pw = await encryptPwd(plain.password); const pw = await encryptPwd(plain.password);
let totpEnc = '', totpIv = ''; let totpEnc = '', totpIv = '';
@@ -476,8 +490,8 @@ async function encryptImportEntry(plain) {
template: plain.template || '', template: plain.template || '',
// Preserve original timestamps on restore — bulk-import falls back // Preserve original timestamps on restore — bulk-import falls back
// to now only when these are absent (foreign CSV imports). // to now only when these are absent (foreign CSV imports).
created_at: plain.created_at || '', created_at: normalizeImportTimestamp(plain.created_at),
updated_at: plain.updated_at || '', updated_at: normalizeImportTimestamp(plain.updated_at),
}); });
} }
+62 -10
View File
@@ -86,6 +86,9 @@ const Bridge = (() => {
// re-locking is redundant — we just stay unlocked and the user is // re-locking is redundant — we just stay unlocked and the user is
// back where they left off when they return. // back where they left off when they return.
onSystemLock() { onSystemLock() {
// Explicit user opt-out (Settings → Security). Default ON keeps
// the historical behavior below.
if (state.lockOnSystemLock === false) return;
if (state.quickUnlockEnabled) { if (state.quickUnlockEnabled) {
if (typeof toast === 'function') if (typeof toast === 'function')
toast('System lock — vault kept unlocked (quick unlock active)'); toast('System lock — vault kept unlocked (quick unlock active)');
@@ -650,6 +653,7 @@ const state = {
autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON
autofillClearField: localStorage.getItem('autofillClearField') !== '0', // default ON autofillClearField: localStorage.getItem('autofillClearField') !== '0', // default ON
clipboardClearSeconds: parseInt(localStorage.getItem('clipboardClearSeconds') ?? '30', 10), // 0 = never clipboardClearSeconds: parseInt(localStorage.getItem('clipboardClearSeconds') ?? '30', 10), // 0 = never
lockOnSystemLock: localStorage.getItem('lockOnSystemLock') !== '0', // default ON
autofillFailBalloon: localStorage.getItem('autofillFailBalloon') !== '0', // default ON autofillFailBalloon: localStorage.getItem('autofillFailBalloon') !== '0', // default ON
// Hotkey combos. Each combo = { ctrl, shift, alt, win, key }. // Hotkey combos. Each combo = { ctrl, shift, alt, win, key }.
// key is the uppercase character or VK label ('A'..'Z', '0'..'9', // key is the uppercase character or VK label ('A'..'Z', '0'..'9',
@@ -3850,6 +3854,14 @@ async function openSlideOver(id, opts) {
} }
if (!isNew && !e) return; if (!isNew && !e) return;
// The editor must be visible when it opens: Settings sits ABOVE the
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
// the new-entry panel hidden underneath. Close Settings — placed AFTER
// the discard guard so cancelling it keeps Settings untouched. The
// inverse (opening Settings over the editor) is deliberate and stays:
// Esc closes Settings first, then handles the editor.
if ($('#settingsPanel').classList.contains('is-open')) closeSettings();
state.selectedId = isNew ? null : id; state.selectedId = isNew ? null : id;
if (!isNew) touchEntry(id); if (!isNew) touchEntry(id);
// Templates: resolve a preset bundle of {kind, title, customFields, // Templates: resolve a preset bundle of {kind, title, customFields,
@@ -4713,7 +4725,17 @@ function soDirtyCheck() {
if (btn) btn.style.display = isSoDirty() ? '' : 'none'; if (btn) btn.style.display = isSoDirty() ? '' : 'none';
} }
// Re-entrancy latch: a double-click on Save (or Enter+click) while the first
// run awaits encryption/POST would fire a SECOND full save → duplicate entry.
// Same async-race class as the sync-button guard.
let soSaving = false;
async function soSave() { async function soSave() {
if (soSaving) return;
soSaving = true;
try { await soSaveInner(); } finally { soSaving = false; }
}
async function soSaveInner() {
if (!soState) return; if (!soState) return;
// Flush any uncommitted tag text — user may have typed in the chip // Flush any uncommitted tag text — user may have typed in the chip
// input without pressing Enter / comma before clicking Save. // input without pressing Enter / comma before clicking Save.
@@ -5781,6 +5803,17 @@ function paletteCommands() {
{ id: 'lock', label: 'Lock vault', icon: 'i-lock', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('lock')) lockVault(); } }, { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('lock')) lockVault(); } },
{ id: 'logout', label: 'Sign out', icon: 'i-log-out', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('logout')) doLogout(); } }, { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: async () => { closePalette(); if (await confirmDiscardForSessionExit('logout')) doLogout(); } },
{ id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } }, { id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } },
// Sidebar Tools — view-based ones go through the sidebar buttons'
// click handlers so their cache invalidation (health/audit) rides along.
{ id: 'gen', label: 'Password generator', icon: 'i-dice', run: () => { closePalette(); openGen('standalone'); } },
{ id: 'authenticator', label: 'Authenticator (2FA codes)', icon: 'i-shield',
run: () => { closePalette(); $('#sidebarAuthenticatorBtn').click(); } },
{ id: 'totp', label: 'TOTP generator', icon: 'i-key', run: () => { closePalette(); openTotpTool(); } },
{ id: 'health', label: 'Vault health', icon: 'i-alert', run: () => { closePalette(); $('#sidebarHealthBtn').click(); } },
{ id: 'audit', label: 'Audit log', icon: 'i-list', run: () => { closePalette(); $('#sidebarAuditBtn').click(); } },
{ id: 'import', label: 'Import vault', icon: 'i-log-in', run: () => { closePalette(); doImport(); } },
{ id: 'export', label: 'Export vault', icon: 'i-log-out',run: () => { closePalette(); doExport(); } },
{ id: 'settings', label: 'Open settings', icon: 'i-settings', run: () => { closePalette(); openSettings(); } },
{ id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } }, { id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } },
{ id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } }, { id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } },
{ id: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } }, { id: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } },
@@ -6380,6 +6413,7 @@ function openSettings() {
$('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0); $('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0);
$('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0); $('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0);
$('#settingClipboardClear').value = String(state.clipboardClearSeconds ?? 30); $('#settingClipboardClear').value = String(state.clipboardClearSeconds ?? 30);
$('#settingLockOnSystemLock').checked = state.lockOnSystemLock !== false;
$('#settingEditorPosition').value = state.editorPosition || 'right'; $('#settingEditorPosition').value = state.editorPosition || 'right';
$('#settingConfirmUnsaved').checked = state.confirmOnUnsaved !== false; $('#settingConfirmUnsaved').checked = state.confirmOnUnsaved !== false;
// PIN unlock — only meaningful when DPAPI is available. // PIN unlock — only meaningful when DPAPI is available.
@@ -6778,6 +6812,8 @@ const SYNCED_SETTING_KEYS = [
'autofillFailBalloon', 'autofillFailBalloon',
// Seconds before a copied secret is auto-cleared (0 = never). Default 30. // Seconds before a copied secret is auto-cleared (0 = never). Default 30.
'clipboardClearSeconds', 'clipboardClearSeconds',
// Lock the vault when Windows locks / sleeps (default ON).
'lockOnSystemLock',
]; ];
// Sets `data-editor-position` on <body> so CSS can swap the slideover // Sets `data-editor-position` on <body> so CSS can swap the slideover
@@ -6867,6 +6903,9 @@ async function loadServerSettings() {
case 'clipboardClearSeconds': case 'clipboardClearSeconds':
localStorage.setItem('clipboardClearSeconds', String(v)); localStorage.setItem('clipboardClearSeconds', String(v));
break; break;
case 'lockOnSystemLock':
localStorage.setItem('lockOnSystemLock', v ? '1' : '0');
break;
} }
}); });
// Apply visual settings immediately. // Apply visual settings immediately.
@@ -6979,9 +7018,15 @@ function installCustomContextMenu() {
document.addEventListener('mousedown', ev => { document.addEventListener('mousedown', ev => {
if (!ev.target.closest('.custom-ctxmenu')) hide(); if (!ev.target.closest('.custom-ctxmenu')) hide();
}); });
// Capture + stopPropagation: Esc with the menu open must close ONLY the
// menu — otherwise the same keystroke reaches the slideover/fallback
// handlers and also closes (or discard-prompts) whatever is behind.
document.addEventListener('keydown', ev => { document.addEventListener('keydown', ev => {
if (ev.key === 'Escape') hide(); if (ev.key !== 'Escape') return;
}); if (menu.classList.contains('is-hidden')) return;
ev.stopPropagation();
hide();
}, true);
window.addEventListener('blur', hide); window.addEventListener('blur', hide);
} }
@@ -7562,6 +7607,13 @@ async function init() {
saveServerSettings(); saveServerSettings();
toast(n === 0 ? 'Clipboard auto-clear disabled' : 'Copied secrets clear after ' + n + 's'); toast(n === 0 ? 'Clipboard auto-clear disabled' : 'Copied secrets clear after ' + n + 's');
}); });
$('#settingLockOnSystemLock').addEventListener('change', e => {
state.lockOnSystemLock = e.target.checked;
localStorage.setItem('lockOnSystemLock', e.target.checked ? '1' : '0');
saveServerSettings();
toast(e.target.checked ? 'Vault will lock when Windows locks'
: 'Vault stays unlocked when Windows locks');
});
$('#settingPasswordExpiry').addEventListener('change', e => { $('#settingPasswordExpiry').addEventListener('change', e => {
const n = parseInt(e.target.value, 10) || 0; const n = parseInt(e.target.value, 10) || 0;
state.passwordExpiryDays = n; state.passwordExpiryDays = n;
@@ -7899,11 +7951,18 @@ async function init() {
e.preventDefault(); e.preventDefault();
openCheatsheet(); openCheatsheet();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
// Close in priority order: confirm first (most modal-y) then others // Close in priority order: confirm first (most modal-y), then the
// palette — it OPENS OVER the info modals (Ctrl+K on top of the
// cheatsheet), so it must close before them. ONE surface per
// keystroke, topmost first.
if (!$('#confirmModal').classList.contains('is-hidden')) { if (!$('#confirmModal').classList.contains('is-hidden')) {
closeConfirm(false); closeConfirm(false);
return; return;
} }
if (!$('#cmdPalette').classList.contains('is-hidden')) {
closePalette();
return;
}
if (!$('#changeMasterModal').classList.contains('is-hidden')) { if (!$('#changeMasterModal').classList.contains('is-hidden')) {
closeChangeMasterModal(); closeChangeMasterModal();
return; return;
@@ -7916,13 +7975,6 @@ async function init() {
closeHistoryModal(); closeHistoryModal();
return; return;
} }
// ONE surface per keystroke, topmost first — closing several at
// once meant "Esc closes the palette AND pops the editor's
// discard prompt" (same class as the quick-search Esc bug).
if (!$('#cmdPalette').classList.contains('is-hidden')) {
closePalette();
return;
}
if (!$('#entryModal').classList.contains('is-hidden')) { if (!$('#entryModal').classList.contains('is-hidden')) {
closeEntryModal(); closeEntryModal();
return; return;
+14
View File
@@ -112,3 +112,17 @@ test('parseEntriesFromCSV: throws when no header + data rows', () => {
test('parseEntriesFromCSV: throws when no title/url/username column present', () => { test('parseEntriesFromCSV: throws when no title/url/username column present', () => {
assert.throws(() => T.parseEntriesFromCSV('password,foo\npw,x'), /title\/url or username/i); assert.throws(() => T.parseEntriesFromCSV('password,foo\npw,x'), /title\/url or username/i);
}); });
// --- Import timestamp normalization (single door into vault_entries) --------
test('normalizeImportTimestamp: every ISO-ish variant lands in DB space-format UTC', () => {
// Our own export (DB format, bare UTC) — unchanged.
assert.equal(T.normalizeImportTimestamp('2026-07-10 15:56:23'), '2026-07-10 15:56:23');
// Strict ISO with T + millis + Z — same instant, space format.
assert.equal(T.normalizeImportTimestamp('2026-07-10T15:56:23.123Z'), '2026-07-10 15:56:23');
// Explicit offset is converted to UTC.
assert.equal(T.normalizeImportTimestamp('2026-07-10T17:56:23+02:00'), '2026-07-10 15:56:23');
// Garbage / absent → '' (server stamps "now").
assert.equal(T.normalizeImportTimestamp('not-a-date'), '');
assert.equal(T.normalizeImportTimestamp(''), '');
});
+2 -2
View File
@@ -146,8 +146,8 @@ function loadApp(overrides = {}) {
base32Decode, generateTOTP, parseOtpAuthUri, base32Decode, generateTOTP, parseOtpAuthUri,
// favicon // favicon
faviconHost, faviconHost,
// csv // csv / import
parseCSV, findColumn, parseEntriesFromCSV, parseCSV, findColumn, parseEntriesFromCSV, normalizeImportTimestamp,
// strength // strength
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined), computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
// merge (async, coupled — tests stub the io seams below) // merge (async, coupled — tests stub the io seams below)