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 =
'
${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 @@
+