feat: Mini-Cloud Plattform - komplette Implementierung Phase 0-8
Selbstgehostete Web-Cloud mit Dateiverwaltung, Kalender, Kontakte, Email-Webclient, Office-Viewer und Passwort-Manager. Backend (Flask/Python): - JWT-Auth mit Access/Refresh Tokens, Benutzerverwaltung - Dateien: Upload/Download, Ordner, Berechtigungen, Share-Links - Kalender: CRUD, Teilen, iCal-Export, CalDAV well-known URLs - Kontakte: Adressbuecher, vCard-Export, Teilen - Email: IMAP/SMTP-Proxy, Multi-Account - Office-Viewer: DOCX/XLSX/PPTX/PDF Vorschau - Passwort-Manager: AES-256-GCM clientseitig, KeePass-Import - Sync-API fuer Desktop/Mobile-Clients - SQLite mit WAL-Modus Frontend (Vue 3 + PrimeVue): - Datei-Explorer mit Breadcrumbs und Share-Dialogen - Monatskalender mit Event-Verwaltung - Kontaktliste mit Adressbuch-Sidebar - Email-Client mit 3-Spalten-Layout - Passwort-Manager mit TOTP und Passwort-Generator - Admin-Panel, Settings, oeffentliche Share-Seite Docker: Multi-Stage Build, Bind Mounts (keine Volumes) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
<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="KeePass 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>
|
||||
|
||||
<!-- 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>
|
||||
<div class="field">
|
||||
<label>KDBX-Datei</label>
|
||||
<input ref="kdbxInput" type="file" accept=".kdbx" />
|
||||
</div>
|
||||
<div 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" />
|
||||
</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 importPassword = ref('')
|
||||
const importing = ref(false)
|
||||
const kdbxInput = ref(null)
|
||||
|
||||
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 importKeePass() {
|
||||
if (!kdbxInput.value?.files?.length || !importPassword.value) return
|
||||
importing.value = true
|
||||
try {
|
||||
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, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Import entries
|
||||
for (const entry of res.data.entries) {
|
||||
const folderId = entry.group_uuid ? folderMap[entry.group_uuid] : 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,
|
||||
})
|
||||
}
|
||||
|
||||
showImport.value = false
|
||||
toast.add({ severity: 'success', summary: `${res.data.count} Eintraege importiert`, life: 5000 })
|
||||
await loadFolders()
|
||||
await loadEntries()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Import-Fehler', detail: err.response?.data?.error, 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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user