Commit 995fc299 authored by Kourser's avatar Kourser
Browse files

Un nom se corrige, une adresse se remplace — et cela ne passe pas inaperçu

Rien ne corrigeait l'identité d'un compte : ni la personne, ni un
administrateur, ni la console d'instance. Les seules écritures étaient le mot
de passe, le second facteur et la désactivation. On se mariait, l'entreprise
changeait de domaine, et le seul chemin était de retirer le compte et d'en
ouvrir un autre — ce qui coupait la personne de tout ce qu'elle avait consigné
sous l'ancien.

Les deux gestes n'ont pas le même poids, et le code les sépare pour cela. Un
nom s'affiche ; une adresse connecte.

Le nom est journalisé sans motif : l'exiger pour une faute de frappe serait une
cérémonie.

L'adresse est l'identifiant de connexion. Un administrateur qui la remplacerait
par la sienne prendrait le compte, et rien dans le produit ne l'en empêche — le
droit d'inviter au rôle de propriétaire le lui donne déjà. Ce qu'on peut faire,
et qu'on fait, c'est que cela se voie : l'ancienne adresse reçoit un courriel
disant qu'elle ne connecte plus et à qui le signaler, la nouvelle repart non
vérifiée avec son lien, et le journal retient les deux adresses et le motif.

Les envois sont au mieux : une instance sans relais de courriel doit pouvoir
corriger une faute de frappe dans une adresse. Ce qui est parti, et ce qui ne
l'est pas, est dit plutôt que tu.

── Deux garanties qui ne prouvaient rien

« Elle repart non vérifiée » portait sur un compte qui n'avait jamais vérifié
son adresse : le contrôle était vrai avant même le changement.

« Une adresse déjà prise est refusée » se contentait de n'importe quelle
erreur, et la contrainte d'unicité en base suffisait à le verdir — l'exploitant
aurait lu « duplicate key value violates unique constraint ». Les refus
attendus sont maintenant nommés.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 23d80749
Loading
Loading
Loading
Loading
Loading
+147 −0
Changes for apps/api/src/auth/comptes.ts: 147 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -665,6 +665,153 @@ export async function changerRoleMembre(
  return { de: actuel, vers: d.role };
}


/**
 * Corriger l'identité d'un membre : son nom, son adresse.
 *
 * Rien ne les corrigeait — ni la personne, ni un administrateur, ni la console
 * d'instance. On se mariait, l'entreprise changeait de domaine, et le produit
 * n'avait aucun chemin : il fallait retirer le compte et en ouvrir un autre,
 * ce qui coupait la personne de tout ce qu'elle avait consigné sous l'ancien.
 *
 * Les deux gestes n'ont pas le même poids, et le code les sépare pour cela.
 * Un nom s'affiche ; une adresse connecte.
 */

export interface ChangementNom {
  organisationId: string;
  compteId: string;
  nom: string;
  parCompteId: string;
}

export async function changerNomMembre(
  sql: Sql, d: ChangementNom,
): Promise<{ de: string; vers: string }> {
  await exigerCompositeur(sql, d.organisationId, d.parCompteId);
  const membre = await roleDans(sql, d.compteId, d.organisationId);
  if (!membre) {
    throw new ErreurAuth("introuvable", "Cette personne n'est pas membre de l'organisation.");
  }
  const nom = d.nom.trim();
  if (nom.length < 2) {
    throw new ErreurAuth("operation_refusee", "Un nom fait au moins deux caractères.");
  }
  const [avant] = await sql<{ nom: string }[]>`
    select nom from compte where id = ${d.compteId}`;
  if (avant?.nom === nom) {
    throw new ErreurAuth("sans_effet", "C'est déjà son nom.");
  }

  await sql.begin(async (tx) => {
    await tx`update compte set nom = ${nom} where id = ${d.compteId}`;
    await journaliser(tx, {
      type: "compte.renomme", acteurId: d.parCompteId, organisationId: d.organisationId,
      charge: { compte_id: d.compteId, de: avant?.nom ?? "", vers: nom },
    });
  });
  return { de: avant?.nom ?? "", vers: nom };
}

