soc2-cockpit/src/App.jsx

333 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useMemo, useState } from "react";
import { motion } from "framer-motion";
import {
Bar,
BarChart,
CartesianGrid,
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
CalendarDays,
ClipboardCheck,
Clock3,
Eye,
FileText,
Gauge,
KanbanSquare,
Layers3,
Plus,
ShieldAlert,
Sparkles,
Target,
} from "lucide-react";
import { useSoc2Registry } from "./features/hooks/useSoc2Registry.js";
import { OFFICIAL_SCHEMA } from "./model/schema.js";
import { INITIAL_DB } from "./model/seed.js";
import { buildRegistryManifests, validateRegistry } from "./lib/validation.js";
import {
buildBarData,
buildBlockers,
buildDataHealth,
buildEvidenceMap,
buildPackReadiness,
buildRadarData,
buildScoredPillars,
computeGlobalScore,
filterEvidence,
getOverallStatus,
getPackTone,
getOwners,
} from "./lib/selectors.js";
import { daysUntil } from "./lib/utils.js";
import { PILLAR_ICONS } from "./lib/view.js";
import PillarOverviewCard from "./components/PillarOverviewCard.jsx";
import MilestoneCard from "./components/MilestoneCard.jsx";
import EvidenceLibrary from "./components/EvidenceLibrary.jsx";
import EvidenceEditor from "./components/EvidenceEditor.jsx";
import DataTab from "./components/DataTab.jsx";
import { ProgressBar, ScorePill, SectionTitle, Tag } from "./components/ui/primitives.jsx";
function DashboardPanel({ scoredPillars, targetReadiness, overall, selectedPillar, setSelectedPillar }) {
const radarData = buildRadarData(scoredPillars, targetReadiness);
return (
<>
<div className="grid dashboard-top gap-20">
<div className="panel">
<SectionTitle icon={Gauge} title="Radar de conformité" subtitle="Vue synthèse des 5 piliers. La cible interne est fixée à 85%." />
<div className="chart-area">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={radarData} outerRadius="72%">
<PolarGrid stroke="rgba(255,255,255,0.12)" />
<PolarAngleAxis dataKey="subject" tick={{ fill: "#cbd5e1", fontSize: 13 }} />
<PolarRadiusAxis domain={[0, 100]} tick={{ fill: "#64748b", fontSize: 11 }} />
<Radar name="Cible" dataKey="target" stroke="#cbd5e1" fill="#cbd5e1" fillOpacity={0.07} />
<Radar name="Actuel" dataKey="score" stroke="#60a5fa" fill="#60a5fa" fillOpacity={0.45} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
</div>
</div>
<div className="panel">
<SectionTitle icon={Sparkles} title="Lecture utile du radar" subtitle="Le radar sert au pilotage. Il nest pas une preuve à lui seul." />
<div className="stack mt-16">
<div className="subpanel"><div className="card-title-xs">Logique de score</div><div className="muted mt-8">Absent = 0, Conçu = 0,35, En opération = 0,7, Prouvé = 1. Les preuves bonifient la crédibilité du score.</div></div>
<div className="subpanel"><div className="card-title-xs">Règle de plancher</div><div className="muted mt-8">Tant que Security reste sous 60%, létat global doit être considéré fragile.</div></div>
<div className={`subpanel ${overall.tone}`}><div className="card-title-xs">État actuel</div><div className="mt-8">{overall.label}</div></div>
</div>
</div>
</div>
<div className="grid pillars-grid gap-20 mt-20">
{scoredPillars.map((pillar) => (
<PillarOverviewCard key={pillar.id} pillar={pillar} selected={selectedPillar === pillar.id} onSelect={setSelectedPillar} />
))}
</div>
</>
);
}
export default function App() {
const registry = useSoc2Registry();
const { db, lastSavedAt, importError, syncMode, syncStatus, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
const [activeTab, setActiveTab] = useState("dashboard");
const [selectedPillar, setSelectedPillar] = useState("security");
const [selectedEvidenceId, setSelectedEvidenceId] = useState(db.evidence[0]?.id || "");
const [search, setSearch] = useState("");
const [evidenceFilter, setEvidenceFilter] = useState("all");
const [ownerFilter, setOwnerFilter] = useState("all");
const [auditorMode, setAuditorMode] = useState(false);
const [importText, setImportText] = useState("");
const evidenceMap = useMemo(() => buildEvidenceMap(db), [db]);
const scoredPillars = useMemo(() => buildScoredPillars(db, evidenceMap), [db, evidenceMap]);
const selected = scoredPillars.find((pillar) => pillar.id === selectedPillar) || scoredPillars[0];
const selectedEvidence = db.evidence.find((item) => item.id === selectedEvidenceId) || db.evidence[0] || null;
const owners = useMemo(() => getOwners(db), [db]);
const globalScore = useMemo(() => computeGlobalScore(db.pillars, evidenceMap), [db, evidenceMap]);
const overall = getOverallStatus(globalScore);
const auditWindowDays = daysUntil(db.appContext.observationWindow.end);
const blockers = useMemo(() => buildBlockers(db, evidenceMap), [db, evidenceMap]);
const packReadiness = useMemo(() => buildPackReadiness(db), [db]);
const dataHealth = useMemo(() => buildDataHealth(db), [db]);
const validation = useMemo(() => validateRegistry(db), [db]);
const manifests = useMemo(() => buildRegistryManifests(db), [db]);
const filteredEvidence = useMemo(
() => filterEvidence(db, { query: search, pillarId: evidenceFilter, owner: ownerFilter, auditorMode }),
[db, search, evidenceFilter, ownerFilter, auditorMode]
);
const barData = useMemo(() => buildBarData(selected, evidenceMap), [selected, evidenceMap]);
const readyEvidenceCount = db.evidence.filter((item) => item.auditorReady).length;
const readinessRate = db.evidence.length ? Math.round((readyEvidenceCount / db.evidence.length) * 1000) / 10 : 0;
const blocker = scoredPillars.find((pillar) => pillar.id === "security" && pillar.score < 60);
const handleCreateEvidence = () => {
const id = createEvidence(selectedPillar);
setSelectedEvidenceId(id);
setActiveTab("evidence");
};
const handleDeleteEvidence = (evidenceId) => {
deleteEvidence(evidenceId);
const fallback = db.evidence.find((item) => item.id !== evidenceId);
setSelectedEvidenceId(fallback?.id || "");
};
const handleImport = () => {
const result = importDb(importText);
if (result.ok) {
setSelectedPillar(result.data.pillars[0]?.id || "security");
setSelectedEvidenceId(result.data.evidence[0]?.id || "");
setImportText("");
}
};
const handleReset = () => {
resetDemo();
setSelectedPillar("security");
setSelectedEvidenceId(INITIAL_DB.evidence[0]?.id || "");
};
return (
<div className="app-shell">
<div className="app-container">
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.45 }} className="hero panel">
<div className="grid hero-grid gap-20">
<div>
<div className="row wrap gap-8">
<Tag tone="info">{db.appContext.orgName} {db.appContext.programName}</Tag>
<Tag tone={overall.tone}>{overall.label}</Tag>
{blocker ? <Tag tone="is-bad">Blocage: Security &lt; 60</Tag> : null}
</div>
<h1 className="hero-title">Assistant web de conformité SOC 2 orienté preuve, remédiation et exposition auditeur</h1>
<p className="hero-subtitle">Architecture modulaire, source de vérité versionnable, backend API optionnel, radar des 5 piliers, bibliothèque de preuves et salle auditeur.</p>
<div className="row wrap gap-8 mt-16">
<button className="btn btn-primary"><Eye size={16} /> Ouvrir la salle auditeur</button>
<button className="btn btn-secondary" onClick={handleCreateEvidence}><Plus size={16} /> Nouvelle preuve</button>
<button className="btn btn-secondary" onClick={() => setAuditorMode((v) => !v)}><ClipboardCheck size={16} /> {auditorMode ? "Mode interne" : "Mode auditeur"}</button>
</div>
</div>
<div className="grid cols-2 gap-12">
<ScorePill label="Conformité pondérée" value={`${globalScore}%`} sublabel="Score global des 5 piliers" />
<ScorePill label="Preuves auditables" value={`${readinessRate}%`} sublabel={`${readyEvidenceCount}/${db.evidence.length} exposables aux auditeurs`} />
<ScorePill label="Fenêtre Type II" value={`${auditWindowDays} j`} sublabel={`${db.appContext.observationWindow.start}${db.appContext.observationWindow.end}`} />
<ScorePill label="Dernière sauvegarde" value={lastSavedAt || "—"} sublabel={`Mode ${syncMode} • état ${syncStatus}`} />
</div>
</div>
</motion.div>
<div className="tabs panel mt-20">
{["dashboard", "controls", "execution", "evidence", "auditor", "data"].map((tab) => (
<button key={tab} className={`tab ${activeTab === tab ? "active" : ""}`} onClick={() => setActiveTab(tab)}>{tab}</button>
))}
</div>
{activeTab === "dashboard" && (
<DashboardPanel scoredPillars={scoredPillars} targetReadiness={db.appContext.observationWindow.targetReadiness} overall={overall} selectedPillar={selectedPillar} setSelectedPillar={setSelectedPillar} />
)}
{activeTab === "controls" && (
<div className="grid controls-grid gap-20">
<div className="panel">
<SectionTitle icon={PILLAR_ICONS[selected.id] || Layers3} title={`Pilier sélectionné : ${selected.name}`} subtitle={selected.objective} />
<div className="grid cols-3 gap-12 mt-16">
<ScorePill label="Score" value={`${selected.score}%`} sublabel="Maturité pondérée du pilier" />
<ScorePill label="Poids global" value={`${selected.globalWeight}%`} sublabel="Charge relative dans lensemble" />
<ScorePill label="Preuves" value={`${selected.evidenceCoverage}%`} sublabel="Couverture actuelle des éléments probants" />
</div>
<div className="chart-area chart-area-sm mt-16">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={barData} margin={{ top: 10, right: 10, left: -10, bottom: 40 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
<XAxis dataKey="name" angle={-20} textAnchor="end" interval={0} height={80} tick={{ fill: "#cbd5e1", fontSize: 11 }} />
<YAxis domain={[0, 100]} tick={{ fill: "#94a3b8", fontSize: 11 }} />
<Tooltip />
<Bar dataKey="score" radius={[8, 8, 0, 0]} fill="#60a5fa" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="panel panel-tall">
<SectionTitle icon={KanbanSquare} title="Jalons détaillés" subtitle="Chaque jalon combine état dimplantation, cadence, propriétaire, échéance et chaîne de preuve." />
<div className="scroll-area mt-16">
<div className="stack">
{selected.milestones.map((milestone) => (
<MilestoneCard key={milestone.id} pillarId={selected.id} milestone={milestone} evidenceMap={evidenceMap} linkedEvidence={db.evidence.filter((item) => milestone.evidenceRefs.includes(item.id))} onStageChange={updateMilestoneStage} />
))}
</div>
</div>
</div>
</div>
)}
{activeTab === "execution" && (
<div className="grid execution-grid gap-20">
<div className="panel">
<SectionTitle icon={Target} title="Écarts et priorités" subtitle="Ce qui freine réellement la préparation Type II, trié par urgence et maturité." />
<div className="stack mt-16">
{blockers.map((item) => (
<div key={`${item.pillarId}-${item.id}`} className="subpanel">
<div className="row space-between align-start gap-12">
<div>
<div className="tiny muted">{item.pillarName}</div>
<div className="card-title-sm mt-6">{item.title}</div>
</div>
<Tag tone={item.dueInDays < 0 ? "is-bad" : item.dueInDays <= 7 ? "is-warn" : "is-muted"}>{item.dueInDays < 0 ? `Retard ${Math.abs(item.dueInDays)} j` : `${item.dueInDays} j`}</Tag>
</div>
<div className="muted mt-8">{item.nextAction}</div>
</div>
))}
</div>
</div>
<div className="panel">
<SectionTitle icon={CalendarDays} title="Cadence opérationnelle" subtitle="Vue synthèse du travail récurrent nécessaire pour faire vivre les contrôles et garder la preuve à jour." />
<div className="grid cols-3 gap-12 mt-16">
{db.pillars.flatMap((pillar) =>
pillar.milestones.map((milestone) => (
<div key={milestone.id} className="subpanel">
<div className="row space-between align-start gap-8"><Tag>{pillar.name}</Tag><Tag>{milestone.stage}</Tag></div>
<div className="card-title-sm mt-8">{milestone.title}</div>
<div className="muted mt-8">{milestone.owner} {milestone.cadence}</div>
<div className="muted mt-8">Échéance: {milestone.dueAt}</div>
</div>
))
)}
</div>
</div>
</div>
)}
{activeTab === "evidence" && (
<div className="grid evidence-grid gap-20">
<EvidenceLibrary
evidence={filteredEvidence}
selectedEvidenceId={selectedEvidenceId}
onSelect={setSelectedEvidenceId}
filters={{ search, setSearch, evidenceFilter, setEvidenceFilter, ownerFilter, setOwnerFilter }}
owners={owners}
pillars={db.pillars}
onCreate={handleCreateEvidence}
/>
<EvidenceEditor evidence={selectedEvidence} owners={owners} pillars={db.pillars} onUpdate={updateEvidence} onDelete={handleDeleteEvidence} onToggleLink={toggleEvidenceMilestoneLink} />
</div>
)}
{activeTab === "auditor" && (
<div className="grid auditor-grid gap-20">
<div className="panel">
<SectionTitle icon={FileText} title="Dossier auditeur" subtitle="Paquets structurés, lisibles, avec degré de préparation calculé." />
<div className="stack mt-16">
{packReadiness.map((pack) => (
<div key={pack.id} className="subpanel">
<div className="row space-between align-start gap-12"><div><div className="card-title-sm">{pack.title}</div><div className="muted mt-8">{pack.description}</div></div><Tag tone={getPackTone(pack.status)}>{pack.status}</Tag></div>
<div className="row space-between tiny muted mt-12"><span>Degré de préparation</span><span>{pack.readiness}%</span></div>
<ProgressBar value={pack.readiness} />
<div className="row wrap gap-8 mt-12">{pack.linked.map((item) => <Tag key={item.id}>{item.title}</Tag>)}</div>
</div>
))}
<div className="subpanel is-info"><div className="row gap-8 align-center"><Clock3 size={16} /><strong>Mode {auditorMode ? "auditeur" : "interne"}</strong></div><div className="mt-8">{auditorMode ? "Affichage réduit aux preuves auditables." : "Affichage complet incluant brouillons et remédiation."}</div></div>
</div>
</div>
<div className="panel">
<SectionTitle icon={Layers3} title="Matériel exposé à lauditeur" subtitle="Vue condensée du narratif daudit : description du système, preuves, écarts connus et actions correctives." />
<div className="stack mt-16">
<div className="subpanel"><div className="card-title-xs">Description du système</div><div className="muted mt-8">Service in-scope, architecture, dépendances, responsabilités, flux de données, outils de surveillance, changements et posture de reprise.</div></div>
<div className="subpanel"><div className="card-title-xs">Chaîne de preuve</div><div className="muted mt-8">Chaque jalon significatif doit être relié à des éléments probants datés, intelligibles et contextualisés, avec propriétaire et fraîcheur connue.</div></div>
<div className="subpanel"><div className="card-title-xs">Écarts et remédiation</div><div className="muted mt-8">Les trous de conformité ne sont pas cachés. Ils sont nommés, qualifiés, suivis et accompagnés dune action corrective explicite.</div></div>
<div className="subpanel good"><div className="row gap-8 align-center"><ShieldAlert size={16} /><strong>Narratif recommandé</strong></div><div className="mt-8">Présenter dabord le périmètre, puis les contrôles communs, ensuite les preuves les plus parlantes, et finir par les exceptions connues avec leurs remédiations.</div></div>
</div>
</div>
</div>
)}
{activeTab === "data" && (
<DataTab
db={db}
owners={owners}
dataHealth={dataHealth}
importText={importText}
setImportText={setImportText}
importError={importError}
onImport={handleImport}
onExport={exportDb}
onCopy={copyDb}
onReset={handleReset}
validation={validation}
manifests={manifests}
schema={OFFICIAL_SCHEMA}
/>
)}
</div>
</div>
);
}