05/19 Enhance codes 3
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user