feat: Share-Dialog Fix, User-Sharing, Admin-Benutzerverwaltung, Registrierungs-Toggle

- Fix: Share-Dialog oeffnet sich jetzt auch bei bereits geteilten Dateien
- Neu: Dateien/Ordner direkt mit anderen Benutzern teilen (Lesen/Schreiben/Admin)
- Neu: Benutzersuche im Share-Dialog, bestehende Freigaben anzeigen/entfernen
- Neu: Admin kann Benutzer ueber die Weboberflaeche anlegen
- Neu: Admin kann Benutzer bearbeiten (Rolle, Quota, aktiv/inaktiv) und loeschen
- Neu: Schieberegler fuer oeffentliche Registrierung in den Admin-Einstellungen
- Neu: Register-Link auf Login-Seite nur sichtbar wenn Registrierung erlaubt
- Neu: Register-Seite leitet um wenn Registrierung deaktiviert
- Neu: AppSettings-Model fuer persistente App-Konfiguration
- Neu: /api/users/search Endpunkt fuer Benutzersuche in Share-Dialogen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 15:26:04 +02:00
parent e7170b7cc8
commit 35099de2c5
8 changed files with 459 additions and 34 deletions
+115 -15
View File
@@ -107,27 +107,56 @@
</Dialog>
<!-- Share Dialog -->
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '500px' }">
<Dialog v-model:visible="showShare" header="Teilen" modal :style="{ width: '550px' }">
<div v-if="shareFile" class="share-content">
<h4>{{ shareFile.name }}</h4>
<div class="share-form">
<div class="field">
<label>Passwort (optional)</label>
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
<!-- User Sharing -->
<div class="share-section">
<h5>Mit Benutzer teilen</h5>
<div class="user-share-row">
<InputText v-model="shareUserQuery" placeholder="Benutzername suchen..." fluid @input="searchUsers" />
<Select v-model="shareUserPermission" :options="userPermOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="shareWithUser" :disabled="!selectedShareUser" />
</div>
<div class="field">
<label>Ablaufdatum (optional)</label>
<InputText v-model="shareExpiry" type="date" fluid />
<div v-if="userSearchResults.length" class="user-search-results">
<div v-for="u in userSearchResults" :key="u.id"
class="user-result" :class="{ selected: selectedShareUser?.id === u.id }"
@click="selectedShareUser = u; shareUserQuery = u.username; userSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
</div>
</div>
<div v-if="filePermissions.length" class="existing-shares">
<div v-for="perm in filePermissions" :key="perm.id" class="share-perm-item">
<i class="pi pi-user"></i>
<span>{{ perm.username }}</span>
<Tag :value="permLabel(perm.permission)" size="small" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeUserShare(perm.id)" />
</div>
</div>
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
</div>
<div v-if="shareLinks.length" class="existing-links">
<h4>Bestehende Links</h4>
<!-- Link Sharing -->
<div class="share-section">
<h5>Freigabe-Link erstellen</h5>
<div class="share-form">
<div class="field">
<label>Passwort (optional)</label>
<Password v-model="sharePassword" :feedback="false" toggle-mask fluid />
</div>
<div class="field">
<label>Ablaufdatum (optional)</label>
<InputText v-model="shareExpiry" type="date" fluid />
</div>
<Button label="Link erstellen" icon="pi pi-link" @click="createShare" :loading="shareLoading" />
</div>
</div>
<div v-if="shareLinks.length" class="share-section">
<h5>Bestehende Links</h5>
<div v-for="link in shareLinks" :key="link.id" class="share-link-item">
<div class="link-info">
<code>{{ window.location.origin }}/share/{{ link.token }}</code>
<code>{{ currentOrigin }}/share/{{ link.token }}</code>
<small>
{{ link.download_count }} Downloads
<template v-if="link.expires_at"> | Bis {{ formatDate(link.expires_at) }}</template>
@@ -160,6 +189,7 @@ import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFilesStore } from '../stores/files'
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'
@@ -167,6 +197,7 @@ import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Tag from 'primevue/tag'
import Select from 'primevue/select'
const route = useRoute()
const router = useRouter()
@@ -184,6 +215,13 @@ const shareFile = ref(null)
const sharePassword = ref('')
const shareExpiry = ref('')
const shareLinks = ref([])
const filePermissions = ref([])
const shareUserQuery = ref('')
const selectedShareUser = ref(null)
const shareUserPermission = ref('read')
const userSearchResults = ref([])
const userPermOptions = [{ label: 'Lesen', value: 'read' }, { label: 'Schreiben', value: 'write' }, { label: 'Admin', value: 'admin' }]
const currentOrigin = window.location.origin
const shareLoading = ref(false)
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
@@ -297,15 +335,69 @@ async function doRename() {
}
}
function permLabel(perm) {
return { read: 'Lesen', write: 'Schreiben', admin: 'Admin' }[perm] || perm
}
async function openShare(data) {
shareFile.value = data
sharePassword.value = ''
shareExpiry.value = ''
shareUserQuery.value = ''
selectedShareUser.value = null
userSearchResults.value = []
showShare.value = true
// Load existing links and permissions in parallel
try {
shareLinks.value = await filesStore.getShareLinks(data.id)
const [links, perms] = await Promise.all([
filesStore.getShareLinks(data.id),
apiClient.get(`/files/${data.id}/permissions`).then(r => r.data).catch(() => []),
])
shareLinks.value = links
filePermissions.value = perms
} catch {
shareLinks.value = []
filePermissions.value = []
}
}
let searchTimeout = null
function searchUsers() {
selectedShareUser.value = null
clearTimeout(searchTimeout)
if (shareUserQuery.value.length < 2) { userSearchResults.value = []; return }
searchTimeout = setTimeout(async () => {
try {
const res = await apiClient.get('/users/search', { params: { q: shareUserQuery.value } })
userSearchResults.value = res.data
} catch { userSearchResults.value = [] }
}, 300)
}
async function shareWithUser() {
if (!selectedShareUser.value || !shareFile.value) return
try {
await apiClient.post(`/files/${shareFile.value.id}/permissions`, {
user_id: selectedShareUser.value.id,
permission: shareUserPermission.value,
})
toast.add({ severity: 'success', summary: `Mit ${selectedShareUser.value.username} geteilt`, life: 3000 })
shareUserQuery.value = ''
selectedShareUser.value = null
const res = await apiClient.get(`/files/${shareFile.value.id}/permissions`)
filePermissions.value = res.data
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
async function removeUserShare(permId) {
if (!shareFile.value) return
try {
await apiClient.delete(`/files/${shareFile.value.id}/permissions/${permId}`)
filePermissions.value = filePermissions.value.filter(p => p.id !== permId)
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
@@ -395,8 +487,16 @@ onMounted(() => {
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.share-content h4 { margin: 0 0 1rem; }
.share-form { margin-bottom: 1.5rem; }
.existing-links { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; }
.share-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--p-surface-200); }
.share-section:last-child { border-bottom: none; }
.share-section h5 { margin: 0 0 0.75rem; font-size: 0.9rem; }
.share-form { }
.user-share-row { display: flex; gap: 0.5rem; align-items: flex-start; }
.user-search-results { border: 1px solid var(--p-surface-200); border-radius: 6px; margin-top: 0.25rem; max-height: 150px; overflow-y: auto; }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
.user-result:hover, .user-result.selected { background: var(--p-primary-50); }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
.share-link-item {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 0; border-bottom: 1px solid var(--p-surface-100);