feat: Papierkorb + Bestaetigungsdialoge bei allen Loeschaktionen

Papierkorb:
- Dateien/Ordner werden beim Loeschen in den Papierkorb verschoben
  (Soft-Delete) statt sofort geloescht
- Papierkorb-Seite in der Sidebar mit Tabelle aller geloeschten Elemente
- Pro Element: Wiederherstellen (am Originalort) oder endgueltig loeschen
- "Papierkorb leeren" Button loescht alles unwiderruflich
- Backend: is_trashed, trashed_at, original_parent_id Felder im File-Model
- Getrashte Dateien erscheinen nicht in der normalen Dateiliste

Bestaetigungsdialoge (vorher fehlend):
- Kontakte: "Moechtest du XY wirklich loeschen?"
- Kalender Events: Bestaetigung vor dem Loeschen
- Kalender: Bestaetigung vor dem Loeschen (mit Hinweis auf Events)
- E-Mail Nachrichten: Bestaetigung mit Betreff-Vorschau
- Share-Link Dateien: Bestaetigung beim Loeschen aus geteiltem Ordner
- Admin SFTP-Backup-Ziele: Bestaetigung
- Admin Email-Konten: Bestaetigung

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 20:50:19 +02:00
parent 1ee80e650d
commit 82f3091f2e
10 changed files with 423 additions and 26 deletions
+5
View File
@@ -28,6 +28,11 @@ const routes = [
name: 'Files',
component: () => import('../views/FilesView.vue'),
},
{
path: 'trash',
name: 'Trash',
component: () => import('../views/TrashView.vue'),
},
{
path: 'calendar',
name: 'Calendar',
+43 -7
View File
@@ -180,7 +180,7 @@
<Button icon="pi pi-list" label="Versionen" size="small" outlined @click="openVersions(tgt)" />
<Button icon="pi pi-check-circle" label="Testen" size="small" text @click="testTarget(tgt)" />
<Button icon="pi pi-pencil" text size="small" @click="openEditTarget(tgt)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="deleteSftpTarget(tgt)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDeleteSftp(tgt)" />
</div>
</div>
</div>
@@ -374,7 +374,7 @@
</div>
<div class="acc-actions">
<Button icon="pi pi-pencil" text size="small" @click="openEditEmailAccount(acc)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="deleteEmailAccount(acc)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="confirmDeleteEmail(acc)" />
</div>
</div>
</div>
@@ -451,6 +451,24 @@
<Button label="Loeschen" severity="danger" @click="doDeleteUser" />
</template>
</Dialog>
<!-- Confirm delete SFTP target -->
<Dialog v-model:visible="showDeleteSftpConfirm" header="Backup-Ziel loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du das Backup-Ziel <strong>{{ deleteSftpTarget_ref?.name }}</strong> wirklich loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteSftpConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDeleteSftpTarget" />
</template>
</Dialog>
<!-- Confirm delete email account -->
<Dialog v-model:visible="showDeleteEmailConfirm" header="E-Mail-Konto loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du das E-Mail-Konto <strong>{{ deleteEmailTarget?.display_name }}</strong> wirklich loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteEmailConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDeleteEmailTarget" />
</template>
</Dialog>
</div>
</template>
@@ -560,6 +578,10 @@ const filteredUsers = computed(() => {
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
const showDeleteSftpConfirm = ref(false)
const deleteSftpTarget_ref = ref(null)
const showDeleteEmailConfirm = ref(false)
const deleteEmailTarget = ref(null)
// Email accounts for edited user
const userEmailAccounts = ref([])
@@ -674,9 +696,16 @@ async function saveTarget() {
}
}
async function deleteSftpTarget(tgt) {
function confirmDeleteSftp(tgt) {
deleteSftpTarget_ref.value = tgt
showDeleteSftpConfirm.value = true
}
async function doDeleteSftpTarget() {
if (!deleteSftpTarget_ref.value) return
try {
await apiClient.delete(`/admin/backup/targets/${tgt.id}`)
await apiClient.delete(`/admin/backup/targets/${deleteSftpTarget_ref.value.id}`)
showDeleteSftpConfirm.value = false
await loadTargets()
toast.add({ severity: 'success', summary: 'Backup-Ziel geloescht', life: 3000 })
} catch (err) {
@@ -1054,10 +1083,17 @@ async function saveEmailAccount() {
}
}
async function deleteEmailAccount(acc) {
function confirmDeleteEmail(acc) {
deleteEmailTarget.value = acc
showDeleteEmailConfirm.value = true
}
async function doDeleteEmailTarget() {
if (!deleteEmailTarget.value) return
try {
await apiClient.delete(`/admin/email-accounts/${acc.id}`)
userEmailAccounts.value = userEmailAccounts.value.filter(a => a.id !== acc.id)
await apiClient.delete(`/admin/email-accounts/${deleteEmailTarget.value.id}`)
userEmailAccounts.value = userEmailAccounts.value.filter(a => a.id !== deleteEmailTarget.value.id)
showDeleteEmailConfirm.value = false
toast.add({ severity: 'success', summary: 'Konto geloescht', life: 3000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
+5
View File
@@ -36,6 +36,11 @@
<i class="pi pi-key"></i>
<span>Passwoerter</span>
</router-link>
<router-link to="/trash" class="nav-item" active-class="active">
<i class="pi pi-trash"></i>
<span>Papierkorb</span>
</router-link>
</nav>
<div class="sidebar-footer">
+22 -2
View File
@@ -85,7 +85,7 @@
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
</div>
<template #footer>
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="deleteEvent" />
<Button v-if="editingEvent" label="Loeschen" severity="danger" text @click="confirmDeleteEvent = true" />
<Button label="Abbrechen" text @click="showEventDialog = false" />
<Button :label="editingEvent ? 'Speichern' : 'Erstellen'" @click="saveEvent" />
</template>
@@ -114,9 +114,27 @@
</div>
<Button v-if="selectedCal.permission === 'owner'" label="Kalender loeschen"
severity="danger" text size="small" @click="deleteCalendar" />
severity="danger" text size="small" @click="confirmDeleteCal = true" />
</div>
</Dialog>
<!-- Confirm delete event -->
<Dialog v-model:visible="confirmDeleteEvent" header="Event loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ editingEvent?.summary }}</strong> wirklich loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteEvent = false" />
<Button label="Loeschen" severity="danger" @click="deleteEvent; confirmDeleteEvent = false" />
</template>
</Dialog>
<!-- Confirm delete calendar -->
<Dialog v-model:visible="confirmDeleteCal" header="Kalender loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du den Kalender <strong>{{ selectedCal?.name }}</strong> mit allen Events loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteCal = false" />
<Button label="Loeschen" severity="danger" @click="deleteCalendar(); confirmDeleteCal = false" />
</template>
</Dialog>
</div>
</template>
@@ -147,6 +165,8 @@ const selectedCal = ref(null)
const shareUsername = ref('')
const sharePermission = ref('read')
const icalUrl = ref('')
const confirmDeleteEvent = ref(false)
const confirmDeleteCal = ref(false)
const permOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Lesen+Schreiben', value: 'readwrite' }]
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
+22 -3
View File
@@ -31,7 +31,7 @@
<Column field="phone" header="Telefon" />
<Column header="" style="width: 80px">
<template #body="{ data }">
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="deleteContact(data)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="confirmDeleteContact(data)" />
</template>
</Column>
</DataTable>
@@ -77,6 +77,15 @@
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
</template>
</Dialog>
<!-- Delete Confirm -->
<Dialog v-model:visible="showDeleteConfirm" header="Kontakt loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ deleteTarget?.display_name }}</strong> wirklich loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDeleteContact" />
</template>
</Dialog>
</div>
</template>
@@ -178,8 +187,18 @@ async function saveContact() {
}
}
async function deleteContact(contact) {
await apiClient.delete(`/contacts/${contact.id}`)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
function confirmDeleteContact(contact) {
deleteTarget.value = contact
showDeleteConfirm.value = true
}
async function doDeleteContact() {
if (!deleteTarget.value) return
await apiClient.delete(`/contacts/${deleteTarget.value.id}`)
showDeleteConfirm.value = false
await loadContacts()
await loadBooks()
}
+12 -1
View File
@@ -54,7 +54,7 @@
</div>
<div class="msg-actions">
<Button icon="pi pi-reply" label="Antworten" size="small" outlined @click="replyTo" />
<Button icon="pi pi-trash" size="small" severity="danger" text @click="deleteMessage" />
<Button icon="pi pi-trash" size="small" severity="danger" text @click="confirmDeleteMsg = true" />
</div>
</div>
<div v-if="messageDetail.html_body" class="msg-body" v-html="messageDetail.html_body"></div>
@@ -100,6 +100,16 @@
<Button label="Senden" icon="pi pi-send" @click="sendEmail" :loading="sending" />
</template>
</Dialog>
<!-- Delete message confirm -->
<Dialog v-model:visible="confirmDeleteMsg" header="Nachricht loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du diese Nachricht wirklich loeschen?</p>
<p v-if="messageDetail" class="msg-subject-preview"><strong>{{ messageDetail.subject }}</strong></p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteMsg = false" />
<Button label="Loeschen" severity="danger" @click="deleteMessage(); confirmDeleteMsg = false" />
</template>
</Dialog>
</div>
</template>
@@ -125,6 +135,7 @@ const currentFolderName = ref('INBOX')
const currentAccount = ref(null)
const loadingMessages = ref(false)
const confirmDeleteMsg = ref(false)
const showCompose = ref(false)
const sending = ref(false)
const composeForm = ref({ account_id: null, to: '', cc: '', subject: '', body_text: '' })
+23 -4
View File
@@ -73,7 +73,7 @@
<Button v-if="!f.is_folder" icon="pi pi-download" text size="small"
@click.stop="downloadFolderFile(f)" />
<Button v-if="fileInfo.permission === 'write'" icon="pi pi-trash" text size="small"
severity="danger" @click.stop="deleteFolderFile(f)" />
severity="danger" @click.stop="confirmDeleteShareFile(f)" />
</div>
</div>
<div v-if="!folderFiles.length" class="empty-folder">
@@ -111,6 +111,15 @@
</div>
</div>
</div>
<!-- Delete confirm for shared folder files -->
<Dialog v-model:visible="showShareDeleteConfirm" header="Datei loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ shareDeleteTarget?.name }}</strong> wirklich loeschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="showShareDeleteConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDeleteShareFile" />
</template>
</Dialog>
</div>
</template>
@@ -122,6 +131,7 @@ import Button from 'primevue/button'
import Password from 'primevue/password'
import Message from 'primevue/message'
import ProgressBar from 'primevue/progressbar'
import Dialog from 'primevue/dialog'
const route = useRoute()
const token = route.params.token
@@ -139,6 +149,8 @@ const folderBreadcrumb = ref([])
const currentParentId = ref(null)
const loadingFiles = ref(false)
const showShareDeleteConfirm = ref(false)
const shareDeleteTarget = ref(null)
const isDragging = ref(false)
const uploading = ref(false)
const uploadPercent = ref(0)
@@ -243,10 +255,17 @@ function downloadFolderFile(file) {
window.location.href = url
}
async function deleteFolderFile(file) {
function confirmDeleteShareFile(file) {
shareDeleteTarget.value = file
showShareDeleteConfirm.value = true
}
async function doDeleteShareFile() {
if (!shareDeleteTarget.value) return
try {
await axios.delete(`/api/share/${token}/files/${file.id}`, { headers: getAuthHeaders() })
folderFiles.value = folderFiles.value.filter(f => f.id !== file.id)
await axios.delete(`/api/share/${token}/files/${shareDeleteTarget.value.id}`, { headers: getAuthHeaders() })
folderFiles.value = folderFiles.value.filter(f => f.id !== shareDeleteTarget.value.id)
showShareDeleteConfirm.value = false
} catch (err) {
alert(err.response?.data?.error || 'Fehler beim Loeschen')
}
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="view-container">
<div class="view-header">
<h2>Papierkorb</h2>
<Button v-if="trashItems.length" icon="pi pi-trash" label="Papierkorb leeren"
size="small" severity="danger" outlined @click="confirmEmpty = true" />
</div>
<div v-if="loading" class="loading">
<i class="pi pi-spin pi-spinner"></i> Laden...
</div>
<div v-else-if="!trashItems.length" class="empty-state">
<i class="pi pi-trash" style="font-size: 3rem; color: var(--p-surface-300)"></i>
<p>Der Papierkorb ist leer</p>
</div>
<DataTable v-else :value="trashItems" striped-rows>
<Column field="name" header="Name" sortable>
<template #body="{ data }">
<div class="file-name">
<i :class="data.is_folder ? 'pi pi-folder' : 'pi pi-file'"></i>
<span>{{ data.name }}</span>
</div>
</template>
</Column>
<Column field="size" header="Groesse" sortable style="width: 120px">
<template #body="{ data }">
{{ data.is_folder ? '' : formatSize(data.size) }}
</template>
</Column>
<Column field="trashed_at" header="Geloescht am" sortable style="width: 180px">
<template #body="{ data }">
{{ formatDate(data.trashed_at) }}
</template>
</Column>
<Column header="Aktionen" style="width: 160px">
<template #body="{ data }">
<Button icon="pi pi-replay" label="Wiederherstellen" text size="small"
@click.stop="restoreItem(data)" />
<Button icon="pi pi-times" text size="small" severity="danger"
title="Endgueltig loeschen" @click.stop="confirmPermanentDelete(data)" />
</template>
</Column>
</DataTable>
<!-- Confirm permanent delete -->
<Dialog v-model:visible="showDeleteConfirm" header="Endgueltig loeschen" modal :style="{ width: '420px' }">
<p>Moechtest du <strong>{{ deleteTarget?.name }}</strong> wirklich endgueltig loeschen?</p>
<p class="warn-text">Diese Aktion kann nicht rueckgaengig gemacht werden!</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
<Button label="Endgueltig loeschen" severity="danger" @click="deletePermanently" />
</template>
</Dialog>
<!-- Confirm empty trash -->
<Dialog v-model:visible="confirmEmpty" header="Papierkorb leeren" modal :style="{ width: '420px' }">
<p>Moechtest du den gesamten Papierkorb endgueltig leeren?</p>
<p class="warn-text">Alle {{ trashItems.length }} Eintraege werden unwiderruflich geloescht!</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmEmpty = false" />
<Button label="Papierkorb leeren" severity="danger" @click="emptyTrash" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import apiClient from '../api/client'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
const toast = useToast()
const trashItems = ref([])
const loading = ref(false)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
const confirmEmpty = ref(false)
function formatSize(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
let i = 0; let size = bytes
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
return `${size.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
async function loadTrash() {
loading.value = true
try {
const res = await apiClient.get('/files/trash')
trashItems.value = res.data
} catch {
trashItems.value = []
} finally {
loading.value = false
}
}
async function restoreItem(item) {
try {
await apiClient.post(`/files/trash/${item.id}/restore`)
toast.add({ severity: 'success', summary: `"${item.name}" wiederhergestellt`, life: 3000 })
await loadTrash()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
function confirmPermanentDelete(item) {
deleteTarget.value = item
showDeleteConfirm.value = true
}
async function deletePermanently() {
if (!deleteTarget.value) return
try {
await apiClient.delete(`/files/trash/${deleteTarget.value.id}`)
toast.add({ severity: 'success', summary: 'Endgueltig geloescht', life: 3000 })
showDeleteConfirm.value = false
await loadTrash()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function emptyTrash() {
try {
await apiClient.post('/files/trash/empty')
toast.add({ severity: 'success', summary: 'Papierkorb geleert', life: 3000 })
confirmEmpty.value = false
await loadTrash()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
onMounted(loadTrash)
</script>
<style scoped>
.view-container { padding: 1.5rem; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
.view-header h2 { margin: 0; }
.loading { text-align: center; padding: 2rem; color: var(--p-text-muted-color); }
.empty-state {
display: flex; flex-direction: column; align-items: center;
gap: 0.75rem; padding: 4rem 2rem; color: var(--p-text-muted-color);
}
.file-name { display: flex; align-items: center; gap: 0.5rem; }
.warn-text { color: var(--p-red-500); font-size: 0.875rem; }
</style>