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:
parent
8b39c961b6
commit
f35798c757
@ -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")
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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:
|
||||||
|
# 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")
|
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||||
|
|
||||||
# Shared calendar: require create_modify+ and check lock
|
# Load the event directly (bypassing calendar filter)
|
||||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
event_result = await db.execute(
|
||||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
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,6 +492,7 @@ 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
|
||||||
|
|
||||||
|
if not is_invited_editor:
|
||||||
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
|
# 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
|
# 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.
|
# an unchanged calendar_id must not be rejected just because they aren't the owner.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
// Invited editors can only edit "this" occurrence — skip scope step
|
||||||
|
if (canModifyAsInvitee) {
|
||||||
|
setEditScope('this');
|
||||||
|
if (event) setEditState(buildEditStateFromEvent(event));
|
||||||
|
setIsEditing(true);
|
||||||
|
} else {
|
||||||
setScopeStep('edit');
|
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,7 +788,8 @@ 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`}>
|
||||||
|
{!canModifyAsInvitee && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-calendar">Calendar</Label>
|
<Label htmlFor="panel-calendar">Calendar</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -790,6 +803,7 @@ export default function EventDetailPanel({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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,7 +836,8 @@ export default function EventDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recurrence */}
|
{/* Recurrence — hidden for invited editors (they can only edit "this" occurrence) */}
|
||||||
|
{!canModifyAsInvitee && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
<Label htmlFor="panel-recurrence">Recurrence</Label>
|
||||||
<Select
|
<Select
|
||||||
@ -838,6 +853,7 @@ export default function EventDetailPanel({
|
|||||||
<option value="monthly_date">Monthly (date)</option>
|
<option value="monthly_date">Monthly (date)</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user