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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user