Commit edfc1385 authored by Kourser's avatar Kourser
Browse files

Interface : le dossier de préparation, la console root et le reste

Les écrans manquants. Annuaire, tiers activables, fiches réflexes, kit
documentaire, sécurité du compte, console root, tableaux blancs, mobilisation,
clôture et retour d'expérience, et les pages d'atterrissage des liens.

Deux défauts d'architecture corrigés
  L'API et l'interface se disputaient l'espace des chemins : « /crises/:id/
  tableaux » servait du JSON au navigateur qui attendait un écran. L'API vit
  désormais sous « /api », ce qui supprime la classe entière de collisions —
  présentes et à venir.

  Les liens envoyés par courriel et par SMS pointaient vers l'API. Ils
  s'ouvrent dans un navigateur, souvent sur un téléphone, souvent par quelqu'un
  qui n'a pas de compte et qui est pressé : ils atterrissent maintenant sur une
  page qui dit ce qui s'est passé.

Un trou comblé
  La barre d'état annonçait « sans second facteur » sans qu'aucun écran ne
  permette de l'activer. La page Sécurité fait l'activation TOTP avec son code
  à scanner, remet la carte de crise imprimable, et liste les appareils
  connectés avec leur déconnexion à distance.

Excalidraw comme composant, pas comme application
  Le canevas est posé dans notre page : le rail, la barre d'état et le panneau
  de contexte restent visibles autour, et il suit le thème de la coque. On ne
  pointe pas vers un site tiers dans une iframe — les scènes vivent dans notre
  base, la barre d'outils qui compte est la nôtre, et l'instantané qui fait foi
  part au dossier documentaire.

Ce que les écrans disent, et pas seulement affichent
  Un tiers sans numéro d'urgence est encadré en rouge avec « NON RENSEIGNÉ » et
  « conduite à tenir non définie ». L'indicateur de préparation nomme le geste
  qui comble chaque manque. La console root annonce qu'aucun mécanisme de
  sauvegarde n'est intégré, plutôt que d'afficher un état rassurant sans
  fondement. Le bris de glace exige un motif que l'organisation lira.

EX-12 tenu malgré la croissance
  Le cœur passe de 88 à 114 Ko compressés — toujours sous la cible. Le canevas
  de dessin rejoint la visioconférence dans les modules chargés à la demande.

Éprouvé dans le navigateur
  Parcours complet sur l'instance conteneurisée, en thème sombre : dossier de
  préparation avec son indicateur à 50/100, registre des tiers, console root
  avec vérification d'intégrité (16 crises en 30 ms), tableau blanc avec
  instantané, mobilisation, clôture avec ses douze indicateurs.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 71671ae5
Loading
Loading
Loading
Loading
+15 −4
Original line number Diff line number Diff line
@@ -23,7 +23,7 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L8** | Mobilisation multicanal, relances, escalade | 🟢 fait |
| **L9** | Rembobinage complet, dossier de crise, retour d'expérience | 🟢 fait |
| **L10** | Intégration exercice, page d'état, vérificateur, CI, documentation | 🟢 fait côté serveur — application installable, accessibilité et bilinguisme suivent le client |
| **Interface** | Coque à trois zones, panneau visio flottant, rembobinage, chat, cockpit | 🟢 chemin critique fait |
| **Interface** | Coque, visio flottante, rembobinage, dossier de préparation, console root, tableaux, mobilisation, clôture | 🟢 fait |
| L11–L12 | Applications mobiles, téléphonie, transcription | ⚪ hors d'atteinte sans infrastructure SIP |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
@@ -106,6 +106,8 @@ apps/api/src/
    points.ts        trame fixe, brouillon depuis le journal, export autonome
    cockpit.ts       ce qui appelle une décision, en dix secondes
apps/web/src/
  ecrans/preparation/  annuaire, tiers, fiches, kit — la moitié amont
  ecrans/administration/ console root et bris de glace
  coque/          la coque à trois zones (§10)
    Coque.tsx        barre d'état, rail, contexte — et ce qui flotte au-dessus
    PanneauVisio.tsx le panneau qui survit à la navigation
@@ -202,9 +204,18 @@ avec le direct n'est possible.
l'appelle, et ne bloque rien : §10 interdit qu'une boîte de dialogue puisse
empêcher la consignation d'un fait.

Le cœur pèse **88 Ko compressés** ; la visioconférence, qui pèse plus que tout
le reste réuni, n'est téléchargée qu'au moment où quelqu'un rejoint un appel
(EX-12).
Le cœur pèse **114 Ko compressés**. La visioconférence et le canevas de dessin,
qui pèsent chacun plus que tout le reste réuni, ne sont téléchargés qu'au moment
où l'on rejoint un appel ou ouvre un tableau (EX-12).

