Initial SOC2 cockpit repository
This commit is contained in:
commit
5836be465c
24 changed files with 3251 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
101
README.md
Normal file
101
README.md
Normal 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.
|
||||
|
||||
L’application 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 d’authentification 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.
|
||||
44
compliance/soc2/registry/auditor-pack-index.json
Normal file
44
compliance/soc2/registry/auditor-pack-index.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
150
compliance/soc2/registry/evidence-manifest.json
Normal file
150
compliance/soc2/registry/evidence-manifest.json
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
[
|
||||
{
|
||||
"id": "ev-001",
|
||||
"title": "Politique de sécurité de l’information 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"
|
||||
}
|
||||
]
|
||||
787
compliance/soc2/registry/soc2-registry.json
Normal file
787
compliance/soc2/registry/soc2-registry.json
Normal 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 l’information 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 d’observation."
|
||||
},
|
||||
{
|
||||
"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 d’exploitation."
|
||||
},
|
||||
{
|
||||
"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 l’actualisation 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 d’accè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 l’inventaire."
|
||||
},
|
||||
{
|
||||
"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 n’est 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 l’incident à l’action 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 d’erreurs 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 d’entré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 d’anomalies 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": "C’est 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 l’accè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 n’est pas encore assez normalisée pour l’audit.",
|
||||
"nextAction": "Uniformiser les niveaux de classification et leurs exigences associées."
|
||||
},
|
||||
{
|
||||
"id": "con-access",
|
||||
"title": "Restriction d’accès",
|
||||
"weight": 25,
|
||||
"owner": "Ops",
|
||||
"cadence": "Mensuelle",
|
||||
"dueAt": "2026-04-22",
|
||||
"stage": "proved",
|
||||
"evidenceRefs": [
|
||||
"ev-002"
|
||||
],
|
||||
"notes": "Contrôle robuste, sous réserve d’une meilleure preuve de revue formelle.",
|
||||
"nextAction": "Ajouter l’attestation 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 d’exception."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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 d’usage",
|
||||
"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 n’est 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 l’angle privacy est sous-documenté.",
|
||||
"nextAction": "Ajouter le scénario “incident touchant des RP” au registre d’incidents."
|
||||
},
|
||||
{
|
||||
"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 l’efficacité 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
16
index.html
Normal 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
21
package.json
Normal 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
333
src/App.jsx
Normal 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 n’est 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 < 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 l’ensemble" />
|
||||
<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 d’implantation, 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é à l’auditeur" subtitle="Vue condensée du narratif d’audit : 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 d’une 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 d’abord 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>
|
||||
);
|
||||
}
|
||||
70
src/components/DataTab.jsx
Normal file
70
src/components/DataTab.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/EvidenceEditor.jsx
Normal file
88
src/components/EvidenceEditor.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
src/components/EvidenceLibrary.jsx
Normal file
57
src/components/EvidenceLibrary.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/MilestoneCard.jsx
Normal file
67
src/components/MilestoneCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/PillarOverviewCard.jsx
Normal file
31
src/components/PillarOverviewCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/ui/primitives.jsx
Normal file
53
src/components/ui/primitives.jsx
Normal 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>;
|
||||
}
|
||||
191
src/features/hooks/useSoc2Registry.js
Normal file
191
src/features/hooks/useSoc2Registry.js
Normal 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 n’a 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
150
src/lib/selectors.js
Normal 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 l’audit", 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
47
src/lib/utils.js
Normal 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
163
src/lib/validation.js
Normal 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
9
src/lib/view.js
Normal 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
10
src/main.jsx
Normal 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
70
src/model/schema.js
Normal 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
642
src/model/seed.js
Normal 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 l’information 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 d’observation.",
|
||||
},
|
||||
{
|
||||
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 d’exploitation.",
|
||||
},
|
||||
{
|
||||
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 l’actualisation 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 d’accè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 l’inventaire.",
|
||||
},
|
||||
{
|
||||
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 n’est 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 l’incident à l’action 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 d’erreurs 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 d’entré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 d’anomalies 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: "C’est 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 l’accè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 n’est pas encore assez normalisée pour l’audit.",
|
||||
nextAction: "Uniformiser les niveaux de classification et leurs exigences associées.",
|
||||
},
|
||||
{
|
||||
id: "con-access",
|
||||
title: "Restriction d’accès",
|
||||
weight: 25,
|
||||
owner: "Ops",
|
||||
cadence: "Mensuelle",
|
||||
dueAt: "2026-04-22",
|
||||
stage: "proved",
|
||||
evidenceRefs: ["ev-002"],
|
||||
notes: "Contrôle robuste, sous réserve d’une meilleure preuve de revue formelle.",
|
||||
nextAction: "Ajouter l’attestation 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 d’exception.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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 d’usage",
|
||||
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 n’est 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 l’angle privacy est sous-documenté.",
|
||||
nextAction: "Ajouter le scénario “incident touchant des RP” au registre d’incidents.",
|
||||
},
|
||||
{
|
||||
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 l’efficacité 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
28
src/model/stages.js
Normal 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
119
src/styles.css
Normal 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; }
|
||||
}
|
||||
Loading…
Reference in a new issue