feat: Kalender - Autocomplete + Privat-Flag + Share-Liste + Bugfix

Sharing-Fix:
Calendar-Model hatte keine owner-Relation zu User - list_calendars
stuerzte beim Listen geteilter Kalender ab (c.owner.username ->
AttributeError). Jetzt mit explizitem foreign_keys Relationship.

Benutzer-Autocomplete:
"Kalender teilen" nutzt jetzt /users/search wie bei Dateien.
Tippt man 2+ Zeichen, erscheint ein Dropdown mit passenden
Benutzernamen. Klick uebernimmt den Namen.

Bestehende Freigaben werden im Menue angezeigt mit Muelleimer
zum Entfernen.

Privat-Flag fuer Termine:
CalendarEvent bekommt is_private-Spalte. Checkbox im Termin-
Dialog "🔒 Privat (Teilnehmer sehen nur den Zeitblock)".

Redaction greift an drei Stellen:
* GET /events: Nicht-Owner sehen summary="Privat", description
  und location = null. Zeitfenster bleibt voll sichtbar.
* iCal-Export (/ical/<token>): Privat-Events werden mit
  CLASS:PRIVATE ausgegeben und SUMMARY/DESCRIPTION/LOCATION
  werden gestrippt.
* CalDAV: aktuell werden eh nur eigene Kalender exportiert,
  also keine Redaction noetig. Kommt bei Share-Support rein.

Der Eigentuemer sieht natuerlich in seiner eigenen Ansicht alle
Details seines privaten Termins.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-12 12:56:25 +02:00
parent 5797a7b738
commit a143325bbe
3 changed files with 133 additions and 4 deletions
+75 -2
View File
@@ -74,6 +74,9 @@
<div class="field">
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
</div>
<div class="field">
<label><input type="checkbox" v-model="eventForm.is_private" /> 🔒 Privat (Teilnehmer sehen nur den Zeitblock)</label>
</div>
<div class="field">
<label>Ort</label>
<InputText v-model="eventForm.location" fluid />
@@ -146,10 +149,27 @@
<div v-if="selectedCal.permission === 'owner'" class="field">
<label>Mit Benutzer teilen</label>
<div class="share-row">
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
<div style="position: relative; flex: 1;">
<InputText v-model="shareUsername" placeholder="Benutzername suchen..."
fluid @input="onShareSearch" />
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
</div>
</div>
</div>
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="shareCalendar" />
</div>
<div v-if="calendarShares.length" class="existing-shares">
<div v-for="s in calendarShares" :key="s.id" class="share-perm-item">
<i class="pi pi-user"></i>
<span>{{ s.username }}</span>
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(s.id)" />
</div>
</div>
</div>
<div v-if="selectedCal.permission === 'owner'" class="field ical-block">
@@ -235,7 +255,7 @@ const showEventDialog = ref(false)
const editingEvent = ref(null)
const eventForm = ref({
summary: '', description: '', location: '',
calendar_id: null, dtstart: '', dtend: '', all_day: false,
calendar_id: null, dtstart: '', dtend: '', all_day: false, is_private: false,
})
// Recurrence editor state
@@ -278,6 +298,9 @@ const showCalMenu = ref(false)
const selectedCal = ref(null)
const shareUsername = ref('')
const sharePermission = ref('read')
const shareSearchResults = ref([])
const calendarShares = ref([])
let shareSearchTimer = null
const icalPassword = ref('')
const confirmDeleteEvent = ref(false)
const confirmDeleteCal = ref(false)
@@ -560,6 +583,7 @@ function openNewEvent(start, end, allDay = false) {
dtstart: toLocalISO(now, allDay),
dtend: toLocalISO(later, allDay),
all_day: allDay,
is_private: false,
}
loadRRuleIntoForm('')
showEventDialog.value = true
@@ -575,6 +599,7 @@ function openEditEvent(evt) {
dtstart: toLocalISO(new Date(evt.dtstart), evt.all_day),
dtend: evt.dtend ? toLocalISO(new Date(evt.dtend), evt.all_day) : '',
all_day: evt.all_day,
is_private: !!evt.is_private,
}
loadRRuleIntoForm(evt.recurrence_rule)
showEventDialog.value = true
@@ -589,6 +614,7 @@ async function saveEvent() {
dtstart: fromLocalISO(eventForm.value.dtstart, eventForm.value.all_day),
dtend: eventForm.value.dtend ? fromLocalISO(eventForm.value.dtend, eventForm.value.all_day) : null,
all_day: eventForm.value.all_day,
is_private: eventForm.value.is_private,
recurrence_rule: buildRRule(),
calendar_id: eventForm.value.calendar_id,
}
@@ -636,7 +662,22 @@ async function deleteEvent() {
function openCalendarMenu(cal) {
selectedCal.value = cal
icalPassword.value = ''
shareUsername.value = ''
shareSearchResults.value = []
showCalMenu.value = true
loadShares()
}
function onShareSearch() {
clearTimeout(shareSearchTimer)
const q = shareUsername.value.trim()
if (q.length < 2) { shareSearchResults.value = []; return }
shareSearchTimer = setTimeout(async () => {
try {
const res = await apiClient.get('/users/search', { params: { q } })
shareSearchResults.value = res.data
} catch { shareSearchResults.value = [] }
}, 250)
}
async function shareCalendar() {
@@ -647,6 +688,30 @@ async function shareCalendar() {
})
toast.add({ severity: 'success', summary: 'Kalender geteilt', life: 3000 })
shareUsername.value = ''
shareSearchResults.value = []
await loadShares()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error || err.message, life: 5000 })
}
}
async function loadShares() {
if (!selectedCal.value || selectedCal.value.permission !== 'owner') {
calendarShares.value = []
return
}
try {
const res = await apiClient.get(`/calendars/${selectedCal.value.id}/shares`)
calendarShares.value = res.data
} catch { calendarShares.value = [] }
}
async function removeShare(shareId) {
if (!selectedCal.value) return
try {
await apiClient.delete(`/calendars/${selectedCal.value.id}/shares/${shareId}`)
await loadShares()
toast.add({ severity: 'success', summary: 'Freigabe entfernt', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
@@ -759,6 +824,14 @@ onMounted(async () => {
background: var(--p-surface-100); font-size: 0.85rem; cursor: pointer; }
.weekday-btn.active { background: var(--p-primary-100); }
.share-row { display: flex; gap: 0.5rem; align-items: center; }
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
background: white; border: 1px solid var(--p-surface-200); border-radius: 4px;
max-height: 160px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; display: flex; gap: 0.5rem; align-items: center; }
.user-result:hover { 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; }
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.ical-block { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; margin-top: 1rem; }
.ical-url { font-size: 0.8rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.ical-url code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px; word-break: break-all; }