mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
feat: let admins limit every share dialog to read access with sharingRights
This commit is contained in:
@@ -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 "read" to allow read access only.
|
||||
With neither ticked nothing is limited, so users can give both. Tick "read" 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'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" />
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user