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
|
||||
|
||||
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 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é.
|
||||
- un **frontend React/Vite** ;
|
||||
- un **backend HTTP Node** ;
|
||||
- un **registre JSON versionné** dans `compliance/soc2/registry/` ;
|
||||
- des **manifests dérivés** ;
|
||||
- un répertoire d’**artefacts de preuves** ;
|
||||
- un **contrat de connecteurs** pour Forgejo / Icinga2 / Keycloak.
|
||||
|
||||
## Architecture
|
||||
## Structure
|
||||
|
||||
```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
|
||||
features/hooks/
|
||||
components/
|
||||
PillarOverviewCard.jsx
|
||||
MilestoneCard.jsx
|
||||
EvidenceLibrary.jsx
|
||||
EvidenceEditor.jsx
|
||||
DataTab.jsx
|
||||
ui/
|
||||
primitives.jsx
|
||||
server/
|
||||
index.js
|
||||
config.js
|
||||
lib/
|
||||
http.js
|
||||
store.js
|
||||
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.
|
||||
- **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.
|
||||
### Mode local
|
||||
|
||||
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
|
||||
Le frontend persiste le registre dans `localStorage`.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# laisser VITE_SOC2_SYNC_MODE=local
|
||||
npm install
|
||||
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
|
||||
npm run build
|
||||
npm run preview
|
||||
cp .env.example .env
|
||||
# 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
|
||||
- Vite
|
||||
- Recharts
|
||||
- Framer Motion
|
||||
- Lucide React
|
||||
## Endpoints backend
|
||||
|
||||
## 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 ;
|
||||
2. une API légère de lecture/écriture ;
|
||||
3. éventuellement une ingestion automatique depuis Icinga, Keycloak et les registres internes.
|
||||
### Exemple : pousser un artefact de preuve
|
||||
|
||||
## 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 ;
|
||||
- pas de backend ;
|
||||
- pas de gestion de fichiers binaires de preuves ;
|
||||
- pas encore de synchronisation distante.
|
||||
curl -X POST http://localhost:4177/api/evidence \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data @payload.json
|
||||
```
|
||||
|
||||
## 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é ;
|
||||
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.
|
||||
- **Forgejo** : changements, PR, approbations
|
||||
- **Icinga2** : disponibilité, incidents, tendances
|
||||
- **Keycloak** : MFA, rôles admin, revues d’accès
|
||||
|
||||
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": {
|
||||
"dev": "vite",
|
||||
"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": {
|
||||
"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() {
|
||||
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 [selectedPillar, setSelectedPillar] = useState("security");
|
||||
|
|
@ -170,7 +170,7 @@ export default function App() {
|
|||
{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>
|
||||
<p className="hero-subtitle">Architecture modulaire, source de vérité versionnable, backend API optionnel, radar des 5 piliers, bibliothèque de preuves et salle auditeur.</p>
|
||||
<div className="row wrap gap-8 mt-16">
|
||||
<button className="btn btn-primary"><Eye size={16} /> Ouvrir la salle auditeur</button>
|
||||
<button className="btn btn-secondary" onClick={handleCreateEvidence}><Plus size={16} /> Nouvelle preuve</button>
|
||||
|
|
@ -181,7 +181,7 @@ export default function App() {
|
|||
<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" />
|
||||
<ScorePill label="Dernière sauvegarde" value={lastSavedAt || "—"} sublabel={`Mode ${syncMode} • état ${syncStatus}`} />
|
||||
</div>
|
||||
</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 { STORAGE_KEY, TODAY } from "../../model/schema.js";
|
||||
import { clone } from "../../lib/utils.js";
|
||||
import { normalizeDb } from "../../lib/validation.js";
|
||||
import { fetchRegistryFromApi, getSyncMode, resetRegistryOnApi, saveRegistryToApi } from "../../lib/apiClient.js";
|
||||
|
||||
function loadDb() {
|
||||
if (typeof window === "undefined") return clone(INITIAL_DB);
|
||||
|
|
@ -16,15 +17,58 @@ function loadDb() {
|
|||
}
|
||||
|
||||
export function useSoc2Registry() {
|
||||
const syncMode = getSyncMode();
|
||||
const [db, setDb] = useState(() => loadDb());
|
||||
const [lastSavedAt, setLastSavedAt] = useState(null);
|
||||
const [importError, setImportError] = useState("");
|
||||
const [syncStatus, setSyncStatus] = useState(syncMode === "api" ? "connecting" : "local");
|
||||
const skipFirstApiSave = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(db, null, 2));
|
||||
setLastSavedAt(new Date().toLocaleString("fr-CA"));
|
||||
}, [db]);
|
||||
if (syncMode !== "api") return;
|
||||
let cancelled = false;
|
||||
fetchRegistryFromApi()
|
||||
.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) => {
|
||||
setDb((current) => ({
|
||||
|
|
@ -171,6 +215,15 @@ export function useSoc2Registry() {
|
|||
};
|
||||
|
||||
const resetDemo = () => {
|
||||
if (syncMode === "api") {
|
||||
resetRegistryOnApi()
|
||||
.then((payload) => {
|
||||
setDb(normalizeDb(payload.registry));
|
||||
setSyncStatus("connected");
|
||||
})
|
||||
.catch(() => setSyncStatus("degraded"));
|
||||
return;
|
||||
}
|
||||
setDb(clone(INITIAL_DB));
|
||||
};
|
||||
|
||||
|
|
@ -178,6 +231,8 @@ export function useSoc2Registry() {
|
|||
db,
|
||||
lastSavedAt,
|
||||
importError,
|
||||
syncMode,
|
||||
syncStatus,
|
||||
updateMilestoneStage,
|
||||
updateEvidence,
|
||||
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