**L'API vit sous `/api`.** Sans ce préfixe, l'interface et l'API se disputeraient
l'espace des chemins : `/crises/:id/documents` servirait du JSON au navigateur
qui attend un écran.

Les liens envoyés par courriel ou par SMS — vérification d'adresse, connexion,
invitation, mobilisation, vérification de coordonnées — atterrissent sur une
**page**, pas sur une réponse JSON. Ils s'ouvrent dans un navigateur, souvent sur
un téléphone, souvent par quelqu'un qui n'a pas de compte et qui est pressé.

## Vérifier sans nous faire confiance

+3 −3
Original line number Diff line number Diff line
@@ -122,7 +122,7 @@ async function envoyerVerification(
    texte:
      `Bonjour ${nom},\n\n` +
      `Confirmez votre adresse pour activer votre espace de crise :\n` +
      `${urlPublique}/auth/verification?jeton=${jeton}\n\n` +
      `${urlPublique}/verification?jeton=${jeton}\n\n` +
      `Ce lien expire dans 24 heures.\n\n` +
      `Conseil : utilisez une adresse indépendante du système d'information que\n` +
      `Kastell doit secourir. Le jour d'une crise, votre messagerie d'entreprise\n` +
@@ -228,7 +228,7 @@ export async function demanderLienConnexion(
    sujet: "Votre lien de connexion — Kastell",
    texte:
      `Bonjour ${compte.nom},\n\n` +
      `${urlPublique}/auth/lien?jeton=${jeton}\n\n` +
      `${urlPublique}/connexion?jeton=${jeton}\n\n` +
      `Ce lien est valable 15 minutes et ne fonctionne qu'une fois.\n` +
      `Si vous n'êtes pas à l'origine de cette demande, ignorez ce message.`,
  });
@@ -502,7 +502,7 @@ export async function inviter(sql: Sql, d: DemandeInvitation): Promise<string> {
      destinataire: email,
      sujet: `Rejoignez la cellule de crise de ${org?.nom ?? "votre organisation"} — Kastell`,
      texte:
        `${d.urlPublique}/auth/invitation?jeton=${jeton}\n\n` +
        `${d.urlPublique}/invitation?jeton=${jeton}\n\n` +
        `Cette invitation est valable 14 jours.\n\n` +
        `Important : préférez une adresse et un appareil indépendants du système\n` +
        `d'information de votre organisation. Kastell sert précisément les\n` +
+1 −1
Original line number Diff line number Diff line
@@ -74,7 +74,7 @@ export async function lancer(
      texte:
        `Bonjour ${e.nom},\n\n` +
        `Confirmez ou corrigez vos coordonnées de crise :\n` +
        `${urlPublique}/preparation/verification?jeton=${e.jeton}\n\n` +
        `${urlPublique}/coordonnees?jeton=${e.jeton}\n\n` +
        `Cela prend une minute et ne se fait que deux fois par an.\n\n` +
        `Ces coordonnées ne servent qu'en cas de crise majeure, quand la\n` +
        `messagerie de l'entreprise peut être précisément ce qui est hors\n` +
+21 −13
Original line number Diff line number Diff line
@@ -62,18 +62,25 @@ export async function construireServeur(
    }
  });

  enregistrerRoutesAuth(app, env, sql);
  enregistrerRoutesAdministration(app, sql);
  enregistrerRoutesPreparation(app, env, sql);
  enregistrerRoutesCrise(app, sql);
  enregistrerRoutesChat(app, sql);
  enregistrerRoutesDocuments(app, sql);
  enregistrerRoutesMedia(app, sql);
  enregistrerRoutesTableaux(app, sql);
  enregistrerRoutesMobilisation(app, env, sql);
  enregistrerRoutesRestitution(app, sql);
  enregistrerRoutesIntegration(app, sql);
  enregistrerRoutesEtat(app, sql);
  // ── L'API sous un préfixe qui lui est propre ─────────────────────────────
  // Sans cela, l'interface et l'API se disputent l'espace des chemins :
  // « /crises/:id/documents » servirait du JSON au navigateur qui attend un
  // écran. Le préfixe supprime toute la classe de collisions, présentes et à
  // venir.
  await app.register(async (api) => {
    enregistrerRoutesAuth(api, env, sql);
    enregistrerRoutesAdministration(api, sql);
    enregistrerRoutesPreparation(api, env, sql);
    enregistrerRoutesCrise(api, sql);
    enregistrerRoutesChat(api, sql);
    enregistrerRoutesDocuments(api, sql);
    enregistrerRoutesMedia(api, sql);
    enregistrerRoutesTableaux(api, sql);
    enregistrerRoutesMobilisation(api, env, sql);
    enregistrerRoutesRestitution(api, sql);
    enregistrerRoutesIntegration(api, sql);
    enregistrerRoutesEtat(api, sql);
  }, { prefix: "/api" });

  // ── L'interface, servie par la même instance (EX-35) ─────────────────────
  // Un seul artefact, un seul port, une seule origine : le cookie de session
