Commit b6a244aa authored by Kourser's avatar Kourser
Browse files

Ce qui est à vous tient sous votre nom

Trois choses qui n'en font qu'une : l'interface rangeait par hasard.

La console root disait « Reconnectez-vous » quand la présentation du
second facteur datait de plus de quinze minutes, et il fallait l'entendre
au pied de la lettre — quitter, puis revenir. Or le serveur accepte depuis
toujours qu'une session complète représente son second facteur, sur la
même route qu'à la connexion. Il ne manquait que le champ. On le saisit
sans quitter la console, et la règle EA-03 ne perd rien : elle exige une
présentation récente, pas une session neuve.

Le rail portait un groupe « Votre compte » où siégeaient les canaux de
diffusion, l'écriture à l'annuaire et la veille externe — tout sauf des
affaires personnelles. Il devient : Organisation, puis Paramètres de
l'organisation, puis Instance. Ce dernier découpage n'invente rien :
« Canaux » et « Membres » l'annonçaient déjà dans leur propre en-tête, et
le rail les rangeait ailleurs.

La langue et le thème occupaient six boutons permanents en haut de chaque
écran, y compris pendant une crise où cette place se paie. Ce sont des
choix qu'on pose une fois — la définition de ce qui ne doit pas occuper
une barre. Ils rejoignent une page Préférences, avec le troisième réglage
personnel qui traînait ailleurs : la coupure du son, jusqu'ici une cloche
dans la barre de crise.

Le nom, en haut à droite, devient la porte de tout cela. Sécurité,
Préférences, Quitter, et rien d'autre n'y entre — c'est cette règle qui
rend un menu prévisible.

Les deux écrans sont servis DANS la coque de crise autant que dans
l'espace de l'organisation : aller changer de thème ne doit pas faire
sortir de la main courante.

Éprouvé à l'écran, sur un compte root doté d'un second facteur et dont la
fraîcheur a été périmée en base : le champ apparaît, un code à six
chiffres rouvre la console sans déconnexion, et le rail affiche « Instance ».
966 garanties, 55 clés traduites sur 55.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 71f2d4e2
Loading
Loading
Loading
Loading
Loading
+15 −16
Changes for apps/web/src/coque/Coque.tsx: 15 added lines, 16 removed lines.
Original line number Diff line number Diff line
@@ -16,14 +16,16 @@ import { MainCourante } from "../ecrans/MainCourante.js";
import { Salon } from "../ecrans/Salon.js";
import { Conduite } from "../ecrans/Conduite.js";
import { Cellules } from "../ecrans/Cellules.js";
import { Preferences } from "../ecrans/Preferences.js";
import { Securite } from "../ecrans/Securite.js";
import { MenuCompte } from "./MenuCompte.js";
import { Documents } from "../ecrans/Documents.js";
import { Preparation } from "../ecrans/Preparation.js";
import { Tableaux } from "../ecrans/Tableaux.js";
import { Mobilisation } from "../ecrans/Mobilisation.js";
import { Cloture } from "../ecrans/Cloture.js";
import { useCapacites } from "../capacites.js";
import { ChoixTheme } from "../theme.js";
import { definirMuet, muet, tinter, titrer } from "../signal.js";
import { tinter, titrer } from "../signal.js";
import { Continuite } from "../ecrans/Continuite.js";
import { Echeances, reste as rendreReste } from "../ecrans/Echeances.js";
import { Kanban } from "../ecrans/Kanban.js";
@@ -31,7 +33,7 @@ import { VeilleExterne } from "../ecrans/VeilleExterne.js";
import { Directs } from "../ecrans/Directs.js";
import { CanauxCrise } from "../ecrans/CanauxCrise.js";
import { Recensement } from "../ecrans/Recensement.js";
import { ChoixLangue, useT } from "../langues/index.js";
import { useT } from "../langues/index.js";

