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:
@@ -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;
|
||||
|
||||
182
extension/src/vault/components/import-panel.ts
Normal file
182
extension/src/vault/components/import-panel.ts
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export function teardown(): void {
|
||||
preview = null;
|
||||
mode = 'idle';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user