Files
ihasmail-inbuxa/web/src/views/settings/ShareDialog.tsx
T
jcoffey-dev e05880eefc Say which property a JMAP SetError rejected
"Send failed: Invalid property or value." is Stalwart's description for
invalidProperties, and on its own it says nothing about what to fix. The
SetError also carries a `properties` array naming the offending fields, which
every call site was discarding.

setErrorMessage appends them, and the 35 places that surfaced a SetError -
send, save draft, mailboxes, calendars, contacts, sieve, files, signature
images, sharing - now go through it.
2026-08-23 13:51:02 -07:00

127 lines
6.2 KiB
TypeScript

import { useEffect, useState } from "react";
import { Trash2 } from "lucide-react";
import { Dialog } from "@/ui/dialog";
import { useContacts } from "@/store/contacts";
import { useMail } from "@/store/mail";
import { useCalendar } from "@/store/calendar";
import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types";
type Kind = "Mailbox" | "Calendar" | "AddressBook";
const RIGHTS: Record<Kind, Array<{ key: string; label: string }>> = {
Mailbox: [
{ key: "mayReadItems", label: "Read" },
{ key: "mayAddItems", label: "Add" },
{ key: "mayRemoveItems", label: "Remove" },
{ key: "maySetSeen", label: "Mark read" },
{ key: "maySetKeywords", label: "Flag" },
{ key: "mayCreateChild", label: "Create subfolders" },
{ key: "mayRename", label: "Rename" },
{ key: "mayDelete", label: "Delete" },
{ key: "maySubmit", label: "Send" },
],
Calendar: [
{ key: "mayReadFreeBusy", label: "See free/busy" },
{ key: "mayReadItems", label: "Read events" },
{ key: "mayWriteAll", label: "Edit all" },
{ key: "mayWriteOwn", label: "Edit own" },
{ key: "mayUpdatePrivate", label: "Private props" },
{ key: "mayRSVP", label: "RSVP" },
{ key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" },
],
AddressBook: [
{ key: "mayRead", label: "Read" },
{ key: "mayWrite", label: "Write" },
{ key: "mayShare", label: "Share" },
{ key: "mayDelete", label: "Delete" },
],
};
const PRESETS: Record<Kind, { reader: string[]; editor: string[] }> = {
Mailbox: { reader: ["mayReadItems"], editor: ["mayReadItems", "mayAddItems", "mayRemoveItems", "maySetSeen", "maySetKeywords", "mayCreateChild"] },
Calendar: { reader: ["mayReadFreeBusy", "mayReadItems"], editor: ["mayReadFreeBusy", "mayReadItems", "mayWriteAll", "mayRSVP"] },
AddressBook: { reader: ["mayRead"], editor: ["mayRead", "mayWrite"] },
};
/** Share a mailbox / calendar / address book with other principals (JMAP Sharing, RFC 9670). */
export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind; id: Id; name: string; shareWith: Record<Id, object> | null; onClose: () => void }) {
const principals = useContacts((s) => s.principals);
const loadPrincipals = useContacts((s) => s.loadPrincipals);
const [rights, setRights] = useState<Record<Id, Record<string, boolean>>>(() => ({ ...((shareWith ?? {}) as Record<Id, Record<string, boolean>>) }));
const [pick, setPick] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
void loadPrincipals();
}, [loadPrincipals]);
const available = principals.filter((p) => !rights[p.id]);
const add = (p: Principal, preset: "reader" | "editor") => {
const r: Record<string, boolean> = {};
for (const k of PRESETS[kind][preset]) r[k] = true;
setRights({ ...rights, [p.id]: r });
setPick("");
};
const save = async () => {
setBusy(true);
try {
const accountId = kind === "Mailbox" ? useMail.getState().accountId : kind === "Calendar" ? useCalendar.getState().accountId : useContacts.getState().accountId;
const res = await client.call<{ notUpdated?: Record<string, { type: string; description?: string }> }>(`${kind}/set`, { accountId, update: { [id]: { shareWith: Object.keys(rights).length ? rights : null } } });
const err = res.notUpdated?.[id];
if (err) throw new Error(setErrorMessage(err));
toast.success("Sharing updated");
if (kind === "Mailbox") void useMail.getState().loadMailboxes();
if (kind === "Calendar") void useCalendar.getState().loadCalendars();
if (kind === "AddressBook") void useContacts.getState().loadBooks();
onClose();
} catch (err) {
toast.error((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<Dialog open onClose={onClose} title={`Share “${name}”`} size="lg" footer={<><button className="btn" onClick={onClose}>Cancel</button><button className="btn btn-primary" disabled={busy} onClick={() => void save()}>Save</button></>}>
{!principals.length ? (
<p className="hint">No other users found in the directory, or sharing is not enabled on this server.</p>
) : (
<>
<div className="row" style={{ marginBottom: 12 }}>
<select className="select" value={pick} onChange={(e) => setPick(e.target.value)}>
<option value="">Add a person or group</option>
{available.map((p) => (
<option key={p.id} value={p.id}>{p.name}{p.email ? ` <${p.email}>` : ""}{p.type !== "individual" ? ` (${p.type})` : ""}</option>
))}
</select>
<button className="btn" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "reader"); }}>Viewer</button>
<button className="btn btn-primary" disabled={!pick} onClick={() => { const p = principals.find((x) => x.id === pick); if (p) add(p, "editor"); }}>Editor</button>
</div>
{Object.entries(rights).map(([pid, r]) => {
const p = principals.find((x) => x.id === pid);
return (
<div key={pid} className="card">
<div className="card-head">
<h3>{p?.name ?? pid}{p?.email ? <span className="hint" style={{ fontWeight: 400 }}> · {p.email}</span> : null}</h3>
<button className="icon-btn sm danger" onClick={() => { const n = { ...rights }; delete n[pid]; setRights(n); }} aria-label="Remove"><Trash2 size={16} /></button>
</div>
<div className="row wrap" style={{ marginTop: 8 }}>
{RIGHTS[kind].map((rt) => (
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
<span className="small">{rt.label}</span>
</label>
))}
</div>
</div>
);
})}
{!Object.keys(rights).length && <p className="hint">Not shared with anyone yet.</p>}
</>
)}
</Dialog>
);
}