Commit 4a2c9cff authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Lot 4 : le reste des écrans, et une mesure qui se trompait deux fois

Membres, sécurité, veille externe, écrire à l'annuaire, clôture, intégrations,
enregistrements, canaux de diffusion, documents, déclenchement, tableaux
blancs, amorce, appareils mobiles, atterrissage, inscription, connexion, second
facteur, partage, préférences, continuité, comparaison, mes actions, veille,
coque de l'organisation, console root.

480 chaînes enveloppées, 333 traductions neuves. Le texte écrit dans le rendu
est intégralement traduit.

── La mesure minorait la couverture

Le motif « > … < » attrapait aussi ce qui sépare le chevron fermant d'un
générique TypeScript du chevron suivant : « useState<string | null>(null); const
[x, y] = useState< » comptait pour du texte affiché. Deux cent neuf faux
positifs gonflaient le dénominateur, et minoraient donc le chiffre publié.
Écartés par ce qui trahit du code : une flèche, un point-virgule, un identifiant
pointé, un guillemet droit — le produit écrit « ainsi ». Quatre chaînes sont
nommées comme intraduisibles : la marque, et « true », « false », « Node » que
la console root affiche telles quelles.

── Et elle majorait la couverture, plus gravement

Une fois les faux positifs retirés, le compteur annonçait cent pour cent. À
l'écran, l'anglais s'arrêtait à « Directeur de crise » et « Envoyer
l'invitation » : le texte qui ne vit pas dans le rendu — libellés de rôles,
catalogues de tiers, messages d'erreur, noms de statuts — n'était pas compté.
Trois cent vingt chaînes.

C'était exactement le défaut que ce chantier corrigeait : un chiffre qui mesure
ce qu'on lui a donné plutôt que ce qu'il y a. Le dénominateur les compte
désormais, et la couverture retombe à soixante-dix-neuf pour cent — chiffre
juste plutôt que cent flatteur. Le cliquet suit.

── Ce que ce lot laisse

Trois cent vingt chaînes en constantes, et cent quatre-vingt-trois côté serveur
— l'indicateur de préparation, les messages d'erreur des routes, les résumés
d'événements — qui arrivent en français dans une interface réglée en anglais et
qu'aucune méthode côté client ne peut couvrir.

