Initial SOC2 cockpit repository

This commit is contained in:
OpenAI 2026-04-17 14:17:56 +00:00
commit 5836be465c
24 changed files with 3251 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# Chezlepro SOC 2 Type II Cockpit
Cockpit web React orienté **conformité opérationnelle SOC 2 Type II** pour Chezlepro Inc.
Lapplication fournit :
- un **radar des 5 piliers** SOC 2 ;
- un suivi **pondéré des jalons** ;
- une **bibliothèque de preuves** éditable ;
- une **salle auditeur** avec paquets de revue ;
- une **source de vérité JSON** persistée localement ;
- des **exports/manifests** pour préparer un dépôt Git versionné.
## Architecture
```text
src/
App.jsx
styles.css
model/
schema.js
seed.js
stages.js
lib/
utils.js
validation.js
selectors.js
view.js
features/
hooks/
useSoc2Registry.js
components/
PillarOverviewCard.jsx
MilestoneCard.jsx
EvidenceLibrary.jsx
EvidenceEditor.jsx
DataTab.jsx
ui/
primitives.jsx
```
## Principes de conception
- **Modèle** : constantes, schéma, seed data, états des contrôles.
- **Lib métier** : normalisation, validation, calculs, sélecteurs.
- **Hook de registre** : persistance locale, import/export, mutations synchronisées.
- **Composants** : interface séparée du moteur métier.
Le cockpit est prêt à évoluer vers une vraie source de vérité Git/Forgejo :
- chemin recommandé pour le registre : `compliance/soc2/registry/soc2-registry.json`
- pièces jointes recommandées : `compliance/soc2/evidence/`
## Lancer le projet
```bash
npm install
npm run dev
```
Build de production :
```bash
npm run build
npm run preview
```
## Dépendances
- React
- Vite
- Recharts
- Framer Motion
- Lucide React
## Source de vérité
Le cockpit persiste son état dans `localStorage` pour le prototype.
La prochaine étape naturelle est de remplacer cette persistance par :
1. un fichier JSON versionné dans un dépôt Forgejo ;
2. une API légère de lecture/écriture ;
3. éventuellement une ingestion automatique depuis Icinga, Keycloak et les registres internes.
## Limites actuelles
- pas dauthentification applicative ;
- pas de backend ;
- pas de gestion de fichiers binaires de preuves ;
- pas encore de synchronisation distante.
## Recommandation dévolution
Séquence recommandée :
1. brancher le registre JSON versionné ;
2. ajouter gestion de pièces jointes ;
3. ajouter règles de validation plus strictes ;
4. ajouter API backend ;
5. brancher les collecteurs automatiques de preuves.

View file

@ -0,0 +1,44 @@
[
{
"id": "pack-1",
"title": "System Description",
"status": "ready",
"requiredEvidence": [
"ev-001",
"ev-011"
]
},
{
"id": "pack-2",
"title": "Management Assertion",
"status": "draft",
"requiredEvidence": [
"ev-012"
]
},
{
"id": "pack-3",
"title": "Controls ↔ Evidence Matrix",
"status": "ready",
"requiredEvidence": [
"ev-012"
]
},
{
"id": "pack-4",
"title": "Exceptions & Remediation Register",
"status": "draft",
"requiredEvidence": [
"ev-009",
"ev-010"
]
},
{
"id": "pack-5",
"title": "Access & Privileged Activity Pack",
"status": "draft",
"requiredEvidence": [
"ev-002"
]
}
]

View file

@ -0,0 +1,150 @@
[
{
"id": "ev-001",
"title": "Politique de sécurité de linformation v1.4",
"pillar": "security",
"auditorReady": true,
"visibility": "auditor",
"linkedMilestones": [
"sec-governance"
],
"updatedAt": "2026-04-09"
},
{
"id": "ev-002",
"title": "Export Keycloak MFA et rôles administrateurs",
"pillar": "security",
"auditorReady": false,
"visibility": "internal",
"linkedMilestones": [
"sec-iam",
"con-access"
],
"updatedAt": "2026-03-29"
},
{
"id": "ev-003",
"title": "Journal des changements Forgejo / Pull Requests",
"pillar": "security",
"auditorReady": true,
"visibility": "auditor",
"linkedMilestones": [
"sec-change",
"pi-inputs"
],
"updatedAt": "2026-04-15"
},
{
"id": "ev-004",
"title": "Rapport Icinga disponibilité des services",
"pillar": "availability",
"auditorReady": true,
"visibility": "auditor",
"linkedMilestones": [
"ava-monitoring",
"ava-incidents",
"sec-logging"
],
"updatedAt": "2026-04-16"
},
{
"id": "ev-005",
"title": "Procédure de sauvegarde et test de restauration",
"pillar": "availability",
"auditorReady": true,
"visibility": "auditor",
"linkedMilestones": [
"ava-backup",
"ava-resilience"
],
"updatedAt": "2026-04-12"
},
{
"id": "ev-006",
"title": "Cartographie des flux critiques de traitement",
"pillar": "processing-integrity",
"auditorReady": false,
"visibility": "internal",
"linkedMilestones": [
"pi-flows",
"pi-audit"
],
"updatedAt": "2026-04-04"
},
{
"id": "ev-007",
"title": "Registre de classification des données",
"pillar": "confidentiality",
"auditorReady": false,
"visibility": "internal",
"linkedMilestones": [
"con-classification",
"con-lifecycle"
],
"updatedAt": "2026-03-18"
},
{
"id": "ev-008",
"title": "Registre des renseignements personnels",
"pillar": "privacy",
"auditorReady": false,
"visibility": "internal",
"linkedMilestones": [
"pri-mapping",
"pri-rules",
"pri-retention"
],
"updatedAt": "2026-04-02"
},
{
"id": "ev-009",
"title": "Journal des incidents et post-mortems",
"pillar": "security",
"auditorReady": true,
"visibility": "auditor",
"linkedMilestones": [
"sec-ir",
"ava-incidents"
],
"updatedAt": "2026-04-10"
},
{
"id": "ev-010",
"title": "Registre des fournisseurs critiques",
"pillar": "confidentiality",
"auditorReady": false,
"visibility": "internal",
"linkedMilestones": [
"con-thirdparty",
"pri-incidents"
],
"updatedAt": "2026-03-27"
},
{
"id": "ev-011",
"title": "Inventaire des services in-scope",
"pillar": "availability",
"auditorReady": true,
"visibility": "auditor",
"linkedMilestones": [
"ava-inventory",
"sec-governance"
],
"updatedAt": "2026-04-11"
},
{
"id": "ev-012",
"title": "Matrice contrôles ↔ preuves",
"pillar": "security",
"auditorReady": true,
"visibility": "auditor",
"linkedMilestones": [
"sec-governance",
"sec-change",
"con-review",
"pri-review",
"pi-audit"
],
"updatedAt": "2026-04-16"
}
]

View file

