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:
Kyle 2026-03-06 16:46:15 +08:00
parent 14fc085009
commit b401fd9392
10 changed files with 163 additions and 72 deletions

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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

View 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>
);
}

View File

@ -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>

View File

@ -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,
});