diff --git a/components/settings/delegates/__tests__/delegates-settings.test.tsx b/components/settings/delegates/__tests__/delegates-settings.test.tsx index dc2a330e4..cd8da4814 100644 --- a/components/settings/delegates/__tests__/delegates-settings.test.tsx +++ b/components/settings/delegates/__tests__/delegates-settings.test.tsx @@ -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) { 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']); diff --git a/components/settings/delegates/delegate-dialog.tsx b/components/settings/delegates/delegate-dialog.tsx index 3b05ed740..59137fa26 100644 --- a/components/settings/delegates/delegate-dialog.tsx +++ b/components/settings/delegates/delegate-dialog.tsx @@ -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; /** 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({ state: "idle" }); const [scope, setScope] = useState(initial?.scope ?? DEFAULT_DELEGATE_SCOPE); - const [level, setLevel] = useState(() => { + const [level, setLevel] = useState(() => { 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(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 ? (

{t("loading")}

) : ( -
+
    {candidates.length === 0 && !canLookUp && ( -

    {t("no_match")}

    +
  • {t("no_match")}
  • )} {candidates.map((p) => ( - +
  • + +
  • ))} {canLookUp && ( - +
  • + +
  • )} -
+ )} {lookup.state === "notFound" &&

{t("not_found", { address: lookup.address })}

} {lookup.state === "failed" &&

{t("lookup_failed")}

} + {lookup.state === "self" &&

{t("is_you")}

} {(directoryOff || lookup.state === "directoryOff") && (

{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(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(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(keep.addressBooks, t("keep")), ["default", t("address_books_default")], ["all", t("address_books_all")], ["none", t("none")]]} /> )} - {scopeIsEmpty(scope) &&

{t("empty_scope")}

} + {!changing && scopeIsEmpty(scope) &&

{t("empty_scope")}

} {changing &&

{t("change_note")}

}
{t("rights")} - {levels.map((option) => ( + {[...(keep.level ? ["keep" as const] : []), ...levels].map((option) => ( ))} {levelsRestricted && }
+ {changing && nothingToDo &&

{t("nothing_to_change")}

} +

{t("who_has_access")}

- {failedKinds.length > 0 &&

{t("read_failed")}

} + {incomplete && ( +
+ {t("read_failed")} + +
+ )} {targets === null ? (

{t("loading")}

) : 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 (
  • -
    {display.name}
    +
    + {display.name} + {principal?.type === "group" && ( + {t("group")} + )} +
    {display.detail &&
    {display.detail}
    }
    {describeSummary(summary, t)}
    - -
    @@ -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)} /> diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 39f6d2abd..2ccc38054 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -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 diff --git a/integration/tests/19-org-delegates.spec.ts b/integration/tests/19-org-delegates.spec.ts index 5bc46c0b1..3f4f8aaa3 100644 --- a/integration/tests/19-org-delegates.spec.ts +++ b/integration/tests/19-org-delegates.spec.ts @@ -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 } }>; - 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(); diff --git a/lib/delegation/__tests__/delegation.test.ts b/lib/delegation/__tests__/delegation.test.ts index 29b42b48d..70f70968b 100644 --- a/lib/delegation/__tests__/delegation.test.ts +++ b/lib/delegation/__tests__/delegation.test.ts @@ -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'); }); }); diff --git a/lib/delegation/delegation.ts b/lib/delegation/delegation.ts index 3145b7a95..f5d0790a9 100644 --- a/lib/delegation/delegation.ts +++ b/lib/delegation/delegation.ts @@ -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 = { 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". */ diff --git a/locales/de/common.json b/locales/de/common.json index 0eb67d43d..bbcc10730 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 9561d9c85..e96dca135 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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",