diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index ac394af..642eefb 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -16,6 +16,7 @@ import { DynamicList } from '@/components/lists/DynamicList'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; import type { Schema } from '@/types/schema'; function lazyFeature(load: () => Promise, select: (module: M) => ComponentType

) { @@ -46,6 +47,10 @@ const ActionPage = lazyFeature( () => import('@/features/actions/ActionPage'), (m) => m.ActionPage, ); +const LegacyProtocolsPage = lazyFeature( + () => import('@/features/hardening/LegacyProtocolsPage'), + (m) => m.LegacyProtocolsPage, +); interface MainContentProps { viewName?: string; @@ -99,6 +104,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LiveTracing') { return ; } + // INBUXA: Settings › Security › Hardening (legacy-protocols spec). + if (componentName === 'LegacyProtocols') { + return ; + } return (

Unknown component: {componentName} @@ -120,6 +129,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti } if (resolved.objectType.type === 'singleton') { + // INBUXA: the Security settings carry the legacy protocols banner (LP-18). + if (resolved.objectName === 'x:Security') { + return ( +
+ + +
+ ); + } return ; } diff --git a/src/features/dashboard/components/DashboardView.tsx b/src/features/dashboard/components/DashboardView.tsx index 1fac3fb..d3bef86 100644 --- a/src/features/dashboard/components/DashboardView.tsx +++ b/src/features/dashboard/components/DashboardView.tsx @@ -15,6 +15,7 @@ import { AlertCircle } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSchemaStore } from '@/stores/schemaStore'; import type { Dashboard } from '../types/schema'; +import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; import { useDashboardStore } from '../stores/dashboardStore'; import { useLiveMetricsStore } from '../stores/liveMetricsStore'; import { useHistoryMetricsStore } from '../stores/historyMetricsStore'; @@ -123,6 +124,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
+
{dashboards.length > 1 && ( navigate(`/${section}/Dashboard/${id}`)}> diff --git a/src/features/hardening/LegacyProtocolsBanner.tsx b/src/features/hardening/LegacyProtocolsBanner.tsx new file mode 100644 index 0000000..ce1a3bf --- /dev/null +++ b/src/features/hardening/LegacyProtocolsBanner.tsx @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: the banner while legacy mail protocols are off (LP-18), on the + * Security settings and the dashboard. It says nothing when the switch is on, + * when the reader may not see the policy, or when the server has no policy. + */ + +import { useEffect, useState } from 'react'; +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'; + +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); + + useEffect(() => { + if (!canGet) return; + const controller = new AbortController(); + fetchProtocolPolicy(controller.signal) + .then((policy) => setOff(policy.legacyProtocols === 'disabled')) + // A banner is not worth an error: an older server simply has no switch. + .catch(() => setOff(false)); + return () => controller.abort(); + }, [canGet]); + + if (!off) return null; + + return ( +
+ + + {t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '} + {t('legacyProtocols.bannerOff', 'off')}{' '} + {t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')} + + + {t('legacyProtocols.review', 'Review')} + +
+ ); +} diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx new file mode 100644 index 0000000..7b3add4 --- /dev/null +++ b/src/features/hardening/LegacyProtocolsPage.tsx @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Settings › Security › Hardening, the server-wide legacy mail + * protocols switch (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21). + * + * 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. + */ + +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 { + CONFIRM_PHRASE, + describeListener, + fetchProtocolPolicy, + phraseMatches, + PolicyUnavailable, + protocolRows, + updateProtocolPolicy, + type PolicyListener, + type ProtocolPolicy, + type ProtocolRow, +} from './protocolPolicy'; + +type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; + +export function LegacyProtocolsPage() { + const { t } = useTranslation(); + const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Update')); + const [load, setLoad] = useState({ kind: 'loading' }); + const [confirming, setConfirming] = useState(false); + const [typed, setTyped] = useState(''); + const [busy, setBusy] = useState(false); + + const loaded = useCallback( + (fetching: Promise, signal?: AbortSignal) => + fetching + .then((policy) => { + if (!signal?.aborted) setLoad({ kind: 'ready', policy }); + }) + .catch((e: unknown) => { + if (signal?.aborted) return; + setLoad({ + kind: 'error', + message: + e instanceof PolicyUnavailable + ? t('legacyProtocols.unavailable', 'This server does not offer the legacy protocols switch.') + : e instanceof Error + ? e.message + : String(e), + }); + }), + [t], + ); + const refresh = useCallback(() => loaded(fetchProtocolPolicy()), [loaded]); + + useEffect(() => { + const controller = new AbortController(); + void loaded(fetchProtocolPolicy(controller.signal), controller.signal); + return () => controller.abort(); + }, [loaded]); + + const turn = useCallback( + async (legacyProtocols: 'enabled' | 'disabled') => { + 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 }); + setConfirming(false); + setTyped(''); + await refresh(); + } 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); + } + }, + [refresh, t], + ); + + if (load.kind === 'loading') return ; + if (load.kind === 'error') { + return ( +
+ {load.message} +
+ ); + } + + const { policy } = load; + const off = policy.legacyProtocols === 'disabled'; + // What closes: what already did while the switch is 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; + + return ( +
+
+

{t('legacyProtocols.title', 'Legacy mail protocols')}

+

+ {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('enabled')} /> + + {stranded.length > 0 && ( +
+
+ +
+

+ {t('legacyProtocols.strandedTitle', 'Some listeners could not be reopened')} +

+

+ {t( + 'legacyProtocols.strandedBody', + 'Their ports may be taken by something else, or need a restart to bind. They are kept, and can be tried again:', + )}{' '} + {stranded.map(describeListener).join(', ')} +

+ {canUpdate && ( + + )} +
+
+
+ )} + + + + {(off || confirming) && } + + {!off && canUpdate && !confirming && ( + + )} + + {!off && confirming && ( +
{ + e.preventDefault(); + if (phraseMatches(typed)) void turn('disabled'); + }} + > + + setTyped(e.target.value)} + /> +
+ + +
+
+ )} +
+ ); +} + +function StatusCard({ + policy, + off, + busy, + canUpdate, + onTurnOn, +}: { + policy: ProtocolPolicy; + off: boolean; + busy: boolean; + canUpdate: boolean; + onTurnOn: () => void; +}) { + const { t } = useTranslation(); + const Icon = off ? ShieldCheck : ShieldOff; + return ( +
+
+ +
+

+ {off + ? t('legacyProtocols.statusOff', 'Legacy mail protocols 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.')} + {policy.changedAt !== null && ( + <> + {' '} + {t('legacyProtocols.changedAt', 'Last changed {{when}}.', { + when: new Date(policy.changedAt).toLocaleString(), + })} + + )} +

+
+
+ {off && canUpdate && ( + + )} +
+ ); +} + +function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) { + const { t } = useTranslation(); + const stateText = (row: ProtocolRow) => { + switch (row.state) { + case 'locked': + return 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'); + } + }; + return ( +
+ + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
{t('legacyProtocols.colProtocol', 'Protocol')}{t('legacyProtocols.colPorts', 'Ports')} + {off ? t('legacyProtocols.colNow', 'Now') : t('legacyProtocols.colWhenOff', 'When turned off')} +
{row.label} + {row.ports.length > 0 ? row.ports.join(', ') : '—'} + + + {row.state === 'locked' && } + {stateText(row)} + +
+

+ {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.', + )} +

+
+ ); +} + +/** The statement (LP-16), at server scope, with the firewall note (LP-20). */ +function Statement({ listeners }: { listeners: PolicyListener[] }) { + const { t } = useTranslation(); + return ( +
+

+ {t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')} +

+

+ {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.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.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementFilters', + 'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementUnaffected', + 'Incoming mail is not affected. Calendars and contacts are not affected.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementWebmail', + 'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.', + )} +
  • +
+

+ {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.', + )} +

+

+ {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.', + )} +

+

+ {t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}{' '} + {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.', + )} +

+

{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}

+
+ ); +} diff --git a/src/features/hardening/protocolPolicy.test.ts b/src/features/hardening/protocolPolicy.test.ts new file mode 100644 index 0000000..46ecfc0 --- /dev/null +++ b/src/features/hardening/protocolPolicy.test.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { CONFIRM_PHRASE, parsePolicy, phraseMatches, protocolRows, type ProtocolPolicy } from './protocolPolicy'; + +// As inbuxa:ProtocolPolicy/get sends it: listeners keyed by the policy's own property names. +const WIRE = { + id: 'singleton', + legacyProtocols: 'enabled', + closeSubmission: false, + savedListeners: [], + changedAt: null, + changedBy: null, + lockedProtocols: ['smtp', 'lmtp', 'http'], + wouldClose: [ + { id: 'imaptls', legacyProtocols: 'imap', wouldClose: [993] }, + { id: 'imap', legacyProtocols: 'imap', wouldClose: [143] }, + { id: 'sieve', legacyProtocols: 'manageSieve', wouldClose: [4190] }, + ], +}; + +function policy(overrides: Partial = {}): ProtocolPolicy { + return { ...parsePolicy(WIRE), ...overrides }; +} + +describe('parsePolicy', () => { + it('reads listeners back into names, protocols and ports', () => { + const p = parsePolicy(WIRE); + expect(p.wouldClose[0]).toEqual({ name: 'imaptls', protocol: 'imap', ports: [993] }); + expect(p.lockedProtocols).toEqual(['smtp', 'lmtp', 'http']); + expect(p.legacyProtocols).toBe('enabled'); + }); + + it('treats anything but "disabled" as enabled, and drops malformed listeners', () => { + const p = parsePolicy({ legacyProtocols: 'maybe', wouldClose: [null, { legacyProtocols: 'imap' }] }); + expect(p.legacyProtocols).toBe('enabled'); + expect(p.wouldClose).toEqual([]); + expect(p.changedAt).toBeNull(); + }); +}); + +describe('protocolRows (LP-21)', () => { + it('lists every mail protocol, with SMTP and JMAP locked', () => { + const rows = protocolRows(policy(), policy().wouldClose); + expect(rows.map((r) => r.key)).toEqual(['imap', 'pop3', 'manageSieve', 'submission', 'smtp', 'jmap']); + expect(rows.find((r) => r.key === 'smtp')?.state).toBe('locked'); + expect(rows.find((r) => r.key === 'jmap')?.state).toBe('locked'); + }); + + it('gathers each protocol’s ports, sorted and without repeats', () => { + const rows = protocolRows(policy(), policy().wouldClose); + expect(rows.find((r) => r.key === 'imap')?.ports).toEqual([143, 993]); + expect(rows.find((r) => r.key === 'pop3')?.ports).toEqual([]); + expect(rows.find((r) => r.key === 'manageSieve')?.ports).toEqual([4190]); + }); + + it('keeps submission locked while the server locks SMTP, whatever closeSubmission says', () => { + const rows = protocolRows(policy({ closeSubmission: true }), []); + expect(rows.find((r) => r.key === 'submission')?.state).toBe('locked'); + }); + + it('follows closeSubmission once the server unlocks SMTP, with no admin change', () => { + const unlocked = policy({ lockedProtocols: ['lmtp', 'http'] }); + const listeners = [{ name: 'submissions', protocol: 'smtp', ports: [465] }]; + const closing = protocolRows({ ...unlocked, closeSubmission: true }, listeners); + expect(closing.find((r) => r.key === 'submission')).toMatchObject({ state: 'closes', ports: [465] }); + const keeping = protocolRows({ ...unlocked, closeSubmission: false }, listeners); + expect(keeping.find((r) => r.key === 'submission')?.state).toBe('refused'); + }); +}); + +describe('phraseMatches (LP-17)', () => { + it('accepts only the exact phrase', () => { + expect(phraseMatches(CONFIRM_PHRASE)).toBe(true); + expect(phraseMatches('Turn off legacy mail')).toBe(false); + expect(phraseMatches(' turn off legacy mail')).toBe(false); + expect(phraseMatches('turn off legacy')).toBe(false); + expect(phraseMatches('')).toBe(false); + }); +}); diff --git a/src/features/hardening/protocolPolicy.ts b/src/features/hardening/protocolPolicy.ts new file mode 100644 index 0000000..7f76e19 --- /dev/null +++ b/src/features/hardening/protocolPolicy.ts @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch + * (legacy-protocols spec). This module is the wire and the rules; the screen + * and the banner draw from it. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import type { JmapSetError } from '@/types/jmap'; + +export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap'; +const OBJECT = 'inbuxa:ProtocolPolicy'; + +/** The phrase that turns legacy protocols off (LP-17). Turning them back on needs none. */ +export const CONFIRM_PHRASE = 'turn off legacy mail'; + +/** A listener the switch closed, or would close, by name and port (LP-16). */ +export interface PolicyListener { + name: string; + protocol: string; + ports: number[]; +} + +export interface ProtocolPolicy { + legacyProtocols: 'enabled' | 'disabled'; + closeSubmission: boolean; + /** Listeners taken away and not yet put back. Non-empty while enabled means some failed to reopen (LP-5). */ + savedListeners: PolicyListener[]; + /** Milliseconds since the epoch. */ + changedAt: number | null; + changedBy: string | null; + /** Registry protocols the switch may never close (LP-21), as the server says. */ + lockedProtocols: string[]; + /** What turning the switch off would close, whichever way it is set now (LP-16). */ + wouldClose: PolicyListener[]; +} + +/** + * The server sends each listener as an object keyed by the policy's own + * property names: `id` is the listener's name, `legacyProtocols` its protocol + * and `wouldClose` its ports. Read them back into something that says so. + */ +function parseListener(raw: unknown): PolicyListener | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (typeof r.id !== 'string') return null; + return { + name: r.id, + protocol: typeof r.legacyProtocols === 'string' ? r.legacyProtocols : '', + ports: Array.isArray(r.wouldClose) ? r.wouldClose.filter((p): p is number => typeof p === 'number') : [], + }; +} + +function parseListeners(raw: unknown): PolicyListener[] { + return Array.isArray(raw) ? raw.map(parseListener).filter((l): l is PolicyListener => l !== null) : []; +} + +export function parsePolicy(raw: Record): ProtocolPolicy { + return { + legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled', + closeSubmission: raw.closeSubmission === true, + savedListeners: parseListeners(raw.savedListeners), + changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null, + changedBy: typeof raw.changedBy === 'string' ? raw.changedBy : null, + lockedProtocols: Array.isArray(raw.lockedProtocols) + ? raw.lockedProtocols.filter((p): p is string => typeof p === 'string') + : [], + wouldClose: parseListeners(raw.wouldClose), + }; +} + +/** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */ +export class PolicyUnavailable extends Error {} + +export async function fetchProtocolPolicy(signal?: AbortSignal): Promise { + const accountId = getAccountId('x:NetworkListener'); + const responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/get`) { + const type = (result as { type?: string } | undefined)?.type; + if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type); + throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed'); + } + const list = (result as { list?: Record[] }).list ?? []; + if (!list[0]) throw new PolicyUnavailable('notFound'); + return parsePolicy(list[0]); +} + +export interface SetOutcome { + /** What the server stored differently from what was asked (LP-21), or nothing. */ + overruled: Record | null; +} + +export async function updateProtocolPolicy( + update: Partial>, +): Promise { + const accountId = getAccountId('x:NetworkListener'); + const responses = await jmapRequest( + [[`${OBJECT}/set`, { accountId, update: { singleton: update } }, '0']], + undefined, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/set`) { + throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed'); + } + const r = result as { + updated?: Record | null> | null; + notUpdated?: Record | null; + }; + const failed = r.notUpdated?.singleton; + if (failed) throw new Error(failed.description ?? failed.type); + const stored = r.updated?.singleton; + return { overruled: stored && Object.keys(stored).length > 0 ? stored : null }; +} + +/** How a protocol stands under the switch, for the selector (LP-21). */ +export type RowState = 'closes' | 'refused' | 'locked'; + +export interface ProtocolRow { + key: string; + label: string; + state: RowState; + /** Ports that close with the switch; empty when nothing is listening or the row doesn't close. */ + ports: number[]; +} + +function portsOf(listeners: PolicyListener[], protocol: string): number[] { + const ports = listeners.filter((l) => l.protocol === protocol).flatMap((l) => l.ports); + return [...new Set(ports)].sort((a, b) => a - b); +} + +/** + * Every mail protocol the server speaks, in one place, with what the switch + * does to each. The locked set comes from the server, so unlocking later is + * a server change and no admin release (LP-21). + * + * `listeners` is what the switch closes: `wouldClose` while it's on, or + * `savedListeners` once it's off. + */ +export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]): ProtocolRow[] { + const locked = new Set(policy.lockedProtocols.map((p) => p.toLowerCase())); + const legacy: ProtocolRow[] = [ + { key: 'imap', label: 'IMAP', state: 'closes', ports: portsOf(listeners, 'imap') }, + { key: 'pop3', label: 'POP3', state: 'closes', ports: portsOf(listeners, 'pop3') }, + { key: 'manageSieve', label: 'ManageSieve', state: 'closes', ports: portsOf(listeners, 'manageSieve') }, + ]; + const smtpLocked = locked.has('smtp'); + return [ + ...legacy, + { + key: 'submission', + label: 'SMTP submission', + // Locked submission keeps its ports; sign-in over them is refused instead. + state: smtpLocked ? 'locked' : policy.closeSubmission ? 'closes' : 'refused', + ports: smtpLocked ? [] : portsOf(listeners, 'smtp'), + }, + // Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever"). + { key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] }, + { key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] }, + ]; +} + +/** Whether the typed confirmation matches (LP-17). Exact: no trimming, no case folding. */ +export function phraseMatches(typed: string): boolean { + return typed === CONFIRM_PHRASE; +} + +export function describeListener(l: PolicyListener): string { + return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name; +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 9c8fee6..776daa2 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -65,6 +65,12 @@ function checkSpecialLink( return { visible: allowed, enterprise: true }; } + // INBUXA: the legacy protocols switch takes listeners away and puts them back, + // so whoever may see a listener may see it (legacy-protocols spec). + if (viewName === 'CustomComponent/LegacyProtocols') { + return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false }; + } + if (viewName.startsWith('CustomComponent/')) { return { visible: true, enterprise: false }; }