From f844ded57d279bcfe2a5a4bd03ae9c37f1c90514 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 7 Jul 2026 10:11:46 +0200 Subject: [PATCH] fix(web): hide edit/delete for others' events; persistent hide for shared calendars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — a calendar shared with me stayed visible after unchecking it: the hide was a one-shot cache filter the server undid on refetch. Add a per-device hidden set (localStorage 'hiddenLocalCalendars'), honoured in filterEvents (normal view) and used to drive the checkbox state, so it survives refetch/reload. Bug 2 — in the group combined view, other members' events showed edit/delete and 403'd on save. The combined endpoint now emits read_only (editable = the group calendar OR my own events), via a read_only param threaded through build_local_event_dict/expand_recurring_local. The event popup and edit modal now treat read_only events as read-only (copy still allowed). Test added. Co-Authored-By: Claude Opus 4.8 --- backend/local_events_util.py | 12 +++++++-- backend/routers/groups_router.py | 7 +++-- backend/tests/test_collaboration.py | 28 ++++++++++++++++++++ frontend/js/calendar.js | 41 +++++++++++++++++++++++++---- frontend/js/version.js | 2 +- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/backend/local_events_util.py b/backend/local_events_util.py index edca593..3e40884 100644 --- a/backend/local_events_util.py +++ b/backend/local_events_util.py @@ -54,7 +54,7 @@ def private_visibility_for(db: Session, user_id: int) -> str: # field (title/location/description/creator/calendar name/recurrence) can leak. _BUSY_KEEP = { "id", "url", "start", "end", "allDay", "calendar_id", "calendarColor", - "source", "type", "owner", "is_group_event", "display_color", + "source", "type", "owner", "is_group_event", "display_color", "read_only", } @@ -102,12 +102,14 @@ def build_local_event_dict( creator: Optional[dict] = None, owner: Optional[dict] = None, is_group_event: bool = False, + read_only: bool = False, ) -> dict: """Build the unified dict for a single local event (or occurrence). ``start``/``end``/``all_day`` override the stored values (used when emitting an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set - by the group combined view. + by the group combined view. ``read_only`` marks events the requester may not + edit (someone else's calendar), so clients can hide edit/delete. """ d = { "id": ev.uid, @@ -134,6 +136,8 @@ def build_local_event_dict( d["owner"] = owner if is_group_event: d["is_group_event"] = True + if read_only: + d["read_only"] = True return d @@ -146,6 +150,7 @@ def expand_recurring_local( creator: Optional[dict] = None, owner: Optional[dict] = None, is_group_event: bool = False, + read_only: bool = False, ) -> list: """Expand a recurring LocalEvent into individual occurrences in the range.""" results = [] @@ -177,6 +182,7 @@ def expand_recurring_local( ev, local_cal, start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, creator=creator, owner=owner, is_group_event=is_group_event, + read_only=read_only, )) else: ev_start = dt_datetime.fromisoformat(ev_start_str) @@ -203,11 +209,13 @@ def expand_recurring_local( ev, local_cal, start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, creator=creator, owner=owner, is_group_event=is_group_event, + read_only=read_only, )) except Exception as exc: logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) # Fall back to a single event. results.append(build_local_event_dict( ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, + read_only=read_only, )) return results diff --git a/backend/routers/groups_router.py b/backend/routers/groups_router.py index 5a8940d..ea27799 100644 --- a/backend/routers/groups_router.py +++ b/backend/routers/groups_router.py @@ -380,6 +380,9 @@ def combined_events( def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool): owner_user = name_cache.get(owner_id) owner = {"id": owner_id, "display_name": owner_user} + # Editable by the requester iff it's the shared group calendar (all members + # may write) or the requester's own calendar; everyone else's is read-only. + read_only = not (is_group or owner_id == current_user.id) events = ( db.query(models.LocalEvent) .filter( @@ -405,9 +408,9 @@ def combined_events( creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} if ev.rrule: - built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group) + built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only) else: - built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)] + built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)] for b in built: if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": diff --git a/backend/tests/test_collaboration.py b/backend/tests/test_collaboration.py index 6333d7d..1457887 100644 --- a/backend/tests/test_collaboration.py +++ b/backend/tests/test_collaboration.py @@ -484,3 +484,31 @@ def test_directory_hidden_excludes_from_picker_but_not_admin(client): # Admin user management still lists the hidden user. assert any(u["id"] == b_id for u in client.get("/api/users/", headers=auth(admin)).json()) + + +def test_combined_view_read_only_for_other_members(client): + """In the group combined view, events I may not edit carry read_only=True: + other members' calendars are read-only; the group calendar + my own aren't.""" + admin = register_admin(client) + b_id, b_tok = create_user(client, admin, "bob") + group = client.post("/api/groups/", headers=auth(admin), + json={"name": "Team", "member_ids": [b_id]}).json() + gid = group["id"] + gcal = group["group_calendar_id"] + + b_cal = _make_calendar(client, b_tok, "Bobs Kalender") + client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal}) + _make_event(client, b_tok, b_cal, "Bobs Termin") + _make_event(client, admin, gcal, "Gruppentermin") + + # As admin: bob's event is read-only; the group calendar is editable. + by = {e["title"]: e for e in + client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]} + assert by["Bobs Termin"].get("read_only") is True + assert by["Gruppentermin"].get("read_only") is not True + + # As bob: his own event and the group calendar are both editable. + by_b = {e["title"]: e for e in + client.get(f"/api/groups/{gid}/combined", headers=auth(b_tok), params=RANGE).json()["events"]} + assert by_b["Bobs Termin"].get("read_only") is not True + assert by_b["Gruppentermin"].get("read_only") is not True diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index a2c54de..2ae72b2 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -519,6 +519,16 @@ function filterEvents(events) { return oid == null || !hidden.has(oid); }); } + // Normal view: honour per-device hide for calendars shared with me (their + // events keep coming from the server since `enabled` is the owner's flag). + const hiddenLocal = state.activeGroupId ? null : loadHiddenLocalCals(); + if (hiddenLocal && hiddenLocal.size) { + events = events.filter(ev => { + if (ev.source !== 'local') return true; + const id = parseInt(String(ev.calendar_id).replace('local-', '')); + return !hiddenLocal.has(id); + }); + } // If dimPast is enabled, events are still shown but CSS handles opacity via .past class return events; } @@ -672,6 +682,20 @@ function saveCalOrder(keys) { localStorage.setItem(CAL_ORDER_KEY, JSON.stringify(keys)); } +// Per-device hide for local calendars shared WITH me (owned=false). Their +// `enabled` flag belongs to the owner, so hiding them can't be a server PUT — +// keep it client-side and honour it at render time so it survives refetches. +const HIDDEN_LOCAL_KEY = 'hiddenLocalCalendars'; +function loadHiddenLocalCals() { + try { return new Set(JSON.parse(localStorage.getItem(HIDDEN_LOCAL_KEY) || '[]')); } + catch (e) { return new Set(); } +} +function setHiddenLocalCal(id, hidden) { + const s = loadHiddenLocalCals(); + if (hidden) s.add(id); else s.delete(id); + try { localStorage.setItem(HIDDEN_LOCAL_KEY, JSON.stringify([...s])); } catch (e) { /* ignore */ } +} + // Drag & drop reordering of the flat calendar list (persisted per device). // The dragged row is moved live among its siblings during dragover, so the // list visibly "makes space" and you can see where it will land. @@ -732,6 +756,7 @@ function renderCalendarList() { // inline (small, grey) next to the name and section headers are gone, so the // whole list can be freely reordered via drag & drop. const entries = []; + const hiddenLocal = loadHiddenLocalCals(); // per-device hide for shared-with-me calendars state.accounts.forEach(acc => { (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => { entries.push({ key: `caldav:${cal.id}`, source: 'caldav', dataId: `data-cal-id="${cal.id}"`, @@ -754,7 +779,7 @@ function renderCalendarList() { // (e.g. Guido's "Persönlich" appears as "Guido"); the original calendar // name stays in the sub-label so it's still identifiable. entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`, - name: cal.shared_by || cal.name, color: cal.color, enabled: cal.enabled, readOnly, + name: cal.shared_by || cal.name, color: cal.color, enabled: !hiddenLocal.has(cal.id), readOnly, sourceLabel: `${t('shared_with_me')} · ${cal.name}${readOnly ? ' · ' + t('perm_read') : ''}`, remove: null }); }); // Group calendars (owned by the creator or reached via membership) — shown so @@ -762,7 +787,8 @@ function renderCalendarList() { // (reminders), which the server then syncs; member-reached ones can't (no PUT). state.localCalendars.filter(c => c.group).forEach(cal => { entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`, - name: cal.name, color: cal.color, enabled: cal.enabled, + name: cal.name, color: cal.color, + enabled: cal.owned !== false ? cal.enabled : !hiddenLocal.has(cal.id), reminders: cal.owned !== false, remindersEnabled: cal.reminders_enabled !== false, sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`, isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null }); @@ -841,8 +867,11 @@ function renderCalendarList() { const calId = parseInt(cb.dataset.calId); const cal = state.localCalendars.find(c => c.id === calId); // `enabled` is the owner's property — only the owner may PUT it. - // For shared/group calendars just toggle visibility client-side. + // For calendars shared with me, persist a per-device hide instead so it + // survives refetches (the server keeps returning the owner's events). if (cal && cal.owned !== false) { + setHiddenLocalCal(calId, !cb.checked); + } else { await api.put(`/local/calendars/${calId}`, { enabled: cb.checked }); } if (cal) cal.enabled = cb.checked; @@ -1525,7 +1554,9 @@ function showEventPopup(ev, anchor) { dragHandle.onpointerup = onPointerUp; // Hide edit/delete for read-only iCal subscription events - const isReadOnly = (ev.source === 'ical'); + // Read-only: iCal subscriptions, and events I may not write (someone else's + // calendar in a share / group overlay — the server flags these `read_only`). + const isReadOnly = (ev.source === 'ical' || ev.read_only === true); document.getElementById('popup-edit').style.display = isReadOnly ? 'none' : ''; document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : ''; @@ -1862,7 +1893,7 @@ function openCopyEditModal(ev, targetCal) { } function openEditEventModal(ev) { - if (ev.source === 'ical') { showToast(t('event_readonly'), true); return; } + if (ev.source === 'ical' || ev.read_only === true) { showToast(t('event_readonly'), true); return; } state.editingEvent = ev; state.selectedEventColor = ev.color || ''; diff --git a/frontend/js/version.js b/frontend/js/version.js index eaf17b2..de5a96b 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -1,2 +1,2 @@ // Increment APP_VERSION with every code change -export const APP_VERSION = 'v79'; +export const APP_VERSION = 'v80';