Add API-backed registry mode and evidence artifact service
This commit is contained in:
parent
5836be465c
commit
2db31cb30e
12 changed files with 448 additions and 74 deletions
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_SOC2_SYNC_MODE=local
|
||||||
|
VITE_SOC2_API_BASE_URL=http://localhost:4177/api
|
||||||
159
README.md
159
README.md
|
|
@ -1,101 +1,130 @@
|
||||||
# Chezlepro SOC 2 Type II Cockpit
|
# Chezlepro SOC 2 Type II Cockpit
|
||||||
|
|
||||||
Cockpit web React orienté **conformité opérationnelle SOC 2 Type II** pour Chezlepro Inc.
|
Cockpit web React + API Node pour piloter une conformité opérationnelle **SOC 2 Type II**.
|
||||||
|
|
||||||
L’application fournit :
|
Cette version n’est plus seulement un prototype d’interface. Le dépôt contient maintenant :
|
||||||
|
|
||||||
- un **radar des 5 piliers** SOC 2 ;
|
- un **frontend React/Vite** ;
|
||||||
- un suivi **pondéré des jalons** ;
|
- un **backend HTTP Node** ;
|
||||||
- une **bibliothèque de preuves** éditable ;
|
- un **registre JSON versionné** dans `compliance/soc2/registry/` ;
|
||||||
- une **salle auditeur** avec paquets de revue ;
|
- des **manifests dérivés** ;
|
||||||
- une **source de vérité JSON** persistée localement ;
|
- un répertoire d’**artefacts de preuves** ;
|
||||||
- des **exports/manifests** pour préparer un dépôt Git versionné.
|
- un **contrat de connecteurs** pour Forgejo / Icinga2 / Keycloak.
|
||||||
|
|
||||||
## Architecture
|
## Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
App.jsx
|
App.jsx
|
||||||
styles.css
|
|
||||||
model/
|
model/
|
||||||
schema.js
|
|
||||||
seed.js
|
|
||||||
stages.js
|
|
||||||
lib/
|
lib/
|
||||||
utils.js
|
features/hooks/
|
||||||
validation.js
|
|
||||||
selectors.js
|
|
||||||
view.js
|
|
||||||
features/
|
|
||||||
hooks/
|
|
||||||
useSoc2Registry.js
|
|
||||||
components/
|
components/
|
||||||
PillarOverviewCard.jsx
|
server/
|
||||||
MilestoneCard.jsx
|
index.js
|
||||||
EvidenceLibrary.jsx
|
config.js
|
||||||
EvidenceEditor.jsx
|
lib/
|
||||||
DataTab.jsx
|
http.js
|
||||||
ui/
|
store.js
|
||||||
primitives.jsx
|
contracts/
|
||||||
|
connectors.json
|
||||||
|
scripts/
|
||||||
|
seed-registry.mjs
|
||||||
|
compliance/soc2/
|
||||||
|
registry/
|
||||||
|
soc2-registry.json
|
||||||
|
evidence-manifest.json
|
||||||
|
auditor-pack-index.json
|
||||||
|
evidence/
|
||||||
|
packs/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Principes de conception
|
## Modes de fonctionnement
|
||||||
|
|
||||||
- **Modèle** : constantes, schéma, seed data, états des contrôles.
|
### Mode local
|
||||||
- **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 :
|
Le frontend persiste le registre dans `localStorage`.
|
||||||
|
|
||||||
- chemin recommandé pour le registre : `compliance/soc2/registry/soc2-registry.json`
|
|
||||||
- pièces jointes recommandées : `compliance/soc2/evidence/`
|
|
||||||
|
|
||||||
## Lancer le projet
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# laisser VITE_SOC2_SYNC_MODE=local
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Build de production :
|
### Mode API
|
||||||
|
|
||||||
|
Le frontend devient client d’une source de vérité sur disque, servie par l’API locale.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
cp .env.example .env
|
||||||
npm run preview
|
# définir VITE_SOC2_SYNC_MODE=api
|
||||||
|
npm install
|
||||||
|
npm run seed:registry
|
||||||
|
npm run server
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dépendances
|
API par défaut : `http://localhost:4177/api`
|
||||||
|
|
||||||
- React
|
## Endpoints backend
|
||||||
- Vite
|
|
||||||
- Recharts
|
|
||||||
- Framer Motion
|
|
||||||
- Lucide React
|
|
||||||
|
|
||||||
## Source de vérité
|
- `GET /api/health`
|
||||||
|
- `GET /api/registry`
|
||||||
|
- `PUT /api/registry`
|
||||||
|
- `POST /api/registry/reset`
|
||||||
|
- `GET /api/evidence`
|
||||||
|
- `POST /api/evidence`
|
||||||
|
|
||||||
Le cockpit persiste son état dans `localStorage` pour le prototype.
|
### Exemple : écrire le registre
|
||||||
|
|
||||||
La prochaine étape naturelle est de remplacer cette persistance par :
|
```bash
|
||||||
|
curl -X PUT http://localhost:4177/api/registry \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data @compliance/soc2/registry/soc2-registry.json
|
||||||
|
```
|
||||||
|
|
||||||
1. un fichier JSON versionné dans un dépôt Forgejo ;
|
### Exemple : pousser un artefact de preuve
|
||||||
2. une API légère de lecture/écriture ;
|
|
||||||
3. éventuellement une ingestion automatique depuis Icinga, Keycloak et les registres internes.
|
|
||||||
|
|
||||||
## Limites actuelles
|
```bash
|
||||||
|
python - <<'PY'
|
||||||
|
import base64, json
|
||||||
|
from pathlib import Path
|
||||||
|
payload = {
|
||||||
|
"filename": "example-proof.txt",
|
||||||
|
"contentBase64": base64.b64encode(b"preuve de test").decode(),
|
||||||
|
"mimeType": "text/plain"
|
||||||
|
}
|
||||||
|
Path("payload.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
- pas d’authentification applicative ;
|
curl -X POST http://localhost:4177/api/evidence \
|
||||||
- pas de backend ;
|
-H 'Content-Type: application/json' \
|
||||||
- pas de gestion de fichiers binaires de preuves ;
|
--data @payload.json
|
||||||
- pas encore de synchronisation distante.
|
```
|
||||||
|
|
||||||
## Recommandation d’évolution
|
## Connecteurs prévus
|
||||||
|
|
||||||
Séquence recommandée :
|
Le fichier `server/contracts/connectors.json` décrit les contrats attendus pour :
|
||||||
|
|
||||||
1. brancher le registre JSON versionné ;
|
- **Forgejo** : changements, PR, approbations
|
||||||
2. ajouter gestion de pièces jointes ;
|
- **Icinga2** : disponibilité, incidents, tendances
|
||||||
3. ajouter règles de validation plus strictes ;
|
- **Keycloak** : MFA, rôles admin, revues d’accès
|
||||||
4. ajouter API backend ;
|
|
||||||
5. brancher les collecteurs automatiques de preuves.
|
Le dépôt n’effectue pas encore les collectes réelles sans les paramètres d’accès du site cible, mais l’architecture est prête pour les implémenter.
|
||||||
|
|
||||||
|
## Ce qui rend cette version utile
|
||||||
|
|
||||||
|
- le registre n’est plus seulement stocké côté navigateur ;
|
||||||
|
- l’API peut servir de **source de vérité locale** ;
|
||||||
|
- les manifests sont régénérés à chaque écriture ;
|
||||||
|
- les artefacts de preuves ont un emplacement physique ;
|
||||||
|
- le front peut fonctionner en **mode local** ou **mode API**.
|
||||||
|
|
||||||
|
## Séquence suivante recommandée
|
||||||
|
|
||||||
|
1. brancher les vrais accès Forgejo / Icinga2 / Keycloak ;
|
||||||
|
2. ajouter une authentification applicative ;
|
||||||
|
3. gérer le téléversement multipart réel ;
|
||||||
|
4. ajouter des jobs de collecte planifiés ;
|
||||||
|
5. produire des packs auditeur exportables en HTML/PDF.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"server": "node server/index.js",
|
||||||
|
"seed:registry": "node server/scripts/seed-registry.mjs",
|
||||||
|
"dev:full": "sh -c \"node server/index.js & vite\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
|
|
|
||||||
12
server/config.js
Normal file
12
server/config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
export const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
export const host = process.env.SOC2_HOST || "127.0.0.1";
|
||||||
|
export const port = Number(process.env.SOC2_PORT || 4177);
|
||||||
|
export const registryPath = path.join(repoRoot, "compliance", "soc2", "registry", "soc2-registry.json");
|
||||||
|
export const manifestsDir = path.join(repoRoot, "compliance", "soc2", "registry");
|
||||||
|
export const evidenceDir = path.join(repoRoot, "compliance", "soc2", "evidence");
|
||||||
34
server/contracts/connectors.json
Normal file
34
server/contracts/connectors.json
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"connectors": [
|
||||||
|
{
|
||||||
|
"name": "forgejo",
|
||||||
|
"purpose": "Collecter les changements, pull requests, approbations et tags de release.",
|
||||||
|
"input": {
|
||||||
|
"baseUrl": "https://forge.example.tld",
|
||||||
|
"tokenEnv": "FORGEJO_TOKEN",
|
||||||
|
"repositories": ["org/repo"]
|
||||||
|
},
|
||||||
|
"producesEvidence": ["change-logs", "pull-request-approvals", "release-history"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icinga2",
|
||||||
|
"purpose": "Collecter la disponibilité, les incidents, les SLA internes et les tendances.",
|
||||||
|
"input": {
|
||||||
|
"apiUrl": "https://icinga.example.tld:5665/v1",
|
||||||
|
"usernameEnv": "ICINGA_USER",
|
||||||
|
"passwordEnv": "ICINGA_PASSWORD"
|
||||||
|
},
|
||||||
|
"producesEvidence": ["uptime-reports", "incident-history", "alert-summary"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "keycloak",
|
||||||
|
"purpose": "Collecter les rôles administrateurs, MFA et revues d'accès.",
|
||||||
|
"input": {
|
||||||
|
"baseUrl": "https://keycloak.example.tld",
|
||||||
|
"realm": "master",
|
||||||
|
"tokenEnv": "KEYCLOAK_TOKEN"
|
||||||
|
},
|
||||||
|
"producesEvidence": ["admin-role-exports", "mfa-status", "access-review-snapshots"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
76
server/index.js
Normal file
76
server/index.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import http from "node:http";
|
||||||
|
import { URL } from "node:url";
|
||||||
|
import { host, port } from "./config.js";
|
||||||
|
import { readJsonBody, sendJson, sendText } from "./lib/http.js";
|
||||||
|
import { listEvidenceArtifacts, loadRegistry, resetRegistry, writeEvidenceArtifact, writeRegistry } from "./lib/store.js";
|
||||||
|
import { validateRegistry } from "../src/lib/validation.js";
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (!req.url) return sendJson(res, 400, { error: "Missing URL." });
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || `${host}:${port}`}`);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204, {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
});
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method === "GET" && url.pathname === "/health") {
|
||||||
|
return sendText(res, 200, "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
const validation = validateRegistry(registry);
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
service: "soc2-cockpit-api",
|
||||||
|
registryValid: validation.isValid,
|
||||||
|
warningCount: validation.warnings.length,
|
||||||
|
errorCount: validation.errors.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/registry") {
|
||||||
|
const registry = await loadRegistry();
|
||||||
|
return sendJson(res, 200, { registry, validation: validateRegistry(registry) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "PUT" && url.pathname === "/api/registry") {
|
||||||
|
const payload = await readJsonBody(req);
|
||||||
|
const result = await writeRegistry(payload);
|
||||||
|
return sendJson(res, 200, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/registry/reset") {
|
||||||
|
const result = await resetRegistry();
|
||||||
|
return sendJson(res, 200, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && url.pathname === "/api/evidence") {
|
||||||
|
const artifacts = await listEvidenceArtifacts();
|
||||||
|
return sendJson(res, 200, { artifacts });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && url.pathname === "/api/evidence") {
|
||||||
|
const payload = await readJsonBody(req);
|
||||||
|
const artifact = await writeEvidenceArtifact(payload);
|
||||||
|
return sendJson(res, 201, { artifact });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, 404, { error: "Not found." });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.validation) {
|
||||||
|
return sendJson(res, 400, { error: error.message, validation: error.validation });
|
||||||
|
}
|
||||||
|
return sendJson(res, 500, { error: error?.message || "Internal server error." });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
console.log(`SOC 2 cockpit API listening on http://${host}:${port}`);
|
||||||
|
});
|
||||||
29
server/lib/http.js
Normal file
29
server/lib/http.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
export function sendJson(res, status, payload) {
|
||||||
|
const body = JSON.stringify(payload, null, 2);
|
||||||
|
res.writeHead(status, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Content-Length": Buffer.byteLength(body),
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendText(res, status, text) {
|
||||||
|
res.writeHead(status, {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Content-Length": Buffer.byteLength(text),
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
});
|
||||||
|
res.end(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJsonBody(req) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) chunks.push(chunk);
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
}
|
||||||
78
server/lib/store.js
Normal file
78
server/lib/store.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { buildRegistryManifests, normalizeDb, validateRegistry } from "../../src/lib/validation.js";
|
||||||
|
import { INITIAL_DB } from "../../src/model/seed.js";
|
||||||
|
import { evidenceDir, manifestsDir, registryPath } from "../config.js";
|
||||||
|
|
||||||
|
async function ensureDirs() {
|
||||||
|
await fs.mkdir(manifestsDir, { recursive: true });
|
||||||
|
await fs.mkdir(evidenceDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRegistry() {
|
||||||
|
await ensureDirs();
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(registryPath, "utf-8");
|
||||||
|
return normalizeDb(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return normalizeDb(INITIAL_DB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeRegistry(input) {
|
||||||
|
await ensureDirs();
|
||||||
|
const normalized = normalizeDb(input);
|
||||||
|
const validation = validateRegistry(normalized);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
const error = new Error("Registry validation failed.");
|
||||||
|
error.validation = validation;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(registryPath, JSON.stringify(normalized, null, 2), "utf-8");
|
||||||
|
const manifests = buildRegistryManifests(normalized);
|
||||||
|
await fs.writeFile(path.join(manifestsDir, "evidence-manifest.json"), JSON.stringify(manifests.evidenceManifest, null, 2), "utf-8");
|
||||||
|
await fs.writeFile(path.join(manifestsDir, "auditor-pack-index.json"), JSON.stringify(manifests.auditorPackIndex, null, 2), "utf-8");
|
||||||
|
return { registry: normalized, validation, manifests };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetRegistry() {
|
||||||
|
return writeRegistry(INITIAL_DB);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvidenceArtifacts() {
|
||||||
|
await ensureDirs();
|
||||||
|
const names = await fs.readdir(evidenceDir).catch(() => []);
|
||||||
|
const stats = await Promise.all(
|
||||||
|
names
|
||||||
|
.filter((name) => !name.startsWith("."))
|
||||||
|
.map(async (name) => {
|
||||||
|
const fullPath = path.join(evidenceDir, name);
|
||||||
|
const stat = await fs.stat(fullPath);
|
||||||
|
return {
|
||||||
|
filename: name,
|
||||||
|
size: stat.size,
|
||||||
|
modifiedAt: stat.mtime.toISOString(),
|
||||||
|
path: fullPath,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return stats.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeEvidenceArtifact({ filename, contentBase64 }) {
|
||||||
|
await ensureDirs();
|
||||||
|
if (!filename || typeof filename !== "string") throw new Error("filename is required.");
|
||||||
|
if (!contentBase64 || typeof contentBase64 !== "string") throw new Error("contentBase64 is required.");
|
||||||
|
|
||||||
|
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
const fullPath = path.join(evidenceDir, safeName);
|
||||||
|
await fs.writeFile(fullPath, Buffer.from(contentBase64, "base64"));
|
||||||
|
const stat = await fs.stat(fullPath);
|
||||||
|
return {
|
||||||
|
filename: safeName,
|
||||||
|
size: stat.size,
|
||||||
|
modifiedAt: stat.mtime.toISOString(),
|
||||||
|
path: fullPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
4
server/scripts/seed-registry.mjs
Normal file
4
server/scripts/seed-registry.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { resetRegistry } from "../lib/store.js";
|
||||||
|
|
||||||
|
const payload = await resetRegistry();
|
||||||
|
console.log(`Registry seeded. Evidence items: ${payload.registry.evidence.length}`);
|
||||||
|
|
@ -96,7 +96,7 @@ function DashboardPanel({ scoredPillars, targetReadiness, overall, selectedPilla
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const registry = useSoc2Registry();
|
const registry = useSoc2Registry();
|
||||||
const { db, lastSavedAt, importError, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
|
const { db, lastSavedAt, importError, syncMode, syncStatus, updateMilestoneStage, updateEvidence, toggleEvidenceMilestoneLink, createEvidence, deleteEvidence, exportDb, copyDb, importDb, resetDemo } = registry;
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState("dashboard");
|
const [activeTab, setActiveTab] = useState("dashboard");
|
||||||
const [selectedPillar, setSelectedPillar] = useState("security");
|
const [selectedPillar, setSelectedPillar] = useState("security");
|
||||||
|
|
@ -170,7 +170,7 @@ export default function App() {
|
||||||
{blocker ? <Tag tone="is-bad">Blocage: Security < 60</Tag> : null}
|
{blocker ? <Tag tone="is-bad">Blocage: Security < 60</Tag> : null}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="hero-title">Assistant web de conformité SOC 2 orienté preuve, remédiation et exposition auditeur</h1>
|
<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>
|
<p className="hero-subtitle">Architecture modulaire, source de vérité versionnable, backend API optionnel, radar des 5 piliers, bibliothèque de preuves et salle auditeur.</p>
|
||||||
<div className="row wrap gap-8 mt-16">
|
<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-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={handleCreateEvidence}><Plus size={16} /> Nouvelle preuve</button>
|
||||||
|
|
@ -181,7 +181,7 @@ export default function App() {
|
||||||
<ScorePill label="Conformité pondérée" value={`${globalScore}%`} sublabel="Score global des 5 piliers" />
|
<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="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="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" />
|
<ScorePill label="Dernière sauvegarde" value={lastSavedAt || "—"} sublabel={`Mode ${syncMode} • état ${syncStatus}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { INITIAL_DB } from "../../model/seed.js";
|
import { INITIAL_DB } from "../../model/seed.js";
|
||||||
import { STORAGE_KEY, TODAY } from "../../model/schema.js";
|
import { STORAGE_KEY, TODAY } from "../../model/schema.js";
|
||||||
import { clone } from "../../lib/utils.js";
|
import { clone } from "../../lib/utils.js";
|
||||||
import { normalizeDb } from "../../lib/validation.js";
|
import { normalizeDb } from "../../lib/validation.js";
|
||||||
|
import { fetchRegistryFromApi, getSyncMode, resetRegistryOnApi, saveRegistryToApi } from "../../lib/apiClient.js";
|
||||||
|
|
||||||
function loadDb() {
|
function loadDb() {
|
||||||
if (typeof window === "undefined") return clone(INITIAL_DB);
|
if (typeof window === "undefined") return clone(INITIAL_DB);
|
||||||
|
|
@ -16,15 +17,58 @@ function loadDb() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSoc2Registry() {
|
export function useSoc2Registry() {
|
||||||
|
const syncMode = getSyncMode();
|
||||||
const [db, setDb] = useState(() => loadDb());
|
const [db, setDb] = useState(() => loadDb());
|
||||||
const [lastSavedAt, setLastSavedAt] = useState(null);
|
const [lastSavedAt, setLastSavedAt] = useState(null);
|
||||||
const [importError, setImportError] = useState("");
|
const [importError, setImportError] = useState("");
|
||||||
|
const [syncStatus, setSyncStatus] = useState(syncMode === "api" ? "connecting" : "local");
|
||||||
|
const skipFirstApiSave = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (syncMode !== "api") return;
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2));
|
let cancelled = false;
|
||||||
setLastSavedAt(new Date().toLocaleString("fr-CA"));
|
fetchRegistryFromApi()
|
||||||
}, [db]);
|
.then((remote) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDb(normalizeDb(remote));
|
||||||
|
setSyncStatus("connected");
|
||||||
|
setLastSavedAt(new Date().toLocaleString("fr-CA"));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSyncStatus("degraded");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [syncMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncMode !== "api") {
|
||||||
|
setLastSavedAt(new Date().toLocaleString("fr-CA"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipFirstApiSave.current) {
|
||||||
|
skipFirstApiSave.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
saveRegistryToApi(db)
|
||||||
|
.then(() => {
|
||||||
|
setSyncStatus("connected");
|
||||||
|
setLastSavedAt(new Date().toLocaleString("fr-CA"));
|
||||||
|
})
|
||||||
|
.catch(() => setSyncStatus("degraded"));
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [db, syncMode]);
|
||||||
|
|
||||||
const updateMilestoneStage = (pillarId, milestoneId, nextStage) => {
|
const updateMilestoneStage = (pillarId, milestoneId, nextStage) => {
|
||||||
setDb((current) => ({
|
setDb((current) => ({
|
||||||
|
|
@ -171,6 +215,15 @@ export function useSoc2Registry() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetDemo = () => {
|
const resetDemo = () => {
|
||||||
|
if (syncMode === "api") {
|
||||||
|
resetRegistryOnApi()
|
||||||
|
.then((payload) => {
|
||||||
|
setDb(normalizeDb(payload.registry));
|
||||||
|
setSyncStatus("connected");
|
||||||
|
})
|
||||||
|
.catch(() => setSyncStatus("degraded"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setDb(clone(INITIAL_DB));
|
setDb(clone(INITIAL_DB));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,6 +231,8 @@ export function useSoc2Registry() {
|
||||||
db,
|
db,
|
||||||
lastSavedAt,
|
lastSavedAt,
|
||||||
importError,
|
importError,
|
||||||
|
syncMode,
|
||||||
|
syncStatus,
|
||||||
updateMilestoneStage,
|
updateMilestoneStage,
|
||||||
updateEvidence,
|
updateEvidence,
|
||||||
toggleEvidenceMilestoneLink,
|
toggleEvidenceMilestoneLink,
|
||||||
|
|
|
||||||
52
src/lib/apiClient.js
Normal file
52
src/lib/apiClient.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
const apiBase = (import.meta.env.VITE_SOC2_API_BASE_URL || "http://localhost:4177/api").replace(/\/$/, "");
|
||||||
|
|
||||||
|
async function readJson(response) {
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncMode() {
|
||||||
|
return import.meta.env.VITE_SOC2_SYNC_MODE === "api" ? "api" : "local";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegistryFromApi() {
|
||||||
|
const response = await fetch(`${apiBase}/registry`);
|
||||||
|
const payload = await readJson(response);
|
||||||
|
return payload.registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRegistryToApi(registry) {
|
||||||
|
const response = await fetch(`${apiBase}/registry`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(registry),
|
||||||
|
});
|
||||||
|
return readJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServerStatus() {
|
||||||
|
const response = await fetch(`${apiBase}/health`);
|
||||||
|
return readJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetRegistryOnApi() {
|
||||||
|
const response = await fetch(`${apiBase}/registry/reset`, { method: "POST" });
|
||||||
|
return readJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvidenceArtifacts() {
|
||||||
|
const response = await fetch(`${apiBase}/evidence`);
|
||||||
|
return readJson(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeEvidenceArtifact({ filename, contentBase64, mimeType = "application/octet-stream" }) {
|
||||||
|
const response = await fetch(`${apiBase}/evidence`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename, contentBase64, mimeType }),
|
||||||
|
});
|
||||||
|
return readJson(response);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue