Who may share: the server's and each tenant's switches #82
No files matched your search
@@ -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 <SpamSettingsPage viewName={viewName} />;
|
||||
}
|
||||
// inbuxa: Sharing opens with who may share (MA-C)
|
||||
if (resolved.objectName === 'x:Sharing') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SharingSwitches />
|
||||
<DynamicForm viewName={viewName} objectId="singleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (resolved.objectName === 'x:Security') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -366,6 +380,7 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TenantLegacyProtocols tenantId={id} />
|
||||
<SharingSwitches tenantId={id} />
|
||||
{page}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<SharingKey | null>(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 (
|
||||
<section className="mx-auto max-w-4xl space-y-3 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Share2 className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">{t('sharingPolicy.title', 'Who may share')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{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 (
|
||||
<label key={key} className="flex items-start justify-between gap-4 rounded-lg border p-3">
|
||||
<span className="space-y-1 text-sm">
|
||||
<span className="block font-medium">{label}</span>
|
||||
<span className="block text-muted-foreground">
|
||||
{locked ? t('sharingPolicy.serverOff', 'Off for the whole server.') : hint}
|
||||
</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={on}
|
||||
disabled={!canUpdate || locked || busy !== null}
|
||||
onCheckedChange={(v) => void turn(key, v === true)}
|
||||
aria-label={label}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] }).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<void> {
|
||||
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<string, { description?: string; type?: string }> }).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';
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user