Each tenant's page carries its legacy protocols switch #5
@@ -51,6 +51,10 @@ const LegacyProtocolsPage = lazyFeature(
|
||||
() => import('@/features/hardening/LegacyProtocolsPage'),
|
||||
(m) => m.LegacyProtocolsPage,
|
||||
);
|
||||
const TenantLegacyProtocols = lazyFeature(
|
||||
() => import('@/features/hardening/TenantLegacyProtocols'),
|
||||
(m) => m.TenantLegacyProtocols,
|
||||
);
|
||||
|
||||
interface MainContentProps {
|
||||
viewName?: string;
|
||||
@@ -150,10 +154,23 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
return <TraceDetailView viewName={viewName} objectId={id} />;
|
||||
}
|
||||
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
|
||||
if (!canUpdate) {
|
||||
return <DynamicViewPage viewName={viewName} objectId={id} />;
|
||||
const page = canUpdate ? (
|
||||
<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} />;
|
||||
|
||||
@@ -15,24 +15,43 @@ import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { fetchProtocolPolicy } from './protocolPolicy';
|
||||
import { fetchProtocolPolicy, fetchTenantPolicy } from './protocolPolicy';
|
||||
|
||||
export const LEGACY_PROTOCOLS_VIEW = 'CustomComponent/LegacyProtocols';
|
||||
|
||||
export function LegacyProtocolsBanner() {
|
||||
const { t } = useTranslation();
|
||||
const canGet = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get'));
|
||||
const [off, setOff] = useState(false);
|
||||
const canGetServer = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get'));
|
||||
const canGetTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
|
||||
const [off, setOff] = useState<null | 'server' | 'tenant'>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canGet) return;
|
||||
if (!canGetServer && !canGetTenant) return;
|
||||
const controller = new AbortController();
|
||||
fetchProtocolPolicy(controller.signal)
|
||||
.then((policy) => setOff(policy.legacyProtocols === 'disabled'))
|
||||
const signal = controller.signal;
|
||||
(async () => {
|
||||
// The server's switch first. Inside a tenant it can't be read, and the
|
||||
// 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.
|
||||
.catch(() => setOff(false));
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [canGet]);
|
||||
}, [canGetServer, canGetTenant]);
|
||||
|
||||
if (!off) return null;
|
||||
|
||||
@@ -42,11 +61,18 @@ export function LegacyProtocolsBanner() {
|
||||
<span>
|
||||
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
|
||||
<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>
|
||||
{off === 'server' && (
|
||||
<Link to={`/Settings/${LEGACY_PROTOCOLS_VIEW}`} className="font-medium text-primary hover:underline">
|
||||
{t('legacyProtocols.review', 'Review')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,27 +18,20 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Loader2, Lock, RotateCcw, ShieldCheck, ShieldOff } from 'lucide-react';
|
||||
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 { useAccountStore } from '@/stores/accountStore';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ago,
|
||||
CONFIRM_PHRASE,
|
||||
describeListener,
|
||||
impactEntries,
|
||||
fetchProtocolPolicy,
|
||||
phraseMatches,
|
||||
PolicyUnavailable,
|
||||
protocolRows,
|
||||
updateProtocolPolicy,
|
||||
type PolicyListener,
|
||||
type ProtocolPolicy,
|
||||
type ProtocolRow,
|
||||
type RecentUse,
|
||||
} from './protocolPolicy';
|
||||
import { ConfirmTurnOff, ImpactPanel, Statement } from './parts';
|
||||
|
||||
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 [load, setLoad] = useState<Load>({ kind: 'loading' });
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [typed, setTyped] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loaded = useCallback(
|
||||
@@ -86,7 +78,6 @@ export function LegacyProtocolsPage() {
|
||||
// overruled by the SMTP lock (LP-21), which the selector already shows.
|
||||
await updateProtocolPolicy({ legacyProtocols });
|
||||
setConfirming(false);
|
||||
setTyped('');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
@@ -161,7 +152,7 @@ export function LegacyProtocolsPage() {
|
||||
|
||||
{!off && policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
|
||||
|
||||
{(off || confirming) && <Statement listeners={listeners} />}
|
||||
{(off || confirming) && <Statement scope={{ kind: 'server', listeners }} />}
|
||||
|
||||
{!off && canUpdate && !confirming && (
|
||||
<Button variant="destructive" onClick={() => setConfirming(true)}>
|
||||
@@ -171,42 +162,13 @@ export function LegacyProtocolsPage() {
|
||||
)}
|
||||
|
||||
{!off && confirming && (
|
||||
<form
|
||||
className="space-y-3 rounded-xl border p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (phraseMatches(typed)) void turn('disabled');
|
||||
}}
|
||||
>
|
||||
<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={() => {
|
||||
<ConfirmTurnOff
|
||||
busy={busy}
|
||||
onConfirm={() => void turn('disabled')}
|
||||
onCancel={() => {
|
||||
setConfirming(false);
|
||||
setTyped('');
|
||||
}}
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -320,113 +282,3 @@ function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) {
|
||||
</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,
|
||||
impactEntries,
|
||||
parsePolicy,
|
||||
parseTenantPolicy,
|
||||
phraseMatches,
|
||||
protocolRows,
|
||||
type ProtocolPolicy,
|
||||
@@ -120,3 +121,18 @@ describe('the impact panel (LP-15)', () => {
|
||||
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 {
|
||||
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