mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
With directory queries off on Stalwart 1.0, the free/busy list under the
attendees disappeared without a word. The share dialog explained every
`forbidden` from Principal/query with the 1.0 text: "only your groups
and people who have shared with you are listed", and a hint at
Stalwart's sharing settings. On 0.16 that refusal only means the role
lacks the permission, Principal/get then lists everyone, and the setting
has no effect over JMAP.
- When the server refuses free/busy for the user (`mayGetAvailability`
false on the user's own principal, or a lookup refused with
`forbidden`), one muted line says "Free/busy isn't available on this
server". The block stays empty only when the server offers no
free/busy at all.
- The share dialog keeps the 1.0 wording for Stalwart 1.0's own refusal
("The administrator has disabled directory queries.") and otherwise
says "Your server doesn't let you browse its user directory.", still
listing whoever Principal/get returned.
450 lines
19 KiB
TypeScript
450 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "@/components/icons";
|
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
|
import type { Principal, PrincipalDirectoryStatus, CalendarRights, AddressBookRights, FileNodeRights, MailboxRights } from "@/lib/jmap/types";
|
|
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
|
|
import { toast } from "@/stores/toast-store";
|
|
|
|
type ShareKind = "calendar" | "addressBook" | "file" | "mailbox";
|
|
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights | MailboxRights;
|
|
|
|
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
|
|
|
|
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
|
|
freeBusy: {
|
|
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
|
|
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
|
},
|
|
read: {
|
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
|
|
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
|
|
},
|
|
readWrite: {
|
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
|
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
|
|
},
|
|
manager: {
|
|
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
|
|
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
|
|
},
|
|
};
|
|
|
|
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
|
|
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
|
|
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
|
|
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
|
|
};
|
|
|
|
const FILE_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights> = {
|
|
read: {
|
|
mayRead: true, mayAddChildren: false, mayRename: false,
|
|
mayDelete: false, mayModifyContent: false, mayShare: false,
|
|
},
|
|
readWrite: {
|
|
mayRead: true, mayAddChildren: true, mayRename: true,
|
|
mayDelete: true, mayModifyContent: true, mayShare: false,
|
|
},
|
|
manager: {
|
|
mayRead: true, mayAddChildren: true, mayRename: true,
|
|
mayDelete: true, mayModifyContent: true, mayShare: true,
|
|
},
|
|
};
|
|
|
|
// Mail folder rights (RFC 8621 §2 + mail:share). "Read & write" lets the
|
|
// grantee file, flag and remove mail; "Manager" additionally lets them
|
|
// rename/delete the folder, create subfolders, send as it and re-share.
|
|
const MAILBOX_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, MailboxRights> = {
|
|
read: {
|
|
mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySetSeen: true,
|
|
maySetKeywords: false, mayCreateChild: false, mayRename: false, mayDelete: false,
|
|
maySubmit: false, mayShare: false,
|
|
},
|
|
readWrite: {
|
|
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
|
|
maySetKeywords: true, mayCreateChild: false, mayRename: false, mayDelete: false,
|
|
maySubmit: false, mayShare: false,
|
|
},
|
|
manager: {
|
|
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
|
|
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true,
|
|
maySubmit: true, mayShare: true,
|
|
},
|
|
};
|
|
|
|
function detectMailboxPreset(r: MailboxRights): RolePreset {
|
|
for (const [name, preset] of Object.entries(MAILBOX_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, MailboxRights][]) {
|
|
const keys = Object.keys(preset) as (keyof MailboxRights)[];
|
|
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
|
return name;
|
|
}
|
|
}
|
|
return "custom";
|
|
}
|
|
|
|
function detectCalendarPreset(r: CalendarRights): RolePreset {
|
|
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
|
|
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
|
|
return name;
|
|
}
|
|
}
|
|
return "custom";
|
|
}
|
|
|
|
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
|
|
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
|
|
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
|
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
|
return name;
|
|
}
|
|
}
|
|
return "custom";
|
|
}
|
|
|
|
function detectFilePreset(r: FileNodeRights): RolePreset {
|
|
for (const [name, preset] of Object.entries(FILE_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights][]) {
|
|
const keys = Object.keys(preset) as (keyof FileNodeRights)[];
|
|
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
|
|
return name;
|
|
}
|
|
}
|
|
return "custom";
|
|
}
|
|
|
|
function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined {
|
|
if (preset === "custom") return undefined;
|
|
if (kind === "calendar") return CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS];
|
|
if (kind === "file") return FILE_PRESETS[preset as keyof typeof FILE_PRESETS];
|
|
if (kind === "mailbox") return MAILBOX_PRESETS[preset as keyof typeof MAILBOX_PRESETS];
|
|
return ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
|
|
}
|
|
|
|
function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
|
|
if (kind === "calendar") return detectCalendarPreset(rights as CalendarRights);
|
|
if (kind === "file") return detectFilePreset(rights as FileNodeRights);
|
|
if (kind === "mailbox") return detectMailboxPreset(rights as MailboxRights);
|
|
return detectAddressBookPreset(rights as AddressBookRights);
|
|
}
|
|
|
|
interface ShareCollectionDialogProps {
|
|
client: IJMAPClient;
|
|
kind: ShareKind;
|
|
collectionName: string;
|
|
shareWith: Record<string, AnyRights> | null | undefined;
|
|
ownAccountId: string;
|
|
onShare: (principalId: string, rights: AnyRights | null) => Promise<void>;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ShareCollectionDialog({
|
|
client,
|
|
kind,
|
|
collectionName,
|
|
shareWith,
|
|
ownAccountId,
|
|
onShare,
|
|
onClose,
|
|
}: ShareCollectionDialogProps) {
|
|
const t = useTranslations("sharing");
|
|
const tCommon = useTranslations("common");
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
|
const [directoryStatus, setDirectoryStatus] = useState<PrincipalDirectoryStatus>("ok");
|
|
const [directoryQueriesOff, setDirectoryQueriesOff] = useState(false);
|
|
const [loadingPrincipals, setLoadingPrincipals] = useState(true);
|
|
const [search, setSearch] = useState("");
|
|
const [savingId, setSavingId] = useState<string | null>(null);
|
|
const [showAdd, setShowAdd] = useState(false);
|
|
|
|
// Load principals on mount
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoadingPrincipals(true);
|
|
void loadPrincipalDirectory(client).then(({ principals, status, directoryQueriesOff }) => {
|
|
if (cancelled) return;
|
|
setAllPrincipals(principals);
|
|
setDirectoryStatus(status);
|
|
setDirectoryQueriesOff(!!directoryQueriesOff);
|
|
setLoadingPrincipals(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [client]);
|
|
|
|
// Map of every fetched principal by id, used for name/description lookups in
|
|
// the shared list. Must include principals that already have a share so the
|
|
// list shows their name rather than the raw id.
|
|
const allPrincipalsById = useMemo(() => {
|
|
const map = new Map<string, Principal>();
|
|
for (const p of allPrincipals) map.set(p.id, p);
|
|
return map;
|
|
}, [allPrincipals]);
|
|
|
|
// Principals available to add: exclude self and anyone already shared with.
|
|
const principals = useMemo(() => {
|
|
const existing = new Set(Object.keys(shareWith || {}));
|
|
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
|
}, [allPrincipals, ownAccountId, shareWith]);
|
|
|
|
// Close on Escape, focus trap, click outside
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [onClose]);
|
|
|
|
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
|
if (preset === "custom") return; // custom is read-only here
|
|
const rights = presetRights(kind, preset);
|
|
if (!rights) return;
|
|
setSavingId(principalId);
|
|
try {
|
|
await onShare(principalId, rights);
|
|
toast.success(t("share_updated"));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
|
} finally {
|
|
setSavingId(null);
|
|
}
|
|
};
|
|
|
|
const handleRemove = async (principalId: string) => {
|
|
setSavingId(principalId);
|
|
try {
|
|
await onShare(principalId, null);
|
|
toast.success(t("share_removed"));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
|
} finally {
|
|
setSavingId(null);
|
|
}
|
|
};
|
|
|
|
const handleAdd = async (principal: Principal) => {
|
|
const rights = presetRights(kind, "read");
|
|
if (!rights) return;
|
|
setSavingId(principal.id);
|
|
try {
|
|
await onShare(principal.id, rights);
|
|
setShowAdd(false);
|
|
setSearch("");
|
|
toast.success(t("share_added"));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : t("share_failed"));
|
|
} finally {
|
|
setSavingId(null);
|
|
}
|
|
};
|
|
|
|
const filteredPrincipals = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
if (!q) return principals;
|
|
return principals.filter((p) =>
|
|
p.name.toLowerCase().includes(q) ||
|
|
p.email?.toLowerCase().includes(q) ||
|
|
p.description?.toLowerCase().includes(q)
|
|
);
|
|
}, [principals, search]);
|
|
|
|
const sharedEntries = useMemo(() => {
|
|
return Object.entries(shareWith || {}) as [string, AnyRights][];
|
|
}, [shareWith]);
|
|
|
|
const presetOptions = kind === "calendar"
|
|
? ["freeBusy", "read", "readWrite", "manager"] as const
|
|
: ["read", "readWrite", "manager"] as const;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
|
<div
|
|
ref={modalRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t("title", { name: collectionName })}
|
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
|
>
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-5 h-5 text-primary" />
|
|
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
|
aria-label={tCommon("close")}
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
|
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
|
|
|
{sharedEntries.length === 0 && !showAdd && (
|
|
<div className="text-sm text-muted-foreground italic py-4 text-center">
|
|
{t("no_shares")}
|
|
</div>
|
|
)}
|
|
|
|
{sharedEntries.length > 0 && (
|
|
<ul className="divide-y divide-border rounded-md border border-border overflow-hidden">
|
|
{sharedEntries.map(([principalId, rights]) => {
|
|
const principal = allPrincipalsById.get(principalId);
|
|
// Stalwart 1.0 with directory queries off hides every principal
|
|
// the user has no relationship with, so a sharee may have no name.
|
|
const unresolved = !principal && !loadingPrincipals;
|
|
const preset = detectPreset(kind, rights);
|
|
return (
|
|
<li key={principalId} className="flex items-center gap-3 px-3 py-2.5">
|
|
<Avatar
|
|
name={principal?.name}
|
|
email={principal?.email ?? undefined}
|
|
size="sm"
|
|
className="shrink-0"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate">
|
|
{principal?.name || principal?.email || (unresolved ? t("unknown_user") : principalId)}
|
|
</div>
|
|
{unresolved && (
|
|
<div className="text-xs text-muted-foreground truncate">{principalId}</div>
|
|
)}
|
|
{principal?.description && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{principal.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<select
|
|
value={preset}
|
|
onChange={(e) => handleSetRights(principalId, e.target.value as RolePreset)}
|
|
disabled={savingId === principalId}
|
|
className="appearance-none rounded-md border border-input bg-background ps-3 pe-8 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
|
>
|
|
{presetOptions.map((p) => (
|
|
<option key={p} value={p}>{t(`preset.${p}`)}</option>
|
|
))}
|
|
{preset === "custom" && (
|
|
<option value="custom">{t("preset.custom")}</option>
|
|
)}
|
|
</select>
|
|
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemove(principalId)}
|
|
disabled={savingId === principalId}
|
|
className="p-1.5 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
|
aria-label={t("remove")}
|
|
title={t("remove")}
|
|
>
|
|
{savingId === principalId
|
|
? <Loader2 className="w-4 h-4 animate-spin" />
|
|
: <Trash2 className="w-4 h-4" />}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
|
|
{!showAdd && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowAdd(true)}
|
|
className="w-full"
|
|
>
|
|
<UserPlus className="w-4 h-4 me-2" />
|
|
{t("add_person")}
|
|
</Button>
|
|
)}
|
|
|
|
{showAdd && (
|
|
<div className="space-y-2 border border-border rounded-md p-3">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("search_placeholder")}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
autoFocus
|
|
/>
|
|
{/* The list below is partial or empty for a reason the user
|
|
can't see otherwise; say so instead of "no users found". Only
|
|
Stalwart 1.0's own refusal says who is still listed and what
|
|
an administrator can do: a 0.16 role without the permission
|
|
gets another refusal, and a list of everyone after it. */}
|
|
{!loadingPrincipals && directoryStatus !== "ok" && (
|
|
<p className="text-xs text-muted-foreground" role="status">
|
|
{directoryStatus === "error"
|
|
? t("directory_failed")
|
|
: directoryQueriesOff ? t("directory_restricted") : t("directory_not_browsable")}
|
|
</p>
|
|
)}
|
|
<div className="max-h-48 overflow-y-auto -mx-1">
|
|
{loadingPrincipals && (
|
|
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
|
<Loader2 className="w-4 h-4 animate-spin me-2" />
|
|
{t("loading_principals")}
|
|
</div>
|
|
)}
|
|
{!loadingPrincipals && filteredPrincipals.length === 0 && (search.trim() || directoryStatus === "ok") && (
|
|
<div className="text-xs text-muted-foreground text-center py-3">
|
|
{search.trim() ? t("no_match") : t("no_principals")}
|
|
</div>
|
|
)}
|
|
{!loadingPrincipals && filteredPrincipals.map((p) => (
|
|
<button
|
|
key={p.id}
|
|
onClick={() => handleAdd(p)}
|
|
disabled={savingId === p.id}
|
|
className="w-full text-start px-3 py-2 rounded-md hover:bg-muted disabled:opacity-50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Avatar
|
|
name={p.name}
|
|
email={p.email ?? undefined}
|
|
size="sm"
|
|
className="shrink-0"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium truncate flex items-center gap-2">
|
|
{p.name}
|
|
{p.type === "group" && (
|
|
<span className="text-[10px] uppercase font-normal text-muted-foreground bg-muted rounded px-1 py-0.5">
|
|
{t("group")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{p.email && p.email !== p.name && (
|
|
<div className="text-xs text-muted-foreground truncate">{p.email}</div>
|
|
)}
|
|
</div>
|
|
{savingId === p.id && <Loader2 className="w-4 h-4 animate-spin" />}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-end pt-1">
|
|
<Button variant="ghost" size="sm" onClick={() => { setShowAdd(false); setSearch(""); }}>
|
|
{tCommon("cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
|
<Button onClick={onClose}>{tCommon("close")}</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|