Commit 203365c3 authored by Kourser's avatar Kourser
Browse files

Console d'instance : voir et administrer les séances de découverte

Une organisation éphémère est exclue de la liste des organisations
clientes — ce n'en est pas une. L'administrateur ne disposait donc que
d'un compteur, « 3 sessions en cours », sans savoir qui jouait quoi, ni
pouvoir libérer une place, ni laisser finir une séance sur le point
d'être coupée par son échéance.

Un tableau les montre : scénario, identifiant public cliquable, rôles
pris sur total, dernière activité, temps restant. Il se relit tout seul,
une séance vivant en minutes — un écran figé afficherait des échéances
fausses au bout de deux minutes.

« Jamais ouverte » distingue une séance abandonnée d'une séance active.
Un rôle peut être pris sans que personne n'ait suivi son lien.

DEUX GESTES, ET SEULEMENT DEUX.

Prolonger de 30 minutes. LES JETONS DES JOUEURS SONT REPOUSSÉS AVEC
L'ÉCHÉANCE : ils portent leur propre date d'expiration, posée à la prise
du rôle, et prolonger la seule organisation aurait laissé la page de
séance ouverte pendant que les joueurs se faisaient refuser à la porte.
Bornée entre 5 et 240 minutes, et jamais au-delà de 24 h au total :
prolonger sert à laisser finir, pas à rendre permanente une organisation
éphémère en contournant par la console le garde-fou que le bac à sable
pose sur lui-même.

Fermer sur-le-champ, avec confirmation : des joueurs sont peut-être en
train de jouer. La suppression du tenant suffit, tout est en cascade sur
lui — c'est le geste de la purge, avancé dans le temps, et non un second
chemin de suppression à maintenir en parallèle.

La route ne peut atteindre QUE des organisations éphémères. Un
identifiant d'organisation cliente y reçoit un 404, vérifié en recette :
une route de fermeture qui pourrait supprimer un client serait une porte
dérobée dans la console.

Les deux gestes sont tracés au journal d'instance, avec leur auteur.

La carte « Découverte » de la page de connexion vantait un seul
scénario — « rançongiciel dans une PME » — depuis qu'il y en a deux. Elle
devient neutre : le choix se fait sur la page suivante.

Un commentaire de la recette affirmait qu'il aurait fallu « inventer un
geste que la console n'offre pas » pour fermer une séance. C'est
précisément ce que ce commit ajoute : le lot referme désormais ce qu'il
ouvre, et ne laisse plus d'organisation éphémère derrière lui.

Recette portée de 145 à 160 cas.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 40c511e7
Loading
Loading
Loading
Loading
+13 −0
Changes for apps/api/src/instance/dto/sandbox-settings.dto.ts: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -29,3 +29,16 @@ export class UpdateSandboxSettingsDto {
  @Max(50)
  maxActive?: number | null;
}

/**
 * Prolongation d'une seance en cours.
 *
 * Bornee des le DTO : prolonger sert a laisser finir une seance, pas a rendre
 * permanente une organisation ephemere.
 */
