fix(web): hide edit/delete for others' events; persistent hide for shared calendars

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-07 10:11:46 +02:00
parent ec85a5b5f3
commit f844ded57d
5 changed files with 80 additions and 10 deletions

View File

@@ -54,7 +54,7 @@ def private_visibility_for(db: Session, user_id: int) -> str:
# field (title/location/description/creator/calendar name/recurrence) can leak. # field (title/location/description/creator/calendar name/recurrence) can leak.
_BUSY_KEEP = { _BUSY_KEEP = {
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor", "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, creator: Optional[dict] = None,
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False,
) -> dict: ) -> dict:
"""Build the unified dict for a single local event (or occurrence). """Build the unified dict for a single local event (or occurrence).
``start``/``end``/``all_day`` override the stored values (used when emitting ``start``/``end``/``all_day`` override the stored values (used when emitting
an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set 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 = { d = {
"id": ev.uid, "id": ev.uid,
@@ -134,6 +136,8 @@ def build_local_event_dict(
d["owner"] = owner d["owner"] = owner
if is_group_event: if is_group_event:
d["is_group_event"] = True d["is_group_event"] = True
if read_only:
d["read_only"] = True
return d return d
@@ -146,6 +150,7 @@ def expand_recurring_local(
creator: Optional[dict] = None, creator: Optional[dict] = None,
owner: Optional[dict] = None, owner: Optional[dict] = None,
is_group_event: bool = False, is_group_event: bool = False,
read_only: bool = False,
) -> list: ) -> list:
"""Expand a recurring LocalEvent into individual occurrences in the range.""" """Expand a recurring LocalEvent into individual occurrences in the range."""
results = [] results = []
@@ -177,6 +182,7 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True, start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
else: else:
ev_start = dt_datetime.fromisoformat(ev_start_str) ev_start = dt_datetime.fromisoformat(ev_start_str)
@@ -203,11 +209,13 @@ def expand_recurring_local(
ev, local_cal, ev, local_cal,
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False, start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
creator=creator, owner=owner, is_group_event=is_group_event, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
except Exception as exc: except Exception as exc:
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc) logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
# Fall back to a single event. # Fall back to a single event.
results.append(build_local_event_dict( results.append(build_local_event_dict(
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event, ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
read_only=read_only,
)) ))
return results return results

View File

@@ -380,6 +380,9 @@ def combined_events(
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool): def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
owner_user = name_cache.get(owner_id) owner_user = name_cache.get(owner_id)
owner = {"id": owner_id, "display_name": owner_user} 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 = ( events = (
db.query(models.LocalEvent) db.query(models.LocalEvent)
.filter( .filter(
@@ -405,9 +408,9 @@ def combined_events(
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"} creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
if ev.rrule: 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: 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: for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy": if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":

View File

@@ -484,3 +484,31 @@ def test_directory_hidden_excludes_from_picker_but_not_admin(client):
# Admin user management still lists the hidden user. # Admin user management still lists the hidden user.
assert any(u["id"] == b_id for u in assert any(u["id"] == b_id for u in
client.get("/api/users/", headers=auth(admin)).json()) 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

View File

@@ -519,6 +519,16 @@ function filterEvents(events) {
return oid == null || !hidden.has(oid); 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 // If dimPast is enabled, events are still shown but CSS handles opacity via .past class
return events; return events;
} }
@@ -672,6 +682,20 @@ function saveCalOrder(keys) {
localStorage.setItem(CAL_ORDER_KEY, JSON.stringify(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). // Drag & drop reordering of the flat calendar list (persisted per device).
// The dragged row is moved live among its siblings during dragover, so the // 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. // 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 // inline (small, grey) next to the name and section headers are gone, so the
// whole list can be freely reordered via drag & drop. // whole list can be freely reordered via drag & drop.
const entries = []; const entries = [];
const hiddenLocal = loadHiddenLocalCals(); // per-device hide for shared-with-me calendars
state.accounts.forEach(acc => { state.accounts.forEach(acc => {
(acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => { (acc.calendars || []).filter(c => !c.sidebar_hidden).forEach(cal => {
entries.push({ key: `caldav:${cal.id}`, source: 'caldav', dataId: `data-cal-id="${cal.id}"`, 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 // (e.g. Guido's "Persönlich" appears as "Guido"); the original calendar
// name stays in the sub-label so it's still identifiable. // 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}"`, 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 }); 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 // 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). // (reminders), which the server then syncs; member-reached ones can't (no PUT).
state.localCalendars.filter(c => c.group).forEach(cal => { state.localCalendars.filter(c => c.group).forEach(cal => {
entries.push({ key: `local:${cal.id}`, source: 'local', dataId: `data-cal-id="${cal.id}"`, 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, reminders: cal.owned !== false, remindersEnabled: cal.reminders_enabled !== false,
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`, sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null }); isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
@@ -841,8 +867,11 @@ function renderCalendarList() {
const calId = parseInt(cb.dataset.calId); const calId = parseInt(cb.dataset.calId);
const cal = state.localCalendars.find(c => c.id === calId); const cal = state.localCalendars.find(c => c.id === calId);
// `enabled` is the owner's property — only the owner may PUT it. // `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) { if (cal && cal.owned !== false) {
setHiddenLocalCal(calId, !cb.checked);
} else {
await api.put(`/local/calendars/${calId}`, { enabled: cb.checked }); await api.put(`/local/calendars/${calId}`, { enabled: cb.checked });
} }
if (cal) cal.enabled = cb.checked; if (cal) cal.enabled = cb.checked;
@@ -1525,7 +1554,9 @@ function showEventPopup(ev, anchor) {
dragHandle.onpointerup = onPointerUp; dragHandle.onpointerup = onPointerUp;
// Hide edit/delete for read-only iCal subscription events // 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-edit').style.display = isReadOnly ? 'none' : '';
document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : ''; document.getElementById('popup-delete').style.display = isReadOnly ? 'none' : '';
@@ -1862,7 +1893,7 @@ function openCopyEditModal(ev, targetCal) {
} }
function openEditEventModal(ev) { 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.editingEvent = ev;
state.selectedEventColor = ev.color || ''; state.selectedEventColor = ev.color || '';

View File

@@ -1,2 +1,2 @@
// Increment APP_VERSION with every code change // Increment APP_VERSION with every code change
export const APP_VERSION = 'v79'; export const APP_VERSION = 'v80';