Compare commits
10 Commits
bf606493bd
...
0404fc65a4
| 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 |
|
||||
| **Windows Hello (biométrie)** | 🟠 Moyenne | Moyen | `Windows.Security.Credentials` |
|
||||
| **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" |
|
||||
|
||||
---
|
||||
|
||||
+16
-2
@@ -894,7 +894,12 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
.entry-grid.is-list .entry-title {
|
||||
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;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
@@ -918,7 +923,10 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
|
||||
.entry-grid.is-list .entry-meta {
|
||||
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;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
@@ -939,6 +947,12 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
order: 6;
|
||||
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
|
||||
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
|
||||
|
||||
@@ -327,7 +327,7 @@ var
|
||||
LBody, LObj: TJSONObject;
|
||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
|
||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
|
||||
LTemplateEnc, LTemplateIv,
|
||||
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt,
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
@@ -373,6 +373,12 @@ begin
|
||||
// identity). Otherwise the server mints a fresh one.
|
||||
LUuid := Trim(LBody.GetValue<string>('uuid', ''));
|
||||
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
|
||||
LBody.Free;
|
||||
end;
|
||||
@@ -445,8 +451,10 @@ begin
|
||||
BindNullable(LQ, 'tplenc', LTemplateEnc);
|
||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||
LQ.ParamByName('uuid').AsString := LUuid;
|
||||
LQ.ParamByName('c').AsString := LNow;
|
||||
LQ.ParamByName('c2').AsString := LNow;
|
||||
if LCreatedAt = '' then LCreatedAt := LNow;
|
||||
if LUpdatedAt = '' then LUpdatedAt := LNow;
|
||||
LQ.ParamByName('c').AsString := LCreatedAt;
|
||||
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
||||
LQ.ExecSQL;
|
||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||
|
||||
|
||||
+13
@@ -566,6 +566,19 @@
|
||||
<option value="60">60</option>
|
||||
</select>
|
||||
</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">
|
||||
<span>Ask before moving to trash</span>
|
||||
<label class="toggle">
|
||||
|
||||
+16
-2
@@ -425,6 +425,20 @@ function parseEntriesFromJSON(text) {
|
||||
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
|
||||
// shape the bulk-import endpoint expects. Reuses encryptPwd which already
|
||||
// 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) {
|
||||
const pw = await encryptPwd(plain.password);
|
||||
let totpEnc = '', totpIv = '';
|
||||
@@ -476,8 +490,8 @@ async function encryptImportEntry(plain) {
|
||||
template: plain.template || '',
|
||||
// Preserve original timestamps on restore — bulk-import falls back
|
||||
// to now only when these are absent (foreign CSV imports).
|
||||
created_at: plain.created_at || '',
|
||||
updated_at: plain.updated_at || '',
|
||||
created_at: normalizeImportTimestamp(plain.created_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
|
||||
// back where they left off when they return.
|
||||
onSystemLock() {
|
||||
// Explicit user opt-out (Settings → Security). Default ON keeps
|
||||
// the historical behavior below.
|
||||
if (state.lockOnSystemLock === false) return;
|
||||
if (state.quickUnlockEnabled) {
|
||||
if (typeof toast === 'function')
|
||||
toast('System lock — vault kept unlocked (quick unlock active)');
|
||||
@@ -650,6 +653,7 @@ const state = {
|
||||
autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON
|
||||
autofillClearField: localStorage.getItem('autofillClearField') !== '0', // default ON
|
||||
clipboardClearSeconds: parseInt(localStorage.getItem('clipboardClearSeconds') ?? '30', 10), // 0 = never
|
||||
lockOnSystemLock: localStorage.getItem('lockOnSystemLock') !== '0', // default ON
|
||||
autofillFailBalloon: localStorage.getItem('autofillFailBalloon') !== '0', // default ON
|
||||
// Hotkey combos. Each combo = { ctrl, shift, alt, win, key }.
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
if (!isNew) touchEntry(id);
|
||||
// Templates: resolve a preset bundle of {kind, title, customFields,
|
||||
@@ -4713,7 +4725,17 @@ function soDirtyCheck() {
|
||||
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() {
|
||||
if (soSaving) return;
|
||||
soSaving = true;
|
||||
try { await soSaveInner(); } finally { soSaving = false; }
|
||||
}
|
||||
|
||||
async function soSaveInner() {
|
||||
if (!soState) return;
|
||||
// Flush any uncommitted tag text — user may have typed in the chip
|
||||
// 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: '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(); } },
|
||||
// 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: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } },
|
||||
{ id: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } },
|
||||
@@ -6380,6 +6413,7 @@ function openSettings() {
|
||||
$('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0);
|
||||
$('#settingPasswordExpiry').value = String(state.passwordExpiryDays || 0);
|
||||
$('#settingClipboardClear').value = String(state.clipboardClearSeconds ?? 30);
|
||||
$('#settingLockOnSystemLock').checked = state.lockOnSystemLock !== false;
|
||||
$('#settingEditorPosition').value = state.editorPosition || 'right';
|
||||
$('#settingConfirmUnsaved').checked = state.confirmOnUnsaved !== false;
|
||||
// PIN unlock — only meaningful when DPAPI is available.
|
||||
@@ -6778,6 +6812,8 @@ const SYNCED_SETTING_KEYS = [
|
||||
'autofillFailBalloon',
|
||||
// Seconds before a copied secret is auto-cleared (0 = never). Default 30.
|
||||
'clipboardClearSeconds',
|
||||
// Lock the vault when Windows locks / sleeps (default ON).
|
||||
'lockOnSystemLock',
|
||||
];
|
||||
|
||||
// Sets `data-editor-position` on <body> so CSS can swap the slideover
|
||||
@@ -6867,6 +6903,9 @@ async function loadServerSettings() {
|
||||
case 'clipboardClearSeconds':
|
||||
localStorage.setItem('clipboardClearSeconds', String(v));
|
||||
break;
|
||||
case 'lockOnSystemLock':
|
||||
localStorage.setItem('lockOnSystemLock', v ? '1' : '0');
|
||||
break;
|
||||
}
|
||||
});
|
||||
// Apply visual settings immediately.
|
||||
@@ -6979,9 +7018,15 @@ function installCustomContextMenu() {
|
||||
document.addEventListener('mousedown', ev => {
|
||||
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 => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7562,6 +7607,13 @@ async function init() {
|
||||
saveServerSettings();
|
||||
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 => {
|
||||
const n = parseInt(e.target.value, 10) || 0;
|
||||
state.passwordExpiryDays = n;
|
||||
@@ -7899,11 +7951,18 @@ async function init() {
|
||||
e.preventDefault();
|
||||
openCheatsheet();
|
||||
} 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')) {
|
||||
closeConfirm(false);
|
||||
return;
|
||||
}
|
||||
if (!$('#cmdPalette').classList.contains('is-hidden')) {
|
||||
closePalette();
|
||||
return;
|
||||
}
|
||||
if (!$('#changeMasterModal').classList.contains('is-hidden')) {
|
||||
closeChangeMasterModal();
|
||||
return;
|
||||
@@ -7916,13 +7975,6 @@ async function init() {
|
||||
closeHistoryModal();
|
||||
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')) {
|
||||
closeEntryModal();
|
||||
return;
|
||||
|
||||
@@ -112,3 +112,17 @@ test('parseEntriesFromCSV: throws when no header + data rows', () => {
|
||||
test('parseEntriesFromCSV: throws when no title/url/username column present', () => {
|
||||
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,
|
||||
// favicon
|
||||
faviconHost,
|
||||
// csv
|
||||
parseCSV, findColumn, parseEntriesFromCSV,
|
||||
// csv / import
|
||||
parseCSV, findColumn, parseEntriesFromCSV, normalizeImportTimestamp,
|
||||
// strength
|
||||
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
|
||||
// merge (async, coupled — tests stub the io seams below)
|
||||
|
||||
Reference in New Issue
Block a user