Commit 74362e1d authored by Kourser's avatar Kourser
Browse files

Suspension : couper aussi les joueurs et les écrans de salle

Ma propre documentation affirmait qu'une organisation suspendue « refuse toute
session ». C'était faux : seul le garde de session le vérifiait. Les joueurs
auraient continué à jouer et les murs de crise à s'afficher jusqu'à l'expiration
de leurs jetons. Suspendre sert à arrêter l'activité, pas seulement à bloquer
l'administration.

Dans le garde d'affichage, le contrôle est placé HORS du `try` : à l'intérieur,
une exception d'autorisation aurait été requalifiée en « session invalide » par le
`catch`, message trompeur pour l'exploitant devant son écran de salle.

Corrige au passage la navigation d'un administrateur d'instance : sa session porte
le rôle `INSTANCE_ADMIN`, qui n'ouvre aucune route d'organisation. Les entrées
Exercices, Kits et RETEX répondaient 403 — elles ne sont plus affichées dans ce
contexte. Pour travailler dans une organisation, il en choisit une au sélecteur ou
y entre depuis la console.

Non vérifié : aucune chaîne Node dans cet environnement.

Co-Authored-By: default avatarClaude (RCA) <noreply@anthropic.com>
parent 0d6b03e2
Loading
Loading
Loading
Loading
+24 −5
Original line number Diff line number Diff line
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import {
@@ -6,6 +12,7 @@ import {
  type DisplayContext,
  type DisplayJwtPayload,
} from '../auth/auth.types';
import { TenantStatusService } from '../common/tenant-status.service';

/**
 * Exige une session d'ecran d'affichage valide (cookie) et attache `req.display`.
@@ -14,19 +21,31 @@ import {
 */
@Injectable()
export class DisplayGuard implements CanActivate {
  constructor(private readonly jwt: JwtService) {}
  constructor(
    private readonly jwt: JwtService,
    private readonly tenants: TenantStatusService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest<Request & { display?: DisplayContext }>();
    const token = (req.cookies as Record<string, string> | undefined)?.[SESSION_DISPLAY_COOKIE];
    if (!token) throw new UnauthorizedException("Session d'affichage requise");
    let d: DisplayJwtPayload;
    try {
      const d = await this.jwt.verifyAsync<DisplayJwtPayload>(token);
      d = await this.jwt.verifyAsync<DisplayJwtPayload>(token);
      if (d.role !== 'DISPLAY') throw new UnauthorizedException();
      req.display = { displayId: d.did, exerciseId: d.eid, tenantId: d.tid };
      return true;
    } catch {
      throw new UnauthorizedException("Session d'affichage invalide ou expiree");
    }

    // Organisation suspendue : l'ecran de salle s'eteint comme le reste. Le
    // controle est HORS du try : une exception d'autorisation ne doit pas etre
    // requalifiee en « session invalide ».
    if (await this.tenants.isSuspended(d.tid)) {
      throw new ForbiddenException('Organisation suspendue');
    }

    req.display = { displayId: d.did, exerciseId: d.eid, tenantId: d.tid };
    return true;
  }
}
+8 −0
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
import { PrismaService } from '../prisma/prisma.service';
import { TenantStatusService } from '../common/tenant-status.service';
import {
  PLAY_SESSION_HEADER,
  SESSION_PLAY_COOKIE,
@@ -53,6 +54,7 @@ export class PlayerGuard implements CanActivate {
  constructor(
    private readonly jwt: JwtService,
    private readonly prisma: PrismaService,
    private readonly tenants: TenantStatusService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -70,6 +72,12 @@ export class PlayerGuard implements CanActivate {
      throw new UnauthorizedException('Session joueur invalide ou expiree');
    }

    // Organisation suspendue : plus aucune session, joueurs compris. Suspendre a
    // pour but d'arreter l'activite, pas seulement d'empecher l'administration.
    if (await this.tenants.isSuspended(p.tid)) {
      throw new ForbiddenException('Organisation suspendue');
    }

    const method = req.method.toUpperCase();
    const isRead = READ_METHODS.has(method);

+14 −3
Original line number Diff line number Diff line
@@ -31,13 +31,24 @@ export function Dashboard({
  // organisation observee, la session travaille avec les droits de cette
  // organisation, et le serveur refuserait de toute facon les routes d'instance.
  const canAdminInstance = me.isInstanceAdmin && !me.switchedBy;
  /**
   * Session ouverte SUR l'organisation système : le rôle actif est
   * `INSTANCE_ADMIN`, qui n'ouvre aucune route d'organisation. On n'affiche donc
   * pas d'entrées qui répondraient 403 — pour travailler dans une organisation,
   * le root en choisit une dans le sélecteur, ou y entre depuis la console.
   */
  const instanceContext = me.role === 'INSTANCE_ADMIN';
  const [section, setSection] = useState<Section>(canAdminInstance ? 'instance' : 'exercises');
  const [exercisesKey, setExercisesKey] = useState(0);

  const nav: { key: Section; label: string; icon: string }[] = [
    { key: 'exercises', label: 'Exercices', icon: '🎯' },
    { key: 'kits', label: 'Bibliothèque de kits', icon: '📦' },
    { key: 'retex-templates', label: 'Modèles de RETEX', icon: '📋' },
    ...(instanceContext
      ? []
      : [
          { key: 'exercises' as Section, label: 'Exercices', icon: '🎯' },
          { key: 'kits' as Section, label: 'Bibliothèque de kits', icon: '📦' },
          { key: 'retex-templates' as Section, label: 'Modèles de RETEX', icon: '📋' },
        ]),
    ...(isAdmin
      ? [
          { key: 'organisation' as Section, label: 'Organisation', icon: '🏢' },