diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx index 403650a..28fb015 100644 --- a/src/features/hardening/LegacyProtocolsPage.tsx +++ b/src/features/hardening/LegacyProtocolsPage.tsx @@ -6,12 +6,14 @@ /** * INBUXA: Settings › Security › Hardening, the server-wide legacy mail - * protocols switch (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21). + * protocols switches (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21, and + * "one switch per protocol"). * - * Nobody should turn this on by accident or without understanding it, so the - * statement is shown in full before the switch moves, and turning it on takes - * a typed phrase. Turning it back on is one click: undoing a restriction must - * never be the hard part. + * Each of IMAP, POP3 and ManageSieve has a switch on its row: turning one off + * asks first, naming who used it lately and the ports that close. Turning + * them all off at once shows the statement in full and takes a typed phrase. + * Turning anything back on is one click: undoing a restriction must never be + * the hard part. */ import { useCallback, useEffect, useState } from 'react'; @@ -25,13 +27,19 @@ import { cn } from '@/lib/utils'; import { describeListener, fetchProtocolPolicy, + offProtocols, PolicyUnavailable, + PROTOCOL_LABELS, protocolRows, + SWITCHED, updateProtocolPolicy, + usersOf, type ProtocolPolicy, type ProtocolRow, + type SwitchedProtocol, + type SwitchUpdate, } from './protocolPolicy'; -import { ConfirmTurnOff, ImpactPanel, Statement } from './parts'; +import { ConfirmTurnOff, ImpactPanel, ProtocolSwitch, Statement } from './parts'; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; @@ -71,12 +79,12 @@ export function LegacyProtocolsPage() { }, [loaded]); const turn = useCallback( - async (legacyProtocols: 'enabled' | 'disabled') => { + async (update: SwitchUpdate) => { setBusy(true); try { - // Only legacyProtocols is sent. The server may still report closeSubmission - // overruled by the SMTP lock (LP-21), which the selector already shows. - await updateProtocolPolicy({ legacyProtocols }); + // Only switches are sent. The server may still report closeSubmission + // overruled by the SMTP lock (LP-21), which the table already shows. + await updateProtocolPolicy(update); setConfirming(false); await refresh(); } catch (e) { @@ -102,11 +110,17 @@ export function LegacyProtocolsPage() { } const { policy } = load; + // All three off: the kill-all's state. const off = policy.legacyProtocols === 'disabled'; - // What closes: what already did while the switch is off, what would otherwise. + // What closes: what already did while all are off, what would otherwise. const listeners = off ? policy.savedListeners : policy.wouldClose; - // Enabled with listeners still saved: some could not be put back (LP-5). - const stranded = off ? [] : policy.savedListeners; + // A protocol that is on with listeners still saved: some could not be put + // back (LP-5). Trying again turns those protocols on again. + const strandedOn = new Set( + SWITCHED.filter((p) => policy.switches[p] === 'enabled' && policy.savedListeners.some((l) => l.protocol === p)), + ); + const stranded = policy.savedListeners.filter((l) => strandedOn.has(l.protocol as SwitchedProtocol)); + const retry: SwitchUpdate = Object.fromEntries([...strandedOn].map((p) => [p, 'enabled'])); return (
@@ -115,12 +129,17 @@ export function LegacyProtocolsPage() {

{t( 'legacyProtocols.subtitle', - 'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.', + 'Turn off IMAP, POP3 or ManageSieve one at a time, or all of them with sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.', )}

- turn('enabled')} /> + void turn({ legacyProtocols: 'enabled' })} + /> {stranded.length > 0 && (
@@ -138,7 +157,7 @@ export function LegacyProtocolsPage() { {stranded.map(describeListener).join(', ')}

{canUpdate && ( - @@ -148,7 +167,13 @@ export function LegacyProtocolsPage() {
)} - + void turn({ [protocol]: value })} + /> {!off && policy.recentLegacyUse && } @@ -157,14 +182,14 @@ export function LegacyProtocolsPage() { {!off && canUpdate && !confirming && ( )} {!off && confirming && ( void turn('disabled')} + onConfirm={() => void turn({ legacyProtocols: 'disabled' })} onCancel={() => { setConfirming(false); }} @@ -174,21 +199,29 @@ export function LegacyProtocolsPage() { ); } +/** Protocol names as a sentence: "IMAP", "IMAP and POP3", "IMAP, POP3 and ManageSieve". */ +function listNames(protocols: SwitchedProtocol[], and: string): string { + const names = protocols.map((p) => PROTOCOL_LABELS[p] ?? p); + return names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} ${and} ${names[names.length - 1]}`; +} + function StatusCard({ policy, - off, busy, canUpdate, onTurnOn, }: { policy: ProtocolPolicy; - off: boolean; busy: boolean; canUpdate: boolean; onTurnOn: () => void; }) { const { t } = useTranslation(); + const offList = offProtocols(policy.switches); + const off = policy.legacyProtocols === 'disabled'; + const some = offList.length > 0 && !off; const Icon = off ? ShieldCheck : ShieldOff; + const and = t('legacyProtocols.and', 'and'); return (
{off ? t('legacyProtocols.statusOff', 'Legacy mail protocols are off on this server.') - : t('legacyProtocols.statusOn', 'Legacy mail protocols are on.')} + : some + ? t('legacyProtocols.statusSome', { + count: offList.length, + list: listNames(offList, and), + defaultValue_one: '{{list}} is off on this server.', + defaultValue_other: '{{list}} are off on this server.', + }) + : t('legacyProtocols.statusOn', 'Legacy mail protocols are on.')}

{off ? t('legacyProtocols.statusOffBody', 'Only inbuxa webmail and JMAP apps can sign in.') - : t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')} + : some + ? t('legacyProtocols.statusSomeBody', 'Mail apps can still use {{list}}, and send.', { + list: listNames( + SWITCHED.filter((p) => !offList.includes(p)), + and, + ), + }) + : t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')} {policy.changedAt !== null && ( <> {' '} @@ -219,64 +266,126 @@ function StatusCard({

- {off && canUpdate && ( + {offList.length > 0 && canUpdate && ( )} ); } -function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) { +function ProtocolTable({ + policy, + rows, + busy, + canUpdate, + onTurn, +}: { + policy: ProtocolPolicy; + rows: ProtocolRow[]; + busy: boolean; + canUpdate: boolean; + onTurn: (protocol: SwitchedProtocol, value: 'enabled' | 'disabled') => void; +}) { const { t } = useTranslation(); + const allOff = policy.legacyProtocols === 'disabled'; + const recent = policy.recentLegacyUse; + const switched = (key: string): key is SwitchedProtocol => (SWITCHED as readonly string[]).includes(key); const stateText = (row: ProtocolRow) => { switch (row.state) { case 'locked': - return t('legacyProtocols.rowLocked', 'Locked open'); + return row.key === 'submission' + ? allOff + ? t('legacyProtocols.rowRefused', 'Port open, sign-in refused') + : t('legacyProtocols.rowSubmission', 'On; sign-in refused only with all three off') + : t('legacyProtocols.rowLocked', 'Locked open'); case 'refused': return t('legacyProtocols.rowRefused', 'Port open, sign-in refused'); case 'closes': - if (row.ports.length === 0) return t('legacyProtocols.rowNoListener', 'No listener'); - return off ? t('legacyProtocols.rowClosed', 'Closed') : t('legacyProtocols.rowWouldClose', 'Closes'); + if (row.off) { + return row.ports.length > 0 + ? t('legacyProtocols.rowClosed', 'Off, ports closed') + : t('legacyProtocols.rowOff', 'Off'); + } + return row.ports.length === 0 + ? t('legacyProtocols.rowNoListener', 'On, no listener') + : t('legacyProtocols.rowOn', 'On'); } }; return ( -
+
- + + + - {rows.map((row) => ( - - - - - - ))} + {rows.map((row) => { + const users = (switched(row.key) || row.key === 'submission') && recent ? usersOf(recent, row.key) : null; + return ( + + + + + + + + ); + })}
{t('legacyProtocols.colProtocol', 'Protocol')} {t('legacyProtocols.colPorts', 'Ports')} - {off ? t('legacyProtocols.colNow', 'Now') : t('legacyProtocols.colWhenOff', 'When turned off')} - {t('legacyProtocols.colUsed', 'Used lately')}{t('legacyProtocols.colNow', 'Now')}{t('legacyProtocols.colSwitch', 'Switch')}
{row.label} - {row.ports.length > 0 ? row.ports.join(', ') : '—'} - - - {row.state === 'locked' && } - {stateText(row)} - -
{row.label} + {row.ports.length > 0 ? row.ports.join(', ') : '—'} + + {users === null + ? '—' + : users.length === 0 + ? t('legacyProtocols.usedNobody', 'Nobody') + : t('legacyProtocols.usedCount', { + count: users.length, + defaultValue_one: '1 account', + defaultValue_other: '{{count}} accounts', + })} + + + {row.state === 'locked' && } + {stateText(row)} + + + {switched(row.key) && canUpdate && ( + onTurn(row.key as SwitchedProtocol, 'enabled')} + onTurnOff={() => onTurn(row.key as SwitchedProtocol, 'disabled')} + /> + )} +

{t( 'legacyProtocols.lockNote', 'Incoming mail (SMTP) and inbuxa webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.', + )}{' '} + {t( + 'legacyProtocols.submissionNote', + 'Sending from mail apps goes on while any of IMAP, POP3 or ManageSieve is on.', )}

diff --git a/src/features/hardening/TenantLegacyProtocols.tsx b/src/features/hardening/TenantLegacyProtocols.tsx index 14f122c..4d99d8e 100644 --- a/src/features/hardening/TenantLegacyProtocols.tsx +++ b/src/features/hardening/TenantLegacyProtocols.tsx @@ -5,14 +5,15 @@ */ /** - * INBUXA: one tenant's legacy mail protocols switch, on the tenant's page - * (legacy-protocols spec, LP-9 to LP-18 at tenant scope). + * INBUXA: one tenant's legacy mail protocols switches, on the tenant's page + * (legacy-protocols spec, LP-9 to LP-18 at tenant scope, and "one switch per + * protocol"). * - * 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. + * They close no port -- other tenants share them (LP-13) -- so nothing here + * names a listener or carries a firewall note. They refuse sign-in over each + * protocol on the tenant's domains. One protocol off asks first; all of them + * off takes the typed phrase; back on is one click, which the server refuses + * while it has that protocol off itself (LP-9), and says which. */ import { useCallback, useEffect, useState } from 'react'; @@ -23,14 +24,29 @@ 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'; +import { + fetchProtocolPolicy, + fetchTenantPolicy, + offProtocols, + PolicyUnavailable, + PROTOCOL_LABELS, + SWITCHED, + updateTenantPolicy, + usersOf, + type Switches, + type SwitchUpdate, + type TenantPolicy, +} from './protocolPolicy'; +import { ConfirmTurnOff, ImpactPanel, ProtocolSwitch, 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(null); + // The server's own switches, which a server administrator can read: a + // protocol off there is off for this tenant whatever its switch says. + const [server, setServer] = useState(null); const [organization, setOrganization] = useState(''); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); @@ -48,10 +64,21 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { [], ); + const loadServer = useCallback((signal?: AbortSignal) => { + fetchProtocolPolicy(signal) + .then((p) => { + if (!signal?.aborted) setServer(p.switches); + }) + // Inside a tenant the server's switches aren't readable; the server + // still refuses what they forbid, and says so. + .catch(() => {}); + }, []); + useEffect(() => { if (!canGet) return; const controller = new AbortController(); void loaded(fetchTenantPolicy(tenantId, controller.signal), controller.signal); + loadServer(controller.signal); // The organization's name, for the statement. jmapGet('x:Tenant', getAccountId('x:Tenant'), [tenantId], ['name'], controller.signal) .then((responses) => { @@ -60,13 +87,13 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { }) .catch(() => {}); return () => controller.abort(); - }, [tenantId, canGet, loaded]); + }, [tenantId, canGet, loaded, loadServer]); const turn = useCallback( - async (value: 'enabled' | 'disabled') => { + async (update: SwitchUpdate) => { setBusy(true); try { - await updateTenantPolicy(tenantId, value); + await updateTenantPolicy(tenantId, update); setConfirming(false); await loaded(fetchTenantPolicy(tenantId)); } catch (e) { @@ -84,7 +111,9 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { if (!policy) return null; const off = policy.legacyProtocols === 'disabled'; + const offList = offProtocols(policy.switches); const name = organization || t('legacyProtocols.thisOrganization', 'this organization'); + const scope = t('legacyProtocols.scopeTenant', 'everyone in {{organization}}', { organization: name }); return ( // Aligned with the tenant form beneath it. @@ -105,32 +134,91 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { '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 }, - )} + : offList.length > 0 + ? t( + 'legacyProtocols.tenantSome', + 'Some are off for {{organization}}. Mail apps can still send, and use what is on.', + { organization: name }, + ) + : t( + 'legacyProtocols.tenantOn', + 'On for {{organization}}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.', + { organization: name }, + )}

- {canUpdate && off && ( - )} {canUpdate && !off && !confirming && ( )} + + {!off && confirming && ( <> {policy.recentLegacyUse && } - void turn('disabled')} onCancel={() => setConfirming(false)} /> + void turn({ legacyProtocols: 'disabled' })} + onCancel={() => setConfirming(false)} + /> )} diff --git a/src/features/hardening/parts.tsx b/src/features/hardening/parts.tsx index 4a9d373..b471016 100644 --- a/src/features/hardening/parts.tsx +++ b/src/features/hardening/parts.tsx @@ -5,9 +5,10 @@ */ /** - * 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). + * INBUXA: the parts the server's switches (Settings › Security › Hardening) + * and a tenant's switches (each tenant's page) share: one protocol's switch + * with its confirmation, the impact panel (LP-15), the statement (LP-16) and + * the typed confirmation for the kill-all (LP-17). */ import { useState } from 'react'; @@ -16,19 +17,156 @@ import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { ago, CONFIRM_PHRASE, describeListener, impactEntries, phraseMatches, + type ImpactEntry, type PolicyListener, type RecentUse, } from './protocolPolicy'; +/** How many names the one-protocol confirmation lists before "and N more". */ +const NAMES_SHOWN = 8; + /** - * The typed confirmation to turn legacy protocols off (LP-17): the button - * stays disabled until the phrase matches exactly. + * One protocol's switch. Turning it on is one click; turning it off asks + * first, naming who used it lately and, at server scope, the ports that + * close. Turning off one protocol takes no typed phrase: that is kept for + * turning them all off at once (LP-17). + */ +export function ProtocolSwitch({ + label, + off, + disabled, + users, + ports, + scope, + onTurnOn, + onTurnOff, +}: { + label: string; + off: boolean; + disabled: boolean; + /** Who used it lately, or null when the server doesn't say. */ + users: ImpactEntry[] | null; + /** The ports that close, at server scope; null at tenant scope, where none do (LP-13). */ + ports: number[] | null; + /** Who it's turned off for: "everyone on this server", "everyone in Northwind". */ + scope: string; + onTurnOn: () => void; + onTurnOff: () => void; +}) { + const { t, i18n } = useTranslation(); + const [asking, setAsking] = useState(false); + const [now] = useState(() => Date.now()); + return ( + <> + (on ? onTurnOn() : setAsking(true))} + /> + + + + + {t('legacyProtocols.oneTitle', 'Turn off {{protocol}}?', { protocol: label })} + + +
+

+ {t( + 'legacyProtocols.oneLead', + 'Mail apps using {{protocol}} will stop working for {{scope}}. The other protocols, sending from mail apps, and inbuxa webmail are not affected.', + { protocol: label, scope }, + )} +

+ {users && users.length === 0 && ( +

+ {t('legacyProtocols.oneNobody', 'No account used {{protocol}} in the last 30 days.', { + protocol: label, + })} +

+ )} + {users && users.length > 0 && ( +
+

+ {t('legacyProtocols.oneUsers', { + count: users.length, + protocol: label, + defaultValue_one: '1 account used {{protocol}} in the last 30 days:', + defaultValue_other: '{{count}} accounts used {{protocol}} in the last 30 days:', + })} +

+
    + {users.slice(0, NAMES_SHOWN).map((user) => ( +
  • + {user.name}{' '} + · {ago(user.lastUsedAt, now, i18n.language)} +
  • + ))} +
+ {users.length > NAMES_SHOWN && ( +

+ {t('legacyProtocols.oneMore', 'and {{count}} more', { count: users.length - NAMES_SHOWN })} +

+ )} +
+ )} + {ports !== null && ( +

+ {ports.length > 0 + ? t( + 'legacyProtocols.onePorts', + 'Its ports close: {{ports}}. Your firewall and port forwarding are not changed.', + { + ports: ports.join(', '), + }, + ) + : t('legacyProtocols.oneNoPorts', 'No {{protocol}} listener is configured, so no port closes.', { + protocol: label, + })} +

+ )} +

{t('legacyProtocols.oneUndo', 'You can turn it back on at any time.')}

+
+
+
+ + {t('common.cancel', 'Cancel')} + + +
+
+ + ); +} + +/** + * The typed confirmation to turn all legacy protocols off at once (LP-17): + * the button stays disabled until the phrase matches exactly. */ export function ConfirmTurnOff({ busy, @@ -63,7 +201,7 @@ export function ConfirmTurnOff({