diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index d162027..0fc793f 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -60,9 +60,9 @@ const ActionPage = lazyFeature( () => import('@/features/actions/ActionPage'), (m) => m.ActionPage, ); -const LegacyProtocolsPage = lazyFeature( - () => import('@/features/hardening/LegacyProtocolsPage'), - (m) => m.LegacyProtocolsPage, +const SecurityPage = lazyFeature( + () => import('@/features/security/SecurityPage'), + (m) => m.SecurityPage, ); // inbuxa: Management › Compliance › Overview and Data Inventory // (personal-data catalog spec, §8). @@ -211,8 +211,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti return ; } // INBUXA: Settings › Security › Hardening (legacy-protocols spec). + // inbuxa: the security to-do list, which holds the legacy-protocols switch if (componentName === 'LegacyProtocols') { - return ; + return ; } // inbuxa: Settings › Overview (settings-reorg). if (componentName === 'SettingsOverview') { diff --git a/src/features/dashboard/components/StatusLine.tsx b/src/features/dashboard/components/StatusLine.tsx index 8a4edda..4c3e4b0 100644 --- a/src/features/dashboard/components/StatusLine.tsx +++ b/src/features/dashboard/components/StatusLine.tsx @@ -11,6 +11,7 @@ import { CircleAlert } from 'lucide-react'; import { usePermissions } from '@/hooks/usePermissions'; import type { ServerFacts } from '../serverFacts'; import { hrefFor, type DashLink } from '../links'; +import { useCriticalCount } from '@/features/security/useCriticalCount'; interface Phrase { text: string; @@ -25,6 +26,8 @@ interface Phrase { export function StatusLine({ facts }: { facts: ServerFacts | null }) { const { t } = useTranslation(); const { canViewObject } = usePermissions(); + // inbuxa: SS-27, critical security items not accepted + const critical = useCriticalCount(); if (!facts) return null; const n = (key: string, count: number, one: string, other: string) => @@ -50,6 +53,17 @@ export function StatusLine({ facts }: { facts: ServerFacts | null }) { // Quiet when all is well: the line only appears when something needs a look. const visible = attention.filter((p) => canViewObject(p.link.viewName)); + // Already limited to those who may see the Security page + if (critical) + visible.push({ + text: n( + 'status.securityCritical', + critical, + '{{count}} critical security item', + '{{count}} critical security items', + ), + link: { viewName: 'CustomComponent/LegacyProtocols', section: 'Settings', label: '' }, + }); if (visible.length === 0) return null; const lead = n('status.needsLook', visible.length, 'One thing needs a look:', '{{count}} things need a look:'); diff --git a/src/features/ports/PortsTable.tsx b/src/features/ports/PortsTable.tsx index 07670d0..353a894 100644 --- a/src/features/ports/PortsTable.tsx +++ b/src/features/ports/PortsTable.tsx @@ -154,7 +154,7 @@ export function PortsTable({ onChanged }: { onChanged?: () => void }) { className="text-xs text-primary hover:underline" to={`/${hardeningSection}/CustomComponent/LegacyProtocols`} > - {t('ports.onHardening', 'Switched on Hardening')} + {t('ports.onHardening', 'Switched on the Security page')} ) : confirming === s.id ? ( diff --git a/src/features/security/SecurityPage.tsx b/src/features/security/SecurityPage.tsx new file mode 100644 index 0000000..d5a3ed5 --- /dev/null +++ b/src/features/security/SecurityPage.tsx @@ -0,0 +1,457 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Settings › Security › Security (security to-do list spec). What + * isn't as it should be, most serious first, each with one action: Fix (one + * field, undoable from the toast), Review (the setting, with why), or the + * legacy-protocols switch below. Any item can be accepted with a note, + * which every administrator then sees. No score, and no "all good" banner: + * when nothing is to do, the page says nothing about it. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, CircleSlash, Loader2, Wrench } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast, useToast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { ExplainButton } from '@/features/ai/explain/ExplainButton'; +import { LegacyProtocolsPage } from '@/features/hardening/LegacyProtocolsPage'; +import { HelpPanel } from '@/help/HelpPanel'; +import { evaluate, type Item, type Passed, type Severity } from './checks'; +import { loadSnapshot } from './load'; +import { + accept, + AcceptancesUnavailable, + fetchAcceptances, + removeAcceptance, + sortOut, + type Acceptance, + type Sorted, +} from './acceptances'; + +interface Loaded { + sorted: Sorted; + passed: Passed[]; + /** Null when the server can't keep acceptances: the page works without Accept. */ + acceptances: Acceptance[] | null; +} + +const SEVERITY_STYLE: Record = { + critical: 'bg-destructive/10 text-destructive border-destructive/30', + important: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/30', + good: 'bg-muted text-muted-foreground border-border', +}; + +async function writePatch(object: string, id: string, patch: Record) { + const responses = await jmapRequest([ + [`${object}/set`, { accountId: getAccountId(object), update: { [id]: patch } }, '0'], + ]); + const [name, body] = responses[0] ?? []; + if (name !== `${object}/set`) { + throw new Error((body as { description?: string } | undefined)?.description ?? 'Request failed'); + } + const failed = (body as { notUpdated?: Record }).notUpdated?.[id]; + if (failed) throw new Error(failed.description ?? failed.type); +} + +function scrollToHardening() { + document.getElementById('hardening')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); +} + +export function SecurityPage() { + const { t } = useTranslation(); + const { dismiss } = useToast(); + const schema = useSchemaStore((s) => s.schema); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const canAccept = useAccountStore((s) => s.hasPermission('sysSecurityAccept')); + const [loaded, setLoaded] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [accepting, setAccepting] = useState(null); + const [note, setNote] = useState(''); + const [showPassed, setShowPassed] = useState(false); + + const defaults = useCallback((object: string, field: string) => schema?.fields[object]?.defaults?.[field], [schema]); + + const load = useCallback(async () => { + const [snapshot, acceptances] = await Promise.all([ + loadSnapshot(), + fetchAcceptances().catch((e: unknown) => { + if (e instanceof AcceptancesUnavailable) return null; + throw e; + }), + ]); + const { items, passed } = evaluate(snapshot, defaults); + return { sorted: sortOut(items, acceptances ?? []), passed, acceptances }; + }, [defaults]); + + const refresh = useCallback(async () => { + try { + setLoaded(await load()); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [load]); + + useEffect(() => { + let live = true; + load() + .then((l) => live && setLoaded(l)) + .catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e))); + return () => { + live = false; + }; + }, [load]); + + const key = (item: Item) => `${item.check}|${item.subject}`; + const href = (viewName: string, id?: string) => + `/${viewToSection[viewName] ?? 'Settings'}/${viewName}${id ? `/${encodeURIComponent(id)}` : ''}`; + + const fix = async (item: Item) => { + if (item.action.kind !== 'fix') return; + const { object, id, patch, undo } = item.action; + setBusy(key(item)); + try { + await writePatch(object, id, patch); + toast({ + title: t('security.fixed', 'Fixed: {{what}}', { what: item.title }), + duration: 10_000, + description: ( + + ), + }); + await refresh(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('security.fixFailed', 'Not fixed'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(null); + } + }; + + const doAccept = async (item: Item, replaces?: Acceptance) => { + setBusy(key(item)); + try { + await accept(item, note.trim(), replaces); + setAccepting(null); + setNote(''); + await refresh(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('security.acceptFailed', 'Not accepted'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(null); + } + }; + + const unaccept = async (acceptance: Acceptance) => { + setBusy(acceptance.id); + try { + await removeAcceptance(acceptance); + await refresh(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('security.removeFailed', 'Not removed'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(null); + } + }; + + const counts = useMemo(() => { + const todo = loaded?.sorted.todo ?? []; + return { all: todo.length, critical: todo.filter((x) => x.item.severity === 'critical').length }; + }, [loaded]); + + const severityLabel: Record = { + critical: t('security.critical', 'Critical'), + important: t('security.important', 'Important'), + good: t('security.good', 'Good practice'), + }; + + const actions = (item: Item) => ( +
+ {item.action.kind === 'fix' && ( + + )} + {item.action.kind === 'review' && ( + + )} + {item.action.kind === 'hardening' && ( + + )} + {item.explain && ( + + )} + {loaded?.acceptances !== null && canAccept && accepting !== key(item) && ( + + )} +
+ ); + + if (error && !loaded) { + return ( +
+ {error} +
+ ); + } + if (!loaded) return ; + + const { sorted, passed } = loaded; + const notChecked = passed.filter((p) => p.notChecked); + + return ( +
+
+
+
+

{t('security.title', 'Security')}

+ +
+

+ {t( + 'security.subtitle', + 'Settings that leave this server more open than it needs to be, most serious first. Checked when this page opens.', + )} +

+
+ + {counts.all > 0 && ( +

+ {counts.critical > 0 + ? t('security.countsCritical', '{{all}} to do, {{critical}} of them critical', counts) + : t('security.counts', '{{all}} to do', counts)} +

+ )} + + {sorted.todo.length > 0 && ( +
    + {sorted.todo.map(({ item, stale }) => ( +
  • +
    + + {severityLabel[item.severity]} + +
    +

    {item.title}

    +

    {item.why}

    +
    +
    + + {item.action.kind === 'fix' && ( +
      + {item.action.changes.map((c) => ( +
    • + {c.field}: {c.from} → {c.to} +
    • + ))} +
    + )} + {item.action.kind === 'review' && item.action.details && ( +
    + {item.action.details.map((d) => ( +
    +
    {d.label}
    +
    {d.value}
    +
    + ))} +
    + )} + + {stale && ( +

    + {t('security.staleAccepted', 'Accepted earlier for a different value, by {{who}}: “{{note}}”', { + who: stale.acceptedBy, + note: stale.note, + })} + {canAccept && ( + + )} +

    + )} + + {actions(item)} + + {accepting === key(item) && ( +
    + +