Commit 5f28b764 authored by Kourser's avatar Kourser
Browse files

Utilisateurs, compte et signalement : six points

MON COMPTE reprenait le gabarit de la page de CONNEXION, plafonné à
420 px : une colonne étroite collée à gauche d'une page vide, trois
formulaires empilés sous un filet. Ils passent en grille qui s'adapte,
chacun dans son bloc, et le panneau du second facteur — même défaut,
même cause — suit le gabarit de la console.

SIGNALER UN BUG, depuis l'icône de l'en-tête, ouvert à TOUT compte
authentifié : celui qui bute sur un défaut est rarement l'administrateur,
et l'obliger à passer par quelqu'un revient à ne jamais être prévenu. La
page courante et le navigateur partent avec le message ; sans eux, il
faut rappeler l'auteur pour savoir où il était, et c'est précisément ce
qu'on ne fait jamais.

Les signalements sont ENREGISTRÉS, pas seulement envoyés. L'envoi de
courriel n'est pas configuré sur toutes les instances : un signalement
qui n'existerait que dans un message serait perdu au moment même où il
est écrit, et son auteur repartirait persuadé d'avoir prévenu quelqu'un.
La table est la mémoire, le courriel un avertissement. La console
d'instance les liste et les referme.

ÉTAT D'UN COMPTE. Le compte ajouté apparaissait déjà — ce qui manquait,
c'était de voir qu'il n'a pas servi. Deux pastilles, parce que les
gestes diffèrent : « En attente » (un lien a été envoyé, pas encore
suivi) se relance, « Jamais connecté » se rappelle à son propriétaire.

INVITER PAR CSV, avec un compte rendu LIGNE PAR LIGNE. Le lot n'est pas
atomique et ne doit pas l'être : une adresse déjà membre ou malformée
n'annule pas les neuf autres, sans quoi il faudrait reprendre le fichier
à l'aveugle. « Déjà membre » est rendu comme un état, pas comme une
faute. L'analyse est faite par le serveur, avec l'analyseur des imports
de kit, qui respecte les guillemets.

RÉINITIALISER UN MOT DE PASSE ne montrait rien. Le bouton est dans la
ligne EN ÉDITION, le compte rendu n'était rendu que dans la ligne AU
REPOS : ni le mot de passe temporaire, ni la confirmation d'envoi, ni
l'erreur n'apparaissaient. Il sort des deux branches et s'affiche dans
les deux.

MATRICE DES DROITS, dépliable, relue contre les gardes de l'API. Elle
dit aussi ce que le produit N'APPLIQUE PAS : concepteur et animateur
ouvrent exactement les mêmes routes, la distinction n'exprime qu'une
intention. Une matrice qui s'écarterait du code vaudrait moins que rien.

