Files
minmal-file-cloud-email-pim…/frontend/src/views/PasswordsView.vue
T
Stefan Hacker 0150bf4b2f 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>
2026-04-11 17:42:00 +02:00

511 lines
20 KiB
Vue

<template>
<div class="view-container">
<div class="view-header">
<h2>Passwoerter</h2>
<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="Import" size="small" outlined @click="showImport = true" />
</div>
</div>
<div class="passwords-layout">
<aside class="folders-sidebar">
<div class="folder-item" :class="{ active: selectedFolderId === null }" @click="selectedFolderId = null; loadEntries()">
<i class="pi pi-key"></i>
<span>Alle</span>
</div>
<div v-for="folder in folders" :key="folder.id"
class="folder-item" :class="{ active: selectedFolderId === folder.id }"
@click="selectedFolderId = folder.id; loadEntries()">
<i :class="folder.icon || 'pi pi-folder'"></i>
<span>{{ folder.name }}</span>
<span v-if="folder.owner_name" class="shared-label">({{ folder.owner_name }})</span>
</div>
</aside>
<div class="entries-main">
<div class="search-bar">
<InputText v-model="searchQuery" placeholder="Passwoerter suchen..." fluid />
</div>
<div class="entries-list">
<div v-for="entry in filteredEntries" :key="entry.id"
class="entry-item" @click="openEntry(entry)">
<div class="entry-icon">
<i class="pi pi-key"></i>
</div>
<div class="entry-info">
<div class="entry-title">{{ decryptedEntries[entry.id]?.title || '(Verschluesselt)' }}</div>
<div class="entry-url">{{ decryptedEntries[entry.id]?.username || '' }}</div>
</div>
<div class="entry-actions">
<Button icon="pi pi-copy" text size="small" title="Passwort kopieren"
@click.stop="copyPassword(entry)" />
<Button v-if="decryptedEntries[entry.id]?.totp_secret" icon="pi pi-clock" text size="small"
title="TOTP Code" @click.stop="showTotp(entry)" />
</div>
</div>
<div v-if="!filteredEntries.length" class="empty">
<i class="pi pi-key" style="font-size: 2rem; color: var(--p-surface-400)"></i>
<p>Keine Eintraege</p>
</div>
</div>
</div>
</div>
<!-- Entry Dialog -->
<Dialog v-model:visible="showEntryDialog" :header="editingEntry ? 'Eintrag bearbeiten' : 'Neuer Eintrag'" modal :style="{ width: '500px' }">
<div class="field">
<label>Titel</label>
<InputText v-model="entryForm.title" fluid autofocus />
</div>
<div class="field">
<label>URL</label>
<InputText v-model="entryForm.url" fluid />
</div>
<div class="field">
<label>Benutzername</label>
<InputText v-model="entryForm.username" fluid />
</div>
<div class="field">
<label>Passwort</label>
<div class="password-field">
<Password v-model="entryForm.password" :feedback="false" toggle-mask fluid />
<Button icon="pi pi-sync" text size="small" title="Generieren" @click="generatePassword" />
</div>
</div>
<div class="field">
<label>TOTP Secret (optional)</label>
<InputText v-model="entryForm.totp_secret" fluid placeholder="otpauth:// oder Secret" />
</div>
<div class="field">
<label>Ordner</label>
<Select v-model="entryForm.folder_id" :options="folderOptions" optionLabel="name" optionValue="id" fluid placeholder="Kein Ordner" />
</div>
<div class="field">
<label>Kategorie</label>
<InputText v-model="entryForm.category" fluid />
</div>
<div class="field">
<label>Notizen</label>
<Textarea v-model="entryForm.notes" rows="3" fluid />
</div>
<template #footer>
<Button v-if="editingEntry" label="Loeschen" severity="danger" text @click="deleteEntry" />
<Button label="Abbrechen" text @click="showEntryDialog = false" />
<Button :label="editingEntry ? 'Speichern' : 'Erstellen'" @click="saveEntry" />
</template>
</Dialog>
<!-- New Folder -->
<Dialog v-model:visible="showNewFolder" header="Neuer Ordner" modal :style="{ width: '400px' }">
<div class="field">
<label>Name</label>
<InputText v-model="newFolderName" fluid autofocus @keyup.enter="createFolder" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewFolder = false" />
<Button label="Erstellen" @click="createFolder" />
</template>
</Dialog>
<!-- Import Dialog -->
<Dialog v-model:visible="showImport" header="Passwoerter importieren" modal :style="{ width: '520px' }">
<div class="field">
<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="doImport" :loading="importing" />
</template>
</Dialog>
<!-- TOTP Dialog -->
<Dialog v-model:visible="showTotpDialog" header="TOTP Code" modal :style="{ width: '300px' }">
<div class="totp-display">
<div class="totp-code">{{ totpCode }}</div>
<Button icon="pi pi-copy" text @click="copyToClipboard(totpCode)" />
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
const toast = useToast()
const auth = useAuthStore()
const folders = ref([])
const entries = ref([])
const decryptedEntries = ref({})
const selectedFolderId = ref(null)
const searchQuery = ref('')
const showEntryDialog = ref(false)
const editingEntry = ref(null)
const entryForm = ref({ title: '', url: '', username: '', password: '', totp_secret: '', folder_id: null, category: '', notes: '' })
const showNewFolder = ref(false)
const newFolderName = ref('')
const showImport = ref(false)
const importSource = ref('firefox')
const importPassword = ref('')
const importing = ref(false)
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('')
const folderOptions = computed(() => [{ id: null, name: '(Kein Ordner)' }, ...folders.value])
const filteredEntries = computed(() => {
if (!searchQuery.value) return entries.value
const q = searchQuery.value.toLowerCase()
return entries.value.filter(e => {
const d = decryptedEntries.value[e.id]
if (!d) return false
return (d.title || '').toLowerCase().includes(q) ||
(d.username || '').toLowerCase().includes(q) ||
(d.url || '').toLowerCase().includes(q)
})
})
// --- Crypto helpers using Web Crypto API ---
async function getMasterKey() {
const salt = auth.masterKeySalt
if (!salt) return null
const enc = new TextEncoder()
const keyMaterial = await crypto.subtle.importKey('raw', enc.encode(auth.user?.username + ':' + 'stored'), 'PBKDF2', false, ['deriveKey'])
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: Uint8Array.from(atob(salt), c => c.charCodeAt(0)), iterations: 600000, hash: 'SHA-256' },
keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']
)
}
async function encryptText(text, key) {
if (!text) return null
const iv = crypto.getRandomValues(new Uint8Array(12))
const enc = new TextEncoder()
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, enc.encode(text))
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv)
combined.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...combined))
}
async function decryptText(b64, key) {
if (!b64) return ''
try {
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
const iv = raw.slice(0, 12)
const ciphertext = raw.slice(12)
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
return new TextDecoder().decode(plaintext)
} catch { return '(Entschluesselung fehlgeschlagen)' }
}
async function decryptEntries() {
const key = await getMasterKey()
if (!key) return
const result = {}
for (const entry of entries.value) {
result[entry.id] = {
title: await decryptText(entry.title_encrypted, key),
url: await decryptText(entry.url_encrypted, key),
username: await decryptText(entry.username_encrypted, key),
password: await decryptText(entry.password_encrypted, key),
notes: await decryptText(entry.notes_encrypted, key),
totp_secret: await decryptText(entry.totp_secret_encrypted, key),
}
}
decryptedEntries.value = result
}
// --- Data loading ---
async function loadFolders() {
const res = await apiClient.get('/passwords/folders')
folders.value = res.data
}
async function loadEntries() {
const params = {}
if (selectedFolderId.value !== null) params.folder_id = selectedFolderId.value
const res = await apiClient.get('/passwords/entries', { params })
entries.value = res.data
await decryptEntries()
}
async function createFolder() {
if (!newFolderName.value.trim()) return
await apiClient.post('/passwords/folders', { name: newFolderName.value.trim() })
showNewFolder.value = false
newFolderName.value = ''
await loadFolders()
}
function openNewEntry() {
editingEntry.value = null
entryForm.value = { title: '', url: '', username: '', password: '', totp_secret: '', folder_id: selectedFolderId.value, category: '', notes: '' }
showEntryDialog.value = true
}
async function openEntry(entry) {
editingEntry.value = entry
const d = decryptedEntries.value[entry.id] || {}
entryForm.value = {
title: d.title || '',
url: d.url || '',
username: d.username || '',
password: d.password || '',
totp_secret: d.totp_secret || '',
folder_id: entry.folder_id,
category: entry.category || '',
notes: d.notes || '',
}
showEntryDialog.value = true
}
async function saveEntry() {
const key = await getMasterKey()
if (!key) { toast.add({ severity: 'error', summary: 'Kein Master-Key', life: 3000 }); return }
const iv = crypto.getRandomValues(new Uint8Array(12))
const ivB64 = btoa(String.fromCharCode(...iv))
const payload = {
title_encrypted: await encryptText(entryForm.value.title, key),
url_encrypted: await encryptText(entryForm.value.url, key),
username_encrypted: await encryptText(entryForm.value.username, key),
password_encrypted: await encryptText(entryForm.value.password, key),
notes_encrypted: await encryptText(entryForm.value.notes, key),
totp_secret_encrypted: await encryptText(entryForm.value.totp_secret, key),
iv: ivB64,
folder_id: entryForm.value.folder_id,
category: entryForm.value.category,
}
if (editingEntry.value) {
await apiClient.put(`/passwords/entries/${editingEntry.value.id}`, payload)
} else {
await apiClient.post('/passwords/entries', payload)
}
showEntryDialog.value = false
await loadEntries()
}
async function deleteEntry() {
if (!editingEntry.value) return
await apiClient.delete(`/passwords/entries/${editingEntry.value.id}`)
showEntryDialog.value = false
await loadEntries()
}
async function copyPassword(entry) {
const d = decryptedEntries.value[entry.id]
if (d?.password) {
await navigator.clipboard.writeText(d.password)
toast.add({ severity: 'info', summary: 'Passwort kopiert', life: 2000 })
}
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text)
toast.add({ severity: 'info', summary: 'Kopiert', life: 2000 })
}
function generatePassword() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+-='
const arr = new Uint8Array(20)
crypto.getRandomValues(arr)
entryForm.value.password = Array.from(arr, b => chars[b % chars.length]).join('')
}
async function showTotp(entry) {
const d = decryptedEntries.value[entry.id]
if (!d?.totp_secret) return
// Simple TOTP generation
try {
const secret = d.totp_secret.replace(/^otpauth:\/\/.*secret=/, '').replace(/&.*/, '')
totpCode.value = await generateTOTP(secret)
showTotpDialog.value = true
} catch {
toast.add({ severity: 'error', summary: 'TOTP-Fehler', life: 3000 })
}
}
async function generateTOTP(secret) {
// Base32 decode
const base32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const bits = secret.toUpperCase().replace(/=+$/, '').split('').map(c => {
const val = base32.indexOf(c)
return val >= 0 ? val.toString(2).padStart(5, '0') : ''
}).join('')
const bytes = new Uint8Array(bits.match(/.{8}/g).map(b => parseInt(b, 2)))
const time = Math.floor(Date.now() / 30000)
const timeBytes = new Uint8Array(8)
let t = time
for (let i = 7; i >= 0; i--) { timeBytes[i] = t & 0xff; t >>= 8 }
const key = await crypto.subtle.importKey('raw', bytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, timeBytes))
const offset = sig[sig.length - 1] & 0xf
const code = ((sig[offset] & 0x7f) << 24 | sig[offset + 1] << 16 | sig[offset + 2] << 8 | sig[offset + 3]) % 1000000
return code.toString().padStart(6, '0')
}
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', 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) {
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 = folderMap[entry.group_uuid || entry.group] || null
const iv = crypto.getRandomValues(new Uint8Array(12))
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: `${imported} Eintraege importiert`, life: 5000 })
await loadFolders()
await loadEntries()
} catch (err) {
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error || String(err), life: 5000 })
} finally {
importing.value = false
}
}
onMounted(async () => {
await loadFolders()
await loadEntries()
})
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; }
.passwords-layout { display: flex; gap: 1rem; }
.folders-sidebar { width: 220px; flex-shrink: 0; }
.folder-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
.folder-item:hover { background: var(--p-surface-100); }
.folder-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
.shared-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.entries-main { flex: 1; }
.search-bar { margin-bottom: 1rem; }
.entries-list { display: flex; flex-direction: column; gap: 2px; }
.entry-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem; background: var(--p-surface-0); border-radius: 6px; cursor: pointer; }
.entry-item:hover { background: var(--p-surface-100); }
.entry-icon { width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; background: var(--p-primary-50); border-radius: 8px; color: var(--p-primary-color); }
.entry-info { flex: 1; }
.entry-title { font-weight: 500; font-size: 0.9rem; }
.entry-url { font-size: 0.8rem; color: var(--p-text-muted-color); }
.entry-actions { display: flex; gap: 0; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.password-field { display: flex; gap: 0.5rem; align-items: flex-start; }
.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>