Each tenant's page carries its legacy protocols switch (LP-9 to LP-18)
A card above the tenant form: whether legacy mail protocols are on or off
for the organization, a one-click "Turn legacy protocols back on", and
"Turn off legacy protocols…", which opens -- before anything can change --
the impact panel with the tenant's own people (LP-15), the statement at
tenant scope and the typed confirmation (LP-16, LP-17). The statement reads
"everyone in {organization}" and names no ports and no firewall: a
tenant's switch closes nothing, other tenants share the ports (LP-13).
It reads and turns inbuxa:TenantProtocolPolicy, with the domain
permissions the server checks for it. It shows on the read-only tenant page
too: a tenant administrator reads its tenant without changing it (MT-12)
and may still turn the switch. Turning it back on while the server has
legacy protocols off is refused by the server, and the refusal is shown.
The banner (LP-18) falls back to the tenant's switch where the server's
can't be read -- inside a tenant -- and reads "off for your
organization".
The impact panel, statement and confirmation move to parts.tsx, shared by
Hardening and the tenant card; the statement takes its scope.
Checked by hand against a local server with a tenant, two of its users and
the admin signed in over IMAP: the tenant card's panel lists the tenant's
two users and not the admin; the statement reads "everyone in Example Co"
with no ports or firewall; turning it off makes the tenant's user get "NO
[ALERT] Your organization allows only INBUXA webmail and JMAP apps" over
IMAP; one click turns it back on; Hardening's panel lists all three. Not
checked by hand: the banner as a tenant administrator sees it.
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. That’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. That’s 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user