Phase 4: Event locking + permission gating for shared calendars

- useEventLock hook with auto-release on unmount/event change
- EventLockBanner component for locked event display
- EventDetailPanel: lock acquire on edit, release on save/cancel, permission-gated edit/delete buttons
- CalendarPage: permission map from owned+shared calendars, per-event editable gating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-06 05:24:43 +08:00
parent 4e3fd35040
commit eedfaaf859
4 changed files with 199 additions and 32 deletions

View File

@ -9,7 +9,7 @@ import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'; import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react'; import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types'; import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
import { useCalendars } from '@/hooks/useCalendars'; import { useCalendars } from '@/hooks/useCalendars';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -42,7 +42,7 @@ export default function CalendarPage() {
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null); const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const { settings } = useSettings(); const { settings } = useSettings();
const { data: calendars = [] } = useCalendars(); const { data: calendars = [], sharedData } = useCalendars();
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set()); const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
const calendarContainerRef = useRef<HTMLDivElement>(null); const calendarContainerRef = useRef<HTMLDivElement>(null);
@ -62,6 +62,14 @@ export default function CalendarPage() {
return map; return map;
}, [locations]); }, [locations]);
// Build permission map: calendar_id -> permission level
const permissionMap = useMemo(() => {
const map = new Map<number, CalendarPermission | 'owner'>();
calendars.forEach((cal) => map.set(cal.id, 'owner'));
sharedData.forEach((m) => map.set(m.calendar_id, m.permission));
return map;
}, [calendars, sharedData]);
// Handle navigation state from dashboard // Handle navigation state from dashboard
useEffect(() => { useEffect(() => {
const state = location.state as { date?: string; view?: string; eventId?: number } | null; const state = location.state as { date?: string; view?: string; eventId?: number } | null;
@ -139,6 +147,9 @@ export default function CalendarPage() {
[selectedEventId, events], [selectedEventId, events],
); );
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false;
// Escape key closes detail panel // Escape key closes detail panel
useEffect(() => { useEffect(() => {
if (!panelOpen) return; if (!panelOpen) return;
@ -498,6 +509,8 @@ export default function CalendarPage() {
onClose={handlePanelClose} onClose={handlePanelClose}
onSaved={handlePanelClose} onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
myPermission={selectedEventPermission}
isSharedEvent={selectedEventIsShared}
/> />
</div> </div>
</div> </div>
@ -520,6 +533,8 @@ export default function CalendarPage() {
onClose={handlePanelClose} onClose={handlePanelClose}
onSaved={handlePanelClose} onSaved={handlePanelClose}
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined} locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
myPermission={selectedEventPermission}
isSharedEvent={selectedEventIsShared}
/> />
</div> </div>
</div> </div>

View File

@ -3,14 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { import {
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api'; import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types'; import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarPermission, EventLockInfo } from '@/types';
import { useCalendars } from '@/hooks/useCalendars'; import { useCalendars } from '@/hooks/useCalendars';
import { useConfirmAction } from '@/hooks/useConfirmAction'; import { useConfirmAction } from '@/hooks/useConfirmAction';
import { useEventLock } from '@/hooks/useEventLock';
import { formatUpdatedAt } from '@/components/shared/utils'; import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField'; import CopyableField from '@/components/shared/CopyableField';
import EventLockBanner from './EventLockBanner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
@ -126,6 +128,8 @@ interface EventDetailPanelProps {
onSaved?: () => void; onSaved?: () => void;
onDeleted?: () => void; onDeleted?: () => void;
locationName?: string; locationName?: string;
myPermission?: CalendarPermission | 'owner' | null;
isSharedEvent?: boolean;
} }
interface EditState { interface EditState {
@ -218,6 +222,8 @@ export default function EventDetailPanel({
onSaved, onSaved,
onDeleted, onDeleted,
locationName, locationName,
myPermission,
isSharedEvent = false,
}: EventDetailPanelProps) { }: EventDetailPanelProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars(); const { data: calendars = [] } = useCalendars();
@ -233,6 +239,11 @@ export default function EventDetailPanel({
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
const { acquire: acquireLock, release: releaseLock, isAcquiring: isAcquiringLock } = useEventLock(
isSharedEvent && event ? (typeof event.id === 'number' ? event.id : null) : null
);
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
const [isEditing, setIsEditing] = useState(isCreating); const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() => const [editState, setEditState] = useState<EditState>(() =>
isCreating isCreating
@ -247,12 +258,17 @@ export default function EventDetailPanel({
const isRecurring = !!(event?.is_recurring || event?.parent_event_id); const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Permission helpers
const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
// Reset state when event changes // Reset state when event changes
useEffect(() => { useEffect(() => {
setIsEditing(false); setIsEditing(false);
setScopeStep(null); setScopeStep(null);
setEditScope(null); setEditScope(null);
setLocationSearch(''); setLocationSearch('');
setLockInfo(null);
if (event) setEditState(buildEditStateFromEvent(event)); if (event) setEditState(buildEditStateFromEvent(event));
}, [event?.id]); }, [event?.id]);
@ -307,6 +323,7 @@ export default function EventDetailPanel({
} }
}, },
onSuccess: () => { onSuccess: () => {
if (isSharedEvent) releaseLock();
invalidateAll(); invalidateAll();
toast.success(isCreating ? 'Event created' : 'Event updated'); toast.success(isCreating ? 'Event created' : 'Event updated');
if (isCreating) { if (isCreating) {
@ -343,7 +360,30 @@ export default function EventDetailPanel({
// --- Handlers --- // --- Handlers ---
const handleEditStart = () => { const handleEditStart = async () => {
// For shared events, acquire lock first (owners skip locking)
if (isSharedEvent && myPermission !== 'owner' && event && typeof event.id === 'number') {
try {
await acquireLock();
setLockInfo(null);
} catch (err: unknown) {
if (err && typeof err === 'object' && 'response' in err) {
const axErr = err as { response?: { status?: number; data?: { detail?: string; locked_by_name?: string; expires_at?: string; is_permanent?: boolean } } };
if (axErr.response?.status === 423) {
setLockInfo({
locked: true,
locked_by_name: axErr.response.data?.locked_by_name || 'another user',
expires_at: axErr.response.data?.expires_at || null,
is_permanent: axErr.response.data?.is_permanent || false,
});
return;
}
}
toast.error('Failed to acquire edit lock');
return;
}
}
if (isRecurring) { if (isRecurring) {
setScopeStep('edit'); setScopeStep('edit');
} else { } else {
@ -361,8 +401,6 @@ export default function EventDetailPanel({
} else if (scopeStep === 'delete') { } else if (scopeStep === 'delete') {
// Delete with scope — execute immediately // Delete with scope — execute immediately
setScopeStep(null); setScopeStep(null);
// The deleteMutation will read editScope, but we need to set it first
// Since setState is async, use the mutation directly with the scope
const scopeParam = `?scope=${scope}`; const scopeParam = `?scope=${scope}`;
api.delete(`/events/${event!.id}${scopeParam}`).then(() => { api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
invalidateAll(); invalidateAll();
@ -376,6 +414,7 @@ export default function EventDetailPanel({
}; };
const handleEditCancel = () => { const handleEditCancel = () => {
if (isSharedEvent) releaseLock();
setIsEditing(false); setIsEditing(false);
setEditScope(null); setEditScope(null);
setLocationSearch(''); setLocationSearch('');
@ -507,16 +546,20 @@ export default function EventDetailPanel({
<> <>
{!event?.is_virtual && ( {!event?.is_virtual && (
<> <>
{canEdit && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={handleEditStart} onClick={handleEditStart}
disabled={isAcquiringLock}
title="Edit event" title="Edit event"
> >
<Pencil className="h-3.5 w-3.5" /> {isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
</Button> </Button>
{confirmingDelete ? ( )}
{canDelete && (
confirmingDelete ? (
<Button <Button
variant="ghost" variant="ghost"
onClick={handleDeleteStart} onClick={handleDeleteStart}
@ -537,6 +580,7 @@ export default function EventDetailPanel({
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
)
)} )}
</> </>
)} )}
@ -557,6 +601,15 @@ export default function EventDetailPanel({
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{/* Lock banner */}
{lockInfo && lockInfo.locked && (
<EventLockBanner
lockedByName={lockInfo.locked_by_name || 'another user'}
expiresAt={lockInfo.expires_at}
isPermanent={lockInfo.is_permanent}
/>
)}
{scopeStep ? ( {scopeStep ? (
/* Scope selection step */ /* Scope selection step */
<div className="space-y-3"> <div className="space-y-3">

View File

@ -0,0 +1,30 @@
import { Lock } from 'lucide-react';
import { format, parseISO } from 'date-fns';
interface EventLockBannerProps {
lockedByName: string;
expiresAt: string | null;
isPermanent?: boolean;
}
export default function EventLockBanner({ lockedByName, expiresAt, isPermanent = false }: EventLockBannerProps) {
return (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 mb-3 animate-fade-in">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-amber-400 shrink-0" />
<div>
<p className="text-sm text-amber-200">
{isPermanent
? `Locked by owner (${lockedByName})`
: `Locked for editing by ${lockedByName}`}
</p>
{!isPermanent && expiresAt && (
<p className="text-xs text-muted-foreground">
Lock expires at {format(parseISO(expiresAt), 'h:mm a')}
</p>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,69 @@
import { useRef, useEffect, useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import api from '@/lib/api';
import type { EventLockInfo } from '@/types';
export function useEventLock(eventId: number | null) {
const lockHeldRef = useRef(false);
const activeEventIdRef = useRef<number | null>(null);
const acquireMutation = useMutation({
mutationFn: async (id: number) => {
const { data } = await api.post<EventLockInfo>(
`/shared-calendars/events/${id}/lock`
);
return data;
},
onSuccess: () => {
lockHeldRef.current = true;
activeEventIdRef.current = eventId;
},
});
const releaseMutation = useMutation({
mutationFn: async (id: number) => {
await api.delete(`/shared-calendars/events/${id}/lock`);
},
onSuccess: () => {
lockHeldRef.current = false;
activeEventIdRef.current = null;
},
});
const acquire = useCallback(async () => {
if (!eventId) return null;
const data = await acquireMutation.mutateAsync(eventId);
return data;
}, [eventId]);
const release = useCallback(async () => {
const id = activeEventIdRef.current;
if (!id || !lockHeldRef.current) return;
try {
await releaseMutation.mutateAsync(id);
} catch {
// Fire-and-forget on release errors
}
}, []);
// Auto-release on unmount or eventId change
useEffect(() => {
return () => {
const id = activeEventIdRef.current;
if (id && lockHeldRef.current) {
// Fire-and-forget cleanup
api.delete(`/shared-calendars/events/${id}/lock`).catch(() => {});
lockHeldRef.current = false;
activeEventIdRef.current = null;
}
};
}, [eventId]);
return {
acquire,
release,
isAcquiring: acquireMutation.isPending,
acquireError: acquireMutation.error,
lockHeld: lockHeldRef.current,
};
}