Each tenant's page carries its legacy protocols switch #5

Merged
jcoffey-dev merged 1 commits from feat/tenant-legacy-switch into main 2026-09-21 20:44:09 +00:00
7 changed files with 484 additions and 172 deletions
+20 -3
View File
@@ -51,6 +51,10 @@ const LegacyProtocolsPage = lazyFeature(
() => import('@/features/hardening/LegacyProtocolsPage'), () => import('@/features/hardening/LegacyProtocolsPage'),
(m) => m.LegacyProtocolsPage, (m) => m.LegacyProtocolsPage,
); );
const TenantLegacyProtocols = lazyFeature(
() => import('@/features/hardening/TenantLegacyProtocols'),
(m) => m.TenantLegacyProtocols,
);
interface MainContentProps { interface MainContentProps {
viewName?: string; viewName?: string;
@@ -150,10 +154,23 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
return <TraceDetailView viewName={viewName} objectId={id} />; return <TraceDetailView viewName={viewName} objectId={id} />;
} }
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update'); const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
if (!canUpdate) { const page = canUpdate ? (
return <DynamicViewPage viewName={viewName} objectId={id} />; <DynamicForm viewName={viewName} objectId={id} />
) : (
<DynamicViewPage viewName={viewName} objectId={id} />
);
// INBUXA: a tenant's page carries its legacy protocols switch (LP-9). A
// tenant admin reads its tenant without changing it (MT-12), and may
// still turn the switch, so it shows on the read-only page too.
if (resolved.objectName === 'x:Tenant') {
return (
<div className="space-y-4">
<TenantLegacyProtocols tenantId={id} />
{page}
</div>
);
} }
return <DynamicForm viewName={viewName} objectId={id} />; return page;
} }
return <DynamicList viewName={viewName} />; return <DynamicList viewName={viewName} />;
@@ -15,24 +15,43 @@ import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ShieldCheck } from 'lucide-react'; import { ShieldCheck } from 'lucide-react';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
import { fetchProtocolPolicy } from './protocolPolicy'; import { fetchProtocolPolicy, fetchTenantPolicy } from './protocolPolicy';
export const LEGACY_PROTOCOLS_VIEW = 'CustomComponent/LegacyProtocols'; export const LEGACY_PROTOCOLS_VIEW = 'CustomComponent/LegacyProtocols';
export function LegacyProtocolsBanner() { export function LegacyProtocolsBanner() {
const { t } = useTranslation(); const { t } = useTranslation();
const canGet = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get')); const canGetServer = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get'));
const [off, setOff] = useState(false); const canGetTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
const [off, setOff] = useState<null | 'server' | 'tenant'>(null);
useEffect(() => { useEffect(() => {
if (!canGet) return; if (!canGetServer && !canGetTenant) return;
const controller = new AbortController(); const controller = new AbortController();
fetchProtocolPolicy(controller.signal) const signal = controller.signal;
.then((policy) => setOff(policy.legacyProtocols === 'disabled')) (async () => {
// A banner is not worth an error: an older server simply has no switch. // The server's switch first. Inside a tenant it can't be read, and the
.catch(() => setOff(false)); // tenant's own is the one to report (LP-18 at tenant scope).
try {
if (canGetServer) {
const policy = await fetchProtocolPolicy(signal);
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'server' : null);
return;
}
} catch {
// Fall through to the tenant's.
}
try {
if (canGetTenant) {
const policy = await fetchTenantPolicy(null, signal);
if (!signal.aborted) setOff(policy.legacyProtocols === 'disabled' ? 'tenant' : null);
}
} catch {
// A banner is not worth an error: an older server simply has no switch.
}
})();
return () => controller.abort(); return () => controller.abort();
}, [canGet]); }, [canGetServer, canGetTenant]);
if (!off) return null; if (!off) return null;
@@ -42,11 +61,18 @@ export function LegacyProtocolsBanner() {
<span> <span>
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '} {t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '} <strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
{t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')} {off === 'server'
? t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')
: t(
'legacyProtocols.bannerTailTenant',
'for your organization. Only INBUXA webmail and JMAP apps can sign in.',
)}
</span> </span>
<Link to={`/Settings/${LEGACY_PROTOCOLS_VIEW}`} className="font-medium text-primary hover:underline"> {off === 'server' && (
{t('legacyProtocols.review', 'Review')} <Link to={`/Settings/${LEGACY_PROTOCOLS_VIEW}`} className="font-medium text-primary hover:underline">
</Link> {t('legacyProtocols.review', 'Review')}
</Link>
)}
</div> </div>
); );
} }
+8 -156
View File
@@ -18,27 +18,20 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2, Lock, RotateCcw, ShieldCheck, ShieldOff } from 'lucide-react'; import { AlertTriangle, Loader2, Lock, RotateCcw, ShieldCheck, ShieldOff } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LoadingFallback } from '@/components/common/LoadingFallback';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
ago,
CONFIRM_PHRASE,
describeListener, describeListener,
impactEntries,
fetchProtocolPolicy, fetchProtocolPolicy,
phraseMatches,
PolicyUnavailable, PolicyUnavailable,
protocolRows, protocolRows,
updateProtocolPolicy, updateProtocolPolicy,
type PolicyListener,
type ProtocolPolicy, type ProtocolPolicy,
type ProtocolRow, type ProtocolRow,
type RecentUse,
} from './protocolPolicy'; } from './protocolPolicy';
import { ConfirmTurnOff, ImpactPanel, Statement } from './parts';
type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string };
@@ -47,7 +40,6 @@ export function LegacyProtocolsPage() {
const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Update')); const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Update'));
const [load, setLoad] = useState<Load>({ kind: 'loading' }); const [load, setLoad] = useState<Load>({ kind: 'loading' });
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const loaded = useCallback( const loaded = useCallback(
@@ -86,7 +78,6 @@ export function LegacyProtocolsPage() {
// overruled by the SMTP lock (LP-21), which the selector already shows. // overruled by the SMTP lock (LP-21), which the selector already shows.
await updateProtocolPolicy({ legacyProtocols }); await updateProtocolPolicy({ legacyProtocols });
setConfirming(false); setConfirming(false);
setTyped('');
await refresh(); await refresh();
} catch (e) { } catch (e) {
toast({ toast({
@@ -161,7 +152,7 @@ export function LegacyProtocolsPage() {
{!off && policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />} {!off && policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
{(off || confirming) && <Statement listeners={listeners} />} {(off || confirming) && <Statement scope={{ kind: 'server', listeners }} />}
{!off && canUpdate && !confirming && ( {!off && canUpdate && !confirming && (
<Button variant="destructive" onClick={() => setConfirming(true)}> <Button variant="destructive" onClick={() => setConfirming(true)}>
@@ -171,42 +162,13 @@ export function LegacyProtocolsPage() {
)} )}
{!off && confirming && ( {!off && confirming && (
<form <ConfirmTurnOff
className="space-y-3 rounded-xl border p-4" busy={busy}
onSubmit={(e) => { onConfirm={() => void turn('disabled')}
e.preventDefault(); onCancel={() => {
if (phraseMatches(typed)) void turn('disabled'); setConfirming(false);
}} }}
> />
<Label htmlFor="legacy-confirm">
{t('legacyProtocols.typeToConfirm', 'To confirm, type')}{' '}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">{CONFIRM_PHRASE}</code>
</Label>
<Input
id="legacy-confirm"
autoComplete="off"
spellCheck={false}
value={typed}
onChange={(e) => setTyped(e.target.value)}
/>
<div className="flex gap-2">
<Button type="submit" variant="destructive" disabled={busy || !phraseMatches(typed)}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('legacyProtocols.confirm', 'Turn off legacy protocols')}
</Button>
<Button
type="button"
variant="ghost"
disabled={busy}
onClick={() => {
setConfirming(false);
setTyped('');
}}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</form>
)} )}
</div> </div>
); );
@@ -320,113 +282,3 @@ function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) {
</div> </div>
); );
} }
/**
* The impact panel (LP-15): who would notice, shown before anything can
* change. With nobody, it says so in one line.
*/
function ImpactPanel({ recent }: { recent: RecentUse[] }) {
const { t, i18n } = useTranslation();
const entries = impactEntries(recent);
// Read once, when the panel appears: "2 days ago" needn't tick.
const [now] = useState(() => Date.now());
if (entries.length === 0) {
return (
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
{t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')}
</p>
);
}
return (
<section className="space-y-2 rounded-xl border p-4 text-sm">
<p>
<strong>
{t('legacyProtocols.impactCount', {
count: entries.length,
defaultValue_one: '1 account used a legacy mail app in the last 30 days.',
defaultValue_other: '{{count}} accounts used a legacy mail app in the last 30 days.',
})}
</strong>{' '}
{t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')}
</p>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{entries.map((entry) => (
<li key={entry.name} className="flex flex-wrap gap-x-2">
<span className="font-medium">{entry.name}</span>
<span className="text-muted-foreground">
{entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)}
</span>
</li>
))}
</ul>
</section>
);
}
/** The statement (LP-16), at server scope, with the firewall note (LP-20). */
function Statement({ listeners }: { listeners: PolicyListener[] }) {
const { t } = useTranslation();
return (
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
<p className="text-base font-semibold">
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}
</p>
<p>
{t(
'legacyProtocols.statementLead',
'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.',
)}
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
{t(
'legacyProtocols.statementApps',
'Phone and desktop mail apps will stop receiving and sending mail. Thats iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.',
)}
</li>
<li>
{t(
'legacyProtocols.statementFilters',
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.',
)}
</li>
<li>
{t(
'legacyProtocols.statementUnaffected',
'Incoming mail is not affected. Calendars and contacts are not affected.',
)}
</li>
<li>
{t(
'legacyProtocols.statementWebmail',
'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.',
)}
</li>
</ul>
<p>
{listeners.length > 0
? t('legacyProtocols.statementPorts', 'The IMAP, POP3 and ManageSieve ports will close: {{list}}.', {
list: listeners.map(describeListener).join(', '),
})
: t(
'legacyProtocols.statementNoPorts',
'No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.',
)}
</p>
<p>
{t(
'legacyProtocols.statementSubmission',
'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.',
)}
</p>
<p>
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
{t(
'legacyProtocols.firewallBody',
'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.',
)}
</p>
<p>{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}</p>
</section>
);
}
@@ -0,0 +1,138 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: one tenant's legacy mail protocols switch, on the tenant's page
* (legacy-protocols spec, LP-9 to LP-18 at tenant scope).
*
* It closes no port -- other tenants share them (LP-13) -- so the statement
* names no listener and carries no firewall note. It refuses sign-in over
* legacy protocols on the tenant's domains. Turning it off takes the typed
* phrase; turning it back on is one click, which the server refuses while
* it has legacy protocols off itself (LP-9), and says so.
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAccountStore } from '@/stores/accountStore';
import { toast } from '@/hooks/use-toast';
import { cn } from '@/lib/utils';
import { getAccountId, jmapGet } from '@/services/jmap/client';
import { fetchTenantPolicy, PolicyUnavailable, updateTenantPolicy, type TenantPolicy } from './protocolPolicy';
import { ConfirmTurnOff, ImpactPanel, Statement } from './parts';
export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) {
const { t } = useTranslation();
const canGet = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Update'));
const [policy, setPolicy] = useState<TenantPolicy | null>(null);
const [organization, setOrganization] = useState('');
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const loaded = useCallback(
(fetching: Promise<TenantPolicy>, signal?: AbortSignal) =>
fetching
.then((p) => {
if (!signal?.aborted) setPolicy(p);
})
.catch((e: unknown) => {
// An older server has no tenant switch: show nothing rather than an error.
if (!signal?.aborted && !(e instanceof PolicyUnavailable)) console.error(e);
}),
[],
);
useEffect(() => {
if (!canGet) return;
const controller = new AbortController();
void loaded(fetchTenantPolicy(tenantId, controller.signal), controller.signal);
// The organization's name, for the statement.
jmapGet('x:Tenant', getAccountId('x:Tenant'), [tenantId], ['name'], controller.signal)
.then((responses) => {
const list = (responses[0]?.[1] as { list?: { name?: string }[] } | undefined)?.list;
if (!controller.signal.aborted && list?.[0]?.name) setOrganization(list[0].name);
})
.catch(() => {});
return () => controller.abort();
}, [tenantId, canGet, loaded]);
const turn = useCallback(
async (value: 'enabled' | 'disabled') => {
setBusy(true);
try {
await updateTenantPolicy(tenantId, value);
setConfirming(false);
await loaded(fetchTenantPolicy(tenantId));
} catch (e) {
toast({
variant: 'destructive',
title: t('legacyProtocols.failed', 'The switch did not change'),
description: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(false);
}
},
[tenantId, loaded, t],
);
if (!policy) return null;
const off = policy.legacyProtocols === 'disabled';
const name = organization || t('legacyProtocols.thisOrganization', 'this organization');
return (
// Aligned with the tenant form beneath it.
<section className="mx-auto max-w-4xl space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
{off ? (
<ShieldCheck className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" />
) : (
<ShieldOff className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div>
<p className="font-medium">{t('legacyProtocols.title', 'Legacy mail protocols')}</p>
<p className={cn('text-sm', off ? 'text-foreground' : 'text-muted-foreground')}>
{off
? t(
'legacyProtocols.tenantOff',
'Off for {{organization}}. Only INBUXA webmail and JMAP apps can sign in to its domains.',
{ organization: name },
)
: t(
'legacyProtocols.tenantOn',
'On for {{organization}}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.',
{ organization: name },
)}
</p>
</div>
</div>
{canUpdate && off && (
<Button variant="outline" disabled={busy} onClick={() => void turn('enabled')} className="shrink-0">
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('legacyProtocols.turnOn', 'Turn legacy protocols back on')}
</Button>
)}
{canUpdate && !off && !confirming && (
<Button variant="destructive" onClick={() => setConfirming(true)} className="shrink-0">
{t('legacyProtocols.turnOff', 'Turn off legacy protocols…')}
</Button>
)}
</div>
{!off && confirming && (
<>
{policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
<Statement scope={{ kind: 'tenant', organization: name }} />
<ConfirmTurnOff busy={busy} onConfirm={() => void turn('disabled')} onCancel={() => setConfirming(false)} />
</>
)}
</section>
);
}
+201
View File
@@ -0,0 +1,201 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: the parts the server's switch (Settings Security Hardening) and
* a tenant's switch (each tenant's page) share: the impact panel (LP-15), the
* statement (LP-16) and the typed confirmation (LP-17).
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
ago,
CONFIRM_PHRASE,
describeListener,
impactEntries,
phraseMatches,
type PolicyListener,
type RecentUse,
} from './protocolPolicy';
/**
* The typed confirmation to turn legacy protocols off (LP-17): the button
* stays disabled until the phrase matches exactly.
*/
export function ConfirmTurnOff({
busy,
onConfirm,
onCancel,
}: {
busy: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [typed, setTyped] = useState('');
return (
<form
className="space-y-3 rounded-xl border p-4"
onSubmit={(e) => {
e.preventDefault();
if (phraseMatches(typed)) onConfirm();
}}
>
<Label htmlFor="legacy-confirm">
{t('legacyProtocols.typeToConfirm', 'To confirm, type')}{' '}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">{CONFIRM_PHRASE}</code>
</Label>
<Input
id="legacy-confirm"
autoComplete="off"
spellCheck={false}
value={typed}
onChange={(e) => setTyped(e.target.value)}
/>
<div className="flex gap-2">
<Button type="submit" variant="destructive" disabled={busy || !phraseMatches(typed)}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('legacyProtocols.confirm', 'Turn off legacy protocols')}
</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</form>
);
}
/**
* The impact panel (LP-15): who would notice, shown before anything can
* change. With nobody, it says so in one line.
*/
export function ImpactPanel({ recent }: { recent: RecentUse[] }) {
const { t, i18n } = useTranslation();
const entries = impactEntries(recent);
// Read once, when the panel appears: "2 days ago" needn't tick.
const [now] = useState(() => Date.now());
if (entries.length === 0) {
return (
<p className="rounded-xl border px-4 py-3 text-sm text-muted-foreground">
{t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')}
</p>
);
}
return (
<section className="space-y-2 rounded-xl border p-4 text-sm">
<p>
<strong>
{t('legacyProtocols.impactCount', {
count: entries.length,
defaultValue_one: '1 account used a legacy mail app in the last 30 days.',
defaultValue_other: '{{count}} accounts used a legacy mail app in the last 30 days.',
})}
</strong>{' '}
{t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')}
</p>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{entries.map((entry) => (
<li key={entry.name} className="flex flex-wrap gap-x-2">
<span className="font-medium">{entry.name}</span>
<span className="text-muted-foreground">
{entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)}
</span>
</li>
))}
</ul>
</section>
);
}
/** Whose switch the statement is about: the server's, naming what closes, or a tenant's. */
export type StatementScope = { kind: 'server'; listeners: PolicyListener[] } | { kind: 'tenant'; organization: string };
/**
* The statement (LP-16). At server scope it names the ports that close and
* carries the firewall note (LP-20); at tenant scope no port closes, so
* neither is said (LP-13).
*/
export function Statement({ scope }: { scope: StatementScope }) {
const { t } = useTranslation();
return (
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
<p className="text-base font-semibold">
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}
</p>
<p>
{scope.kind === 'server'
? t(
'legacyProtocols.statementLead',
'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.',
)
: t(
'legacyProtocols.statementLeadTenant',
'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone in {{organization}}.',
{ organization: scope.organization },
)}
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
{t(
'legacyProtocols.statementApps',
'Phone and desktop mail apps will stop receiving and sending mail. Thats iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.',
)}
</li>
<li>
{t(
'legacyProtocols.statementFilters',
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.',
)}
</li>
<li>
{t(
'legacyProtocols.statementUnaffected',
'Incoming mail is not affected. Calendars and contacts are not affected.',
)}
</li>
<li>
{t(
'legacyProtocols.statementWebmail',
'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.',
)}
</li>
</ul>
{scope.kind === 'server' && (
<p>
{scope.listeners.length > 0
? t('legacyProtocols.statementPorts', 'The IMAP, POP3 and ManageSieve ports will close: {{list}}.', {
list: scope.listeners.map(describeListener).join(', '),
})
: t(
'legacyProtocols.statementNoPorts',
'No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.',
)}
</p>
)}
<p>
{t(
'legacyProtocols.statementSubmission',
'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.',
)}
</p>
{scope.kind === 'server' && (
<p>
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
{t(
'legacyProtocols.firewallBody',
'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.',
)}
</p>
)}
<p>{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}</p>
</section>
);
}
@@ -10,6 +10,7 @@ import {
CONFIRM_PHRASE, CONFIRM_PHRASE,
impactEntries, impactEntries,
parsePolicy, parsePolicy,
parseTenantPolicy,
phraseMatches, phraseMatches,
protocolRows, protocolRows,
type ProtocolPolicy, type ProtocolPolicy,
@@ -120,3 +121,18 @@ describe('the impact panel (LP-15)', () => {
expect(ago(now - 10_000, now, 'en')).toBe('this minute'); expect(ago(now - 10_000, now, 'en')).toBe('this minute');
}); });
}); });
describe("a tenant's switch", () => {
it('reads the wire, and tells an older server from nobody', () => {
const p = parseTenantPolicy({
id: 'b',
tenantId: 'b',
legacyProtocols: 'disabled',
changedAt: 5,
recentLegacyUse: [{ accountId: 'c', name: '[email protected]', protocol: 'imap', lastUsedAt: 9 }],
});
expect(p).toMatchObject({ id: 'b', legacyProtocols: 'disabled', changedAt: 5 });
expect(p.recentLegacyUse).toHaveLength(1);
expect(parseTenantPolicy({ id: 'b' })).toMatchObject({ legacyProtocols: 'enabled', recentLegacyUse: null });
});
});
+62
View File
@@ -256,3 +256,65 @@ export function phraseMatches(typed: string): boolean {
export function describeListener(l: PolicyListener): string { export function describeListener(l: PolicyListener): string {
return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name; return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name;
} }
// ---- A tenant's switch: inbuxa:TenantProtocolPolicy (LP-9 to LP-14) ----
const TENANT_OBJECT = 'inbuxa:TenantProtocolPolicy';
export interface TenantPolicy {
/** The tenant's id, which is also the policy's. */
id: string;
legacyProtocols: 'enabled' | 'disabled';
/** Milliseconds since the epoch. */
changedAt: number | null;
/** The tenant's own people who used a legacy mail app lately (LP-15), or null from an older server. */
recentLegacyUse: RecentUse[] | null;
}
export function parseTenantPolicy(raw: Record<string, unknown>): TenantPolicy {
return {
id: typeof raw.id === 'string' ? raw.id : '',
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
};
}
/**
* A tenant's switch. With no id, the caller's own tenant's -- which is how a
* tenant administrator reads it; a server administrator names the tenant.
*/
export async function fetchTenantPolicy(tenantId: string | null, signal?: AbortSignal): Promise<TenantPolicy> {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest(
[[`${TENANT_OBJECT}/get`, { accountId, ids: tenantId ? [tenantId] : null }, '0']],
signal,
[INBUXA_CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${TENANT_OBJECT}/get`) {
const type = (result as { type?: string } | undefined)?.type;
if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type);
throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed');
}
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
// A tenant admin's /get with no ids holds exactly its own tenant's.
if (!list[0] || (!tenantId && list.length !== 1)) throw new PolicyUnavailable('notFound');
return parseTenantPolicy(list[0]);
}
/** Turns a tenant's switch. The server refuses turning it on while its own is off (LP-9). */
export async function updateTenantPolicy(tenantId: string, legacyProtocols: 'enabled' | 'disabled'): Promise<void> {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest(
[[`${TENANT_OBJECT}/set`, { accountId, update: { [tenantId]: { legacyProtocols } } }, '0']],
undefined,
[INBUXA_CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${TENANT_OBJECT}/set`) {
throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
}
const failed = (result as { notUpdated?: Record<string, JmapSetError> | null }).notUpdated?.[tenantId];
if (failed) throw new Error(failed.description ?? failed.type);
}