04/26 Enhanced functionalities

This commit is contained in:
2026-04-26 10:10:29 -04:00
parent 194601e461
commit 517f4dab61
10 changed files with 1114 additions and 1046 deletions
+81
View File
@@ -137,3 +137,84 @@ def delete_item(item_id):
)
db.session.commit()
return jsonify({'message': 'Item deleted'}), 200
# ── Import / Export ───────────────────────────────────────────────────────────
@vault_bp.route('/export', methods=['GET'])
@require_jwt
def export_items():
"""
Return all vault items as an encrypted JSON export payload.
The client receives raw encrypted blobs and wraps them in a
signed JSON envelope — the server never sees plaintext.
Each object: { id, name, item_type, folder_id, enc_data, iv, enc_name, iv_name,
created_at, updated_at }
"""
items = VaultItem.query.filter_by(user_id=g.current_user_id).order_by(
VaultItem.name.asc()
).all()
AuditLog.log(
user_id=g.current_user_id,
action='vault_item.export',
resource_type='vault_item',
resource_id=None,
detail=f'Exported {len(items)} vault item(s)',
ip_address=_client_ip(),
)
db.session.commit()
return jsonify([item.to_dict() for item in items]), 200
@vault_bp.route('/import', methods=['POST'])
@require_jwt
def import_items():
"""
Bulk-import pre-encrypted vault items.
Accepts a JSON array of objects matching the POST /api/vault schema.
Items are imported as-is — the server stores encrypted blobs only.
Duplicate detection is left to the client.
Returns { imported: N, skipped: N } where skipped = malformed rows.
"""
data = request.get_json(silent=True) or []
if not isinstance(data, list):
return jsonify({'error': 'Request body must be a JSON array'}), 400
imported = 0
skipped = 0
for row in data:
name = (row.get('name') or '').strip()
item_type = row.get('item_type', 'password')
enc_data = row.get('enc_data', '')
iv = row.get('iv', '')
if not name or item_type not in VALID_TYPES or not enc_data or not iv:
skipped += 1
continue
folder_id = row.get('folder_id')
enc_name = row.get('enc_name') or None
iv_name = row.get('iv_name') or None
item = VaultItem(
user_id=g.current_user_id,
folder_id=folder_id,
item_type=item_type,
name=name,
enc_data=enc_data,
iv=iv,
enc_name=enc_name,
iv_name=iv_name,
)
db.session.add(item)
imported += 1
if imported:
db.session.flush()
AuditLog.log(
user_id=g.current_user_id,
action='vault_item.import',
resource_type='vault_item',
resource_id=None,
detail=f'Imported {imported} item(s), skipped {skipped} malformed row(s)',
ip_address=_client_ip(),
)
db.session.commit()
return jsonify({'imported': imported, 'skipped': skipped}), 200
+91
View File
@@ -2032,3 +2032,94 @@ html.sidebar-open {
font-family: monospace;
letter-spacing: 0.05em;
}
/* ── Import / Export view ──────────────────────────────────────────────────── */
.import-export-section {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 24px;
margin-bottom: 20px;
max-width: 640px;
}
.import-export-heading {
font-size: 15px;
font-weight: 700;
color: #111827;
margin: 0 0 8px;
}
.import-export-desc {
font-size: 13px;
color: #6b7280;
margin: 0 0 16px;
line-height: 1.55;
}
.import-export-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.import-file-label {
display: inline-flex;
align-items: center;
cursor: pointer;
padding: 7px 14px;
border-radius: 7px;
font-size: 13px;
}
.import-file-name {
font-size: 13px;
color: #6b7280;
}
.import-preview {
margin: 14px 0 0;
font-size: 13px;
color: #374151;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 7px;
padding: 12px 14px;
line-height: 1.6;
}
.import-preview-list {
margin: 6px 0 0 16px;
padding: 0;
}
.import-note {
color: #6b7280;
font-size: 12px;
margin: 4px 0 0;
}
.import-error {
color: #dc2626;
}
.import-result {
margin-top: 14px;
font-size: 13px;
padding: 10px 14px;
border-radius: 7px;
}
.import-result-ok {
background: #f0fdf4;
color: #15803d;
border: 1px solid #bbf7d0;
}
.import-result-err {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
+245 -4
View File
@@ -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 (&gt;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 &amp; 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());
+57
View File
@@ -122,6 +122,15 @@
<span class="sidebar-icon">🚨</span>
<span class="sidebar-label">Emergency Access</span>
</li>
<li
class="sidebar-item"
id="sidebar-import-export"
data-view="import-export"
data-tooltip="Import / Export"
>
<span class="sidebar-icon">↕️</span>
<span class="sidebar-label">Import / Export</span>
</li>
<li
class="sidebar-item"
id="sidebar-generator"
@@ -264,6 +273,54 @@
</div>
</div>
<!-- Import / Export view -->
<div id="view-import-export" class="hidden">
<header class="vault-header">
<h2 class="vault-title">Import / Export</h2>
</header>
<div class="panel-body">
<!-- Export -->
<section class="import-export-section">
<h3 class="import-export-heading">Export Vault</h3>
<p class="import-export-desc">
Download an encrypted backup of your entire vault. The file contains
AES-256-GCM ciphertext — your master password is required to read it.
Keep it safe.
</p>
<div class="import-export-row">
<button class="btn-primary" id="btn-export-json">Download encrypted JSON</button>
<button class="btn-secondary" id="btn-export-csv">Download CSV (plaintext — handle with care)</button>
</div>
</section>
<!-- Import -->
<section class="import-export-section">
<h3 class="import-export-heading">Import</h3>
<p class="import-export-desc">
Import from a PassKeeper encrypted JSON export, or from a CSV file exported
by Chrome, Bitwarden, or 1Password. Duplicates are not checked — review
your vault after importing.
</p>
<div class="import-export-row">
<label class="btn-secondary import-file-label" for="import-file-input">
Choose file&hellip;
</label>
<input type="file" id="import-file-input" accept=".json,.csv" class="hidden" />
<span id="import-file-name" class="import-file-name">No file chosen</span>
</div>
<div id="import-preview" class="import-preview hidden"></div>
<div class="import-export-row">
<button class="btn-primary" id="btn-import-confirm" disabled>
Import items
</button>
</div>
<div id="import-result" class="import-result hidden"></div>
</section>
</div>
</div>
<!-- Emergency Access view -->
<div id="view-emergency" class="hidden">
<header class="vault-header">