@ -0,0 +1,787 @@
{
"schemaVersion": "1.0.0",
"appContext": {
"orgName": "Chezlepro Inc.",
"programName": "SOC 2 Type II Operational Cockpit",
"observationWindow": {
"start": "2026-01-01",
"end": "2026-06-30",
"targetReadiness": 85
}
},
"evidence": [
{
"id": "ev-001",
"title": "Politique de sécurité de linformation v1.4",
"category": "Policy",
"pillar": "security",
"owner": "Direction",
"freshness": "Valide",
"updatedAt": "2026-04-09",
"auditorReady": true,
"visibility": "auditor",
"source": "Wiki / Gouvernance",
"tags": [
"policy",
"security",
"governance"
],
"linkedMilestones": [
"sec-governance"
],
"summary": "Politique cadre de sécurité approuvée et versionnée."
},
{
"id": "ev-002",
"title": "Export Keycloak MFA et rôles administrateurs",
"category": "Access",
"pillar": "security",
"owner": "Ops",
"freshness": "À revoir",
"updatedAt": "2026-03-29",
"auditorReady": false,
"visibility": "internal",
"source": "Keycloak / Export CSV",
"tags": [
"iam",
"mfa",
"admin"
],
"linkedMilestones": [
"sec-iam",
"con-access"
],
"summary": "État courant des administrateurs, rôles et MFA; manque la revue signée."
},
{
"id": "ev-003",
"title": "Journal des changements Forgejo / Pull Requests",
"category": "Change",
"pillar": "security",
"owner": "Ops",
"freshness": "Valide",
"updatedAt": "2026-04-15",
"auditorReady": true,
"visibility": "auditor",
"source": "Forgejo / Repositories",
"tags": [
"change",
"git",
"approval"
],
"linkedMilestones": [
"sec-change",
"pi-inputs"
],
"summary": "Historique des changements, approbations et validations post-déploiement."
},
{
"id": "ev-004",
"title": "Rapport Icinga disponibilité des services",
"category": "Monitoring",
"pillar": "availability",
"owner": "Ops",
"freshness": "Valide",
"updatedAt": "2026-04-16",
"auditorReady": true,
"visibility": "auditor",
"source": "Icinga2 / Monitoring",
"tags": [
"availability",
"monitoring",
"uptime"
],
"linkedMilestones": [
"ava-monitoring",
"ava-incidents",
"sec-logging"
],
"summary": "Disponibilité, incidents et tendances de capacité sur la fenêtre dobservation."
},
{
"id": "ev-005",
"title": "Procédure de sauvegarde et test de restauration",
"category": "Backup",
"pillar": "availability",
"owner": "Ops",
"freshness": "Valide",
"updatedAt": "2026-04-12",
"auditorReady": true,
"visibility": "auditor",
"source": "Runbook / Restore test",
"tags": [
"backup",
"restore",
"dr"
],
"linkedMilestones": [
"ava-backup",
"ava-resilience"
],
"summary": "Procédure documentée et test de restauration horodaté."
},
{
"id": "ev-006",
"title": "Cartographie des flux critiques de traitement",
"category": "Process",
"pillar": "processing-integrity",
"owner": "Architecture",
"freshness": "Brouillon",
"updatedAt": "2026-04-04",
"auditorReady": false,
"visibility": "internal",
"source": "Architecture / Flux",
"tags": [
"flows",
"processing",
"integrity"
],
"linkedMilestones": [
"pi-flows",
"pi-audit"
],
"summary": "Cartographie initiale des flux critiques; consolidation encore en cours."
},
{
"id": "ev-007",
"title": "Registre de classification des données",
"category": "Data",
"pillar": "confidentiality",
"owner": "Architecture",
"freshness": "À revoir",
"updatedAt": "2026-03-18",
"auditorReady": false,
"visibility": "internal",
"source": "Registre data",
"tags": [
"classification",
"data",
"confidentiality"
],
"linkedMilestones": [
"con-classification",
"con-lifecycle"
],
"summary": "Inventaire de classification existant mais incomplet pour certaines charges hébergées."
},
{
"id": "ev-008",
"title": "Registre des renseignements personnels",
"category": "Privacy",
"pillar": "privacy",
"owner": "Direction",
"freshness": "Brouillon",
"updatedAt": "2026-04-02",
"auditorReady": false,
"visibility": "internal",
"source": "Registre privacy",
"tags": [
"privacy",
"pii",
"register"
],
"linkedMilestones": [
"pri-mapping",
"pri-rules",
"pri-retention"
],
"summary": "Première version du registre des renseignements personnels et de leurs finalités."
},
{
"id": "ev-009",
"title": "Journal des incidents et post-mortems",
"category": "Incident",
"pillar": "security",
"owner": "Ops",
"freshness": "Valide",
"updatedAt": "2026-04-10",
"auditorReady": true,
"visibility": "auditor",
"source": "Incident register",
"tags": [
"incident",
"rca",
"security"
],
"linkedMilestones": [
"sec-ir",
"ava-incidents"
],
"summary": "Historique des incidents, causes racines et actions correctives."
},
{
"id": "ev-010",
"title": "Registre des fournisseurs critiques",
"category": "Vendor",
"pillar": "confidentiality",
"owner": "Direction",
"freshness": "À revoir",
"updatedAt": "2026-03-27",
"auditorReady": false,
"visibility": "internal",
"source": "Vendor register",
"tags": [
"vendors",
"third-party",
"contracts"
],
"linkedMilestones": [
"con-thirdparty",
"pri-incidents"
],
"summary": "Liste des tiers, contrats et dépendances; classement critique à finaliser."
},
{
"id": "ev-011",
"title": "Inventaire des services in-scope",
"category": "Inventory",
"pillar": "availability",
"owner": "Architecture",
"freshness": "Valide",
"updatedAt": "2026-04-11",
"auditorReady": true,
"visibility": "auditor",
"source": "CMDB / Architecture",
"tags": [
"inventory",
"scope",
"services"
],
"linkedMilestones": [
"ava-inventory",
"sec-governance"
],
"summary": "Liste des services critiques, dépendances et responsables dexploitation."
},
{
"id": "ev-012",
"title": "Matrice contrôles ↔ preuves",
"category": "Matrix",
"pillar": "security",
"owner": "Direction",
"freshness": "Valide",
"updatedAt": "2026-04-16",
"auditorReady": true,
"visibility": "auditor",
"source": "Compliance register",
"tags": [
"matrix",
"controls",
"evidence"
],
"linkedMilestones": [
"sec-governance",
"sec-change",
"con-review",
"pri-review",
"pi-audit"
],
"summary": "Matrice de rapprochement entre critères, contrôles, jalons et éléments probants."
}
],
"pillars": [
{
"id": "security",
"name": "Security",
"globalWeight": 32,
"objective": "Protéger les systèmes, les accès, les changements et la capacité de détection/réaction.",
"description": "Protection globale des systèmes, identités, journaux, changements et incidents.",
"milestones": [
{
"id": "sec-governance",
"title": "Gouvernance sécurité de base",
"weight": 10,
"owner": "Direction",
"cadence": "Mensuelle",
"dueAt": "2026-04-25",
"stage": "proved",
"evidenceRefs": [
"ev-001",
"ev-011",
"ev-012"
],
"notes": "Périmètre et rôles définis; maintenir lactualisation du registre des risques.",
"nextAction": "Ajouter la revue mensuelle du registre des risques à la fenêtre Type II."
},
{
"id": "sec-iam",
"title": "Gestion des accès et des identités",
"weight": 20,
"owner": "Ops",
"cadence": "Mensuelle",
"dueAt": "2026-04-22",
"stage": "operating",
"evidenceRefs": [
"ev-002"
],
"notes": "MFA déployé mais la revue périodique signée et la révocation démontrée restent à consolider.",
"nextAction": "Capturer la revue daccès du mois et la rattacher à ce jalon."
},
{
"id": "sec-hardening",
"title": "Durcissement et gestion des vulnérabilités",
"weight": 20,
"owner": "Ops",
"cadence": "Hebdomadaire",
"dueAt": "2026-04-21",
"stage": "operating",
"evidenceRefs": [],
"notes": "Le standard technique existe mais la chaîne de preuve automatisée doit être branchée.",
"nextAction": "Importer le dernier rapport de scan et lier la remédiation en cours."
},
{
"id": "sec-logging",
"title": "Journalisation, surveillance et alertes",
"weight": 20,
"owner": "Ops",
"cadence": "Hebdomadaire",
"dueAt": "2026-04-19",
"stage": "proved",
"evidenceRefs": [
"ev-004"
],
"notes": "Bonne maturité opérationnelle, surveillance déjà exploitable côté auditeur.",
"nextAction": "Ajouter un sommaire de rétention des journaux à la salle auditeur."
},
{
"id": "sec-change",
"title": "Gestion des changements",
"weight": 15,
"owner": "Ops",
"cadence": "Continue",
"dueAt": "2026-04-20",
"stage": "operating",
"evidenceRefs": [
"ev-003",
"ev-012"
],
"notes": "Les PR existent; il reste à standardiser la preuve des validations post-changement.",
"nextAction": "Ajouter le champ “validation post-déploiement” dans le flux de changement."
},
{
"id": "sec-ir",
"title": "Réponse aux incidents",
"weight": 15,
"owner": "Ops",
"cadence": "Trimestrielle",
"dueAt": "2026-05-05",
"stage": "operating",
"evidenceRefs": [
"ev-009"
],
"notes": "Registre en place; exercice formel tabletop à programmer.",
"nextAction": "Planifier un exercice et produire le compte rendu auditable."
}
]
},
{
"id": "availability",
"name": "Availability",
"globalWeight": 22,
"objective": "Maintenir la disponibilité promise, démontrer la résilience et la restaurabilité.",
"description": "Disponibilité des services, dépendances, capacité, sauvegardes et reprise.",
"milestones": [
{
"id": "ava-inventory",
"title": "Inventaire des services critiques et dépendances",
"weight": 15,
"owner": "Architecture",
"cadence": "Mensuelle",
"dueAt": "2026-04-24",
"stage": "proved",
"evidenceRefs": [
"ev-011"
],
"notes": "Inventaire déjà formalisé, à garder vivant.",
"nextAction": "Ajouter les dépendances de monitoring externe dans linventaire."
},
{
"id": "ava-slo",
"title": "Objectifs de service définis",
"weight": 10,
"owner": "Direction",
"cadence": "Trimestrielle",
"dueAt": "2026-05-10",
"stage": "designed",
"evidenceRefs": [],
"notes": "Le langage de promesse client existe, pas encore uniformisé en SLO/SLA.",
"nextAction": "Formaliser les cibles internes et externes par service in-scope."
},
{
"id": "ava-monitoring",
"title": "Monitoring et capacité",
"weight": 25,
"owner": "Ops",
"cadence": "Hebdomadaire",
"dueAt": "2026-04-19",
"stage": "proved",
"evidenceRefs": [
"ev-004"
],
"notes": "Surveillance solide et historique disponible.",
"nextAction": "Ajouter un résumé tendance capacité par trimestre."
},
{
"id": "ava-backup",
"title": "Sauvegardes et restauration testée",
"weight": 25,
"owner": "Ops",
"cadence": "Mensuelle",
"dueAt": "2026-04-23",
"stage": "operating",
"evidenceRefs": [
"ev-005"
],
"notes": "Test de restauration existant; cadence et couverture des jeux de données à élargir.",
"nextAction": "Ajouter un deuxième scénario de restauration ciblant la chaîne complète applicative."
},
{
"id": "ava-resilience",
"title": "Résilience opérationnelle",
"weight": 15,
"owner": "Architecture",
"cadence": "Trimestrielle",
"dueAt": "2026-05-12",
"stage": "operating",
"evidenceRefs": [
"ev-005"
],
"notes": "Les mécanismes existent mais le récit auditeur nest pas encore assez structuré.",
"nextAction": "Rédiger une fiche de reprise courte et exposable."
},
{
"id": "ava-incidents",
"title": "Gestion des incidents de disponibilité",
"weight": 10,
"owner": "Ops",
"cadence": "Continue",
"dueAt": "2026-04-20",
"stage": "proved",
"evidenceRefs": [
"ev-004",
"ev-009"
],
"notes": "Bonne base; il faut lier plus clairement lincident à laction corrective.",
"nextAction": "Relier chaque incident clos à une décision corrective explicite."
}
]
},
{
"id": "processing-integrity",
"name": "Processing Integrity",
"globalWeight": 16,
"objective": "Démontrer que les traitements critiques sont exacts, complets, traçables et revus.",
"description": "Intégrité des traitements, qualité des flux, détection derreurs et validation.",
"milestones": [
{
"id": "pi-flows",
"title": "Flux critiques identifiés",
"weight": 15,
"owner": "Architecture",
"cadence": "Mensuelle",
"dueAt": "2026-04-28",
"stage": "operating",
"evidenceRefs": [
"ev-006"
],
"notes": "La cartographie existe mais pas encore sur tout le périmètre Type II.",
"nextAction": "Terminer la cartographie des flux administratifs et de preuve."
},
{
"id": "pi-inputs",
"title": "Contrôles dentrée et de changement",
"weight": 20,
"owner": "Ops",
"cadence": "Continue",
"dueAt": "2026-04-22",
"stage": "operating",
"evidenceRefs": [
"ev-003"
],
"notes": "Le lien entre changements techniques et impact métier doit être explicité.",
"nextAction": "Ajouter un champ “impact traitement” dans les changements sensibles."
},
{
"id": "pi-audit",
"title": "Traçabilité et auditabilité des traitements",
"weight": 20,
"owner": "Ops",
"cadence": "Hebdomadaire",
"dueAt": "2026-04-20",
"stage": "proved",
"evidenceRefs": [
"ev-006",
"ev-012"
],
"notes": "Base structurante en place.",
"nextAction": "Mieux exposer la traçabilité pour les auditeurs non techniques."
},
{
"id": "pi-errors",
"title": "Détection et gestion des erreurs",
"weight": 20,
"owner": "Ops",
"cadence": "Hebdomadaire",
"dueAt": "2026-04-21",
"stage": "operating",
"evidenceRefs": [],
"notes": "Le processus existe de façon implicite; il faut le rendre explicite et prouvable.",
"nextAction": "Créer un registre simple danomalies de traitement."
},
{
"id": "pi-validation",
"title": "Validation / tests / réconciliation",
"weight": 15,
"owner": "Ops",
"cadence": "Mensuelle",
"dueAt": "2026-04-26",
"stage": "operating",
"evidenceRefs": [],
"notes": "Présent dans la pratique, mais pas encore uniformisé dans la preuve.",
"nextAction": "Définir trois contrôles de réconciliation standard."
},
{
"id": "pi-review",
"title": "Revue périodique des écarts",
"weight": 10,
"owner": "Direction",
"cadence": "Mensuelle",
"dueAt": "2026-04-30",
"stage": "absent",
"evidenceRefs": [],
"notes": "Cest un vrai trou de gouvernance pour ce pilier.",
"nextAction": "Instaurer une revue mensuelle décarts avec décision consignée."
}
]
},
{
"id": "confidentiality",
"name": "Confidentiality",
"globalWeight": 18,
"objective": "Encadrer laccès, les secrets, la classification, les tiers et le cycle de vie des données confidentielles.",
"description": "Protection des données confidentielles, accès, secrets, conservation et tiers.",
"milestones": [
{
"id": "con-classification",
"title": "Classification des données",
"weight": 15,
"owner": "Architecture",
"cadence": "Trimestrielle",
"dueAt": "2026-05-15",
"stage": "operating",
"evidenceRefs": [
"ev-007"
],
"notes": "Une base existe, mais elle nest pas encore assez normalisée pour laudit.",
"nextAction": "Uniformiser les niveaux de classification et leurs exigences associées."
},
{
"id": "con-access",
"title": "Restriction daccès",
"weight": 25,
"owner": "Ops",
"cadence": "Mensuelle",
"dueAt": "2026-04-22",
"stage": "proved",
"evidenceRefs": [
"ev-002"
],
"notes": "Contrôle robuste, sous réserve dune meilleure preuve de revue formelle.",
"nextAction": "Ajouter lattestation mensuelle du responsable sur les accès sensibles."
},
{
"id": "con-tech",
"title": "Protection technique",
"weight": 20,
"owner": "Ops",
"cadence": "Hebdomadaire",
"dueAt": "2026-04-20",
"stage": "proved",
"evidenceRefs": [],
"notes": "Bonne maîtrise technique, chaîne de preuve encore dispersée.",
"nextAction": "Centraliser les preuves chiffrement/TLS/secrets dans un seul dossier."
},
{
"id": "con-lifecycle",
"title": "Cycle de vie des données",
"weight": 15,
"owner": "Architecture",
"cadence": "Trimestrielle",
"dueAt": "2026-05-08",
"stage": "operating",
"evidenceRefs": [
"ev-007"
],
"notes": "Règles de conservation à préciser selon type de données.",
"nextAction": "Créer une matrice conservation / archivage / destruction."
},
{
"id": "con-thirdparty",
"title": "Tiers et obligations contractuelles",
"weight": 10,
"owner": "Direction",
"cadence": "Trimestrielle",
"dueAt": "2026-05-02",
"stage": "operating",
"evidenceRefs": [
"ev-010"
],
"notes": "Le registre existe, la criticité et le suivi restent à renforcer.",
"nextAction": "Classer les fournisseurs par criticité et lier les clauses sécurité."
},
{
"id": "con-review",
"title": "Preuves de revue et exceptions",
"weight": 15,
"owner": "Direction",
"cadence": "Mensuelle",
"dueAt": "2026-04-30",
"stage": "operating",
"evidenceRefs": [
"ev-012"
],
"notes": "Bonne structure, manque encore des revues exécutées sur la durée.",
"nextAction": "Ajouter la dernière revue mensuelle avec décisions dexception."
}
]
},
{
"id": "privacy",
"name": "Privacy",
"globalWeight": 12,
"objective": "Encadrer les renseignements personnels, leurs finalités, leurs droits et leur suppression.",
"description": "Renseignements personnels, finalités, droits, conservation et incidents.",
"milestones": [
{
"id": "pri-mapping",
"title": "Cartographie des renseignements personnels",
"weight": 20,
"owner": "Direction",
"cadence": "Trimestrielle",
"dueAt": "2026-05-16",
"stage": "operating",
"evidenceRefs": [
"ev-008"
],
"notes": "Démarré, mais encore incomplet sur certains fournisseurs et flux secondaires.",
"nextAction": "Finaliser la source, la finalité et la localisation pour chaque catégorie de RP."
},
{
"id": "pri-rules",
"title": "Règles de collecte et dusage",
"weight": 20,
"owner": "Direction",
"cadence": "Trimestrielle",
"dueAt": "2026-05-18",
"stage": "operating",
"evidenceRefs": [
"ev-008"
],
"notes": "La doctrine existe, la démonstration opérationnelle est incomplète.",
"nextAction": "Documenter explicitement les finalités et la minimisation."
},
{
"id": "pri-rights",
"title": "Droits et demandes des personnes",
"weight": 20,
"owner": "Direction",
"cadence": "Mensuelle",
"dueAt": "2026-04-29",
"stage": "absent",
"evidenceRefs": [],
"notes": "Aucun mécanisme simple et démontrable nest encore en place.",
"nextAction": "Créer un registre de demandes et une procédure courte de traitement."
},
{
"id": "pri-retention",
"title": "Conservation et suppression",
"weight": 20,
"owner": "Architecture",
"cadence": "Trimestrielle",
"dueAt": "2026-05-11",
"stage": "operating",
"evidenceRefs": [
"ev-008"
],
"notes": "Les règles existent partiellement par système, pas encore au niveau programme.",
"nextAction": "Formaliser la matrice de rétention et les méthodes de suppression."
},
{
"id": "pri-incidents",
"title": "Incidents vie privée et tiers",
"weight": 10,
"owner": "Direction",
"cadence": "Trimestrielle",
"dueAt": "2026-05-09",
"stage": "operating",
"evidenceRefs": [
"ev-010"
],
"notes": "La zone tiers est présente, mais langle privacy est sous-documenté.",
"nextAction": "Ajouter le scénario “incident touchant des RP” au registre dincidents."
},
{
"id": "pri-review",
"title": "Revue périodique de conformité vie privée",
"weight": 10,
"owner": "Direction",
"cadence": "Mensuelle",
"dueAt": "2026-04-30",
"stage": "absent",
"evidenceRefs": [],
"notes": "Pas encore de cycle récurrent de revue privacy.",
"nextAction": "Instaurer une revue mensuelle compacte de conformité privacy."
}
]
}
],
"auditorPacks": [
{
"id": "pack-1",
"title": "System Description",
"status": "ready",
"requiredEvidence": [
"ev-001",
"ev-011"
],
"description": "Périmètre, architecture, dépendances, flux de données, rôles et responsabilités."
},
{
"id": "pack-2",
"title": "Management Assertion",
"status": "draft",
"requiredEvidence": [
"ev-012"
],
"description": "Déclaration de la direction sur la fidélité de la description et lefficacité opérationnelle."
},
{
"id": "pack-3",
"title": "Controls ↔ Evidence Matrix",
"status": "ready",
"requiredEvidence": [
"ev-012"
],
"description": "Matrice de rapprochement entre critères, jalons, contrôles, propriétaires et preuves."
},
{
"id": "pack-4",
"title": "Exceptions & Remediation Register",
"status": "draft",
"requiredEvidence": [
"ev-009",
"ev-010"
],
"description": "Écarts connus, compensating controls, décisions et plan de remédiation."
},
{
"id": "pack-5",
"title": "Access & Privileged Activity Pack",
"status": "draft",
"requiredEvidence": [
"ev-002"
],
"description": "Revue des accès, privilèges, MFA et preuves de révocation."
}
]
}