export interface ChangementEmail {
  organisationId: string;
  compteId: string;
  email: string;
  parCompteId: string;
  motif: string;
  urlPublique: string;
}

/**
 * Changer l'adresse d'un membre.
 *
 * C'est son identifiant de connexion qu'on remplace : l'ancienne adresse
 * cesse d'ouvrir le compte, la nouvelle l'ouvre. Un administrateur qui la
 * remplacerait par la sienne prendrait le compte, et rien dans le produit ne
 * l'en empêche — le droit d'inviter au rôle de propriétaire le lui donne déjà.
 * Ce qu'on peut faire, et qu'on fait ici, c'est que cela ne passe pas
 * inaperçu :
 *
 *   L'ancienne adresse est prévenue. C'est la seule mesure qui vaille contre
 *   une prise de compte silencieuse — la personne apprend le changement là où
 *   l'auteur du changement n'a plus la main.
 *
 *   La nouvelle repart non vérifiée, avec son lien de confirmation. Une
 *   adresse posée par un tiers n'a jamais prouvé qu'elle existe.
 *
 *   Le motif est exigé et journalisé, avec les deux adresses.
 *
 * Les envois sont au mieux : une instance sans relais de courriel doit pouvoir
 * corriger une faute de frappe dans une adresse. Ce qui est parti, et ce qui
 * n'est pas parti, est rendu à l'appelant plutôt que tu.
 */
export async function changerEmailMembre(
  sql: Sql, d: ChangementEmail,
): Promise<{ de: string; vers: string; prevenu: boolean; confirmation: boolean }> {
  await exigerCompositeur(sql, d.organisationId, d.parCompteId);
  const membre = await roleDans(sql, d.compteId, d.organisationId);
  if (!membre) {
    throw new ErreurAuth("introuvable", "Cette personne n'est pas membre de l'organisation.");
  }
  const email = normaliserEmail(d.email);
  const [avant] = await sql<{ email: string; nom: string }[]>`
    select email, nom from compte where id = ${d.compteId}`;
  if (avant?.email === email) {
    throw new ErreurAuth("sans_effet", "C'est déjà son adresse.");
  }
  const [pris] = await sql`
    select 1 from compte where email = ${email} and id <> ${d.compteId}`;
  if (pris) {
    throw new ErreurAuth("email_deja_pris",
      "Un compte porte déjà cette adresse. Deux comptes ne peuvent pas la partager : "
      + "c'est par elle qu'on se connecte.");
  }

  const jeton = engendrer();
  await sql.begin(async (tx) => {
    await tx`
      update compte set email = ${email}, email_verifie = false where id = ${d.compteId}`;
    await tx`
      insert into jeton_usage_unique (empreinte, genre, compte_id, expire_at)
      values (${empreinte(jeton)}, 'verification_email', ${d.compteId},
              ${new Date(Date.now() + TTL_VERIFICATION_MS)})`;
    await journaliser(tx, {
      type: "compte.email_change", acteurId: d.parCompteId, organisationId: d.organisationId,
      charge: { compte_id: d.compteId, de: avant?.email ?? "", vers: email, motif: d.motif },
    });
  });

  const partie = async (m: Parameters<typeof envoyer>[0]) =>
    envoyer(m).then(() => true).catch(() => false);

  // À l'ancienne d'abord : c'est celle que la personne relève encore.
  const prevenu = avant?.email
    ? await partie({
      destinataire: avant.email,
      sujet: "Votre adresse de connexion a changé — Kastell",
      texte:
        `Bonjour ${avant.nom},\n\n`
        + `L'adresse qui ouvre votre compte Kastell est désormais ${email}.\n`
        + `Motif indiqué : ${d.motif}\n\n`
        + `Cette adresse-ci ne connecte plus. Si vous n'êtes pas à l'origine de ce\n`
        + `changement, dites-le tout de suite à la direction de votre organisation :\n`
        + `quelqu'un a la main sur votre compte.\n`,
    })
    : false;

  const confirmation = await partie({
    destinataire: email,
    sujet: "Confirmez votre nouvelle adresse — Kastell",
    texte:
      `Bonjour ${avant?.nom ?? ""},\n\n`
      + `Cette adresse ouvre désormais votre compte Kastell. Confirmez-la :\n`
      + `${d.urlPublique}/verification?jeton=${jeton}\n\n`
      + `Ce lien expire dans 24 heures.\n`,
  });

  return { de: avant?.email ?? "", vers: email, prevenu, confirmation };
}

