Account
diff --git a/js/app.js b/js/app.js
index cb267bf..f348984 100644
--- a/js/app.js
+++ b/js/app.js
@@ -2407,6 +2407,281 @@ function closeReauth(ok) {
}
}
+// ============================================================
+// IMPORT — JSON (native round-trip) + CSV (universal)
+// ============================================================
+//
+// Two supported input formats:
+// 1. Native JSON: the same shape produced by doExport() above
+// { version, exported_at, username, entries: [
+// { site, username, password, folder, tags, favorite, ... }
+// ]}
+// 2. CSV: with a header row. Column names are mapped heuristically so
+// exports from Bitwarden / KeePass / Chrome / 1Password generally
+// "just work" without manual column mapping.
+//
+// Each parsed entry is encrypted client-side with the vault key (same
+// flow as a single-entry add), then sent to /entries/bulk-import as one
+// transactional batch.
+
+// Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes
+// (""), commas inside quotes, and CRLF line endings. Returns an array of
+// arrays (rows × columns). No streaming — fine for the ~MB-scale imports
+// a password manager realistically deals with.
+function parseCSV(text) {
+ const rows = [];
+ let row = [], field = '', inQuotes = false;
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ if (inQuotes) {
+ if (c === '"') {
+ if (text[i + 1] === '"') { field += '"'; i++; } // escaped ""
+ else inQuotes = false;
+ } else field += c;
+ } else {
+ if (c === '"') inQuotes = true;
+ else if (c === ',') { row.push(field); field = ''; }
+ else if (c === '\n' || c === '\r') {
+ if (c === '\r' && text[i + 1] === '\n') i++; // CRLF
+ row.push(field); field = '';
+ if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);
+ row = [];
+ } else field += c;
+ }
+ }
+ // Flush trailing field/row (file without final newline)
+ if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
+ return rows;
+}
+
+// Header heuristics: pick the first matching column name (case-insensitive,
+// underscore/space-tolerant). Returns null if no candidate header matches.
+function findColumn(headers, candidates) {
+ const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, '');
+ const cand = candidates.map(norm);
+ for (let i = 0; i < headers.length; i++) {
+ if (cand.indexOf(norm(headers[i])) >= 0) return i;
+ }
+ return null;
+}
+
+// Parse a CSV text into an array of plaintext entries
+// ({ site, username, password, folder, tags, totp_secret }). Returns
+// { entries, skipped, columns } so the preview can show what was matched.
+function parseEntriesFromCSV(text) {
+ const rows = parseCSV(text);
+ if (rows.length < 2) {
+ throw new Error('CSV needs a header row and at least one data row');
+ }
+ const headers = rows[0];
+ // Candidate names per format observed in real exports:
+ // Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes
+ // KeePass CSV : Title, URL, Username, Password, Group, Notes
+ // Chrome/Edge : name, url, username, password
+ // 1Password CSV : Title, URL, Username, Password, Notes
+ // findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and
+ // 'loginuri' both match the same candidate.
+ const colSite = findColumn(headers, ['name', 'title', 'url', 'site', 'website', 'login_uri', 'login_url', 'web_site']);
+ const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']);
+ const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']);
+ const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']);
+ const colTags = findColumn(headers, ['tags', 'labels']);
+ const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']);
+ const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']);
+
+ if (colSite === null && colUser === null)
+ throw new Error('No recognizable site/url or username column in CSV header');
+ if (colPwd === null)
+ throw new Error('No recognizable password column in CSV header');
+
+ const entries = [];
+ let skipped = 0;
+ for (let i = 1; i < rows.length; i++) {
+ const r = rows[i];
+ const site = (colSite !== null ? r[colSite] : '').trim() ||
+ (colUser !== null ? r[colUser] : '').trim();
+ const pwd = (colPwd !== null ? r[colPwd] : '');
+ if (!site || !pwd) { skipped++; continue; }
+
+ // Tags: combine the tags column and any free-form notes into a
+ // comma-separated string. Notes often contain useful metadata we
+ // don't want to drop on the floor.
+ let tagsArr = [];
+ if (colTags !== null) {
+ String(r[colTags] || '').split(/[,;]/).forEach(t => {
+ t = t.trim();
+ if (t) tagsArr.push(t);
+ });
+ }
+ if (colNotes !== null) {
+ const n = String(r[colNotes] || '').trim();
+ if (n && n.length < 80) tagsArr.push(n); // long notes become noise as tags
+ }
+
+ // TOTP: support raw base32 OR full otpauth:// URI in the cell.
+ let totp = '';
+ if (colTotp !== null) {
+ const raw = String(r[colTotp] || '').trim();
+ totp = parseOtpAuthUri(raw) || raw;
+ }
+
+ entries.push({
+ site: site,
+ username: (colUser !== null ? r[colUser] : '').trim(),
+ password: pwd,
+ folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All',
+ tags: tagsArr.join(','),
+ totp_secret: totp,
+ });
+ }
+ return { entries, skipped, columns: {
+ site: colSite, username: colUser, password: colPwd,
+ folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp,
+ } };
+}
+
+// Parse a native JSON export. Forgiving: accepts both our own format and
+// a flat array of entry objects.
+function parseEntriesFromJSON(text) {
+ let data;
+ try { data = JSON.parse(text); }
+ catch (e) { throw new Error('Invalid JSON: ' + e.message); }
+ const raw = Array.isArray(data) ? data : (data.entries || []);
+ if (!Array.isArray(raw) || raw.length === 0)
+ throw new Error('No entries in JSON file');
+
+ const entries = [];
+ let skipped = 0;
+ for (const e of raw) {
+ if (!e || typeof e !== 'object') { skipped++; continue; }
+ const site = String(e.site || e.url || e.name || '').trim();
+ const pwd = String(e.password || '');
+ if (!site || !pwd) { skipped++; continue; }
+
+ const tagsVal = e.tags;
+ const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
+ : String(tagsVal || '');
+ entries.push({
+ site: site,
+ username: String(e.username || e.user || e.login || '').trim(),
+ password: pwd,
+ folder: String(e.folder || e.group || 'All').trim() || 'All',
+ tags: tagsStr,
+ totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(),
+ });
+ }
+ return { entries, skipped, columns: null }; // JSON: no column report
+}
+
+// Encrypt one parsed entry (plaintext password + optional TOTP) into the
+// shape the bulk-import endpoint expects. Reuses encryptPwd which already
+// generates a fresh IV per call.
+async function encryptImportEntry(plain) {
+ const pw = await encryptPwd(plain.password);
+ let totpEnc = '', totpIv = '';
+ if (plain.totp_secret) {
+ try {
+ base32Decode(plain.totp_secret); // validate before encrypting
+ const t = await encryptPwd(plain.totp_secret);
+ totpEnc = t.encrypted;
+ totpIv = t.iv;
+ } catch (e) {
+ // Bad TOTP secret in source file — keep the entry but drop the
+ // 2FA silently. The user can fix it later via the slide-over.
+ }
+ }
+ return {
+ site: plain.site,
+ username: plain.username || '',
+ encrypted_password: pw.encrypted,
+ iv: pw.iv,
+ folder: plain.folder || 'All',
+ tags: plain.tags || '',
+ totp_secret: totpEnc,
+ totp_iv: totpIv,
+ };
+}
+
+// Open a hidden file picker, route the result through the right parser,
+// show a preview confirmation, then bulk-encrypt + POST.
+async function doImport() {
+ const fileInput = el('input', {
+ type: 'file',
+ accept: '.json,.csv,application/json,text/csv',
+ style: 'display:none',
+ });
+ document.body.appendChild(fileInput);
+
+ fileInput.addEventListener('change', async () => {
+ const file = fileInput.files && fileInput.files[0];
+ fileInput.remove();
+ if (!file) return;
+
+ let text;
+ try { text = await file.text(); }
+ catch (e) { return toast('Cannot read file: ' + e.message, 'error'); }
+
+ const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('[');
+ let parsed;
+ try {
+ parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text);
+ } catch (e) {
+ return toast('Parse error: ' + e.message, 'error');
+ }
+
+ if (parsed.entries.length === 0) {
+ return toast('No valid entries found in file', 'warning');
+ }
+
+ // Build the preview message (innerHTML target → escape user data)
+ const esc = s => String(s).replace(/[&<>"]/g,
+ c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+ const parts = [];
+ parts.push('' + parsed.entries.length + ' entries detected in ' +
+ esc(file.name) + '');
+ if (parsed.skipped > 0)
+ parts.push('' + parsed.skipped +
+ ' rows skipped (missing site or password)');
+ const sample = parsed.entries.slice(0, 3).map(e =>
+ '• ' + esc(e.site || '?') +
+ (e.username ? ' (' + esc(e.username) + ')' : '')
+ ).join('
');
+ parts.push('
' + sample +
+ (parsed.entries.length > 3 ? '
…' : '') + '
');
+ parts.push('
Import now? This adds the entries to your existing vault.
');
+
+ const confirmed = await confirmDialog({
+ title: 'Import vault',
+ message: parts.join('
'),
+ okText: 'Import',
+ });
+ if (!confirmed) return;
+
+ // Encrypt all entries client-side, then POST as one transaction.
+ toast('Encrypting ' + parsed.entries.length + ' entries…');
+ const encrypted = [];
+ for (const e of parsed.entries) {
+ encrypted.push(await encryptImportEntry(e));
+ }
+
+ try {
+ const r = await api('/entries/bulk-import', {
+ method: 'POST',
+ headers: authHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ entries: encrypted }),
+ });
+ toast('Imported ' + r.imported + ' entries');
+ await loadEntries();
+ render();
+ if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
+ } catch (err) {
+ toast('Import failed: ' + err.message, 'error');
+ }
+ });
+
+ fileInput.click();
+}
+
async function doExport() {
const pwd = await askReauth('Enter your master password to export the vault as JSON. The file will be UNENCRYPTED.');
if (!pwd) return;
@@ -2712,6 +2987,7 @@ async function init() {
// Sidebar Generator tool
$('#sidebarGenBtn').addEventListener('click', () => openGen('standalone'));
$('#sidebarExportBtn').addEventListener('click', doExport);
+ $('#sidebarImportBtn').addEventListener('click', doImport);
// Idle warning "Stay unlocked"
$('#idleStayBtn').addEventListener('click', resetAutoLock);
@@ -2757,6 +3033,7 @@ async function init() {
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning');
});
$('#exportBtn').addEventListener('click', doExport);
+ $('#importBtn').addEventListener('click', doImport);
// Re-auth modal
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });