feat: give a delegate access to mail, calendars and contacts in one step

This commit is contained in:
Linus Rath
2026-10-28 05:11:23 +01:00
parent 2e34759f54
commit bcf4f3a7a6
16 changed files with 1903 additions and 0 deletions
@@ -0,0 +1,230 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DelegatesSettings } from '../delegates-settings';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import type { ShareChange, ShareChangeResult, ShareTarget } from '@/lib/jmap/share-batch';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '@/lib/jmap/share-rights';
import type { Principal, PrincipalListing } from '@/lib/jmap/types';
const ALICE: Principal = { id: 'ci', type: 'individual', name: 'org-alice@example.org', email: 'org-alice@example.org' };
const TEAM: Principal = { id: 'ch', type: 'group', name: 'org-team@example.org', email: 'org-team@example.org' };
const CAROL: Principal = { id: 'ck', type: 'individual', name: 'org-carol@example.org', email: 'org-carol@example.org' };
// Stalwart 1.0 with directory queries off: only the principals alice knows.
const DIRECTORY_OFF: PrincipalListing = { principals: [ALICE, TEAM, CAROL], status: 'restricted', directoryQueriesOff: true };
function targets(withCarol = false): ShareTarget[] {
const carol = (rights: object) => (withCarol ? { ck: rights } : {});
return [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: carol({ ...MAILBOX_PRESETS.read, maySetKeywords: true }) as never },
{ kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Sent Items', role: 'sent', shareWith: {} },
{ kind: 'calendar', accountId: 'ci', id: 'c', name: 'Calendar', isDefault: true, shareWith: carol(CALENDAR_PRESETS.read) as never },
{ kind: 'addressBook', accountId: 'ci', id: 'd', name: 'Contacts', isDefault: true, shareWith: {} },
...(withCarol ? [{ kind: 'file' as const, accountId: 'ci', id: 'n', name: 'plan.pdf', shareWith: { ck: FILE_PRESETS.read } }] : []),
];
}
const ok = (changes: ShareChange[]): ShareChangeResult[] => changes.map((change) => ({ change, ok: true }));
function makeClient(options: { withCarol?: boolean; directory?: PrincipalListing; apply?: (c: ShareChange[]) => ShareChangeResult[] } = {}) {
return {
getAccountId: () => 'ci',
supportsPrincipals: () => true,
getPrincipals: vi.fn(async () => (options.directory ?? DIRECTORY_OFF).principals),
listPrincipals: vi.fn(async () => options.directory ?? DIRECTORY_OFF),
getShareTargets: vi.fn(async () => ({ targets: targets(options.withCarol), failed: [] })),
applyShareChanges: vi.fn(async (changes: ShareChange[]) => (options.apply ?? ok)(changes)),
findPrincipalByEmail: vi.fn(async () => ({ principal: null, status: 'forbidden' as const, directoryQueriesOff: true })),
};
}
const initialPolicy = usePolicyStore.getState();
const initialAuth = useAuthStore.getState();
function restrict(restrictions: Record<string, object>) {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: { ...state.policy.restrictions, ...restrictions } } }));
}
function renderWith(client: ReturnType<typeof makeClient>) {
useAuthStore.setState({ client: client as never, activeAccountId: 'acc-alice' });
return render(<DelegatesSettings />);
}
beforeEach(() => {
try { localStorage.clear(); } catch { /* ignore */ }
});
afterEach(() => {
cleanup();
usePolicyStore.setState(initialPolicy, true);
useAuthStore.setState(initialAuth, true);
});
describe('DelegatesSettings', () => {
it('shows nothing and reads nothing when the admin hides delegation', async () => {
restrict({ delegation: { hidden: true } });
const client = makeClient();
const { container } = renderWith(client);
await act(async () => {});
expect(container).toBeEmptyDOMElement();
expect(client.getShareTargets).not.toHaveBeenCalled();
});
it('appears once a late policy stops hiding it', async () => {
restrict({ delegation: { hidden: true } });
const client = makeClient();
renderWith(client);
await act(async () => {});
act(() => usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: {} } })));
expect(await screen.findByText('give_access')).toBeInTheDocument();
expect(client.getShareTargets).toHaveBeenCalled();
});
it('lists everyone with access, grouped by person, with what they have', async () => {
renderWith(makeClient({ withCarol: true }));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-carol@example.org')).toBeInTheDocument();
expect(within(row).getByText('summary.mail_inbox · summary.calendars · summary.files · level.read')).toBeInTheDocument();
expect(screen.getAllByTestId('delegate-row')).toHaveLength(1);
});
it('removes a person from every item, files included', async () => {
const client = makeClient({ withCarol: true });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'remove' }));
const confirm = await screen.findByRole('alertdialog');
fireEvent.click(within(confirm).getByRole('button', { name: 'remove' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.kind, c.id, c.principalId, c.rights])).toEqual([
['mailbox', 'a', 'ck', null],
['calendar', 'c', 'ck', null],
['file', 'n', 'ck', null],
]);
// The overview is read again afterwards.
await waitFor(() => expect(client.getShareTargets).toHaveBeenCalledTimes(2));
});
});
describe('DelegateDialog', () => {
async function openDialog(client: ReturnType<typeof makeClient>) {
renderWith(client);
fireEvent.click(await screen.findByText('give_access'));
return screen.findByRole('dialog');
}
it('says why only known people are listed when directory queries are off', async () => {
const dialog = await openDialog(makeClient());
expect(within(dialog).getByTestId('delegate-directory-off')).toHaveTextContent('directory_off_note');
// The user's own principal isn't offered; their group and carol are.
const options = within(dialog).getAllByRole('option').map((o) => o.textContent);
expect(options.some((o) => o?.includes('org-alice'))).toBe(false);
expect(options.some((o) => o?.includes('org-carol'))).toBe(true);
});
it('shows the note for a typed address the server cannot look up', async () => {
const client = makeClient({ directory: { principals: [ALICE], status: 'restricted', directoryQueriesOff: true } });
const dialog = await openDialog(client);
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'org-carol@example.org' } });
fireEvent.click(within(dialog).getByText('use_address'));
await waitFor(() => expect(client.findPrincipalByEmail).toHaveBeenCalledWith('org-carol@example.org'));
expect(within(dialog).getByTestId('delegate-directory-off')).toHaveTextContent('directory_off_note');
});
it('takes a typed address the server resolves', async () => {
const client = makeClient({ directory: { principals: [ALICE], status: 'ok' } });
client.findPrincipalByEmail.mockResolvedValue({ principal: CAROL, status: 'ok' } as never);
const dialog = await openDialog(client);
const input = within(dialog).getByRole('textbox');
fireEvent.change(input, { target: { value: 'org-carol@example.org' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await within(dialog).findByText('org-carol@example.org')).toBeInTheDocument();
expect(within(dialog).queryByRole('textbox')).toBeNull();
});
it('gives read access to all folders and the default calendar and address book in one batch', async () => {
const client = makeClient();
const dialog = await openDialog(client);
fireEvent.click(within(dialog).getAllByRole('option').find((o) => o.textContent?.includes('org-carol'))!);
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.kind, c.id, c.principalId])).toEqual([
['mailbox', 'a', 'ck'], ['mailbox', 'b', 'ck'], ['calendar', 'c', 'ck'], ['addressBook', 'd', 'ck'],
]);
expect(changes[0].rights).toEqual(MAILBOX_PRESETS.read);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('reports the items that failed and tries only those again', async () => {
let calls = 0;
const client = makeClient({
apply: (changes) => {
calls += 1;
return changes.map((change) => ({ change, ok: calls > 1 || change.kind !== 'calendar', error: calls > 1 ? undefined : 'You may not share this calendar' }));
},
});
const dialog = await openDialog(client);
fireEvent.click(within(dialog).getAllByRole('option').find((o) => o.textContent?.includes('org-carol'))!);
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
const report = await within(dialog).findByRole('alert');
expect(report).toHaveTextContent('partial_title');
expect(report).toHaveTextContent('Calendar');
expect(report).toHaveTextContent('You may not share this calendar');
expect(report).not.toHaveTextContent('Inbox');
fireEvent.click(within(dialog).getByRole('button', { name: 'retry' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(2));
expect(client.applyShareChanges.mock.calls[1][0].map((c) => c.id)).toEqual(['c']);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('offers only read when the admin allows only read', async () => {
restrict({ delegationRights: { allowedValues: ['read'] } });
const dialog = await openDialog(makeClient());
const rights = within(dialog).getByTestId('delegate-rights');
expect(within(rights).getAllByRole('radio').map((r) => (r as HTMLInputElement).value)).toEqual(['read']);
expect(within(rights).getByText('org_locked')).toBeInTheDocument();
});
it('offers both without a restriction, and no lock note', async () => {
const dialog = await openDialog(makeClient());
const rights = within(dialog).getByTestId('delegate-rights');
expect(within(rights).getAllByRole('radio').map((r) => (r as HTMLInputElement).value)).toEqual(['read', 'write']);
expect(within(rights).queryByText('org_locked')).toBeNull();
});
it('keeps keys from the shortcuts behind it and closes on Escape', async () => {
const onWindowKey = vi.fn();
window.addEventListener('keydown', onWindowKey);
try {
const dialog = await openDialog(makeClient());
fireEvent.keyDown(within(dialog).getByRole('textbox'), { key: 'e' });
expect(onWindowKey).not.toHaveBeenCalled();
fireEvent.keyDown(within(dialog).getByRole('textbox'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(onWindowKey).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', onWindowKey);
}
});
it('starts "Change" from what the person has, and replaces it', async () => {
const client = makeClient({ withCarol: true });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'change' }));
const dialog = await screen.findByRole('dialog');
const checked = within(dialog).getAllByRole('radio').filter((r) => (r as HTMLInputElement).checked).map((r) => (r as HTMLInputElement).value);
expect(checked).toEqual(['inbox', 'default', 'none', 'read']);
fireEvent.click(within(dialog).getAllByRole('radio').find((r) => (r as HTMLInputElement).value === 'write')!);
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_change' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.id, (c.rights as Record<string, boolean> | null)?.mayAddItems ?? (c.rights as Record<string, boolean> | null)?.mayWriteAll])).toEqual([
['a', true], ['c', true],
]);
});
});
@@ -0,0 +1,386 @@
"use client";
import { useId, useMemo, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { Info, Loader2, X } from "@/components/icons";
import { OrgLockedNote } from "@/components/settings/org-locked-note";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, PrincipalListing } from "@/lib/jmap/types";
import type { ShareChange, ShareChangeResult, ShareTarget } from "@/lib/jmap/share-batch";
import type { AccessLevel } from "@/lib/jmap/share-rights";
import {
buildGrantChanges,
DEFAULT_DELEGATE_SCOPE,
failedChanges,
scopeIsEmpty,
type CollectionScope,
type DelegateScope,
type MailScope,
} from "@/lib/delegation/delegation";
import { principalDisplay } from "./principal-display";
import { cn } from "@/lib/utils";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type Lookup =
| { state: "idle" }
| { state: "busy" }
| { state: "notFound"; address: string }
| { state: "directoryOff" }
| { state: "failed" };
export interface DelegateDialogProps {
client: IJMAPClient;
targets: ShareTarget[];
directory: PrincipalListing | null;
/** The user's own principal ids, never offered. */
ownIds: string[];
/** Ids that already have access to something. */
existingIds: Set<string>;
/** Set for "Change": the person, and what they have now. */
initial?: { principal: Principal; scope: DelegateScope; level: AccessLevel };
levels: AccessLevel[];
/** The admin limited the rights (delegationRights). */
levelsRestricted: boolean;
onApplied: (principal: Principal, results: ShareChangeResult[]) => void;
onClose: () => void;
}
/**
* "Give access" and "Change": one person, what they get, and with which
* rights, applied to every folder, calendar and address book in one batch.
* Full screen on a phone.
*/
export function DelegateDialog({
client, targets, directory, ownIds, existingIds, initial, levels, levelsRestricted, onApplied, onClose,
}: DelegateDialogProps) {
const t = useTranslations("delegates");
const tCommon = useTranslations("common");
const titleId = useId();
const changing = !!initial;
const [principal, setPrincipal] = useState<Principal | null>(initial?.principal ?? null);
const [query, setQuery] = useState("");
const [lookup, setLookup] = useState<Lookup>({ state: "idle" });
const [scope, setScope] = useState<DelegateScope>(initial?.scope ?? DEFAULT_DELEGATE_SCOPE);
const [level, setLevel] = useState<AccessLevel>(() => {
const wanted = initial?.level ?? "read";
return levels.includes(wanted) ? wanted : levels[0];
});
const [saving, setSaving] = useState(false);
const [failures, setFailures] = useState<ShareChangeResult[] | null>(null);
const dialogRef = useFocusTrap({ isActive: true, onEscape: () => { if (!saving) onClose(); }, restoreFocus: true });
const has = (kind: ShareTarget["kind"]) => targets.some((target) => target.kind === kind);
const directoryOff = directory?.status === "restricted" && !!directory.directoryQueriesOff;
const candidates = useMemo(() => {
const own = new Set(ownIds);
const q = query.trim().toLowerCase();
return (directory?.principals ?? [])
.filter((p) => !own.has(p.id))
.filter((p) => !q || p.name.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q));
}, [directory, ownIds, query]);
const typedAddress = query.trim();
const canLookUp = EMAIL_RE.test(typedAddress)
&& !candidates.some((p) => p.email?.toLowerCase() === typedAddress.toLowerCase());
const lookUp = async () => {
if (!canLookUp || lookup.state === "busy") return;
if (!client.findPrincipalByEmail) {
setLookup({ state: "failed" });
return;
}
setLookup({ state: "busy" });
const found = await client.findPrincipalByEmail(typedAddress);
if (found.principal && !ownIds.includes(found.principal.id)) {
setPrincipal(found.principal);
setLookup({ state: "idle" });
} else if (found.status === "forbidden") {
setLookup({ state: "directoryOff" });
} else if (found.status === "ok") {
setLookup({ state: "notFound", address: typedAddress });
} else {
setLookup({ state: "failed" });
}
};
const apply = async (changes: ShareChange[]) => {
if (!principal || !client.applyShareChanges) return;
setSaving(true);
try {
const results = await client.applyShareChanges(changes);
const failed = failedChanges(results);
onApplied(principal, results);
if (failed.length === 0) onClose();
else setFailures(failed);
} finally {
setSaving(false);
}
};
const submit = () => {
if (!principal || scopeIsEmpty(scope)) return;
void apply(buildGrantChanges(targets, principal.id, scope, level, { replace: changing }));
};
const retry = () => {
if (failures) void apply(failures.map((f) => f.change));
};
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-0 z-[100000] flex items-stretch justify-center bg-black/50 backdrop-blur-[1px] sm:items-center sm:p-4"
onMouseDown={(e) => { if (e.target === e.currentTarget && !saving) onClose(); }}
// Keys typed here are the dialog's: the mail shortcuts behind it must not see them.
onKeyDown={(e) => e.stopPropagation()}
data-testid="delegate-dialog"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative flex h-full w-full flex-col bg-background shadow-xl sm:h-auto sm:max-h-[85vh] sm:max-w-lg sm:rounded-lg sm:border sm:border-border"
>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 id={titleId} className="truncate text-lg font-semibold">
{changing && principal ? t("dialog_title_change", { name: principalDisplay(principal).name }) : t("dialog_title_add")}
</h2>
<button
type="button"
onClick={onClose}
disabled={saving}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="h-5 w-5" />
</button>
</div>
{failures ? (
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4 sm:px-6" role="alert">
<p className="text-sm font-medium">{t("partial_title")}</p>
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{failures.map((f) => (
<li key={`${f.change.kind}:${f.change.id}`} className="px-3 py-2">
<span className="font-medium">{f.change.name}</span>
<span className="text-muted-foreground"> · {t(`kind.${f.change.kind}`)}</span>
{f.error && <div className="text-xs text-muted-foreground">{f.error}</div>}
</li>
))}
</ul>
</div>
) : (
<div className="flex-1 space-y-5 overflow-y-auto px-4 py-4 sm:px-6">
<Field label={t("person")}>
{principal ? (
<div className="flex items-center gap-3 rounded-md border border-border px-3 py-2">
<Avatar name={principal.name} email={principal.email ?? undefined} size="sm" className="shrink-0" />
<PrincipalText principal={principal} />
{!changing && (
<Button variant="ghost" size="sm" onClick={() => { setPrincipal(null); setLookup({ state: "idle" }); }}>
{t("change_person")}
</Button>
)}
</div>
) : (
<div className="space-y-2">
<input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setLookup({ state: "idle" }); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void lookUp(); } }}
placeholder={t("search_placeholder")}
aria-label={t("person")}
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
/>
{directory === null ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3 w-3 animate-spin" />{t("loading")}</p>
) : (
<div className="max-h-48 overflow-y-auto rounded-md border border-border" role="listbox" aria-label={t("person")}>
{candidates.length === 0 && !canLookUp && (
<p className="px-3 py-2 text-xs text-muted-foreground">{t("no_match")}</p>
)}
{candidates.map((p) => (
<button
key={p.id}
type="button"
role="option"
aria-selected={false}
onClick={() => setPrincipal(p)}
className="flex w-full items-center gap-2 px-3 py-2 text-start hover:bg-muted focus:bg-muted focus:outline-none"
>
<Avatar name={p.name} email={p.email ?? undefined} size="sm" className="shrink-0" />
<PrincipalText principal={p} />
{existingIds.has(p.id) && <span className="shrink-0 text-[10px] text-muted-foreground">{t("has_access")}</span>}
</button>
))}
{canLookUp && (
<button
type="button"
onClick={() => void lookUp()}
disabled={lookup.state === "busy"}
className="flex w-full items-center gap-2 px-3 py-2 text-start text-sm hover:bg-muted focus:bg-muted focus:outline-none disabled:opacity-50"
>
{lookup.state === "busy" && <Loader2 className="h-4 w-4 animate-spin" />}
{lookup.state === "busy" ? t("looking_up") : t("use_address", { address: typedAddress })}
</button>
)}
</div>
)}
{lookup.state === "notFound" && <p className="text-xs text-destructive" role="status">{t("not_found", { address: lookup.address })}</p>}
{lookup.state === "failed" && <p className="text-xs text-destructive" role="status">{t("lookup_failed")}</p>}
{(directoryOff || lookup.state === "directoryOff") && (
<p className="text-xs text-muted-foreground" role="status" data-testid="delegate-directory-off">
{directoryOff ? `${t("known_only")} ` : ""}{t("directory_off_note")}
</p>
)}
{directory?.status === "error" && <p className="text-xs text-muted-foreground">{t("directory_failed")}</p>}
</div>
)}
</Field>
<fieldset className="space-y-3">
<legend className="mb-1 text-sm font-medium">{t("what")}</legend>
{has("mailbox") && (
<Choice<MailScope>
label={t("mail")}
value={scope.mail}
onChange={(mail) => setScope({ ...scope, mail })}
options={[["all", t("mail_all")], ["inbox", t("mail_inbox")], ["none", t("none")]]}
/>
)}
{has("calendar") && (
<Choice<CollectionScope>
label={t("calendars")}
value={scope.calendars}
onChange={(calendars) => setScope({ ...scope, calendars })}
options={[["default", t("calendars_default")], ["all", t("calendars_all")], ["none", t("none")]]}
/>
)}
{has("addressBook") && (
<Choice<CollectionScope>
label={t("address_books")}
value={scope.addressBooks}
onChange={(addressBooks) => setScope({ ...scope, addressBooks })}
options={[["default", t("address_books_default")], ["all", t("address_books_all")], ["none", t("none")]]}
/>
)}
{scopeIsEmpty(scope) && <p className="text-xs text-destructive">{t("empty_scope")}</p>}
{changing && <p className="text-xs text-muted-foreground">{t("change_note")}</p>}
</fieldset>
<fieldset className="space-y-2" data-testid="delegate-rights">
<legend className="mb-1 text-sm font-medium">{t("rights")}</legend>
{levels.map((option) => (
<label key={option} className="flex cursor-pointer items-start gap-2 text-sm">
<input
type="radio"
name="delegate-level"
value={option}
checked={level === option}
onChange={() => setLevel(option)}
className="mt-1"
/>
<span>
<span className="block">{t(`rights_${option}`)}</span>
<span className="block text-xs text-muted-foreground">{t(`rights_${option}_hint`)}</span>
</span>
</label>
))}
{levelsRestricted && <OrgLockedNote />}
</fieldset>
<p className="flex gap-2 rounded-md bg-muted/60 px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>{t("send_note")}</span>
</p>
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3 sm:px-6 sm:py-4">
{failures ? (
<>
<Button variant="outline" onClick={onClose} disabled={saving}>{tCommon("close")}</Button>
<Button onClick={retry} disabled={saving}>
{saving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t("retry")}
</Button>
</>
) : (
<>
<Button variant="outline" onClick={onClose} disabled={saving}>{tCommon("cancel")}</Button>
<Button onClick={submit} disabled={saving || !principal || scopeIsEmpty(scope)}>
{saving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{changing ? t("apply_change") : t("apply_add")}
</Button>
</>
)}
</div>
</div>
</div>,
document.body,
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<div className="text-sm font-medium">{label}</div>
{children}
</div>
);
}
function PrincipalText({ principal }: { principal: Principal }) {
const t = useTranslations("delegates");
const { name, detail } = principalDisplay(principal);
return (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 truncate text-sm font-medium">
<span className="truncate">{name}</span>
{principal.type === "group" && (
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-normal uppercase text-muted-foreground">{t("group")}</span>
)}
</div>
{detail && <div className="truncate text-xs text-muted-foreground">{detail}</div>}
</div>
);
}
function Choice<T extends string>({ label, value, onChange, options }: {
label: string;
value: T;
onChange: (value: T) => void;
options: [T, string][];
}) {
const name = useId();
return (
<div role="radiogroup" aria-label={label} className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="w-28 shrink-0 text-sm text-muted-foreground">{label}</span>
<div className="flex flex-wrap gap-1.5">
{options.map(([option, text]) => (
<label
key={option}
className={cn(
"cursor-pointer rounded-md border px-2.5 py-1 text-xs transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
value === option ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted",
)}
>
<input type="radio" name={name} value={option} checked={value === option} onChange={() => onChange(option)} className="sr-only" />
{text}
</label>
))}
</div>
</div>
);
}
@@ -0,0 +1,219 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Loader2, UserPlus } from "@/components/icons";
import { SettingsSection } from "@/components/settings/settings-section";
import { useAuthStore } from "@/stores/auth-store";
import { toast } from "@/stores/toast-store";
import { useRestriction } from "@/hooks/use-restriction";
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
import type { Principal, PrincipalListing } from "@/lib/jmap/types";
import type { ShareChangeResult, ShareTarget } from "@/lib/jmap/share-batch";
import type { ShareKind } from "@/lib/jmap/share-rights";
import {
allowedLevels,
buildRevokeChanges,
failedChanges,
scopeOfSummary,
summarizeDelegates,
type DelegateSummary,
} from "@/lib/delegation/delegation";
import { rememberPrincipals, rememberedPrincipals } from "@/lib/delegation/known-principals";
import { DelegateDialog, type DelegateDialogProps } from "./delegate-dialog";
import { principalDisplay } from "./principal-display";
type DialogState = { mode: "add" } | { mode: "change"; summary: DelegateSummary } | null;
/**
* Settings > Sharing > Delegates: give someone access to mail, calendars
* and contacts in one step, and see and take back who has access to what.
* Hidden when the admin hides `delegation`; `delegationRights` limits the
* rights offered.
*/
export function DelegatesSettings() {
const t = useTranslations("delegates");
const client = useAuthStore((s) => s.client);
const accountKey = useAuthStore((s) => s.activeAccountId) ?? "";
const delegation = useRestriction("delegation");
const rights = useRestriction("delegationRights");
const levels = useMemo(() => allowedLevels(rights.allowedValues), [rights.allowedValues]);
const [targets, setTargets] = useState<ShareTarget[] | null>(null);
const [failedKinds, setFailedKinds] = useState<ShareKind[]>([]);
const [directory, setDirectory] = useState<PrincipalListing | null>(null);
const [remembered, setRemembered] = useState<Map<string, Principal>>(() => new Map());
const [dialog, setDialog] = useState<DialogState>(null);
const [confirmRemove, setConfirmRemove] = useState<DelegateSummary | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
const reload = useCallback(async () => {
if (!client?.getShareTargets) return;
const result = await client.getShareTargets();
setTargets(result.targets);
setFailedKinds(result.failed);
}, [client]);
useEffect(() => {
if (!client || delegation.hidden) return;
let cancelled = false;
setTargets(null);
setDirectory(null);
setRemembered(rememberedPrincipals(accountKey));
void reload();
void loadPrincipalDirectory(client).then((listing) => {
if (cancelled) return;
setDirectory(listing);
rememberPrincipals(accountKey, listing.principals);
});
return () => { cancelled = true; };
}, [client, accountKey, delegation.hidden, reload]);
const ownIds = useMemo(() => (client ? [client.getAccountId()] : []), [client]);
const summaries = useMemo(() => (targets ? summarizeDelegates(targets, ownIds) : []), [targets, ownIds]);
const principalFor = useCallback((id: string): Principal | null =>
directory?.principals.find((p) => p.id === id) ?? remembered.get(id) ?? null, [directory, remembered]);
if (delegation.hidden || !client) return null;
const onApplied: DelegateDialogProps["onApplied"] = (principal, results) => {
rememberPrincipals(accountKey, [principal]);
setRemembered(rememberedPrincipals(accountKey));
const failed = failedChanges(results);
if (failed.length === 0) {
toast.success(dialog?.mode === "change" ? t("updated") : t("applied", { name: principalDisplay(principal).name }));
}
void reload();
};
const remove = async (summary: DelegateSummary) => {
if (!targets || !client.applyShareChanges) return;
setRemovingId(summary.principalId);
try {
const results: ShareChangeResult[] = await client.applyShareChanges(buildRevokeChanges(targets, summary.principalId));
const failed = failedChanges(results);
if (failed.length === 0) toast.success(t("removed"));
else toast.error(t("remove_partial", { items: failed.map((f) => f.change.name).join(", ") }));
await reload();
} finally {
setRemovingId(null);
}
};
const nameOf = (id: string) => {
const principal = principalFor(id);
return principal ? principalDisplay(principal).name : t("unknown_person");
};
return (
<SettingsSection id="delegates" title={t("title")} description={t("description")}>
<div>
<Button onClick={() => setDialog({ mode: "add" })} disabled={!targets} data-testid="delegates-give-access">
<UserPlus className="me-2 h-4 w-4" />
{t("give_access")}
</Button>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("who_has_access")}</h4>
{failedKinds.length > 0 && <p className="text-xs text-destructive" role="status">{t("read_failed")}</p>}
{targets === null ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("loading")}</p>
) : summaries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("nobody")}</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border" data-testid="delegates-list">
{summaries.map((summary) => {
const principal = principalFor(summary.principalId);
const display = principal ? principalDisplay(principal) : { name: t("unknown_person"), detail: summary.principalId };
return (
<li key={summary.principalId} className="flex flex-col gap-2 px-3 py-3 sm:flex-row sm:items-center" data-testid="delegate-row">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Avatar name={principal?.name} email={principal?.email ?? undefined} size="sm" className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{display.name}</div>
{display.detail && <div className="truncate text-xs text-muted-foreground">{display.detail}</div>}
<div className="text-xs text-muted-foreground">{describeSummary(summary, t)}</div>
</div>
</div>
<div className="flex shrink-0 gap-2 ps-11 sm:ps-0">
<Button
variant="outline"
size="sm"
onClick={() => setDialog({ mode: "change", summary })}
disabled={removingId === summary.principalId}
>
{t("change")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setConfirmRemove(summary)}
disabled={removingId === summary.principalId}
>
{removingId === summary.principalId && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t("remove")}
</Button>
</div>
</li>
);
})}
</ul>
)}
</div>
{dialog && targets && (
<DelegateDialog
client={client}
targets={targets}
directory={directory}
ownIds={ownIds}
existingIds={new Set(summaries.map((s) => s.principalId))}
initial={dialog.mode === "change"
? {
principal: principalFor(dialog.summary.principalId)
?? { id: dialog.summary.principalId, name: t("unknown_person"), type: "individual" },
...scopeOfSummary(dialog.summary, targets),
}
: undefined}
levels={levels}
levelsRestricted={rights.allowedValues !== null && levels.length < 2}
onApplied={onApplied}
onClose={() => setDialog(null)}
/>
)}
<ConfirmDialog
isOpen={!!confirmRemove}
onClose={() => setConfirmRemove(null)}
onConfirm={() => { if (confirmRemove) void remove(confirmRemove); }}
title={confirmRemove ? t("remove_title", { name: nameOf(confirmRemove.principalId) }) : ""}
message={confirmRemove ? t("remove_message", { name: nameOf(confirmRemove.principalId) }) : ""}
confirmText={t("remove")}
variant="destructive"
/>
</SettingsSection>
);
}
type Translate = ReturnType<typeof useTranslations<"delegates">>;
/** "All mail folders · 1 calendar · Read", in the order of the dialog. */
export function describeSummary(summary: DelegateSummary, t: Translate): string {
const parts: string[] = [];
const { mail, calendars, addressBooks, files } = summary;
if (mail.shared > 0) {
parts.push(mail.shared === mail.total
? t("summary.mail_all")
: mail.shared === 1 && mail.inbox ? t("summary.mail_inbox") : t("summary.mail_some", { count: mail.shared }));
}
if (calendars.shared > 0) parts.push(t("summary.calendars", { count: calendars.shared }));
if (addressBooks.shared > 0) parts.push(t("summary.address_books", { count: addressBooks.shared }));
if (files > 0) parts.push(t("summary.files", { count: files }));
parts.push(t(`level.${summary.level ?? "none"}`));
return parts.join(" · ");
}
@@ -0,0 +1,10 @@
import type { Principal } from "@/lib/jmap/types";
/** The line to show for a principal, and the smaller one under it. */
export function principalDisplay(principal: Pick<Principal, "id" | "name" | "email" | "description">): { name: string; detail: string | null } {
const name = principal.name || principal.email || principal.id;
const detail = principal.email && principal.email !== name
? principal.email
: principal.description && principal.description !== name ? principal.description : null;
return { name, detail };
}
+11
View File
@@ -24,6 +24,7 @@ import {
PenLine,
LayoutList,
Database,
Users,
X,
type AppIcon,
} from '@/components/icons';
@@ -48,6 +49,8 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { AddonsSettings } from '@/components/settings/addons-settings';
import { NotificationSettings } from '@/components/settings/notification-settings';
import { DelegatesSettings } from '@/components/settings/delegates/delegates-settings';
import { useRestriction } from '@/hooks/use-restriction';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { AdvancedSettingsProvider, AdvancedSettingsSwitch, SectionNav, scrollToSettingsSection } from '@/components/settings/settings-section';
@@ -127,6 +130,7 @@ const tabIcons: Record<Tab, AppIcon> = {
filters: Filter,
organize: FolderOpen,
security: Shield,
sharing: Users,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
@@ -254,6 +258,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const tDelegates = useTranslations('delegates');
useDocumentTitle(tSidebar('settings'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -266,6 +271,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const appName = productName(configAppName);
const { isFeatureEnabled } = usePolicyStore();
const archiveActionEnabled = isFeatureEnabled('archiveActionEnabled');
const delegationHidden = useRestriction('delegation').hidden;
// A tab in the URL wins over the persisted one; without it, nothing changes.
const [activeTab, setActiveTab] = useState<SettingsTabId>(
() => targetFromDeepLink(linkSegments)?.tab ?? readPersistedTab(),
@@ -629,11 +635,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
// Delegates (Settings > Sharing) needs someone to share with and something to share.
const supportsSharing = !delegationHidden && !!client?.getShareTargets && (client?.supportsPrincipals() ?? false)
&& ((client?.supportsMailboxSharing?.() ?? false) || supportsCalendar || (client?.supportsContacts() ?? false));
const tabs: TabDef[] = [
// You and the app
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'top' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'top' as TabGroup }] : []),
...(supportsSharing ? [{ id: 'sharing' as Tab, label: tDelegates('tab'), icon: tabIcons.sharing, group: 'top' as TabGroup }] : []),
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'top' },
{ id: 'general', label: t('tabs.general'), icon: tabIcons.general, group: 'top' },
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'top' },
@@ -783,6 +793,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'organize' && <OrganizeSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'sharing' && <DelegatesSettings />}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
+130
View File
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { buildShareSetCalls, parseShareTargets, readShareSetResults, type ShareChange } from '../jmap/share-batch';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../jmap/share-rights';
const change = (kind: ShareChange['kind'], id: string, rights: ShareChange['rights'], principalId = 'ck', accountId = 'ci'): ShareChange =>
({ kind, accountId, id, name: id, principalId, rights });
describe('buildShareSetCalls', () => {
it('puts each type in one call with a shareWith patch per item', () => {
const { calls } = buildShareSetCalls([
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('mailbox', 'b', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
change('file', 'n1', null),
], { maxObjectsInSet: 500 });
expect(calls).toEqual([
['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': MAILBOX_PRESETS.read }, b: { 'shareWith/ck': MAILBOX_PRESETS.read } } }, 's0'],
['Calendar/set', { accountId: 'ci', update: { c2: { 'shareWith/ck': CALENDAR_PRESETS.read } } }, 's1'],
['FileNode/set', { accountId: 'ci', update: { n1: { 'shareWith/ck': null } } }, 's2'],
]);
});
it('splits a type past maxObjectsInSet', () => {
const changes = ['a', 'b', 'c'].map((id) => change('mailbox', id, null));
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 2 });
expect(calls.map(([, args]) => Object.keys(args.update as object))).toEqual([['a', 'b'], ['c']]);
expect(changesByCall.get('s1')!.map((c) => c.id)).toEqual(['c']);
});
it('converts rights through wireRights, but never a revoke', () => {
const { calls } = buildShareSetCalls([change('file', 'n1', FILE_PRESETS.read), change('file', 'n2', null)], {
maxObjectsInSet: 500,
wireRights: () => ({ mayRead: true, mayWrite: false, mayShare: false }),
});
expect(calls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: false, mayShare: false } }, n2: { 'shareWith/ck': null } });
});
});
describe('readShareSetResults', () => {
it('reports each item: updated, refused with the reason, or the whole call failing', () => {
const changes = [
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('mailbox', 'b', MAILBOX_PRESETS.read),
change('mailbox', 'z', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
];
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 500 });
const results = readShareSetResults(calls, changesByCall, [
['Mailbox/set', { updated: { a: null }, notUpdated: { b: { type: 'forbidden', description: 'You may not share this folder' } } }, 's0'],
['error', { type: 'forbidden' }, 's1'],
]);
expect(results.map((r) => [r.change.id, r.ok, r.error])).toEqual([
['a', true, undefined],
['b', false, 'You may not share this folder'],
['z', false, undefined],
['c2', false, 'forbidden'],
]);
});
});
describe('parseShareTargets', () => {
it('reads every type and names the ones whose call failed', () => {
const { targets, failed } = parseShareTargets([
['Mailbox/get', { list: [{ id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } }] }, 'm'],
['error', { type: 'serverFail' }, 'c'],
['AddressBook/get', { list: [{ id: 'ab', name: 'Contacts', isDefault: true, shareWith: null }] }, 'a'],
], { mailbox: 'ci', calendar: 'ci', addressBook: 'ci' });
expect(targets).toEqual([
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
]);
expect(failed).toEqual(['calendar']);
});
});
describe('JMAPClient.applyShareChanges', () => {
afterEach(() => vi.restoreAllMocks());
function client(fileCapability: Record<string, unknown> = { forbiddenNameChars: '/' }) {
const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(c, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'ci',
capabilities: {
'urn:ietf:params:jmap:core': { maxCallsInRequest: 2, maxObjectsInSet: 500 },
'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:filenode': {},
},
});
const accounts = { ci: { name: 'org-alice@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:filenode': fileCapability } } };
Object.assign(c, { accounts, session: { accounts } });
return c;
}
function respondWith(handler: (body: { using: string[]; methodCalls: [string, Record<string, unknown>, string][] }) => unknown) {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string);
const response = handler(body);
return { ok: true, status: 200, text: async () => JSON.stringify(response), json: async () => response } as Response;
});
}
it('sends one request per maxCallsInRequest calls and reports every item', async () => {
const spy = respondWith((body) => ({
methodResponses: body.methodCalls.map(([method, args, id]) => [method, { updated: Object.fromEntries(Object.keys(args.update as object).map((k) => [k, null])) }, id]),
}));
const results = await client().applyShareChanges([
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
change('file', 'n1', null),
]);
expect(spy).toHaveBeenCalledTimes(2);
const first = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(first.using).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail:share', 'urn:ietf:params:jmap:calendars']));
expect(results.map((r) => [r.change.id, r.ok])).toEqual([['a', true], ['c2', true], ['n1', true]]);
});
it('marks the items of a failed request failed instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'));
const results = await client().applyShareChanges([change('mailbox', 'a', MAILBOX_PRESETS.read)]);
expect(results).toEqual([{ change: expect.objectContaining({ id: 'a' }), ok: false, error: expect.any(String) }]);
});
it('sends the old file rights to a server before 0.16.6', async () => {
const spy = respondWith((body) => ({ methodResponses: [['FileNode/set', { updated: { n1: null } }, body.methodCalls[0][2]]] }));
await client({}).applyShareChanges([change('file', 'n1', FILE_PRESETS.readWrite)]);
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: true, mayShare: false } } });
});
});
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import {
allowedLevels,
buildGrantChanges,
buildRevokeChanges,
scopeOfSummary,
selectTargets,
summarizeDelegates,
} from '../delegation';
import type { ShareTarget } from '@/lib/jmap/share-batch';
import { ADDRESS_BOOK_PRESETS, CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '@/lib/jmap/share-rights';
const CAROL = 'ck';
const BOB = 'cj';
// A 1.0-style read grant of a folder: keywords come back with seen.
const MAILBOX_READ_STORED = { ...MAILBOX_PRESETS.read, maySetKeywords: true };
function targets(): ShareTarget[] {
return [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: {} },
{ kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Deleted Items', role: 'trash', shareWith: {} },
{ kind: 'mailbox', accountId: 'ci', id: 'f', name: 'Projects', role: null, shareWith: { [BOB]: MAILBOX_PRESETS.readWrite } },
{ kind: 'calendar', accountId: 'ci', id: 'c1', name: 'Work', isDefault: false, shareWith: {} },
{ kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', isDefault: true, shareWith: {} },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
{ kind: 'addressBook', accountId: 'ci', id: 'ts', name: 'Trusted Senders', isDefault: false, shareWith: {} },
{ kind: 'file', accountId: 'ci', id: 'n1', name: 'report.pdf', shareWith: { [CAROL]: FILE_PRESETS.read } },
];
}
const ids = (list: { kind: string; id: string }[]) => list.map((t) => `${t.kind}:${t.id}`);
describe('selectTargets', () => {
it('takes every folder, the default calendar and the default address book by default', () => {
expect(ids(selectTargets(targets(), { mail: 'all', calendars: 'default', addressBooks: 'default' }))).toEqual([
'mailbox:a', 'mailbox:b', 'mailbox:f', 'calendar:c2', 'addressBook:ab',
]);
});
it('takes only the Inbox, every calendar, and no address book', () => {
expect(ids(selectTargets(targets(), { mail: 'inbox', calendars: 'all', addressBooks: 'none' }))).toEqual([
'mailbox:a', 'calendar:c1', 'calendar:c2',
]);
});
it('leaves out the trusted senders list and files even with "all"', () => {
const picked = ids(selectTargets(targets(), { mail: 'none', calendars: 'none', addressBooks: 'all' }));
expect(picked).toEqual(['addressBook:ab']);
});
it('uses the first calendar when none is marked default', () => {
const list = targets().map((t) => (t.kind === 'calendar' ? { ...t, isDefault: false } : t));
expect(ids(selectTargets(list, { mail: 'none', calendars: 'default', addressBooks: 'none' }))).toEqual(['calendar:c1']);
});
});
describe('buildGrantChanges', () => {
it('builds read patches per type', () => {
const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'read');
expect(changes).toEqual([
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', principalId: CAROL, rights: MAILBOX_PRESETS.read },
{ kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', principalId: CAROL, rights: CALENDAR_PRESETS.read },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', principalId: CAROL, rights: ADDRESS_BOOK_PRESETS.read },
]);
});
it('builds read and write patches that never let a delegate send or re-share', () => {
const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'write');
const [mail, calendar, book] = changes.map((c) => c.rights as unknown as Record<string, boolean>);
expect(mail).toMatchObject({ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetKeywords: true, maySubmit: false, mayShare: false });
expect(calendar).toMatchObject({ mayReadItems: true, mayWriteAll: true, mayShare: false });
expect(book).toMatchObject({ mayRead: true, mayWrite: true, mayShare: false });
});
it('takes away what a change leaves out, but never files', () => {
const list = targets();
list[2].shareWith[CAROL] = MAILBOX_READ_STORED; // Projects
list[3].shareWith[CAROL] = CALENDAR_PRESETS.read; // Work
const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'none' }, 'read', { replace: true });
expect(changes.filter((c) => c.rights === null).map((c) => `${c.kind}:${c.id}`)).toEqual(['mailbox:f', 'calendar:c1']);
expect(changes.some((c) => c.kind === 'file')).toBe(false);
});
it('takes nothing away without replace', () => {
const list = targets();
list[3].shareWith[CAROL] = CALENDAR_PRESETS.read;
const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, 'read');
expect(changes.every((c) => c.rights !== null)).toBe(true);
});
});
describe('buildRevokeChanges', () => {
it('removes the person from everything, files included, and leaves others alone', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_READ_STORED;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.readWrite;
const changes = buildRevokeChanges(list, CAROL);
expect(changes.map((c) => [`${c.kind}:${c.id}`, c.principalId, c.rights])).toEqual([
['mailbox:a', CAROL, null],
['calendar:c2', CAROL, null],
['file:n1', CAROL, null],
]);
});
});
describe('summarizeDelegates', () => {
it('groups grants by person and counts each type', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_READ_STORED;
list[1].shareWith[CAROL] = MAILBOX_READ_STORED;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.read;
const summary = summarizeDelegates(list);
expect(summary.map((s) => s.principalId)).toEqual([CAROL, BOB]);
const carol = summary[0];
expect(carol.level).toBe('read');
expect(carol.mail).toEqual({ shared: 2, total: 3, inbox: true });
expect(carol.calendars).toEqual({ shared: 1, total: 2 });
expect(carol.addressBooks).toEqual({ shared: 0, total: 1 });
expect(carol.files).toBe(1);
expect(summary[1]).toMatchObject({ level: 'write', mail: { shared: 1, total: 3, inbox: false } });
});
it('says "mixed" when read and write grants meet, and leaves out excluded ids', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_PRESETS.readWrite;
const summary = summarizeDelegates(list, [BOB]);
expect(summary.map((s) => [s.principalId, s.level])).toEqual([[CAROL, 'mixed']]);
});
it('gives a free/busy-only calendar no level', () => {
const list = targets().filter((t) => t.kind !== 'file' && t.id !== 'f');
list.find((t) => t.id === 'c1')!.shareWith[CAROL] = CALENDAR_PRESETS.freeBusy;
expect(summarizeDelegates(list)[0].level).toBeNull();
});
});
describe('scopeOfSummary', () => {
it('starts "Change" from what the person has now', () => {
const list = targets();
for (const t of list) if (t.kind === 'mailbox') t.shareWith[CAROL] = MAILBOX_PRESETS.readWrite;
list[3].shareWith[CAROL] = CALENDAR_PRESETS.readWrite;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.readWrite;
const [carol] = summarizeDelegates(list);
expect(scopeOfSummary(carol, list)).toEqual({ scope: { mail: 'all', calendars: 'all', addressBooks: 'none' }, level: 'write' });
});
it('reads an Inbox-only read grant back as Inbox only and read', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_READ_STORED;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.read;
list[5].shareWith[CAROL] = ADDRESS_BOOK_PRESETS.read;
const carol = summarizeDelegates(list).find((s) => s.principalId === CAROL)!;
expect(scopeOfSummary(carol, list)).toEqual({ scope: { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, level: 'read' });
});
});
describe('allowedLevels', () => {
it('offers both without a restriction', () => {
expect(allowedLevels(null)).toEqual(['read', 'write']);
});
it('offers only what the admin allows', () => {
expect(allowedLevels(['read'])).toEqual(['read']);
expect(allowedLevels(['write'])).toEqual(['write']);
});
it('falls back to read when the list names neither', () => {
expect(allowedLevels(['manager'])).toEqual(['read']);
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* Delegates (Settings > Sharing): give one person access to mail, calendars
* and contacts in one step, list who has access to what, and take it away
* everywhere. Pure functions over the user's shareable items
* (JMAPClient.getShareTargets); JMAPClient.applyShareChanges sends what they
* build.
*
* Stalwart has no "send as" or "send on behalf" for a person's own account,
* so delegates get no submission right; the dialog says so.
*/
import type { ShareChange, ShareChangeResult, ShareTarget } from '@/lib/jmap/share-batch';
import { accessLevelOf, accessRights, type AccessLevel, type ShareKind } from '@/lib/jmap/share-rights';
export type MailScope = 'all' | 'inbox' | 'none';
export type CollectionScope = 'default' | 'all' | 'none';
/** What a delegate gets: which folders, calendars and address books. */
export interface DelegateScope {
mail: MailScope;
calendars: CollectionScope;
addressBooks: CollectionScope;
}
export const DEFAULT_DELEGATE_SCOPE: DelegateScope = { mail: 'all', calendars: 'default', addressBooks: 'default' };
export const ACCESS_LEVELS: readonly AccessLevel[] = ['read', 'write'];
// Bulwark's own list of senders whose images load (stores/contact-store.ts,
// TRUSTED_SENDERS_BOOK_NAME). It is an address book on the server, but not
// one a delegate needs.
const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
function ofKind(targets: ShareTarget[], kind: ShareKind): ShareTarget[] {
return targets.filter((t) => t.kind === kind);
}
function defaultOf(collections: ShareTarget[]): ShareTarget[] {
const flagged = collections.find((c) => c.isDefault);
return flagged ? [flagged] : collections.slice(0, 1);
}
function delegableAddressBooks(targets: ShareTarget[]): ShareTarget[] {
return ofKind(targets, 'addressBook').filter((b) => b.name !== TRUSTED_SENDERS_BOOK_NAME || b.isDefault);
}
/** The items a scope covers. Files are never part of it. */
export function selectTargets(targets: ShareTarget[], scope: DelegateScope): ShareTarget[] {
const mailboxes = ofKind(targets, 'mailbox');
const mail = scope.mail === 'all'
? mailboxes
: scope.mail === 'inbox' ? mailboxes.filter((m) => m.role?.toLowerCase() === 'inbox') : [];
const calendars = ofKind(targets, 'calendar');
const books = delegableAddressBooks(targets);
const pick = (list: ShareTarget[], s: CollectionScope) => (s === 'all' ? list : s === 'default' ? defaultOf(list) : []);
return [...mail, ...pick(calendars, scope.calendars), ...pick(books, scope.addressBooks)];
}
function change(target: ShareTarget, principalId: string, rights: ShareChange['rights']): ShareChange {
return { kind: target.kind, accountId: target.accountId, id: target.id, name: target.name, principalId, rights };
}
/**
* The changes that give `principalId` `level` access to what `scope` covers.
* With `replace` (the "Change" of someone who already has access), the
* folders, calendars and address books outside the scope lose their access;
* shared files stay as they are, since the dialog doesn't offer them.
*/
export function buildGrantChanges(
targets: ShareTarget[],
principalId: string,
scope: DelegateScope,
level: AccessLevel,
options: { replace?: boolean } = {},
): ShareChange[] {
const selected = selectTargets(targets, scope);
const chosen = new Set(selected.map((t) => `${t.kind}:${t.id}`));
const changes = selected.map((t) => change(t, principalId, accessRights(t.kind, level)));
if (options.replace) {
for (const t of targets) {
if (t.kind === 'file' || chosen.has(`${t.kind}:${t.id}`)) continue;
if (principalId in t.shareWith) changes.push(change(t, principalId, null));
}
}
return changes;
}
/** The changes that take every grant of `principalId` away, files included. */
export function buildRevokeChanges(targets: ShareTarget[], principalId: string): ShareChange[] {
return targets.filter((t) => principalId in t.shareWith).map((t) => change(t, principalId, null));
}
export interface DelegateItem {
kind: ShareKind;
id: string;
name: string;
/** null: shared without read access (calendar free/busy only). */
level: AccessLevel | null;
}
export interface DelegateSummary {
principalId: string;
/** "mixed" when some items are read only and others writable; null when nothing is readable. */
level: AccessLevel | 'mixed' | null;
mail: { shared: number; total: number; inbox: boolean };
calendars: { shared: number; total: number };
addressBooks: { shared: number; total: number };
files: number;
items: DelegateItem[];
}
/**
* Everyone who has access to something of the user's, one entry per person
* or group, in the order they first appear. `excludeIds` leaves out the
* user's own principal, should a server list it.
*/
export function summarizeDelegates(targets: ShareTarget[], excludeIds: string[] = []): DelegateSummary[] {
const exclude = new Set(excludeIds);
const byPrincipal = new Map<string, DelegateItem[]>();
for (const target of targets) {
for (const [principalId, rights] of Object.entries(target.shareWith)) {
if (exclude.has(principalId) || !rights) continue;
const items = byPrincipal.get(principalId) ?? [];
items.push({ kind: target.kind, id: target.id, name: target.name, level: accessLevelOf(target.kind, rights) });
byPrincipal.set(principalId, items);
}
}
const totals = {
mailbox: ofKind(targets, 'mailbox').length,
calendar: ofKind(targets, 'calendar').length,
addressBook: delegableAddressBooks(targets).length,
};
const inboxIds = new Set(ofKind(targets, 'mailbox').filter((m) => m.role?.toLowerCase() === 'inbox').map((m) => m.id));
return [...byPrincipal.entries()].map(([principalId, items]) => {
const count = (kind: ShareKind) => items.filter((i) => i.kind === kind).length;
const levels = new Set(items.map((i) => i.level).filter((l): l is AccessLevel => l !== null));
return {
principalId,
level: levels.size === 0 ? null : levels.size > 1 ? 'mixed' : [...levels][0],
mail: { shared: count('mailbox'), total: totals.mailbox, inbox: items.some((i) => i.kind === 'mailbox' && inboxIds.has(i.id)) },
calendars: { shared: count('calendar'), total: totals.calendar },
addressBooks: { shared: count('addressBook'), total: totals.addressBook },
files: count('file'),
items,
};
});
}
/** The dialog's starting point for "Change": what the person has now. */
export function scopeOfSummary(summary: DelegateSummary, targets: ShareTarget[]): { scope: DelegateScope; level: AccessLevel } {
const sharedIds = new Set(summary.items.map((i) => `${i.kind}:${i.id}`));
const collection = (kind: 'calendar' | 'addressBook', shared: number, total: number): CollectionScope => {
if (shared === 0) return 'none';
if (shared === total && total > 1) return 'all';
const list = kind === 'addressBook' ? delegableAddressBooks(targets) : ofKind(targets, kind);
const def = defaultOf(list)[0];
return def && sharedIds.has(`${kind}:${def.id}`) ? 'default' : 'all';
};
const { mail } = summary;
return {
scope: {
mail: mail.shared === 0 ? 'none' : mail.shared === mail.total ? 'all' : 'inbox',
calendars: collection('calendar', summary.calendars.shared, summary.calendars.total),
addressBooks: collection('addressBook', summary.addressBooks.shared, summary.addressBooks.total),
},
// Files aren't in the dialog, so their rights don't decide its level.
level: summary.items.some((i) => i.kind !== 'file' && i.level === 'write') ? 'write' : 'read',
};
}
/**
* The levels the admin allows (`delegationRights.allowedValues`). Values
* other than "read" and "write" are ignored; a list with neither leaves
* read only, the safe end.
*/
export function allowedLevels(allowedValues: unknown[] | null): AccessLevel[] {
if (!allowedValues) return [...ACCESS_LEVELS];
const allowed = ACCESS_LEVELS.filter((level) => allowedValues.includes(level));
return allowed.length > 0 ? allowed : ['read'];
}
/** Whether a scope gives access to anything at all. */
export function scopeIsEmpty(scope: DelegateScope): boolean {
return scope.mail === 'none' && scope.calendars === 'none' && scope.addressBooks === 'none';
}
/** The changes that failed, for the report and for "Try again". */
export function failedChanges(results: ShareChangeResult[]): ShareChangeResult[] {
return results.filter((r) => !r.ok);
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Names of the people and groups the user gave access to, kept in this
* browser. Stalwart 1.0 with directory queries off (its default) won't name
* a principal the user shares with unless that principal also shares
* something back, so the "Who has access" list would otherwise show bare
* ids for the very people the user just picked by name. Per account, at
* most MAX entries, and only a convenience: losing it costs names, not
* access.
*/
import type { Principal } from '@/lib/jmap/types';
const STORAGE_KEY = 'bulwark-delegate-names';
const MAX = 200;
type Entry = Pick<Principal, 'id' | 'name' | 'email' | 'type'>;
type Store = Record<string, Record<string, Entry>>;
function readStore(): Store {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? (parsed as Store) : {};
} catch {
return {};
}
}
/** Remember these principals for `accountKey` (the signed-in account). */
export function rememberPrincipals(accountKey: string, principals: Principal[]): void {
if (!accountKey || principals.length === 0) return;
try {
const store = readStore();
const own = { ...(store[accountKey] ?? {}) };
for (const p of principals) {
delete own[p.id];
own[p.id] = { id: p.id, name: p.name, email: p.email ?? null, type: p.type };
}
const ids = Object.keys(own);
for (const id of ids.slice(0, Math.max(0, ids.length - MAX))) delete own[id];
store[accountKey] = own;
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// Private window or storage full: the list shows ids instead.
}
}
/** The principals remembered for `accountKey`, by id. */
export function rememberedPrincipals(accountKey: string): Map<string, Principal> {
const own = readStore()[accountKey] ?? {};
return new Map(Object.values(own).map((entry) => [entry.id, entry as Principal]));
}
+6
View File
@@ -1,3 +1,5 @@
import type { ShareChange, ShareChangeResult, ShareTarget } from "./share-batch";
import type { ShareKind } from "./share-rights";
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { FileNameRules } from "@/lib/file-name-rules";
@@ -230,6 +232,10 @@ export interface IJMAPClient {
supportsMailboxSharing?(accountId?: string): boolean;
getMailboxShareWith?(mailboxId: string, accountId?: string): Promise<Record<string, MailboxRights> | null>;
setMailboxShare?(mailboxId: string, principalId: string, rights: MailboxRights | null, accountId?: string): Promise<void>;
// Many items at once, for the Delegates page (lib/jmap/share-batch.ts). Optional (demo client).
getShareTargets?(): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }>;
applyShareChanges?(changes: ShareChange[]): Promise<ShareChangeResult[]>;
findPrincipalByEmail?(email: string): Promise<{ principal: Principal | null; status: "ok" | "forbidden" | "error"; directoryQueriesOff?: boolean }>;
// ── Emails ────────────────────────────────────────────────────
// `pinnedFirst` sorts emails carrying the $pinned keyword to the top
+125
View File
@@ -24,6 +24,8 @@ import { SchedulingDeniedError } from "./scheduling-error";
import { jmapError, jmapErrorFailedIds, jmapErrorType, jmapSetError, type JmapError, type JmapErrorInfo } from "./jmap-error";
import { isServerSetKeyword } from "./server-keywords";
import { PrincipalsRequestError } from "./principals-error";
import { buildShareSetCalls, failAll, parseShareTargets, readShareSetResults, SET_METHOD, type ShareChange, type ShareChangeResult, type ShareTarget } from "./share-batch";
import type { ShareKind } from "./share-rights";
import { RescheduleRestoredError } from "./reschedule-error";
export { SchedulingDeniedError, RescheduleRestoredError };
@@ -7378,6 +7380,129 @@ export class JMAPClient implements IJMAPClient {
}
}
// ── Sharing many items at once (the Delegates page) ──────────────
/**
* Every folder, calendar and address book of the user's own account with
* its `shareWith`, and the files that are shared with someone. A type the
* account can't share is left out; one whose read failed is in `failed`,
* so the caller doesn't mistake it for "shared with nobody".
*/
async getShareTargets(): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> {
const calls: JMAPMethodCall[] = [];
const using = new Set<string>(["urn:ietf:params:jmap:core"]);
const accounts: { mailbox?: string; calendar?: string; addressBook?: string } = {};
if (this.supportsMailboxSharing()) {
accounts.mailbox = this.accountId;
calls.push(["Mailbox/get", { accountId: this.accountId, ids: null, properties: ["id", "name", "role", "parentId", "shareWith"] }, "m"]);
for (const u of ["urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY]) using.add(u);
}
const calendarsAccountId = this.getCalendarsAccountId();
if (this.supportsCalendars(calendarsAccountId)) {
accounts.calendar = calendarsAccountId;
calls.push(["Calendar/get", { accountId: calendarsAccountId, ids: null, properties: ["id", "name", "isDefault", "shareWith"] }, "c"]);
for (const u of this.calendarUsing()) using.add(u);
}
const contactsAccountId = this.getContactsAccountId();
if (this.supportsContacts(contactsAccountId)) {
accounts.addressBook = contactsAccountId;
calls.push(["AddressBook/get", { accountId: contactsAccountId, ids: null, properties: ["id", "name", "isDefault", "shareWith"] }, "a"]);
for (const u of this.contactUsing()) using.add(u);
}
let result: { targets: ShareTarget[]; failed: ShareKind[] } = { targets: [], failed: [] };
if (calls.length > 0) {
try {
const response = await this.request(calls, [...using]);
result = parseShareTargets(response.methodResponses as [string, Record<string, unknown>, string][], accounts);
} catch (error) {
console.error("Failed to read sharing:", error);
result.failed = (Object.keys(accounts) as ShareKind[]);
}
}
const filesAccountId = this.getFilesAccountId();
if (this.supportsFiles(filesAccountId)) {
try {
for (const node of await this.listAllFileNodes()) {
if (!node.shareWith || Object.keys(node.shareWith).length === 0) continue;
result.targets.push({ kind: "file", accountId: filesAccountId, id: node.id, name: node.name, parentId: node.parentId ?? null, shareWith: node.shareWith });
}
} catch (error) {
console.error("Failed to read file sharing:", error);
result.failed.push("file");
}
}
return result;
}
/**
* Applies `shareWith` changes to many items in as few requests as the
* server's limits allow, and says for each one whether it worked. Never
* throws: a request that fails marks its items failed.
*/
async applyShareChanges(changes: ShareChange[]): Promise<ShareChangeResult[]> {
if (changes.length === 0) return [];
const { calls, changesByCall } = buildShareSetCalls(changes, {
maxObjectsInSet: this.getMaxObjectsInSet(),
wireRights: (change) => change.kind === "file" && change.rights && this.isLegacyFileNodeServer(change.accountId)
? toLegacyRights(change.rights as FileNodeRights)
: change.rights,
});
const usingFor: Record<string, string[]> = {
[SET_METHOD.mailbox]: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY],
[SET_METHOD.calendar]: this.calendarUsing(),
[SET_METHOD.addressBook]: this.contactUsing(),
[SET_METHOD.file]: this.fileUsing(),
};
const results: ShareChangeResult[] = [];
for (const group of batched(calls, this.getMaxCallsInRequest())) {
const using = [...new Set(group.flatMap(([method]) => usingFor[method] ?? []))];
try {
const response = await this.request(group, using);
results.push(...readShareSetResults(group, changesByCall, response.methodResponses as [string, Record<string, unknown>, string][]));
} catch (error) {
results.push(...failAll(group, changesByCall, error));
}
}
return results;
}
/**
* The principal with this address, through a directory query. `forbidden`
* when the server refuses queries (Stalwart 1.0 with directory queries off,
* the default), with `directoryQueriesOff` when that is the reason.
*/
async findPrincipalByEmail(email: string): Promise<{ principal: Principal | null; status: "ok" | "forbidden" | "error"; directoryQueriesOff?: boolean }> {
if (!this.supportsPrincipals()) return { principal: null, status: "error" };
const wanted = email.trim().toLowerCase();
try {
const response = await this.request([
["Principal/query", { accountId: this.accountId, filter: { email: wanted }, limit: 10 }, "0"],
["Principal/get", {
accountId: this.accountId,
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
properties: PRINCIPAL_PROPERTIES,
}, "1"],
], this.principalsUsing());
const responses = response.methodResponses ?? [];
const getResp = responses.find((r) => r[0] === "Principal/get");
if (!getResp) {
const error = responses.find((r) => r[0] === "error")?.[1] as JmapErrorInfo | undefined;
if (error?.type === "forbidden") {
return { principal: null, status: "forbidden", ...(error.description === DIRECTORY_QUERIES_OFF ? { directoryQueriesOff: true } : {}) };
}
return { principal: null, status: "error" };
}
const list = (getResp[1].list ?? []) as Principal[];
const principal = list.find((p) => p.email?.toLowerCase() === wanted || p.name?.toLowerCase() === wanted) ?? null;
return { principal: principal ? { ...principal, accountId: this.accountId } : null, status: "ok" };
} catch (error) {
console.error("Failed to look up principal:", error);
return { principal: null, status: "error" };
}
}
private async fetchPaginatedContacts(
accountId: string,
filter?: Record<string, unknown>,
+182
View File
@@ -0,0 +1,182 @@
/**
* Changing the `shareWith` of many folders, calendars, address books and
* files in one go, for the Delegates page: the method calls for one request
* and a result for every item, so a partial failure can say what failed.
* The single-item helpers (JMAPClient.setMailboxShare and friends) send the
* same `shareWith/<principalId>` patch.
*/
import { batched } from './request-limits';
import type { AnyRights, ShareKind } from './share-rights';
/** One shareable item of the user's own account, with its grants. */
export interface ShareTarget {
kind: ShareKind;
accountId: string;
/** The id in `accountId`. */
id: string;
name: string;
/** Mailboxes: the folder role ("inbox", "sent", ...). */
role?: string | null;
parentId?: string | null;
/** Calendars and address books: the account's default one. */
isDefault?: boolean;
shareWith: Record<string, AnyRights>;
}
/** Grant (`rights`) or revoke (`null`) one principal's access to one item. */
export interface ShareChange {
kind: ShareKind;
accountId: string;
id: string;
name: string;
principalId: string;
rights: AnyRights | null;
}
export interface ShareChangeResult {
change: ShareChange;
ok: boolean;
/** The server's reason, when it gave one. */
error?: string;
}
type MethodCall = [string, Record<string, unknown>, string];
type MethodResponse = [string, Record<string, unknown>, string];
export const SET_METHOD: Record<ShareKind, string> = {
mailbox: 'Mailbox/set',
calendar: 'Calendar/set',
addressBook: 'AddressBook/set',
file: 'FileNode/set',
};
export interface ShareSetCalls {
calls: MethodCall[];
/** The changes each call carries, by call id. */
changesByCall: Map<string, ShareChange[]>;
}
/**
* One /set call per type and account, split so none touches more than
* `maxObjectsInSet` items. `wireRights` lets the caller convert rights for
* a server that names them differently (old file rights).
*/
export function buildShareSetCalls(
changes: ShareChange[],
options: { maxObjectsInSet: number; wireRights?: (change: ShareChange) => unknown },
): ShareSetCalls {
const groups = new Map<string, ShareChange[]>();
for (const change of changes) {
const key = `${change.kind}\u0000${change.accountId}`;
const list = groups.get(key);
if (list) list.push(change);
else groups.set(key, [change]);
}
const calls: MethodCall[] = [];
const changesByCall = new Map<string, ShareChange[]>();
for (const group of groups.values()) {
const { kind, accountId } = group[0];
// Batch by item, not by change: two principals on one item are one object.
const ids = [...new Set(group.map((c) => c.id))];
for (const idBatch of batched(ids, options.maxObjectsInSet)) {
const inBatch = new Set(idBatch);
const batchChanges = group.filter((c) => inBatch.has(c.id));
const update: Record<string, Record<string, unknown>> = {};
for (const change of batchChanges) {
const rights = change.rights && options.wireRights ? options.wireRights(change) : change.rights;
(update[change.id] ??= {})[`shareWith/${change.principalId}`] = rights;
}
const callId = `s${calls.length}`;
calls.push([SET_METHOD[kind], { accountId, update }, callId]);
changesByCall.set(callId, batchChanges);
}
}
return { calls, changesByCall };
}
function describe(error: unknown): string | undefined {
const e = error as { description?: unknown; type?: unknown } | undefined;
if (typeof e?.description === 'string' && e.description) return e.description;
if (typeof e?.type === 'string' && e.type) return e.type;
return undefined;
}
/** A result for every change of `calls`, from the responses to them. */
export function readShareSetResults(
calls: MethodCall[],
changesByCall: Map<string, ShareChange[]>,
responses: MethodResponse[] | undefined,
): ShareChangeResult[] {
const results: ShareChangeResult[] = [];
for (const [, , callId] of calls) {
const changes = changesByCall.get(callId) ?? [];
const response = responses?.find((r) => r[2] === callId);
if (!response) {
for (const change of changes) results.push({ change, ok: false });
continue;
}
const [name, result] = response;
if (name === 'error') {
const error = describe(result);
for (const change of changes) results.push({ change, ok: false, error });
continue;
}
const updated = (result.updated ?? {}) as Record<string, unknown>;
const notUpdated = (result.notUpdated ?? {}) as Record<string, unknown>;
for (const change of changes) {
if (change.id in notUpdated) results.push({ change, ok: false, error: describe(notUpdated[change.id]) });
else if (change.id in updated) results.push({ change, ok: true });
else results.push({ change, ok: false });
}
}
return results;
}
/** Every change of `calls` failed with one error (the request didn't go through). */
export function failAll(calls: MethodCall[], changesByCall: Map<string, ShareChange[]>, error: unknown): ShareChangeResult[] {
const message = error instanceof Error ? error.message : describe(error);
return calls.flatMap(([, , callId]) => (changesByCall.get(callId) ?? []).map((change) => ({ change, ok: false, error: message })));
}
function asShareWith(value: unknown): Record<string, AnyRights> {
return value && typeof value === 'object' ? (value as Record<string, AnyRights>) : {};
}
/**
* The items of a Mailbox/get, Calendar/get and AddressBook/get response
* (call ids `m`, `c`, `a`), and the types whose call failed.
*/
export function parseShareTargets(
responses: MethodResponse[] | undefined,
accounts: { mailbox?: string; calendar?: string; addressBook?: string },
): { targets: ShareTarget[]; failed: ShareKind[] } {
const targets: ShareTarget[] = [];
const failed: ShareKind[] = [];
const read = (kind: ShareKind, callId: string, method: string, accountId: string | undefined) => {
if (!accountId) return;
const response = responses?.find((r) => r[2] === callId);
if (!response || response[0] !== method) {
failed.push(kind);
return;
}
const list = (response[1].list ?? []) as Array<Record<string, unknown>>;
for (const item of list) {
if (typeof item.id !== 'string') continue;
targets.push({
kind,
accountId,
id: item.id,
name: typeof item.name === 'string' ? item.name : item.id,
...(kind === 'mailbox' ? { role: (item.role as string | null | undefined) ?? null, parentId: (item.parentId as string | null | undefined) ?? null } : {}),
...(kind !== 'mailbox' ? { isDefault: item.isDefault === true } : {}),
shareWith: asShareWith(item.shareWith),
});
}
};
read('mailbox', 'm', 'Mailbox/get', accounts.mailbox);
read('calendar', 'c', 'Calendar/get', accounts.calendar);
read('addressBook', 'a', 'AddressBook/get', accounts.addressBook);
return { targets, failed };
}
+32
View File
@@ -112,3 +112,35 @@ export function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
}
return 'custom';
}
/** Read or read and write, as the Delegates page offers them. */
export type AccessLevel = 'read' | 'write';
/** The rights Delegates grants for one level. */
export function accessRights(kind: ShareKind, level: AccessLevel): AnyRights {
return presetRights(kind, level === 'write' ? 'readWrite' : 'read')!;
}
/**
* What a stored grant lets the grantee do, in the two words of the Delegates
* page: "write" when they may change anything inside, "read" when they may
* only read, null when they may not even read (free/busy only, or nothing).
*/
export function accessLevelOf(kind: ShareKind, rights: AnyRights | null | undefined): AccessLevel | null {
if (!rights) return null;
const r = rights as unknown as Record<string, boolean | undefined>;
switch (kind) {
case 'mailbox':
if (!r.mayReadItems) return null;
return r.mayAddItems || r.mayRemoveItems ? 'write' : 'read';
case 'calendar':
if (!r.mayReadItems) return null;
return r.mayWriteAll || r.mayWriteOwn ? 'write' : 'read';
case 'addressBook':
if (!r.mayRead) return null;
return r.mayWrite ? 'write' : 'read';
case 'file':
if (!r.mayRead) return null;
return r.mayModifyContent || r.mayAddChildren || r.mayRename || r.mayDelete || r.mayWrite ? 'write' : 'read';
}
}
+3
View File
@@ -14,6 +14,7 @@ export type SettingsSearchTab =
| 'filters'
| 'organize'
| 'security'
| 'sharing'
| 'calendar'
| 'contacts'
| 'files'
@@ -122,6 +123,7 @@ export const tabSearchPaths: Record<Tab, string[]> = {
filters: ['settings.filters'],
organize: ['settings.folders', 'settings.keywords'],
security: ['settings.security'],
sharing: ['delegates'],
calendar: ['calendar.settings', 'calendar.management'],
contacts: ['settings.contacts', 'contacts'],
files: ['settings.files'],
@@ -146,6 +148,7 @@ export const tabKeywords: Record<Tab, string> = {
filters: 'sieve rules block junk forward',
organize: 'folders mailbox subscribe roles tags labels keywords colors nested',
security: 'password 2fa two-factor passkey app password mfa api key token phone mobile app pair qr thunderbird outlook imap client encryption pgp smime',
sharing: 'delegate delegation share access assistant permissions revoke folders calendars contacts colleague',
calendar: 'event schedule appointment meeting timezone invitation ics tasks birthday working hours',
contacts: 'address book contact',
files: 'attachments cloud drive storage upload',
+77
View File
@@ -4151,6 +4151,83 @@
"custom": "Benutzerdefiniert"
}
},
"delegates": {
"tab": "Freigaben",
"title": "Stellvertretungen",
"description": "Geben Sie einer Person in einem Schritt Zugriff auf Ihre E-Mails, Kalender und Kontakte, und sehen Sie, wer worauf Zugriff hat.",
"give_access": "Zugriff geben",
"who_has_access": "Wer Zugriff hat",
"nobody": "Niemand hat Zugriff auf Ihre E-Mails, Kalender, Kontakte oder Dateien.",
"loading": "Wird geladen…",
"read_failed": "Ein Teil Ihrer Freigaben konnte nicht gelesen werden. Die Liste ist deshalb vielleicht unvollständig.",
"change": "Ändern",
"remove": "Zugriff entfernen",
"remove_title": "Zugriff für {name} entfernen?",
"remove_message": "{name} verliert den Zugriff auf alle Ordner, Kalender, Adressbücher und Dateien, die Sie freigegeben haben.",
"removed": "Zugriff entfernt",
"remove_partial": "Hier besteht noch Zugriff: {items}",
"applied": "{name} hat jetzt Zugriff",
"updated": "Zugriff geändert",
"unknown_person": "Unbekannte Person",
"group": "Gruppe",
"has_access": "Hat Zugriff",
"summary": {
"mail_all": "Alle E-Mail-Ordner",
"mail_inbox": "Posteingang",
"mail_some": "{count, plural, one {# E-Mail-Ordner} other {# E-Mail-Ordner}}",
"calendars": "{count, plural, one {# Kalender} other {# Kalender}}",
"address_books": "{count, plural, one {# Adressbuch} other {# Adressbücher}}",
"files": "{count, plural, one {# Datei oder Ordner} other {# Dateien oder Ordner}}"
},
"level": {
"read": "Lesen",
"write": "Lesen und schreiben",
"mixed": "Unterschiedliche Rechte",
"none": "Nur frei/belegt"
},
"dialog_title_add": "Zugriff geben",
"dialog_title_change": "Zugriff für {name} ändern",
"person": "Person",
"search_placeholder": "Name oder E-Mail-Adresse",
"change_person": "Ändern",
"no_match": "Niemand gefunden.",
"use_address": "{address} verwenden",
"looking_up": "Wird gesucht…",
"not_found": "Auf diesem Server hat niemand die Adresse {address}.",
"lookup_failed": "Die Adresse konnte nicht gesucht werden.",
"known_only": "Aufgeführt sind nur Ihre Gruppen und Personen, die etwas mit Ihnen geteilt haben.",
"directory_off_note": "Ihre Administration muss in Stalwart die Verzeichnissuche einschalten, bevor Sie eine neue Person hinzufügen können.",
"directory_failed": "Die Liste der Personen und Gruppen konnte nicht geladen werden.",
"what": "Was",
"mail": "E-Mail",
"mail_all": "Alle Ordner",
"mail_inbox": "Nur Posteingang",
"calendars": "Kalender",
"calendars_default": "Standardkalender",
"calendars_all": "Alle Kalender",
"address_books": "Kontakte",
"address_books_default": "Standard-Adressbuch",
"address_books_all": "Alle Adressbücher",
"none": "Nichts",
"empty_scope": "Wählen Sie E-Mail, Kalender oder Kontakte.",
"change_note": "Ordner, Kalender und Adressbücher, die Sie weglassen, verlieren den Zugriff. Freigegebene Dateien bleiben, wie sie sind; „Zugriff entfernen“ nimmt auch diese weg.",
"rights": "Rechte",
"rights_read": "Lesen",
"rights_read_hint": "Ihre E-Mails, Termine und Kontakte sehen.",
"rights_write": "Lesen und schreiben",
"rights_write_hint": "Außerdem E-Mails ablegen, markieren und löschen sowie Termine und Kontakte ändern.",
"send_note": "Stellvertretungen können Ihre E-Mails und Ihren Kalender lesen und ordnen. Stalwart erlaubt ihnen nicht, als Sie oder in Ihrem Auftrag zu senden. Wenn sich mehrere Personen eine Adresse teilen sollen, bitten Sie Ihre Administration um ein gemeinsames Postfach.",
"apply_add": "Zugriff geben",
"apply_change": "Speichern",
"partial_title": "Diese Einträge konnten nicht geändert werden. Der Rest ist erledigt.",
"retry": "Erneut versuchen",
"kind": {
"mailbox": "E-Mail-Ordner",
"calendar": "Kalender",
"addressBook": "Adressbuch",
"file": "Datei"
}
},
"quote_header": {
"reply_line": "Am {date} schrieb {from}:",
"forwarded_separator": "---------- Weitergeleitete Nachricht ----------",
+77
View File
@@ -3801,6 +3801,83 @@
"custom": "Custom"
}
},
"delegates": {
"tab": "Sharing",
"title": "Delegates",
"description": "Give someone access to your mail, calendars and contacts in one step, and see who has access to what.",
"give_access": "Give access",
"who_has_access": "Who has access",
"nobody": "Nobody has access to your mail, calendars, contacts or files.",
"loading": "Loading…",
"read_failed": "Some of your sharing couldn't be read, so this list may be incomplete.",
"change": "Change",
"remove": "Remove access",
"remove_title": "Remove access for {name}?",
"remove_message": "{name} loses access to every folder, calendar, address book and file you shared with them.",
"removed": "Access removed",
"remove_partial": "These still have access: {items}",
"applied": "{name} now has access",
"updated": "Access updated",
"unknown_person": "Unknown person",
"group": "Group",
"has_access": "Has access",
"summary": {
"mail_all": "All mail folders",
"mail_inbox": "Inbox",
"mail_some": "{count, plural, one {# mail folder} other {# mail folders}}",
"calendars": "{count, plural, one {# calendar} other {# calendars}}",
"address_books": "{count, plural, one {# address book} other {# address books}}",
"files": "{count, plural, one {# file or folder} other {# files or folders}}"
},
"level": {
"read": "Read",
"write": "Read and write",
"mixed": "Different rights",
"none": "Free/busy only"
},
"dialog_title_add": "Give access",
"dialog_title_change": "Change access for {name}",
"person": "Person",
"search_placeholder": "Name or email address",
"change_person": "Change",
"no_match": "Nobody found.",
"use_address": "Use {address}",
"looking_up": "Looking up…",
"not_found": "Nobody on this server has the address {address}.",
"lookup_failed": "Couldn't look up this address.",
"known_only": "Only your groups and people who have shared something with you are listed.",
"directory_off_note": "Your administrator has to turn on directory lookups in Stalwart before you can add someone new.",
"directory_failed": "Couldn't load the list of people and groups.",
"what": "What",
"mail": "Mail",
"mail_all": "All folders",
"mail_inbox": "Inbox only",
"calendars": "Calendars",
"calendars_default": "Default calendar",
"calendars_all": "All calendars",
"address_books": "Contacts",
"address_books_default": "Default address book",
"address_books_all": "All address books",
"none": "None",
"empty_scope": "Choose mail, calendars or contacts.",
"change_note": "Folders, calendars and address books you leave out lose access. Shared files stay as they are; Remove access takes those away too.",
"rights": "Rights",
"rights_read": "Read",
"rights_read_hint": "See your mail, events and contacts.",
"rights_write": "Read and write",
"rights_write_hint": "Also file, flag and delete mail, and change events and contacts.",
"send_note": "Delegates can read and organise your mail and calendar. Stalwart doesn't let them send as you or on your behalf. To share one address between several people, ask your administrator for a shared mailbox.",
"apply_add": "Give access",
"apply_change": "Save",
"partial_title": "These couldn't be changed. The rest is done.",
"retry": "Try again",
"kind": {
"mailbox": "Mail folder",
"calendar": "Calendar",
"addressBook": "Address book",
"file": "File"
}
},
"advanced_search": {
"title": "Advanced Search",
"from": "From",