export interface RetraitMembre {
  organisationId: string;
  compteId: string;
+46 −0
Changes for apps/api/src/routes/auth.ts: 46 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -370,6 +370,52 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
      });
    });

  /** Corriger le nom d'un membre. Bénin, journalisé quand même. */
  app.post("/organisations/:id/membres/:sousId/nom",
    { preHandler: requerirMembre(sql) }, async (req, reply) => {
      const { id, sousId } = z.object({
        id: z.string().uuid(), sousId: z.string().uuid(),
      }).parse(req.params);
      const c = z.object({
        nom: z.string().trim().min(2, "Un nom fait au moins deux caractères.").max(120),
      }).parse(req.body);
      const r = await comptes.changerNomMembre(sql, {
        organisationId: id, compteId: sousId, nom: c.nom,
        parCompteId: req.auth!.compte.id,
      });
      return reply.send({ message: `« ${r.de} » devient « ${r.vers} ».` });
    });

  /**
   * Corriger l'adresse d'un membre — c'est-à-dire son identifiant de connexion.
   *
   * Le motif est exigé parce que ce geste peut être une prise de compte, et que
   * la seule défense possible est qu'il se voie : l'ancienne adresse est
   * prévenue, le journal retient les deux.
   */
  app.post("/organisations/:id/membres/:sousId/email",
    { preHandler: requerirMembre(sql) }, async (req, reply) => {
      const { id, sousId } = z.object({
        id: z.string().uuid(), sousId: z.string().uuid(),
      }).parse(req.params);
      const c = z.object({
        email: z.string().email("Une adresse électronique complète."),
        motif: z.string().trim().min(3, "Dites pourquoi : le journal le gardera.").max(500),
      }).parse(req.body);
      const r = await comptes.changerEmailMembre(sql, {
        organisationId: id, compteId: sousId, email: c.email, motif: c.motif,
        parCompteId: req.auth!.compte.id, urlPublique: env.KASTELL_PUBLIC_URL,
      });
      return reply.send({
        message: `L'adresse devient « ${r.vers} ». `
          + (r.prevenu ? "L'ancienne en a été prévenue. " : "L'ancienne n'a pas pu être prévenue. ")
          + (r.confirmation
            ? "Un lien de confirmation est parti à la nouvelle."
            : "Le lien de confirmation n'a pas pu partir : l'adresse restera non vérifiée."),
        prevenu: r.prevenu, confirmation: r.confirmation,
      });
    });

  /**
   * Retirer un membre.
   *
+114 −1
Changes for apps/api/src/verification/renforts.ts: 114 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -6,7 +6,7 @@ import {
  ajouterMembre, consigner, ouvrirCrise, retirerMembreDeCellule,
} from "../domaine/crise.js";
import { etatALaSeq, mainCourante } from "../noyau/rejeu.js";
import { jetonDans, vider } from "./boite.js";
import { boite, jetonDans, vider } from "./boite.js";
import type { Rapport } from "./rapport.js";

const URL_TEST = "http://localhost:8080";
@@ -416,6 +416,119 @@ export async function verifierRenforts(
      && (await comptes.roleDans(sql, simple.compteId, hote.organisationId)) === null,
    `${avecMotif.statusCode}`);

  // ═══════════════════════════════════════════════════════════════════════
  r.titre("Corriger l'identité d'un membre");

  /**
   * Rien ne corrigeait un nom ni une adresse.
   *
   * Ni la personne, ni un administrateur, ni la console d'instance : les seules
   * écritures sur un compte étaient le mot de passe, le second facteur et la
   * désactivation. On se mariait, l'entreprise changeait de domaine, et le seul
   * chemin était de retirer le compte et d'en ouvrir un autre — ce qui coupait
   * la personne de tout ce qu'elle avait consigné sous l'ancien.
   *
   * Les deux gestes n'ont pas le même poids. Un nom s'affiche ; une adresse
   * connecte. Ce qui suit éprouve surtout le second.
   */
  const corrige = await comptes.inscrire(sql, {
    email: adresse("corrige"), nom: "Anaig Le Meur", motDePasse,
    nomOrganisation: "À corriger", urlPublique: URL_TEST,
  });
  await sql`
    insert into membre (organisation_id, compte_id, role)
    values (${hote.organisationId}, ${corrige.compteId}, 'membre')
    on conflict (organisation_id, compte_id) do nothing`;

  // ── Le nom ───────────────────────────────────────────────────────────────
  const renomme = await comptes.changerNomMembre(sql, {
    organisationId: hote.organisationId, compteId: corrige.compteId,
    nom: "Anaig Le Meur-Tanguy", parCompteId: hote.compteId,
  });
  r.verifie("EF-107", "un nom se corrige, et le journal garde l'ancien",
    renomme.de === "Anaig Le Meur" && renomme.vers === "Anaig Le Meur-Tanguy");
  const [nomJournal] = await sql<{ charge: { de: string; vers: string } }[]>`
    select charge from evenement_administration
     where type = 'compte.renomme' and charge->>'compte_id' = ${corrige.compteId}
     order by seq desc limit 1`;
  r.verifie("EF-107", "et il est inscrit au journal d'administration",
    nomJournal?.charge.vers === "Anaig Le Meur-Tanguy");
  await r.refuse("EF-107", "le renommer à l'identique est refusé plutôt qu'ignoré",
    () => comptes.changerNomMembre(sql, {
      organisationId: hote.organisationId, compteId: corrige.compteId,
      nom: "Anaig Le Meur-Tanguy", parCompteId: hote.compteId,
    }), "sans_effet");

  // ── L'adresse, qui est l'identifiant de connexion ────────────────────────
  vider();
  // Vérifiée d'abord, sinon « elle repart non vérifiée » serait vrai avant même
  // qu'on ait changé quoi que ce soit — un contrôle qui ne peut pas échouer.
  await sql`update compte set email_verifie = true where id = ${corrige.compteId}`;
  const ancienne = (await sql<{ email: string }[]>`
    select email from compte where id = ${corrige.compteId}`)[0]!.email;
  const neuve = adresse("neuve");
  const bouge = await comptes.changerEmailMembre(sql, {
    organisationId: hote.organisationId, compteId: corrige.compteId,
    email: neuve, parCompteId: hote.compteId, motif: "Changement de domaine.",
    urlPublique: URL_TEST,
  });
  r.verifie("EF-107", "une adresse se corrige", bouge.vers === neuve.toLowerCase());

  // C'est la garantie qui compte : l'identifiant a réellement bougé.
  const parLaNeuve = await comptes.connecterParMotDePasse(sql,
    { email: neuve, motDePasse });
  r.verifie("EF-107", "la nouvelle adresse ouvre le compte",
    parLaNeuve.jeton.length > 0);
  await r.refuse("EF-107", "et l'ancienne ne l'ouvre plus",
    () => comptes.connecterParMotDePasse(sql, { email: ancienne, motDePasse }));

  // Une adresse posée par un tiers n'a jamais prouvé qu'elle existe.
  const [verifiee] = await sql<{ email_verifie: boolean }[]>`
    select email_verifie from compte where id = ${corrige.compteId}`;
  r.verifie("EF-107", "elle repart non vérifiée, quoi qu'elle ait été avant",
    verifiee?.email_verifie === false);
  const lien = jetonDans("/verification", neuve.toLowerCase());
  r.verifie("EF-107", "un lien de confirmation part à la nouvelle", lien !== null);
  if (lien) {
    await comptes.verifierEmail(sql, lien);
    const [apresLien] = await sql<{ email_verifie: boolean }[]>`
      select email_verifie from compte where id = ${corrige.compteId}`;
    r.verifie("EF-107", "et ce lien la vérifie pour de bon",
      apresLien?.email_verifie === true);
  }

  // La seule défense contre une prise de compte silencieuse : la personne
  // l'apprend là où l'auteur du changement n'a plus la main.
  const prevenu = boite.some((m) => m.destinataire === ancienne
    && /ne connecte plus/.test(m.texte));
  r.verifie("EF-107", "l'ancienne adresse est prévenue que son compte lui échappe",
    prevenu && bouge.prevenu);
  const [emailJournal] = await sql<{
    charge: { de: string; vers: string; motif: string };
  }[]>`
    select charge from evenement_administration
     where type = 'compte.email_change' and charge->>'compte_id' = ${corrige.compteId}
     order by seq desc limit 1`;
  r.verifie("EF-107", "et le journal retient les deux adresses et le motif",
    emailJournal?.charge.de === ancienne && emailJournal?.charge.vers === neuve.toLowerCase()
      && emailJournal?.charge.motif === "Changement de domaine.");

  // Deux comptes ne partagent pas une adresse : c'est par elle qu'on se connecte.
  const celleDuChef = (await sql<{ email: string }[]>`
    select email from compte where id = ${hote.compteId}`)[0]!.email;
  await r.refuse("EF-107", "une adresse déjà prise est refusée",
    () => comptes.changerEmailMembre(sql, {
      organisationId: hote.organisationId, compteId: corrige.compteId,
      email: celleDuChef, parCompteId: hote.compteId, motif: "Tentative.",
      urlPublique: URL_TEST,
    }), "email_deja_pris");

  await r.refuse("EF-107", "et un simple membre ne corrige l'identité de personne",
    () => comptes.changerNomMembre(sql, {
      organisationId: hote.organisationId, compteId: hote.compteId,
      nom: "Usurpé", parCompteId: corrige.compteId,
    }), "operation_refusee");

  // ═══════════════════════════════════════════════════════════════════════
  r.titre("Sortir quelqu'un d'une cellule");

