From f0539a33f6ccb6d78b4a5f30cc373bef044dd0cc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:27:49 +0100 Subject: [PATCH] feat: let admins limit every share dialog to read access with sharingRights --- .../admin/org/sections/delegates-section.tsx | 14 +-- ...-collection-dialog-sharing-rights.test.tsx | 85 +++++++++++++++++++ .../settings/share-collection-dialog.tsx | 23 +++-- lib/admin/restriction-keys.ts | 2 +- 4 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 components/settings/__tests__/share-collection-dialog-sharing-rights.test.tsx diff --git a/components/admin/org/sections/delegates-section.tsx b/components/admin/org/sections/delegates-section.tsx index 59b4993fd..1fecb99c5 100644 --- a/components/admin/org/sections/delegates-section.tsx +++ b/components/admin/org/sections/delegates-section.tsx @@ -6,11 +6,11 @@ import { RestrictionRow } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; const DELEGATION = RESTRICTION_KEYS.find((def) => def.key === 'delegation')!; -const DELEGATION_RIGHTS = RESTRICTION_KEYS.find((def) => def.key === 'delegationRights')!; +const SHARING_RIGHTS = RESTRICTION_KEYS.find((def) => def.key === 'sharingRights')!; -/** Organisation > Sharing > Delegates: whether users may delegate, and which rights. */ +/** Organisation > Sharing > Delegates: whether users may delegate, and which rights any share dialog offers. */ export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) { - const rights = draft.restriction(DELEGATION_RIGHTS.key).value; + const rights = draft.restriction(SHARING_RIGHTS.key).value; const noneTicked = Array.isArray(rights?.allowedValues) && rights.allowedValues.length === 0; return ( - + {noneTicked && (

- With neither ticked nothing is limited, so delegates can get both. Tick "read" to allow read access only. + With neither ticked nothing is limited, so users can give both. Tick "read" to allow read access only.

)}

- These apply to the Delegates dialog only. Sharing one folder, calendar or file from its own menu works as before, and Stalwart - itself enforces neither setting. + Both limit what Bulwark offers. Stalwart doesn't enforce them, so users can still share through other clients. Shared + mailboxes of groups are yours to manage in Stalwart.

); diff --git a/components/settings/__tests__/share-collection-dialog-sharing-rights.test.tsx b/components/settings/__tests__/share-collection-dialog-sharing-rights.test.tsx new file mode 100644 index 000000000..1ae4a235c --- /dev/null +++ b/components/settings/__tests__/share-collection-dialog-sharing-rights.test.tsx @@ -0,0 +1,85 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ShareCollectionDialog } from '../share-collection-dialog'; +import { MailboxShareDialog } from '@/components/layout/mailbox-share-dialog'; +import { usePolicyStore } from '@/stores/policy-store'; +import { presetRights, type AnyRights, type ShareKind } from '@/lib/jmap/share-rights'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Mailbox } from '@/lib/jmap/types'; + +// The admin's `sharingRights` without "write": every share dialog offers +// the read presets only, and a grant that already gives more stays named but +// can't be picked again. + +const initialPolicy = usePolicyStore.getState(); +afterEach(() => usePolicyStore.setState(initialPolicy, true)); + +function readOnly() { + usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: { ...state.policy.restrictions, sharingRights: { allowedValues: ['read'] } } } })); +} + +const client = { + getAccountId: () => 'me', + listPrincipals: vi.fn(async () => ({ principals: [], status: 'ok' as const })), + getPrincipals: vi.fn(async () => []), +} as unknown as IJMAPClient; + +function renderKind(kind: ShareKind, shareWith: Record) { + const onShare = vi.fn().mockResolvedValue(undefined); + render(); + return onShare; +} + +const offered = (select: HTMLElement) => within(select).getAllByRole('option') + .filter((o) => !(o as HTMLOptionElement).disabled) + .map((o) => (o as HTMLOptionElement).value); + +describe('sharingRights in the share dialogs', () => { + it.each([ + ['mailbox', ['read', 'readWrite', 'manager']], + ['calendar', ['freeBusy', 'read', 'readWrite', 'manager']], + ['addressBook', ['read', 'readWrite', 'manager']], + ['file', ['read', 'readWrite', 'manager']], + ] as const)('offers every preset for a %s without a restriction', (kind, all) => { + renderKind(kind, { anna: presetRights(kind, 'read')! }); + expect(offered(screen.getByRole('combobox'))).toEqual(all); + expect(screen.queryByText('org_locked')).toBeNull(); + }); + + it.each([ + ['mailbox', ['read']], + ['calendar', ['freeBusy', 'read']], + ['addressBook', ['read']], + ['file', ['read']], + ] as const)('offers only read presets for a %s without write, and says why', (kind, readOnlyPresets) => { + readOnly(); + renderKind(kind, { anna: presetRights(kind, 'read')! }); + expect(offered(screen.getByRole('combobox'))).toEqual(readOnlyPresets); + expect(screen.getByText('org_locked')).toBeInTheDocument(); + }); + + it('keeps an existing write grant named but not pickable, and refuses to give write', async () => { + readOnly(); + const onShare = renderKind('mailbox', { anna: presetRights('mailbox', 'readWrite')! }); + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(select.value).toBe('readWrite'); + expect((within(select).getByRole('option', { name: 'preset.readWrite' }) as HTMLOptionElement).disabled).toBe(true); + fireEvent.change(select, { target: { value: 'manager' } }); + await act(async () => {}); + expect(onShare).not.toHaveBeenCalled(); + fireEvent.change(select, { target: { value: 'read' } }); + await waitFor(() => expect(onShare).toHaveBeenCalledWith('anna', presetRights('mailbox', 'read'))); + }); + + it('applies to the folder share dialog', async () => { + readOnly(); + const mailboxClient = { + ...client, + getMailboxShareWith: vi.fn(async () => ({ anna: presetRights('mailbox', 'read') })), + setMailboxShare: vi.fn(async () => {}), + } as unknown as IJMAPClient; + render(); + const select = await screen.findByRole('combobox'); + expect(offered(select)).toEqual(['read']); + }); +}); diff --git a/components/settings/share-collection-dialog.tsx b/components/settings/share-collection-dialog.tsx index 437da3f19..7ce4dce0f 100644 --- a/components/settings/share-collection-dialog.tsx +++ b/components/settings/share-collection-dialog.tsx @@ -10,6 +10,9 @@ import type { Principal, PrincipalDirectoryStatus } from "@/lib/jmap/types"; import { detectPreset, presetRights, type AnyRights, type RolePreset, type ShareKind } from "@/lib/jmap/share-rights"; import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory"; import { toast } from "@/stores/toast-store"; +import { useRestriction } from "@/hooks/use-restriction"; +import { allowedLevels } from "@/lib/delegation/delegation"; +import { OrgLockedNote } from "./org-locked-note"; interface ShareCollectionDialogProps { client: IJMAPClient; @@ -40,6 +43,9 @@ export function ShareCollectionDialog({ const [search, setSearch] = useState(""); const [savingId, setSavingId] = useState(null); const [showAdd, setShowAdd] = useState(false); + // The admin's `sharingRights`: without write, only the read presets. + const sharingRights = useRestriction("sharingRights"); + const writeAllowed = allowedLevels(sharingRights.allowedValues).includes("write"); // Load principals on mount useEffect(() => { @@ -79,8 +85,15 @@ export function ShareCollectionDialog({ return () => document.removeEventListener("keydown", onKey); }, [onClose]); + const presetOptions: RolePreset[] = [ + ...(kind === "calendar" ? ["freeBusy" as const] : []), + "read", + ...(writeAllowed ? ["readWrite" as const, "manager" as const] : []), + ]; + const handleSetRights = async (principalId: string, preset: RolePreset) => { if (preset === "custom") return; // custom is read-only here + if (!(presetOptions as readonly RolePreset[]).includes(preset)) return; const rights = presetRights(kind, preset); if (!rights) return; setSavingId(principalId); @@ -136,9 +149,7 @@ export function ShareCollectionDialog({ return Object.entries(shareWith || {}) as [string, AnyRights][]; }, [shareWith]); - const presetOptions = kind === "calendar" - ? ["freeBusy", "read", "readWrite", "manager"] as const - : ["read", "readWrite", "manager"] as const; + return (
@@ -166,6 +177,7 @@ export function ShareCollectionDialog({

{t("description")}

+ {!writeAllowed && } {sharedEntries.length === 0 && !showAdd && (
@@ -212,8 +224,9 @@ export function ShareCollectionDialog({ {presetOptions.map((p) => ( ))} - {preset === "custom" && ( - + {/* A grant the admin no longer lets users give stays named, but can't be picked. */} + {!presetOptions.includes(preset) && ( + )} diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index 0d11e4885..df4b49d04 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -43,7 +43,7 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [ { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean', page: 'policy' }, { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean', page: 'policy' }, { key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] }, - { key: 'delegationRights', label: 'Rights delegates can get', description: 'Untick "write" and the Delegates dialog offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] }, + { key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] }, ]; export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] {