05/19 Enhance codes 3

This commit is contained in:
2026-05-19 09:58:43 -04:00
parent c42164e21c
commit 976845f96e
3 changed files with 399 additions and 6 deletions
+125
View File
@@ -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);
}
+259 -6
View File
@@ -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 =
'<li class="vault-empty">No items found. Click + to add one.</li>';
@@ -581,6 +590,7 @@ const Vault = (() => {
.join("");
li.innerHTML = `
<input type="checkbox" class="item-checkbox" data-id="${item.id}" ${_selectedIds.has(item.id) ? "checked" : ""} aria-label="Select ${escHtml(item.name)}"/>
<div class="item-icon">${icon}</div>
<div class="item-info">
<span class="item-name">${escHtml(item.name)}</span>
@@ -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 =
'<option value="">— No folder —</option>' +
_folders.map((f) => `<option value="${f.id}">${escHtml(f.name)}</option>`).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 =
`<div class="pw-history-title">🕐 Previous passwords (${history.length})</div>` +
history.map((h) => {
const date = h.changed_at
? new Date(h.changed_at).toLocaleDateString()
: "Unknown date";
return `<div class="pw-history-row">
<span class="pw-history-masked">••••••••</span>
<span class="pw-history-date">${escHtml(date)}</span>
<button class="btn-text btn-sm pw-history-reveal" data-pw="${escHtml(h.password || "")}" title="Reveal">👁</button>
<button class="btn-text btn-sm pw-history-restore" data-pw="${escHtml(h.password || "")}" title="Restore this password">↩ Restore</button>
</div>`;
}).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();