feat: let admins limit every share dialog to read access with sharingRights

This commit is contained in:
Linus Rath
2026-10-28 06:27:49 +01:00
parent f0a89fdb65
commit f0539a33f6
4 changed files with 111 additions and 13 deletions
@@ -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 (
<SettingsSection
@@ -19,15 +19,15 @@ export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) {
description="Users can give a colleague read or read and write access to their mail folders, calendars and address books in one step, under Settings > Sharing. Stalwart doesn't let delegates send as the user; for an address several people send from, set up a shared mailbox."
>
<RestrictionRow draft={draft} def={DELEGATION} />
<RestrictionRow draft={draft} def={DELEGATION_RIGHTS} />
<RestrictionRow draft={draft} def={SHARING_RIGHTS} />
{noneTicked && (
<p className="text-xs text-destructive" role="status">
With neither ticked nothing is limited, so delegates can get both. Tick &quot;read&quot; to allow read access only.
With neither ticked nothing is limited, so users can give both. Tick &quot;read&quot; to allow read access only.
</p>
)}
<p className="text-xs text-muted-foreground">
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&apos;t enforce them, so users can still share through other clients. Shared
mailboxes of groups are yours to manage in Stalwart.
</p>
</SettingsSection>
);
@@ -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<string, AnyRights>) {
const onShare = vi.fn().mockResolvedValue(undefined);
render(<ShareCollectionDialog client={client} kind={kind} collectionName="X" shareWith={shareWith} ownAccountId="me" onShare={onShare} onClose={vi.fn()} />);
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(<MailboxShareDialog client={mailboxClient} mailbox={{ id: 'a', name: 'Inbox', role: 'inbox' } as Mailbox} onClose={vi.fn()} />);
const select = await screen.findByRole('combobox');
expect(offered(select)).toEqual(['read']);
});
});
@@ -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<string | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -166,6 +177,7 @@ export function ShareCollectionDialog({
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{!writeAllowed && <OrgLockedNote />}
{sharedEntries.length === 0 && !showAdd && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
@@ -212,8 +224,9 @@ export function ShareCollectionDialog({
{presetOptions.map((p) => (
<option key={p} value={p}>{t(`preset.${p}`)}</option>
))}
{preset === "custom" && (
<option value="custom">{t("preset.custom")}</option>
{/* A grant the admin no longer lets users give stays named, but can't be picked. */}
{!presetOptions.includes(preset) && (
<option value={preset} disabled>{t(`preset.${preset}`)}</option>
)}
</select>
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
+1 -1
View File
@@ -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[] {