Co-Authored-By: Claude (RCA)
parent cadc79a3
Loading
Loading
Loading
Loading
Loading
+63 −3
Changes for apps/api/src/verification/interface.ts: 63 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -137,13 +137,37 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  // Quatre chaînes techniques en sortent, qui ne se traduisent pas : un chemin
  // d'API, un numéro de licence, un sigle.
  const NON_TRADUISIBLE = /^(?:[/#][^\s]*|[A-Z0-9][A-Z0-9.\-+_]*|\d[\d\s.,%€–-]*|[^\wÀ-ÿ]+)$/;
  // Le motif « > … < » attrape aussi ce qui sépare le chevron fermant d'un
  // générique TypeScript du chevron suivant : « useState<string | null>(null);
  // const [x, y] = useState< » n'est pas du texte affiché. Deux cent neuf faux
  // positifs gonflaient le dénominateur — et donc minoraient la couverture
  // publiée. On écarte ce qui porte une marque de code.
  const CODE = /=>|;|\bconst\b|\buseState\b|\bapi\.|\breturn\b|\bfunction\b|===|!==|&&|\?\?|\?\.|\bnew [A-Z]|\bPromise\b|\.trim\(|\?\s*\(|^[:)=]|\bo\.[a-z_]+\s*\?/;

  // Quatre chaînes ne se traduisent pas, et il vaut mieux les nommer que les
  // laisser peser sur un pourcentage : la marque, qui est un nom propre, et
  // trois valeurs techniques affichées telles quelles dans la console root.
  const TELLES_QUELLES = new Set(["Kastell", "true", "false", "Node"]);
  const nues = new Set<string>();
  for (const f of fichiers) {
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
    for (const m of texte.matchAll(/>([^<>{}]*[A-Za-zÀ-ÿ][^<>{}]*)</g)) {
      const brut = m[1]!.replace(/\s+/g, " ").trim();
      if (brut && !NON_TRADUISIBLE.test(brut)) nues.add(brut);
      // Un commentaire cite parfois des chevrons — « l:<nom> » — que le motif
      // prend pour des balises. Et « m.seq » ou « (e: React.ChangeEvent » sont
      // du code, pas du texte : un identifiant pointé, un début d'expression.
      const ligne = texte.lastIndexOf("\n", m.index!) + 1;
      const enCommentaire = texte.slice(ligne, m.index!).includes("//");
      // Un identifiant pointé, ou une liste de paramètres typés : « m.seq »,
      // « (e: React.ChangeEvent ». Jamais du texte.
      const identifiant = /^\(?[a-z_$][\w$]*(\.[\w$]+)*(\s*:\s*[\w$.]+)?$/.test(brut);
      // Un texte affiché ne porte jamais de guillemet droit : ce produit écrit
      // « ainsi ». Un fragment qui en contient vient d'une chaîne de code.
      const guillemetDroit = brut.includes('"');
      if (brut && !NON_TRADUISIBLE.test(brut) && !CODE.test(brut)
        && !TELLES_QUELLES.has(brut) && !enCommentaire && !identifiant
        && !guillemetDroit) nues.add(brut);
    }
    for (const m of texte.matchAll(
      /(?:placeholder|aria-label|title)="([^"{}]*[A-Za-zÀ-ÿ][^"{}]*)"/g)) {
@@ -151,6 +175,31 @@ export async function verifierInterface(r: Rapport): Promise<void> {
      if (brut && !NON_TRADUISIBLE.test(brut)) nues.add(brut);
    }
  }
  // Troisième source, et celle qui manquait : le texte qui ne vit pas dans le
  // rendu. Les libellés de rôles, les catalogues, les messages d'erreur des
  // écrans sont des littéraux dans des constantes ; l'enveloppeur ne les voit
  // pas, et les compter à part reviendrait à publier de nouveau un chiffre qui
  // ne dit pas ce qu'il paraît dire. Ils s'affichent, donc ils comptent.
  const FRANCAIS = /"([^"\\\n]*[àâäéèêëîïôöùûü眒][^"\\\n]*)"/g;
  const APPEL_T = /\bt\(\s*(?:"(?:[^"\\\n]|\\.)*"\s*\+?\s*)+\)?/g;
  for (const f of fichiers) {
    if (f.includes("/langues/")) continue;
    const texte = await readFile(f, "utf8");
    const couverts = [...texte.matchAll(APPEL_T)].map((m) => [m.index!, m.index! + m[0].length]);
    for (const m of texte.matchAll(FRANCAIS)) {
      const i = m.index!;
      if (couverts.some(([a, b]) => a! <= i && i < b!)) continue;
      const debut = texte.lastIndexOf("\n", i) + 1;
      const avant = texte.slice(debut, i);
      // Un commentaire n'est pas de l'interface ; « throw new Error » non plus,
      // qui s'adresse à celui qui lit la console, pas à la cellule.
      if (avant.trimStart().startsWith("*") || avant.includes("//")) continue;
      if (avant.includes("throw new Error(") || avant.includes("console.")) continue;
      const valeur = m[1]!.trim();
      if (valeur.length >= 3) nues.add(valeur);
    }
  }

  const visibles = employees.size + nues.size;
  const couverture = visibles === 0 ? 0 : Math.round((employees.size / visibles) * 100);

@@ -159,12 +208,23 @@ export async function verifierInterface(r: Rapport): Promise<void> {
  // laisser la couverture reculer. Ce plancher se relève au fil des lots — il
  // ne se baisse jamais. Tant qu'il n'atteint pas 100, EX-21 n'est pas tenue,
  // et le chiffre le dit à chaque exécution.
  const PLANCHER = 56;
  // Le cliquet a fait son office : parti de dix-sept pour cent, il a servi de
  // liste de tâches lot après lot. Il est arrivé à cent, et devient donc une
  // exigence simple — EX-21 demande une interface bilingue, pas une interface
  // presque bilingue. Ce qui manquerait est nommé, pour qu'aucune chaîne neuve
  // ne se glisse en français sans qu'on la voie.
  // Le cliquet, relevé lot après lot : dix-sept, vingt-et-un, trente-neuf,
  // cinquante-six, puis soixante-dix-neuf. Il ne se baisse jamais. Le jour où
  // il atteindra cent, il deviendra une exigence simple — d'ici là, il dit
  // exactement ce qui reste, et empêche que cela reparte à l'envers.
  const PLANCHER = 79;
  r.verifie("EX-21",
    `${employees.size} chaînes visibles sur ${visibles} passent par la traduction`
    + ` (${couverture} %, plancher ${PLANCHER} %)`,
    couverture >= PLANCHER,
    couverture < PLANCHER ? `la couverture a reculé de ${PLANCHER - couverture} points` : "");
    couverture < PLANCHER
      ? `recul de ${PLANCHER - couverture} points ; il reste ${nues.size} chaînes`
      : "");

  // Un texte non traduit s'affiche en français : c'est le repli voulu, jamais
  // un identifiant technique illisible sous pression.
+27 −17
Changes for apps/web/src/ecrans/Amorce.tsx: 27 added lines, 17 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import QRCode from "qrcode";
import { useEffect, useState } from "react";
import { api, ErreurApi } from "../api.js";
import { useEtat } from "../etat.js";
import { useT } from "../langues/index.js";

/**
 * Le parcours qu'on ne peut pas contourner, à la première connexion.
@@ -19,6 +20,7 @@ import { useEtat } from "../etat.js";
 * requête écrite à la main le rencontre aussi.
 */
export function Amorce() {
  const t = useT();
  const { moi, rafraichir } = useEtat();
  const [ancien, definirAncien] = useState("");
  const [nouveau, definirNouveau] = useState("");
@@ -59,33 +61,36 @@ export function Amorce() {
    return (
      <div className="centre">
        <div className="panneau">
          <h1 className="marque">Posez votre mot de passe</h1>
          <h1 className="marque">{t("Posez votre mot de passe")}</h1>
          <p className="accroche">
            Celui qui vous a été remis à l'installation a traversé un terminal,
            et peut-être une épaule. Il ne sert qu'à cette première connexion.
            {t("Celui qui vous a été remis à "
                                                 + "l'installation a traversé un terminal, et "
                                                 + "peut-être une épaule. Il ne sert qu'à "
                                                 + "cette première connexion.")}
          </p>
          {erreur && <div className="erreur">{erreur}</div>}

          <form onSubmit={soumettre}>
            <div className="champ">
              <label htmlFor="ancien">Mot de passe actuel</label>
              <label htmlFor="ancien">{t("Mot de passe actuel")}</label>
              <input id="ancien" type="password" value={ancien}
                autoComplete="current-password"
                onChange={(e) => definirAncien(e.target.value)} />
              <p className="aide">
                Laissez vide si vous êtes arrivé par un lien reçu par courriel :
                vous n'en avez alors jamais eu.
                {t("Laissez vide si vous êtes arrivé par "
                                                     + "un lien reçu par courriel : vous n'en "
                                                     + "avez alors jamais eu.")}
              </p>
            </div>
            <div className="champ">
              <label htmlFor="nouveau">Nouveau mot de passe</label>
              <label htmlFor="nouveau">{t("Nouveau mot de passe")}</label>
              <input id="nouveau" type="password" value={nouveau} required autoFocus
                autoComplete="new-password"
                onChange={(e) => definirNouveau(e.target.value)} />
              <p className="aide">Douze caractères au moins.</p>
              <p className="aide">{t("Douze caractères au moins.")}</p>
            </div>
            <div className="champ">
              <label htmlFor="repete">Répétez-le</label>
              <label htmlFor="repete">{t("Répétez-le")}</label>
              <input id="repete" type="password" value={repete} required
                autoComplete="new-password"
                onChange={(e) => definirRepete(e.target.value)} />
@@ -104,7 +109,7 @@ export function Amorce() {
  return (
    <div className="centre">
      <div className="panneau">
        <h1 className="marque">Activez votre second facteur</h1>
        <h1 className="marque">{t("Activez votre second facteur")}</h1>
        <p className="accroche">
          {amorce.destineARoot
            ? "Le droit d'administration vous sera accordé au bout de cette "
@@ -121,6 +126,7 @@ export function Amorce() {

/** Préparer puis activer un TOTP, comme l'écran Sécurité, sans en sortir. */
function ActiverSecondFacteur() {
  const t = useT();
  const { moi, rafraichir } = useEtat();
  const [secret, definirSecret] = useState<string | null>(null);
  const [qr, definirQr] = useState<string | null>(null);
@@ -165,15 +171,19 @@ function ActiverSecondFacteur() {
    return (
      <>
        <p className="accroche">
          Second facteur activé. Voici vos codes de secours — ils ne seront plus
          affichés. Imprimez-les et rangez-les hors du système d'information.
          {t("Second facteur activé. Voici vos codes de "
                                             + "secours — ils ne seront plus affichés. "
                                             + "Imprimez-les et rangez-les hors du système "
                                             + "d'information.")}
        </p>
        <pre style={{ overflowWrap: "anywhere", whiteSpace: "pre-wrap" }}>
          {codes.join("\n")}
        </pre>
        <button className="accent" onClick={() => rafraichir()}
          style={{ width: "100%", justifyContent: "center" }}>
          Je les ai notés, continuer
          {t("Je les ai "
                                                                           + "notés, "
                                                                           + "continuer")}
        </button>
      </>
    );
@@ -185,7 +195,7 @@ function ActiverSecondFacteur() {
        {erreur && <div className="erreur">{erreur}</div>}
        <button className="accent" onClick={() => void preparer()}
          style={{ width: "100%", justifyContent: "center" }}>
          Commencer
          {t("Commencer")}
        </button>
      </>
    );
@@ -197,11 +207,11 @@ function ActiverSecondFacteur() {
        <img src={qr} alt="Code à photographier avec votre application"
          style={{ display: "block", margin: "0 auto 12px" }} />
      )}
      <p className="aide">Ou saisissez ce secret à la main :</p>
      <p className="aide">{t("Ou saisissez ce secret à la main :")}</p>
      <pre style={{ overflowWrap: "anywhere", whiteSpace: "pre-wrap" }}>{secret}</pre>
      {erreur && <div className="erreur">{erreur}</div>}
      <div className="champ">
        <label htmlFor="totp">Code à six chiffres</label>
        <label htmlFor="totp">{t("Code à six chiffres")}</label>
        <input id="totp" value={code} inputMode="numeric" autoFocus
          autoComplete="one-time-code"
          onChange={(e) => definirCode(e.target.value)}
@@ -210,7 +220,7 @@ function ActiverSecondFacteur() {
      <button className="accent" onClick={() => void activer()}
        disabled={code.length < 6}
        style={{ width: "100%", justifyContent: "center" }}>
        Activer
        {t("Activer")}
      </button>
    </>
  );
+50 −17
Changes for apps/web/src/ecrans/AppareilsMobiles.tsx: 50 added lines, 17 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import QRCode from "qrcode";
import { api, ErreurApi } from "../api.js";
import { dateHeure } from "../etat.js";
import { useT } from "../langues/index.js";

interface Appareil {
  id: string; nom: string; plateforme: "ios" | "android";
@@ -31,6 +32,7 @@ const AUTORISATIONS: Record<string, string> = {
 * la page Sécurité et nulle part ailleurs.
 */
export function AppareilsMobiles() {
  const t = useT();
  const client = useQueryClient();
  const [enrolement, definirEnrolement] = useState<Enrolement | null>(null);
  const [qr, definirQr] = useState<string | null>(null);
@@ -62,8 +64,8 @@ export function AppareilsMobiles() {
      if (s === 0) definirEnrolement(null);
    };
    battre();
    const t = setInterval(battre, 1000);
    return () => clearInterval(t);
    const minuteur = setInterval(battre, 1000);
    return () => clearInterval(minuteur);
  }, [enrolement]);

  async function autoriser() {
@@ -80,21 +82,35 @@ export function AppareilsMobiles() {

  return (
    <section style={{ marginTop: 28 }}>
      <h2>Appareils mobiles</h2>
      <h2>{t("Appareils mobiles")}</h2>
      <p className="aide" style={{ marginTop: 4 }}>
        Un téléphone enrôlé peut être réveillé lors d’une mobilisation. Il reçoit une
        identité propre&nbsp;: le révoquer ne touche ni votre mot de passe, ni vos autres
        appareils.
        {t("Un téléphone enrôlé peut "
                                                              + "être réveillé lors d’une "
                                                              + "mobilisation. Il reçoit une "
                                                              + "identité propre : le "
                                                              + "révoquer ne touche ni votre "
                                                              + "mot de passe, ni vos autres "
                                                              + "appareils.")}
      </p>

      {/* Ce que l’instance sait faire, dit avant qu’on enrôle quoi que ce soit. */}
      {data && !data.relais.configure && (
        <div className="carte alerte" style={{ marginTop: 12 }}>
          <span className="etiq">Cette instance ne fait sonner aucun téléphone</span>
          <span className="etiq">{t("Cette instance ne fait sonner aucun téléphone")}</span>
          <div style={{ fontSize: 13.5, marginTop: 4 }}>
            Aucun relais de notification n’est configuré ici. Un appareil enrôlé servira à
            lire, consigner et rejoindre un appel — il ne réveillera personne. Le SMS et
            l’appel vocal restent les canaux de mobilisation, comme prévu.
            {t("Aucun relais de "
                                                                       + "notification n’est "
                                                                       + "configuré ici. Un "
                                                                       + "appareil enrôlé "
                                                                       + "servira à lire, "
                                                                       + "consigner et "
                                                                       + "rejoindre un appel "
                                                                       + "— il ne réveillera "
                                                                       + "personne. Le SMS et "
                                                                       + "l’appel vocal "
                                                                       + "restent les canaux "
                                                                       + "de mobilisation, "
                                                                       + "comme prévu.")}
          </div>
        </div>
      )}
@@ -103,7 +119,7 @@ export function AppareilsMobiles() {

      {enrolement ? (
        <div className="carte" style={{ marginTop: 14 }}>
          <span className="etiq">À scanner depuis l’application</span>
          <span className="etiq">{t("À scanner depuis l’application")}</span>
          <div className="ligne" style={{ alignItems: "flex-start", gap: 20, marginTop: 10 }}>
            {qr && <img src={qr} alt="Code d’enrôlement" width={180} height={180}
              style={{ borderRadius: 4, background: "#fff", padding: 6 }} />}
@@ -118,21 +134,23 @@ export function AppareilsMobiles() {
              </p>
              <button className="petit" style={{ marginTop: 8 }}
                onClick={() => definirEnrolement(null)}>
                Annuler ce code
                {t("Annuler ce code")}
              </button>
            </div>
          </div>
        </div>
      ) : (
        <button className="accent" style={{ marginTop: 12 }} onClick={() => void autoriser()}>
          Autoriser un téléphone
          {t("Autoriser "
                                                                                                           + "un "
                                                                                                           + "téléphone")}
        </button>
      )}

      {actifs.length > 0 && (
        <table style={{ marginTop: 18 }}>
          <thead>
            <tr><th>Appareil</th><th>Notifications</th><th>Enrôlé</th><th>Vu</th><th /></tr>
            <tr><th>{t("Appareil")}</th><th>{t("Notifications")}</th><th>{t("Enrôlé")}</th><th>{t("Vu")}</th><th /></tr>
          </thead>
          <tbody>
            {actifs.map((a) => (
@@ -162,7 +180,7 @@ export function AppareilsMobiles() {
                    onClick={() => void api.poster(`/api/appareils/${a.id}/revocation`,
                      { motif: "Révoqué depuis la page Sécurité." })
                      .then(() => client.invalidateQueries({ queryKey: ["appareils"] }))}>
                    Révoquer
                    {t("Révoquer")}
                  </button>
                </td>
              </tr>
@@ -173,8 +191,23 @@ export function AppareilsMobiles() {

      {actifs.length === 0 && (
        <p className="discret" style={{ marginTop: 14, fontSize: 13.5 }}>
          Aucun téléphone enrôlé. Ce n’est pas un manque&nbsp;: la mobilisation passe
          d’abord par le SMS et l’appel.
          {t("Aucun "
                                                                                      + "téléphone "
                                                                                      + "enrôlé. "
                                                                                      + "Ce "
                                                                                      + "n’est "
                                                                                      + "pas "
                                                                                      + "un "
                                                                                      + "manque : "
                                                                                      + "la "
                                                                                      + "mobilisation "
                                                                                      + "passe "
                                                                                      + "d’abord "
                                                                                      + "par "
                                                                                      + "le "
                                                                                      + "SMS "
                                                                                      + "et "
                                                                                      + "l’appel.")}
        </p>
      )}

+42 −22

File changed.

Preview size limit exceeded, changes collapsed.

+77 −29

File changed.

Preview size limit exceeded, changes collapsed.

Loading