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>
|
</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">
|
<div class="settings-footer">
|
||||||
<button class="btn" id="discard-btn">discard</button>
|
<button class="btn" id="discard-btn">discard</button>
|
||||||
<button class="btn btn-primary" id="save-btn" disabled>save changes</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('back-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
|
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
|
||||||
document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup'));
|
document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup'));
|
||||||
|
document.getElementById('open-import')?.addEventListener('click', () => openVaultTab('import'));
|
||||||
|
|
||||||
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
|
||||||
if (!pendingSettings) return;
|
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 { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||||
|
import { renderImportPanel, teardown as teardownImport } from './components/import-panel';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
@@ -67,7 +68,7 @@ function typeLabel(t: ItemType): string {
|
|||||||
// Hash routing
|
// 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 {
|
interface HashRoute {
|
||||||
view: VaultView;
|
view: VaultView;
|
||||||
@@ -94,6 +95,7 @@ function parseHash(): HashRoute {
|
|||||||
case 'settings-vault':
|
case 'settings-vault':
|
||||||
case 'field-history':
|
case 'field-history':
|
||||||
case 'backup':
|
case 'backup':
|
||||||
|
case 'import':
|
||||||
return { view };
|
return { view };
|
||||||
default:
|
default:
|
||||||
return { view: 'list' };
|
return { view: 'list' };
|
||||||
@@ -421,6 +423,7 @@ function teardownPaneComponents(): void {
|
|||||||
teardownDevices();
|
teardownDevices();
|
||||||
teardownFieldHistory();
|
teardownFieldHistory();
|
||||||
teardownBackup();
|
teardownBackup();
|
||||||
|
teardownImport();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPane(): void {
|
function renderPane(): void {
|
||||||
@@ -470,6 +473,9 @@ function renderPane(): void {
|
|||||||
case 'backup':
|
case 'backup':
|
||||||
renderBackupPanel(pane);
|
renderBackupPanel(pane);
|
||||||
break;
|
break;
|
||||||
|
case 'import':
|
||||||
|
renderImportPanel(pane);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
pane.className = 'vault-pane vault-pane--empty';
|
pane.className = 'vault-pane vault-pane--empty';
|
||||||
pane.innerHTML = 'select an item';
|
pane.innerHTML = 'select an item';
|
||||||
|
|||||||
Reference in New Issue
Block a user