Files
inbuxa-webmail/web/src/views/settings/ShareDialog.tsx
T
jcoffey-dev 6566f4c2d3 Share files and folders with other people
Calendars and address books have been shareable since JMAP Sharing went
in; Files never was, though Stalwart treats file storage as a first-class
thing to share and ihasmail has carried the types for it all along.
`FilesRights` and `FileNode.shareWith` were already declared -- what was
missing was asking for the property, offering the dialog, and saying so
in the list.

Checked against the live 0.16.19 first, read-only, because building a
picker against a mock that agrees with you proves nothing:

  - `FileNode/get` returns `shareWith`, and `myRights` carries all six
    rights, `mayShare` among them and true on one's own nodes. So the
    menu entry has a real right to gate on -- unlike folder sharing,
    which is offered ungated because `MailboxRights` has no such right
  - `Principal/query` answers now that `allowDirectoryQueries` is on:
    six individuals, no groups
  - `ShareNotification/get` is implemented, which is worth knowing for
    later; nothing here reads it yet

The editor preset grants read, add files and edit contents, and stops
there. Rename and delete stay with whoever shared the folder: someone
given a folder to work in should not be able to rename the thing they
were given, or delete it out from under the person who shared it. Both
are still there to tick by hand.

One finding is worth a test of its own, and has one. Stalwart answers
`shareWith` as `{}` for a node shared with nobody, not `null` -- every
unshared node in a live account came back that way. A truthiness test on
the property is therefore true for every node the server has ever
returned, and the badge driven by it would report the whole account as
shared while being, technically, about the right property. `isShared`
counts keys, and the test says why.

Verified against the mock end to end: sharing Documents with a principal
as Editor persists `mayRead`, `mayAddChildren` and `mayModifyContent` and
nothing else, the badge appears on that folder and not on the file beside
it, and re-opening the dialog shows the saved rights rather than an empty
form -- which is what proves `fileNodeProps` is really asking for the
property.
2026-08-27 09:07:13 -07:00

146 lines
7.1 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 { useFiles } from "@/store/files";
import { client, setErrorMessage } from "@/jmap/client";
import { toast } from "@/ui/toast";
import type { Id, Principal } from "@/jmap/types";
/* The JMAP type name, used verbatim as the `/set` method prefix. */
type Kind = "Mailbox" | "Calendar" | "AddressBook" | "FileNode";
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" },
],
// Stalwart 0.16.19 returns all six on a node of your own (2026-08-27).
FileNode: [
{ key: "mayRead", label: "Read" },
{ key: "mayAddChildren", label: "Add files" },
{ key: "mayModifyContent", label: "Edit contents" },
{ key: "mayRename", label: "Rename" },
{ key: "mayDelete", label: "Delete" },
{ key: "mayShare", label: "Share" },
],
};
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"] },
// An editor can fill a folder and change what is in it, but not rename or
// delete the folder they were given -- those stay with whoever shared it.
FileNode: { reader: ["mayRead"], editor: ["mayRead", "mayAddChildren", "mayModifyContent"] },
};
/** Share a mailbox / calendar / address book / file node 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
: kind === "FileNode" ? useFiles.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();
if (kind === "FileNode") void useFiles.getState().refresh([id]);
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>
);
}