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." } }