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:
parent
7220a2ef75
commit
0150bf4b2f
|
|
@ -359,3 +359,156 @@ def import_keepass():
|
|||
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Import fehlgeschlagen: {str(e)}'}), 400
|
||||
|
||||
|
||||
# --- Firefox CSV Import ---
|
||||
|
||||
@api_bp.route('/passwords/import/firefox', methods=['POST'])
|
||||
@token_required
|
||||
def import_firefox():
|
||||
"""Import passwords from Firefox CSV export.
|
||||
|
||||
Firefox: Einstellungen > Passwoerter > ... > Passwoerter exportieren
|
||||
CSV columns: url, username, password, httpRealm, formActionOrigin, guid, timeCreated, timeLastUsed, timePasswordChanged
|
||||
"""
|
||||
user = request.current_user
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'Keine Datei gesendet'}), 400
|
||||
|
||||
csv_file = request.files['file']
|
||||
|
||||
try:
|
||||
import csv
|
||||
import io
|
||||
|
||||
content = csv_file.read().decode('utf-8')
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
|
||||
entries = []
|
||||
for row in reader:
|
||||
url = row.get('url', row.get('origin', '')).strip()
|
||||
username = row.get('username', '').strip()
|
||||
password = row.get('password', '').strip()
|
||||
|
||||
if not url and not username and not password:
|
||||
continue
|
||||
|
||||
# Extract domain as title
|
||||
title = url
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
title = parsed.netloc or parsed.path or url
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entries.append({
|
||||
'title': title,
|
||||
'url': url,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'notes': '',
|
||||
'totp': '',
|
||||
'group': 'Firefox Import',
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'entries': entries,
|
||||
'groups': [{'name': 'Firefox Import', 'uuid': 'firefox-import', 'parent_uuid': None}] if entries else [],
|
||||
'count': len(entries),
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'CSV-Import fehlgeschlagen: {str(e)}'}), 400
|
||||
|
||||
|
||||
# --- Generic CSV Import (Chrome, Bitwarden, etc.) ---
|
||||
|
||||
@api_bp.route('/passwords/import/csv', methods=['POST'])
|
||||
@token_required
|
||||
def import_generic_csv():
|
||||
"""Import passwords from generic CSV (Chrome, Bitwarden, 1Password, etc.)
|
||||
|
||||
Tries to auto-detect columns: name/title, url, username, password, notes
|
||||
"""
|
||||
user = request.current_user
|
||||
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'Keine Datei gesendet'}), 400
|
||||
|
||||
csv_file = request.files['file']
|
||||
|
||||
try:
|
||||
import csv
|
||||
import io
|
||||
|
||||
content = csv_file.read().decode('utf-8')
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
|
||||
# Map common column names
|
||||
col_map = {
|
||||
'title': ['title', 'name', 'titel', 'bezeichnung', 'entry'],
|
||||
'url': ['url', 'uri', 'website', 'login_uri', 'origin'],
|
||||
'username': ['username', 'user', 'login', 'benutzername', 'email', 'login_username'],
|
||||
'password': ['password', 'passwort', 'pass', 'login_password'],
|
||||
'notes': ['notes', 'note', 'notizen', 'comment', 'comments', 'extra'],
|
||||
'totp': ['totp', 'otp', 'login_totp', '2fa'],
|
||||
'group': ['group', 'folder', 'ordner', 'category', 'kategorie', 'type'],
|
||||
}
|
||||
|
||||
def find_col(fieldnames, target):
|
||||
for col_name in col_map.get(target, []):
|
||||
for fn in fieldnames:
|
||||
if fn.lower().strip() == col_name:
|
||||
return fn
|
||||
return None
|
||||
|
||||
fieldnames = reader.fieldnames or []
|
||||
mapping = {target: find_col(fieldnames, target) for target in col_map}
|
||||
|
||||
entries = []
|
||||
groups_set = set()
|
||||
for row in reader:
|
||||
def get(target):
|
||||
col = mapping.get(target)
|
||||
return row.get(col, '').strip() if col else ''
|
||||
|
||||
title = get('title')
|
||||
url = get('url')
|
||||
username = get('username')
|
||||
password = get('password')
|
||||
|
||||
if not title and not url and not username and not password:
|
||||
continue
|
||||
|
||||
if not title and url:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
title = urlparse(url).netloc or url
|
||||
except Exception:
|
||||
title = url
|
||||
|
||||
group = get('group') or 'CSV Import'
|
||||
groups_set.add(group)
|
||||
|
||||
entries.append({
|
||||
'title': title or '(Unbenannt)',
|
||||
'url': url,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'notes': get('notes'),
|
||||
'totp': get('totp'),
|
||||
'group': group,
|
||||
})
|
||||
|
||||
groups = [{'name': g, 'uuid': g, 'parent_uuid': None} for g in groups_set]
|
||||
|
||||
return jsonify({
|
||||
'entries': entries,
|
||||
'groups': groups,
|
||||
'count': len(entries),
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'CSV-Import fehlgeschlagen: {str(e)}'}), 400
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue