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 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>
|
||||||
|
|||||||
@ -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,37 +546,42 @@ export default function EventDetailPanel({
|
|||||||
<>
|
<>
|
||||||
{!event?.is_virtual && (
|
{!event?.is_virtual && (
|
||||||
<>
|
<>
|
||||||
<Button
|
{canEdit && (
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={handleEditStart}
|
|
||||||
title="Edit event"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
{confirmingDelete ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={handleDeleteStart}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
|
||||||
title="Confirm delete"
|
|
||||||
>
|
|
||||||
Sure?
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
className="h-7 w-7"
|
||||||
onClick={handleDeleteStart}
|
onClick={handleEditStart}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={isAcquiringLock}
|
||||||
title="Delete event"
|
title="Edit event"
|
||||||
>
|
>
|
||||||
<Trash2 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>
|
||||||
)}
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
confirmingDelete ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDeleteStart}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||||
|
title="Confirm delete"
|
||||||
|
>
|
||||||
|
Sure?
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={handleDeleteStart}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
title="Delete event"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<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">
|
||||||
|
|||||||
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