@@ -85,7 +92,8 @@ export async function construireServeur(
    app.setNotFoundHandler((req, reply) => {
      // Les chemins d'API inconnus restent des 404 franches ; le reste relève
      // du routeur de l'interface.
      if (req.method !== "GET" || req.headers.accept?.includes("application/json")) {
      if (req.method !== "GET" || req.url.startsWith("/api/")
        || req.headers.accept?.includes("application/json")) {
        return reply.code(404).send({ code: "introuvable", message: "Ressource inconnue." });
      }
      return reply.sendFile("index.html");
+7 −7
Original line number Diff line number Diff line
@@ -176,7 +176,7 @@ export async function verifierAdministration(
    organisationId: quotaOrg.organisationId, email: emailRecale,
    role: "membre", parCompteId: quotaOrg.compteId, urlPublique: URL_TEST,
  });
  const jetonInvit = jetonDans("/auth/invitation");
  const jetonInvit = jetonDans("/invitation");

  await r.refuse("EA-06", "le quota de membres est appliqué à l'acceptation, pas seulement affiché",
    () => comptes.accepterInvitation(sql, jetonInvit!, invite.compteId));
@@ -305,18 +305,18 @@ export async function verifierAdministration(
  r.titre("Accès à la console");
  const cookieRoot = await sessionFraiche(sql, root);

  const anonyme = await app.inject({ method: "GET", url: "/administration/etat" });
  const anonyme = await app.inject({ method: "GET", url: "/api/administration/etat" });
  r.verifie("EA-03", "la console refuse une requête sans session", anonyme.statusCode === 401);

  const cookieClient = await sessionFraiche(sql, cible);
  const nonRoot = await app.inject({
    method: "GET", url: "/administration/etat", headers: { cookie: cookieClient },
    method: "GET", url: "/api/administration/etat", headers: { cookie: cookieClient },
  });
  r.verifie("EA-03", "la console est invisible pour un compte non root",
    nonRoot.statusCode === 404, `reçu ${nonRoot.statusCode}`);

  const ouverte = await app.inject({
    method: "GET", url: "/administration/etat", headers: { cookie: cookieRoot },
    method: "GET", url: "/api/administration/etat", headers: { cookie: cookieRoot },
  });
  r.verifie("EA-03", "un root muni d'un second facteur récent entre",
    ouverte.statusCode === 200 && ouverte.json().version === e.version);
@@ -328,7 +328,7 @@ export async function verifierAdministration(
  await sql`update session set second_facteur_at = now() - interval '30 minutes'
             where id = ${sessionRoot!.id}`;
  const perimee = await app.inject({
    method: "GET", url: "/administration/etat", headers: { cookie: cookieRoot },
    method: "GET", url: "/api/administration/etat", headers: { cookie: cookieRoot },
  });
  r.verifie("EA-03", "une authentification trop ancienne est refusée, session valide ou non",
    perimee.statusCode === 401 && perimee.json().code === "reauthentification_requise");
@@ -336,14 +336,14 @@ export async function verifierAdministration(
  // ── Transparence côté organisation ───────────────────────────────────────
  r.titre("Transparence");
  const vueClient = await app.inject({
    method: "GET", url: `/organisations/${cible.organisationId}/acces-exceptionnels`,
    method: "GET", url: `/api/organisations/${cible.organisationId}/acces-exceptionnels`,
    headers: { cookie: cookieClient },
  });
  r.verifie("EA-10", "l'organisation consulte l'historique des accès dont elle a fait l'objet",
    vueClient.statusCode === 200 && vueClient.json().historique.length === 2);

  const vueEtrangere = await app.inject({
    method: "GET", url: `/organisations/${root.organisationId}/acces-exceptionnels`,
    method: "GET", url: `/api/organisations/${root.organisationId}/acces-exceptionnels`,
    headers: { cookie: cookieClient },
  });
  r.verifie("EA-10", "elle ne voit pas ceux d'une autre organisation", vueEtrangere.statusCode === 403);
Loading