04/26 Enhanced functionalities
This commit is contained in:
+245
-4
@@ -199,7 +199,7 @@ const Vault = (() => {
|
||||
|
||||
function switchView(view) {
|
||||
_currentView = view;
|
||||
["vault", "security", "sharing", "emergency"].forEach((v) => {
|
||||
["vault", "security", "sharing", "emergency", "import-export"].forEach((v) => {
|
||||
document
|
||||
.getElementById(`view-${v}`)
|
||||
?.classList.toggle("hidden", v !== view);
|
||||
@@ -218,6 +218,7 @@ const Vault = (() => {
|
||||
if (view === "security") renderSecurityDashboard();
|
||||
if (view === "sharing") loadSharingView();
|
||||
if (view === "emergency") loadEmergencyView();
|
||||
if (view === "import-export") loadImportExportView();
|
||||
}
|
||||
|
||||
// ── Vault render ──────────────────────────────────────────────────────────
|
||||
@@ -552,8 +553,17 @@ const Vault = (() => {
|
||||
.flat();
|
||||
|
||||
const cutoff = Date.now() - 180 * 86400000;
|
||||
// "Old" only penalises passwords that are ALSO weak or reused.
|
||||
// A strong, unique password that hasn't changed in 200 days is fine —
|
||||
// penalising it discourages good password hygiene.
|
||||
const weakOrReusedIds = new Set([
|
||||
...weak.map((i) => i.id),
|
||||
...reused.map((i) => i.id),
|
||||
]);
|
||||
const old = pwItems.filter(
|
||||
(i) => new Date(i.created_at).getTime() < cutoff,
|
||||
(i) =>
|
||||
new Date(i.created_at).getTime() < cutoff &&
|
||||
weakOrReusedIds.has(i.id),
|
||||
);
|
||||
|
||||
const total = pwItems.length;
|
||||
@@ -580,7 +590,7 @@ const Vault = (() => {
|
||||
<div class="sec-stats">
|
||||
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
|
||||
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
|
||||
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old (>180d)</span></div>
|
||||
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old & Weak</span></div>
|
||||
</div>`;
|
||||
|
||||
sectionsEl.innerHTML = "";
|
||||
@@ -631,7 +641,7 @@ const Vault = (() => {
|
||||
.filter(Boolean),
|
||||
"Same password used on multiple sites.",
|
||||
);
|
||||
makeSection("Old Passwords", "🕐", old, "Not changed in over 180 days.");
|
||||
makeSection("Old Passwords", "🕐", old, "Weak or reused passwords not changed in over 180 days.");
|
||||
|
||||
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
|
||||
// Run after the synchronous sections are rendered so the UI is immediately
|
||||
@@ -694,6 +704,234 @@ const Vault = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Import / Export View ─────────────────────────────────────────────────
|
||||
|
||||
// Holds parsed rows from a chosen file, ready for import.
|
||||
let _importRows = [];
|
||||
|
||||
/**
|
||||
* Parse a Chrome/Bitwarden/1Password CSV into a normalised array of plain objects.
|
||||
* Supported column sets:
|
||||
* Chrome: name, url, username, password
|
||||
* Bitwarden: name, login_uri, login_username, login_password, notes, type
|
||||
* 1Password: Title, Url, Username, Password, Notes
|
||||
*/
|
||||
function _parseCsvImport(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
if (lines.length < 2) return [];
|
||||
const headers = lines[0].split(',').map((h) => h.trim().replace(/^"|"$/g, '').toLowerCase());
|
||||
|
||||
// Detect format by inspecting header names.
|
||||
const col = (candidates) => {
|
||||
for (const c of candidates) {
|
||||
const idx = headers.indexOf(c);
|
||||
if (idx !== -1) return idx;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
const iName = col(['name', 'title']);
|
||||
const iUrl = col(['url', 'login_uri']);
|
||||
const iUser = col(['username', 'login_username']);
|
||||
const iPass = col(['password', 'login_password']);
|
||||
const iNotes = col(['notes', 'note']);
|
||||
|
||||
const rows = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
// Simple CSV split — handles quoted fields containing commas.
|
||||
const cells = [];
|
||||
let cur = '', inQuote = false;
|
||||
for (const ch of line + ',') {
|
||||
if (ch === '"') { inQuote = !inQuote; }
|
||||
else if (ch === ',' && !inQuote) { cells.push(cur.trim()); cur = ''; }
|
||||
else { cur += ch; }
|
||||
}
|
||||
const get = (idx) => (idx !== -1 && cells[idx] != null ? cells[idx].replace(/^"|"$/g, '') : '');
|
||||
const name = get(iName);
|
||||
const password = get(iPass);
|
||||
if (!name || !password) continue;
|
||||
rows.push({
|
||||
name,
|
||||
url: get(iUrl),
|
||||
username: get(iUser),
|
||||
password,
|
||||
notes: get(iNotes),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
let _importViewInitialised = false;
|
||||
|
||||
function loadImportExportView() {
|
||||
if (_importViewInitialised) return;
|
||||
_importViewInitialised = true;
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('btn-export-json')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/vault');
|
||||
if (!res) return;
|
||||
const items = await res.json();
|
||||
const payload = JSON.stringify({ version: 1, exported_at: new Date().toISOString(), items }, null, 2);
|
||||
const blob = new Blob([payload], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Encrypted vault exported.');
|
||||
console.log('[PassKeeper] Vault exported:', items.length, 'items');
|
||||
} catch (err) {
|
||||
showToast('Export failed: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-export-csv')?.addEventListener('click', async () => {
|
||||
const vaultKey = VaultSession.getKey();
|
||||
if (!vaultKey) { showUnlockOverlay(); return; }
|
||||
try {
|
||||
const res = await apiFetch('/api/vault');
|
||||
if (!res) return;
|
||||
const raw = await res.json();
|
||||
const decrypted = await Promise.all(raw.map(async (item) => {
|
||||
try {
|
||||
const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv);
|
||||
let displayName = item.name;
|
||||
if (item.enc_name && item.iv_name) {
|
||||
const n = await Crypto.decryptName(vaultKey, item.enc_name, item.iv_name);
|
||||
if (n) displayName = n;
|
||||
}
|
||||
return { name: displayName, ...plain };
|
||||
} catch { return null; }
|
||||
}));
|
||||
const csvRows = [['name','url','username','password','notes']];
|
||||
decrypted.filter(Boolean).forEach((r) => {
|
||||
if (r.password) {
|
||||
const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
csvRows.push([r.name, r.url, r.username, r.password, r.notes].map(esc));
|
||||
}
|
||||
});
|
||||
const csv = csvRows.map((r) => r.join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `passkeeper-export-${new Date().toISOString().slice(0,10)}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('CSV exported — store it securely.');
|
||||
console.log('[PassKeeper] CSV export:', decrypted.filter(Boolean).length, 'items');
|
||||
} catch (err) {
|
||||
showToast('CSV export failed: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Import ───────────────────────────────────────────────────────────────
|
||||
|
||||
const fileInput = document.getElementById('import-file-input');
|
||||
const fileNameEl = document.getElementById('import-file-name');
|
||||
const previewEl = document.getElementById('import-preview');
|
||||
const confirmBtn = document.getElementById('btn-import-confirm');
|
||||
const resultEl = document.getElementById('import-result');
|
||||
|
||||
fileInput?.addEventListener('change', async () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
fileNameEl.textContent = file.name;
|
||||
previewEl.classList.add('hidden');
|
||||
resultEl.classList.add('hidden');
|
||||
confirmBtn.disabled = true;
|
||||
_importRows = [];
|
||||
|
||||
const text = await file.text();
|
||||
const isJson = file.name.endsWith('.json');
|
||||
|
||||
try {
|
||||
if (isJson) {
|
||||
// PassKeeper encrypted JSON export.
|
||||
const parsed = JSON.parse(text);
|
||||
const items = parsed.items || (Array.isArray(parsed) ? parsed : []);
|
||||
if (!items.length) throw new Error('No items found in JSON file.');
|
||||
_importRows = items;
|
||||
previewEl.innerHTML = `<p>Found <strong>${items.length}</strong> encrypted item(s) ready to import.</p>
|
||||
<p class="import-note">These are already encrypted with your vault key — they will be imported as-is.</p>`;
|
||||
} else {
|
||||
// CSV — decrypt and re-encrypt with current vault key.
|
||||
const vaultKey = VaultSession.getKey();
|
||||
if (!vaultKey) { showUnlockOverlay(); return; }
|
||||
const rows = _parseCsvImport(text);
|
||||
if (!rows.length) throw new Error('No valid rows found. Check the CSV format.');
|
||||
_importRows = rows; // stored as plaintext — encrypted on confirm
|
||||
previewEl.innerHTML = `<p>Found <strong>${rows.length}</strong> password(s) to import.</p>
|
||||
<p class="import-note">Preview (first 5):</p>
|
||||
<ul class="import-preview-list">${rows.slice(0, 5).map((r) =>
|
||||
`<li><strong>${escHtml(r.name)}</strong> — ${escHtml(r.username || '(no username)')}</li>`
|
||||
).join('')}</ul>
|
||||
${rows.length > 5 ? `<p class="import-note">…and ${rows.length - 5} more.</p>` : ''}`;
|
||||
}
|
||||
previewEl.classList.remove('hidden');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.dataset.mode = isJson ? 'json' : 'csv';
|
||||
} catch (err) {
|
||||
previewEl.innerHTML = `<p class="import-error">⚠️ ${escHtml(err.message)}</p>`;
|
||||
previewEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
confirmBtn?.addEventListener('click', async () => {
|
||||
const vaultKey = VaultSession.getKey();
|
||||
if (!vaultKey) { showUnlockOverlay(); return; }
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Importing…';
|
||||
resultEl.classList.add('hidden');
|
||||
|
||||
try {
|
||||
let payload;
|
||||
if (confirmBtn.dataset.mode === 'json') {
|
||||
// Already-encrypted items — send directly.
|
||||
payload = _importRows;
|
||||
} else {
|
||||
// Plaintext CSV rows — encrypt each one now.
|
||||
payload = await Promise.all(_importRows.map(async (row) => {
|
||||
const plain = { url: row.url || '', username: row.username || '', password: row.password, notes: row.notes || '' };
|
||||
const { enc_data, iv } = await Crypto.encryptItem(vaultKey, plain);
|
||||
const { enc_name, iv_name } = await Crypto.encryptName(vaultKey, row.name);
|
||||
return { name: 'password', item_type: 'password', enc_data, iv, enc_name, iv_name };
|
||||
}));
|
||||
}
|
||||
|
||||
const res = await apiFetch('/api/vault/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res) return;
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Import failed.');
|
||||
|
||||
resultEl.innerHTML = `✅ Imported <strong>${data.imported}</strong> item(s)${data.skipped ? `, skipped ${data.skipped} malformed row(s)` : ''}.`;
|
||||
resultEl.className = 'import-result import-result-ok';
|
||||
resultEl.classList.remove('hidden');
|
||||
_importRows = [];
|
||||
confirmBtn.textContent = 'Import items';
|
||||
fileInput.value = '';
|
||||
fileNameEl.textContent = 'No file chosen';
|
||||
previewEl.classList.add('hidden');
|
||||
console.log('[PassKeeper] Import complete:', data.imported, 'imported,', data.skipped, 'skipped');
|
||||
await loadVault();
|
||||
} catch (err) {
|
||||
resultEl.innerHTML = `⚠️ ${escHtml(err.message)}`;
|
||||
resultEl.className = 'import-result import-result-err';
|
||||
resultEl.classList.remove('hidden');
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Import items';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sharing View ──────────────────────────────────────────────────────────
|
||||
|
||||
async function loadSharingView() {
|
||||
@@ -2673,6 +2911,9 @@ const Vault = (() => {
|
||||
document
|
||||
.getElementById("sidebar-emergency")
|
||||
?.addEventListener("click", () => switchView("emergency"));
|
||||
document
|
||||
.getElementById("sidebar-import-export")
|
||||
?.addEventListener("click", () => switchView("import-export"));
|
||||
document
|
||||
.getElementById("sidebar-generator")
|
||||
?.addEventListener("click", () => openPasswordGeneratorModal());
|
||||
|
||||
Reference in New Issue
Block a user