Unverified Commit 1fedd72d authored by Kourser's avatar Kourser
Browse files

Invitations : ne plus annoncer un envoi qui n'a pas lieu

« Invitation envoyée » s'affichait dans deux situations où rien ne
partait.

Sans serveur de courriel, le service d'envoi est inerte par
construction : il journalise et se tait. L'invitation est désormais
refusée, et l'écran retire le formulaire au profit de la création
directe, qui n'envoie rien et affiche un mot de passe temporaire. La
règle vit dans l'API, pas seulement à l'affichage : l'écran peut cacher
le formulaire, la règle doit tenir sans lui.

Avec un serveur configuré mais un envoi rejeté — adresse inconnue, hôte
injoignable — l'échec était avalé. C'est le bon choix partout ailleurs :
une organisation créée reste créée si l'avis se perd. Mais une
invitation n'est RIEN d'autre que son courriel. Elle passe donc par un
envoi strict, annule son jeton et remonte le motif du serveur.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 01d88c43
Loading
Loading
Loading
Loading
+17 −0
Original line number Diff line number Diff line
@@ -15,8 +15,10 @@ import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthUser } from '../auth/auth.types';
import type { UserRole } from '@prisma/client';
import type { ApiInvitationAvailability } from '@cythin/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AccountService } from './account.service';
import { MailerService } from '../platform-mail/mailer.service';
import { SignupService } from './signup.service';
import { ConsumeTokenDto, ForgotPasswordDto, InviteDto, SignupDto } from './dto/signup.dto';
import { docsUrl } from '../common/docs-url';
@@ -100,8 +102,23 @@ export class InvitationsController {
  constructor(
    private readonly prisma: PrismaService,
    private readonly account: AccountService,
    private readonly mailer: MailerService,
  ) {}

  /**
   * L'invitation est-elle possible ? Elle ne l'est pas sans serveur de courriel :
   * le lien n'aurait aucun moyen d'atteindre l'invité.
   *
   * Le reglage SMTP lui-meme releve de la console d'instance et ne regarde pas un
   * admin d'organisation ; il a seulement besoin de savoir si la voie existe, pour
   * ne pas proposer un geste qui echouerait.
   */
  @Get()
  @Roles('TENANT_ADMIN')
  async availability(): Promise<ApiInvitationAvailability> {
    return { available: await this.mailer.isConfigured() };
  }

  @Post()
  @Roles('TENANT_ADMIN')
  @HttpCode(HttpStatus.NO_CONTENT)
+53 −8
Original line number Diff line number Diff line
import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common';
import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  Logger,
  ServiceUnavailableException,
} from '@nestjs/common';
import { hash } from 'bcryptjs';
import type { AuthTokenKind, UserRole } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@@ -45,15 +51,18 @@ export class AccountService {
  /**
   * Cree un jeton et renvoie le lien en clair. Le lien N'EST PAS journalise :
   * c'est un secret a usage unique.
   *
   * L'identifiant du jeton accompagne le lien : l'appelant dont l'envoi echoue
   * doit pouvoir retirer un jeton que personne ne recevra.
   */
  private async issue(
    kind: AuthTokenKind,
    email: string,
    extra: { userId?: string; tenantId?: string; role?: UserRole } = {},
  ): Promise<string> {
  ): Promise<{ url: string; id: string }> {
    const { token, hash: tokenHash } = generateToken();
    const expiresAt = new Date(Date.now() + TTL_HOURS[kind] * 3600 * 1000);
    await this.prisma.authToken.create({
    const row = await this.prisma.authToken.create({
      data: {
        kind,
        tokenHash,
@@ -65,12 +74,12 @@ export class AccountService {
      },
    });
    const path = kind === 'PASSWORD_RESET' ? 'mot-de-passe' : 'activation';
    return `${await this.appUrl.base()}/${path}/${token}`;
    return { url: `${await this.appUrl.base()}/${path}/${token}`, id: row.id };
  }

  /** Lien de premiere prise de main, envoye a la creation d'un compte. */
  async sendActivation(email: string, userId: string, organisationName: string): Promise<void> {
    const url = await this.issue('ACTIVATION', email, { userId });
    const { url } = await this.issue('ACTIVATION', email, { userId });
    await this.mailer.send({
      to: email,
      subject: `Cythin — votre accès à ${organisationName}`,
@@ -99,7 +108,7 @@ export class AccountService {
      this.audit.record('account.reset_requested.unknown');
      return;
    }
    const url = await this.issue('PASSWORD_RESET', email, { userId: user.id });
    const { url } = await this.issue('PASSWORD_RESET', email, { userId: user.id });
    await this.mailer.send({
      to: email,
      subject: 'Cythin — réinitialisation de votre mot de passe',
@@ -133,6 +142,22 @@ export class AccountService {
    if (!isTenantRole(role)) {
      throw new ForbiddenException('Rôle non attribuable dans une organisation');
    }

    /*
     * Une invitation n'existe QUE par le courriel qui la porte : l'invité n'a
     * aucun autre moyen de recevoir son lien. Sans serveur d'envoi, le service de
     * courriel est inerte — il journalise et se tait — et l'appelant repartirait
     * avec « Invitation envoyée » pour un message que personne n'a reçu.
     *
     * Refuser ici plutot qu'a l'affichage : l'ecran peut masquer le formulaire,
     * mais la regle doit tenir meme sans lui.
     */
    if (!(await this.mailer.isConfigured())) {
      throw new ServiceUnavailableException(
        "Aucun serveur de courriel n'est configuré sur cette instance : l'invitation ne partirait pas. Créez le compte directement, avec un mot de passe temporaire.",
      );
    }

    const user = await this.prisma.user.findUnique({ where: { email } });
    if (user) {
      const already = await this.prisma.membership.findUnique({
@@ -141,12 +166,26 @@ export class AccountService {
      if (already) throw new BadRequestException('Ce compte est déjà membre de cette organisation');
    }

    const url = await this.issue('INVITATION', email, {
    const { url, id: tokenId } = await this.issue('INVITATION', email, {
      userId: user?.id,
      tenantId,
      role,
    });
    await this.mailer.send({

    /*
     * ENVOI STRICT, a l'inverse du reste des courriels de compte.
     *
     * Ailleurs, l'echec est tu volontairement : une organisation creee reste
     * creee, un mot de passe reinitialise reste reinitialise, et l'avis n'est
     * qu'un accompagnement. Une invitation, elle, n'est RIEN d'autre que le
     * courriel : sans lui, l'invite n'apprend jamais qu'on l'attend.
     *
     * Le jeton est donc retire et l'erreur remonte, avec le motif du serveur.
     * Sans cela, l'ecran annonce « Invitation envoyée » pour un message que le
     * serveur SMTP a rejete.
     */
    try {
      await this.mailer.sendOrFail({
        to: email,
        subject: `Cythin — invitation à rejoindre ${organisationName}`,
        body: [
@@ -160,6 +199,12 @@ export class AccountService {
          "Si vous ne connaissez pas cette organisation, ignorez ce message.",
        ].join('\n'),
      });
    } catch (err) {
      await this.prisma.authToken.delete({ where: { id: tokenId } }).catch(() => undefined);
      throw new ServiceUnavailableException(
        `L'invitation n'a pas pu être envoyée : ${(err as Error).message}. Le lien a été annulé ; vérifiez l'adresse, ou créez le compte directement.`,
      );
    }
    this.audit.record('account.invited', { tenantId, role });
  }

+26 −0
Original line number Diff line number Diff line
@@ -135,6 +135,32 @@ export class MailerService {
    }
  }

  /**
   * Envoi dont l'ECHEC COMPTE, contrairement a `send`.
   *
   * A reserver aux courriels qui SONT l'action, et non son accompagnement : une
   * invitation n'existe que par le lien qu'elle porte, alors qu'une organisation
   * creee reste creee meme si l'avis n'arrive pas. Dans le premier cas, taire
   * l'echec ferait annoncer un envoi qui n'a pas eu lieu ; l'appelant doit pouvoir
   * revenir en arriere et le dire.
   *
   * Le message d'erreur remonte le motif du serveur — « adresse rejetee », « hote
   * introuvable » — sans le corps du courriel, qui porte un lien a usage unique.
   */
  async sendOrFail(mail: PlatformMail): Promise<void> {
    const settings = await this.settings();
    if (!settings.host || !settings.from) {
      throw new Error("aucun serveur d'envoi n'est configuré sur cette instance");
    }
    await this.transport(settings).sendMail({
      from: settings.from,
      to: mail.to,
      subject: mail.subject,
      text: mail.body,
    });
    this.logger.log(`Courriel envoye : ${mail.subject} (${settings.source})`);
  }

  /**
   * Envoi d'essai, depuis la console. Contrairement a `send`, il PROPAGE l'erreur :
   * tout l'objet d'un essai est de voir ce qui ne marche pas.
+37 −1
Original line number Diff line number Diff line
@@ -366,12 +366,28 @@ function CrisisCell(): JSX.Element {
 * la seule façon d'ajouter quelqu'un qui a déjà un compte ailleurs — la création
 * directe le refuse, pour ne pas rattacher une personne sans son accord.
 */
export function InviteMember(): JSX.Element {
export function InviteMember(): JSX.Element | null {
  const { t } = useT();
  const [email, setEmail] = useState('');
  const [role, setRole] = useState<TenantRole>('ANIMATOR');
  const [message, setMessage] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  /** `null` tant qu'on ne sait pas : on n'affiche ni le formulaire ni son refus. */
  const [available, setAvailable] = useState<boolean | null>(null);

  useEffect(() => {
    void (async () => {
      try {
        const a = await api.invitationAvailability();
        setAvailable(a.available);
      } catch {
        // L'ignorance ne doit pas retirer le formulaire : l'API refusera au besoin,
        // avec son propre message. Mieux vaut un geste qui echoue clairement qu'un
        // geste absent sans explication.
        setAvailable(true);
      }
    })();
  }, []);

  async function submit(e: FormEvent): Promise<void> {
    e.preventDefault();
@@ -386,6 +402,26 @@ export function InviteMember(): JSX.Element {
    }
  }

  if (available === null) return null;

  /*
   * Sans serveur de courriel, l'invitation n'a aucun moyen de partir. Le
   * formulaire disparait plutot que d'annoncer un envoi qui n'a pas lieu ; la
   * creation directe, juste en dessous, reste ouverte et n'envoie rien.
   */
  if (!available) {
    return (
      <div className="create-form">
        <h4>{t('Inviter un collègue')}</h4>
        <p className="muted small">
          {t(
            'Indisponible : aucun serveur de courriel n’est configuré sur cette instance, le lien d’invitation ne partirait pas. Ajoutez directement l’utilisateur ci-dessous — son mot de passe temporaire s’affiche à la création.',
          )}
        </p>
      </div>
    );
  }

  return (
    <form className="create-form" onSubmit={submit}>
      <h4>{t('Inviter un collègue')}</h4>
+4 −0
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ import type {
  ApiInject,
  ApiInstanceAudit,
  ApiInstanceMetrics,
  ApiInvitationAvailability,
  ApiKit,
  ApiKitCharacter,
  ApiKitInject,
@@ -848,6 +849,9 @@ export const api = {
      method: 'POST',
      body: JSON.stringify(input),
    }),
  /** L'invitation est-elle possible ? Faux sans serveur de courriel sur l'instance. */
  invitationAvailability: () =>
    request<ApiInvitationAvailability>('/organisation/invitations'),
  /** Invite un collègue dans l'organisation active (il accepte lui-même). */
  inviteMember: (email: string, role: TenantRole) =>
    request<void>('/organisation/invitations', {
Loading