diff --git a/app/static/css/app.css b/app/static/css/app.css index 460378d..9adc778 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2568,3 +2568,128 @@ html.sidebar-open { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.15); } + +/* ── Bulk operations ─────────────────────────────────────────────────────────── */ + +/* Bulk action toolbar */ +.bulk-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--surface-2, #f8fafc); + border-top: 1px solid var(--border); + flex-wrap: wrap; +} + +.bulk-count { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-right: 4px; +} + +/* Item checkbox — hidden by default, shown in select mode */ +.vault-item .item-checkbox { + display: none; + width: 16px; + height: 16px; + margin-right: 6px; + cursor: pointer; + flex-shrink: 0; + accent-color: var(--primary); +} + +.vault-list.select-mode .vault-item .item-checkbox { + display: block; +} + +/* Selected item highlight */ +.vault-item.item-selected { + background: #fef2f2; + border-radius: var(--radius); +} + +/* Select mode — hide per-item action buttons to reduce clutter */ +.vault-list.select-mode .item-actions { + display: none; +} + +/* Select-mode indicator on the Select button */ +#btn-select-mode.active { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +/* Folder move dropdown in bulk toolbar */ +.bulk-move-select { + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 13px; + background: var(--surface); + color: var(--text); + max-width: 160px; +} + +/* ── Password history panel (edit modal) ─────────────────────────────────── */ + +.password-history-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + margin-bottom: 14px; + background: var(--surface-2, #f8fafc); +} + +.pw-history-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.pw-history-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 0; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.pw-history-row:last-child { + border-bottom: none; +} + +.pw-history-masked { + font-family: monospace; + flex: 1; + color: var(--text-muted); + letter-spacing: 0.1em; +} + +.pw-history-date { + font-size: 11px; + color: var(--text-muted); + flex-shrink: 0; +} + +.pw-history-reveal, +.pw-history-restore { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + font-size: 12px; + color: var(--text-muted); + flex-shrink: 0; +} + +.pw-history-reveal:hover, +.pw-history-restore:hover { + color: var(--text); +} diff --git a/app/static/js/vault.js b/app/static/js/vault.js index cef2abb..3758d9f 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -93,6 +93,9 @@ const Vault = (() => { } let _activeFilter = null; let _sortOrder = "name-asc"; + // ── Bulk selection state ────────────────────────────────────────────────── + let _selectMode = false; + let _selectedIds = new Set(); const FIELD_LABELS = { url: "Website URL", @@ -301,6 +304,8 @@ const Vault = (() => { // ── View switching ──────────────────────────────────────────────────────── function switchView(view, { pushState = true } = {}) { + // Exit bulk-select mode when navigating away from the vault view. + if (view !== "vault" && _selectMode) _exitSelectMode(); _currentView = view; ["vault", "security", "sharing", "emergency", "import-export"].forEach( (v) => { @@ -453,6 +458,10 @@ const Vault = (() => { const list = document.getElementById("vault-list"); if (!list) return; list.innerHTML = ""; + + // Apply or remove select-mode CSS class on the parent wrapper. + const wrapper = list.closest(".vault-list"); + if (wrapper) wrapper.classList.toggle("select-mode", _selectMode); if (items.length === 0) { list.innerHTML = '
  • No items found. Click + to add one.
  • '; @@ -581,6 +590,7 @@ const Vault = (() => { .join(""); li.innerHTML = ` +
    ${icon}
    ${escHtml(item.name)} @@ -621,6 +631,23 @@ const Vault = (() => { li.dataset.totpInterval = intervalId; } + // Wire the bulk-select checkbox. + const checkbox = li.querySelector(".item-checkbox"); + if (checkbox) { + checkbox.addEventListener("change", () => { + if (checkbox.checked) { + _selectedIds.add(item.id); + li.classList.add("item-selected"); + } else { + _selectedIds.delete(item.id); + li.classList.remove("item-selected"); + } + _updateBulkToolbar(); + }); + // Apply selected state from module state (survives re-renders). + if (_selectedIds.has(item.id)) li.classList.add("item-selected"); + } + if (showLaunch) { li.querySelector('[data-action="launch"]').addEventListener( "click", @@ -724,6 +751,171 @@ const Vault = (() => { } } + // ── Bulk operations ─────────────────────────────────────────────────────── + + function _enterSelectMode() { + _selectMode = true; + _selectedIds.clear(); + document.getElementById("btn-select-mode")?.classList.add("active"); + document.getElementById("bulk-toolbar")?.classList.remove("hidden"); + _updateBulkToolbar(); + applyCurrentFilter(); // re-render to show checkboxes + } + + function _exitSelectMode() { + _selectMode = false; + _selectedIds.clear(); + document.getElementById("btn-select-mode")?.classList.remove("active"); + document.getElementById("bulk-toolbar")?.classList.add("hidden"); + applyCurrentFilter(); // re-render to hide checkboxes + } + + function _updateBulkToolbar() { + const countEl = document.getElementById("bulk-count"); + const n = _selectedIds.size; + if (countEl) { + countEl.textContent = n === 0 + ? "0 selected" + : `${n} item${n !== 1 ? "s" : ""} selected`; + } + // Disable action buttons when nothing is selected. + ["btn-bulk-move", "btn-bulk-delete", "btn-bulk-export"].forEach((id) => { + const btn = document.getElementById(id); + if (btn) btn.disabled = n === 0; + }); + } + + async function _handleBulkDelete() { + const ids = [..._selectedIds]; + if (!ids.length) return; + if (!confirm(`Delete ${ids.length} item${ids.length !== 1 ? "s" : ""}? This cannot be undone.`)) return; + try { + await Promise.all( + ids.map((id) => apiFetch(`/api/vault/${id}`, { method: "DELETE" })), + ); + showToast(`${ids.length} item${ids.length !== 1 ? "s" : ""} deleted`); + _exitSelectMode(); + await loadVault(); + } catch (err) { + showToast("Delete failed: " + err.message, "error"); + } + } + + async function _handleBulkMove() { + if (!_selectedIds.size) return; + // Build a folder picker inline in the bulk toolbar. + const toolbar = document.getElementById("bulk-toolbar"); + if (!toolbar) return; + // Remove any existing picker first. + toolbar.querySelector(".bulk-move-select")?.remove(); + + const sel = document.createElement("select"); + sel.className = "bulk-move-select"; + sel.innerHTML = + '' + + _folders.map((f) => ``).join(""); + + sel.addEventListener("change", async () => { + const folderId = sel.value ? parseInt(sel.value) : null; + sel.remove(); + try { + await Promise.all( + [..._selectedIds].map((id) => + apiFetch(`/api/vault/${id}`, { + method: "PUT", + body: JSON.stringify({ folder_id: folderId }), + }), + ), + ); + const dest = folderId + ? (_folders.find((f) => f.id === folderId)?.name || "folder") + : "root"; + showToast(`${_selectedIds.size} item${_selectedIds.size !== 1 ? "s" : ""} moved to ${dest}`); + _exitSelectMode(); + await loadVault(); + } catch (err) { + showToast("Move failed: " + err.message, "error"); + } + }); + + // Insert after the Move button. + const moveBtn = document.getElementById("btn-bulk-move"); + moveBtn?.after(sel); + sel.focus(); + } + + async function _handleBulkExport() { + const ids = new Set(_selectedIds); + if (!ids.size) return; + const vaultKey = VaultSession.getKey(); + if (!vaultKey) { showUnlockOverlay(); return; } + + const selected = _items.filter((i) => ids.has(i.id)); + const envelope = { + version: 1, + exported_at: new Date().toISOString(), + items: selected.map((i) => ({ + id: i.id, + name: i.name, + item_type: i.item_type, + folder_id: i.folder_id, + enc_data: i.enc_data, + iv: i.iv, + enc_name: i.enc_name || null, + iv_name: i.iv_name || null, + })), + }; + + const blob = new Blob([JSON.stringify(envelope, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `passkeeper-selection-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast(`${selected.length} item${selected.length !== 1 ? "s" : ""} exported`); + _exitSelectMode(); + } + + function _initBulkToolbar() { + document.getElementById("btn-select-mode")?.addEventListener("click", () => { + if (_selectMode) _exitSelectMode(); + else _enterSelectMode(); + }); + + document.getElementById("btn-bulk-cancel")?.addEventListener("click", _exitSelectMode); + + document.getElementById("btn-bulk-delete")?.addEventListener("click", _handleBulkDelete); + + document.getElementById("btn-bulk-move")?.addEventListener("click", _handleBulkMove); + + document.getElementById("btn-bulk-export")?.addEventListener("click", _handleBulkExport); + + document.getElementById("btn-bulk-select-all")?.addEventListener("click", () => { + // Select all currently visible items (from the rendered list). + const list = document.getElementById("vault-list"); + if (!list) return; + const checkboxes = list.querySelectorAll(".item-checkbox"); + const allChecked = [...checkboxes].every((cb) => cb.checked); + checkboxes.forEach((cb) => { + cb.checked = !allChecked; + const id = parseInt(cb.dataset.id); + if (!allChecked) { + _selectedIds.add(id); + cb.closest(".vault-item")?.classList.add("item-selected"); + } else { + _selectedIds.delete(id); + cb.closest(".vault-item")?.classList.remove("item-selected"); + } + }); + _updateBulkToolbar(); + }); + } + // ── Background vault health checks ────────────────────────────────────────── // // Runs after every vault load. Computes weak/reused counts synchronously, @@ -3472,6 +3664,58 @@ const Vault = (() => { } document.getElementById("item-modal").classList.add("open"); document.getElementById("field-name").focus(); + + // Render password history panel for password items in edit mode. + const historyPanel = document.getElementById("password-history-panel"); + if (historyPanel) { + if (mode === "edit" && item?.item_type === "password") { + const history = item.plain?.password_history || []; + if (history.length) { + historyPanel.classList.remove("hidden"); + historyPanel.innerHTML = + `
    🕐 Previous passwords (${history.length})
    ` + + history.map((h) => { + const date = h.changed_at + ? new Date(h.changed_at).toLocaleDateString() + : "Unknown date"; + return `
    + •••••••• + ${escHtml(date)} + + +
    `; + }).join(""); + historyPanel.querySelectorAll(".pw-history-reveal").forEach((btn) => { + btn.addEventListener("click", () => { + const masked = btn.closest(".pw-history-row").querySelector(".pw-history-masked"); + if (masked.textContent === "••••••••") { + masked.textContent = btn.dataset.pw; + btn.textContent = "🙈"; + } else { + masked.textContent = "••••••••"; + btn.textContent = "👁"; + } + }); + }); + historyPanel.querySelectorAll(".pw-history-restore").forEach((btn) => { + btn.addEventListener("click", () => { + const pwField = document.getElementById("field-password"); + if (pwField) { + pwField.value = btn.dataset.pw; + pwField.dispatchEvent(new Event("input")); + showToast("Password restored — click Save to apply"); + } + }); + }); + } else { + historyPanel.classList.add("hidden"); + historyPanel.innerHTML = ""; + } + } else { + historyPanel.classList.add("hidden"); + historyPanel.innerHTML = ""; + } + } if ( (mode === "add" && (document.getElementById("field-type").value || "password") === @@ -3594,20 +3838,28 @@ const Vault = (() => { if (mode === "add") { // New item — set password_changed_at to now. plainData.password_changed_at = new Date().toISOString(); + plainData.password_history = []; } else { // Edit — only update if the password field actually changed. const existingItem = _items.find((i) => i.id === parseInt(itemId)); const existingPassword = existingItem?.plain?.password ?? null; const existingChangedAt = existingItem?.plain?.password_changed_at ?? null; + const existingHistory = existingItem?.plain?.password_history ?? []; + if (plainData.password !== existingPassword) { - // Password changed — record now. + // Password changed — push the old password onto history (max 5 entries). + const historyEntry = { + password: existingPassword, + changed_at: existingChangedAt || existingItem?.created_at || new Date().toISOString(), + }; + const newHistory = [historyEntry, ...existingHistory].slice(0, 5); plainData.password_changed_at = new Date().toISOString(); - } else if (existingChangedAt) { - // Password unchanged — preserve the existing timestamp. - plainData.password_changed_at = existingChangedAt; + plainData.password_history = newHistory; + } else { + // Password unchanged — preserve existing tracking data. + if (existingChangedAt) plainData.password_changed_at = existingChangedAt; + plainData.password_history = existingHistory; } - // If no existing timestamp and password unchanged, leave it absent - // — the security dashboard will fall back to created_at. } } @@ -4349,6 +4601,7 @@ const Vault = (() => { // Start web-app inactivity tracking. _startWebIdleTracking(); + _initBulkToolbar(); if (!VaultSession.getKey()) { showUnlockOverlay(); diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index 44bf24c..cc6f455 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -236,11 +236,23 @@
    + + + + + +