feat: Passwort-Import aus Firefox, Chrome, Bitwarden und generischem CSV

- Firefox-Import: CSV aus Einstellungen > Passwoerter > Exportieren
  Domain wird automatisch als Titel extrahiert
- Generischer CSV-Import: Erkennt automatisch Spaltennamen aus
  Chrome, Bitwarden, 1Password und anderen Managern
- KeePass-Import bleibt bestehen
- Einheitlicher Import-Dialog mit Quellen-Auswahl (Dropdown)
- Jede Quelle zeigt eine kurze Anleitung an
- Alle Eintraege werden clientseitig verschluesselt vor dem Speichern
- Backend: /passwords/import/firefox und /passwords/import/csv Endpunkte

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 17:42:00 +02:00
parent 7220a2ef75
commit 0150bf4b2f
2 changed files with 238 additions and 28 deletions
+85 -28
View File
@@ -5,7 +5,7 @@
<div class="header-actions">
<Button icon="pi pi-folder-plus" label="Neuer Ordner" size="small" outlined @click="showNewFolder = true" />
<Button icon="pi pi-plus" label="Neuer Eintrag" size="small" @click="openNewEntry" />
<Button icon="pi pi-upload" label="KeePass Import" size="small" outlined @click="showImport = true" />
<Button icon="pi pi-upload" label="Import" size="small" outlined @click="showImport = true" />
</div>
</div>
@@ -110,20 +110,38 @@
</template>
</Dialog>
<!-- KeePass Import -->
<Dialog v-model:visible="showImport" header="KeePass Import" modal :style="{ width: '500px' }">
<p>Waehle eine .kdbx-Datei und gib das KeePass-Passwort ein.</p>
<!-- Import Dialog -->
<Dialog v-model:visible="showImport" header="Passwoerter importieren" modal :style="{ width: '520px' }">
<div class="field">
<label>KDBX-Datei</label>
<input ref="kdbxInput" type="file" accept=".kdbx" />
<label>Quelle</label>
<Select v-model="importSource" :options="importSources" optionLabel="label" optionValue="value" fluid />
</div>
<div class="import-hint">
<template v-if="importSource === 'keepass'">
<p>Waehle eine <strong>.kdbx</strong>-Datei und gib das KeePass-Master-Passwort ein.</p>
</template>
<template v-else-if="importSource === 'firefox'">
<p>Firefox: <strong>Einstellungen > Passwoerter > > Passwoerter exportieren</strong><br/>Waehle die exportierte CSV-Datei aus.</p>
</template>
<template v-else-if="importSource === 'csv'">
<p>CSV mit Spalten wie <em>title/name, url, username, password, notes</em>.<br/>Funktioniert mit Chrome, Bitwarden, 1Password und anderen Managern.</p>
</template>
</div>
<div class="field">
<label>Datei</label>
<input ref="importFileInput" type="file" :accept="importAccept" />
</div>
<div v-if="importSource === 'keepass'" class="field">
<label>KeePass-Passwort</label>
<Password v-model="importPassword" :feedback="false" toggle-mask fluid />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showImport = false" />
<Button label="Importieren" @click="importKeePass" :loading="importing" />
<Button label="Importieren" @click="doImport" :loading="importing" />
</template>
</Dialog>
@@ -165,9 +183,19 @@ const entryForm = ref({ title: '', url: '', username: '', password: '', totp_sec
const showNewFolder = ref(false)
const newFolderName = ref('')
const showImport = ref(false)
const importSource = ref('firefox')
const importPassword = ref('')
const importing = ref(false)
const kdbxInput = ref(null)
const importFileInput = ref(null)
const importSources = [
{ label: 'Firefox (CSV)', value: 'firefox' },
{ label: 'KeePass (.kdbx)', value: 'keepass' },
{ label: 'CSV (Chrome, Bitwarden, etc.)', value: 'csv' },
]
const importAccept = computed(() => {
if (importSource.value === 'keepass') return '.kdbx'
return '.csv'
})
const showTotpDialog = ref(false)
const totpCode = ref('')
@@ -369,49 +397,76 @@ async function generateTOTP(secret) {
return code.toString().padStart(6, '0')
}
async function importKeePass() {
if (!kdbxInput.value?.files?.length || !importPassword.value) return
async function doImport() {
if (!importFileInput.value?.files?.length) return
if (importSource.value === 'keepass' && !importPassword.value) {
toast.add({ severity: 'warn', summary: 'KeePass-Passwort erforderlich', life: 3000 })
return
}
importing.value = true
try {
// Step 1: Upload file to backend for parsing
const formData = new FormData()
formData.append('file', kdbxInput.value.files[0])
formData.append('password', importPassword.value)
const res = await apiClient.post('/passwords/import/keepass', formData, {
formData.append('file', importFileInput.value.files[0])
if (importSource.value === 'keepass') {
formData.append('password', importPassword.value)
}
const endpoint = {
keepass: '/passwords/import/keepass',
firefox: '/passwords/import/firefox',
csv: '/passwords/import/csv',
}[importSource.value]
const res = await apiClient.post(endpoint, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (!res.data.count) {
toast.add({ severity: 'warn', summary: 'Keine Eintraege gefunden', life: 3000 })
return
}
// Step 2: Encrypt and store entries client-side
const key = await getMasterKey()
if (!key) return
// Create folders
const folderMap = {}
for (const group of res.data.groups) {
const folder = await apiClient.post('/passwords/folders', { name: group.name })
folderMap[group.uuid] = folder.data.id
try {
const folder = await apiClient.post('/passwords/folders', { name: group.name })
folderMap[group.uuid || group.name] = folder.data.id
} catch { /* folder may already exist */ }
}
// Import entries
let imported = 0
for (const entry of res.data.entries) {
const folderId = entry.group_uuid ? folderMap[entry.group_uuid] : null
const folderId = folderMap[entry.group_uuid || entry.group] || null
const iv = crypto.getRandomValues(new Uint8Array(12))
await apiClient.post('/passwords/entries', {
title_encrypted: await encryptText(entry.title, key),
url_encrypted: await encryptText(entry.url, key),
username_encrypted: await encryptText(entry.username, key),
password_encrypted: await encryptText(entry.password, key),
notes_encrypted: await encryptText(entry.notes, key),
totp_secret_encrypted: await encryptText(entry.totp, key),
iv: btoa(String.fromCharCode(...iv)),
folder_id: folderId,
})
try {
await apiClient.post('/passwords/entries', {
title_encrypted: await encryptText(entry.title, key),
url_encrypted: await encryptText(entry.url, key),
username_encrypted: await encryptText(entry.username, key),
password_encrypted: await encryptText(entry.password, key),
notes_encrypted: await encryptText(entry.notes, key),
totp_secret_encrypted: await encryptText(entry.totp, key),
iv: btoa(String.fromCharCode(...iv)),
folder_id: folderId,
})
imported++
} catch { /* skip duplicates */ }
}
showImport.value = false
toast.add({ severity: 'success', summary: `${res.data.count} Eintraege importiert`, life: 5000 })
toast.add({ severity: 'success', summary: `${imported} Eintraege importiert`, life: 5000 })
await loadFolders()
await loadEntries()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error, life: 5000 })
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error || String(err), life: 5000 })
} finally {
importing.value = false
}
@@ -450,4 +505,6 @@ onMounted(async () => {
.empty { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 3rem; color: var(--p-text-muted-color); }
.totp-display { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 1rem; }
.totp-code { font-size: 2rem; font-weight: 700; letter-spacing: 0.25em; font-family: monospace; }
.import-hint { background: var(--p-surface-50); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
.import-hint p { margin: 0; line-height: 1.5; }
</style>