Treize essais couvrent l'invitation en masse et ce qui l'entoure.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 54e0eeeb
Loading
Loading
Loading
Loading
+37 −0
Changes for apps/api/prisma/migrations/20260903000000_bug_reports/migration.sql: 37 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Signalements de bug, deposes depuis l'application.
--
-- POURQUOI EN BASE, ET PAS UN SIMPLE COURRIEL. L'envoi de courriel n'est pas
-- configure sur toutes les instances — il ne l'est meme pas par defaut. Un
-- signalement qui n'existerait que dans un message serait alors perdu au
-- moment ou il est ecrit, sans que personne ne s'en apercoive. La table est la
-- memoire ; le courriel n'est qu'un avertissement.
--
-- `tenantId` est NULLABLE et en SET NULL : la suppression d'une organisation ne
-- doit pas effacer les signalements qu'elle a produits, qui documentent l'etat
-- du produit et non celui du client.
CREATE TABLE "BugReport" (
  "id" TEXT NOT NULL,
  "tenantId" TEXT,
  "tenantName" TEXT,
  "reporterUserId" TEXT,
  "reporterEmail" TEXT NOT NULL,
  "title" TEXT NOT NULL,
  "body" TEXT NOT NULL,
  -- Ou l'utilisateur se trouvait, et avec quoi. Renseigne par l'interface :
  -- un signalement sans contexte oblige a rappeler son auteur pour l'obtenir.
  "page" TEXT,
  "userAgent" TEXT,
  "status" TEXT NOT NULL DEFAULT 'OPEN',
  "handledAt" TIMESTAMP(3),
  "handledByUserId" TEXT,
  "handledNote" TEXT,
  "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

  CONSTRAINT "BugReport_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "BugReport_status_createdAt_idx" ON "BugReport"("status", "createdAt");

ALTER TABLE "BugReport"
  ADD CONSTRAINT "BugReport_tenantId_fkey"
  FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+33 −0
Changes for apps/api/prisma/schema.prisma: 33 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -51,6 +51,9 @@ model Tenant {
  /// deploiement (RETENTION_DAYS). En SaaS, la retention releve de la politique
  /// de chaque client, pas de celle de l'hebergeur.
  retentionDays Int?
  /// Signalements de bug emis depuis cette organisation.
  bugReports    BugReport[]

  /// Logo de l'organisation (cle de fichier televerse). Avatar par defaut du
  /// compte officiel sur les reseaux simules ; un exercice peut le remplacer.
  logoPath     String?
@@ -1393,3 +1396,33 @@ model RetexAnswer {
  @@index([tenantId])
  @@index([questionId])
}

/// Signalement de bug depose depuis l'application.
///
/// EN BASE, et pas un simple courriel : l'envoi n'est pas configure sur toutes
/// les instances, et un signalement qui n'existerait que dans un message serait
/// perdu au moment meme ou il est ecrit. La table est la memoire ; le courriel
/// n'est qu'un avertissement.
model BugReport {
  id             String    @id @default(cuid())
  /// Organisation d'ou vient le signalement. SET NULL a sa suppression : le
  /// signalement documente le produit, pas le client.
  tenantId       String?
  tenant         Tenant?   @relation(fields: [tenantId], references: [id], onDelete: SetNull)
  /// Recopie a la creation : le nom survit a la suppression de l'organisation.
  tenantName     String?
  reporterUserId String?
  reporterEmail  String
  title          String
  body           String
  /// Ou l'auteur se trouvait, et avec quoi. Sans contexte, il faut le rappeler.
  page           String?
  userAgent      String?
  status         String    @default("OPEN")
  handledAt      DateTime?
  handledByUserId String?
  handledNote    String?
  createdAt      DateTime  @default(now())

  @@index([status, createdAt])
}
+36 −2
Changes for apps/api/src/account/account.controller.ts: 36 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -15,12 +15,18 @@ 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 '@enkadenn/shared';
import type { ApiInvitationAvailability, ApiInvitationLigne } from '@enkadenn/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 {
  ConsumeTokenDto,
  ForgotPasswordDto,
  InviteCsvDto,
  InviteDto,
  SignupDto,
} from './dto/signup.dto';
import { docsUrl } from '../common/docs-url';

const MINUTE = 60_000;
@@ -131,4 +137,32 @@ export class InvitationsController {
      tenant?.name ?? 'votre organisation',
    );
  }

  /**
   * Invitation en masse, depuis un CSV : `email` et, facultativement, `role`.
   *
   * LE LOT N'EST PAS ATOMIQUE, et c'est voulu. Une adresse deja membre ou
   * malformee ne doit pas annuler les neuf autres : l'administrateur repartirait
   * corriger son fichier a l'aveugle, sans savoir lesquelles etaient passees. Un
   * compte rendu ligne par ligne dit exactement ou en est chacune.
   *
   * L'analyse est faite ICI et non dans le navigateur : c'est le meme analyseur
   * que les imports de kit, qui respecte les guillemets — un nom contenant une
   * virgule ne decale pas les colonnes.
   */
  @Post('csv')
  @Roles('TENANT_ADMIN')
  async inviteCsv(
    @CurrentUser() me: AuthUser,
    @Body() dto: InviteCsvDto,
  ): Promise<{ lignes: ApiInvitationLigne[] }> {
    const tenant = await this.prisma.tenant.findUnique({ where: { id: me.tenantId } });
    return {
      lignes: await this.account.inviteEnMasse(
        me.tenantId,
        dto.csv,
        tenant?.name ?? 'votre organisation',
      ),
    };
  }
}
+79 −0
Changes for apps/api/src/account/account.service.ts: 79 added lines, 0 removed lines.
Original line number Diff line number Diff line
import {
  BadRequestException,
  ForbiddenException,
  HttpException,
  Injectable,
  Logger,
  ServiceUnavailableException,
@@ -13,10 +14,15 @@ import { generateToken, hashToken } from '../common/one-time-token';
import { AppUrlService } from '../common/app-url.service';
import { MailerService } from '../platform-mail/mailer.service';
import { isTenantRole } from '../auth/roles';
import { parseCsvTable } from '../common/csv';
import type { ApiInvitationLigne } from '@enkadenn/shared';
import { SessionEpochService } from '../common/session-epoch.service';

const BCRYPT_ROUNDS = 12;

/** Plafond d'un lot d'invitations : autant d'envois que de lignes acceptees. */
const MAX_INVITATIONS_CSV = 200;

/** Duree de vie d'un lien envoye par courriel, par nature de jeton. */
const TTL_HOURS: Record<AuthTokenKind, number> = {
  // Une premiere prise de main peut attendre quelques jours (vacances, congés).
@@ -208,6 +214,79 @@ export class AccountService {
    this.audit.record('account.invited', { tenantId, role });
  }

  /**
   * Invite en masse depuis un CSV. Colonnes : `email`, et `role` facultatif.
   *
   * Chaque ligne est traitee POUR ELLE-MEME : une adresse deja membre ou
   * malformee n'annule pas les autres. Le compte rendu dit ou en est chacune,
   * ce qui evite de renvoyer l'administrateur corriger son fichier a l'aveugle.
   *
   * Le refus global — pas de serveur de courriel — reste global : sans lui,
   * aucune ligne ne peut aboutir, et pretendre le contraire ligne par ligne
   * serait bavard pour rien.
   */
  async inviteEnMasse(
    tenantId: string,
    csv: string,
    organisationName: string,
  ): Promise<ApiInvitationLigne[]> {
    if (!(await this.mailer.isConfigured())) {
      throw new ServiceUnavailableException(
        "Aucun serveur de courriel n'est configuré sur cette instance : les invitations ne partiraient pas.",
      );
    }

    const { entetes, lignes } = parseCsvTable(csv);
    if (!entetes.includes('email')) {
      throw new BadRequestException("Le fichier doit comporter une colonne « email ».");
    }
    if (lignes.length === 0) {
      throw new BadRequestException('Aucune ligne à traiter.');
    }
    if (lignes.length > MAX_INVITATIONS_CSV) {
      throw new BadRequestException(
        `Trop de lignes : ${MAX_INVITATIONS_CSV} au plus par fichier.`,
      );
    }

    const resultats: ApiInvitationLigne[] = [];
    const vues = new Set<string>();
    for (const ligne of lignes) {
      if (ligne.vide) continue;
      // `numero` est celui du FICHIER, en-tete comprise : il correspond a ce que
      // l'administrateur voit dans son tableur.
      const numero = ligne.numero;
      const email = ligne.valeur('email', 'e-mail', 'courriel').trim().toLowerCase();
      if (!email) {
        resultats.push({ ligne: numero, email: '', etat: 'erreur', motif: 'Adresse absente' });
        continue;
      }
      if (vues.has(email)) {
        resultats.push({ ligne: numero, email, etat: 'ignoree', motif: 'Doublon dans le fichier' });
        continue;
      }
      vues.add(email);

      const role = ligne.valeur('role', 'rôle').trim().toUpperCase() || 'ANIMATOR';
      if (!isTenantRole(role)) {
        resultats.push({ ligne: numero, email, etat: 'erreur', motif: `Rôle inconnu : ${role}` });
        continue;
      }

      try {
        await this.invite(tenantId, email, role as UserRole, organisationName);
        resultats.push({ ligne: numero, email, etat: 'envoyee' });
      } catch (err) {
        const motif = err instanceof HttpException ? err.message : 'Invitation impossible';
        // « Déjà membre » n'est pas une erreur de l'administrateur : c'est un
        // état, et le distinguer évite de faire chercher une faute inexistante.
        const etat = /déjà membre/i.test(motif) ? 'ignoree' : 'erreur';
        resultats.push({ ligne: numero, email, etat, motif });
      }
    }
    return resultats;
  }

  // --- Consommation ---

  /** Ce qu'un lien propose, sans encore le consommer (pour l'afficher). */
+12 −0
Changes for apps/api/src/account/dto/signup.dto.ts: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -58,3 +58,15 @@ export class InviteDto {
  @IsString()
  role!: string;
}

/**
 * Invitation en masse depuis un CSV.
 *
 * La borne haute vaut pour ce qu'elle empeche : un fichier de plusieurs mega-
 * octets colle par megarde, dont chaque ligne declencherait un envoi.
 */
export class InviteCsvDto {
  @IsString()
  @MaxLength(200_000)
  csv!: string;
}
Loading