feat(ext/vault): Import panel — LastPass CSV

New vault.html#import panel with a file picker, parse-preview
("N logins, M notes, K skipped — proceed?"), confirm/cancel
buttons, inline progress, and a post-import warnings list. The
popup's settings-vault view links to it via a new
"LastPass CSV →" button next to "Backup & restore →".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-30 18:43:35 -04:00
parent da6f08fa35
commit 66981588e7
3 changed files with 197 additions and 1 deletions

View File

@@ -165,6 +165,13 @@ export function renderVaultSettings(app: HTMLElement): void {
</div>
</div>
<div class="settings-section">
<div class="settings-section__title">import</div>
<div class="settings-row">
<button class="btn" id="open-import">LastPass CSV →</button>
</div>
</div>
<div class="settings-footer">
<button class="btn" id="discard-btn">discard</button>
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
@@ -195,6 +202,7 @@ export function renderVaultSettings(app: HTMLElement): void {
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup'));
document.getElementById('open-import')?.addEventListener('click', () => openVaultTab('import'));
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
if (!pendingSettings) return;

View File

@@ -0,0 +1,182 @@
import { sendMessage } from '../../shared/state';
import type { Item } from '../../shared/types';
type ViewMode = 'idle' | 'parsing' | 'preview' | 'committing' | 'done';
interface PreviewState {
items: Item[];
warnings: Array<{ row: number; title?: string; message: string }>;
loginCount: number;
noteCount: number;
}
let mode: ViewMode = 'idle';
let preview: PreviewState | null = null;
export function renderImportPanel(app: HTMLElement): void {
app.innerHTML = `
<div class="panel">
<h1>Import</h1>
<section class="card" id="import-card">
<h2>LastPass CSV</h2>
<p>Pick the CSV exported from LastPass
(<code>More options → Advanced → Export</code>).
Items are added to the unlocked vault with fresh IDs.
Title collisions are kept — no dedup. Failed rows are
skipped and reported below.</p>
<input type="file" id="lp-file" accept=".csv,text/csv">
<div id="lp-preview" class="hidden">
<p id="lp-preview-text"></p>
<button id="lp-confirm-btn">Import…</button>
<button id="lp-cancel-btn">Cancel</button>
</div>
<div id="lp-progress" class="hidden">
<p id="lp-progress-text"></p>
</div>
<div id="lp-warnings" class="hidden">
<h3>Warnings</h3>
<ul id="lp-warning-list"></ul>
</div>
</section>
</div>
`;
wireImport(app);
}
function wireImport(scope: HTMLElement): void {
const fileEl = scope.querySelector('#lp-file') as HTMLInputElement;
const previewEl = scope.querySelector('#lp-preview') as HTMLElement;
const previewTx = scope.querySelector('#lp-preview-text') as HTMLElement;
const confirmBt = scope.querySelector('#lp-confirm-btn') as HTMLButtonElement;
const cancelBt = scope.querySelector('#lp-cancel-btn') as HTMLButtonElement;
const progressE = scope.querySelector('#lp-progress') as HTMLElement;
const progressT = scope.querySelector('#lp-progress-text') as HTMLElement;
const warningsE = scope.querySelector('#lp-warnings') as HTMLElement;
const warningsL = scope.querySelector('#lp-warning-list') as HTMLElement;
fileEl.addEventListener('change', async () => {
const file = fileEl.files?.[0];
if (!file) return;
if (mode !== 'idle' && mode !== 'done') return;
resetPanels(previewEl, progressE, warningsE);
mode = 'parsing';
progressE.classList.remove('hidden');
progressT.textContent = 'Parsing…';
try {
const bytes = await file.arrayBuffer();
const resp = await sendMessage({ type: 'parse_lastpass_csv', bytes });
progressE.classList.add('hidden');
if (!resp.ok) {
previewTx.textContent = `Failed to parse: ${resp.error}`;
previewEl.classList.remove('hidden');
confirmBt.classList.add('hidden');
cancelBt.classList.remove('hidden');
mode = 'idle';
return;
}
const data = (resp as { ok: true; data: PreviewState }).data;
preview = aggregate(data);
previewTx.textContent =
`${preview.loginCount} logins, ${preview.noteCount} notes, ` +
`${preview.warnings.length} skipped/warn — proceed?`;
previewEl.classList.remove('hidden');
confirmBt.classList.remove('hidden');
cancelBt.classList.remove('hidden');
confirmBt.disabled = preview.items.length === 0;
mode = 'preview';
} catch (e) {
progressE.classList.add('hidden');
previewTx.textContent = `Failed: ${(e as Error).message}`;
previewEl.classList.remove('hidden');
mode = 'idle';
}
});
cancelBt.addEventListener('click', () => {
preview = null;
fileEl.value = '';
resetPanels(previewEl, progressE, warningsE);
mode = 'idle';
});
confirmBt.addEventListener('click', async () => {
if (mode !== 'preview' || !preview) return;
mode = 'committing';
confirmBt.disabled = true;
cancelBt.disabled = true;
progressE.classList.remove('hidden');
progressT.textContent = `Importing ${preview.items.length} items…`;
try {
const resp = await sendMessage({
type: 'import_lastpass_commit',
items: preview.items,
});
progressE.classList.add('hidden');
if (!resp.ok) {
previewTx.textContent = `Import failed: ${resp.error}`;
mode = 'idle';
confirmBt.disabled = false;
cancelBt.disabled = false;
return;
}
const data = (resp as { ok: true; data: { summary: { itemCount: number } } }).data;
previewTx.textContent =
`Imported ${data.summary.itemCount} items. Reload the sidebar to see them.`;
confirmBt.classList.add('hidden');
cancelBt.textContent = 'Done';
if (preview.warnings.length > 0) {
warningsL.innerHTML = preview.warnings
.map((w) => {
const head = w.title ? `row ${w.row} (${escapeText(w.title)})` : `row ${w.row}`;
return `<li>${head}: ${escapeText(w.message)}</li>`;
})
.join('');
warningsE.classList.remove('hidden');
}
mode = 'done';
} catch (e) {
progressE.classList.add('hidden');
previewTx.textContent = `Failed: ${(e as Error).message}`;
confirmBt.disabled = false;
cancelBt.disabled = false;
mode = 'idle';
}
});
}
function aggregate(data: PreviewState): PreviewState {
let loginCount = 0;
let noteCount = 0;
for (const item of data.items) {
if (item.type === 'login') loginCount += 1;
else if (item.type === 'secure_note') noteCount += 1;
}
return { ...data, loginCount, noteCount };
}
function resetPanels(preview: HTMLElement, progress: HTMLElement, warnings: HTMLElement): void {
preview.classList.add('hidden');
progress.classList.add('hidden');
warnings.classList.add('hidden');
}
function escapeText(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function teardown(): void {
preview = null;
mode = 'idle';
}

View File

@@ -17,6 +17,7 @@ import { renderSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
// ---------------------------------------------------------------------------
// Helpers
@@ -67,7 +68,7 @@ function typeLabel(t: ItemType): string {
// Hash routing
// ---------------------------------------------------------------------------
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup';
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup' | 'import';
interface HashRoute {
view: VaultView;
@@ -94,6 +95,7 @@ function parseHash(): HashRoute {
case 'settings-vault':
case 'field-history':
case 'backup':
case 'import':
return { view };
default:
return { view: 'list' };
@@ -421,6 +423,7 @@ function teardownPaneComponents(): void {
teardownDevices();
teardownFieldHistory();
teardownBackup();
teardownImport();
}
function renderPane(): void {
@@ -470,6 +473,9 @@ function renderPane(): void {
case 'backup':
renderBackupPanel(pane);
break;
case 'import':
renderImportPanel(pane);
break;
default:
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';