Phase 6: Real-time sync, drag-drop guards, security fix, invite bug fix, UI polish
- Event polling (5s refetchInterval) so collaborators see changes without refresh - Lock status polling in EventDetailPanel view mode — proactive lock banner - Per-event editable flag blocks drag on read-only shared events - Read-only permission guard in handleEventDrop/handleEventResize - M-01 security fix: block non-owners from moving events off shared calendars (403) - Fix invite response type (backend returns list, not wrapper object) - Remove is_shared from CalendarCreate/CalendarUpdate input schemas - New PermissionToggle segmented control (Eye/Pencil/Shield icons) - CalendarMemberRow restructured into spacious two-line card layout - CalendarForm dialog widened (sm:max-w-2xl), polished invite card with accent border - SharedCalendarSettings dialog widened (sm:max-w-lg) - CalendarMemberList max-height increased (max-h-48 → max-h-72) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
14fc085009
commit
b401fd9392
@ -356,6 +356,18 @@ async def update_event(
|
|||||||
if "calendar_id" in update_data and update_data["calendar_id"] is not None:
|
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)
|
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)
|
start = update_data.get("start_datetime", event.start_datetime)
|
||||||
end_dt = update_data.get("end_datetime", event.end_datetime)
|
end_dt = update_data.get("end_datetime", event.end_datetime)
|
||||||
if end_dt is not None and end_dt < start:
|
if end_dt is not None and end_dt < start:
|
||||||
|
|||||||
@ -8,7 +8,6 @@ class CalendarCreate(BaseModel):
|
|||||||
|
|
||||||
name: str = Field(min_length=1, max_length=100)
|
name: str = Field(min_length=1, max_length=100)
|
||||||
color: str = Field("#3b82f6", max_length=20)
|
color: str = Field("#3b82f6", max_length=20)
|
||||||
is_shared: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class CalendarUpdate(BaseModel):
|
class CalendarUpdate(BaseModel):
|
||||||
@ -17,7 +16,6 @@ class CalendarUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
color: Optional[str] = Field(None, max_length=20)
|
color: Optional[str] = Field(None, max_length=20)
|
||||||
is_visible: Optional[bool] = None
|
is_visible: Optional[bool] = None
|
||||||
is_shared: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class CalendarResponse(BaseModel):
|
class CalendarResponse(BaseModel):
|
||||||
@ -27,9 +25,9 @@ class CalendarResponse(BaseModel):
|
|||||||
is_default: bool
|
is_default: bool
|
||||||
is_system: bool
|
is_system: bool
|
||||||
is_visible: bool
|
is_visible: bool
|
||||||
|
is_shared: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
is_shared: bool = False
|
|
||||||
owner_umbral_name: Optional[str] = None
|
owner_umbral_name: Optional[str] = None
|
||||||
my_permission: Optional[str] = None
|
my_permission: Optional[str] = None
|
||||||
my_can_add_others: bool = False
|
my_can_add_others: bool = False
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Select } from '@/components/ui/select';
|
import PermissionToggle from './PermissionToggle';
|
||||||
import { useConnections } from '@/hooks/useConnections';
|
import { useConnections } from '@/hooks/useConnections';
|
||||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||||
import CalendarMemberSearch from './CalendarMemberSearch';
|
import CalendarMemberSearch from './CalendarMemberSearch';
|
||||||
@ -131,7 +131,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent className={isShared && showSharing ? 'sm:max-w-lg' : undefined}>
|
<DialogContent className={isShared && showSharing ? 'sm:max-w-2xl' : undefined}>
|
||||||
<DialogClose onClick={onClose} />
|
<DialogClose onClick={onClose} />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
||||||
@ -188,29 +188,28 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pendingInvite ? (
|
{pendingInvite ? (
|
||||||
<div className="rounded-md border border-border bg-card-elevated p-3 space-y-2 animate-fade-in">
|
<div
|
||||||
|
className="rounded-lg border border-border bg-card-elevated p-4 space-y-3 animate-fade-in"
|
||||||
|
style={{ borderLeftWidth: '3px', borderLeftColor: 'hsl(var(--accent-color) / 0.5)' }}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
|
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPendingInvite(null)}
|
onClick={() => setPendingInvite(null)}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Select
|
<PermissionToggle
|
||||||
value={pendingInvite.permission}
|
value={pendingInvite.permission}
|
||||||
onChange={(e) => setPendingInvite((prev) => prev ? { ...prev, permission: e.target.value as CalendarPermission } : null)}
|
onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)}
|
||||||
className="text-xs flex-1"
|
/>
|
||||||
>
|
<div className="flex-1" />
|
||||||
<option value="read_only">Read Only</option>
|
|
||||||
<option value="create_modify">Create / Modify</option>
|
|
||||||
<option value="full_access">Full Access</option>
|
|
||||||
</Select>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export default function CalendarMemberList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
<div className="space-y-2 max-h-72 overflow-y-auto">
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<CalendarMemberRow
|
<CalendarMemberRow
|
||||||
key={member.id}
|
key={member.id}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { X, UserPlus } from 'lucide-react';
|
import { X, UserPlus } from 'lucide-react';
|
||||||
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
|
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
|
||||||
import { Select } from '@/components/ui/select';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||||
import PermissionBadge from './PermissionBadge';
|
import PermissionBadge from './PermissionBadge';
|
||||||
|
import PermissionToggle from './PermissionToggle';
|
||||||
|
|
||||||
interface CalendarMemberRowProps {
|
interface CalendarMemberRowProps {
|
||||||
member: CalendarMemberInfo;
|
member: CalendarMemberInfo;
|
||||||
@ -30,7 +30,9 @@ export default function CalendarMemberRow({
|
|||||||
const initial = displayName.charAt(0).toUpperCase();
|
const initial = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5 rounded-lg border border-border p-3 transition-all duration-300">
|
<div className="rounded-lg border border-border p-3 space-y-2.5 transition-all duration-200 hover:border-border/80">
|
||||||
|
{/* Row 1: Avatar + Name + Status + Remove */}
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||||
<span className="text-sm text-violet-400 font-medium">{initial}</span>
|
<span className="text-sm text-violet-400 font-medium">{initial}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -49,51 +51,47 @@ export default function CalendarMemberRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isOwner && !readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveClick}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||||
|
title={confirming ? 'Click again to confirm' : 'Remove member'}
|
||||||
|
>
|
||||||
|
{confirming ? (
|
||||||
|
<span className="text-[10px] text-destructive font-medium px-1">Sure?</span>
|
||||||
|
) : (
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Permission control or badge */}
|
||||||
|
<div className="flex items-center justify-between gap-2 pl-[42px]">
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<PermissionBadge permission={member.permission} />
|
<PermissionBadge permission={member.permission} />
|
||||||
) : isOwner ? (
|
) : isOwner ? (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<>
|
||||||
<Select
|
<PermissionToggle
|
||||||
value={member.permission}
|
value={member.permission}
|
||||||
onChange={(e) =>
|
onChange={(p) => onUpdatePermission?.(member.id, p)}
|
||||||
onUpdatePermission?.(member.id, e.target.value as CalendarPermission)
|
/>
|
||||||
}
|
|
||||||
className="h-7 w-auto text-xs py-0 pr-7 pl-2"
|
|
||||||
>
|
|
||||||
<option value="read_only">Read Only</option>
|
|
||||||
<option value="create_modify">Create/Modify</option>
|
|
||||||
<option value="full_access">Full Access</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{(member.permission === 'create_modify' || member.permission === 'full_access') && (
|
{(member.permission === 'create_modify' || member.permission === 'full_access') && (
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer" title="Can add others">
|
<label className="flex items-center gap-1.5 cursor-pointer shrink-0" title="Can add others">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={member.can_add_others}
|
checked={member.can_add_others}
|
||||||
onChange={() =>
|
onChange={() => onUpdateCanAddOthers?.(member.id, !member.can_add_others)}
|
||||||
onUpdateCanAddOthers?.(member.id, !member.can_add_others)
|
|
||||||
}
|
|
||||||
className="h-3.5 w-3.5"
|
className="h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemoveClick}
|
|
||||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
title={confirming ? 'Click again to confirm' : 'Remove member'}
|
|
||||||
>
|
|
||||||
{confirming ? (
|
|
||||||
<span className="text-[10px] text-destructive font-medium">Sure?</span>
|
|
||||||
) : (
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<PermissionBadge permission={member.permission} />
|
<PermissionBadge permission={member.permission} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,6 +149,7 @@ export default function CalendarPage() {
|
|||||||
const { data } = await api.get<CalendarEvent[]>('/events');
|
const { data } = await api.get<CalendarEvent[]>('/events');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
refetchInterval: 5_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedEvent = useMemo(
|
const selectedEvent = useMemo(
|
||||||
@ -279,10 +280,12 @@ export default function CalendarPage() {
|
|||||||
allDay: event.all_day,
|
allDay: event.all_day,
|
||||||
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
|
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||||
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||||
|
editable: 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,
|
||||||
parent_event_id: event.parent_event_id,
|
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');
|
toast.info('Click the event to edit recurring events');
|
||||||
return;
|
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 id = parseInt(info.event.id);
|
||||||
const start = info.event.allDay
|
const start = info.event.allDay
|
||||||
? info.event.startStr
|
? info.event.startStr
|
||||||
@ -331,6 +339,11 @@ export default function CalendarPage() {
|
|||||||
toast.info('Click the event to edit recurring events');
|
toast.info('Click the event to edit recurring events');
|
||||||
return;
|
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 id = parseInt(info.event.id);
|
||||||
const start = info.event.allDay
|
const start = info.event.allDay
|
||||||
? info.event.startStr
|
? info.event.startStr
|
||||||
|
|||||||
@ -250,6 +250,7 @@ export default function EventDetailPanel({
|
|||||||
);
|
);
|
||||||
const [lockInfo, setLockInfo] = useState<EventLockInfo | 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
|
||||||
@ -262,6 +263,30 @@ export default function EventDetailPanel({
|
|||||||
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
||||||
const [locationSearch, setLocationSearch] = useState('');
|
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<EventLockInfo>(
|
||||||
|
`/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);
|
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
|
||||||
|
|
||||||
// Permission helpers
|
// Permission helpers
|
||||||
|
|||||||
46
frontend/src/components/calendar/PermissionToggle.tsx
Normal file
46
frontend/src/components/calendar/PermissionToggle.tsx
Normal file
@ -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 (
|
||||||
|
<div className={`inline-flex items-center rounded-md border border-border overflow-hidden ${className}`}>
|
||||||
|
{segments.map((seg) => {
|
||||||
|
const isActive = value === seg.value;
|
||||||
|
const Icon = seg.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={seg.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(seg.value)}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium transition-colors duration-150 ${
|
||||||
|
isActive
|
||||||
|
? 'text-accent'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||||
|
color: isActive ? 'hsl(var(--accent-color))' : undefined,
|
||||||
|
}}
|
||||||
|
title={seg.label}
|
||||||
|
>
|
||||||
|
<Icon className="h-3 w-3 shrink-0" />
|
||||||
|
{!compact && <span>{seg.shortLabel}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -69,7 +69,7 @@ export default function SharedCalendarSettings({ membership, onClose }: SharedCa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={true} onOpenChange={onClose}>
|
<Dialog open={true} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogClose onClick={onClose} />
|
<DialogClose onClick={onClose} />
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Shared Calendar Settings</DialogTitle>
|
<DialogTitle>Shared Calendar Settings</DialogTitle>
|
||||||
|
|||||||
@ -10,10 +10,10 @@ export function useSharedCalendars() {
|
|||||||
const incomingInvitesQuery = useQuery({
|
const incomingInvitesQuery = useQuery({
|
||||||
queryKey: ['calendar-invites', 'incoming'],
|
queryKey: ['calendar-invites', 'incoming'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get<{ invites: CalendarInvite[]; total: number }>(
|
const { data } = await api.get<CalendarInvite[]>(
|
||||||
'/shared-calendars/invites/incoming'
|
'/shared-calendars/invites/incoming'
|
||||||
);
|
);
|
||||||
return data.invites;
|
return data;
|
||||||
},
|
},
|
||||||
refetchOnMount: 'always' as const,
|
refetchOnMount: 'always' as const,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user