From f35798c7570f4369b1955c9e60402ab840d861c0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 00:59:36 +0800 Subject: [PATCH] Add per-invitee can_modify toggle for event edit access Allows event owners to grant individual invitees edit permission via a toggle in the invitee list. Invited editors can modify event details (title, description, time, location) but cannot change calendars, manage invitees, delete events, or bulk-edit recurring series (scope restricted to "this" only). The can_modify flag resets on decline to prevent silent re-grant. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...056_add_can_modify_to_event_invitations.py | 26 +++++ backend/app/models/event_invitation.py | 7 +- backend/app/routers/event_invitations.py | 36 ++++++ backend/app/routers/events.py | 103 +++++++++++++----- backend/app/schemas/event_invitation.py | 6 + backend/app/services/event_invitation.py | 5 + .../src/components/calendar/CalendarPage.tsx | 3 +- .../components/calendar/EventDetailPanel.tsx | 91 ++++++++++------ .../components/calendar/InviteeSection.tsx | 23 +++- frontend/src/hooks/useEventInvitations.ts | 22 ++++ frontend/src/types/index.ts | 2 + 11 files changed, 259 insertions(+), 65 deletions(-) create mode 100644 backend/alembic/versions/056_add_can_modify_to_event_invitations.py diff --git a/backend/alembic/versions/056_add_can_modify_to_event_invitations.py b/backend/alembic/versions/056_add_can_modify_to_event_invitations.py new file mode 100644 index 0000000..bf54dcf --- /dev/null +++ b/backend/alembic/versions/056_add_can_modify_to_event_invitations.py @@ -0,0 +1,26 @@ +"""add can_modify to event_invitations + +Revision ID: 056 +Revises: 055 +Create Date: 2025-01-01 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "056" +down_revision = "055" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "event_invitations", + sa.Column("can_modify", sa.Boolean(), server_default=sa.false(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("event_invitations", "can_modify") diff --git a/backend/app/models/event_invitation.py b/backend/app/models/event_invitation.py index 54b1091..cbd53d4 100644 --- a/backend/app/models/event_invitation.py +++ b/backend/app/models/event_invitation.py @@ -1,6 +1,6 @@ from sqlalchemy import ( - CheckConstraint, DateTime, Integer, ForeignKey, Index, - String, UniqueConstraint, func, + Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index, + String, UniqueConstraint, false as sa_false, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from datetime import datetime @@ -38,6 +38,9 @@ class EventInvitation(Base): display_calendar_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True ) + can_modify: Mapped[bool] = mapped_column( + Boolean, default=False, server_default=sa_false() + ) event: Mapped["CalendarEvent"] = relationship(lazy="raise") user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise") diff --git a/backend/app/routers/event_invitations.py b/backend/app/routers/event_invitations.py index b306d7e..5067b06 100644 --- a/backend/app/routers/event_invitations.py +++ b/backend/app/routers/event_invitations.py @@ -14,10 +14,12 @@ from app.models.calendar_event import CalendarEvent from app.models.event_invitation import EventInvitation from app.models.user import User from app.routers.auth import get_current_user +from sqlalchemy.orm import selectinload from app.schemas.event_invitation import ( EventInvitationCreate, EventInvitationRespond, EventInvitationOverrideCreate, + UpdateCanModify, UpdateDisplayCalendar, ) from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission @@ -231,6 +233,40 @@ async def update_display_calendar( return response_data +@router.put("/{invitation_id}/can-modify") +async def update_can_modify( + body: UpdateCanModify, + invitation_id: int = Path(ge=1, le=2147483647), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Toggle can_modify on an invitation. Owner-only.""" + inv_result = await db.execute( + select(EventInvitation) + .options(selectinload(EventInvitation.event)) + .where(EventInvitation.id == invitation_id) + ) + invitation = inv_result.scalar_one_or_none() + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + # Only the calendar owner can toggle can_modify (W-03) + perm = await get_user_permission(db, invitation.event.calendar_id, current_user.id) + if perm != "owner": + raise HTTPException(status_code=403, detail="Only the calendar owner can grant edit access") + + invitation.can_modify = body.can_modify + + response_data = { + "id": invitation.id, + "event_id": invitation.event_id, + "can_modify": invitation.can_modify, + } + + await db.commit() + return response_data + + @router.delete("/{invitation_id}", status_code=204) async def leave_or_revoke_invitation( invitation_id: int = Path(ge=1, le=2147483647), diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 4c9949f..e743f47 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -34,6 +34,7 @@ def _event_to_dict( display_calendar_id: int | None = None, display_calendar_name: str | None = None, display_calendar_color: str | None = None, + can_modify: bool = False, ) -> dict: """Serialize a CalendarEvent ORM object to a response dict including calendar info.""" # For invited events: use display calendar if set, otherwise fallback to "Invited"/gray @@ -72,6 +73,7 @@ def _event_to_dict( "invitation_status": invitation_status, "invitation_id": invitation_id, "display_calendar_id": display_calendar_id, + "can_modify": can_modify, } return d @@ -207,7 +209,7 @@ async def get_events( # Build invitation lookup for the current user invited_event_id_set = set(invited_event_ids) - invitation_map: dict[int, tuple[str, int, int | None]] = {} # event_id -> (status, invitation_id, display_calendar_id) + invitation_map: dict[int, tuple[str, int, int | None, bool]] = {} # event_id -> (status, invitation_id, display_calendar_id, can_modify) if invited_event_ids: inv_result = await db.execute( select( @@ -215,13 +217,14 @@ async def get_events( EventInvitation.status, EventInvitation.id, EventInvitation.display_calendar_id, + EventInvitation.can_modify, ).where( EventInvitation.user_id == current_user.id, EventInvitation.event_id.in_(invited_event_ids), ) ) - for eid, status, inv_id, disp_cal_id in inv_result.all(): - invitation_map[eid] = (status, inv_id, disp_cal_id) + for eid, status, inv_id, disp_cal_id, cm in inv_result.all(): + invitation_map[eid] = (status, inv_id, disp_cal_id, cm) # Batch-fetch display calendars for invited events display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None} @@ -250,8 +253,9 @@ async def get_events( disp_cal_id = None disp_cal_name = None disp_cal_color = None + inv_can_modify = False if is_invited and parent_id in invitation_map: - inv_status, inv_id, disp_cal_id = invitation_map[parent_id] + inv_status, inv_id, disp_cal_id, inv_can_modify = invitation_map[parent_id] # Check for per-occurrence override if e.id in override_map: inv_status = override_map[e.id] @@ -267,6 +271,7 @@ async def get_events( display_calendar_id=disp_cal_id, display_calendar_name=disp_cal_name, display_calendar_color=disp_cal_color, + can_modify=inv_can_modify, )) # Fetch the user's Birthdays system calendar; only generate virtual events if visible @@ -409,9 +414,10 @@ async def update_event( current_user: User = Depends(get_current_user), ): # IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope). - # Event invitees can VIEW events but must NOT be able to edit them. - # Do not add invited_event_ids to this query. + # Event invitees can VIEW events but must NOT be able to edit them + # UNLESS they have can_modify=True (checked in fallback path below). all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) + is_invited_editor = False result = await db.execute( select(CalendarEvent) @@ -424,14 +430,60 @@ async def update_event( event = result.scalar_one_or_none() if not event: - raise HTTPException(status_code=404, detail="Calendar event not found") + # Fallback: check if user has can_modify invitation for this event + # Must check both event_id (direct) and parent_event_id (recurring child) + # because invitations are stored against the parent event + target_event_result = await db.execute( + select(CalendarEvent.parent_event_id).where(CalendarEvent.id == event_id) + ) + target_row = target_event_result.one_or_none() + if not target_row: + raise HTTPException(status_code=404, detail="Calendar event not found") + candidate_ids = [event_id] + if target_row[0] is not None: + candidate_ids.append(target_row[0]) - # Shared calendar: require create_modify+ and check lock - await require_permission(db, event.calendar_id, current_user.id, "create_modify") - await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + inv_result = await db.execute( + select(EventInvitation).where( + EventInvitation.event_id.in_(candidate_ids), + EventInvitation.user_id == current_user.id, + EventInvitation.can_modify == True, + EventInvitation.status.in_(["accepted", "tentative"]), + ) + ) + inv = inv_result.scalar_one_or_none() + if not inv: + raise HTTPException(status_code=404, detail="Calendar event not found") + + # Load the event directly (bypassing calendar filter) + event_result = await db.execute( + select(CalendarEvent) + .options(selectinload(CalendarEvent.calendar)) + .where(CalendarEvent.id == event_id) + ) + event = event_result.scalar_one_or_none() + if not event: + raise HTTPException(status_code=404, detail="Calendar event not found") + is_invited_editor = True update_data = event_update.model_dump(exclude_unset=True) + if is_invited_editor: + # Invited editor restrictions — enforce BEFORE any data mutation + scope_peek = update_data.get("edit_scope") + # Block all bulk-scope edits on recurring events (C-01/F-01) + if event.is_recurring and scope_peek != "this": + raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences") + # Block calendar moves (C-02) + if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: + raise HTTPException(status_code=403, detail="Invited editors cannot move events between calendars") + else: + # Standard calendar-access path: require create_modify+ permission + await require_permission(db, event.calendar_id, current_user.id, "create_modify") + + # Lock check applies to both paths (uses owner's calendar_id) + await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) + # Extract scope before applying fields to the model scope: Optional[str] = update_data.pop("edit_scope", None) @@ -440,23 +492,24 @@ async def update_event( if rule_obj is not None: update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None - # SEC-04: if calendar_id is being changed, verify the target belongs to the user - # Only verify ownership when the calendar is actually changing — members submitting - # an unchanged calendar_id must not be rejected just because they aren't the owner. - if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id: - await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) + if not is_invited_editor: + # SEC-04: if calendar_id is being changed, verify the target belongs to the user + # Only verify ownership when the calendar is actually changing — members submitting + # an unchanged calendar_id must not be rejected just because they aren't the owner. + if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id: + await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) - # M-01: Block non-owners from moving events off shared calendars - if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: - source_cal_result = await db.execute( - select(Calendar).where(Calendar.id == event.calendar_id) - ) - source_cal = source_cal_result.scalar_one_or_none() - if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id: - raise HTTPException( - status_code=403, - detail="Only the calendar owner can move events between calendars", + # M-01: Block non-owners from moving events off shared calendars + if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: + source_cal_result = await db.execute( + select(Calendar).where(Calendar.id == event.calendar_id) ) + source_cal = source_cal_result.scalar_one_or_none() + if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id: + raise HTTPException( + status_code=403, + detail="Only the calendar owner can move events between calendars", + ) start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) diff --git a/backend/app/schemas/event_invitation.py b/backend/app/schemas/event_invitation.py index 7b639e4..f59bb9a 100644 --- a/backend/app/schemas/event_invitation.py +++ b/backend/app/schemas/event_invitation.py @@ -24,6 +24,11 @@ class UpdateDisplayCalendar(BaseModel): calendar_id: Annotated[int, Field(ge=1, le=2147483647)] +class UpdateCanModify(BaseModel): + model_config = ConfigDict(extra="forbid") + can_modify: bool + + class EventInvitationResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -36,3 +41,4 @@ class EventInvitationResponse(BaseModel): invitee_name: Optional[str] = None invitee_umbral_name: Optional[str] = None display_calendar_id: Optional[int] = None + can_modify: bool = False diff --git a/backend/app/services/event_invitation.py b/backend/app/services/event_invitation.py index 23609db..5e4a872 100644 --- a/backend/app/services/event_invitation.py +++ b/backend/app/services/event_invitation.py @@ -152,6 +152,10 @@ async def respond_to_invitation( invitation.status = status invitation.responded_at = datetime.now() + # Clear can_modify on decline (F-02: prevent silent re-grant) + if status == "declined": + invitation.can_modify = False + # Auto-assign display calendar on accept/tentative (atomic: only if not already set) if status in ("accepted", "tentative"): default_cal = await db.execute( @@ -307,6 +311,7 @@ async def get_event_invitations( "responded_at": inv.responded_at, "invitee_name": preferred_name or umbral_name or "Unknown", "invitee_umbral_name": umbral_name or "Unknown", + "can_modify": inv.can_modify, } for inv, preferred_name, umbral_name in rows ] diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index a7eca35..b37c481 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -381,7 +381,7 @@ export default function CalendarPage() { end: event.end_datetime || undefined, allDay: event.all_day, color: 'transparent', - editable: !event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only', + editable: (event.is_invited && !!event.can_modify) || (!event.is_invited && permissionMap.get(event.calendar_id) !== 'read_only'), extendedProps: { is_virtual: event.is_virtual, is_recurring: event.is_recurring, @@ -389,6 +389,7 @@ export default function CalendarPage() { calendar_id: event.calendar_id, calendarColor: event.calendar_color || 'hsl(var(--accent-color))', is_invited: event.is_invited, + can_modify: event.can_modify, }, })); diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 4d34c47..0781656 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -262,11 +262,13 @@ export default function EventDetailPanel({ invitees, invite, isInviting, respond: respondInvitation, isResponding, override: overrideInvitation, updateDisplayCalendar, isUpdatingDisplayCalendar, leave: leaveInvitation, isLeaving, + toggleCanModify, togglingInvitationId, } = useEventInvitations(parentEventId); const { connections } = useConnectedUsersSearch(); const [showLeaveDialog, setShowLeaveDialog] = useState(false); const isInvitedEvent = !!event?.is_invited; + const canModifyAsInvitee = isInvitedEvent && !!event?.can_modify; const myInvitationStatus = event?.invitation_status ?? null; const myInvitationId = event?.invitation_id ?? null; @@ -313,7 +315,7 @@ export default function EventDetailPanel({ const isRecurring = !!(event?.is_recurring || event?.parent_event_id); // Permission helpers - const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access'; + const canEdit = canModifyAsInvitee || !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access'; const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access'; // Reset state when event changes @@ -364,10 +366,13 @@ export default function EventDetailPanel({ end_datetime: endDt, all_day: data.all_day, location_id: data.location_id ? parseInt(data.location_id) : null, - calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null, is_starred: data.is_starred, recurrence_rule: rule, }; + // Invited editors cannot change calendars — omit calendar_id from payload + if (!canModifyAsInvitee) { + payload.calendar_id = data.calendar_id ? parseInt(data.calendar_id) : null; + } if (event && !isCreating) { if (editScope) payload.edit_scope = editScope; @@ -437,7 +442,14 @@ export default function EventDetailPanel({ } if (isRecurring) { - setScopeStep('edit'); + // Invited editors can only edit "this" occurrence — skip scope step + if (canModifyAsInvitee) { + setEditScope('this'); + if (event) setEditState(buildEditStateFromEvent(event)); + setIsEditing(true); + } else { + setScopeStep('edit'); + } } else { if (event) setEditState(buildEditStateFromEvent(event)); setIsEditing(true); @@ -598,8 +610,8 @@ export default function EventDetailPanel({ <> {!event?.is_virtual && ( <> - {/* Edit button — only for own events or shared with edit permission */} - {canEdit && !isInvitedEvent && ( + {/* Edit button — own events, shared with edit permission, or can_modify invitees */} + {canEdit && (!isInvitedEvent || canModifyAsInvitee) && ( + )} ))} diff --git a/frontend/src/hooks/useEventInvitations.ts b/frontend/src/hooks/useEventInvitations.ts index 22fb06b..1c260d8 100644 --- a/frontend/src/hooks/useEventInvitations.ts +++ b/frontend/src/hooks/useEventInvitations.ts @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import api, { getErrorMessage } from '@/lib/api'; @@ -74,6 +75,24 @@ export function useEventInvitations(eventId: number | null) { }, }); + const [togglingInvitationId, setTogglingInvitationId] = useState(null); + const toggleCanModifyMutation = useMutation({ + mutationFn: async ({ invitationId, canModify }: { invitationId: number; canModify: boolean }) => { + setTogglingInvitationId(invitationId); + const { data } = await api.put(`/event-invitations/${invitationId}/can-modify`, { can_modify: canModify }); + return data; + }, + onSuccess: () => { + setTogglingInvitationId(null); + queryClient.invalidateQueries({ queryKey: ['event-invitations', eventId] }); + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + }, + onError: (error) => { + setTogglingInvitationId(null); + toast.error(getErrorMessage(error, 'Failed to update edit access')); + }, + }); + const leaveMutation = useMutation({ mutationFn: async (invitationId: number) => { await api.delete(`/event-invitations/${invitationId}`); @@ -102,6 +121,9 @@ export function useEventInvitations(eventId: number | null) { isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending, leave: leaveMutation.mutateAsync, isLeaving: leaveMutation.isPending, + toggleCanModify: toggleCanModifyMutation.mutateAsync, + isTogglingCanModify: toggleCanModifyMutation.isPending, + togglingInvitationId, }; } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f228206..357a297 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -116,6 +116,7 @@ export interface CalendarEvent { invitation_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null; invitation_id?: number | null; display_calendar_id?: number | null; + can_modify?: boolean; created_at: string; updated_at: string; } @@ -502,6 +503,7 @@ export interface EventInvitation { responded_at: string | null; invitee_name: string; invitee_umbral_name: string; + can_modify?: boolean; } export interface PendingEventInvitation {