From 1de4a019e753371235886e8b70f93f96a015ad3f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 15:44:34 -0700 Subject: [PATCH] Who may share: the server's and each tenant's switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Sharing opens with two switches for the whole server, and a tenant's page carries the same two for that organization (multi-account spec, MA-C, MA-13): people may share their own mail folders, and people may add other accounts to the webmail. Turning mail sharing off refuses new shares and stops honoring the old ones until it is on again; groups and shared mailboxes keep working. A tenant can only be stricter: a switch the server has off shows off on the tenant's page and can't be turned on there. An older server without inbuxa:SharingPolicy shows nothing. Changing the server's needs sysSharingUpdate; a tenant's, the tenant's domain permissions, as its legacy protocols switch does. Nine new strings, in en.json. typecheck, lint, i18n:check, vitest and build pass. --- src/components/layout/MainContent.tsx | 15 +++ src/features/sharing/SharingSwitches.tsx | 136 +++++++++++++++++++++ src/features/sharing/sharingPolicy.test.ts | 23 ++++ src/features/sharing/sharingPolicy.ts | 82 +++++++++++++ src/i18n/en.json | 11 ++ 5 files changed, 267 insertions(+) create mode 100644 src/features/sharing/SharingSwitches.tsx create mode 100644 src/features/sharing/sharingPolicy.test.ts create mode 100644 src/features/sharing/sharingPolicy.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 00e34b0..57a309d 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -147,6 +147,11 @@ const AccountLockBanner = lazyFeature( () => import('@/features/lock/AccountLockBanner'), (m) => m.AccountLockBanner, ); +// inbuxa: who may share mail (multi-account spec, MA-C) +const SharingSwitches = lazyFeature( + () => import('@/features/sharing/SharingSwitches'), + (m) => m.SharingSwitches, +); const TenantLegacyProtocols = lazyFeature( () => import('@/features/hardening/TenantLegacyProtocols'), (m) => m.TenantLegacyProtocols, @@ -321,6 +326,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (resolved.objectName === 'x:SpamSettings') { return ; } + // inbuxa: Sharing opens with who may share (MA-C) + if (resolved.objectName === 'x:Sharing') { + return ( +
+ + +
+ ); + } if (resolved.objectName === 'x:Security') { return (
@@ -366,6 +380,7 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti return (
+ {page}
); diff --git a/src/features/sharing/SharingSwitches.tsx b/src/features/sharing/SharingSwitches.tsx new file mode 100644 index 0000000..160f5f6 --- /dev/null +++ b/src/features/sharing/SharingSwitches.tsx @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: who may share mail (multi-account spec, MA-C, MA-13), on Settings › + * Sharing for the server and on a tenant's page for that tenant. + * + * Off refuses new mail shares and stops honoring the ones already made; they + * stay stored and come back when it's on again. Groups and shared mailboxes + * aren't people's shares and are never affected. A tenant can only be + * stricter: a switch the server has off shows off and can't be turned on. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Share2 } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { + effectiveSwitch, + fetchSharingPolicies, + SERVER, + setSharingSwitch, + SharingPolicyUnavailable, + type SharingKey, + type SharingPolicy, +} from './sharingPolicy'; + +export function SharingSwitches({ tenantId }: { tenantId?: string }) { + const { t } = useTranslation(); + const canGet = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get')); + const canUpdateTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Update')); + const canUpdateServer = useAccountStore((s) => s.hasPermission('sysSharingUpdate')); + const [policies, setPolicies] = useState<{ server: SharingPolicy; tenant?: SharingPolicy } | null>(null); + const [busy, setBusy] = useState(null); + + const load = useCallback( + (signal?: AbortSignal) => + fetchSharingPolicies(tenantId, signal) + .then((p) => { + if (!signal?.aborted) setPolicies(p); + }) + .catch((e: unknown) => { + // An older server has no switches: show nothing rather than an error. + if (!signal?.aborted && !(e instanceof SharingPolicyUnavailable)) console.error(e); + }), + [tenantId], + ); + + useEffect(() => { + if (!canGet) return; + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [canGet, load]); + + if (!policies || (tenantId && !policies.tenant)) return null; + const canUpdate = tenantId ? canUpdateTenant : canUpdateServer; + + const turn = async (key: SharingKey, on: boolean) => { + setBusy(key); + try { + // On is the default: clearing the switch lets the server's apply + await setSharingSwitch(tenantId ?? SERVER, key, on ? null : 'disabled'); + await load(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('sharingPolicy.failed', 'The switch did not change'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(null); + } + }; + + const rows: { key: SharingKey; label: string; hint: string }[] = [ + { + key: 'mailSharing', + label: t('sharingPolicy.mailSharing', 'People may share their mail folders'), + hint: t( + 'sharingPolicy.mailSharingHint', + 'Off: nobody can share a mail folder with someone else, and what was shared stops working until this is on again. Groups and shared mailboxes keep working.', + ), + }, + { + key: 'addAccounts', + label: t('sharingPolicy.addAccounts', 'People may add other accounts to the webmail'), + hint: t( + 'sharingPolicy.addAccountsHint', + 'Off: the webmail doesn’t offer to sign in to a second account beside the first.', + ), + }, + ]; + + return ( +
+
+ +
+

{t('sharingPolicy.title', 'Who may share')}

+

+ {tenantId + ? t('sharingPolicy.tenantScope', 'For everyone in this organization. It can be stricter than the server, never looser.') + : t('sharingPolicy.serverScope', 'For everyone on this server. Each organization can be stricter on its own page.')} +

+
+
+ {rows.map(({ key, label, hint }) => { + const serverOff = policies.server[key] === 'disabled'; + const on = (tenantId ? effectiveSwitch(policies.server, policies.tenant, key) : policies.server[key]) === 'enabled'; + const locked = !!tenantId && serverOff; + return ( + + ); + })} +
+ ); +} diff --git a/src/features/sharing/sharingPolicy.test.ts b/src/features/sharing/sharingPolicy.test.ts new file mode 100644 index 0000000..6fc92aa --- /dev/null +++ b/src/features/sharing/sharingPolicy.test.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { effectiveSwitch, type SharingPolicy } from './sharingPolicy'; + +const p = (mailSharing: 'enabled' | 'disabled', addAccounts: 'enabled' | 'disabled' = 'enabled'): SharingPolicy => ({ + id: 'x', + mailSharing, + addAccounts, +}); + +describe('effectiveSwitch (MA-C)', () => { + it('is off when either level has it off: a tenant is only ever stricter', () => { + expect(effectiveSwitch(p('enabled'), undefined, 'mailSharing')).toBe('enabled'); + expect(effectiveSwitch(p('enabled'), p('disabled'), 'mailSharing')).toBe('disabled'); + expect(effectiveSwitch(p('disabled'), p('enabled'), 'mailSharing')).toBe('disabled'); + expect(effectiveSwitch(p('enabled', 'disabled'), p('enabled'), 'addAccounts')).toBe('disabled'); + }); +}); diff --git a/src/features/sharing/sharingPolicy.ts b/src/features/sharing/sharingPolicy.ts new file mode 100644 index 0000000..ce8c3a4 --- /dev/null +++ b/src/features/sharing/sharingPolicy.ts @@ -0,0 +1,82 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: `inbuxa:SharingPolicy` over JMAP (multi-account spec, MA-C): + * whether people may share their own mail folders, and add other accounts to + * the webmail. The server's policy has the id `singleton`; each tenant's has + * the tenant's id, and can only be stricter than the server's. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; + +const INBUXA_CAPABILITY = 'urn:inbuxa:jmap'; +const OBJECT = 'inbuxa:SharingPolicy'; +export const SERVER = 'singleton'; + +export type Switch = 'enabled' | 'disabled'; + +export interface SharingPolicy { + id: string; + mailSharing: Switch; + addAccounts: Switch; +} + +export type SharingKey = 'mailSharing' | 'addAccounts'; + +/** The server doesn't have the switches (an older server). */ +export class SharingPolicyUnavailable extends Error {} + +function asSwitch(value: unknown): Switch { + return value === 'disabled' ? 'disabled' : 'enabled'; +} + +/** The server's policy and, with `tenantId`, that tenant's too. */ +export async function fetchSharingPolicies( + tenantId?: string, + signal?: AbortSignal, +): Promise<{ server: SharingPolicy; tenant?: SharingPolicy }> { + const ids = tenantId ? [SERVER, tenantId] : [SERVER]; + const responses = await jmapRequest( + [[`${OBJECT}/get`, { accountId: getAccountId('x:Account'), ids }, '0']], + signal, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/get`) { + const type = (result as { type?: string } | undefined)?.type; + if (type === 'unknownMethod' || type === 'unknownCapability') throw new SharingPolicyUnavailable(type); + throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed'); + } + const list = ((result as { list?: Record[] }).list ?? []).map( + (raw): SharingPolicy => ({ + id: String(raw.id ?? ''), + mailSharing: asSwitch(raw.mailSharing), + addAccounts: asSwitch(raw.addAccounts), + }), + ); + const server = list.find((p) => p.id === SERVER); + if (!server) throw new SharingPolicyUnavailable('no server policy'); + return { server, tenant: tenantId ? list.find((p) => p.id === tenantId) : undefined }; +} + +/** Turns one switch. `null` puts it back to the default, on as far as the server allows. */ +export async function setSharingSwitch(id: string, key: SharingKey, value: Switch | null): Promise { + const responses = await jmapRequest( + [[`${OBJECT}/set`, { accountId: getAccountId('x:Account'), update: { [id]: { [key]: value } } }, '0']], + undefined, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/set`) throw new Error('Request failed'); + const failed = (result as { notUpdated?: Record }).notUpdated?.[id]; + if (failed) throw new Error(failed.description ?? failed.type ?? 'Not saved'); +} + +/** What applies inside a tenant: off if either level has it off. */ +export function effectiveSwitch(server: SharingPolicy, tenant: SharingPolicy | undefined, key: SharingKey): Switch { + return server[key] === 'disabled' || tenant?.[key] === 'disabled' ? 'disabled' : 'enabled'; +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 59bab00..cdbf35a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1998,5 +1998,16 @@ "editAction": "People…", "stopAction": "Stop sharing…", "noPeople": "Nobody has it yet." + }, + "sharingPolicy": { + "failed": "The switch did not change", + "mailSharing": "People may share their mail folders", + "mailSharingHint": "Off: nobody can share a mail folder with someone else, and what was shared stops working until this is on again. Groups and shared mailboxes keep working.", + "addAccounts": "People may add other accounts to the webmail", + "addAccountsHint": "Off: the webmail doesn’t offer to sign in to a second account beside the first.", + "title": "Who may share", + "tenantScope": "For everyone in this organization. It can be stricter than the server, never looser.", + "serverScope": "For everyone on this server. Each organization can be stricter on its own page.", + "serverOff": "Off for the whole server." } } -- 2.54.0