/** * Security Center. * * Die Seite, die in Phase 12 als „noch nicht verfügbar" stand — mit dem * Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt * gibt es beides. * * Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund trägt * Schweregrad, Erklärung, betroffenes Objekt und Empfehlung. Die Empfehlung ist * der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung. */ import { useCallback } from 'react'; import { requestApi } from '@/api/client'; import { useApiResource } from '@/api/useApiResource'; import { describeApiError } from '@/api/useMutation'; import { PageHeader } from '@/components/layout/PageHeader'; import { Callout, Card, CardContent, CardHeader, DataTable, EmptyState, ErrorState, LoadingState, StatusBadge, type TableColumn, } from '@/components/ui'; import { cn } from '@/lib/utils'; /** Schweregrad eines Befunds. */ type FindingSeverity = 'information' | 'warning' | 'high' | 'critical'; /** Ein Sicherheitsbefund. */ interface SecurityFinding { /** Prüfbereich. */ readonly area: string; /** Schweregrad. */ readonly severity: FindingSeverity; /** Überschrift. */ readonly title: string; /** Erklärung, warum der Zustand ein Problem ist. */ readonly explanation: string; /** Das betroffene Objekt. */ readonly affected_object: string; /** Die nächste Handlung. */ readonly recommendation: string; } /** Ergebnis eines Prüfbereichs. */ interface AreaResult { /** Bezeichner. */ readonly area: string; /** Bezeichnung. */ readonly title: string; /** Meldet, ob der Bereich geprüft werden konnte. */ readonly available: boolean; /** Erklärt einen nicht prüfbaren Bereich. */ readonly unavailable_reason?: string; /** Befunde des Bereichs. */ readonly findings: readonly SecurityFinding[]; /** Zusammenfassung. */ readonly summary: string; /** Gewicht in der Gesamtbewertung. */ readonly weight: number; /** Erreichte Punkte. */ readonly earned_points: number; } /** Die Sicherheitslage. */ interface SecurityAssessment { /** Gesamtbewertung in Prozent. */ readonly score: number; /** Erreichbare Punkte der geprüften Bereiche. */ readonly maximum_score: number; /** Einstufung. */ readonly grade: string; /** Ergebnisse je Bereich. */ readonly areas: readonly AreaResult[]; /** Zahl nicht prüfbarer Bereiche. */ readonly unchecked_area_count: number; /** Zahl kritischer Befunde. */ readonly critical_finding_count: number; /** Zahl ernster Befunde. */ readonly high_finding_count: number; /** Zusammenfassung. */ readonly summary: string; /** Nicht geprüfte Bereiche. */ readonly unchecked_areas: readonly string[]; } /** Antwort des Security Center. */ interface SecurityResponse { /** Die Sicherheitslage. */ readonly assessment: SecurityAssessment; /** Meldet eine belastbare Bewertung. */ readonly is_trustworthy: boolean; } /** Zeigt das Security Center. */ export function SecurityPage(): React.JSX.Element { const loadAssessment = useCallback( (abortSignal: AbortSignal) => requestApi('/security', { signal: abortSignal }), [], ); const { loadState, data, loadError, reload } = useApiResource(loadAssessment); return ( <> {loadState === 'loading' ? ( ) : null} {loadState === 'failed' && loadError !== null ? ( ) : null} {loadState === 'loaded' && data !== null ? (
) : null} ); } /** * Zeigt die Gesamtbewertung. * * Neben der Prozentzahl steht immer die erreichbare Höchstpunktzahl und die * Liste der ungepruften Bereiche. Ein ungeprufter Bereich geht **nicht** in die * Rechnung ein — ihn als bestanden zu werten wäre Schönfärberei, als * durchgefallen eine Behauptung. * * Ein kritischer Befund deckelt die Einstufung unabhängig von der Zahl: 88 von * 100 Punkten mit einem kritischen Befund ergeben trotzdem „unzureichend". */ function ScoreHeader({ response }: { readonly response: SecurityResponse }): React.JSX.Element { const { assessment } = response; const criticalCount = assessment.areas .flatMap((areaResult) => areaResult.findings) .filter((finding) => finding.severity === 'critical').length; return (

0 ? 'text-critical' : 'text-fg', )} > {assessment.score} %

{assessment.grade}

{assessment.summary}

{criticalCount > 0 ? ( {criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund {criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf „unzureichend" — unabhängig von der Punktzahl. ) : null} {/* Die nicht geprüfte Bereiche stehen namentlich da: Sie sind die Handlungsanweisung, nicht nur eine Einschränkung. */} {assessment.unchecked_areas.length > 0 ? (

Nicht geprüft: {assessment.unchecked_areas.join(', ')} {assessment.unchecked_areas.length >= 3 ? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.' : ''}

) : null}
); } /** * Zeigt die Befunde nach Schweregrad geordnet. * * Jeder Befund trägt Schweregrad, Erklärung, betroffenes Objekt und * Empfehlung. Ein Befund ohne Empfehlung ist eine Beunruhigung: Er sagt, dass * etwas nicht stimmt, und lässt den Betreiber damit allein. */ function FindingList({ assessment, }: { readonly assessment: SecurityAssessment; }): React.JSX.Element { const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings); const orderedFindings = [...allFindings].sort( (firstFinding, secondFinding) => severityRankOf(secondFinding.severity) - severityRankOf(firstFinding.severity), ); if (orderedFindings.length === 0) { return ( ); } return (
    {orderedFindings.map((finding) => (
  • {finding.title}

    {finding.explanation}

    Betroffen
    {finding.affected_object}
    {/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der Befund überhaupt angezeigt wird. */}
    Nächster Schritt
    {finding.recommendation}
  • ))}
); } /** Zeigt die Ergebnisse je Prüfbereich. */ function AreaTable({ assessment, }: { readonly assessment: SecurityAssessment; }): React.JSX.Element { const areaColumns: ReadonlyArray> = [ { key: 'title', header: 'Bereich', render: (areaResult) => {areaResult.title}, }, { key: 'result', header: 'Ergebnis', render: (areaResult) => areaResult.available ? ( {areaResult.summary} ) : (
{areaResult.unavailable_reason}
), }, { key: 'points', header: 'Punkte', numeric: true, render: (areaResult) => areaResult.available ? ( `${areaResult.earned_points} / ${areaResult.weight}` ) : ( // Ein ungeprufter Bereich geht nicht in die Rechnung ein — weder // positiv noch negativ. zählt nicht ), }, ]; return ( areaResult.area} /> ); } /** Liefert die Ordnungszahl eines Schweregrads. */ function severityRankOf(severity: FindingSeverity): number { switch (severity) { case 'critical': return 4; case 'high': return 3; case 'warning': return 2; default: return 1; } }