**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:
- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.
Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.
Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).
**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.
**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.
Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.
82 Tests gruen, tsc sauber, eslint ohne Warnung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
346 lines
10 KiB
TypeScript
346 lines
10 KiB
TypeScript
/**
|
|
* 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<SecurityResponse>('/security', { signal: abortSignal }),
|
|
[],
|
|
);
|
|
|
|
const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="Security Center"
|
|
description="Was geprüft wurde — und was nicht."
|
|
/>
|
|
|
|
{loadState === 'loading' ? (
|
|
<Card>
|
|
<LoadingState label="Die Sicherheitslage wird ermittelt …" />
|
|
</Card>
|
|
) : null}
|
|
|
|
{loadState === 'failed' && loadError !== null ? (
|
|
<Card>
|
|
<ErrorState
|
|
message={describeApiError(loadError)}
|
|
requestId={loadError.requestId}
|
|
onRetry={reload}
|
|
/>
|
|
</Card>
|
|
) : null}
|
|
|
|
{loadState === 'loaded' && data !== null ? (
|
|
<div className="space-y-4">
|
|
<ScoreHeader response={data} />
|
|
<FindingList assessment={data.assessment} />
|
|
<AreaTable assessment={data.assessment} />
|
|
</div>
|
|
) : 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 (
|
|
<Card>
|
|
<CardContent className="flex flex-wrap items-start gap-6">
|
|
<div>
|
|
<p
|
|
className={cn(
|
|
'text-4xl font-semibold tabular',
|
|
criticalCount > 0 ? 'text-critical' : 'text-fg',
|
|
)}
|
|
>
|
|
{assessment.score}
|
|
<span className="ml-1 text-lg text-fg-muted">%</span>
|
|
</p>
|
|
<p className="mt-1 text-sm font-medium text-fg">{assessment.grade}</p>
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<p className="text-sm text-fg">{assessment.summary}</p>
|
|
|
|
{criticalCount > 0 ? (
|
|
<Callout tone="critical">
|
|
{criticalCount} kritische{criticalCount === 1 ? 'r' : ''} Befund
|
|
{criticalCount === 1 ? '' : 'e'} deckelt die Einstufung auf
|
|
„unzureichend" — unabhängig von der Punktzahl.
|
|
</Callout>
|
|
) : null}
|
|
|
|
{/* Die nicht geprüfte Bereiche stehen namentlich da: Sie sind die
|
|
Handlungsanweisung, nicht nur eine Einschränkung. */}
|
|
{assessment.unchecked_areas.length > 0 ? (
|
|
<p className="text-xs text-fg-muted">
|
|
Nicht geprüft: {assessment.unchecked_areas.join(', ')}
|
|
{assessment.unchecked_areas.length >= 3
|
|
? ' — bei so vielen ungepruften Bereichen ist die Zahl eine Vermutung, keine Aussage.'
|
|
: ''}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<Card>
|
|
<EmptyState
|
|
title="Keine Befunde in den geprüften Bereichen"
|
|
description="Das gilt ausschließlich für die geprüften Bereiche — die ungeprüften stehen oben."
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader title={`Befunde (${orderedFindings.length})`} />
|
|
<CardContent>
|
|
<ul className="space-y-3">
|
|
{orderedFindings.map((finding) => (
|
|
<li
|
|
key={`${finding.area}-${finding.title}`}
|
|
className="rounded-md border border-line p-3"
|
|
>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StatusBadge status={finding.severity} />
|
|
<h3 className="font-medium text-fg">{finding.title}</h3>
|
|
</div>
|
|
|
|
<p className="mt-1.5 text-sm text-fg-muted">{finding.explanation}</p>
|
|
|
|
<dl className="mt-3 space-y-2 border-t border-line pt-2 text-sm">
|
|
<div className="flex gap-2">
|
|
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
|
|
Betroffen
|
|
</dt>
|
|
<dd className="min-w-0 break-all text-fg">{finding.affected_object}</dd>
|
|
</div>
|
|
|
|
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum
|
|
der Befund überhaupt angezeigt wird. */}
|
|
<div className="flex gap-2">
|
|
<dt className="w-32 shrink-0 text-xs uppercase tracking-wide text-fg-subtle">
|
|
Nächster Schritt
|
|
</dt>
|
|
<dd className="min-w-0 font-medium text-fg">{finding.recommendation}</dd>
|
|
</div>
|
|
</dl>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/** Zeigt die Ergebnisse je Prüfbereich. */
|
|
function AreaTable({
|
|
assessment,
|
|
}: {
|
|
readonly assessment: SecurityAssessment;
|
|
}): React.JSX.Element {
|
|
const areaColumns: ReadonlyArray<TableColumn<AreaResult>> = [
|
|
{
|
|
key: 'title',
|
|
header: 'Bereich',
|
|
render: (areaResult) => <span className="font-medium text-fg">{areaResult.title}</span>,
|
|
},
|
|
{
|
|
key: 'result',
|
|
header: 'Ergebnis',
|
|
render: (areaResult) =>
|
|
areaResult.available ? (
|
|
<span className="text-sm text-fg">{areaResult.summary}</span>
|
|
) : (
|
|
<div className="flex flex-col gap-1">
|
|
<StatusBadge status="neutral" label="nicht prüfbar" />
|
|
<span className="text-xs text-fg-muted">{areaResult.unavailable_reason}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
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.
|
|
<span className="text-xs text-fg-subtle">zählt nicht</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader
|
|
title="Geprüft wurde"
|
|
description={`Erreichbar sind ${assessment.maximum_score} Punkte.`}
|
|
/>
|
|
<DataTable
|
|
columns={areaColumns}
|
|
rows={assessment.areas}
|
|
getRowKey={(areaResult) => areaResult.area}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
/** 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;
|
|
}
|
|
}
|