Files
caldav/web/dav/CalendarView.tsx
T
pastilhasandClaude Opus 5 785e5f9eed edit and delete, on both screens
the screens could show a calendar and an address book and change neither. this
adds a pencil and a bin to every event and every card, with a confirm on the bin.

three things worth defending.

THE HREF IS THE ADDRESS, and the listing now carries it. it used to return only
the object's data, which is enough to show something and not enough to change
it. deriving `<uid>.ics` was the obvious shortcut and is wrong: a .ics filename
only conventionally matches the uid — radicale stores what the writing client
chose — so it works for everything officer wrote and misses everything a phone
did. proved immediately: this machine's own calendar holds
62970EC7-47B1-4214-858E-85C32C2476A5.ics, written by an iphone.

READ, PATCH, PUT — NEVER REBUILD. an event is a file with far more in it than
these screens show: alarms, attendees, RRULEs, every X- property. rebuilding it
from the fields on screen would silently drop all of that, and an edit that
quietly deletes half an event is worse than no editing. so a save reads the
original text, replaces only the lines it owns, and puts the whole file back.
verified on a real event: 22 lines before, 22 after, one changed.

THE EDITOR IS DELIBERATELY SMALL. title and place for an event, name/email/phone
for a card. times and recurrence are where an editor does damage, and a
half-editor that rewrites an RRULE it does not understand is the thing this
avoids. `setProperty` also escapes commas, semicolons and newlines, because a
raw comma silently splits an iCalendar TEXT value into parts.

the sidecar grew GET/PUT/DELETE on /_officer/item, above the `collection is
required` guard — they take an href rather than a collection, and sitting below
it they answered 400 forever. both check the href is inside this user's own tree
even though X-Remote-User already scopes radicale: an href from a request body
is attacker-shaped input either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 10:25:38 +00:00

199 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CalendarEvent } from './shared';
import { useState } from 'react';
import { CalendarDays, MapPin, Repeat, Pencil, Trash2, Check, X } from 'lucide-react';
import { useCalendarEvents, useSelectedCollection } from './useDavData';
import { useDavEdit, setProperty } from './useDavEdit';
import { useTranslation } from 'officerdev';
// Right panel of /calendar. Deliberately a grouped agenda list, not a month grid: the sidecar returns
// events as they are stored, with RRULE unexpanded, so a grid would have to invent occurrences the server
// never claimed existed. A list can show a repeating event honestly, as one entry with a "repeats" badge.
const dayKey = (ev: CalendarEvent) => (ev.start ? ev.start.slice(0, 10) : '');
const formatDay = (iso: string) => {
if (!iso) return 'No date';
const date = new Date(`${iso}T12:00:00`);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
};
const formatTime = (ev: CalendarEvent) => {
if (ev.allDay || !ev.start) return 'All day';
const start = new Date(ev.start);
if (Number.isNaN(start.getTime())) return '';
const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
const end = ev.end ? new Date(ev.end) : null;
if (!end || Number.isNaN(end.getTime())) return start.toLocaleTimeString(undefined, opts);
return `${start.toLocaleTimeString(undefined, opts)} – ${end.toLocaleTimeString(undefined, opts)}`;
};
export const CalendarView = () => {
const { t } = useTranslation();
const { selected, isLoading: loadingCollections } = useSelectedCollection('calendar');
const { events, isLoading, error } = useCalendarEvents(selected?.path ?? null);
// Keyed on the COLLECTION, so a save refreshes the list this event came from.
const edit = useDavEdit(['caldav', 'events', selected?.path ?? null]);
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState({ summary: '', location: '' });
const [confirming, setConfirming] = useState<string | null>(null);
if (loadingCollections) return <Placeholder text="Loading calendars…" />;
if (!selected) return <Placeholder text="No calendars yet. Create one from a CalDAV client." />;
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
if (isLoading) return <Placeholder text="Loading events…" />;
if (events.length === 0) return <Placeholder text={`${selected.displayName} has no events.`} />;
const sorted = [...events].sort((a, b) => (a.start ?? '').localeCompare(b.start ?? ''));
const days: Array<{ key: string; events: CalendarEvent[] }> = [];
for (const ev of sorted) {
const key = dayKey(ev);
const last = days[days.length - 1];
if (last && last.key === key) last.events.push(ev);
else days.push({ key, events: [ev] });
}
return (
<div className="h-full overflow-y-auto">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
{days.map((day) => (
<section key={day.key} className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{formatDay(day.key)}
</h2>
{day.events.map((ev) => (
<article key={ev.uid} className="flex gap-3 rounded-xl border bg-card p-3">
<span
className="mt-1 h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: selected.color || '#94a3b8' }}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{ev.summary || '(no title)'}</span>
{ev.rrule && (
<span
title={ev.rrule}
className="flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground"
>
<Repeat className="h-3 w-3" />
repeats
</span>
)}
</div>
<div className="mt-0.5 text-xs tabular-nums text-muted-foreground">{formatTime(ev)}</div>
{/* The editor is the title and the place, and nothing else. Those are the two fields a
person actually corrects; times and recurrence are where an editor does damage, and
a half-editor that silently rewrites an RRULE is worse than none. */}
{editing === ev.href && (
<form
className="mt-2 flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
if (!ev.href) return;
void edit
.save(ev.href, (raw) =>
setProperty(setProperty(raw, 'SUMMARY', draft.summary), 'LOCATION', draft.location || null),
)
.then(() => setEditing(null));
}}
>
<input
autoFocus
value={draft.summary}
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
placeholder="Title"
className="rounded-md border bg-background px-2 py-1 text-sm"
/>
<input
value={draft.location}
onChange={(e) => setDraft((d) => ({ ...d, location: e.target.value }))}
placeholder="Where"
className="rounded-md border bg-background px-2 py-1 text-xs"
/>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={edit.busy === ev.href}
className="flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs text-primary disabled:opacity-50"
>
<Check className="h-3 w-3" /> {edit.busy === ev.href ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={() => setEditing(null)}
className="rounded-md px-2 py-1 text-xs text-muted-foreground"
>
Cancel
</button>
</div>
</form>
)}
{ev.location && (
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{ev.location}</span>
</div>
)}
{ev.description && (
<p className="mt-1 whitespace-pre-wrap text-xs text-muted-foreground">{ev.description}</p>
)}
</div>
{/* Only for an event we can actually address. An item with no href came from somewhere
that did not give us one, and offering a delete that cannot work is worse than not
offering it. */}
{ev.href && editing !== ev.href && (
<div className="flex shrink-0 items-start gap-1">
{confirming === ev.href ? (
<>
<button
onClick={() => void edit.remove(ev.href!).then(() => setConfirming(null))}
disabled={edit.busy === ev.href}
title="Delete this event"
className="rounded p-1 text-destructive hover:bg-destructive/10 disabled:opacity-50"
>
<Check className="h-3.5 w-3.5" />
</button>
<button onClick={() => setConfirming(null)} className="rounded p-1 opacity-50 hover:opacity-100">
<X className="h-3.5 w-3.5" />
</button>
</>
) : (
<>
<button
onClick={() => {
setDraft({ summary: ev.summary ?? '', location: ev.location ?? '' });
setEditing(ev.href!);
}}
title="Edit"
className="rounded p-1 text-muted-foreground hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirming(ev.href!)}
title="Delete"
className="rounded p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
)}
</article>
))}
</section>
))}
</div>
</div>
);
};
const Placeholder = ({ text }: { text: string }) => (
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center text-sm text-muted-foreground">
<CalendarDays className="h-6 w-6 opacity-40" />
<span>{text}</span>
</div>
);