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 ( from sqlalchemy import (
CheckConstraint, DateTime, Integer, ForeignKey, Index, Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
String, UniqueConstraint, func, String, UniqueConstraint, false as sa_false, func,
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from datetime import datetime from datetime import datetime
@ -38,6 +38,9 @@ class EventInvitation(Base):
display_calendar_id: Mapped[Optional[int]] = mapped_column( display_calendar_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("calendars.id", ondelete="SET NULL"), nullable=True 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") event: Mapped["CalendarEvent"] = relationship(lazy="raise")
user: Mapped["User"] = relationship(foreign_keys=[user_id], 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.event_invitation import EventInvitation
from app.models.user import User from app.models.user import User
from app.routers.auth import get_current_user from app.routers.auth import get_current_user
from sqlalchemy.orm import selectinload
from app.schemas.event_invitation import ( from app.schemas.event_invitation import (
EventInvitationCreate, EventInvitationCreate,
EventInvitationRespond, EventInvitationRespond,
EventInvitationOverrideCreate, EventInvitationOverrideCreate,
UpdateCanModify,
UpdateDisplayCalendar, UpdateDisplayCalendar,
) )
from app.services.calendar_sharing import get_accessible_calendar_ids, get_user_permission 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 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) @router.delete("/{invitation_id}", status_code=204)
async def leave_or_revoke_invitation( async def leave_or_revoke_invitation(
invitation_id: int = Path(ge=1, le=2147483647), 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_id: int | None = None,
display_calendar_name: str | None = None, display_calendar_name: str | None = None,
display_calendar_color: str | None = None, display_calendar_color: str | None = None,
can_modify: bool = False,
) -> dict: ) -> dict:
"""Serialize a CalendarEvent ORM object to a response dict including calendar info.""" """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 # 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_status": invitation_status,
"invitation_id": invitation_id, "invitation_id": invitation_id,
"display_calendar_id": display_calendar_id, "display_calendar_id": display_calendar_id,
"can_modify": can_modify,
} }
return d return d
@ -207,7 +209,7 @@ async def get_events(
# Build invitation lookup for the current user # Build invitation lookup for the current user
invited_event_id_set = set(invited_event_ids) 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: if invited_event_ids:
inv_result = await db.execute( inv_result = await db.execute(
select( select(
@ -215,13 +217,14 @@ async def get_events(
EventInvitation.status, EventInvitation.status,
EventInvitation.id, EventInvitation.id,
EventInvitation.display_calendar_id, EventInvitation.display_calendar_id,
EventInvitation.can_modify,
).where( ).where(
EventInvitation.user_id == current_user.id, EventInvitation.user_id == current_user.id,
EventInvitation.event_id.in_(invited_event_ids), EventInvitation.event_id.in_(invited_event_ids),
) )
) )
for eid, status, inv_id, disp_cal_id in inv_result.all(): for eid, status, inv_id, disp_cal_id, cm in inv_result.all():
invitation_map[eid] = (status, inv_id, disp_cal_id) invitation_map[eid] = (status, inv_id, disp_cal_id, cm)
# Batch-fetch display calendars for invited events # Batch-fetch display calendars for invited events
display_cal_ids = {t[2] for t in invitation_map.values() if t[2] is not None} 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_id = None
disp_cal_name = None disp_cal_name = None
disp_cal_color = None disp_cal_color = None
inv_can_modify = False
if is_invited and parent_id in invitation_map: 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 # Check for per-occurrence override
if e.id in override_map: if e.id in override_map:
inv_status = override_map[e.id] inv_status = override_map[e.id]
@ -267,6 +271,7 @@ async def get_events(
display_calendar_id=disp_cal_id, display_calendar_id=disp_cal_id,
display_calendar_name=disp_cal_name, display_calendar_name=disp_cal_name,
display_calendar_color=disp_cal_color, display_calendar_color=disp_cal_color,
can_modify=inv_can_modify,
)) ))
# Fetch the user's Birthdays system calendar; only generate virtual events if visible # 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), current_user: User = Depends(get_current_user),
): ):
# IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope). # IMPORTANT: Uses get_accessible_calendar_ids (NOT get_accessible_event_scope).
# Event invitees can VIEW events but must NOT be able to edit them. # Event invitees can VIEW events but must NOT be able to edit them
# Do not add invited_event_ids to this query. # UNLESS they have can_modify=True (checked in fallback path below).
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db)
is_invited_editor = False
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
@ -424,14 +430,60 @@ async def update_event(
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
if not event: 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 inv_result = await db.execute(
await require_permission(db, event.calendar_id, current_user.id, "create_modify") select(EventInvitation).where(
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id) 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) 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 # Extract scope before applying fields to the model
scope: Optional[str] = update_data.pop("edit_scope", None) scope: Optional[str] = update_data.pop("edit_scope", None)
@ -440,23 +492,24 @@ async def update_event(
if rule_obj is not None: 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 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 if not is_invited_editor:
# Only verify ownership when the calendar is actually changing — members submitting # SEC-04: if calendar_id is being changed, verify the target belongs to the user
# an unchanged calendar_id must not be rejected just because they aren't the owner. # Only verify ownership when the calendar is actually changing — members submitting
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id: # an unchanged calendar_id must not be rejected just because they aren't the owner.
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id) 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 # 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: if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
source_cal_result = await db.execute( source_cal_result = await db.execute(
select(Calendar).where(Calendar.id == event.calendar_id) 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",
) )
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) start = update_data.get("start_datetime", event.start_datetime)
end_dt = update_data.get("end_datetime", event.end_datetime) end_dt = update_data.get("end_datetime", event.end_datetime)

View File

@ -24,6 +24,11 @@ class UpdateDisplayCalendar(BaseModel):
calendar_id: Annotated[int, Field(ge=1, le=2147483647)] calendar_id: Annotated[int, Field(ge=1, le=2147483647)]
class UpdateCanModify(BaseModel):
model_config = ConfigDict(extra="forbid")
can_modify: bool
class EventInvitationResponse(BaseModel): class EventInvitationResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@ -36,3 +41,4 @@ class EventInvitationResponse(BaseModel):
invitee_name: Optional[str] = None invitee_name: Optional[str] = None
invitee_umbral_name: Optional[str] = None invitee_umbral_name: Optional[str] = None
display_calendar_id: Optional[int] = 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.status = status
invitation.responded_at = datetime.now() 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) # Auto-assign display calendar on accept/tentative (atomic: only if not already set)
if status in ("accepted", "tentative"): if status in ("accepted", "tentative"):
default_cal = await db.execute( default_cal = await db.execute(
@ -307,6 +311,7 @@ async def get_event_invitations(
"responded_at": inv.responded_at, "responded_at": inv.responded_at,
"invitee_name": preferred_name or umbral_name or "Unknown", "invitee_name": preferred_name or umbral_name or "Unknown",
"invitee_umbral_name": umbral_name or "Unknown", "invitee_umbral_name": umbral_name or "Unknown",
"can_modify": inv.can_modify,
} }
for inv, preferred_name, umbral_name in rows for inv, preferred_name, umbral_name in rows
] ]

View File

@ -381,7 +381,7 @@ export default function CalendarPage() {
end: event.end_datetime || undefined, end: event.end_datetime || undefined,
allDay: event.all_day, allDay: event.all_day,
color: 'transparent', 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: { extendedProps: {
is_virtual: event.is_virtual, is_virtual: event.is_virtual,
is_recurring: event.is_recurring, is_recurring: event.is_recurring,
@ -389,6 +389,7 @@ export default function CalendarPage() {
calendar_id: event.calendar_id, calendar_id: event.calendar_id,
calendarColor: event.calendar_color || 'hsl(var(--accent-color))', calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
is_invited: event.is_invited, 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, invitees, invite, isInviting, respond: respondInvitation,
isResponding, override: overrideInvitation, updateDisplayCalendar, isResponding, override: overrideInvitation, updateDisplayCalendar,
isUpdatingDisplayCalendar, leave: leaveInvitation, isLeaving, isUpdatingDisplayCalendar, leave: leaveInvitation, isLeaving,
toggleCanModify, togglingInvitationId,
} = useEventInvitations(parentEventId); } = useEventInvitations(parentEventId);
const { connections } = useConnectedUsersSearch(); const { connections } = useConnectedUsersSearch();
const [showLeaveDialog, setShowLeaveDialog] = useState(false); const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const isInvitedEvent = !!event?.is_invited; const isInvitedEvent = !!event?.is_invited;
const canModifyAsInvitee = isInvitedEvent && !!event?.can_modify;
const myInvitationStatus = event?.invitation_status ?? null; const myInvitationStatus = event?.invitation_status ?? null;
const myInvitationId = event?.invitation_id ?? null; const myInvitationId = event?.invitation_id ?? null;
@ -313,7 +315,7 @@ export default function EventDetailPanel({
const isRecurring = !!(event?.is_recurring || event?.parent_event_id); const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Permission helpers // 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'; const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
// Reset state when event changes // Reset state when event changes
@ -364,10 +366,13 @@ export default function EventDetailPanel({
end_datetime: endDt, end_datetime: endDt,
all_day: data.all_day, all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null, 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, is_starred: data.is_starred,
recurrence_rule: rule, 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 (event && !isCreating) {
if (editScope) payload.edit_scope = editScope; if (editScope) payload.edit_scope = editScope;
@ -437,7 +442,14 @@ export default function EventDetailPanel({
} }
if (isRecurring) { 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 { } else {
if (event) setEditState(buildEditStateFromEvent(event)); if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true); setIsEditing(true);
@ -598,8 +610,8 @@ export default function EventDetailPanel({
<> <>
{!event?.is_virtual && ( {!event?.is_virtual && (
<> <>
{/* Edit button — only for own events or shared with edit permission */} {/* Edit button — own events, shared with edit permission, or can_modify invitees */}
{canEdit && !isInvitedEvent && ( {canEdit && (!isInvitedEvent || canModifyAsInvitee) && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -776,20 +788,22 @@ export default function EventDetailPanel({
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className={`grid ${canModifyAsInvitee ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
<div className="space-y-1"> {!canModifyAsInvitee && (
<Label htmlFor="panel-calendar">Calendar</Label> <div className="space-y-1">
<Select <Label htmlFor="panel-calendar">Calendar</Label>
id="panel-calendar" <Select
value={editState.calendar_id} id="panel-calendar"
onChange={(e) => updateField('calendar_id', e.target.value)} value={editState.calendar_id}
className="text-xs" onChange={(e) => updateField('calendar_id', e.target.value)}
> className="text-xs"
{selectableCalendars.map((cal) => ( >
<option key={cal.id} value={cal.id}>{cal.name}</option> {selectableCalendars.map((cal) => (
))} <option key={cal.id} value={cal.id}>{cal.name}</option>
</Select> ))}
</div> </Select>
</div>
)}
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="panel-location">Location</Label> <Label htmlFor="panel-location">Location</Label>
<LocationPicker <LocationPicker
@ -822,22 +836,24 @@ export default function EventDetailPanel({
</div> </div>
</div> </div>
{/* Recurrence */} {/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
<div className="space-y-1"> {!canModifyAsInvitee && (
<Label htmlFor="panel-recurrence">Recurrence</Label> <div className="space-y-1">
<Select <Label htmlFor="panel-recurrence">Recurrence</Label>
id="panel-recurrence" <Select
value={editState.recurrence_type} id="panel-recurrence"
onChange={(e) => updateField('recurrence_type', e.target.value)} value={editState.recurrence_type}
className="text-xs" onChange={(e) => updateField('recurrence_type', e.target.value)}
> className="text-xs"
<option value="">None</option> >
<option value="every_n_days">Every X days</option> <option value="">None</option>
<option value="weekly">Weekly</option> <option value="every_n_days">Every X days</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option> <option value="weekly">Weekly</option>
<option value="monthly_date">Monthly (date)</option> <option value="monthly_nth_weekday">Monthly (nth weekday)</option>
</Select> <option value="monthly_date">Monthly (date)</option>
</div> </Select>
</div>
)}
{editState.recurrence_type === 'every_n_days' && ( {editState.recurrence_type === 'every_n_days' && (
<div className="space-y-1"> <div className="space-y-1">
@ -1077,6 +1093,11 @@ export default function EventDetailPanel({
<InviteeList <InviteeList
invitees={invitees} invitees={invitees}
isRecurringChild={!!event.parent_event_id} 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 { 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 { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { EventInvitation, Connection } from '@/types'; import type { EventInvitation, Connection } from '@/types';
@ -37,9 +37,12 @@ function AvatarCircle({ name }: { name: string }) {
interface InviteeListProps { interface InviteeListProps {
invitees: EventInvitation[]; invitees: EventInvitation[];
isRecurringChild?: boolean; 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; if (invitees.length === 0) return null;
const goingCount = invitees.filter((i) => i.status === 'accepted').length; 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"> <div key={inv.id} className="flex items-center gap-2 py-1">
<AvatarCircle name={inv.invitee_name} /> <AvatarCircle name={inv.invitee_name} />
<span className="text-sm flex-1 truncate">{inv.invitee_name}</span> <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} /> <StatusBadge status={inv.status} />
</div> </div>
))} ))}

View File

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import api, { getErrorMessage } from '@/lib/api'; 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({ const leaveMutation = useMutation({
mutationFn: async (invitationId: number) => { mutationFn: async (invitationId: number) => {
await api.delete(`/event-invitations/${invitationId}`); await api.delete(`/event-invitations/${invitationId}`);
@ -102,6 +121,9 @@ export function useEventInvitations(eventId: number | null) {
isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending, isUpdatingDisplayCalendar: updateDisplayCalendarMutation.isPending,
leave: leaveMutation.mutateAsync, leave: leaveMutation.mutateAsync,
isLeaving: leaveMutation.isPending, 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_status?: 'pending' | 'accepted' | 'tentative' | 'declined' | null;
invitation_id?: number | null; invitation_id?: number | null;
display_calendar_id?: number | null; display_calendar_id?: number | null;
can_modify?: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -502,6 +503,7 @@ export interface EventInvitation {
responded_at: string | null; responded_at: string | null;
invitee_name: string; invitee_name: string;
invitee_umbral_name: string; invitee_umbral_name: string;
can_modify?: boolean;
} }
export interface PendingEventInvitation { export interface PendingEventInvitation {