export class ExtendSandboxSessionDto {
  @IsInt()
  @Min(5)
  @Max(240)
  minutes!: number;
}
+65 −1
Changes for apps/api/src/instance/instance.controller.ts: 65 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -27,6 +27,10 @@ import { SignupModeDto, RejectSignupDto } from './dto/signup-mode.dto';
import { MailSettingsService, type MailSettingsView } from './mail-settings.service';
import { TestMailDto, UpdateMailSettingsDto } from './dto/mail-settings.dto';
import { SandboxSettingsService, type SandboxSettingsView } from './sandbox-settings.service';
import {
  SandboxSessionsService,
  type SandboxSessionView,
} from './sandbox-sessions.service';
import { InstanceMetricsService, type InstanceMetrics } from './metrics.service';
import { AnnouncementService, type AnnouncementState } from './announcement.service';
import { SetAnnouncementDto } from './dto/announcement.dto';
@@ -43,7 +47,10 @@ interface AppUrlStateView {
  /** D'ou elle vient. `null` quand rien n'est configure (production). */
  source: ResolvedAppBase['source'] | null;
}
import { UpdateSandboxSettingsDto } from './dto/sandbox-settings.dto';
import {
  ExtendSandboxSessionDto,
  UpdateSandboxSettingsDto,
} from './dto/sandbox-settings.dto';
import {
  AttachMemberDto,
  CreateTenantDto,
@@ -83,6 +90,7 @@ export class InstanceController {
    private readonly signup: SignupService,
    private readonly mail: MailSettingsService,
    private readonly sandbox: SandboxSettingsService,
    private readonly seances: SandboxSessionsService,
    private readonly metricsService: InstanceMetricsService,
    private readonly announcements: AnnouncementService,
    private readonly appUrl: AppUrlService,
@@ -357,6 +365,62 @@ export class InstanceController {
    return view;
  }

  // --- Bac a sable : les seances en cours ---

  /**
   * Seances vivantes du bac a sable.
   *
   * Elles n'apparaissent NULLE PART ailleurs : une organisation ephemere est
   * exclue de la liste des organisations clientes, parce que ce n'en est pas
   * une. Sans cette route, l'administrateur ne disposait que d'un compteur.
   */
  @Get('sandbox/sessions')
  sandboxSessions(): Promise<SandboxSessionView[]> {
    return this.seances.list();
  }

  /**
   * Prolonge une seance. Tracee : elle repousse une echeance de suppression.
   */
  @Post('sandbox/sessions/:id/extend')
  async extendSandboxSession(
    @CurrentUser() me: AuthUser,
    @Param('id') id: string,
    @Body() dto: ExtendSandboxSessionDto,
  ): Promise<SandboxSessionView> {
    const view = await this.seances.extend(id, dto.minutes);
    const actor = await this.actor(me);
    await this.audit.record({
      actorUserId: actor.userId,
      actorEmail: actor.email,
      action: 'instance.sandbox.session.extend',
      tenantId: view.id,
      tenantName: view.exerciseName,
      details: { minutes: dto.minutes, expiresAt: view.expiresAt },
    });
    return view;
  }

  /**
   * Ferme une seance sur-le-champ, avec tout son contenu.
   *
   * Tracee, et pas seulement pour la forme : c'est une suppression definitive
   * decidee par un tiers, pendant que des joueurs sont peut-etre en train de
   * jouer. Elle doit rester attribuable.
   */
  @Delete('sandbox/sessions/:id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async closeSandboxSession(@CurrentUser() me: AuthUser, @Param('id') id: string): Promise<void> {
    const { slug } = await this.seances.close(id);
    const actor = await this.actor(me);
    await this.audit.record({
      actorUserId: actor.userId,
      actorEmail: actor.email,
      action: 'instance.sandbox.session.close',
      details: { slug },
    });
  }

  // --- Inscription publique ---

  /** Interrupteur global : ferme, moderee, ou libre-service. */
+2 −0
Changes for apps/api/src/instance/instance.module.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@ import { InstanceSwitchService } from './instance-switch.service';
import { TenantsService } from './tenants.service';
import { MailSettingsService } from './mail-settings.service';
import { SandboxSettingsService } from './sandbox-settings.service';
import { SandboxSessionsService } from './sandbox-sessions.service';
import { InstanceMetricsService } from './metrics.service';
import { AnnouncementService } from './announcement.service';
import { RateLimitGuard } from '../common/rate-limit.guard';
@@ -23,6 +24,7 @@ import { PublicAnnouncementController } from './announcement.controller';
    TenantsService,
    MailSettingsService,
    SandboxSettingsService,
    SandboxSessionsService,
    InstanceMetricsService,
    AnnouncementService,
    // Requis par la route publique d'annonce, plafonnee.
+170 −0
Changes for apps/api/src/instance/sandbox-sessions.service.ts: 170 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AppUrlService } from '../common/app-url.service';

/**
 * Prolongation : bornes volontairement etroites.
 *
 * Prolonger sert a laisser finir une seance en cours, pas a transformer une
 * organisation ephemere en organisation permanente — ce serait contourner par
 * la console le garde-fou que le bac a sable pose sur lui-meme.
 */
const EXTEND_MIN_MINUTES = 5;
const EXTEND_MAX_MINUTES = 240;
/** Plafond de l'echeance resultante, aligne sur la duree de vie maximale (24 h). */
const MAX_REMAINING_MINUTES = 1440;

/** Une seance de decouverte, telle que la console la montre. */
export interface SandboxSessionView {
  id: string;
  /** Identifiant public de la seance : c'est aussi la fin de son adresse. */
  slug: string;
  sessionUrl: string;
  exerciseName: string;
  exerciseStatus: string;
  startedAt: string;
  expiresAt: string;
  rolesTotal: number;
  rolesTaken: number;
  /**
   * Derniere fois qu'un joueur a ouvert son espace. `null` = personne n'est
   * jamais venu, ce qui distingue une seance abandonnee d'une seance active et
   * decide s'il est prudent de la fermer.
   */
  lastActivityAt: string | null;
}

/**
 * Administration des seances du bac a sable, depuis la console d'instance.
 *
 * POURQUOI CE SERVICE EXISTE. Les organisations ephemeres sont exclues de la
 * liste des organisations clientes — ce ne sont pas des clientes — et
 * l'administrateur n'avait donc AUCUNE vue sur ce que le bac a sable faisait
 * tourner : un compteur, « 3 sessions en cours », et rien d'autre. Il ne pouvait
 * ni voir qui jouait quoi, ni liberer une place, ni laisser finir une seance qui
 * allait etre coupee par son echeance.
 *
 * Les trois gestes offerts sont ceux-la, et seulement ceux-la : regarder,
 * prolonger, fermer.
 */
@Injectable()
export class SandboxSessionsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly appUrl: AppUrlService,
  ) {}

  /** Seances vivantes, la plus proche de son echeance en tete. */
  async list(): Promise<SandboxSessionView[]> {
    const tenants = await this.prisma.tenant.findMany({
      where: { ephemeralUntil: { gt: new Date() } },
      orderBy: { ephemeralUntil: 'asc' },
      select: {
        id: true,
        slug: true,
        createdAt: true,
        ephemeralUntil: true,
        exercises: {
          orderBy: { createdAt: 'asc' },
          take: 1,
          select: {
            name: true,
            status: true,
            _count: { select: { characters: true, participants: true } },
          },
        },
      },
    });

    const base = await this.appUrl.base();
    const vues: SandboxSessionView[] = [];
    for (const t of tenants) {
      const ex = t.exercises[0];
      /*
       * Les personnages comptent les PNJ, qui ne sont pas des roles jouables :
       * on recompte les joueurs a part plutot que de servir un total faux.
       */
      const [rolesTotal, derniere] = await Promise.all([
        this.prisma.forTenant(t.id).character.count({ where: { kind: 'PLAYER' } }),
        this.prisma.forTenant(t.id).participant.findFirst({
          where: { lastSeenAt: { not: null } },
          orderBy: { lastSeenAt: 'desc' },
          select: { lastSeenAt: true },
        }),
      ]);
      vues.push({
        id: t.id,
        slug: t.slug,
        sessionUrl: `${base}/decouverte/${t.slug}`,
        exerciseName: ex?.name ?? '—',
        exerciseStatus: ex?.status ?? 'DRAFT',
        startedAt: t.createdAt.toISOString(),
        expiresAt: t.ephemeralUntil!.toISOString(),
        rolesTotal,
        rolesTaken: ex?._count.participants ?? 0,
        lastActivityAt: derniere?.lastSeenAt?.toISOString() ?? null,
      });
    }
    return vues;
  }

  /**
   * Ferme une seance immediatement, avec tout son contenu.
   *
   * La suppression du tenant suffit : tout est en cascade sur lui, comme pour la
   * purge a l'echeance. C'est le meme geste, avance dans le temps — et non un
   * second chemin de suppression qu'il faudrait maintenir en parallele.
   */
  async close(id: string): Promise<{ slug: string }> {
    const tenant = await this.prisma.tenant.findFirst({
      where: { id, ephemeralUntil: { not: null } },
      select: { id: true, slug: true },
    });
    // Restreint aux organisations EPHEMERES : cette route ne doit en aucun cas
    // servir a supprimer une organisation cliente.
    if (!tenant) throw new NotFoundException('Séance de découverte introuvable');
    await this.prisma.tenant.delete({ where: { id: tenant.id } });
    return { slug: tenant.slug };
  }

  /**
   * Repousse l'echeance d'une seance.
   *
   * LES JETONS DES JOUEURS SONT REPOUSSES AVEC ELLE. Ils portent leur propre
   * date d'expiration, posee a la prise du role : prolonger la seule
   * organisation aurait laisse la page de session ouverte pendant que les
   * joueurs, eux, se faisaient refuser a la porte.
   */
  async extend(id: string, minutes: number): Promise<SandboxSessionView> {
    if (!Number.isInteger(minutes) || minutes < EXTEND_MIN_MINUTES || minutes > EXTEND_MAX_MINUTES) {
      throw new BadRequestException(
        `Prolongation attendue entre ${EXTEND_MIN_MINUTES} et ${EXTEND_MAX_MINUTES} minutes.`,
      );
    }
    const tenant = await this.prisma.tenant.findFirst({
      where: { id, ephemeralUntil: { gt: new Date() } },
      select: { id: true, slug: true, ephemeralUntil: true },
    });
    if (!tenant?.ephemeralUntil) throw new NotFoundException('Séance de découverte introuvable');

    const echeance = new Date(tenant.ephemeralUntil.getTime() + minutes * 60_000);
    const plafond = new Date(Date.now() + MAX_REMAINING_MINUTES * 60_000);
    if (echeance > plafond) {
      throw new BadRequestException(
        'Une séance de découverte ne peut pas courir au-delà de 24 heures.',
      );
    }

    await this.prisma.$transaction([
      this.prisma.tenant.update({ where: { id: tenant.id }, data: { ephemeralUntil: echeance } }),
      this.prisma.participant.updateMany({
        where: { tenantId: tenant.id },
        data: { tokenExpiresAt: echeance },
      }),
    ]);

    const vue = (await this.list()).find((s) => s.id === tenant.id);
    if (!vue) throw new NotFoundException('Séance de découverte introuvable');
    return vue;
  }
}
+169 −0
Changes for apps/web/src/Instance.tsx: 169 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import type {
  ApiAppUrlState,
  ApiInstanceAudit,
  ApiMailSettings,
  ApiSandboxSessionAdmin,
  ApiSandboxSettings,
  ApiSignupRequest,
  ApiSignupState,
@@ -13,6 +14,7 @@ import type {
  TenantRole,
} from '@enkadenn/shared';
import { api, ApiError } from './api';
import { Countdown } from './Countdown';
import { USER_ROLES } from './constants';
import { useT } from './i18n';
import { InstanceMonitoring } from './InstanceMonitoring';
@@ -57,6 +59,7 @@ export function Instance(): JSX.Element {
      <MailPanel />
      <SignupPanel onApproved={reload} />
      <SandboxPanel />
      <SandboxSessionsPanel />
      <CreateTenantForm onCreated={reload} />

      {error && <p className="err">{error}</p>}
@@ -941,6 +944,172 @@ function SandboxPanel(): JSX.Element {
  );
}

/**
 * Séances de découverte en cours (EV-7.9).
 *
 * Les organisations éphémères sont exclues de la liste des organisations
 * clientes — ce ne sont pas des clientes — et l'administrateur ne disposait donc
 * que d'un compteur : « 3 sessions en cours », sans savoir qui jouait quoi, ni
 * pouvoir libérer une place, ni laisser finir une séance sur le point d'être
 * coupée par son échéance.
 */
function SandboxSessionsPanel(): JSX.Element {
  const { t } = useT();
  const [seances, setSeances] = useState<ApiSandboxSessionAdmin[] | null>(null);
  const [busy, setBusy] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const reload = useCallback(async () => {
    try {
      setSeances(await api.sandboxSessions());
      setError(null);
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Chargement impossible'));
    }
  }, [t]);

  useEffect(() => {
    void reload();
  }, [reload]);

  /*
   * Une séance vit en minutes : un écran figé montrerait des échéances fausses
   * au bout de deux minutes. On relit régulièrement, sans que l'exploitant ait
   * à recharger sa console.
   */
  useEffect(() => {
    const id = window.setInterval(() => void reload(), 30_000);
    return () => window.clearInterval(id);
  }, [reload]);

  async function prolonger(s: ApiSandboxSessionAdmin, minutes: number): Promise<void> {
    setBusy(s.id);
    setError(null);
    try {
      await api.extendSandboxSession(s.id, minutes);
      await reload();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Prolongation impossible'));
    } finally {
      setBusy(null);
    }
  }

  async function fermer(s: ApiSandboxSessionAdmin): Promise<void> {
    // Confirmation obligatoire : des joueurs sont peut-être en train de jouer,
    // et la suppression est immédiate et définitive.
    if (
      !window.confirm(
        t(
          'Fermer cette séance supprime immédiatement son exercice et déconnecte les joueurs en cours. Cette action est définitive. Confirmer ?',
        ),
      )
    ) {
      return;
    }
    setBusy(s.id);
    setError(null);
    try {
      await api.closeSandboxSession(s.id);
      await reload();
    } catch (err) {
      setError(err instanceof ApiError ? err.message : t('Fermeture impossible'));
    } finally {
      setBusy(null);
    }
  }

  return (
    <section className="panel">
      <div className="panel-head">
        <h3>{t('Séances de découverte en cours')}</h3>
        {seances && <span className="pill">{seances.length}</span>}
      </div>

      {error && <p className="err">{error}</p>}

      {!seances ? (
        <p className="muted">{t('Chargement…')}</p>
      ) : seances.length === 0 ? (
        <p className="muted">
          {t('Aucune séance en cours. Les séances échues sont supprimées automatiquement.')}
        </p>
      ) : (
        <div className="table-wrap">
          <table className="grid">
            <thead>
              <tr>
                <th>{t('Scénario')}</th>
                <th>{t('Rôles pris')}</th>
                <th>{t('Dernière activité')}</th>
                <th>{t('Fin dans')}</th>
                <th>{t('Actions')}</th>
              </tr>
            </thead>
            <tbody>
              {seances.map((s) => (
                <tr key={s.id}>
                  <td>
                    <b>{s.exerciseName}</b>
                    <br />
                    <a
                      className="muted small"
                      href={s.sessionUrl}
                      target="_blank"
                      rel="noreferrer noopener"
                    >
                      {s.slug}
                    </a>
                  </td>
                  <td>
                    {s.rolesTaken} / {s.rolesTotal}
                  </td>
                  <td className="muted small">
                    {s.lastActivityAt
                      ? new Date(s.lastActivityAt).toLocaleTimeString([], {
                          hour: '2-digit',
                          minute: '2-digit',
                        })
                      : t('jamais ouverte')}
                  </td>
                  <td>
                    <Countdown until={s.expiresAt} />
                  </td>
                  <td>
                    <div className="row">
                      <button
                        className="btn"
                        disabled={busy !== null}
                        onClick={() => void prolonger(s, 30)}
                        title={t('Repousser l’échéance de 30 minutes')}
                      >
                        +30 min
                      </button>
                      <button
                        className="btn danger"
                        disabled={busy !== null}
                        onClick={() => void fermer(s)}
                      >
                        {t('Fermer')}
                      </button>
                    </div>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      <p className="muted small">
        {t(
          'Prolonger repousse aussi les accès des joueurs : sans cela, la page de séance resterait ouverte pendant qu’ils se feraient refuser à la porte.',
        )}
      </p>
    </section>
  );
}

function CreateTenantForm({ onCreated }: { onCreated: () => Promise<void> }): JSX.Element {
  const { t } = useT();
  const [name, setName] = useState('');
Loading