+89 −0
Changes for apps/web/src/ecrans/Membres.tsx: 89 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -72,6 +72,43 @@ export function Membres(
  const { moi } = useEtat();
  const [retirant, definirRetirant] = useState<string | null>(null);
  const [motifRetrait, definirMotifRetrait] = useState("");
  // La correction d'identité : le nom seul, ou l'adresse — deux gestes de
  // poids différent, et l'écran ne les mélange pas.
  const [corrigeant, definirCorrigeant] = useState<string | null>(null);
  const [nomCorrige, definirNomCorrige] = useState("");
  const [emailCorrige, definirEmailCorrige] = useState("");
  const [motifEmail, definirMotifEmail] = useState("");

  function ouvrirCorrection(m: Membre) {
    definirErreur(null); definirMessage(null); definirRetirant(null);
    definirCorrigeant(corrigeant === m.compte_id ? null : m.compte_id);
    definirNomCorrige(m.nom); definirEmailCorrige(m.email); definirMotifEmail("");
  }

  async function corriger(m: Membre) {
    definirErreur(null); definirMessage(null);
    const dits: string[] = [];
    try {
      if (nomCorrige.trim() !== m.nom) {
        const r = await api.poster<{ message: string }>(
          `/api/organisations/${organisationId}/membres/${m.compte_id}/nom`,
          { nom: nomCorrige.trim() });
        dits.push(r.message);
      }
      if (emailCorrige.trim().toLowerCase() !== m.email.toLowerCase()) {
        const r = await api.poster<{ message: string }>(
          `/api/organisations/${organisationId}/membres/${m.compte_id}/email`,
          { email: emailCorrige.trim(), motif: motifEmail.trim() });
        dits.push(r.message);
      }
      definirMessage(dits.join(" "));
      definirCorrigeant(null);
      await client.invalidateQueries({ queryKey: ["membres", organisationId] });
      await client.invalidateQueries({ queryKey: ["moi"] });
    } catch (e) {
      definirErreur(e instanceof ErreurApi ? e.message : t("Correction refusée."));
    }
  }

  async function changerRole(compteId: string, nouveau: string) {
    definirErreur(null); definirMessage(null);
@@ -532,6 +569,10 @@ export function Membres(
                          <option key={r.cle} value={r.cle}>{t(r.libelle)}</option>
                        ))}
                      </select>
                      <button className="discret petit"
                        onClick={() => ouvrirCorrection(m)}>
                        {corrigeant === m.compte_id ? t("Annuler") : t("Modifier")}
                      </button>
                      <button className="discret petit"
                        onClick={() => { definirRetirant(m.compte_id); definirMotifRetrait(""); }}>
                        {t("Retirer")}
