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) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 00:59:36 +08:00
parent 8b39c961b6
commit f35798c757
11 changed files with 259 additions and 65 deletions

View File

@ -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")

View File

@ -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")

View File

@ -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),

View File

@ -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:
# 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])
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")
# 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)
# 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,6 +492,7 @@ 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
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.

View File

@ -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

View File

@ -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
]

View File

@ -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,
},
}));

View File

@ -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) {
// 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) && (
<Button
variant="ghost"
size="icon"
@ -776,7 +788,8 @@ export default function EventDetailPanel({
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
{!canModifyAsInvitee && (
<div className="space-y-1">
<Label htmlFor="panel-calendar">Calendar</Label>
<Select
@ -790,6 +803,7 @@ export default function EventDetailPanel({
))}
</Select>
</div>
)}
<div className="space-y-1">
<Label htmlFor="panel-location">Location</Label>
<LocationPicker
@ -822,7 +836,8 @@ export default function EventDetailPanel({
</div>
</div>
{/* Recurrence */}
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
{!canModifyAsInvitee && (
<div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label>
<Select
@ -838,6 +853,7 @@ export default function EventDetailPanel({
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
)}
{editState.recurrence_type === 'every_n_days' && (
<div className="space-y-1">
@ -1077,6 +1093,11 @@ export default function EventDetailPanel({
<InviteeList
invitees={invitees}
isRecurringChild={!!event.parent_event_id}
isOwner={myPermission === 'owner'}
onToggleCanModify={(invitationId, canModify) =>
toggleCanModify({ invitationId, canModify })
}
togglingInvitationId={togglingInvitationId}
/>
)}

View File

@ -1,5 +1,5 @@
import { useState, useMemo } from 'react';
import { Users, UserPlus, Search, X } from 'lucide-react';
import { Users, UserPlus, Search, X, Pencil, PencilOff } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import type { EventInvitation, Connection } from '@/types';
@ -37,9 +37,12 @@ function AvatarCircle({ name }: { name: string }) {
interface InviteeListProps {
invitees: EventInvitation[];
isRecurringChild?: boolean;
isOwner?: boolean;
onToggleCanModify?: (invitationId: number, canModify: boolean) => void;
togglingInvitationId?: number | null;
}
export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
export function InviteeList({ invitees, isRecurringChild, isOwner, onToggleCanModify, togglingInvitationId }: InviteeListProps) {
if (invitees.length === 0) return null;
const goingCount = invitees.filter((i) => i.status === 'accepted').length;
@ -61,6 +64,22 @@ export function InviteeList({ invitees, isRecurringChild }: InviteeListProps) {
<div key={inv.id} className="flex items-center gap-2 py-1">
<AvatarCircle name={inv.invitee_name} />
<span className="text-sm flex-1 truncate">{inv.invitee_name}</span>
{isOwner && onToggleCanModify && (
<button
type="button"
onClick={() => onToggleCanModify(inv.id, !inv.can_modify)}
disabled={togglingInvitationId === inv.id}
title={inv.can_modify ? 'Remove edit access' : 'Allow editing'}
className={`p-1 rounded transition-colors ${
inv.can_modify
? 'text-accent bg-accent/10'
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}`}
style={inv.can_modify ? { color: 'hsl(var(--accent-color))', backgroundColor: 'hsl(var(--accent-color) / 0.1)' } : undefined}
>
{inv.can_modify ? <Pencil className="h-3 w-3" /> : <PencilOff className="h-3 w-3" />}
</button>
)}
<StatusBadge status={inv.status} />
</div>
))}

View File

@ -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<number | null>(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,
};
}

View File

@ -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 {