16
index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chezlepro SOC 2 Cockpit</title>
<meta
name="description"
content="Cockpit opérationnel SOC 2 Type II pour Chezlepro Inc. avec radar, jalons, preuves et salle auditeur."
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "chezlepro-soc2-cockpit",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.23.12",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"recharts": "^3.1.2"
},
"devDependencies": {
"vite": "^7.1.3"
}
}

333
src/App.jsx Normal file
View file

@ -0,0 +1,333 @@
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, 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, 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="Persistance locale automatique" />
</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>
);
}

View file

@ -0,0 +1,70 @@
import React from "react";
import { Database, Download, RefreshCcw, Save, Upload } from "lucide-react";
import { MiniKpi, SectionTitle } from "./ui/primitives.jsx";
export default function DataTab({ db, owners, dataHealth, importText, setImportText, importError, onImport, onExport, onCopy, onReset, validation, manifests, schema }) {
return (
<div className="grid data-grid gap-20">
<div className="panel">
<SectionTitle icon={Database} title="Cœur des données" subtitle="Persistance locale, intégrité référentielle, import/export et remise à zéro." />
<div className="grid cols-2 gap-12 mt-16">
<MiniKpi title="Preuves" value={String(db.evidence.length)} />
<MiniKpi title="Jalons" value={String(db.pillars.flatMap((pillar) => pillar.milestones).length)} />
<MiniKpi title="Packs auditeur" value={String(db.auditorPacks.length)} />
<MiniKpi title="Propriétaires" value={String(owners.length)} />
</div>
<div className="grid cols-2 gap-12 mt-16">
<MiniKpi title="Preuves orphelines" value={String(dataHealth.orphanEvidence)} tone={dataHealth.orphanEvidence ? "#fbbf24" : "#86efac"} />
<MiniKpi title="Refs cassées jalons → preuves" value={String(dataHealth.brokenMilestoneRefs)} tone={dataHealth.brokenMilestoneRefs ? "#fda4af" : "#86efac"} />
<MiniKpi title="Refs cassées preuves → jalons" value={String(dataHealth.brokenEvidenceRefs)} tone={dataHealth.brokenEvidenceRefs ? "#fda4af" : "#86efac"} />
<MiniKpi title="Jalons en retard" value={String(dataHealth.overdueMilestones)} tone={dataHealth.overdueMilestones ? "#fbbf24" : "#86efac"} />
</div>
<div className="row wrap gap-8 mt-16">
<button className="btn btn-primary" onClick={onExport}><Download size={16} /> Exporter JSON</button>
<button className="btn btn-secondary" onClick={onCopy}><Save size={16} /> Copier JSON</button>
<button className="btn btn-secondary" onClick={onReset}><RefreshCcw size={16} /> Réinitialiser</button>
</div>
<div className="subpanel mt-16">
Structure persistée : <strong>schemaVersion</strong>, <strong>appContext</strong>, <strong>pillars</strong>, <strong>evidence</strong>, <strong>auditorPacks</strong>.
</div>
<div className={`subpanel mt-16 ${validation.isValid ? "good" : "bad"}`}>
<div className="card-title-xs">Validation du registre</div>
<div className="mt-8">{validation.isValid ? "Le registre respecte le contrat structurel courant." : "Le registre contient des erreurs structurelles à corriger avant versionnement."}</div>
</div>
<div className="subpanel mt-16">
<div className="card-title-xs">Stratégie dépôt</div>
<div className="mt-8">Chemin recommandé : <code>{schema.repositoryStrategy.recommendedPath}</code></div>
</div>
</div>
<div className="stack gap-20">
<div className="panel">
<SectionTitle icon={Upload} title="Importer une base JSON" subtitle="Colle une base complète normalisée. Le cockpit la validera et la chargera." />
<textarea className="textarea textarea-lg mt-16" value={importText} onChange={(e) => setImportText(e.target.value)} placeholder='{"appContext": {}, "pillars": [], "evidence": [], "auditorPacks": []}' />
{importError ? <div className="subpanel bad mt-12">{importError}</div> : null}
<div className="row wrap gap-8 mt-16">
<button className="btn btn-primary" onClick={onImport}><Upload size={16} /> Importer</button>
<button className="btn btn-secondary" onClick={() => setImportText(JSON.stringify(db, null, 2))}>Préremplir avec la base courante</button>
</div>
</div>
<div className="panel">
<SectionTitle icon={Database} title="Contrat et manifests" subtitle="Schéma officiel et artefacts dérivés pour un dépôt Git versionné." />
<textarea className="textarea textarea-md mt-16" readOnly value={JSON.stringify(schema, null, 2)} />
<textarea className="textarea textarea-md mt-16" readOnly value={JSON.stringify(manifests, null, 2)} />
{(validation.errors.length > 0 || validation.warnings.length > 0) && (
<div className="grid cols-2 gap-12 mt-16">
<div className="subpanel bad">
<div className="card-title-xs">Erreurs</div>
<ul className="mt-8 list-reset">{validation.errors.length ? validation.errors.map((x) => <li key={x}> {x}</li>) : <li> Aucune</li>}</ul>
</div>
<div className="subpanel warn">
<div className="card-title-xs">Avertissements</div>
<ul className="mt-8 list-reset">{validation.warnings.length ? validation.warnings.map((x) => <li key={x}> {x}</li>) : <li> Aucun</li>}</ul>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,88 @@
import React from "react";
import { Database, Link2, Trash2, Save, Unlink2, Layers3 } from "lucide-react";
import { PILLAR_ICONS } from "../lib/view.js";
import { Field, SectionTitle } from "./ui/primitives.jsx";
export default function EvidenceEditor({ evidence, owners, pillars, onUpdate, onDelete, onToggleLink }) {
if (!evidence) {
return (
<div className="panel panel-tall">
<SectionTitle icon={Database} title="Fiche preuve" subtitle="Aucune preuve sélectionnée." />
<div className="subpanel mt-16">Aucune preuve sélectionnée.</div>
</div>
);
}
return (
<div className="panel panel-tall">
<SectionTitle icon={Database} title="Fiche preuve" subtitle="Édite la preuve sélectionnée et gère ses liaisons avec les jalons." />
<div className="stack mt-16">
<div className="grid cols-2 gap-12">
<Field label="Titre"><input className="input" value={evidence.title} onChange={(e) => onUpdate(evidence.id, { title: e.target.value })} /></Field>
<Field label="Catégorie"><input className="input" value={evidence.category} onChange={(e) => onUpdate(evidence.id, { category: e.target.value })} /></Field>
<Field label="Pilier">
<select className="input" value={evidence.pillar} onChange={(e) => onUpdate(evidence.id, { pillar: e.target.value })}>
{pillars.map((pillar) => <option key={pillar.id} value={pillar.id}>{pillar.name}</option>)}
</select>
</Field>
<Field label="Propriétaire">
<select className="input" value={evidence.owner} onChange={(e) => onUpdate(evidence.id, { owner: e.target.value })}>
{owners.map((owner) => <option key={owner} value={owner}>{owner}</option>)}
</select>
</Field>
<Field label="Fraîcheur">
<select className="input" value={evidence.freshness} onChange={(e) => onUpdate(evidence.id, { freshness: e.target.value })}>
<option value="Valide">Valide</option>
<option value="À revoir">À revoir</option>
<option value="Brouillon">Brouillon</option>
</select>
</Field>
<Field label="Visibilité">
<select className="input" value={evidence.visibility} onChange={(e) => onUpdate(evidence.id, { visibility: e.target.value })}>
<option value="internal">Interne</option>
<option value="auditor">Auditeur</option>
</select>
</Field>
</div>
<div className="grid cols-2 gap-12">
<Field label="Source"><input className="input" value={evidence.source} onChange={(e) => onUpdate(evidence.id, { source: e.target.value })} /></Field>
<Field label="Tags (séparés par virgules)"><input className="input" value={evidence.tags.join(", ")} onChange={(e) => onUpdate(evidence.id, { tags: e.target.value.split(",").map((v) => v.trim()).filter(Boolean) })} /></Field>
</div>
<Field label="Résumé"><textarea className="textarea" value={evidence.summary} onChange={(e) => onUpdate(evidence.id, { summary: e.target.value })} /></Field>
<div className="row wrap gap-8">
<button className={`btn ${evidence.auditorReady ? "btn-primary" : "btn-secondary"}`} onClick={() => onUpdate(evidence.id, { auditorReady: !evidence.auditorReady })}><Save size={16} /> {evidence.auditorReady ? "Marquée auditable" : "Marquer auditable"}</button>
<button className="btn btn-secondary" onClick={() => onDelete(evidence.id)}><Trash2 size={16} /> Supprimer</button>
</div>
<div className="separator" />
<div>
<div className="row gap-8 align-center"><Link2 size={16} /><span className="card-title-xs">Liaisons jalons preuve</span></div>
<div className="stack mt-16">
{pillars.map((pillar) => {
const Icon = PILLAR_ICONS[pillar.id] || Layers3;
return (
<div key={pillar.id} className="subpanel">
<div className="row gap-8 align-center"><Icon size={16} /><span>{pillar.name}</span></div>
<div className="row wrap gap-8 mt-12">
{pillar.milestones.map((milestone) => {
const linked = evidence.linkedMilestones.includes(milestone.id);
return (
<button key={milestone.id} className={`btn small ${linked ? "btn-info" : "btn-secondary"}`} onClick={() => onToggleLink(evidence.id, pillar.id, milestone.id)}>
{linked ? <Unlink2 size={14} /> : <Link2 size={14} />} {milestone.id}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,57 @@
import React from "react";
import { Filter, FolderOpen, Plus, Search, Users } from "lucide-react";
import { PILLAR_ICONS } from "../lib/view.js";
import { SectionTitle, Tag } from "./ui/primitives.jsx";
export default function EvidenceLibrary({ evidence, selectedEvidenceId, onSelect, filters, owners, pillars, onCreate }) {
return (
<div className="panel panel-tall">
<div className="row space-between align-end gap-12">
<SectionTitle icon={FolderOpen} title="Bibliothèque de preuves" subtitle="Recherche, filtrage, édition et rattachement des pièces pertinentes." />
<button className="btn btn-secondary" onClick={onCreate}><Plus size={16} /> Ajouter</button>
</div>
<div className="grid filter-grid mt-16 gap-12">
<label className="input-wrap with-icon"><Search size={16} /><input value={filters.search} onChange={(e) => filters.setSearch(e.target.value)} placeholder="Rechercher une preuve, un tag, une source…" /></label>
<label className="input-wrap with-icon">
<Filter size={16} />
<select value={filters.evidenceFilter} onChange={(e) => filters.setEvidenceFilter(e.target.value)}>
<option value="all">Tous les piliers</option>
{pillars.map((pillar) => <option key={pillar.id} value={pillar.id}>{pillar.name}</option>)}
</select>
</label>
<label className="input-wrap with-icon">
<Users size={16} />
<select value={filters.ownerFilter} onChange={(e) => filters.setOwnerFilter(e.target.value)}>
<option value="all">Tous les propriétaires</option>
{owners.map((owner) => <option key={owner} value={owner}>{owner}</option>)}
</select>
</label>
</div>
<div className="scroll-area mt-16">
<div className="stack">
{evidence.map((item) => {
const Icon = PILLAR_ICONS[item.pillar] || FolderOpen;
return (
<button key={item.id} className={`panel item-button ${selectedEvidenceId === item.id ? "selected" : ""}`} onClick={() => onSelect(item.id)}>
<div className="row gap-12 align-start">
<div className="icon-chip"><Icon size={16} /></div>
<div className="grow text-left">
<div className="card-title-sm truncate-1">{item.title}</div>
<div className="row wrap gap-8 mt-8">
<Tag>{item.category}</Tag>
<Tag>{item.owner}</Tag>
<Tag>{item.visibility}</Tag>
</div>
<div className="muted mt-8 truncate-2">{item.summary || "Aucun résumé."}</div>
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,67 @@
import React from "react";
import { STAGE_META } from "../model/stages.js";
import { computeMilestoneScore, getDeadlineTone } from "../lib/selectors.js";
import { daysUntil } from "../lib/utils.js";
import { MiniKpi, ProgressBar, Tag } from "./ui/primitives.jsx";
export default function MilestoneCard({ pillarId, milestone, evidenceMap, linkedEvidence, onStageChange }) {
const meta = STAGE_META[milestone.stage];
const MetaIcon = meta.icon;
const dueInDays = daysUntil(milestone.dueAt);
const milestoneScore = computeMilestoneScore(milestone, evidenceMap);
return (
<div className="panel">
<div className="row wrap space-between align-start gap-12">
<div className="row gap-12 align-start">
<div className="icon-chip"><MetaIcon size={16} /></div>
<div>
<div className="card-title-sm">{milestone.title}</div>
<div className="muted mt-6">{meta.hint}</div>
</div>
</div>
<div className="row wrap gap-8">
<Tag>Poids {milestone.weight}</Tag>
<Tag tone={getDeadlineTone(dueInDays)}>{dueInDays < 0 ? `En retard ${Math.abs(dueInDays)} j` : `Échéance ${dueInDays} j`}</Tag>
<Tag>{meta.label}</Tag>
</div>
</div>
<div className="grid cols-4 mt-16 gap-12">
<MiniKpi title="Score jalon" value={`${milestoneScore}%`} />
<MiniKpi title="Propriétaire" value={milestone.owner} />
<MiniKpi title="Cadence" value={milestone.cadence} />
<MiniKpi title="Preuves liées" value={String(linkedEvidence.length)} />
</div>
<div className="grid milestone-meter mt-16 gap-12">
<div>
<div className="row space-between tiny muted"><span>Maturité mesurée</span><span className="strong">{milestoneScore}%</span></div>
<ProgressBar value={milestoneScore} />
</div>
<div className="row wrap gap-8">
{Object.entries(STAGE_META).map(([value, stage]) => (
<button
key={value}
className={`btn small ${milestone.stage === value ? "btn-primary" : "btn-secondary"}`}
onClick={() => onStageChange(pillarId, milestone.id, value)}
>
{stage.label}
</button>
))}
</div>
</div>
<div className="grid cols-2 mt-16 gap-12">
<div className="subpanel">
<div className="card-title-xs">Notes détat</div>
<div className="muted mt-8">{milestone.notes}</div>
</div>
<div className="subpanel subpanel-info">
<div className="card-title-xs">Prochaine action utile</div>
<div className="mt-8">{milestone.nextAction}</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import React from "react";
import { Layers3 } from "lucide-react";
import { PILLAR_ICONS } from "../lib/view.js";
import { ProgressBar, Tag } from "./ui/primitives.jsx";
export default function PillarOverviewCard({ pillar, selected, onSelect }) {
const Icon = PILLAR_ICONS[pillar.id] || Layers3;
return (
<button className={`panel pillar-card ${selected ? "selected" : ""}`} onClick={() => onSelect(pillar.id)}>
<div className="row space-between align-start">
<div className="icon-chip"><Icon size={18} /></div>
<Tag>{pillar.globalWeight}% effort</Tag>
</div>
<div className="mt-16">
<div className="card-title">{pillar.name}</div>
<div className="muted mt-8">{pillar.objective}</div>
</div>
<div className="stack mt-16">
<div>
<div className="row space-between tiny muted"><span>Score</span><span className="strong">{pillar.score}%</span></div>
<ProgressBar value={pillar.score} />
</div>
<div>
<div className="row space-between tiny muted"><span>Couverture des preuves</span><span className="strong">{pillar.evidenceCoverage}%</span></div>
<ProgressBar value={pillar.evidenceCoverage} />
</div>
<div className="tiny muted">{pillar.overdueCount} jalon(x) en retard</div>
</div>
</button>
);
}

View file

@ -0,0 +1,53 @@
import React from "react";
export function ScorePill({ label, value, sublabel }) {
return (
<div className="pill">
<div className="eyebrow">{label}</div>
<div className="pill-value">{value}</div>
<div className="pill-subtitle">{sublabel}</div>
</div>
);
}
export function SectionTitle({ icon: Icon, title, subtitle }) {
return (
<div className="section-title-wrap">
<div className="section-title-icon">{Icon ? <Icon size={18} /> : null}</div>
<div>
<h2 className="section-title">{title}</h2>
<p className="section-subtitle">{subtitle}</p>
</div>
</div>
);
}
export function MiniKpi({ title, value, tone = "inherit" }) {
return (
<div className="mini-kpi">
<div className="eyebrow">{title}</div>
<div className="mini-kpi-value" style={{ color: tone === "inherit" ? undefined : tone }}>{value}</div>
</div>
);
}
export function Field({ label, children }) {
return (
<label className="field-block">
<div className="eyebrow field-label">{label}</div>
{children}
</label>
);
}
export function ProgressBar({ value }) {
return (
<div className="progress-track">
<div className="progress-fill" style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
</div>
);
}
export function Tag({ children, tone = "default" }) {
return <span className={`tag tag-${tone}`}>{children}</span>;
}

View file

@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { INITIAL_DB } from "../../model/seed.js";
import { STORAGE_KEY, TODAY } from "../../model/schema.js";
import { clone } from "../../lib/utils.js";
import { normalizeDb } from "../../lib/validation.js";
function loadDb() {
if (typeof window === "undefined") return clone(INITIAL_DB);
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return clone(INITIAL_DB);
return normalizeDb(JSON.parse(raw));
} catch {
return clone(INITIAL_DB);
}
}
export function useSoc2Registry() {
const [db, setDb] = useState(() => loadDb());
const [lastSavedAt, setLastSavedAt] = useState(null);
const [importError, setImportError] = useState("");
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2));
setLastSavedAt(new Date().toLocaleString("fr-CA"));
}, [db]);
const updateMilestoneStage = (pillarId, milestoneId, nextStage) => {
setDb((current) => ({
...current,
pillars: current.pillars.map((pillar) =>
pillar.id !== pillarId
? pillar
: {
...pillar,
milestones: pillar.milestones.map((milestone) =>
milestone.id === milestoneId ? { ...milestone, stage: nextStage } : milestone
),
}
),
}));
};
const updateEvidence = (evidenceId, patch) => {
setDb((current) => ({
...current,
evidence: current.evidence.map((item) =>
item.id === evidenceId ? { ...item, ...patch, updatedAt: patch.updatedAt || TODAY } : item
),
}));
};
const toggleEvidenceMilestoneLink = (evidenceId, pillarId, milestoneId) => {
setDb((current) => {
const evidence = current.evidence.find((item) => item.id === evidenceId);
if (!evidence) return current;
const hasLink = evidence.linkedMilestones.includes(milestoneId);
return {
...current,
evidence: current.evidence.map((item) =>
item.id !== evidenceId
? item
: {
...item,
linkedMilestones: hasLink
? item.linkedMilestones.filter((id) => id !== milestoneId)
: [...item.linkedMilestones, milestoneId],
updatedAt: TODAY,
}
),
pillars: current.pillars.map((pillar) =>
pillar.id !== pillarId
? pillar
: {
...pillar,
milestones: pillar.milestones.map((milestone) => {
if (milestone.id !== milestoneId) return milestone;
const alreadyLinked = milestone.evidenceRefs.includes(evidenceId);
return {
...milestone,
evidenceRefs: hasLink
? milestone.evidenceRefs.filter((id) => id !== evidenceId)
: alreadyLinked
? milestone.evidenceRefs
: [...milestone.evidenceRefs, evidenceId],
};
}),
}
),
};
});
};
const createEvidence = (selectedPillar) => {
const maxId = db.evidence
.map((item) => Number(String(item.id).replace("ev-", "")))
.filter((n) => Number.isFinite(n))
.reduce((max, n) => Math.max(max, n), 0);
const nextId = `ev-${String(maxId + 1).padStart(3, "0")}`;
const seed = {
id: nextId,
title: "Nouvelle preuve",
category: "General",
pillar: selectedPillar,
owner: "Ops",
freshness: "Brouillon",
updatedAt: TODAY,
auditorReady: false,
visibility: "internal",
source: "Source à définir",
tags: [],
linkedMilestones: [],
summary: "",
};
setDb((current) => ({ ...current, evidence: [seed, ...current.evidence] }));
return nextId;
};
const deleteEvidence = (evidenceId) => {
setDb((current) => ({
...current,
evidence: current.evidence.filter((item) => item.id !== evidenceId),
pillars: current.pillars.map((pillar) => ({
...pillar,
milestones: pillar.milestones.map((milestone) => ({
...milestone,
evidenceRefs: milestone.evidenceRefs.filter((ref) => ref !== evidenceId),
})),
})),
auditorPacks: current.auditorPacks.map((pack) => ({
...pack,
requiredEvidence: pack.requiredEvidence.filter((ref) => ref !== evidenceId),
})),
}));
};
const exportDb = () => {
const blob = new Blob([JSON.stringify(db, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chezlepro-soc2-cockpit.json";
a.click();
URL.revokeObjectURL(url);
};
const copyDb = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(db, null, 2));
} catch {
// clipboard may be unavailable
}
};
const importDb = (payload) => {
setImportError("");
try {
const parsed = JSON.parse(payload);
const normalized = normalizeDb(parsed);
setDb(normalized);
return { ok: true, data: normalized };
} catch {
const error = "Import invalide : le JSON na pas pu être interprété.";
setImportError(error);
return { ok: false, error };
}
};
const resetDemo = () => {
setDb(clone(INITIAL_DB));
};
return {
db,
lastSavedAt,
importError,
updateMilestoneStage,
updateEvidence,
toggleEvidenceMilestoneLink,
createEvidence,
deleteEvidence,
exportDb,
copyDb,
importDb,
resetDemo,
};
}

150
src/lib/selectors.js Normal file
View file

@ -0,0 +1,150 @@
import { STAGE_META } from "../model/stages.js";
import { daysUntil, normalizeArray, round } from "./utils.js";
export function buildEvidenceMap(db) {
return Object.fromEntries(db.evidence.map((item) => [item.id, item]));
}
export function getOwners(db) {
return Array.from(
new Set([
...db.pillars.flatMap((pillar) => pillar.milestones.map((milestone) => milestone.owner)),
...db.evidence.map((item) => item.owner),
])
).sort();
}
export function evidenceCoverageRatio(milestone, evidenceMap) {
const linked = normalizeArray(milestone.evidenceRefs)
.map((id) => evidenceMap[id])
.filter(Boolean);
if (!linked.length) return 0;
const quality = linked.reduce((sum, evidence) => sum + (evidence.auditorReady ? 1 : 0.5), 0);
return Math.min(1, quality / 2);
}
export function computeMilestoneScore(milestone, evidenceMap) {
const stageScore = STAGE_META[milestone.stage]?.score ?? 0;
const evidenceBonus = milestone.stage === "proved" ? 1 : evidenceCoverageRatio(milestone, evidenceMap);
return round(stageScore * 100 * (0.75 + evidenceBonus * 0.25));
}
export function computePillarScore(pillar, evidenceMap) {
const totalWeight = pillar.milestones.reduce((sum, milestone) => sum + milestone.weight, 0);
const earned = pillar.milestones.reduce((sum, milestone) => {
const score = computeMilestoneScore(milestone, evidenceMap) / 100;
return sum + milestone.weight * score;
}, 0);
return totalWeight ? round((earned / totalWeight) * 100) : 0;
}
export function computeEvidenceCoverageForPillar(pillar) {
const refs = pillar.milestones.flatMap((milestone) => normalizeArray(milestone.evidenceRefs));
const uniqueRefs = Array.from(new Set(refs));
const totalMilestones = pillar.milestones.length;
if (!totalMilestones) return 0;
return round(Math.min(100, (uniqueRefs.length / (totalMilestones * 1.5)) * 100));
}
export function computeGlobalScore(pillars, evidenceMap) {
const total = pillars.reduce((sum, pillar) => sum + pillar.globalWeight, 0);
const weighted = pillars.reduce((sum, pillar) => sum + computePillarScore(pillar, evidenceMap) * pillar.globalWeight, 0);
return total ? round(weighted / total) : 0;
}
export function getOverallStatus(score) {
if (score >= 85) return { label: "Très proche de laudit", tone: "is-good" };
if (score >= 70) return { label: "Structuré et crédible", tone: "is-info" };
if (score >= 55) return { label: "En construction sérieuse", tone: "is-warn" };
return { label: "Travail substantiel requis", tone: "is-bad" };
}
export function getPackTone(status) {
if (status === "ready") return "is-good";
if (status === "draft") return "is-warn";
return "is-muted";
}
export function getDeadlineTone(days) {
if (days < 0) return "is-bad";
if (days <= 7) return "is-warn";
return "is-muted";
}
export function buildScoredPillars(db, evidenceMap) {
return db.pillars.map((pillar) => ({
...pillar,
score: computePillarScore(pillar, evidenceMap),
evidenceCoverage: computeEvidenceCoverageForPillar(pillar),
overdueCount: pillar.milestones.filter((milestone) => daysUntil(milestone.dueAt) < 0).length,
}));
}
export function buildRadarData(scoredPillars, targetReadiness) {
return scoredPillars.map((pillar) => ({
subject: pillar.name,
score: pillar.score,
target: targetReadiness,
}));
}
export function buildBarData(pillar, evidenceMap) {
return pillar.milestones.map((milestone) => ({
name: milestone.title.length > 30 ? `${milestone.title.slice(0, 30)}` : milestone.title,
score: computeMilestoneScore(milestone, evidenceMap),
}));
}
export function buildBlockers(db, evidenceMap) {
return db.pillars
.flatMap((pillar) =>
pillar.milestones.map((milestone) => ({
pillarId: pillar.id,
pillarName: pillar.name,
...milestone,
score: computeMilestoneScore(milestone, evidenceMap),
dueInDays: daysUntil(milestone.dueAt),
}))
)
.filter((milestone) => milestone.stage !== "proved" || milestone.dueInDays < 0)
.sort((a, b) => {
const overdueDelta = a.dueInDays - b.dueInDays;
if (overdueDelta !== 0) return overdueDelta;
return a.score - b.score;
})
.slice(0, 8);
}
export function filterEvidence(db, params) {
const { query, pillarId, owner, auditorMode } = params;
const q = query.trim().toLowerCase();
return db.evidence.filter((item) => {
const searchOk = !q || [item.title, item.category, item.owner, item.source, ...(item.tags || [])].join(" ").toLowerCase().includes(q);
const pillarOk = pillarId === "all" || item.pillar === pillarId;
const ownerOk = owner === "all" || item.owner === owner;
const visibilityOk = auditorMode ? item.auditorReady && item.visibility === "auditor" : true;
return searchOk && pillarOk && ownerOk && visibilityOk;
});
}
export function buildPackReadiness(db) {
return db.auditorPacks.map((pack) => {
const linked = db.evidence.filter((evidence) => pack.requiredEvidence.includes(evidence.id));
const readyCount = linked.filter((evidence) => evidence.auditorReady).length;
const readiness = linked.length ? round((readyCount / linked.length) * 100) : 0;
return { ...pack, readiness, linked };
});
}
export function buildDataHealth(db) {
const milestoneIds = new Set(db.pillars.flatMap((pillar) => pillar.milestones.map((milestone) => milestone.id)));
const evidenceIds = new Set(db.evidence.map((item) => item.id));
return {
orphanEvidence: db.evidence.filter((item) => item.linkedMilestones.length === 0).length,
brokenMilestoneRefs: db.pillars.flatMap((pillar) => pillar.milestones.flatMap((milestone) => milestone.evidenceRefs.filter((ref) => !evidenceIds.has(ref)))).length,
brokenEvidenceRefs: db.evidence.flatMap((item) => item.linkedMilestones.filter((ref) => !milestoneIds.has(ref))).length,
overdueMilestones: db.pillars.flatMap((pillar) => pillar.milestones).filter((milestone) => daysUntil(milestone.dueAt) < 0).length,
};
}

47
src/lib/utils.js Normal file
View file

@ -0,0 +1,47 @@
import { TODAY } from "../model/schema.js";
import { INITIAL_DB } from "../model/seed.js";
export function round(n) {
return Math.round(n * 10) / 10;
}
export function clone(obj) {
return JSON.parse(JSON.stringify(obj));
}
export function parseDate(dateString) {
return new Date(`${dateString}T00:00:00`);
}
export function daysUntil(dateString) {
const today = parseDate(TODAY);
const due = parseDate(dateString);
const diffMs = due.getTime() - today.getTime();
return Math.ceil(diffMs / (1000 * 60 * 60 * 24));
}
export function normalizeArray(value) {
return Array.isArray(value) ? value.filter(Boolean) : [];
}
export function normalizeTags(value) {
if (Array.isArray(value)) return value.map(String).map((v) => v.trim()).filter(Boolean);
if (typeof value === "string") return value.split(",").map((v) => v.trim()).filter(Boolean);
return [];
}
export function buildTemplates() {
const milestoneTemplates = {};
INITIAL_DB.pillars.forEach((pillar) => {
pillar.milestones.forEach((milestone) => {
milestoneTemplates[milestone.id] = clone(milestone);
});
});
return {
pillarTemplates: Object.fromEntries(INITIAL_DB.pillars.map((pillar) => [pillar.id, clone(pillar)])),
milestoneTemplates,
evidenceTemplates: Object.fromEntries(INITIAL_DB.evidence.map((evidence) => [evidence.id, clone(evidence)])),
packTemplates: Object.fromEntries(INITIAL_DB.auditorPacks.map((pack) => [pack.id, clone(pack)])),
};
}

163
src/lib/validation.js Normal file
View file

@ -0,0 +1,163 @@
import {
OFFICIAL_SCHEMA,
SCHEMA_VERSION,
TODAY,
VALID_EVIDENCE_FRESHNESS,
VALID_EVIDENCE_VISIBILITY,
VALID_MILESTONE_STAGES,
VALID_PACK_STATUS,
VALID_PILLAR_IDS,
} from "../model/schema.js";
import { INITIAL_DB } from "../model/seed.js";
import { buildTemplates, clone, normalizeArray, normalizeTags } from "./utils.js";
const TEMPLATES = buildTemplates();
function validateDateString(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(String(value || ""));
}
export function normalizeDb(raw) {
const db = raw && typeof raw === "object" ? raw : {};
const schemaVersion = db.schemaVersion || SCHEMA_VERSION;
const appContext = {
...clone(INITIAL_DB.appContext),
...(db.appContext || {}),
observationWindow: {
...clone(INITIAL_DB.appContext.observationWindow),
...(db.appContext?.observationWindow || {}),
},
};
const evidence = normalizeArray(db.evidence).map((item) => {
const template = TEMPLATES.evidenceTemplates[item.id] || {};
return {
id: item.id || `ev-${Math.random().toString(36).slice(2, 8)}`,
title: item.title || template.title || "Nouvelle preuve",
category: item.category || template.category || "General",
pillar: item.pillar || template.pillar || "security",
owner: item.owner || template.owner || "Ops",
freshness: item.freshness || template.freshness || "Brouillon",
updatedAt: item.updatedAt || template.updatedAt || TODAY,
auditorReady: Boolean(item.auditorReady ?? template.auditorReady ?? false),
visibility: item.visibility || template.visibility || "internal",
source: item.source || template.source || "Source à définir",
tags: normalizeTags(item.tags || template.tags),
linkedMilestones: normalizeArray(item.linkedMilestones || template.linkedMilestones),
summary: item.summary || template.summary || "",
};
});
const pillars = normalizeArray(db.pillars).map((pillar) => {
const template = TEMPLATES.pillarTemplates[pillar.id] || {};
return {
id: pillar.id || template.id,
name: pillar.name || template.name || "Pillar",
globalWeight: Number(pillar.globalWeight ?? template.globalWeight ?? 10),
objective: pillar.objective || template.objective || "",
description: pillar.description || template.description || "",
milestones: normalizeArray(pillar.milestones).map((milestone) => {
const milestoneTemplate = TEMPLATES.milestoneTemplates[milestone.id] || {};
return {
id: milestone.id || `ms-${Math.random().toString(36).slice(2, 8)}`,
title: milestone.title || milestoneTemplate.title || "Nouveau jalon",
weight: Number(milestone.weight ?? milestoneTemplate.weight ?? 10),
owner: milestone.owner || milestoneTemplate.owner || "Ops",
cadence: milestone.cadence || milestoneTemplate.cadence || "Mensuelle",
dueAt: milestone.dueAt || milestoneTemplate.dueAt || TODAY,
stage: milestone.stage || milestoneTemplate.stage || "absent",
evidenceRefs: normalizeArray(milestone.evidenceRefs || milestoneTemplate.evidenceRefs),
notes: milestone.notes || milestoneTemplate.notes || "",
nextAction: milestone.nextAction || milestoneTemplate.nextAction || "",
};
}),
};
});
const auditorPacks = normalizeArray(db.auditorPacks).map((pack) => {
const template = TEMPLATES.packTemplates[pack.id] || {};
return {
id: pack.id || `pack-${Math.random().toString(36).slice(2, 8)}`,
title: pack.title || template.title || "Pack auditeur",
status: pack.status || template.status || "draft",
requiredEvidence: normalizeArray(pack.requiredEvidence || template.requiredEvidence),
description: pack.description || template.description || "",
};
});
return { schemaVersion, appContext, evidence, pillars, auditorPacks };
}
export function validateRegistry(db) {
const errors = [];
const warnings = [];
if (db.schemaVersion !== SCHEMA_VERSION) {
warnings.push(`Version de schéma attendue ${SCHEMA_VERSION}, trouvée ${db.schemaVersion || "absente"}.`);
}
const pillarIds = new Set(db.pillars.map((pillar) => pillar.id));
const evidenceIds = new Set(db.evidence.map((item) => item.id));
const milestoneIds = new Set(db.pillars.flatMap((pillar) => pillar.milestones.map((milestone) => milestone.id)));
if (pillarIds.size !== db.pillars.length) errors.push("Des ids de piliers sont dupliqués.");
if (evidenceIds.size !== db.evidence.length) errors.push("Des ids de preuves sont dupliqués.");
if (milestoneIds.size !== db.pillars.flatMap((pillar) => pillar.milestones).length) errors.push("Des ids de jalons sont dupliqués.");
db.pillars.forEach((pillar) => {
if (!VALID_PILLAR_IDS.includes(pillar.id)) errors.push(`Pilier invalide: ${pillar.id}`);
pillar.milestones.forEach((milestone) => {
if (!VALID_MILESTONE_STAGES.includes(milestone.stage)) errors.push(`Stage invalide pour le jalon ${milestone.id}: ${milestone.stage}`);
if (!validateDateString(milestone.dueAt)) errors.push(`Date d'échéance invalide pour le jalon ${milestone.id}: ${milestone.dueAt}`);
milestone.evidenceRefs.forEach((ref) => {
if (!evidenceIds.has(ref)) errors.push(`Le jalon ${milestone.id} référence une preuve inexistante: ${ref}`);
});
if (milestone.stage === "proved" && milestone.evidenceRefs.length === 0) warnings.push(`Le jalon ${milestone.id} est marqué prouvé sans preuve liée.`);
});
});
db.evidence.forEach((item) => {
if (!VALID_PILLAR_IDS.includes(item.pillar)) errors.push(`Pilier invalide pour la preuve ${item.id}: ${item.pillar}`);
if (!VALID_EVIDENCE_VISIBILITY.includes(item.visibility)) errors.push(`Visibilité invalide pour la preuve ${item.id}: ${item.visibility}`);
if (!VALID_EVIDENCE_FRESHNESS.includes(item.freshness)) warnings.push(`Fraîcheur non standard pour la preuve ${item.id}: ${item.freshness}`);
if (!validateDateString(item.updatedAt)) errors.push(`Date de mise à jour invalide pour la preuve ${item.id}: ${item.updatedAt}`);
item.linkedMilestones.forEach((ref) => {
if (!milestoneIds.has(ref)) errors.push(`La preuve ${item.id} référence un jalon inexistant: ${ref}`);
});
if (item.auditorReady && item.visibility !== "auditor") warnings.push(`La preuve ${item.id} est auditable mais non visible côté auditeur.`);
});
db.auditorPacks.forEach((pack) => {
if (!VALID_PACK_STATUS.includes(pack.status)) errors.push(`Statut de pack invalide pour ${pack.id}: ${pack.status}`);
pack.requiredEvidence.forEach((ref) => {
if (!evidenceIds.has(ref)) errors.push(`Le pack ${pack.id} référence une preuve inexistante: ${ref}`);
});
});
const orphanEvidence = db.evidence.filter((item) => item.linkedMilestones.length === 0);
if (orphanEvidence.length) warnings.push(`${orphanEvidence.length} preuve(s) orpheline(s) sans jalon lié.`);
return { isValid: errors.length === 0, errors, warnings };
}
export function buildRegistryManifests(db) {
const evidenceManifest = db.evidence.map((item) => ({
id: item.id,
title: item.title,
pillar: item.pillar,
auditorReady: item.auditorReady,
visibility: item.visibility,
linkedMilestones: item.linkedMilestones,
updatedAt: item.updatedAt,
}));
const auditorPackIndex = db.auditorPacks.map((pack) => ({
id: pack.id,
title: pack.title,
status: pack.status,
requiredEvidence: pack.requiredEvidence,
}));
return { evidenceManifest, auditorPackIndex, schema: OFFICIAL_SCHEMA };
}

9
src/lib/view.js Normal file
View file

@ -0,0 +1,9 @@
import { FileCheck, Lock, Server, Shield, UserRound } from "lucide-react";
export const PILLAR_ICONS = {
security: Shield,
availability: Server,
"processing-integrity": FileCheck,
confidentiality: Lock,
privacy: UserRound,
};

10
src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

70
src/model/schema.js Normal file
View file

@ -0,0 +1,70 @@
export const STORAGE_KEY = "chezlepro_soc2_cockpit_db_v1";
export const SCHEMA_VERSION = "1.0.0";
export const TODAY = "2026-04-17";
export const VALID_PILLAR_IDS = [
"security",
"availability",
"processing-integrity",
"confidentiality",
"privacy",
];
export const VALID_EVIDENCE_VISIBILITY = ["internal", "auditor"];
export const VALID_EVIDENCE_FRESHNESS = ["Valide", "À revoir", "Brouillon"];
export const VALID_PACK_STATUS = ["ready", "draft", "na"];
export const VALID_MILESTONE_STAGES = ["absent", "designed", "operating", "proved"];
export const OFFICIAL_SCHEMA = {
schemaVersion: SCHEMA_VERSION,
topLevel: {
appContext: {
required: ["orgName", "programName", "observationWindow"],
observationWindow: ["start", "end", "targetReadiness"],
},
pillars: {
identity: "id",
required: ["id", "name", "globalWeight", "objective", "milestones"],
milestoneRequired: [
"id",
"title",
"weight",
"owner",
"cadence",
"dueAt",
"stage",
"evidenceRefs",
],
},
evidence: {
identity: "id",
required: [
"id",
"title",
"category",
"pillar",
"owner",
"freshness",
"updatedAt",
"visibility",
"linkedMilestones",
],
},
auditorPacks: {
identity: "id",
required: ["id", "title", "status", "requiredEvidence", "description"],
},
},
enums: {
pillarIds: VALID_PILLAR_IDS,
milestoneStages: VALID_MILESTONE_STAGES,
evidenceVisibility: VALID_EVIDENCE_VISIBILITY,
evidenceFreshness: VALID_EVIDENCE_FRESHNESS,
packStatus: VALID_PACK_STATUS,
},
repositoryStrategy: {
recommendedPath: "compliance/soc2/registry/soc2-registry.json",
attachmentsPath: "compliance/soc2/evidence/",
exportArtifacts: ["soc2-registry.json", "auditor-pack-index.json", "evidence-manifest.json"],
},
};

642
src/model/seed.js Normal file
View file

@ -0,0 +1,642 @@
import { SCHEMA_VERSION } from "./schema.js";
export const APP_CONTEXT = {
orgName: "Chezlepro Inc.",
programName: "SOC 2 Type II Operational Cockpit",
observationWindow: {
start: "2026-01-01",
end: "2026-06-30",
targetReadiness: 85,
},
};
export const INITIAL_DB = {
schemaVersion: SCHEMA_VERSION,
appContext: APP_CONTEXT,
evidence: [
{
id: "ev-001",
title: "Politique de sécurité de linformation v1.4",
category: "Policy",
pillar: "security",
owner: "Direction",
freshness: "Valide",
updatedAt: "2026-04-09",
auditorReady: true,
visibility: "auditor",
source: "Wiki / Gouvernance",
tags: ["policy", "security", "governance"],
linkedMilestones: ["sec-governance"],
summary: "Politique cadre de sécurité approuvée et versionnée.",
},
{
id: "ev-002",
title: "Export Keycloak MFA et rôles administrateurs",
category: "Access",
pillar: "security",
owner: "Ops",
freshness: "À revoir",
updatedAt: "2026-03-29",
auditorReady: false,
visibility: "internal",
source: "Keycloak / Export CSV",
tags: ["iam", "mfa", "admin"],
linkedMilestones: ["sec-iam", "con-access"],
summary: "État courant des administrateurs, rôles et MFA; manque la revue signée.",
},
{
id: "ev-003",
title: "Journal des changements Forgejo / Pull Requests",
category: "Change",
pillar: "security",
owner: "Ops",
freshness: "Valide",
updatedAt: "2026-04-15",
auditorReady: true,
visibility: "auditor",
source: "Forgejo / Repositories",
tags: ["change", "git", "approval"],
linkedMilestones: ["sec-change", "pi-inputs"],
summary: "Historique des changements, approbations et validations post-déploiement.",
},
{
id: "ev-004",
title: "Rapport Icinga disponibilité des services",
category: "Monitoring",
pillar: "availability",
owner: "Ops",
freshness: "Valide",
updatedAt: "2026-04-16",
auditorReady: true,
visibility: "auditor",
source: "Icinga2 / Monitoring",
tags: ["availability", "monitoring", "uptime"],
linkedMilestones: ["ava-monitoring", "ava-incidents", "sec-logging"],
summary: "Disponibilité, incidents et tendances de capacité sur la fenêtre dobservation.",
},
{
id: "ev-005",
title: "Procédure de sauvegarde et test de restauration",
category: "Backup",
pillar: "availability",
owner: "Ops",
freshness: "Valide",
updatedAt: "2026-04-12",
auditorReady: true,
visibility: "auditor",
source: "Runbook / Restore test",
tags: ["backup", "restore", "dr"],
linkedMilestones: ["ava-backup", "ava-resilience"],
summary: "Procédure documentée et test de restauration horodaté.",
},
{
id: "ev-006",
title: "Cartographie des flux critiques de traitement",
category: "Process",
pillar: "processing-integrity",
owner: "Architecture",
freshness: "Brouillon",
updatedAt: "2026-04-04",
auditorReady: false,
visibility: "internal",
source: "Architecture / Flux",
tags: ["flows", "processing", "integrity"],
linkedMilestones: ["pi-flows", "pi-audit"],
summary: "Cartographie initiale des flux critiques; consolidation encore en cours.",
},
{
id: "ev-007",
title: "Registre de classification des données",
category: "Data",
pillar: "confidentiality",
owner: "Architecture",
freshness: "À revoir",
updatedAt: "2026-03-18",
auditorReady: false,
visibility: "internal",
source: "Registre data",
tags: ["classification", "data", "confidentiality"],
linkedMilestones: ["con-classification", "con-lifecycle"],
summary: "Inventaire de classification existant mais incomplet pour certaines charges hébergées.",
},
{
id: "ev-008",
title: "Registre des renseignements personnels",
category: "Privacy",
pillar: "privacy",
owner: "Direction",
freshness: "Brouillon",
updatedAt: "2026-04-02",
auditorReady: false,
visibility: "internal",
source: "Registre privacy",
tags: ["privacy", "pii", "register"],
linkedMilestones: ["pri-mapping", "pri-rules", "pri-retention"],
summary: "Première version du registre des renseignements personnels et de leurs finalités.",
},
{
id: "ev-009",
title: "Journal des incidents et post-mortems",
category: "Incident",
pillar: "security",
owner: "Ops",
freshness: "Valide",
updatedAt: "2026-04-10",
auditorReady: true,
visibility: "auditor",
source: "Incident register",
tags: ["incident", "rca", "security"],
linkedMilestones: ["sec-ir", "ava-incidents"],
summary: "Historique des incidents, causes racines et actions correctives.",
},
{
id: "ev-010",
title: "Registre des fournisseurs critiques",
category: "Vendor",
pillar: "confidentiality",
owner: "Direction",
freshness: "À revoir",
updatedAt: "2026-03-27",
auditorReady: false,
visibility: "internal",
source: "Vendor register",
tags: ["vendors", "third-party", "contracts"],
linkedMilestones: ["con-thirdparty", "pri-incidents"],
summary: "Liste des tiers, contrats et dépendances; classement critique à finaliser.",
},
{
id: "ev-011",
title: "Inventaire des services in-scope",
category: "Inventory",
pillar: "availability",
owner: "Architecture",
freshness: "Valide",
updatedAt: "2026-04-11",
auditorReady: true,
visibility: "auditor",
source: "CMDB / Architecture",
tags: ["inventory", "scope", "services"],
linkedMilestones: ["ava-inventory", "sec-governance"],
summary: "Liste des services critiques, dépendances et responsables dexploitation.",
},
{
id: "ev-012",
title: "Matrice contrôles ↔ preuves",
category: "Matrix",
pillar: "security",
owner: "Direction",
freshness: "Valide",
updatedAt: "2026-04-16",
auditorReady: true,
visibility: "auditor",
source: "Compliance register",
tags: ["matrix", "controls", "evidence"],
linkedMilestones: ["sec-governance", "sec-change", "con-review", "pri-review", "pi-audit"],
summary: "Matrice de rapprochement entre critères, contrôles, jalons et éléments probants.",
},
],
pillars: [
{
id: "security",
name: "Security",
globalWeight: 32,
objective: "Protéger les systèmes, les accès, les changements et la capacité de détection/réaction.",
description: "Protection globale des systèmes, identités, journaux, changements et incidents.",
milestones: [
{
id: "sec-governance",
title: "Gouvernance sécurité de base",
weight: 10,
owner: "Direction",
cadence: "Mensuelle",
dueAt: "2026-04-25",
stage: "proved",
evidenceRefs: ["ev-001", "ev-011", "ev-012"],
notes: "Périmètre et rôles définis; maintenir lactualisation du registre des risques.",
nextAction: "Ajouter la revue mensuelle du registre des risques à la fenêtre Type II.",
},
{
id: "sec-iam",
title: "Gestion des accès et des identités",
weight: 20,
owner: "Ops",
cadence: "Mensuelle",
dueAt: "2026-04-22",
stage: "operating",
evidenceRefs: ["ev-002"],
notes: "MFA déployé mais la revue périodique signée et la révocation démontrée restent à consolider.",
nextAction: "Capturer la revue daccès du mois et la rattacher à ce jalon.",
},
{
id: "sec-hardening",
title: "Durcissement et gestion des vulnérabilités",
weight: 20,
owner: "Ops",
cadence: "Hebdomadaire",
dueAt: "2026-04-21",
stage: "operating",
evidenceRefs: [],
notes: "Le standard technique existe mais la chaîne de preuve automatisée doit être branchée.",
nextAction: "Importer le dernier rapport de scan et lier la remédiation en cours.",
},
{
id: "sec-logging",
title: "Journalisation, surveillance et alertes",
weight: 20,
owner: "Ops",
cadence: "Hebdomadaire",
dueAt: "2026-04-19",
stage: "proved",
evidenceRefs: ["ev-004"],
notes: "Bonne maturité opérationnelle, surveillance déjà exploitable côté auditeur.",
nextAction: "Ajouter un sommaire de rétention des journaux à la salle auditeur.",
},
{
id: "sec-change",
title: "Gestion des changements",
weight: 15,
owner: "Ops",
cadence: "Continue",
dueAt: "2026-04-20",
stage: "operating",
evidenceRefs: ["ev-003", "ev-012"],
notes: "Les PR existent; il reste à standardiser la preuve des validations post-changement.",
nextAction: "Ajouter le champ “validation post-déploiement” dans le flux de changement.",
},
{
id: "sec-ir",
title: "Réponse aux incidents",
weight: 15,
owner: "Ops",
cadence: "Trimestrielle",
dueAt: "2026-05-05",
stage: "operating",
evidenceRefs: ["ev-009"],
notes: "Registre en place; exercice formel tabletop à programmer.",
nextAction: "Planifier un exercice et produire le compte rendu auditable.",
},
],
},
{
id: "availability",
name: "Availability",
globalWeight: 22,
objective: "Maintenir la disponibilité promise, démontrer la résilience et la restaurabilité.",
description: "Disponibilité des services, dépendances, capacité, sauvegardes et reprise.",
milestones: [
{
id: "ava-inventory",
title: "Inventaire des services critiques et dépendances",
weight: 15,
owner: "Architecture",
cadence: "Mensuelle",
dueAt: "2026-04-24",
stage: "proved",
evidenceRefs: ["ev-011"],
notes: "Inventaire déjà formalisé, à garder vivant.",
nextAction: "Ajouter les dépendances de monitoring externe dans linventaire.",
},
{
id: "ava-slo",
title: "Objectifs de service définis",
weight: 10,
owner: "Direction",
cadence: "Trimestrielle",
dueAt: "2026-05-10",
stage: "designed",
evidenceRefs: [],
notes: "Le langage de promesse client existe, pas encore uniformisé en SLO/SLA.",
nextAction: "Formaliser les cibles internes et externes par service in-scope.",
},
{
id: "ava-monitoring",
title: "Monitoring et capacité",
weight: 25,
owner: "Ops",
cadence: "Hebdomadaire",
dueAt: "2026-04-19",
stage: "proved",
evidenceRefs: ["ev-004"],
notes: "Surveillance solide et historique disponible.",
nextAction: "Ajouter un résumé tendance capacité par trimestre.",
},
{
id: "ava-backup",
title: "Sauvegardes et restauration testée",
weight: 25,
owner: "Ops",
cadence: "Mensuelle",
dueAt: "2026-04-23",
stage: "operating",
evidenceRefs: ["ev-005"],
notes: "Test de restauration existant; cadence et couverture des jeux de données à élargir.",
nextAction: "Ajouter un deuxième scénario de restauration ciblant la chaîne complète applicative.",
},
{
id: "ava-resilience",
title: "Résilience opérationnelle",
weight: 15,
owner: "Architecture",
cadence: "Trimestrielle",
dueAt: "2026-05-12",
stage: "operating",
evidenceRefs: ["ev-005"],
notes: "Les mécanismes existent mais le récit auditeur nest pas encore assez structuré.",
nextAction: "Rédiger une fiche de reprise courte et exposable.",
},
{
id: "ava-incidents",
title: "Gestion des incidents de disponibilité",
weight: 10,
owner: "Ops",
cadence: "Continue",
dueAt: "2026-04-20",
stage: "proved",
evidenceRefs: ["ev-004", "ev-009"],
notes: "Bonne base; il faut lier plus clairement lincident à laction corrective.",
nextAction: "Relier chaque incident clos à une décision corrective explicite.",
},
],
},
{
id: "processing-integrity",
name: "Processing Integrity",
globalWeight: 16,
objective: "Démontrer que les traitements critiques sont exacts, complets, traçables et revus.",
description: "Intégrité des traitements, qualité des flux, détection derreurs et validation.",
milestones: [
{
id: "pi-flows",
title: "Flux critiques identifiés",
weight: 15,
owner: "Architecture",
cadence: "Mensuelle",
dueAt: "2026-04-28",
stage: "operating",
evidenceRefs: ["ev-006"],
notes: "La cartographie existe mais pas encore sur tout le périmètre Type II.",
nextAction: "Terminer la cartographie des flux administratifs et de preuve.",
},
{
id: "pi-inputs",
title: "Contrôles dentrée et de changement",
weight: 20,
owner: "Ops",
cadence: "Continue",
dueAt: "2026-04-22",
stage: "operating",
evidenceRefs: ["ev-003"],
notes: "Le lien entre changements techniques et impact métier doit être explicité.",
nextAction: "Ajouter un champ “impact traitement” dans les changements sensibles.",
},
{
id: "pi-audit",
title: "Traçabilité et auditabilité des traitements",
weight: 20,
owner: "Ops",
cadence: "Hebdomadaire",
dueAt: "2026-04-20",
stage: "proved",
evidenceRefs: ["ev-006", "ev-012"],
notes: "Base structurante en place.",
nextAction: "Mieux exposer la traçabilité pour les auditeurs non techniques.",
},
{
id: "pi-errors",
title: "Détection et gestion des erreurs",
weight: 20,
owner: "Ops",
cadence: "Hebdomadaire",
dueAt: "2026-04-21",
stage: "operating",
evidenceRefs: [],
notes: "Le processus existe de façon implicite; il faut le rendre explicite et prouvable.",
nextAction: "Créer un registre simple danomalies de traitement.",
},
{
id: "pi-validation",
title: "Validation / tests / réconciliation",
weight: 15,
owner: "Ops",
cadence: "Mensuelle",
dueAt: "2026-04-26",
stage: "operating",
evidenceRefs: [],
notes: "Présent dans la pratique, mais pas encore uniformisé dans la preuve.",
nextAction: "Définir trois contrôles de réconciliation standard.",
},
{
id: "pi-review",
title: "Revue périodique des écarts",
weight: 10,
owner: "Direction",
cadence: "Mensuelle",
dueAt: "2026-04-30",
stage: "absent",
evidenceRefs: [],
notes: "Cest un vrai trou de gouvernance pour ce pilier.",
nextAction: "Instaurer une revue mensuelle décarts avec décision consignée.",
},
],
},
{
id: "confidentiality",
name: "Confidentiality",
globalWeight: 18,
objective: "Encadrer laccès, les secrets, la classification, les tiers et le cycle de vie des données confidentielles.",
description: "Protection des données confidentielles, accès, secrets, conservation et tiers.",
milestones: [
{
id: "con-classification",
title: "Classification des données",
weight: 15,
owner: "Architecture",
cadence: "Trimestrielle",
dueAt: "2026-05-15",
stage: "operating",
evidenceRefs: ["ev-007"],
notes: "Une base existe, mais elle nest pas encore assez normalisée pour laudit.",
nextAction: "Uniformiser les niveaux de classification et leurs exigences associées.",
},
{
id: "con-access",
title: "Restriction daccès",
weight: 25,
owner: "Ops",
cadence: "Mensuelle",
dueAt: "2026-04-22",
stage: "proved",
evidenceRefs: ["ev-002"],
notes: "Contrôle robuste, sous réserve dune meilleure preuve de revue formelle.",
nextAction: "Ajouter lattestation mensuelle du responsable sur les accès sensibles.",
},
{
id: "con-tech",
title: "Protection technique",
weight: 20,
owner: "Ops",
cadence: "Hebdomadaire",
dueAt: "2026-04-20",
stage: "proved",
evidenceRefs: [],
notes: "Bonne maîtrise technique, chaîne de preuve encore dispersée.",
nextAction: "Centraliser les preuves chiffrement/TLS/secrets dans un seul dossier.",
},
{
id: "con-lifecycle",
title: "Cycle de vie des données",
weight: 15,
owner: "Architecture",
cadence: "Trimestrielle",
dueAt: "2026-05-08",
stage: "operating",
evidenceRefs: ["ev-007"],
notes: "Règles de conservation à préciser selon type de données.",
nextAction: "Créer une matrice conservation / archivage / destruction.",
},
{
id: "con-thirdparty",
title: "Tiers et obligations contractuelles",
weight: 10,
owner: "Direction",
cadence: "Trimestrielle",
dueAt: "2026-05-02",
stage: "operating",
evidenceRefs: ["ev-010"],
notes: "Le registre existe, la criticité et le suivi restent à renforcer.",
nextAction: "Classer les fournisseurs par criticité et lier les clauses sécurité.",
},
{
id: "con-review",
title: "Preuves de revue et exceptions",
weight: 15,
owner: "Direction",
cadence: "Mensuelle",
dueAt: "2026-04-30",
stage: "operating",
evidenceRefs: ["ev-012"],
notes: "Bonne structure, manque encore des revues exécutées sur la durée.",
nextAction: "Ajouter la dernière revue mensuelle avec décisions dexception.",
},
],
},
{
id: "privacy",
name: "Privacy",
globalWeight: 12,
objective: "Encadrer les renseignements personnels, leurs finalités, leurs droits et leur suppression.",
description: "Renseignements personnels, finalités, droits, conservation et incidents.",
milestones: [
{
id: "pri-mapping",
title: "Cartographie des renseignements personnels",
weight: 20,
owner: "Direction",
cadence: "Trimestrielle",
dueAt: "2026-05-16",
stage: "operating",
evidenceRefs: ["ev-008"],
notes: "Démarré, mais encore incomplet sur certains fournisseurs et flux secondaires.",
nextAction: "Finaliser la source, la finalité et la localisation pour chaque catégorie de RP.",
},
{
id: "pri-rules",
title: "Règles de collecte et dusage",
weight: 20,
owner: "Direction",
cadence: "Trimestrielle",
dueAt: "2026-05-18",
stage: "operating",
evidenceRefs: ["ev-008"],
notes: "La doctrine existe, la démonstration opérationnelle est incomplète.",
nextAction: "Documenter explicitement les finalités et la minimisation.",
},
{
id: "pri-rights",
title: "Droits et demandes des personnes",
weight: 20,
owner: "Direction",
cadence: "Mensuelle",
dueAt: "2026-04-29",
stage: "absent",
evidenceRefs: [],
notes: "Aucun mécanisme simple et démontrable nest encore en place.",
nextAction: "Créer un registre de demandes et une procédure courte de traitement.",
},
{
id: "pri-retention",
title: "Conservation et suppression",
weight: 20,
owner: "Architecture",
cadence: "Trimestrielle",
dueAt: "2026-05-11",
stage: "operating",
evidenceRefs: ["ev-008"],
notes: "Les règles existent partiellement par système, pas encore au niveau programme.",
nextAction: "Formaliser la matrice de rétention et les méthodes de suppression.",
},
{
id: "pri-incidents",
title: "Incidents vie privée et tiers",
weight: 10,
owner: "Direction",
cadence: "Trimestrielle",
dueAt: "2026-05-09",
stage: "operating",
evidenceRefs: ["ev-010"],
notes: "La zone tiers est présente, mais langle privacy est sous-documenté.",
nextAction: "Ajouter le scénario “incident touchant des RP” au registre dincidents.",
},
{
id: "pri-review",
title: "Revue périodique de conformité vie privée",
weight: 10,
owner: "Direction",
cadence: "Mensuelle",
dueAt: "2026-04-30",
stage: "absent",
evidenceRefs: [],
notes: "Pas encore de cycle récurrent de revue privacy.",
nextAction: "Instaurer une revue mensuelle compacte de conformité privacy.",
},
],
},
],
auditorPacks: [
{
id: "pack-1",
title: "System Description",
status: "ready",
requiredEvidence: ["ev-001", "ev-011"],
description: "Périmètre, architecture, dépendances, flux de données, rôles et responsabilités.",
},
{
id: "pack-2",
title: "Management Assertion",
status: "draft",
requiredEvidence: ["ev-012"],
description: "Déclaration de la direction sur la fidélité de la description et lefficacité opérationnelle.",
},
{
id: "pack-3",
title: "Controls ↔ Evidence Matrix",
status: "ready",
requiredEvidence: ["ev-012"],
description: "Matrice de rapprochement entre critères, jalons, contrôles, propriétaires et preuves.",
},
{
id: "pack-4",
title: "Exceptions & Remediation Register",
status: "draft",
requiredEvidence: ["ev-009", "ev-010"],
description: "Écarts connus, compensating controls, décisions et plan de remédiation.",
},
{
id: "pack-5",
title: "Access & Privileged Activity Pack",
status: "draft",
requiredEvidence: ["ev-002"],
description: "Revue des accès, privilèges, MFA et preuves de révocation.",
},
],
};

28
src/model/stages.js Normal file
View file

@ -0,0 +1,28 @@
import { AlertTriangle, BadgeCheck, CircleDashed, Hammer } from "lucide-react";
export const STAGE_META = {
absent: {
score: 0,
label: "Absent",
icon: AlertTriangle,
hint: "Contrôle absent ou non démontrable.",
},
designed: {
score: 0.35,
label: "Conçu",
icon: CircleDashed,
hint: "Contrôle défini et documenté, mais pas encore vivant dans la période Type II.",
},
operating: {
score: 0.7,
label: "En opération",
icon: Hammer,
hint: "Contrôle exécuté, mais preuve ou couverture encore insuffisante pour le marquer plein.",
},
proved: {
score: 1,
label: "Prouvé",
icon: BadgeCheck,
hint: "Contrôle opéré et preuve Type II exploitable déjà rattachée.",
},
};

119
src/styles.css Normal file
View file

@ -0,0 +1,119 @@
:root {
color-scheme: dark;
--bg: #07111f;
--bg-2: #0b1527;
--panel: rgba(255, 255, 255, 0.05);
--panel-2: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.1);
--text: #eef3ff;
--muted: #96a4bc;
--accent: #60a5fa;
--accent-2: #22d3ee;
--good: #22c55e;
--warn: #f59e0b;
--bad: #f43f5e;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; background: radial-gradient(circle at top, rgba(59,130,246,.18), transparent 28%), radial-gradient(circle at bottom right, rgba(168,85,247,.14), transparent 24%), linear-gradient(180deg, #020617 0%, #0f172a 56%, #111827 100%); color: var(--text); }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
code { color: #c4b5fd; }
.app-shell { min-height: 100vh; }
.app-container { max-width: 1400px; margin: 0 auto; padding: 24px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 28px; padding: 24px; box-shadow: var(--shadow); backdrop-filter: blur(14px); }
.panel-tall { min-height: 740px; }
.subpanel { background: rgba(2, 6, 23, 0.28); border: 1px solid var(--border); border-radius: 20px; padding: 16px; }
.subpanel-info, .is-info { background: rgba(34, 211, 238, 0.1); border-color: rgba(34, 211, 238, 0.18); }
.good, .is-good { background: rgba(34, 197, 94, 0.1); border-color: rgba(34, 197, 94, 0.18); }
.warn, .is-warn { background: rgba(245, 158, 11, 0.1); border-color: rgba(245, 158, 11, 0.18); }
.bad, .is-bad { background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.18); }
.is-muted { background: rgba(255,255,255,0.04); }
.hero-title { font-size: clamp(2rem, 4vw, 3.5rem); line-height: 1.05; margin: 16px 0 0; max-width: 900px; }
.hero-subtitle { color: var(--muted); max-width: 820px; line-height: 1.7; margin-top: 16px; }
.hero-grid, .dashboard-top, .controls-grid, .execution-grid, .evidence-grid, .auditor-grid, .data-grid { display: grid; }
.hero-grid { grid-template-columns: 1.25fr .75fr; }
.dashboard-top { grid-template-columns: 1.15fr .85fr; }
.controls-grid { grid-template-columns: .9fr 1.1fr; }
.execution-grid, .auditor-grid { grid-template-columns: .8fr 1.2fr; }
.evidence-grid, .data-grid { grid-template-columns: .78fr 1.22fr; }
.pillars-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); display: grid; }
.row { display: flex; }
.wrap { flex-wrap: wrap; }
.align-start { align-items: flex-start; }
.align-center { align-items: center; }
.align-end { align-items: flex-end; }
.space-between { justify-content: space-between; }
.grow { flex: 1; }
.stack > * + * { margin-top: 12px; }
.gap-8 { gap: 8px; }
.gap-12 { gap: 12px; }
.gap-20 { gap: 20px; }
.mt-6 { margin-top: 6px; }
.mt-8 { margin-top: 8px; }
.mt-12 { margin-top: 12px; }
.mt-16 { margin-top: 16px; }
.mt-20 { margin-top: 20px; }
.grid { display: grid; }
.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.milestone-meter { grid-template-columns: 1fr auto; }
.filter-grid { grid-template-columns: 1fr 180px 160px; }
.eyebrow, .tiny { font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: #74829b; }
.section-title-wrap { display: flex; gap: 12px; align-items: flex-start; }
.section-title-icon, .icon-chip { width: 40px; height: 40px; border-radius: 18px; display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.05); border: 1px solid var(--border); }
.section-title { margin: 0; font-size: 1.25rem; }
.section-subtitle, .muted { color: var(--muted); line-height: 1.6; }
.card-title { font-weight: 700; font-size: 1.05rem; }
.card-title-sm { font-weight: 700; font-size: 1rem; }
.card-title-xs { font-weight: 700; font-size: .95rem; }
.strong { color: var(--text); font-weight: 700; }
.pill-value { font-size: 2rem; font-weight: 700; margin-top: 8px; }
.pill-subtitle { color: var(--muted); margin-top: 4px; font-size: .95rem; }
.mini-kpi-value { margin-top: 8px; font-size: 1.1rem; font-weight: 700; }
.progress-track { width: 100%; height: 10px; background: rgba(255,255,255,0.08); border-radius: 999px; overflow: hidden; margin-top: 6px; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); border-radius: inherit; }
.btn { display: inline-flex; align-items: center; gap: 8px; border-radius: 16px; border: 1px solid var(--border); padding: 10px 14px; background: rgba(255,255,255,0.05); color: var(--text); }
.btn:hover { background: rgba(255,255,255,0.08); }
.btn-primary { background: #fff; color: #0f172a; }
.btn-primary:hover { background: #eef2ff; }
.btn-secondary { background: rgba(255,255,255,0.05); }
.btn-info { background: rgba(34, 211, 238, 0.14); color: #c5f9ff; border-color: rgba(34, 211, 238, 0.25); }
.btn.small { padding: 8px 10px; border-radius: 12px; font-size: .9rem; }
.tag { display: inline-flex; align-items: center; padding: 6px 10px; border: 1px solid var(--border); border-radius: 999px; font-size: .8rem; background: rgba(255,255,255,0.05); color: #d6def0; }
.tag-info { background: rgba(34, 211, 238, 0.1); border-color: rgba(34, 211, 238, 0.2); color: #c7fbff; }
.tag-is-good { background: rgba(34, 197, 94, 0.1); border-color: rgba(34, 197, 94, 0.2); color: #c6ffd5; }
.tag-is-info { background: rgba(96, 165, 250, 0.1); border-color: rgba(96, 165, 250, 0.2); color: #d8e8ff; }
.tag-is-warn { background: rgba(245, 158, 11, 0.1); border-color: rgba(245, 158, 11, 0.2); color: #fde7ba; }
.tag-is-bad { background: rgba(244, 63, 94, 0.1); border-color: rgba(244, 63, 94, 0.2); color: #ffd0d8; }
.tag-is-muted { background: rgba(255,255,255,0.05); }
.tabs { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); padding: 6px; gap: 6px; }
.tab { border: 0; border-radius: 16px; background: transparent; color: var(--muted); padding: 12px; text-transform: capitalize; }
.tab.active { background: #fff; color: #0f172a; font-weight: 700; }
.chart-area { height: 420px; margin-top: 12px; }
.chart-area-sm { height: 320px; }
.scroll-area { max-height: 680px; overflow: auto; padding-right: 8px; }
.item-button { width: 100%; text-align: left; }
.item-button.selected, .pillar-card.selected { border-color: rgba(96,165,250,.4); background: rgba(96,165,250,.12); }
.truncate-1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.truncate-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.input-wrap, .field-block { display: block; }
.input-wrap.with-icon { position: relative; display: flex; align-items: center; gap: 8px; background: rgba(255,255,255,.05); border: 1px solid var(--border); padding: 0 12px; border-radius: 16px; }
.input-wrap input, .input-wrap select, .input, .textarea { width: 100%; background: rgba(255,255,255,.05); border: 1px solid var(--border); color: var(--text); border-radius: 16px; padding: 12px 14px; }
.input-wrap input, .input-wrap select { background: transparent; border: 0; padding: 12px 0; outline: none; }
.textarea { min-height: 120px; resize: vertical; }
.textarea-lg { min-height: 360px; }
.textarea-md { min-height: 220px; }
.separator { height: 1px; background: var(--border); }
.list-reset { list-style: none; padding: 0; margin: 0; }
@media (max-width: 1200px) {
.hero-grid, .dashboard-top, .controls-grid, .execution-grid, .evidence-grid, .auditor-grid, .data-grid, .pillars-grid, .cols-4 { grid-template-columns: 1fr; }
.cols-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.app-container { padding: 16px; }
.cols-2, .cols-3, .filter-grid, .milestone-meter, .tabs { grid-template-columns: 1fr; }
.hero-title { font-size: 2rem; }
}