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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user