@@ -544,6 +585,54 @@ export function Membres(
                  )}
                </div>

                {corrigeant === m.compte_id && (
                  <div style={{ marginTop: 10 }}>
                    <div className="champ">
                      <label htmlFor={`nom-${m.compte_id}`}>{t("Nom")}</label>
                      <input id={`nom-${m.compte_id}`} maxLength={120} value={nomCorrige}
                        onChange={(e) => definirNomCorrige(e.target.value)} />
                    </div>
                    <div className="champ">
                      <label htmlFor={`email-${m.compte_id}`}>
                        {t("Adresse électronique")}
                      </label>
                      <input id={`email-${m.compte_id}`} type="email" value={emailCorrige}
                        onChange={(e) => definirEmailCorrige(e.target.value)} />
                      <div className="aide">
                        {t("C’est l’identifiant de connexion. La changer coupe l’ancienne "
                          + "adresse : celle-ci en sera prévenue, et la nouvelle recevra un "
                          + "lien de confirmation.")}
                      </div>
                    </div>
                    {/* Le motif n'apparaît que si l'adresse bouge : l'exiger pour
                        une faute de frappe dans un nom serait une cérémonie. */}
                    {emailCorrige.trim().toLowerCase() !== m.email.toLowerCase() && (
                      <div className="champ">
                        <label htmlFor={`motif-email-${m.compte_id}`}>
                          {t("Pourquoi changer l’adresse ?")}
                        </label>
                        <input id={`motif-email-${m.compte_id}`} maxLength={500}
                          value={motifEmail}
                          placeholder={t("par ex. changement de domaine")}
                          onChange={(e) => definirMotifEmail(e.target.value)} />
                      </div>
                    )}
                    <div className="ligne" style={{ gap: 8 }}>
                      <button className="accent"
                        disabled={
                          (nomCorrige.trim() === m.nom
                            && emailCorrige.trim().toLowerCase() === m.email.toLowerCase())
                          || (emailCorrige.trim().toLowerCase() !== m.email.toLowerCase()
                            && motifEmail.trim().length < 3)}
                        onClick={() => void corriger(m)}>
                        {t("Enregistrer")}
                      </button>
                      <button className="discret"
                        onClick={() => definirCorrigeant(null)}>{t("Annuler")}</button>
                    </div>
                  </div>
                )}

                {retirant === m.compte_id && (
                  <div style={{ marginTop: 10 }}>
                    <div className="champ">
+5 −0
Changes for apps/web/src/langues/en.ts: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -2448,4 +2448,9 @@ export const EN: Record<string, string> = {
  "L’accès tombe à la requête suivante, y compris aux crises en cours. Ce que cette personne a consigné reste au journal, à son nom : le journal ne se réécrit pas.":
    "Access falls from the next request, including to crises under way. What this person logged stays in the record, under their name: the log is not rewritten.",
  "Retirer de l’organisation": "Remove from the organisation",
  "Correction refusée.": "Correction refused.",
  "C’est l’identifiant de connexion. La changer coupe l’ancienne adresse : celle-ci en sera prévenue, et la nouvelle recevra un lien de confirmation.":
    "This is the sign-in identifier. Changing it cuts off the old address: it will be told, and the new one will get a confirmation link.",
  "Pourquoi changer l’adresse ?": "Why change the address?",
  "par ex. changement de domaine": "e.g. domain change",
};
Loading