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
188 lines
8.5 KiB
TypeScript
188 lines
8.5 KiB
TypeScript
import { useState } from 'react';
|
|
import { Building2, Mail, Phone, User, Pencil, Trash2, Check, X } from 'lucide-react';
|
|
import { useDavContacts, useSelectedCollection } from './useDavData';
|
|
import { useDavEdit, setProperty } from './useDavEdit';
|
|
import { useTranslation } from 'officerdev';
|
|
|
|
// Right panel of /contacts: the selected address book as cards, sorted by name.
|
|
//
|
|
// `hasPhoto` is a flag, not the bytes — the sidecar keeps embedded photos out of the list response so a
|
|
// book with a hundred cards is not a hundred base64 blobs. Until there is an endpoint that serves one
|
|
// card's photo, an initial stands in.
|
|
|
|
const initials = (name: string) =>
|
|
name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
|
.join('') || '?';
|
|
|
|
export const ContactsView = () => {
|
|
const { t } = useTranslation();
|
|
const { selected, isLoading: loadingCollections } = useSelectedCollection('addressbook');
|
|
const { contacts, isLoading, error } = useDavContacts(selected?.path ?? null);
|
|
const edit = useDavEdit(['caldav', 'contacts', selected?.path ?? null]);
|
|
const [editing, setEditing] = useState<string | null>(null);
|
|
const [draft, setDraft] = useState({ fullName: '', email: '', phone: '' });
|
|
const [confirming, setConfirming] = useState<string | null>(null);
|
|
|
|
if (loadingCollections) return <Placeholder text="Loading address books…" />;
|
|
if (!selected) return <Placeholder text="No address books yet. Create one from a CardDAV client." />;
|
|
if (error) return <Placeholder text={t('dav.readFailed', { name: selected.displayName, reason: String(error) })} />;
|
|
if (isLoading) return <Placeholder text="Loading contacts…" />;
|
|
if (contacts.length === 0) return <Placeholder text={`${selected.displayName} has no contacts.`} />;
|
|
|
|
const sorted = [...contacts].sort((a, b) => a.fullName.localeCompare(b.fullName));
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto">
|
|
<div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 p-6 sm:grid-cols-2">
|
|
{sorted.map((contact) => (
|
|
<article key={contact.uid} className="flex gap-3 rounded-xl border bg-card p-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-sm font-semibold text-sky-600">
|
|
{initials(contact.fullName)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium">{contact.fullName || '(no name)'}</div>
|
|
{(contact.title || contact.org) && (
|
|
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Building2 className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{[contact.title, contact.org].filter(Boolean).join(' · ')}</span>
|
|
</div>
|
|
)}
|
|
{contact.emails.map((email) => (
|
|
<a
|
|
key={email}
|
|
href={`mailto:${email}`}
|
|
className="mt-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Mail className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{email}</span>
|
|
</a>
|
|
))}
|
|
{contact.phones.map((phone) => (
|
|
<a
|
|
key={phone}
|
|
href={`tel:${phone}`}
|
|
className="mt-1 flex items-center gap-1 text-xs tabular-nums text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Phone className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">{phone}</span>
|
|
</a>
|
|
))}
|
|
|
|
{/* Name, one email, one phone — the three fields worth correcting from a list. A card can
|
|
hold several of each; editing rewrites the FIRST and leaves the rest untouched, which
|
|
is why `setProperty` replaces a line rather than rebuilding the card. */}
|
|
{editing === contact.href && (
|
|
<form
|
|
className="mt-2 flex flex-col gap-2"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (!contact.href) return;
|
|
void edit
|
|
.save(contact.href, (raw) => {
|
|
let out = setProperty(raw, 'FN', draft.fullName);
|
|
out = setProperty(out, 'EMAIL', draft.email || null);
|
|
return setProperty(out, 'TEL', draft.phone || null);
|
|
})
|
|
.then(() => setEditing(null));
|
|
}}
|
|
>
|
|
<input
|
|
autoFocus
|
|
value={draft.fullName}
|
|
onChange={(e) => setDraft((d) => ({ ...d, fullName: e.target.value }))}
|
|
placeholder="Name"
|
|
className="rounded-md border bg-background px-2 py-1 text-sm"
|
|
/>
|
|
<input
|
|
value={draft.email}
|
|
onChange={(e) => setDraft((d) => ({ ...d, email: e.target.value }))}
|
|
placeholder="Email"
|
|
className="rounded-md border bg-background px-2 py-1 text-xs"
|
|
/>
|
|
<input
|
|
value={draft.phone}
|
|
onChange={(e) => setDraft((d) => ({ ...d, phone: e.target.value }))}
|
|
placeholder="Phone"
|
|
className="rounded-md border bg-background px-2 py-1 text-xs tabular-nums"
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="submit"
|
|
disabled={edit.busy === contact.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 === contact.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>
|
|
)}
|
|
</div>
|
|
|
|
{contact.href && editing !== contact.href && (
|
|
<div className="flex shrink-0 items-start gap-1">
|
|
{confirming === contact.href ? (
|
|
<>
|
|
<button
|
|
onClick={() => void edit.remove(contact.href!).then(() => setConfirming(null))}
|
|
disabled={edit.busy === contact.href}
|
|
title="Delete this contact"
|
|
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({
|
|
fullName: contact.fullName ?? '',
|
|
email: contact.emails[0] ?? '',
|
|
phone: contact.phones[0] ?? '',
|
|
});
|
|
setEditing(contact.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(contact.href!)}
|
|
title="Delete"
|
|
className="rounded p-1 text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</article>
|
|
))}
|
|
</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">
|
|
<User className="h-6 w-6 opacity-40" />
|
|
<span>{text}</span>
|
|
</div>
|
|
);
|