Compare commits
10 Commits
bf606493bd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0404fc65a4 | |||
| 90a696b1a8 | |||
| 64a843d23a | |||
| a090c64081 | |||
| 8555661823 | |||
| 1751f0534f | |||
| a911f2588f | |||
| 829056f1fa | |||
| 8cc1599434 | |||
| 51f72e560a |
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user