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:
parent
4e3fd35040
commit
eedfaaf859
@ -9,7 +9,7 @@ import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
|
||||
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
||||
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 { useSettings } from '@/hooks/useSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -42,7 +42,7 @@ export default function CalendarPage() {
|
||||
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
const { data: calendars = [], sharedData } = useCalendars();
|
||||
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
||||
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -62,6 +62,14 @@ export default function CalendarPage() {
|
||||
return map;
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
|
||||
@ -139,6 +147,9 @@ export default function CalendarPage() {
|
||||
[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
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
@ -498,6 +509,8 @@ export default function CalendarPage() {
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -520,6 +533,8 @@ export default function CalendarPage() {
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,14 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
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';
|
||||
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 { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { useEventLock } from '@/hooks/useEventLock';
|
||||
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||
import CopyableField from '@/components/shared/CopyableField';
|
||||
import EventLockBanner from './EventLockBanner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
@ -126,6 +128,8 @@ interface EventDetailPanelProps {
|
||||
onSaved?: () => void;
|
||||
onDeleted?: () => void;
|
||||
locationName?: string;
|
||||
myPermission?: CalendarPermission | 'owner' | null;
|
||||
isSharedEvent?: boolean;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
@ -218,6 +222,8 @@ export default function EventDetailPanel({
|
||||
onSaved,
|
||||
onDeleted,
|
||||
locationName,
|
||||
myPermission,
|
||||
isSharedEvent = false,
|
||||
}: EventDetailPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
@ -233,6 +239,11 @@ export default function EventDetailPanel({
|
||||
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 [editState, setEditState] = useState<EditState>(() =>
|
||||
isCreating
|
||||
@ -247,12 +258,17 @@ 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 canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
|
||||
|
||||
// Reset state when event changes
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
setScopeStep(null);
|
||||
setEditScope(null);
|
||||
setLocationSearch('');
|
||||
setLockInfo(null);
|
||||
if (event) setEditState(buildEditStateFromEvent(event));
|
||||
}, [event?.id]);
|
||||
|
||||
@ -307,6 +323,7 @@ export default function EventDetailPanel({
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (isSharedEvent) releaseLock();
|
||||
invalidateAll();
|
||||
toast.success(isCreating ? 'Event created' : 'Event updated');
|
||||
if (isCreating) {
|
||||
@ -343,7 +360,30 @@ export default function EventDetailPanel({
|
||||
|
||||
// --- 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) {
|
||||
setScopeStep('edit');
|
||||
} else {
|
||||
@ -361,8 +401,6 @@ export default function EventDetailPanel({
|
||||
} else if (scopeStep === 'delete') {
|
||||
// Delete with scope — execute immediately
|
||||
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}`;
|
||||
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
|
||||
invalidateAll();
|
||||
@ -376,6 +414,7 @@ export default function EventDetailPanel({
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
if (isSharedEvent) releaseLock();
|
||||
setIsEditing(false);
|
||||
setEditScope(null);
|
||||
setLocationSearch('');
|
||||
@ -507,16 +546,20 @@ export default function EventDetailPanel({
|
||||
<>
|
||||
{!event?.is_virtual && (
|
||||
<>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
disabled={isAcquiringLock}
|
||||
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>
|
||||
{confirmingDelete ? (
|
||||
)}
|
||||
{canDelete && (
|
||||
confirmingDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDeleteStart}
|
||||
@ -537,6 +580,7 @@ export default function EventDetailPanel({
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@ -557,6 +601,15 @@ export default function EventDetailPanel({
|
||||
|
||||
{/* Body */}
|
||||
<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 ? (
|
||||
/* Scope selection step */
|
||||
<div className="space-y-3">
|
||||
|
||||
30
frontend/src/components/calendar/EventLockBanner.tsx
Normal file
30
frontend/src/components/calendar/EventLockBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
frontend/src/hooks/useEventLock.ts
Normal file
69
frontend/src/hooks/useEventLock.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user