diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index df83bb0..140d046 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -356,6 +356,18 @@ async def update_event( if "calendar_id" in update_data and update_data["calendar_id"] is not None: 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", + ) + start = update_data.get("start_datetime", event.start_datetime) end_dt = update_data.get("end_datetime", event.end_datetime) if end_dt is not None and end_dt < start: diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py index 15da21f..0ce2b05 100644 --- a/backend/app/schemas/calendar.py +++ b/backend/app/schemas/calendar.py @@ -8,7 +8,6 @@ class CalendarCreate(BaseModel): name: str = Field(min_length=1, max_length=100) color: str = Field("#3b82f6", max_length=20) - is_shared: bool = False class CalendarUpdate(BaseModel): @@ -17,7 +16,6 @@ class CalendarUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) color: Optional[str] = Field(None, max_length=20) is_visible: Optional[bool] = None - is_shared: Optional[bool] = None class CalendarResponse(BaseModel): @@ -27,9 +25,9 @@ class CalendarResponse(BaseModel): is_default: bool is_system: bool is_visible: bool + is_shared: bool = False created_at: datetime updated_at: datetime - is_shared: bool = False owner_umbral_name: Optional[str] = None my_permission: Optional[str] = None my_can_add_others: bool = False diff --git a/frontend/src/components/calendar/CalendarForm.tsx b/frontend/src/components/calendar/CalendarForm.tsx index 842b694..6ad8466 100644 --- a/frontend/src/components/calendar/CalendarForm.tsx +++ b/frontend/src/components/calendar/CalendarForm.tsx @@ -15,7 +15,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; -import { Select } from '@/components/ui/select'; +import PermissionToggle from './PermissionToggle'; import { useConnections } from '@/hooks/useConnections'; import { useSharedCalendars } from '@/hooks/useSharedCalendars'; import CalendarMemberSearch from './CalendarMemberSearch'; @@ -131,7 +131,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { return ( - + {calendar ? 'Edit Calendar' : 'New Calendar'} @@ -188,29 +188,28 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) { {pendingInvite ? ( -
+
- + {pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
-
- + onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)} + /> +
-
- ) : ( - - )} + )} +
+ + {/* Row 2: Permission control or badge */} +
+ {readOnly ? ( + + ) : isOwner ? ( + <> + onUpdatePermission?.(member.id, p)} + /> + {(member.permission === 'create_modify' || member.permission === 'full_access') && ( + + )} + + ) : ( + + )} +
); } diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index cf5ad2a..ba8899a 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -149,6 +149,7 @@ export default function CalendarPage() { const { data } = await api.get('/events'); return data; }, + refetchInterval: 5_000, }); const selectedEvent = useMemo( @@ -279,10 +280,12 @@ export default function CalendarPage() { allDay: event.all_day, backgroundColor: event.calendar_color || 'hsl(var(--accent-color))', borderColor: event.calendar_color || 'hsl(var(--accent-color))', + editable: permissionMap.get(event.calendar_id) !== 'read_only', extendedProps: { is_virtual: event.is_virtual, is_recurring: event.is_recurring, parent_event_id: event.parent_event_id, + calendar_id: event.calendar_id, }, })); @@ -307,6 +310,11 @@ export default function CalendarPage() { toast.info('Click the event to edit recurring events'); return; } + if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') { + info.revert(); + toast.error('You have read-only access to this calendar'); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr @@ -331,6 +339,11 @@ export default function CalendarPage() { toast.info('Click the event to edit recurring events'); return; } + if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') { + info.revert(); + toast.error('You have read-only access to this calendar'); + return; + } const id = parseInt(info.event.id); const start = info.event.allDay ? info.event.startStr diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index b4e0e0e..f7ee3ff 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -250,6 +250,7 @@ export default function EventDetailPanel({ ); const [lockInfo, setLockInfo] = useState(null); + const [isEditing, setIsEditing] = useState(isCreating); const [editState, setEditState] = useState(() => isCreating @@ -262,6 +263,30 @@ export default function EventDetailPanel({ const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null); const [locationSearch, setLocationSearch] = useState(''); + // Poll lock status in view mode for shared events (Stream A: real-time lock awareness) + const viewLockQuery = useQuery({ + queryKey: ['event-lock', event?.id], + queryFn: async () => { + const { data } = await api.get( + `/shared-calendars/events/${event!.id}/lock` + ); + return data; + }, + enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating, + refetchInterval: 5_000, + }); + + // Show/hide lock banner proactively in view mode + useEffect(() => { + if (viewLockQuery.data && !isEditing && !isCreating) { + if (viewLockQuery.data.locked) { + setLockInfo(viewLockQuery.data); + } else { + setLockInfo(null); + } + } + }, [viewLockQuery.data, isEditing, isCreating]); + const isRecurring = !!(event?.is_recurring || event?.parent_event_id); // Permission helpers diff --git a/frontend/src/components/calendar/PermissionToggle.tsx b/frontend/src/components/calendar/PermissionToggle.tsx new file mode 100644 index 0000000..3e7cdd1 --- /dev/null +++ b/frontend/src/components/calendar/PermissionToggle.tsx @@ -0,0 +1,46 @@ +import { Eye, Pencil, Shield } from 'lucide-react'; +import type { CalendarPermission } from '@/types'; + +const segments: { value: CalendarPermission; label: string; shortLabel: string; icon: typeof Eye }[] = [ + { value: 'read_only', label: 'Read Only', shortLabel: 'Read', icon: Eye }, + { value: 'create_modify', label: 'Create & Modify', shortLabel: 'Edit', icon: Pencil }, + { value: 'full_access', label: 'Full Access', shortLabel: 'Full', icon: Shield }, +]; + +interface PermissionToggleProps { + value: CalendarPermission; + onChange: (permission: CalendarPermission) => void; + compact?: boolean; + className?: string; +} + +export default function PermissionToggle({ value, onChange, compact = false, className = '' }: PermissionToggleProps) { + return ( +
+ {segments.map((seg) => { + const isActive = value === seg.value; + const Icon = seg.icon; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/calendar/SharedCalendarSettings.tsx b/frontend/src/components/calendar/SharedCalendarSettings.tsx index 95dc1f4..62d0caa 100644 --- a/frontend/src/components/calendar/SharedCalendarSettings.tsx +++ b/frontend/src/components/calendar/SharedCalendarSettings.tsx @@ -69,7 +69,7 @@ export default function SharedCalendarSettings({ membership, onClose }: SharedCa return ( - + Shared Calendar Settings diff --git a/frontend/src/hooks/useSharedCalendars.ts b/frontend/src/hooks/useSharedCalendars.ts index a60e10b..d643c53 100644 --- a/frontend/src/hooks/useSharedCalendars.ts +++ b/frontend/src/hooks/useSharedCalendars.ts @@ -10,10 +10,10 @@ export function useSharedCalendars() { const incomingInvitesQuery = useQuery({ queryKey: ['calendar-invites', 'incoming'], queryFn: async () => { - const { data } = await api.get<{ invites: CalendarInvite[]; total: number }>( + const { data } = await api.get( '/shared-calendars/invites/incoming' ); - return data.invites; + return data; }, refetchOnMount: 'always' as const, });