fix: start a delegate's Change from what they have and keep what fits no choice as it is

Change used to pre-fill the closest choice and then replace everything
of that type, so a folder added after "all", a single non-Inbox folder,
a non-default calendar, free/busy only or manager rights were narrowed
or widened on Save. Now a type gets a choice only when the grants match
it exactly; anything else starts at "Keep as it is", which leaves that
type out of the patch. Mixed levels keep each type's rights.

Also in the Delegates page:
- Change and Remove wait, with Try again, while a type couldn't be read
- an answer from the account before an account switch is dropped
- calendars, contacts and files follow their feature gates
- Remove says it covers what was shared from the user's own account
- typing your own address says "That's you"
- groups named only by the session get the group badge
- on Stalwart 1.0 the read hint says read delegates can flag mail
- the person list is plain buttons, not a listbox without arrow keys
- the rights follow sharingRights
This commit is contained in:
Linus Rath
2026-10-28 06:28:02 +01:00
parent f0539a33f6
commit 49c9ec6a45
9 changed files with 481 additions and 145 deletions
@@ -27,14 +27,24 @@ function targets(withCarol = false): ShareTarget[] {
const ok = (changes: ShareChange[]): ShareChangeResult[] => changes.map((change) => ({ change, ok: true }));
function makeClient(options: { withCarol?: boolean; directory?: PrincipalListing; apply?: (c: ShareChange[]) => ShareChangeResult[]; sharedAccounts?: Array<{ id: string; name: string }> } = {}) {
function makeClient(options: {
withCarol?: boolean;
directory?: PrincipalListing;
apply?: (c: ShareChange[]) => ShareChangeResult[];
sharedAccounts?: Array<{ id: string; name: string }>;
targets?: ShareTarget[];
failed?: string[];
seenSetsKeywords?: boolean;
} = {}) {
return {
getAccountId: () => 'ci',
getUsername: () => 'org-alice@example.org',
getSharedAccounts: () => options.sharedAccounts ?? [],
supportsPrincipals: () => true,
sharesSeenWithKeywords: () => options.seenSetsKeywords ?? false,
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: [] })),
getShareTargets: vi.fn(async (_include?: object) => ({ targets: options.targets ?? targets(options.withCarol), failed: (options.failed ?? []) as never[] })),
applyShareChanges: vi.fn(async (changes: ShareChange[]) => (options.apply ?? ok)(changes)),
findPrincipalByEmail: vi.fn(async () => ({ principal: null, status: 'forbidden' as const, directoryQueriesOff: true })),
};
@@ -101,6 +111,65 @@ describe('DelegatesSettings', () => {
expect(within(row).queryByText('unknown_person')).toBeNull();
});
it('marks a group named only by the session as a group', async () => {
const list = targets();
list[0].shareWith.ch = MAILBOX_PRESETS.read;
renderWith(makeClient({
targets: list,
directory: { principals: [], status: 'error' },
sharedAccounts: [{ id: 'ch', name: 'org-team@example.org' }],
}));
// The auth store sets the policy subject once the session is in; it knows the user's groups.
act(() => usePolicyStore.setState({ subject: { email: ALICE.email!, groups: ['org-team@example.org'] } }));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-team@example.org')).toBeInTheDocument();
expect(await within(row).findByText('group')).toBeInTheDocument();
});
it('asks only for the types the admin left on', async () => {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, features: { ...state.policy.features, calendarEnabled: false, filesEnabled: false } } }));
const client = makeClient();
renderWith(client);
await screen.findByText('nobody');
expect(client.getShareTargets).toHaveBeenLastCalledWith({ calendars: false, contacts: true, files: false });
});
it('holds Change and Remove while a type is unread, and reads again on Try again', async () => {
const client = makeClient({ withCarol: true, failed: ['calendar'] });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByRole('button', { name: 'change' })).toBeDisabled();
expect(within(row).getByRole('button', { name: 'remove' })).toBeDisabled();
const notice = screen.getByTestId('delegates-read-failed');
expect(notice).toHaveTextContent('read_failed');
client.getShareTargets.mockResolvedValue({ targets: targets(true), failed: [] });
fireEvent.click(within(notice).getByRole('button', { name: 'retry' }));
await waitFor(() => expect(within(screen.getByTestId('delegate-row')).getByRole('button', { name: 'remove' })).toBeEnabled());
expect(client.getShareTargets).toHaveBeenCalledTimes(2);
});
it('shows the answer of the current account, not a slow one of the account before', async () => {
let releaseFirst: (v: { targets: ShareTarget[]; failed: never[] }) => void = () => {};
const first = makeClient({ withCarol: true });
first.getShareTargets.mockImplementation(() => new Promise((resolve) => { releaseFirst = resolve; }));
renderWith(first);
await act(async () => {});
const second = makeClient();
act(() => useAuthStore.setState({ client: second as never, activeAccountId: 'acc-other' }));
expect(await screen.findByText('nobody')).toBeInTheDocument();
await act(async () => { releaseFirst({ targets: targets(true), failed: [] }); });
expect(screen.queryByTestId('delegate-row')).toBeNull();
expect(screen.getByText('nobody')).toBeInTheDocument();
});
it('remembers the names of people with access, never the rest of the directory', async () => {
renderWith(makeClient({ withCarol: true }));
await screen.findByTestId('delegate-row');
await waitFor(() => expect(localStorage.getItem('bulwark-delegate-names')).toContain('ck'));
const stored = JSON.parse(localStorage.getItem('bulwark-delegate-names')!);
expect(Object.keys(stored['acc-alice'])).toEqual(['ck']);
});
it('removes a person from every item, files included', async () => {
const client = makeClient({ withCarol: true });
renderWith(client);
@@ -120,6 +189,10 @@ describe('DelegatesSettings', () => {
});
});
function candidate(dialog: HTMLElement, text: string): HTMLElement {
return within(within(dialog).getByTestId('delegate-candidates')).getAllByRole('button').find((b) => b.textContent?.includes(text))!;
}
describe('DelegateDialog', () => {
async function openDialog(client: ReturnType<typeof makeClient>) {
renderWith(client);
@@ -132,11 +205,50 @@ describe('DelegateDialog', () => {
await waitFor(() => expect(within(dialog).getByRole('textbox')).toHaveFocus());
});
it("says \"That's you\" for the user's own address", async () => {
const client = makeClient({ directory: { principals: [ALICE], status: 'ok' } });
const dialog = await openDialog(client);
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'ORG-alice@example.org' } });
fireEvent.click(within(dialog).getByText('use_address'));
expect(await within(dialog).findByText('is_you')).toBeInTheDocument();
expect(client.findPrincipalByEmail).not.toHaveBeenCalled();
});
it('remembers the person just picked', async () => {
const client = makeClient({ directory: { principals: [ALICE, CAROL, TEAM], status: 'ok' } });
const dialog = await openDialog(client);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
const stored = JSON.parse(localStorage.getItem('bulwark-delegate-names')!);
expect(Object.keys(stored['acc-alice'])).toEqual(['ck']);
});
it('says read delegates can flag mail where the server ties the two rights', async () => {
const dialog = await openDialog(makeClient({ seenSetsKeywords: true }));
expect(within(dialog).getByText('rights_read_hint_flags')).toBeInTheDocument();
expect(within(dialog).queryByText('rights_read_hint')).toBeNull();
});
it('starts Change of manager rights at "Keep as it is" and has nothing to save', async () => {
const list = targets();
for (const t of list) if (t.kind === 'mailbox') t.shareWith.ck = MAILBOX_PRESETS.manager;
const client = makeClient({ targets: list });
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(['keep', 'none', 'none', 'keep']);
expect(within(dialog).getByRole('button', { name: 'apply_change' })).toBeDisabled();
expect(within(dialog).getByText('nothing_to_change')).toBeInTheDocument();
});
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);
const options = within(within(dialog).getByTestId('delegate-candidates')).getAllByRole('button').map((o) => o.textContent);
expect(options.some((o) => o?.includes('org-alice'))).toBe(false);
expect(options.some((o) => o?.includes('org-carol'))).toBe(true);
});
@@ -166,7 +278,7 @@ describe('DelegateDialog', () => {
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(candidate(dialog, '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];
@@ -186,7 +298,7 @@ describe('DelegateDialog', () => {
},
});
const dialog = await openDialog(client);
fireEvent.click(within(dialog).getAllByRole('option').find((o) => o.textContent?.includes('org-carol'))!);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
const report = await within(dialog).findByRole('alert');
expect(report).toHaveTextContent('partial_title');
@@ -201,7 +313,7 @@ describe('DelegateDialog', () => {
});
it('offers only read when the admin allows only read', async () => {
restrict({ delegationRights: { allowedValues: ['read'] } });
restrict({ sharingRights: { 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']);
@@ -19,6 +19,7 @@ import {
scopeIsEmpty,
type CollectionScope,
type DelegateScope,
type LevelChoice,
type MailScope,
} from "@/lib/delegation/delegation";
import { principalDisplay } from "./principal-display";
@@ -31,6 +32,7 @@ type Lookup =
| { state: "busy" }
| { state: "notFound"; address: string }
| { state: "directoryOff" }
| { state: "self" }
| { state: "failed" };
export interface DelegateDialogProps {
@@ -39,13 +41,20 @@ export interface DelegateDialogProps {
directory: PrincipalListing | null;
/** The user's own principal ids, never offered. */
ownIds: string[];
/** The user's own addresses: typing one says "That's you". */
ownEmails: 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 };
initial?: { principal: Principal; scope: DelegateScope; level: LevelChoice };
levels: AccessLevel[];
/** The admin limited the rights (delegationRights). */
/** The admin limited the rights (sharingRights). */
levelsRestricted: boolean;
/**
* The server keeps "mark as read" and flags as one right on folders
* (Stalwart 1.0), so read delegates can flag too; the read hint says so.
*/
seenSetsKeywords?: boolean;
onApplied: (principal: Principal, results: ShareChangeResult[]) => void;
onClose: () => void;
}
@@ -56,7 +65,7 @@ export interface DelegateDialogProps {
* Full screen on a phone.
*/
export function DelegateDialog({
client, targets, directory, ownIds, existingIds, initial, levels, levelsRestricted, onApplied, onClose,
client, targets, directory, ownIds, ownEmails, existingIds, initial, levels, levelsRestricted, seenSetsKeywords, onApplied, onClose,
}: DelegateDialogProps) {
const t = useTranslations("delegates");
const tCommon = useTranslations("common");
@@ -67,10 +76,17 @@ export function DelegateDialog({
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 [level, setLevel] = useState<LevelChoice>(() => {
const wanted = initial?.level ?? "read";
return levels.includes(wanted) ? wanted : levels[0];
return wanted === "keep" || levels.includes(wanted) ? wanted : levels[0];
});
// "Keep as it is" is offered where Change started with it, and stays offered.
const keep = {
mail: initial?.scope.mail === "keep",
calendars: initial?.scope.calendars === "keep",
addressBooks: initial?.scope.addressBooks === "keep",
level: initial?.level === "keep",
};
const [saving, setSaving] = useState(false);
const [failures, setFailures] = useState<ShareChangeResult[] | null>(null);
@@ -99,13 +115,19 @@ export function DelegateDialog({
const lookUp = async () => {
if (!canLookUp || lookup.state === "busy") return;
if (ownEmails.some((email) => email.toLowerCase() === typedAddress.toLowerCase())) {
setLookup({ state: "self" });
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)) {
if (found.principal && ownIds.includes(found.principal.id)) {
setLookup({ state: "self" });
} else if (found.principal) {
setPrincipal(found.principal);
setLookup({ state: "idle" });
} else if (found.status === "forbidden") {
@@ -131,9 +153,15 @@ export function DelegateDialog({
}
};
const changes = useMemo(
() => (principal ? buildGrantChanges(targets, principal.id, scope, level, { replace: changing }) : []),
[targets, principal, scope, level, changing],
);
const nothingToDo = changing ? changes.length === 0 : scopeIsEmpty(scope);
const submit = () => {
if (!principal || scopeIsEmpty(scope)) return;
void apply(buildGrantChanges(targets, principal.id, scope, level, { replace: changing }));
if (!principal || nothingToDo) return;
void apply(changes);
};
const retry = () => {
@@ -212,39 +240,41 @@ export function DelegateDialog({
{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")}>
<ul className="max-h-48 overflow-y-auto rounded-md border border-border" aria-label={t("person")} data-testid="delegate-candidates">
{candidates.length === 0 && !canLookUp && (
<p className="px-3 py-2 text-xs text-muted-foreground">{t("no_match")}</p>
<li className="px-3 py-2 text-xs text-muted-foreground">{t("no_match")}</li>
)}
{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>
<li key={p.id}>
<button
type="button"
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>
</li>
))}
{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>
<li>
<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>
</li>
)}
</div>
</ul>
)}
{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>}
{lookup.state === "self" && <p className="text-xs text-destructive" role="status">{t("is_you")}</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")}
@@ -262,7 +292,7 @@ export function DelegateDialog({
label={t("mail")}
value={scope.mail}
onChange={(mail) => setScope({ ...scope, mail })}
options={[["all", t("mail_all")], ["inbox", t("mail_inbox")], ["none", t("none")]]}
options={[...keepOption<MailScope>(keep.mail, t("keep")), ["all", t("mail_all")], ["inbox", t("mail_inbox")], ["none", t("none")]]}
/>
)}
{has("calendar") && (
@@ -270,7 +300,7 @@ export function DelegateDialog({
label={t("calendars")}
value={scope.calendars}
onChange={(calendars) => setScope({ ...scope, calendars })}
options={[["default", t("calendars_default")], ["all", t("calendars_all")], ["none", t("none")]]}
options={[...keepOption<CollectionScope>(keep.calendars, t("keep")), ["default", t("calendars_default")], ["all", t("calendars_all")], ["none", t("none")]]}
/>
)}
{has("addressBook") && (
@@ -278,16 +308,16 @@ export function DelegateDialog({
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")]]}
options={[...keepOption<CollectionScope>(keep.addressBooks, t("keep")), ["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 && 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) => (
{[...(keep.level ? ["keep" as const] : []), ...levels].map((option) => (
<label key={option} className="flex cursor-pointer items-start gap-2 text-sm">
<input
type="radio"
@@ -299,13 +329,17 @@ export function DelegateDialog({
/>
<span>
<span className="block">{t(`rights_${option}`)}</span>
<span className="block text-xs text-muted-foreground">{t(`rights_${option}_hint`)}</span>
<span className="block text-xs text-muted-foreground">
{t(option === "read" && seenSetsKeywords ? "rights_read_hint_flags" : `rights_${option}_hint`)}
</span>
</span>
</label>
))}
{levelsRestricted && <OrgLockedNote />}
</fieldset>
{changing && nothingToDo && <p className="text-xs text-muted-foreground" role="status">{t("nothing_to_change")}</p>}
<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>
@@ -325,7 +359,7 @@ export function DelegateDialog({
) : (
<>
<Button variant="outline" onClick={onClose} disabled={saving}>{tCommon("cancel")}</Button>
<Button onClick={submit} disabled={saving || !principal || scopeIsEmpty(scope)}>
<Button onClick={submit} disabled={saving || !principal || nothingToDo}>
{saving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{changing ? t("apply_change") : t("apply_add")}
</Button>
@@ -363,6 +397,10 @@ function PrincipalText({ principal }: { principal: Principal }) {
);
}
function keepOption<T extends string>(offered: boolean, label: string): [T, string][] {
return offered ? [["keep" as T, label]] : [];
}
function Choice<T extends string>({ label, value, onChange, options }: {
label: string;
value: T;
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
@@ -8,6 +8,7 @@ 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 { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { useRestriction } from "@/hooks/use-restriction";
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
@@ -17,8 +18,8 @@ import type { ShareKind } from "@/lib/jmap/share-rights";
import {
allowedLevels,
buildRevokeChanges,
changeStartFor,
failedChanges,
scopeOfSummary,
summarizeDelegates,
type DelegateSummary,
} from "@/lib/delegation/delegation";
@@ -31,16 +32,20 @@ type DialogState = { mode: "add" } | { mode: "change"; summary: DelegateSummary
/**
* 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.
* Hidden when the admin hides `delegation`; `sharingRights` limits the
* rights offered. Calendars, contacts and files follow their feature gates.
*/
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 rights = useRestriction("sharingRights");
const levels = useMemo(() => allowedLevels(rights.allowedValues), [rights.allowedValues]);
const calendars = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
const contacts = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
const files = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled"));
const groupAddresses = usePolicyStore((s) => s.subject?.groups);
const [targets, setTargets] = useState<ShareTarget[] | null>(null);
const [failedKinds, setFailedKinds] = useState<ShareKind[]>([]);
@@ -50,44 +55,76 @@ export function DelegatesSettings() {
const [confirmRemove, setConfirmRemove] = useState<DelegateSummary | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
// Each read is numbered; an answer that arrives after a newer read started
// (another account, a changed gate) is dropped instead of shown.
const readSeq = useRef(0);
const reload = useCallback(async () => {
if (!client?.getShareTargets) return;
const result = await client.getShareTargets();
const seq = ++readSeq.current;
const result = await client.getShareTargets({ calendars, contacts, files });
if (seq !== readSeq.current) return;
setTargets(result.targets);
setFailedKinds(result.failed);
}, [client]);
}, [client, calendars, contacts, files]);
useEffect(() => {
if (!client || delegation.hidden) return;
let cancelled = false;
const reads = readSeq;
setTargets(null);
setFailedKinds([]);
setDirectory(null);
setRemembered(rememberedPrincipals(accountKey));
void reload();
void loadPrincipalDirectory(client).then((listing) => {
if (cancelled) return;
setDirectory(listing);
rememberPrincipals(accountKey, listing.principals);
if (!cancelled) setDirectory(listing);
});
return () => { cancelled = true; };
return () => {
cancelled = true;
reads.current++;
};
}, [client, accountKey, delegation.hidden, reload]);
const ownIds = useMemo(() => (client ? [client.getAccountId()] : []), [client]);
const ownEmails = useMemo(() => {
const emails = new Set<string>();
const username = client?.getUsername();
if (username?.includes("@")) emails.add(username);
for (const p of directory?.principals ?? []) if (ownIds.includes(p.id) && p.email) emails.add(p.email);
return [...emails];
}, [client, directory, ownIds]);
const summaries = useMemo(() => (targets ? summarizeDelegates(targets, ownIds) : []), [targets, ownIds]);
// Stalwart names a principal by its account id, and an account shared with
// the user is in the session under the owner's address: a last way to name
// someone the directory won't (Stalwart 1.0 with directory queries off).
// The policy subject knows which of those accounts are groups.
const sessionAccounts = useMemo(() => new Map((client?.getSharedAccounts() ?? []).map((a) => [a.id, a.name])), [client]);
const principalFor = useCallback((id: string): Principal | null => {
const known = directory?.principals.find((p) => p.id === id) ?? remembered.get(id);
if (known) return known;
const accountName = sessionAccounts.get(id);
return accountName ? { id, name: accountName, email: accountName.includes('@') ? accountName : null, type: 'individual' } : null;
}, [directory, remembered, sessionAccounts]);
if (!accountName) return null;
const isGroup = (groupAddresses ?? []).some((g) => g.toLowerCase() === accountName.toLowerCase());
return { id, name: accountName, email: accountName.includes("@") ? accountName : null, type: isGroup ? "group" : "individual" };
}, [directory, remembered, sessionAccounts, groupAddresses]);
// Remember the names of the people who have access, so the list can name
// them when the directory no longer does. Never the whole directory.
useEffect(() => {
if (!accountKey || summaries.length === 0 || !directory) return;
const named = summaries
.map((s) => directory.principals.find((p) => p.id === s.principalId))
.filter((p): p is Principal => !!p);
if (named.length > 0) rememberPrincipals(accountKey, named);
}, [accountKey, summaries, directory]);
if (delegation.hidden || !client) return null;
// Change and Remove act on everything the person has; with a type unread,
// that isn't known, so they wait until a read succeeds.
const incomplete = failedKinds.length > 0;
const onApplied: DelegateDialogProps["onApplied"] = (principal, results) => {
rememberPrincipals(accountKey, [principal]);
setRemembered(rememberedPrincipals(accountKey));
@@ -99,7 +136,7 @@ export function DelegatesSettings() {
};
const remove = async (summary: DelegateSummary) => {
if (!targets || !client.applyShareChanges) return;
if (!targets || !client.applyShareChanges || incomplete) return;
setRemovingId(summary.principalId);
try {
const results: ShareChangeResult[] = await client.applyShareChanges(buildRevokeChanges(targets, summary.principalId));
@@ -128,7 +165,12 @@ export function DelegatesSettings() {
<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>}
{incomplete && (
<div className="flex flex-wrap items-center gap-2 text-xs text-destructive" role="status" data-testid="delegates-read-failed">
<span>{t("read_failed")}</span>
<Button variant="outline" size="sm" onClick={() => void reload()}>{t("retry")}</Button>
</div>
)}
{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 ? (
@@ -138,32 +180,28 @@ export function DelegatesSettings() {
{summaries.map((summary) => {
const principal = principalFor(summary.principalId);
const display = principal ? principalDisplay(principal) : { name: t("unknown_person"), detail: summary.principalId };
const busy = removingId === 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>
<div className="flex items-center gap-2 truncate text-sm font-medium">
<span className="truncate">{display.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>
{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}
>
<Button variant="outline" size="sm" onClick={() => setDialog({ mode: "change", summary })} disabled={busy || incomplete}>
{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" />}
<Button variant="outline" size="sm" onClick={() => setConfirmRemove(summary)} disabled={busy || incomplete}>
{busy && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t("remove")}
</Button>
</div>
@@ -180,16 +218,18 @@ export function DelegatesSettings() {
targets={targets}
directory={directory}
ownIds={ownIds}
ownEmails={ownEmails}
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),
...changeStartFor(targets, dialog.summary.principalId, levels),
}
: undefined}
levels={levels}
levelsRestricted={rights.allowedValues !== null && levels.length < 2}
seenSetsKeywords={client.sharesSeenWithKeywords?.() ?? false}
onApplied={onApplied}
onClose={() => setDialog(null)}
/>
+3 -1
View File
@@ -637,7 +637,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
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));
&& ((client?.supportsMailboxSharing?.() ?? false)
|| (supportsCalendar && isFeatureEnabled('calendarEnabled'))
|| ((client?.supportsContacts() ?? false) && isFeatureEnabled('contactsEnabled')));
const tabs: TabDef[] = [
// You and the app
+7 -2
View File
@@ -146,7 +146,7 @@ test('alice gives carol read access to her mail, calendar and contacts in one st
const d = dialog(page);
await expect(d.getByText("Stalwart doesn't let them send as you or on your behalf.", { exact: false })).toBeVisible();
await d.getByRole('textbox', { name: 'Person' }).fill('org-carol');
await d.getByRole('option', { name: /org-carol@example\.org/ }).click();
await d.getByTestId('delegate-candidates').getByRole('button', { name: /org-carol@example\.org/ }).click();
await expect(d.getByRole('radio', { name: /^Read See/ })).toBeChecked();
await shot(page, 'delegates-dialog');
await d.getByRole('button', { name: 'Give access' }).click();
@@ -188,6 +188,11 @@ test('Change gives carol read and write access, and only to the Inbox', async ({
await page.getByTestId('delegate-row').getByRole('button', { name: 'Change' }).click();
const d = dialog(page);
await expect(d.getByRole('heading', { name: `Change access for ${ORG.carol}` })).toBeVisible();
// It starts from what carol has: exactly every folder, the default calendar and address book, read.
await expect(d.getByRole('radiogroup', { name: 'Mail' }).getByRole('radio', { name: 'All folders' })).toBeChecked();
await expect(d.getByRole('radiogroup', { name: 'Calendars' }).getByRole('radio', { name: 'Default calendar' })).toBeChecked();
await expect(d.getByRole('radiogroup', { name: 'Contacts' }).getByRole('radio', { name: 'Default address book' })).toBeChecked();
await expect(d.getByRole('radio', { name: /^Read See/ })).toBeChecked();
await d.getByRole('radiogroup', { name: 'Mail' }).getByText('Inbox only').click();
await d.getByRole('radio', { name: /^Read and write/ }).check();
await d.getByRole('button', { name: 'Save' }).click();
@@ -244,7 +249,7 @@ test('the admin limits delegates to read for one domain and hides them for a gro
const saved = await getPolicy(admin);
const scopes = saved.scopes as Array<{ id: string; policy: { restrictions?: Record<string, unknown> } }>;
expect(scopes.find((s) => s.id === 'delegates-example')!.policy.restrictions).toEqual({ delegationRights: { allowedValues: ['read'] } });
expect(scopes.find((s) => s.id === 'delegates-example')!.policy.restrictions).toEqual({ sharingRights: { allowedValues: ['read'] } });
expect(scopes.find((s) => s.id === 'delegates-team')!.policy.restrictions).toEqual({ delegation: { hidden: true } });
await page.close();
await admin.close();
+78 -16
View File
@@ -3,7 +3,7 @@ import {
allowedLevels,
buildGrantChanges,
buildRevokeChanges,
scopeOfSummary,
changeStartFor,
selectTargets,
summarizeDelegates,
} from '../delegation';
@@ -135,23 +135,85 @@ describe('summarizeDelegates', () => {
});
});
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' });
describe('changeStartFor', () => {
const withCarol = (list: ShareTarget[], ids: string[], rights: object) => {
for (const t of list) if (ids.includes(t.id)) t.shareWith[CAROL] = rights as never;
return list;
};
it('starts from exact matches: every folder, the default calendar, read', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_READ_STORED);
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'read' });
});
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' });
it('starts from Inbox only and all calendars, read and write', () => {
const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite);
withCarol(list, ['c1', 'c2'], CALENDAR_PRESETS.readWrite);
expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'inbox', calendars: 'all', addressBooks: 'none' }, level: 'write' });
});
it('keeps mail as it is when a folder was added after "all", and the patch leaves mail alone', () => {
// Inbox and Trash shared, Projects created later.
const list = withCarol(targets(), ['a', 'b'], MAILBOX_READ_STORED);
const start = changeStartFor(list, CAROL);
expect(start.scope.mail).toBe('keep');
const changes = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true });
expect(changes.filter((c) => c.kind === 'mailbox')).toEqual([]);
});
it('keeps one shared non-Inbox folder as it is instead of turning it into the Inbox', () => {
const list = withCarol(targets(), ['b'], MAILBOX_READ_STORED);
expect(changeStartFor(list, CAROL).scope.mail).toBe('keep');
});
it('keeps a non-default calendar as it is', () => {
const list = withCarol(targets(), ['c1'], CALENDAR_PRESETS.read);
const start = changeStartFor(list, CAROL);
expect(start.scope.calendars).toBe('keep');
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
});
it('keeps free/busy only as it is, until the user picks a scope', () => {
const list = withCarol(targets(), ['c2'], CALENDAR_PRESETS.freeBusy);
const start = changeStartFor(list, CAROL);
expect(start.scope.calendars).toBe('keep');
expect(start.level).toBe('keep');
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
// Picking "Default calendar" with read replaces it.
const picked = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true });
expect(picked.map((c) => [c.id, c.rights])).toEqual([['c2', CALENDAR_PRESETS.read]]);
});
it('keeps manager rights untouched', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.manager);
const start = changeStartFor(list, CAROL);
expect(start).toEqual({ scope: { mail: 'keep', calendars: 'none', addressBooks: 'none' }, level: 'keep' });
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
});
it('keeps mixed levels per type: grants stay, anything added gets that type\'s level', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.readWrite);
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
const start = changeStartFor(list, CAROL);
expect(start).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'keep' });
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
// Widening calendars to all adds Work at the calendars' level (read), not mail's (write).
const wider = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'all' }, 'keep', { replace: true });
expect(wider.map((c) => [c.id, c.rights])).toEqual([['c1', CALENDAR_PRESETS.read]]);
});
it('counts a shared trusted senders book as something the dialog cannot express', () => {
const list = withCarol(targets(), ['ab', 'ts'], ADDRESS_BOOK_PRESETS.read);
expect(changeStartFor(list, CAROL).scope.addressBooks).toBe('keep');
});
it('clamps a write delegate to read when the admin allows only read', () => {
const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite);
expect(changeStartFor(list, CAROL, ['read'])).toEqual({ scope: { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, level: 'read' });
// Mixed with write starts at read too, so nothing keeps write by default.
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
expect(changeStartFor(list, CAROL, ['read']).level).toBe('read');
});
});
+112 -47
View File
@@ -10,10 +10,12 @@
*/
import type { ShareChange, ShareChangeResult, ShareTarget } from '@/lib/jmap/share-batch';
import { accessLevelOf, accessRights, type AccessLevel, type ShareKind } from '@/lib/jmap/share-rights';
import { accessLevelOf, accessRights, detectPreset, type AccessLevel, type ShareKind } from '@/lib/jmap/share-rights';
export type MailScope = 'all' | 'inbox' | 'none';
export type CollectionScope = 'default' | 'all' | 'none';
/** `keep`: "Keep as it is", offered by Change when what the person has fits no other choice. */
export type MailScope = 'all' | 'inbox' | 'none' | 'keep';
export type CollectionScope = 'default' | 'all' | 'none' | 'keep';
export type LevelChoice = AccessLevel | 'keep';
/** What a delegate gets: which folders, calendars and address books. */
export interface DelegateScope {
@@ -22,6 +24,10 @@ export interface DelegateScope {
addressBooks: CollectionScope;
}
type DelegateKind = 'mailbox' | 'calendar' | 'addressBook';
const SCOPE_KEY: Record<DelegateKind, keyof DelegateScope> = { mailbox: 'mail', calendar: 'calendars', addressBook: 'addressBooks' };
const DELEGATE_KINDS: DelegateKind[] = ['mailbox', 'calendar', 'addressBook'];
export const DEFAULT_DELEGATE_SCOPE: DelegateScope = { mail: 'all', calendars: 'default', addressBooks: 'default' };
export const ACCESS_LEVELS: readonly AccessLevel[] = ['read', 'write'];
@@ -44,42 +50,75 @@ 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. */
/** The items of one type the dialog's choices are about. */
function itemsOfType(targets: ShareTarget[], kind: DelegateKind): ShareTarget[] {
return kind === 'addressBook' ? delegableAddressBooks(targets) : ofKind(targets, kind);
}
const isInbox = (t: ShareTarget) => t.role?.toLowerCase() === 'inbox';
/** The items one choice covers; null for "Keep as it is". */
function itemsFor(targets: ShareTarget[], kind: DelegateKind, choice: MailScope | CollectionScope): ShareTarget[] | null {
if (choice === 'keep') return null;
const items = itemsOfType(targets, kind);
if (choice === 'none') return [];
if (choice === 'all') return items;
if (choice === 'inbox') return items.filter(isInbox);
return defaultOf(items);
}
/** The items a scope covers. Files are never part of it, nor types left as they are. */
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)];
return DELEGATE_KINDS.flatMap((kind) => itemsFor(targets, kind, scope[SCOPE_KEY[kind]]) ?? []);
}
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 };
}
/** read or write when every grant of one type has exactly that preset, otherwise null. */
function uniformLevel(items: ShareTarget[], kind: ShareKind, principalId: string): AccessLevel | null {
const presets = new Set(items.filter((t) => principalId in t.shareWith).map((t) => detectPreset(kind, t.shareWith[principalId])));
if (presets.size !== 1) return null;
const [preset] = [...presets];
return preset === 'read' ? 'read' : preset === 'readWrite' ? 'write' : null;
}
/**
* 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.
* The changes that give `principalId` access to what `scope` covers at
* `level`. A type left as it is ("keep") gets no change at all. With
* `replace` (the "Change" of someone who already has access), the items of a
* chosen type outside the scope lose their access; shared files stay as they
* are, since the dialog doesn't offer them. With level "keep", items the
* person has keep their rights and items added get the rights the rest of
* that type has, or read.
*/
export function buildGrantChanges(
targets: ShareTarget[],
principalId: string,
scope: DelegateScope,
level: AccessLevel,
level: LevelChoice,
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));
const changes: ShareChange[] = [];
for (const kind of DELEGATE_KINDS) {
const chosen = itemsFor(targets, kind, scope[SCOPE_KEY[kind]]);
if (!chosen) continue;
const all = itemsOfType(targets, kind);
const typeLevel = uniformLevel(all, kind, principalId) ?? 'read';
const ids = new Set(chosen.map((t) => t.id));
for (const t of chosen) {
if (level === 'keep') {
if (principalId in t.shareWith) continue;
changes.push(change(t, principalId, accessRights(kind, typeLevel)));
} else {
changes.push(change(t, principalId, accessRights(kind, level)));
}
}
if (options.replace) {
for (const t of all) {
if (!ids.has(t.id) && principalId in t.shareWith) changes.push(change(t, principalId, null));
}
}
}
return changes;
@@ -148,30 +187,56 @@ export function summarizeDelegates(targets: ShareTarget[], excludeIds: string[]
});
}
/** 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',
};
export interface ChangeStart {
scope: DelegateScope;
level: LevelChoice;
}
/**
* The levels the admin allows (`delegationRights.allowedValues`). Values
* The dialog's starting point for "Change". A type gets a choice only when
* the person's grants match it exactly (every folder for "all", exactly the
* Inbox for "Inbox only", exactly the default calendar, ...), all with the
* read or the read-and-write preset. Anything else (a folder added since,
* another single folder, free/busy only, manager or custom rights) starts
* as "Keep as it is", which leaves that type out of the patch. The level is
* read or write when every grant has that preset, else "keep". Without
* write among `allowed`, a level that would keep or give write starts at
* read: saving then lowers the types the user chose to read.
*/
export function changeStartFor(targets: ShareTarget[], principalId: string, allowed: AccessLevel[] = [...ACCESS_LEVELS]): ChangeStart {
const scopeFor = (kind: DelegateKind): MailScope | CollectionScope => {
// Address books Delegates doesn't offer still count: a grant there isn't one the dialog can express.
const relevant = kind === 'addressBook' ? ofKind(targets, kind) : itemsOfType(targets, kind);
const shared = relevant.filter((t) => principalId in t.shareWith);
if (shared.length === 0) return 'none';
if (shared.some((t) => !['read', 'readWrite'].includes(detectPreset(kind, t.shareWith[principalId])))) return 'keep';
const ids = shared.map((t) => t.id).sort().join('\n');
const exact = (choice: MailScope | CollectionScope) => {
const covered = itemsFor(targets, kind, choice) ?? [];
return covered.length > 0 && covered.map((t) => t.id).sort().join('\n') === ids;
};
if (kind === 'mailbox') return exact('all') ? 'all' : exact('inbox') ? 'inbox' : 'keep';
return exact('default') ? 'default' : exact('all') ? 'all' : 'keep';
};
const scope: DelegateScope = {
mail: scopeFor('mailbox') as MailScope,
calendars: scopeFor('calendar') as CollectionScope,
addressBooks: scopeFor('addressBook') as CollectionScope,
};
const granted = targets.filter((t) => t.kind !== 'file' && principalId in t.shareWith);
const presets = new Set(granted.map((t) => detectPreset(t.kind, t.shareWith[principalId])));
let level: LevelChoice = presets.size === 1 && presets.has('read') ? 'read'
: presets.size === 1 && presets.has('readWrite') ? 'write'
: 'keep';
const hasWrite = granted.some((t) => accessLevelOf(t.kind, t.shareWith[principalId]) === 'write');
if (!allowed.includes('write') && (level === 'write' || (level === 'keep' && hasWrite))) level = 'read';
else if (level !== 'keep' && !allowed.includes(level)) level = 'keep';
return { scope, level };
}
/**
* The levels the admin allows (`sharingRights.allowedValues`). Values
* other than "read" and "write" are ignored; a list with neither leaves
* read only, the safe end.
*/
@@ -181,9 +246,9 @@ export function allowedLevels(allowedValues: unknown[] | null): AccessLevel[] {
return allowed.length > 0 ? allowed : ['read'];
}
/** Whether a scope gives access to anything at all. */
/** Whether a scope gives access to nothing at all. */
export function scopeIsEmpty(scope: DelegateScope): boolean {
return scope.mail === 'none' && scope.calendars === 'none' && scope.addressBooks === 'none';
return Object.values(scope).every((choice) => choice === 'none');
}
/** The changes that failed, for the report and for "Try again". */
+8 -2
View File
@@ -4159,11 +4159,11 @@
"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.",
"read_failed": "Ein Teil Ihrer Freigaben konnte nicht gelesen werden. „Ändern“ und „Zugriff entfernen“ gehen erst wieder, wenn das klappt.",
"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.",
"remove_message": "{name} verliert den Zugriff auf alles, was Sie aus Ihrem eigenen Konto freigegeben haben: E-Mails, Kalender, Kontakte und Dateien.",
"removed": "Zugriff entfernt",
"remove_partial": "Hier besteht noch Zugriff: {items}",
"applied": "{name} hat jetzt Zugriff",
@@ -4195,6 +4195,7 @@
"looking_up": "Wird gesucht…",
"not_found": "Auf diesem Server hat niemand die Adresse {address}.",
"lookup_failed": "Die Adresse konnte nicht gesucht werden.",
"is_you": "Das sind Sie.",
"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.",
@@ -4209,13 +4210,18 @@
"address_books_default": "Standard-Adressbuch",
"address_books_all": "Alle Adressbücher",
"none": "Nichts",
"keep": "Unverändert lassen",
"empty_scope": "Wählen Sie E-Mail, Kalender oder Kontakte.",
"nothing_to_change": "Nichts zu ändern. Wählen Sie, was die Person bekommen soll, oder nutzen Sie „Zugriff entfernen“.",
"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_read_hint_flags": "Ihre E-Mails, Termine und Kontakte sehen. Auf diesem Server können sie E-Mails außerdem markieren und als gelesen kennzeichnen.",
"rights_write": "Lesen und schreiben",
"rights_write_hint": "Außerdem E-Mails ablegen, markieren und löschen sowie Termine und Kontakte ändern.",
"rights_keep": "Unverändert lassen",
"rights_keep_hint": "Was die Person hat, behält seine Rechte; Neues bekommt die Rechte, die der Rest dieser Art hat, sonst Lesen.",
"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",
+8 -2
View File
@@ -3809,11 +3809,11 @@
"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.",
"read_failed": "Some of your sharing couldn't be read, so Change and Remove access wait until it can.",
"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.",
"remove_message": "{name} loses access to everything you shared from your own account: mail, calendars, contacts and files.",
"removed": "Access removed",
"remove_partial": "These still have access: {items}",
"applied": "{name} now has access",
@@ -3845,6 +3845,7 @@
"looking_up": "Looking up…",
"not_found": "Nobody on this server has the address {address}.",
"lookup_failed": "Couldn't look up this address.",
"is_you": "That's you.",
"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.",
@@ -3859,13 +3860,18 @@
"address_books_default": "Default address book",
"address_books_all": "All address books",
"none": "None",
"keep": "Keep as it is",
"empty_scope": "Choose mail, calendars or contacts.",
"nothing_to_change": "Nothing to change. Pick what they should get, or use Remove access.",
"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_read_hint_flags": "See your mail, events and contacts. This server also lets them flag mail and mark it as read.",
"rights_write": "Read and write",
"rights_write_hint": "Also file, flag and delete mail, and change events and contacts.",
"rights_keep": "Keep as it is",
"rights_keep_hint": "What they have keeps its rights; anything added gets the rights the rest of that type has, or read.",
"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",