interface SalleListee {
  id: string; libelle: string; cellule_id: string | null; genre: string;
@@ -66,7 +68,6 @@ export function Coque({ criseId }: { criseId: string }) {
  const [appel, definirAppel] = useState<Appel | null>(null);
  const [railOuvert, definirRailOuvert] = useState(false);
  const [nonLus, definirNonLus] = useState<Record<string, number>>({});
  const [silence, definirSilence] = useState(muet);
  // Lus dans le rappel du flux, qui ne doit pas se réabonner à chaque
  // navigation : une référence plutôt qu’une dépendance.
  const salonOuvertRef = useRef<string | undefined>(undefined);
@@ -245,18 +246,6 @@ export function Coque({ criseId }: { criseId: string }) {
              {t("{n} en ligne", { n: flux.presents.length })}
            </span>
          )}
          <ChoixLangue />
          <ChoixTheme />
          <button className="discret petit"
            aria-pressed={silence}
            aria-label={silence ? "Réactiver le son des nouveaux messages"
              : "Couper le son des nouveaux messages"}
            title={silence
              ? "Le son est coupé. Les pastilles et le titre de l’onglet restent."
              : "Couper le son des nouveaux messages"}
            onClick={() => { definirMuet(!silence); definirSilence(!silence); }}>
            {silence ? "🔇" : "🔔"}
          </button>
          <button className="discret petit" onClick={() => definirRecherche(true)}>
            {t("⌘K Chercher")}
          </button>
@@ -280,6 +269,12 @@ export function Coque({ criseId }: { criseId: string }) {
              {capacites.visio ? t("Rejoindre l’appel") : t("Visio non configurée")}
            </button>
          )}
          {/* Langue, thème et son ont quitté cette barre pour les préférences.
              Ces écrans sont servis DANS la crise : aller changer de thème ne
              doit pas faire sortir de la main courante. */}
          <MenuCompte
            versSecurite={() => aller(`/crises/${criseId}/securite`)}
            versPreferences={() => aller(`/crises/${criseId}/preferences`)} />
          {rembobinage === null ? (
            <button className="accent" onClick={() => definirConsigne({
              celluleId: celluleActive,
@@ -466,6 +461,10 @@ export function Coque({ criseId }: { criseId: string }) {
              ?? null}
            saisie={flux.saisie}
            consigner={(p) => definirConsigne(p)} />
        ) : module === "preferences" ? (
          <Preferences />
        ) : module === "securite" ? (
          <Securite />
        ) : module === "cellules" ? (
          <Cellules criseId={criseId} organisationId={crise?.organisation_id ?? ""}
            lectureSeule={rembobinage !== null} />
+85 −0
Changes for apps/web/src/coque/MenuCompte.tsx: 85 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useEffect, useRef, useState } from "react";
import { api } from "../api.js";
import { useEtat } from "../etat.js";
import { useT } from "../langues/index.js";

/**
 * Le nom, en haut à droite, devient une porte.
 *
 * Il n’était qu’une étiquette, et ce qui relevait du compte était éparpillé :
 * « Sécurité » dans un groupe du rail nommé « Votre compte » où siégeaient
 * surtout des réglages d’organisation, la langue et le thème en boutons
 * permanents dans la barre, le son en cloche ailleurs encore.
 *
 * Tout ce qui est à vous tient maintenant sous votre nom, et rien d’autre n’y
 * entre. C’est la règle qui rend le menu prévisible : ce qu’on y cherche, on
 * l’y trouve, et ce qu’on n’y cherche pas n’y est pas.
 */
export function MenuCompte(
  { versSecurite, versPreferences }:
  { versSecurite: () => void; versPreferences: () => void },
) {
  const { moi } = useEtat();
  // Le menu vit dans la coque, et la coque est la partie traduite.
  const t = useT();
  const [ouvert, definirOuvert] = useState(false);
  const boite = useRef<HTMLDivElement>(null);

  // Un menu qui ne se ferme pas au clic à côté est un menu qu’on referme en
  // rechargeant la page.
  useEffect(() => {
    if (!ouvert) return undefined;
    const dehors = (e: MouseEvent) => {
      if (!boite.current?.contains(e.target as Node)) definirOuvert(false);
    };
    const echap = (e: KeyboardEvent) => { if (e.key === "Escape") definirOuvert(false); };
    document.addEventListener("mousedown", dehors);
    document.addEventListener("keydown", echap);
    return () => {
      document.removeEventListener("mousedown", dehors);
      document.removeEventListener("keydown", echap);
    };
  }, [ouvert]);

  const sansSecond = moi ? !moi.compte.secondFacteurActif : false;

  const choisir = (f: () => void) => () => { definirOuvert(false); f(); };

  return (
    <div className="menu-compte" ref={boite}>
      <button className="declencheur" aria-haspopup="menu" aria-expanded={ouvert}
        onClick={() => definirOuvert(!ouvert)}>
        {moi?.compte.nom}
        {/* Le second facteur manquant se voit sans ouvrir le menu : c’est la
            seule chose d’ici qui appelle un geste, et elle ne peut pas
            attendre qu’on pense à regarder. */}
        {sansSecond && <span className="alerte-pastille" aria-label="sans second facteur">◆</span>}
        <span aria-hidden="true" className="chevron">▾</span>
      </button>

      {ouvert && (
        <div className="panneau-menu" role="menu">
          <div className="tete">
            <strong>{moi?.compte.nom}</strong>
            <span className="discret">{moi?.compte.email}</span>
          </div>
          <button role="menuitem" onClick={choisir(versSecurite)}>
            {t("Sécurité")}
            {sansSecond && (
              <span className="pousse pastille alerte sans-point">{t("sans second facteur")}</span>
            )}
          </button>
          <button role="menuitem" onClick={choisir(versPreferences)}>
            {t("Préférences")}
          </button>
          <div className="separateur" />
          <button role="menuitem" className="sortie"
            onClick={() => void api.poster("/api/auth/deconnexion")
              .then(() => location.reload())}>
            {t("Quitter")}
          </button>
        </div>
      )}
    </div>
  );
}
+17 −32
Changes for apps/web/src/ecrans/EspaceOrganisation.tsx: 17 added lines, 32 removed lines.
Original line number Diff line number Diff line
import { api } from "../api.js";
import { useEtat } from "../etat.js";
import { useRoute } from "../routeur.js";
import { Veille } from "./Veille.js";
@@ -6,12 +5,12 @@ import { Declenchement } from "./Declenchement.js";
import { DossierPreparation } from "./preparation/DossierPreparation.js";
import { Administration } from "./administration/Administration.js";
import { Securite } from "./Securite.js";
import { ChoixTheme } from "../theme.js";
import { Canaux } from "./Canaux.js";
import { Membres } from "./Membres.js";
import { Preferences } from "./Preferences.js";
import { MenuCompte } from "../coque/MenuCompte.js";
import { EnvoiMasse } from "./EnvoiMasse.js";
import { VeilleExterne } from "./VeilleExterne.js";
import { ChoixLangue } from "../langues/index.js";

/**
 * Espace de l’organisation, hors crise.
@@ -54,52 +53,37 @@ export function EspaceOrganisation() {
          <span className="intitule">{organisation?.nom}</span>
        )}
        <span className="pousse ligne" style={{ gap: 8 }}>
          <ChoixLangue />
          <ChoixTheme />
          <span className="discret" style={{ fontSize: 13 }}>{moi?.compte.nom}</span>
          {!moi?.compte.secondFacteurActif && (
            <button className="pastille alerte" style={{ border: "1px solid var(--alerte)" }}
              onClick={() => aller("/securite")}>
              sans second facteur
            </button>
          )}
          <button className="discret petit"
            onClick={() => void api.poster("/api/auth/deconnexion").then(() => location.reload())}>
            Quitter
          </button>
          <MenuCompte versSecurite={() => aller("/securite")}
            versPreferences={() => aller("/preferences")} />
        </span>
      </div>

      <div className="corps">
        <nav className="rail">
          {/* Ce que l'organisation fait. */}
          <div className="groupe">
            <span className="etiq">Organisation</span>
            <button className={`item${module === "veille" ? " actif" : ""}`}
              onClick={() => aller("/")}>Veille</button>
            <button className={`item${module === "veille-externe" ? " actif" : ""}`}
              onClick={() => aller("/veille-externe")}>Veille externe</button>
            <button className={`item${module === "preparation" ? " actif" : ""}`}
              onClick={() => aller("/preparation")}>Dossier de préparation</button>
            <button className={`item${module === "membres" ? " actif" : ""}`}
              onClick={() => aller("/membres")}>Membres</button>
            {estProprietaire && (
              <button className={`item${module === "diffusion" ? " actif" : ""}`}
                onClick={() => aller("/diffusion")}>Écrire à l’annuaire</button>
            )}
          </div>
          {/* Comment elle est réglée. Ces deux écrans le disaient déjà dans
              leur propre en-tête ; le rail les rangeait ailleurs. */}
          <div className="groupe">
            <span className="etiq">Votre compte</span>
            <span className="etiq">Paramètres de l’organisation</span>
            <button className={`item${module === "membres" ? " actif" : ""}`}
              onClick={() => aller("/membres")}>Membres</button>
            {estProprietaire && (
              <>
              <button className={`item${module === "canaux" ? " actif" : ""}`}
                onClick={() => aller("/canaux")}>Canaux de diffusion</button>
                <button className={`item${module === "diffusion" ? " actif" : ""}`}
                  onClick={() => aller("/diffusion")}>Écrire à l’annuaire</button>
              </>
            )}
            <button className={`item${module === "veille-externe" ? " actif" : ""}`}
              onClick={() => aller("/veille-externe")}>Veille externe</button>
            <button className={`item${module === "securite" ? " actif" : ""}`}
              onClick={() => aller("/securite")}>
              Sécurité
              {!moi?.compte.secondFacteurActif && (
                <span className="compteur" style={{ color: "var(--alerte)" }}>◆</span>
            )}
            </button>
          </div>
          {root && (
            <div className="groupe">
@@ -125,6 +109,7 @@ export function EspaceOrganisation() {
          : module === "veille-externe" && organisationId
            ? <VeilleExterne organisationId={organisationId} />
          : module === "securite" ? <Securite />
          : module === "preferences" ? <Preferences />
          : module === "administration" ? <Administration />
          : <Veille />}
      </div>
+84 −0
Changes for apps/web/src/ecrans/Preferences.tsx: 84 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { ChoixLangue } from "../langues/index.js";
import { ChoixTheme } from "../theme.js";
import { definirMuet, muet } from "../signal.js";

/**
 * Les réglages qui n’appartiennent qu’à vous.
 *
 * Ils vivaient dans la barre d’état : six boutons permanents en haut de chaque
 * écran, y compris pendant une crise où cette place se paie. Or ce sont des
 * choix qu’on pose une fois et qu’on ne rouvre presque jamais — la définition
 * même de ce qui ne doit pas occuper une barre.
 *
 * Ils ne quittent pas la crise pour autant : la coque de crise sert cette même
 * page. Aller changer de thème ne doit pas faire sortir de la main courante.
 *
 * Rien n’est envoyé au serveur. La langue et le thème vivent dans le stockage
 * du navigateur, le son aussi : ce sont des préférences d’appareil, et le même
 * compte ouvert sur le poste de la salle de crise et sur un téléphone n’a pas
 * les mêmes besoins.
 */
export function Preferences() {
  const [silence, definirSilence] = useState(muet);

  return (
    <div className="scene">
      <header>
        <div className="etiq">Votre compte</div>
        <h1>Préférences</h1>
      </header>

      <div className="contenu">
        <p className="aide mesure" style={{ marginBottom: 20 }}>
          Ces trois réglages valent pour ce navigateur et pour lui seul. Ils ne
          suivent pas votre compte d’un appareil à l’autre — le poste de la salle
          de crise et votre téléphone n’ont pas les mêmes besoins.
        </p>

        <div className="pile" style={{ maxWidth: 640 }}>
          <div className="carte">
            <div className="ligne">
              <strong>Langue</strong>
              <span className="pousse"><ChoixLangue /></span>
            </div>
            <div className="aide" style={{ marginTop: 6 }}>
              L’anglais couvre la coque et les écrans centraux ; le reste retombe
              en français. On préfère le dire que le laisser découvrir.
            </div>
          </div>

          <div className="carte">
            <div className="ligne">
              <strong>Thème</strong>
              <span className="pousse"><ChoixTheme /></span>
            </div>
            <div className="aide" style={{ marginTop: 6 }}>
              « Auto » suit le réglage de votre système et bascule avec lui. Les
              deux autres s’imposent, quelle que soit l’heure.
            </div>
          </div>

          <div className="carte">
            <div className="ligne">
              <strong>Son des nouveaux messages</strong>
              <label className="pousse ligne" style={{ gap: 6, fontSize: 13.5 }}>
                <input type="checkbox" checked={!silence}
                  onChange={(e) => {
                    definirMuet(!e.target.checked);
                    definirSilence(!e.target.checked);
                  }} />
                Signaler
              </label>
            </div>
            <div className="aide" style={{ marginTop: 6 }}>
              Coupé, les pastilles et le titre de l’onglet continuent de compter :
              on perd l’interruption, jamais l’information. À couper en salle de
              crise, où dix postes qui tintent ensemble ne signalent plus rien.
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
+74 −8
Changes for apps/web/src/ecrans/administration/Administration.tsx: 74 added lines, 8 removed lines.
Original line number Diff line number Diff line
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ErreurApi } from "../../api.js";
import { dateHeure } from "../../etat.js";
import { dateHeure, useEtat } from "../../etat.js";
import { Configuration } from "./Configuration.js";
import { Diagnostic } from "./Diagnostic.js";

@@ -38,6 +38,76 @@ const poids = (o: string) => `${(Number(o) / 1048576).toFixed(1)} Mo`;
 * dont la main courante est illisible après restauration — mais c’est un bris
 * de glace&nbsp;: motivé, borné, tracé, et visible de l’organisation concernée.
 */
/**
 * Rouvrir la console sans se déconnecter.
 *
 * EA-03 exige un second facteur de moins de quinze minutes pour entrer ici,
 * quelle que soit la fraîcheur de la session. C'est une bonne règle : la
 * console révoque des accès et lit des journaux, et une session laissée
 * ouverte sur un poste partagé ne doit pas y suffire.
 *
 * Ce qui n'allait pas, c'est la sortie. L'écran disait « Reconnectez-vous »,
 * et il fallait l'entendre au pied de la lettre : quitter, puis revenir. Or le
 * serveur accepte depuis toujours qu'une session complète représente son
 * second facteur — c'est le même geste qu'à la connexion, sur la même route.
 * Il ne manquait que le champ.
 */
function Rafraichir({ reprendre }: { reprendre: () => void }) {
  const { rafraichir } = useEtat();
  const [code, definirCode] = useState("");
  const [erreur, definirErreur] = useState<string | null>(null);
  const [envoi, definirEnvoi] = useState(false);

  async function presenter(e: React.FormEvent) {
    e.preventDefault();
    definirErreur(null);
    definirEnvoi(true);
    try {
      await api.poster("/api/auth/second-facteur", { code: code.trim() });
      // La session porte la fraîcheur : l'état global doit la relire, sans
      // quoi le reste de l'interface continuerait de croire l'accès périmé.
      rafraichir();
      reprendre();
    } catch (e2) {
      definirErreur(e2 instanceof ErreurApi ? e2.message : "Code refusé.");
      definirCode("");
    } finally {
      definirEnvoi(false);
    }
  }

  return (
    <div className="scene"><div className="contenu">
      <div className="carte" style={{ maxWidth: 460 }}>
        <span className="etiq">Console root</span>
        <strong style={{ display: "block", marginTop: 4 }}>
          Présentez à nouveau votre second facteur.
        </strong>
        <p className="aide" style={{ marginTop: 6 }}>
          La console exige une présentation de moins de quinze minutes, quelle que
          soit l’ancienneté de votre session. Vous restez connecté.
        </p>
        {erreur && <div className="erreur" style={{ marginTop: 10 }}>{erreur}</div>}
        <form onSubmit={presenter} style={{ marginTop: 12 }}>
          <div className="champ">
            <label htmlFor="fraicheur">Code à six chiffres</label>
            <input id="fraicheur" value={code} inputMode="numeric" autoFocus
              autoComplete="one-time-code"
              onChange={(ev) => definirCode(ev.target.value)}
              style={{ fontFamily: '"JetBrains Mono", monospace', letterSpacing: "0.08em" }} />
            <div className="aide">
              Un code de secours convient aussi, et se consomme.
            </div>
          </div>
          <button className="accent" type="submit" disabled={envoi || code.trim().length < 6}>
            {envoi ? "…" : "Rouvrir la console"}
          </button>
        </form>
      </div>
    </div></div>
  );
}

export function Administration() {
  const client = useQueryClient();
  const [onglet, definirOnglet] = useState<
@@ -65,17 +135,13 @@ export function Administration() {
  if (etat.isError) {
    const e = etat.error;
    const perimee = e instanceof ErreurApi && e.code === "reauthentification_requise";
    if (perimee) return <Rafraichir reprendre={() => void etat.refetch()} />;
    return (
      <div className="scene"><div className="contenu">
        <div className="carte alerte" style={{ maxWidth: 560 }}>
          <strong>{perimee
            ? "Authentification trop ancienne."
            : "Console inaccessible."}</strong>
          <strong>Console inaccessible.</strong>
          <p style={{ marginTop: 6, fontSize: 13.5 }}>
            {perimee
              ? "La console exige une présentation du second facteur de moins de quinze "
                + "minutes, quelle que soit la fraîcheur de votre session. Reconnectez-vous."
              : "Cette console est réservée au compte root de l’instance."}
            Cette console est réservée au compte root de l’instance.
          </p>
        </div>
      </div></div>
Loading