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:
|
||||
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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
<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} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
||||
@ -188,29 +188,28 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<span className="text-sm text-foreground">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingInvite(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
<div className="flex items-center gap-3">
|
||||
<PermissionToggle
|
||||
value={pendingInvite.permission}
|
||||
onChange={(e) => setPendingInvite((prev) => prev ? { ...prev, permission: e.target.value as CalendarPermission } : null)}
|
||||
className="text-xs flex-1"
|
||||
>
|
||||
<option value="read_only">Read Only</option>
|
||||
<option value="create_modify">Create / Modify</option>
|
||||
<option value="full_access">Full Access</option>
|
||||
</Select>
|
||||
onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@ -38,7 +38,7 @@ export default function CalendarMemberList({
|
||||
}
|
||||
|
||||
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) => (
|
||||
<CalendarMemberRow
|
||||
key={member.id}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { X, UserPlus } from 'lucide-react';
|
||||
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import PermissionBadge from './PermissionBadge';
|
||||
import PermissionToggle from './PermissionToggle';
|
||||
|
||||
interface CalendarMemberRowProps {
|
||||
member: CalendarMemberInfo;
|
||||
@ -30,7 +30,9 @@ export default function CalendarMemberRow({
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
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">
|
||||
<span className="text-sm text-violet-400 font-medium">{initial}</span>
|
||||
</div>
|
||||
@ -49,51 +51,47 @@ export default function CalendarMemberRow({
|
||||
)}
|
||||
</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 ? (
|
||||
<PermissionBadge permission={member.permission} />
|
||||
) : isOwner ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Select
|
||||
<>
|
||||
<PermissionToggle
|
||||
value={member.permission}
|
||||
onChange={(e) =>
|
||||
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>
|
||||
|
||||
onChange={(p) => onUpdatePermission?.(member.id, p)}
|
||||
/>
|
||||
{(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
|
||||
checked={member.can_add_others}
|
||||
onChange={() =>
|
||||
onUpdateCanAddOthers?.(member.id, !member.can_add_others)
|
||||
}
|
||||
onChange={() => onUpdateCanAddOthers?.(member.id, !member.can_add_others)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</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} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,6 +149,7 @@ export default function CalendarPage() {
|
||||
const { data } = await api.get<CalendarEvent[]>('/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
|
||||
|
||||
@ -250,6 +250,7 @@ export default function EventDetailPanel({
|
||||
);
|
||||
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
||||
|
||||
|
||||
const [isEditing, setIsEditing] = useState(isCreating);
|
||||
const [editState, setEditState] = useState<EditState>(() =>
|
||||
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<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);
|
||||
|
||||
// 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 (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogClose onClick={onClose} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Shared Calendar Settings</DialogTitle>
|
||||
|
||||
@ -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<CalendarInvite[]>(
|
||||
'/shared-calendars/invites/incoming'
|
||||
);
|
||||
return data.invites;
|
||||
return data;
|
||||
},
|
||||
refetchOnMount: 'always' as const,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user