Merge pull request 'Dev' (#4) from dev into main
Build and deploy / build-and-push (push) Successful in 19s
Build and deploy / deploy (push) Successful in 12s

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-08-29 04:02:11 +00:00
83 changed files with 3774 additions and 142 deletions
+11
View File
@@ -12,3 +12,14 @@ IMAGE_TAG=latest
# Port exposé sur l'hôte de production
PORT=5050
# Réinitialisation de mot de passe par email (auth/send_email.py) — un
# serveur SMTP existant (ex. Mailcow) suffit, aucun service tiers requis.
# Laisser vide désactive l'envoi (le lien de réinitialisation n'est alors
# jamais transmis à l'utilisateur, seule une erreur est journalisée côté
# serveur — jamais montrée telle quelle à l'utilisateur).
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
+5
View File
@@ -8,6 +8,7 @@ __pycache__/
.venv/
venv/
env/
Bug/
# Variables d'environnement / secrets locaux
.env
@@ -16,6 +17,10 @@ env/
# Jeux créés par les utilisateurs (données runtime, pas du code)
projects/
# Comptes utilisateurs (base SQLite + clé de session) — données runtime,
# jamais du code, et sensibles (mots de passe hachés, secrets 2FA).
data/
# OS / éditeurs
.DS_Store
Thumbs.db
+4
View File
@@ -21,6 +21,10 @@ import webbrowser
from core.flask_app import app
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja
import routes # noqa: F401 - enregistre toutes les routes sur `app`
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
def _open_browser():
+47
View File
@@ -0,0 +1,47 @@
"""auth — comptes utilisateurs, connexion, 2FA (TOTP) et isolation par
utilisateur d'un SEUL projet (sauf le rôle "admin", illimité) — voir
create_user.py pour le détail des règles. Base SQLite entièrement séparée
de db/ (une base par JEU) : ces comptes n'appartiennent à aucun jeu, ils
en POSSÈDENT un (project_slug)."""
from .connection import users_db_path
from .ensure_schema import ensure_users_schema
from .is_first_user import is_first_user
from .create_user import create_user, UserCreationError
from .get_user_by_email import get_user_by_email
from .get_user_by_id import get_user_by_id
from .verify_password import verify_password
from .confirm_totp import confirm_totp
from .verify_totp import verify_totp
from .set_project_slug import set_project_slug
from .password_strength import password_strength, MIN_SCORE_REQUIRED
from .totp_qrcode_svg import totp_provisioning_uri, totp_qrcode_svg
from .rate_limit import lockout_minutes_for, seconds_locked_remaining, lockout_message
from .record_failed_attempt import record_failed_attempt
from .reset_failed_attempts import reset_failed_attempts
from .recovery_codes import generate_recovery_codes, verify_recovery_code
from .set_password import set_password
from .password_reset import (
create_password_reset_token, get_user_id_for_valid_token, consume_password_reset_token,
TOKEN_TTL_MINUTES,
)
from .send_email import send_password_reset_email, EmailNotConfiguredError
from .update_profile import update_profile
from .count_admins import count_admins
from .delete_user import delete_user
__all__ = [
"users_db_path", "ensure_users_schema", "is_first_user",
"create_user", "UserCreationError",
"get_user_by_email", "get_user_by_id",
"verify_password", "confirm_totp", "verify_totp", "set_project_slug",
"password_strength", "MIN_SCORE_REQUIRED",
"totp_provisioning_uri", "totp_qrcode_svg",
"lockout_minutes_for", "seconds_locked_remaining", "lockout_message",
"record_failed_attempt", "reset_failed_attempts",
"generate_recovery_codes", "verify_recovery_code",
"set_password", "create_password_reset_token", "get_user_id_for_valid_token",
"consume_password_reset_token", "TOKEN_TTL_MINUTES",
"send_password_reset_email", "EmailNotConfiguredError",
"update_profile", "count_admins", "delete_user",
]
+8
View File
@@ -0,0 +1,8 @@
from .connection import connect
def confirm_totp(user_id):
conn = connect()
conn.execute("UPDATE _users SET totp_confirmed = 1 WHERE id = ?", (user_id,))
conn.commit()
conn.close()
+28
View File
@@ -0,0 +1,28 @@
"""Connexion à la base SQLite GLOBALE des comptes utilisateurs — SÉPARÉE
de db/ (une base par JEU, voir db/connection.py) : les comptes ne
"appartiennent" à aucun jeu en particulier, ils en POSSÈDENT un (voir
auth/create_user.py, project_slug).
Le chemin se résout à CHAQUE appel (pas une constante figée à l'import) :
tests/conftest.py positionne FORGE_USERS_DB_PATH vers un fichier temporaire
avant de faire tourner la suite, pour ne jamais toucher à la vraie base de
comptes (data/users.db) ni dépendre d'un état "premier compte = admin" déjà
consommé par un run précédent."""
import os
import sqlite3
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_USERS_DB_PATH = os.path.join(_BASE_DIR, "data", "users.db")
def users_db_path():
return os.environ.get("FORGE_USERS_DB_PATH") or DEFAULT_USERS_DB_PATH
def connect():
path = users_db_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
conn = sqlite3.connect(path, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 8000")
return conn
+8
View File
@@ -0,0 +1,8 @@
from .connection import connect
def count_admins():
conn = connect()
n = conn.execute("SELECT COUNT(*) AS n FROM _users WHERE role = 'admin'").fetchone()["n"]
conn.close()
return n
+53
View File
@@ -0,0 +1,53 @@
import re
import pyotp
from werkzeug.security import generate_password_hash
from .ensure_schema import ensure_users_schema
from .connection import connect
from .get_user_by_email import get_user_by_email
from .is_first_user import is_first_user
from .password_strength import password_strength
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class UserCreationError(Exception):
"""Erreur de validation lisible par un humain (affichée telle quelle
dans le formulaire d'inscription) — jamais un détail SQL/technique."""
def create_user(email, password, nom, prenom):
"""Crée un compte : mot de passe fort (auth/password_strength.py) et
2FA (TOTP) rendus obligatoires — le secret est généré ici mais
totp_confirmed reste à 0 tant que confirm_totp() n'a pas vérifié un
premier code réellement scanné/tapé (voir routes/auth/register_2fa.py),
pour ne jamais activer un compte sur un secret QR jamais réellement
configuré. Le TOUT PREMIER compte créé (base vide) devient
automatiquement admin (voir is_first_user.py) : pas de mot de passe
par défaut à faire circuler, juste "inscris-toi en premier"."""
email = (email or "").strip().lower()
nom = (nom or "").strip()
prenom = (prenom or "").strip()
if not _EMAIL_RE.match(email):
raise UserCreationError("Adresse email invalide.")
if not nom or not prenom:
raise UserCreationError("Nom et prénom sont requis.")
if get_user_by_email(email):
raise UserCreationError("Un compte existe déjà avec cette adresse email.")
if not password_strength(password)["valid"]:
raise UserCreationError("Mot de passe trop faible — respecte les critères affichés.")
ensure_users_schema()
role = "admin" if is_first_user() else "user"
totp_secret = pyotp.random_base32()
conn = connect()
conn.execute(
"""INSERT INTO _users (email, password_hash, nom, prenom, role, totp_secret, totp_confirmed)
VALUES (?, ?, ?, ?, ?, ?, 0)""",
(email, generate_password_hash(password), nom, prenom, role, totp_secret),
)
user_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return user_id
+16
View File
@@ -0,0 +1,16 @@
from .connection import connect
def delete_user(user_id):
"""Supprime le compte et tout ce qui lui est directement rattaché
(codes de récupération, jetons de réinitialisation encore en cours) —
le dossier de projet, lui, est géré par l'appelant (voir
routes/auth/profile.py) : un compte "admin" peut posséder plusieurs
jeux qu'il n'a pas forcément envie de perdre en même temps que son
compte."""
conn = connect()
conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM _password_reset_tokens WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM _users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
+65
View File
@@ -0,0 +1,65 @@
from .connection import connect
def ensure_users_schema():
conn = connect()
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS _users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
nom TEXT NOT NULL,
prenom TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
totp_secret TEXT NOT NULL,
totp_confirmed INTEGER NOT NULL DEFAULT 0,
project_slug TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
# Anti-bruteforce (voir rate_limit.py) : ajoutées en ALTER TABLE pour
# ne rien casser sur une base de comptes déjà créée avant cette
# fonctionnalité (le compte admin déjà inscrit sur ce poste, par
# exemple) — CREATE TABLE IF NOT EXISTS ci-dessus n'aurait sinon
# aucun effet sur une table qui existe déjà sans ces colonnes.
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_users)").fetchall()}
if "failed_attempts" not in cols:
conn.execute("ALTER TABLE _users ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0")
if "locked_until" not in cols:
conn.execute("ALTER TABLE _users ADD COLUMN locked_until TEXT")
# Codes de récupération 2FA (voir recovery_codes.py) : table séparée
# plutôt qu'une colonne JSON sur _users, pour pouvoir marquer/consommer
# chaque code UN PAR UN (used_at) sans avoir à relire/réécrire toute
# la liste à chaque connexion.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _recovery_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
code_hash TEXT NOT NULL,
used_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
# Jetons de réinitialisation de mot de passe (voir password_reset.py) :
# seul leur hash (SHA-256, un jeton aléatoire à haute entropie n'a pas
# besoin d'un hash de mot de passe volontairement lent) est stocké, un
# jeton envoyé par email reste donc inutilisable même en cas de fuite
# de cette base.
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
conn.commit()
conn.close()
+10
View File
@@ -0,0 +1,10 @@
from .ensure_schema import ensure_users_schema
from .connection import connect
def get_user_by_email(email):
ensure_users_schema()
conn = connect()
row = conn.execute("SELECT * FROM _users WHERE email = ?", ((email or "").strip().lower(),)).fetchone()
conn.close()
return dict(row) if row else None
+12
View File
@@ -0,0 +1,12 @@
from .ensure_schema import ensure_users_schema
from .connection import connect
def get_user_by_id(user_id):
ensure_users_schema()
if not user_id:
return None
conn = connect()
row = conn.execute("SELECT * FROM _users WHERE id = ?", (user_id,)).fetchone()
conn.close()
return dict(row) if row else None
+14
View File
@@ -0,0 +1,14 @@
from .ensure_schema import ensure_users_schema
from .connection import connect
def is_first_user():
"""True s'il n'existe encore AUCUN compte — le tout premier compte créé
devient automatiquement admin (voir create_user.py), pour ne jamais
avoir besoin d'un mot de passe par défaut ou d'un script de bootstrap
manipulant un vrai mot de passe."""
ensure_users_schema()
conn = connect()
count = conn.execute("SELECT COUNT(*) AS c FROM _users").fetchone()["c"]
conn.close()
return count == 0
+55
View File
@@ -0,0 +1,55 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from .connection import connect
TOKEN_TTL_MINUTES = 60
def _hash_token(token):
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def create_password_reset_token(user_id):
"""Un seul jeton valide à la fois par utilisateur : en créer un
nouveau invalide silencieusement tout jeu envoyé plus tôt (par
exemple si l'utilisateur redemande un email parce que le premier
s'est perdu), pour ne jamais avoir deux liens valides en même temps."""
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=TOKEN_TTL_MINUTES)).isoformat()
conn = connect()
conn.execute("DELETE FROM _password_reset_tokens WHERE user_id = ?", (user_id,))
conn.execute(
"INSERT INTO _password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
(user_id, _hash_token(token), expires_at),
)
conn.commit()
conn.close()
return token
def get_user_id_for_valid_token(token):
if not token:
return None
conn = connect()
row = conn.execute(
"SELECT user_id, expires_at, used_at FROM _password_reset_tokens WHERE token_hash = ?",
(_hash_token(token),),
).fetchone()
conn.close()
if not row or row["used_at"]:
return None
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
return None
return row["user_id"]
def consume_password_reset_token(token):
conn = connect()
conn.execute(
"UPDATE _password_reset_tokens SET used_at = CURRENT_TIMESTAMP WHERE token_hash = ?",
(_hash_token(token),),
)
conn.commit()
conn.close()
+29
View File
@@ -0,0 +1,29 @@
import re
# Mêmes règles des deux côtés (ici pour le refus serveur, en JS dans
# templates/auth/register.html pour le schéma visuel qui guide la saisie
# en temps réel) — un mot de passe REFUSÉ côté serveur doit toujours
# correspondre à une jauge/coche déjà rouge côté client, jamais une
# surprise après coup.
_CHECKS = [
("longueur", "Au moins 8 caractères", lambda p: len(p) >= 8),
("majuscule", "Une majuscule", lambda p: re.search(r"[A-Z]", p) is not None),
("minuscule", "Une minuscule", lambda p: re.search(r"[a-z]", p) is not None),
("chiffre", "Un chiffre", lambda p: re.search(r"\d", p) is not None),
("special", "Un caractère spécial (ex. ! ? # -)", lambda p: re.search(r"[^A-Za-z0-9]", p) is not None),
]
# Nombre de règles satisfaites en dessous duquel le mot de passe est
# refusé — un mot de passe "fort" au sens de cette jauge, pas seulement
# "pas complètement trivial".
MIN_SCORE_REQUIRED = 4
def password_strength(password):
"""Renvoie {"score": 0-5, "checks": [{"key","label","ok"}, ...],
"valid": bool} — jamais None, un mot de passe vide obtient juste un
score de 0 (toutes les règles échouent), pas une erreur."""
password = password or ""
checks = [{"key": key, "label": label, "ok": bool(test(password))} for key, label, test in _CHECKS]
score = sum(1 for c in checks if c["ok"])
return {"score": score, "checks": checks, "valid": score >= MIN_SCORE_REQUIRED}
+50
View File
@@ -0,0 +1,50 @@
"""Anti-bruteforce sur les tentatives de connexion (mot de passe ET code
2FA — même compteur pour les deux, un compte ciblé reste protégé quelle
que soit l'étape attaquée) : 3 essais libres, puis un temps d'attente qui
double à chaque échec supplémentaire (5 min, 10, 20, 40...), plafonné à
1h. Remis à zéro dès une connexion réussie (voir reset_failed_attempts)."""
import math
from datetime import datetime, timedelta, timezone
_FREE_ATTEMPTS = 3
_FIRST_LOCKOUT_MINUTES = 5
_MAX_LOCKOUT_MINUTES = 60
def lockout_minutes_for(failed_attempts):
"""0 tant qu'on est dans les 3 essais libres ; sinon 5 * 2^n, plafonné
à 60 — jamais négatif, jamais None."""
if failed_attempts <= _FREE_ATTEMPTS:
return 0
exponent = failed_attempts - _FREE_ATTEMPTS - 1
return min(_FIRST_LOCKOUT_MINUTES * (2 ** exponent), _MAX_LOCKOUT_MINUTES)
def _parse(dt_str):
if not dt_str:
return None
try:
return datetime.fromisoformat(dt_str)
except ValueError:
return None
def lockout_message(seconds_remaining):
"""Message affiché tel quel dans le formulaire — arrondi à la minute
supérieure (jamais "0 minute" alors qu'il en reste un peu, jamais un
compte de secondes qui oblige à recharger sans arrêt pour voir si
c'est fini)."""
minutes = max(1, math.ceil(seconds_remaining / 60))
unit = "minute" if minutes == 1 else "minutes"
return f"Trop de tentatives. Réessaie dans {minutes} {unit}."
def seconds_locked_remaining(user):
"""> 0 si le compte est actuellement verrouillé (temps restant, en
secondes, arrondi au-dessus pour ne jamais afficher "0 minute" alors
qu'il en reste réellement un peu) ; 0 sinon."""
locked_until = _parse((user or {}).get("locked_until"))
if not locked_until:
return 0
remaining = (locked_until - datetime.now(timezone.utc)).total_seconds()
return max(0, math.ceil(remaining))
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timedelta, timezone
from .connection import connect
from .rate_limit import lockout_minutes_for
def record_failed_attempt(user_id):
"""Incrémente le compteur d'échecs de CE compte et, une fois passé les
3 essais libres, pose/allonge son verrouillage (voir rate_limit.py) —
appelé sur un mot de passe OU un code 2FA incorrect, jamais sur un
compte introuvable (rien à incrémenter dans ce cas). Renvoie le
nombre de minutes du verrouillage tout juste posé (0 si encore dans
les essais libres) : l'appelant peut ainsi annoncer IMMÉDIATEMENT
"trop de tentatives" sur CETTE réponse plutôt que de laisser
l'utilisateur découvrir le verrouillage seulement au prochain essai."""
conn = connect()
row = conn.execute("SELECT failed_attempts FROM _users WHERE id = ?", (user_id,)).fetchone()
if not row:
conn.close()
return 0
failed_attempts = row["failed_attempts"] + 1
minutes = lockout_minutes_for(failed_attempts)
locked_until = (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat() if minutes else None
conn.execute(
"UPDATE _users SET failed_attempts = ?, locked_until = ? WHERE id = ?",
(failed_attempts, locked_until, user_id),
)
conn.commit()
conn.close()
return minutes
+60
View File
@@ -0,0 +1,60 @@
import secrets
from werkzeug.security import check_password_hash, generate_password_hash
from .connection import connect
_CODE_COUNT = 10
def _format_code(raw):
return "-".join(raw[i:i + 4] for i in range(0, len(raw), 4))
def generate_recovery_codes(user_id):
"""(Re)génère les codes de récupération 2FA d'un utilisateur : un
nouvel appel invalide tout jeu de codes précédent (un seul jeu valide
à la fois, pour ne jamais avoir à deviner lesquels tiennent encore).
Les codes ne sont renvoyés EN CLAIR qu'ICI, à l'instant de leur
création — seul leur hash (werkzeug, comme les mots de passe) est
conservé en base, pour qu'une fuite de la base ne les rende pas
directement réutilisables."""
conn = connect()
conn.execute("DELETE FROM _recovery_codes WHERE user_id = ?", (user_id,))
codes = []
for _ in range(_CODE_COUNT):
code = _format_code(secrets.token_hex(6))
codes.append(code)
conn.execute(
"INSERT INTO _recovery_codes (user_id, code_hash) VALUES (?, ?)",
(user_id, generate_password_hash(code)),
)
conn.commit()
conn.close()
return codes
def verify_recovery_code(user_id, code):
"""Un code n'est utilisable qu'UNE SEULE FOIS (used_at) : consommé dès
qu'il sert à une connexion réussie, pour qu'un code intercepté une
fois (capture d'écran, historique du navigateur...) ne redonne pas un
accès permanent."""
code = (code or "").strip().lower()
if not code:
return False
conn = connect()
rows = conn.execute(
"SELECT id, code_hash FROM _recovery_codes WHERE user_id = ? AND used_at IS NULL",
(user_id,),
).fetchall()
for row in rows:
if check_password_hash(row["code_hash"], code):
conn.execute(
"UPDATE _recovery_codes SET used_at = CURRENT_TIMESTAMP WHERE id = ?",
(row["id"],),
)
conn.commit()
conn.close()
return True
conn.close()
return False
+12
View File
@@ -0,0 +1,12 @@
from .connection import connect
def reset_failed_attempts(user_id):
"""Remet le compteur à zéro et lève tout verrouillage — appelé dès
qu'une étape de connexion réussit (mot de passe validé ou code 2FA
validé), pour ne jamais punir un utilisateur légitime qui s'est juste
trompé une fois avant de retrouver le bon mot de passe/code."""
conn = connect()
conn.execute("UPDATE _users SET failed_attempts = 0, locked_until = NULL WHERE id = ?", (user_id,))
conn.commit()
conn.close()
+52
View File
@@ -0,0 +1,52 @@
"""Envoi d'email via LE SERVEUR SMTP DE L'UTILISATEUR (Mailcow ou autre) —
aucune dépendance supplémentaire, smtplib est dans la bibliothèque standard
Python. Configuré uniquement par variables d'environnement (SMTP_HOST,
SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) : ce module ne connaît et
ne stocke jamais le mot de passe SMTP en dur, à définir sur le poste/
serveur qui fait tourner l'appli."""
import os
import smtplib
from email.mime.text import MIMEText
from .password_reset import TOKEN_TTL_MINUTES
class EmailNotConfiguredError(Exception):
"""Levée si les variables d'environnement SMTP_* ne sont pas toutes
définies — jamais renvoyée telle quelle à l'utilisateur (voir
routes/auth/forgot_password.py, qui affiche toujours le même message
générique), seulement journalisée côté serveur pour que l'admin sache
qu'il doit finir de configurer son SMTP."""
def send_email(to_email, subject, body):
host = os.environ.get("SMTP_HOST")
port = int(os.environ.get("SMTP_PORT", "587"))
user = os.environ.get("SMTP_USER")
password = os.environ.get("SMTP_PASSWORD")
sender = os.environ.get("SMTP_FROM") or user
if not host or not user or not password or not sender:
raise EmailNotConfiguredError(
"SMTP_HOST / SMTP_USER / SMTP_PASSWORD / SMTP_FROM doivent être "
"définis en variables d'environnement pour envoyer des emails."
)
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = sender
msg["To"] = to_email
with smtplib.SMTP(host, port, timeout=10) as server:
server.starttls()
server.login(user, password)
server.sendmail(sender, [to_email], msg.as_string())
def send_password_reset_email(to_email, reset_url):
body = (
"Une réinitialisation de mot de passe a été demandée pour ce compte "
"Forge Engine.\n\n"
f"Clique sur ce lien pour choisir un nouveau mot de passe "
f"(valable {TOKEN_TTL_MINUTES} minutes) :\n{reset_url}\n\n"
"Si tu n'es pas à l'origine de cette demande, ignore cet email : "
"ton mot de passe actuel reste inchangé."
)
send_email(to_email, "Réinitialisation de ton mot de passe — Forge Engine", body)
+17
View File
@@ -0,0 +1,17 @@
from werkzeug.security import generate_password_hash
from .connection import connect
def set_password(user_id, new_password):
"""Remet aussi le compteur anti-bruteforce à zéro (auth/rate_limit.py) :
prouver son identité par email est une voie de récupération légitime,
un compte verrouillé après trop d'échecs ne doit pas rester bloqué une
fois le mot de passe changé pour de vrai."""
conn = connect()
conn.execute(
"UPDATE _users SET password_hash = ?, failed_attempts = 0, locked_until = NULL WHERE id = ?",
(generate_password_hash(new_password), user_id),
)
conn.commit()
conn.close()
+12
View File
@@ -0,0 +1,12 @@
from .connection import connect
def set_project_slug(user_id, slug):
"""Enregistre le SEUL projet que ce compte possède (voir
create_user.py — un compte "user" n'en aura jamais qu'un ; un compte
"admin" n'a PAS besoin de cette colonne, il reste libre de créer
plusieurs jeux nommés comme avant)."""
conn = connect()
conn.execute("UPDATE _users SET project_slug = ? WHERE id = ?", (slug, user_id))
conn.commit()
conn.close()
+33
View File
@@ -0,0 +1,33 @@
import io
import pyotp
import qrcode
import qrcode.image.svg
def totp_provisioning_uri(secret, email):
return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="Forge Engine")
def totp_qrcode_svg(secret, email):
"""SVG (pas PNG) : la variante "image factory" par défaut de qrcode a
besoin de Pillow pour produire un PNG — SvgPathImage, elle, est du pur
Python, sans dépendance supplémentaire à installer juste pour un QR
code affiché une seule fois par compte, à l'inscription. SvgPathImage
précisément (pas le simple SvgImage) : c'est la seule des deux variantes
dont le <svg> racine porte un attribut viewBox — sans lui, la règle CSS
qui fait tenir le QR code dans son cadre (.totpQrWrap svg { width:100% })
n'a rien à quoi se raccorder pour mettre à l'échelle le dessin interne
(en mm) : le QR code restait invisible/coupé au lieu de s'afficher.
qrcode.make() préfixe toujours sa sortie d'une déclaration XML
("<?xml version=...?>") — valide pour un fichier .svg autonome, mais
invalide au milieu d'un document HTML : certains navigateurs
n'affichent alors rien du tout à partir de ce point. On ne garde donc
que ce qui commence à "<svg"."""
uri = totp_provisioning_uri(secret, email)
img = qrcode.make(uri, image_factory=qrcode.image.svg.SvgPathImage)
buf = io.BytesIO()
img.save(buf)
svg = buf.getvalue().decode("utf-8")
return svg[svg.index("<svg"):]
+8
View File
@@ -0,0 +1,8 @@
from .connection import connect
def update_profile(user_id, nom, prenom):
conn = connect()
conn.execute("UPDATE _users SET nom = ?, prenom = ? WHERE id = ?", (nom, prenom, user_id))
conn.commit()
conn.close()
+5
View File
@@ -0,0 +1,5 @@
from werkzeug.security import check_password_hash
def verify_password(user, password):
return bool(user) and check_password_hash(user["password_hash"], password or "")
+12
View File
@@ -0,0 +1,12 @@
import pyotp
def verify_totp(secret, code):
"""valid_window=1 : tolère un léger décalage d'horloge entre le
serveur et le téléphone (accepte aussi le code de la période
précédente/suivante, ±30s) — sans quoi une horloge un peu désynchronisée
suffirait à rendre la 2FA injouable."""
code = (code or "").strip()
if not code:
return False
return pyotp.TOTP(secret).verify(code, valid_window=1)
+65
View File
@@ -0,0 +1,65 @@
"""Garde d'accès globale — connexion obligatoire pour tout le moteur, et
isolation par utilisateur d'un SEUL projet (sauf le rôle "admin",
illimité comme avant l'authentification). Un seul before_request plutôt
qu'un décorateur à poser sur chacune des ~80 routes existantes : moins de
risque d'en oublier une, et aucun fichier de routes existant n'a besoin
d'être modifié.
Cet import doit avoir lieu APRÈS `import routes` (voir app.py/conftest.py)
pour que `app.url_map` connaisse déjà toutes les routes au moment où ce
module tente de résoudre request.endpoint — en pratique sans importance
ici (la résolution se fait à la requête, pas à l'import), mais gardé pour
rester cohérent avec l'ordre d'import du reste du moteur."""
from flask import g, redirect, request, session, url_for, abort
import auth
from .flask_app import app
# Endpoints accessibles SANS être connecté — tout le reste exige une
# session valide. "static" (CSS/JS/images) doit rester public : la page
# de connexion elle-même en a besoin pour s'afficher.
_PUBLIC_ENDPOINTS = {
"static", "login", "login_2fa", "register", "register_2fa", "logout",
"forgot_password", "reset_password",
}
@app.before_request
def _require_login_and_enforce_project_isolation():
endpoint = request.endpoint
if endpoint is None or endpoint in _PUBLIC_ENDPOINTS:
return None
user_id = session.get("user_id")
if not user_id:
return redirect(url_for("login", next=request.path))
user = auth.get_user_by_id(user_id)
if not user:
# Compte supprimé/introuvable depuis la dernière requête : la
# session ne doit jamais rester "connectée" dans le vide.
session.clear()
return redirect(url_for("login"))
g.current_user = user
# Un compte "user" n'a accès qu'à SON SEUL projet (project_slug) — un
# "admin" reste illimité, exactement comme avant l'authentification.
# Les routes de gestion multi-jeux (page d'accueil, "+ Nouveau jeu")
# n'ont pas leur place pour un compte à projet unique : redirigées
# directement vers son propre tableau de bord plutôt qu'un 403 sec.
if user["role"] != "admin":
if endpoint in ("index", "games_new") and user.get("project_slug"):
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
slug = request.view_args.get("slug") if request.view_args else None
if slug is not None and slug != user.get("project_slug"):
abort(403)
if endpoint == "game_delete":
# Un compte "user" n'a qu'UN SEUL projet, et aucun moyen d'en
# recréer un une fois supprimé (games_new le renvoie toujours
# vers project_slug, même si le dossier n'existe plus) —
# bloqué plutôt que de risquer de le laisser sans projet du
# tout. Un admin, lui, peut toujours supprimer ses jeux comme
# avant (aucune restriction ci-dessus pour ce rôle).
abort(403)
return None
+24
View File
@@ -0,0 +1,24 @@
"""Jeton CSRF — un seul jeton par SESSION (pas un par formulaire, comme le
ferait Flask-WTF) : suffisant ici et beaucoup plus simple à propager
partout où l'appli envoie une requête qui modifie quelque chose, que ce
soit un <form> classique (intercepté par pjax.js, voir static/pjax.js) ou
un fetch() construit à la main (screen_edit.html, game_dashboard.html,
play.html...) — voir static/csrf_fetch.js, qui l'ajoute automatiquement en
en-tête à CHAQUE fetch() non-GET de l'appli plutôt que de devoir modifier
individuellement chacun des nombreux appels existants."""
import secrets
from flask import session
from .flask_app import app
def get_csrf_token():
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return token
app.jinja_env.globals["csrf_token"] = get_csrf_token
+39
View File
@@ -0,0 +1,39 @@
"""Vérifie le jeton CSRF (voir core/csrf.py) sur toute requête qui modifie
quelque chose — un before_request de plus, dans le même esprit que
core/auth_guard.py (une seule garde globale plutôt que de toucher aux
~90 routes existantes une par une).
Toute l'appli soumet ses formulaires ET ses fetch() via une seule voie :
pjax.js intercepte CHAQUE <form> interne et le transforme lui-même en
fetch() (voir static/pjax.js) — aucun formulaire de ce moteur ne fait donc
jamais de vraie soumission HTML native. static/csrf_fetch.js patche
window.fetch une seule fois pour y ajouter automatiquement l'en-tête
X-CSRFToken sur toute requête non-GET de l'appli (celles de pjax.js
comprises) : pas besoin de modifier individuellement les nombreux appels
fetch() déjà écrits à la main dans screen_edit.html/game_dashboard.html/
play.html."""
from flask import abort, request, session
from .flask_app import app
_SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
@app.before_request
def _verify_csrf_token():
if request.method in _SAFE_METHODS:
return None
if app.config.get("TESTING"):
# Les tests appellent les routes directement via le client de
# test Flask (de vraies requêtes HTTP, mais jamais passées par le
# navigateur/static/csrf_fetch.js qui pose l'en-tête) — même
# convention que Flask-WTF (WTF_CSRF_ENABLED désactivé en test).
# Ce garde a ses PROPRES tests dédiés (test_csrf.py), qui
# réactivent la vérification explicitement pour la mettre à
# l'épreuve.
return None
submitted = request.headers.get("X-CSRFToken") or request.form.get("csrf_token")
expected = session.get("csrf_token")
if not expected or not submitted or submitted != expected:
abort(400, description="Jeton de sécurité manquant ou expiré — recharge la page et réessaie.")
return None
+25
View File
@@ -1,4 +1,5 @@
import os
import secrets
from flask import Flask
@@ -7,3 +8,27 @@ _TEMPLATE_FOLDER = os.path.join(_BASE_DIR, "templates")
_STATIC_FOLDER = os.path.join(_BASE_DIR, "static")
app = Flask(__name__, template_folder=_TEMPLATE_FOLDER, static_folder=_STATIC_FOLDER)
def _load_or_create_secret_key():
"""Nécessaire dès qu'une session Flask (flask.session) est utilisée —
ici pour se souvenir de qui est connecté (auth/). Généré une seule
fois et PERSISTÉ dans un fichier (jamais en dur dans le code, jamais
commité — voir .gitignore, data/) : sans ça, chaque redémarrage du
serveur changerait la clé et déconnecterait tout le monde. Le chemin
est overridable (FORGE_SECRET_KEY_PATH) pour que les tests utilisent
un fichier temporaire, jamais celui du poste réel."""
path = os.environ.get("FORGE_SECRET_KEY_PATH") or os.path.join(_BASE_DIR, "data", "secret_key")
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
key = f.read().strip()
if key:
return key
key = secrets.token_hex(32)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(key)
return key
app.secret_key = _load_or_create_secret_key()
+15
View File
@@ -0,0 +1,15 @@
"""Affiche UNE SEULE FOIS les codes de récupération 2FA (voir
auth/recovery_codes.py et routes/auth/register_2fa.py) : posés en session
au moment de la redirection qui suit leur génération, puis consommés
(session.pop) dès le tout premier rendu de base.html qui suit — jamais
revus après ce rendu, comme un message flash à usage unique."""
from flask import session
from .flask_app import app
def pop_recovery_codes():
return session.pop("recovery_codes_to_show", None)
app.jinja_env.globals["pop_recovery_codes"] = pop_recovery_codes
+9 -3
View File
@@ -5,10 +5,16 @@ from ..game_dir import game_dir
from ..slugify import slugify
def create_game(name):
def create_game(name, slug_override=None):
"""Feature 1 : crée le dossier du jeu, ses fichiers index.html/css/js
reliés entre eux, et sa base de données dédiée (nom du jeu en méta)."""
slug = slugify(name)
reliés entre eux, et sa base de données dédiée (nom du jeu en méta).
slug_override : impose le nom du dossier au lieu de le dériver de
`name` — utilisé par l'inscription (auth/) pour que le dossier d'un
compte porte son adresse email (slugifiée), indépendamment du nom
donné à son jeu. Toujours rendu unique de la même façon (suffixe
-2/-3...) si, par un hasard extrême, le dossier existe déjà."""
slug = slugify(slug_override) if slug_override else slugify(name)
base = slug
n = 2
while os.path.isdir(game_dir(base)):
+10
View File
@@ -6,6 +6,16 @@ services:
- "${PORT:-5050}:5050"
volumes:
- forge_projects:/app/projects
environment:
# Réinitialisation de mot de passe par email (voir auth/send_email.py)
# — laissés vides ici tant qu'ils ne sont pas définis dans le .env du
# serveur : l'appli continue de fonctionner sans, l'envoi d'email
# échoue juste silencieusement côté utilisateur (logué côté serveur).
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
volumes:
forge_projects:
+38 -16
View File
@@ -20,25 +20,47 @@ def _element_style(el):
Bug corrigé : le widget "superposition" (render_overlay.py) ignore déjà
x/y/width/height et pose lui-même position:fixed; inset:0; z-index:9999
sur SA PROPRE balise — mais tant que CE cadre-ci gardait quand même
"position:absolute; z-index:{z_index}" (le z-index de sa place dans le
canevas, souvent petit), il devenait un élément positionné avec z-index
explicite, donc un NOUVEAU contexte d'empilement CSS — le z-index:9999
posé plus profond ne se comparait alors plus qu'AU SEIN de ce contexte,
et perdait face au z-index (plus grand) d'un frère ajouté après lui sur
le canevas, qui se retrouvait affiché PAR-DESSUS le dialogue censé tout
couvrir. En ne posant ICI aucune position/z-index pour ce widget (sa
place dans le flux normal, invisible puisque son contenu est en
position:fixed de toute façon), plus aucun contexte d'empilement n'est
créé à ce niveau : le z-index:9999 se compare alors directement aux
autres éléments de l'écran, et gagne toujours."""
if el.get("widget") == "superposition":
return "position:static;"
return "; ".join([
"z-index:{z_index}" (le z-index de sa place dans le canevas, souvent
petit), un élément positionné avec un z-index EXPLICITE (même sur ce
cadre, pas sur son contenu) devient un NOUVEAU contexte d'empilement
CSS — le z-index:9999 posé plus profond ne se comparait alors plus
qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un
frère ajouté après lui sur le canevas, qui se retrouvait affiché
PAR-DESSUS le dialogue censé tout couvrir.
Première correction tentée (retirer aussi position/left/top/width/
height ici) : régression dans l'ÉDITEUR — ce même cadre (.canvasElement)
sert aussi de prise pour glisser-déposer/redimensionner CE widget dans
le canevas (voir screen_edit.html), dont le calcul se base sur ses
dimensions réelles ; sans position:absolute + left/top/width/height, ce
cadre s'effondre à 0×0 (son contenu réel est en position:fixed, donc
hors flux), le calcul division par zéro produit NaN, et
JSON.stringify(NaN) donne "null" — element_geometry.py plantait alors
sur float(None). left/top/width/height restent donc posés comme pour
n'importe quel widget (l'éditeur continue de fonctionner normalement) :
SEUL le z-index est omis pour ce widget précis. position:absolute SANS
z-index explicite (donc z-index:auto) ne crée PAS de nouveau contexte
d'empilement — le z-index:9999 posé plus profond se compare alors
directement aux autres éléments de l'écran, et gagne toujours.
Même omission pour un exemplaire (element_type_id) d'un élément de jeu
dont le modèle N'EST QU'une superposition (marqué "_overlay_only_instance"
par list_elements.py) : son propre widget est "conteneur" par défaut
(add_element.py), pas "superposition", mais son cadre entoure quand
même une superposition rendue à l'intérieur (voir
render_element_html.py/_render_element_type_children) — exactement le
même risque de contexte d'empilement piégeant."""
exempt_from_z_index = (
el.get("widget") == "superposition" or el.get("_overlay_only_instance")
)
parts = [
"position:absolute",
f"left:{el['x']}%", f"top:{el['y']}%",
f"width:{el['width']}%", f"height:{el['height']}%",
f"z-index:{el['z_index']}",
]) + ";"
]
if not exempt_from_z_index:
parts.append(f"z-index:{el['z_index']}")
return "; ".join(parts) + ";"
def _element_transform_style(el):
+2
View File
@@ -1 +1,3 @@
Flask==3.0.3
pyotp==2.10.0
qrcode==8.2
+1 -1
View File
@@ -1 +1 @@
from . import games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
from . import auth, games, objects, screens, elements, legacy_actions, flow, element_types, uploads, play, animations, global_vars
+1
View File
@@ -0,0 +1 @@
from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password, profile
+29
View File
@@ -0,0 +1,29 @@
from flask import current_app, redirect, render_template, request, session, url_for
import auth
from core.flask_app import app
@app.route("/forgot-password", methods=["GET", "POST"])
def forgot_password():
if session.get("user_id"):
return redirect(url_for("index"))
sent = False
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
user = auth.get_user_by_email(email)
if user:
token = auth.create_password_reset_token(user["id"])
reset_url = url_for("reset_password", token=token, _external=True)
try:
auth.send_password_reset_email(user["email"], reset_url)
except auth.EmailNotConfiguredError:
# Jamais révélé à l'utilisateur (voir le message générique
# ci-dessous) : seul l'admin qui lit les logs serveur doit
# savoir que le SMTP n'est pas encore configuré.
current_app.logger.exception("Email de réinitialisation non envoyé : SMTP non configuré.")
# Même message que le compte existe ou non, pour ne jamais
# permettre de deviner quelles adresses sont déjà inscrites.
sent = True
return render_template("auth/forgot_password.html", sent=sent)
+43
View File
@@ -0,0 +1,43 @@
from flask import redirect, render_template, request, session, url_for
import auth
from core.flask_app import app
@app.route("/login", methods=["GET", "POST"])
def login():
if session.get("user_id"):
return redirect(url_for("index"))
error = None
email = ""
next_url = request.values.get("next") or ""
if not (next_url.startswith("/") and not next_url.startswith("//")):
next_url = ""
if request.method == "POST":
email = request.form.get("email", "").strip()
password = request.form.get("password", "")
next_url = request.form.get("next") or next_url
user = auth.get_user_by_email(email)
# Anti-bruteforce (voir auth/rate_limit.py) : vérifié AVANT le mot
# de passe lui-même — un compte verrouillé ne doit même pas
# apprendre si le mot de passe tapé était le bon.
locked_seconds = auth.seconds_locked_remaining(user) if user else 0
if locked_seconds:
error = auth.lockout_message(locked_seconds)
elif not user or not auth.verify_password(user, password):
if user:
just_locked_minutes = auth.record_failed_attempt(user["id"])
if just_locked_minutes:
error = auth.lockout_message(just_locked_minutes * 60)
if not error:
error = "Adresse email ou mot de passe incorrect."
else:
# Mot de passe correct, mais pas encore connecté : le second
# facteur (TOTP) reste à vérifier — voir login_2fa.py. Session
# "en attente" distincte de "user_id" (la vraie connexion),
# pour qu'un mot de passe seul ne suffise jamais à se connecter.
session["pending_login_user_id"] = user["id"]
session["pending_login_next"] = next_url
return redirect(url_for("login_2fa"))
return render_template("auth/login.html", error=error, email=email, next_url=next_url)
+56
View File
@@ -0,0 +1,56 @@
from flask import redirect, render_template, request, session, url_for
import auth
from core.flask_app import app
@app.route("/login/2fa", methods=["GET", "POST"])
def login_2fa():
user_id = session.get("pending_login_user_id")
if not user_id:
return redirect(url_for("login"))
user = auth.get_user_by_id(user_id)
if not user:
session.pop("pending_login_user_id", None)
return redirect(url_for("login"))
error = None
if request.method == "POST":
locked_seconds = auth.seconds_locked_remaining(user)
# Un code de récupération (format "xxxx-xxxx-xxxx") est accepté à la
# place du code TOTP habituel — utile si le téléphone est perdu/hors
# service (voir auth/recovery_codes.py). verify_totp est essayé en
# premier : verify_recovery_code CONSOMME le code dès qu'il
# correspond, on ne veut donc pas en griller un pour rien sur une
# saisie qui aurait en fait été un TOTP valide.
code = request.form.get("code", "")
valid = not locked_seconds and (
auth.verify_totp(user["totp_secret"], code) or auth.verify_recovery_code(user_id, code)
)
if locked_seconds:
error = auth.lockout_message(locked_seconds)
elif not valid:
just_locked_minutes = auth.record_failed_attempt(user_id)
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else "Code invalide."
else:
# Connexion réellement aboutie (mot de passe ET code corrects) :
# seul ce point remet le compteur d'échecs à zéro — pas le
# succès du mot de passe seul (voir login.py), sinon un
# attaquant qui connaît déjà le mot de passe aurait un nombre
# illimité d'essais sur le code 2FA.
auth.reset_failed_attempts(user_id)
next_url = session.pop("pending_login_next", "") or ""
session.pop("pending_login_user_id", None)
session["user_id"] = user_id
# "/" seul (chemin relatif interne) accepté, jamais une URL
# absolue/protocole-relative ("//evil.com") — sans ce garde,
# "next" (posé par core/auth_guard.py au moment d'une
# redirection vers /login) serait une ouverture classique de
# redirection ouverte (open redirect).
if next_url.startswith("/") and not next_url.startswith("//"):
return redirect(next_url)
if user["role"] == "admin":
return redirect(url_for("index"))
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
return render_template("auth/login_2fa.html", error=error)
+9
View File
@@ -0,0 +1,9 @@
from flask import redirect, session, url_for
from core.flask_app import app
@app.route("/logout", methods=["POST"])
def logout():
session.clear()
return redirect(url_for("login"))
+77
View File
@@ -0,0 +1,77 @@
import os
import shutil
from flask import g, redirect, render_template, request, session, url_for
import auth
import db
from core.flask_app import app
def _render(error=None, success=None):
user = auth.get_user_by_id(g.current_user["id"])
return render_template(
"auth/profile.html", user=user, error=error, success=success,
min_score_required=auth.MIN_SCORE_REQUIRED,
)
@app.route("/profile", methods=["GET"])
def profile():
return _render()
@app.route("/profile/update-name", methods=["POST"])
def profile_update_name():
nom = request.form.get("nom", "").strip()
prenom = request.form.get("prenom", "").strip()
if not nom or not prenom:
return _render(error="Nom et prénom sont requis.")
auth.update_profile(g.current_user["id"], nom, prenom)
return _render(success="Informations mises à jour.")
@app.route("/profile/change-password", methods=["POST"])
def profile_change_password():
user = g.current_user
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
new_password_confirm = request.form.get("new_password_confirm", "")
if not auth.verify_password(user, current_password):
return _render(error="Mot de passe actuel incorrect.")
if new_password != new_password_confirm:
return _render(error="Les nouveaux mots de passe ne correspondent pas.")
if not auth.password_strength(new_password)["valid"]:
return _render(error="Nouveau mot de passe trop faible — respecte les critères affichés.")
auth.set_password(user["id"], new_password)
return _render(success="Mot de passe modifié.")
@app.route("/profile/delete", methods=["POST"])
def profile_delete():
user = g.current_user
current_password = request.form.get("current_password", "")
confirm_text = request.form.get("confirm_text", "").strip().upper()
if not auth.verify_password(user, current_password):
return _render(error="Mot de passe incorrect — suppression annulée.")
if confirm_text != "SUPPRIMER":
return _render(error='Tape "SUPPRIMER" pour confirmer — suppression annulée.')
if user["role"] == "admin" and auth.count_admins() <= 1:
return _render(error="Impossible de supprimer l'unique compte administrateur.")
# Un compte "user" ne possède qu'UN SEUL projet, créé automatiquement
# pour lui et impossible à supprimer autrement (voir core/auth_guard.py,
# game_delete bloqué pour ce rôle) — supprimer le compte est donc la
# seule façon de s'en débarrasser aussi, sans quoi il resterait orphelin
# sur le disque sans plus aucun propriétaire. Un "admin" peut posséder
# plusieurs jeux qui ne lui sont pas dédiés de la même façon : ses
# projets restent intacts.
if user["role"] != "admin" and user.get("project_slug"):
game_dir = db.game_dir(user["project_slug"])
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
auth.delete_user(user["id"])
session.clear()
return redirect(url_for("login"))
+36
View File
@@ -0,0 +1,36 @@
from flask import redirect, render_template, request, session, url_for
import auth
from core.flask_app import app
@app.route("/register", methods=["GET", "POST"])
def register():
error = None
form = {"email": "", "nom": "", "prenom": ""}
if request.method == "POST":
form["email"] = request.form.get("email", "").strip()
form["nom"] = request.form.get("nom", "").strip()
form["prenom"] = request.form.get("prenom", "").strip()
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
if password != password_confirm:
error = "Les deux mots de passe ne correspondent pas."
else:
try:
user_id = auth.create_user(form["email"], password, form["nom"], form["prenom"])
except auth.UserCreationError as exc:
error = str(exc)
else:
# Le compte existe mais n'est pas encore utilisable (2FA
# pas encore confirmée, voir create_user.py) — une session
# "en attente" DISTINCTE de la vraie connexion (session
# "user_id"), pour qu'un compte jamais confirmé ne puisse
# jamais se retrouver connecté malgré lui.
session["pending_register_user_id"] = user_id
return redirect(url_for("register_2fa"))
return render_template(
"auth/register.html", error=error, form=form,
min_score_required=auth.MIN_SCORE_REQUIRED,
)
+59
View File
@@ -0,0 +1,59 @@
from flask import redirect, render_template, request, session, url_for
import auth
import db
from core.flask_app import app
@app.route("/register/2fa", methods=["GET", "POST"])
def register_2fa():
user_id = session.get("pending_register_user_id")
if not user_id:
return redirect(url_for("register"))
user = auth.get_user_by_id(user_id)
if not user:
session.pop("pending_register_user_id", None)
return redirect(url_for("register"))
error = None
if request.method == "POST":
locked_seconds = auth.seconds_locked_remaining(user)
if locked_seconds:
error = auth.lockout_message(locked_seconds)
elif not auth.verify_totp(user["totp_secret"], request.form.get("code", "")):
just_locked_minutes = auth.record_failed_attempt(user_id)
error = auth.lockout_message(just_locked_minutes * 60) if just_locked_minutes else \
"Code invalide — vérifie l'heure de ton téléphone et réessaie."
else:
auth.reset_failed_attempts(user_id)
auth.confirm_totp(user_id)
# Un compte "user" (pas "admin") reçoit ICI son unique projet,
# nommé d'après son adresse email (voir create_user.py — le
# rôle est déjà tranché à la création : premier compte jamais
# créé = admin, tous les suivants = user) — un admin, lui,
# reste libre de créer ses jeux comme avant ("+ Nouveau jeu"),
# aucun projet ne lui est imposé d'office.
if user["role"] != "admin":
slug = db.create_game(f"Jeu de {user['prenom']}", slug_override=user["email"])
auth.set_project_slug(user_id, slug)
user = auth.get_user_by_id(user_id)
session.pop("pending_register_user_id", None)
session["user_id"] = user_id
# Générés ici, une seule fois, à l'instant même où la 2FA
# devient active (voir auth/recovery_codes.py) : c'est la SEULE
# occasion où ils sont visibles en clair (seul leur hash reste
# en base) — posés en session pour être affichés une unique
# fois par la page suivante (voir core/recovery_codes_flash.py
# et le modal dans base.html), plutôt que d'interrompre ici la
# redirection habituelle vers le tableau de bord/l'accueil.
session["recovery_codes_to_show"] = auth.generate_recovery_codes(user_id)
if user["role"] == "admin":
return redirect(url_for("index"))
return redirect(url_for("game_dashboard", slug=user["project_slug"]))
qr_svg = auth.totp_qrcode_svg(user["totp_secret"], user["email"])
return render_template(
"auth/register_2fa.html", error=error, qr_svg=qr_svg,
secret=user["totp_secret"], email=user["email"],
)
+29
View File
@@ -0,0 +1,29 @@
from flask import redirect, render_template, request, url_for
import auth
from core.flask_app import app
@app.route("/reset-password/<token>", methods=["GET", "POST"])
def reset_password(token):
user_id = auth.get_user_id_for_valid_token(token)
if not user_id:
return render_template("auth/reset_password.html", invalid=True)
error = None
if request.method == "POST":
password = request.form.get("password", "")
password_confirm = request.form.get("password_confirm", "")
if password != password_confirm:
error = "Les mots de passe ne correspondent pas."
elif not auth.password_strength(password)["valid"]:
error = "Mot de passe trop faible — respecte les critères affichés."
else:
auth.set_password(user_id, password)
auth.consume_password_reset_token(token)
return redirect(url_for("login"))
return render_template(
"auth/reset_password.html", invalid=False, error=error,
min_score_required=auth.MIN_SCORE_REQUIRED,
)
+45
View File
@@ -0,0 +1,45 @@
import json
# Partagé par control_value.py (widgets) et filter_repeater_rows.py
# (rendering) — module "feuille" sans import de l'un ou l'autre, pour ne
# jamais introduire de dépendance circulaire entre ces deux packages.
def decode_clauses(attrs, clauses_attr, combinator_attr, legacy_prefix=None):
"""Lit une liste de clauses {"champ", "operateur", "valeur"} depuis un
attribut JSON (clauses_attr). Repli sur l'ANCIEN format à 2 filtres
fixes (legacy_prefix + "_champ"/"_operateur"/"_valeur", et
legacy_prefix + "2_champ"/"2_operateur"/"2_valeur") si clauses_attr est
absent — pour ne rien casser sur les éléments créés avant l'ajout des
clauses illimitées (aucune migration de données nécessaire, la
conversion se fait à la volée, ici, à la lecture). Renvoie toujours
(clauses, combinateur), combinateur valant "et" par défaut."""
raw = attrs.get(clauses_attr)
clauses = None
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
clauses = parsed
except (TypeError, ValueError):
clauses = None
if clauses is None and legacy_prefix:
clauses = []
for suffix in ("", "2"):
champ = (attrs.get(f"{legacy_prefix}{suffix}_champ") or "").strip()
if champ:
clauses.append({
"champ": champ,
"operateur": attrs.get(f"{legacy_prefix}{suffix}_operateur") or "egal",
"valeur": attrs.get(f"{legacy_prefix}{suffix}_valeur") or "",
})
combinateur = attrs.get(combinator_attr) or "et"
return clauses or [], combinateur
def encode_clauses(clauses):
"""Sérialise une liste de clauses en JSON, en retirant celles sans nom
de champ (une ligne ajoutée puis jamais remplie) — "" si la liste
nettoyée est vide, pour ne rien écrire de superflu en base."""
cleaned = [c for c in clauses if (c.get("champ") or "").strip()]
return json.dumps(cleaned) if cleaned else ""
+16
View File
@@ -0,0 +1,16 @@
from .load_template_tree import _load_template_tree
def _is_overlay_only_element_type(slug, element_type_id):
"""True si le modèle d'un élément de jeu N'EST QU'une "Superposition /
boîte de dialogue" (un seul élément de premier niveau, de ce widget) —
utilisé à deux endroits qui doivent s'accorder : render_element_html.py
(court-circuite l'enveloppe générique "conteneur" d'un exemplaire, qui
resterait sinon visible en permanence) et element_style_filter.py (le
cadre .canvasElement/.playElement de cet exemplaire ne doit pas non
plus poser de z-index, pour la même raison qu'une superposition posée
directement — voir son propre commentaire)."""
template_tree = _load_template_tree(slug, element_type_id)
if not template_tree:
return False
return {c.get("widget") for c in template_tree["top"]} == {"superposition"}
+10
View File
@@ -6,6 +6,7 @@ from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
@@ -39,6 +40,15 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non
# proprement) — hors couleurs, qui restent volontairement transparentes/
# sans bordure tant qu'on ne les choisit pas explicitement.
attributes, style = default_style_and_attributes(widget_key)
if element_type_id and _is_overlay_only_element_type(slug, int(element_type_id)):
# Un exemplaire dont le modèle N'EST QU'une "Superposition / boîte
# de dialogue" (render_element_html.py décide alors, au rendu, de
# faire porter le masquage à CET EXEMPLAIRE plutôt qu'au modèle) :
# démarre masqué par défaut, exactement comme une superposition
# posée directement (voir default_style_for_widget.py) — sinon un
# dialogue fraîchement déposé couvrirait tout l'écran en jeu dès
# sa pose, avant même qu'une action ne l'affiche.
style["display"] = "none"
content = meta.get("default_content", "")
width, height = meta.get("w", 30), meta.get("h", 10)
+10
View File
@@ -3,6 +3,7 @@ import json
import db
from ..rendering.render_element_html import render_element_html
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def list_elements(slug, screen_id, enforce_visibility=False):
@@ -75,5 +76,14 @@ def list_elements(slug, screen_id, enforce_visibility=False):
children_map.setdefault(d["parent_id"], []).append(d)
play_ctx = {"_forge_play_mode": True} if enforce_visibility else None
for d in result:
# Un exemplaire de premier niveau d'un élément de jeu dont le
# modèle N'EST QU'une superposition : son cadre de positionnement
# (.canvasElement/.playElement, voir filters/element_style_filter.py)
# ne doit pas non plus poser de z-index, pour la même raison qu'une
# superposition posée directement (sinon elle crée un contexte
# d'empilement CSS qui piège le z-index:9999 de la superposition
# rendue à l'intérieur — voir element_style_filter.py).
if d.get("element_type_id") and not d.get("parent_id"):
d["_overlay_only_instance"] = _is_overlay_only_element_type(slug, d["element_type_id"])
d["rendered_html"] = render_element_html(d, slug, children_map, play_ctx)
return result
+46
View File
@@ -3,6 +3,8 @@ import json
import db
from ..widgets.widget_meta import widget_meta
from ..widgets.layout_capable_widgets import LAYOUT_CAPABLE_WIDGETS
from ..clause_list_codec import encode_clauses
from .get_element import get_element
@@ -52,6 +54,32 @@ def save_element_controls(slug, element_id, form):
toggle_groups[name].add(control["token"])
continue
if ctype == "clause_list":
# Nombre ILLIMITÉ de lignes (voir c_clause_list.py) : un champ
# tableau par colonne ("field_name_champ[]"/"_operateur[]"/
# "_valeur[]"), reconstituées ici en une liste de clauses par
# position (zip) — même principe que field_name[]/field_type[]
# pour les champs d'un objet (routes/objects/parse_field_rows.py).
champs = form.getlist(field_name + "_champ[]")
operateurs = form.getlist(field_name + "_operateur[]")
valeurs = form.getlist(field_name + "_valeur[]")
clauses = [
{"champ": champ, "operateur": operateurs[i] if i < len(operateurs) else "egal",
"valeur": valeurs[i] if i < len(valeurs) else ""}
for i, champ in enumerate(champs)
]
encoded = encode_clauses(clauses)
if encoded:
attributes[control["clauses_attr"]] = encoded
else:
attributes.pop(control["clauses_attr"], None)
combinateur = form.get(field_name + "_combinateur") or "et"
if combinateur == "ou":
attributes[control["combinator_attr"]] = combinateur
else:
attributes.pop(control["combinator_attr"], None)
continue
if ctype == "preset":
chosen = form.get(field_name, control.get("default"))
opt = next((o for o in control["options"] if o[0] == chosen), None)
@@ -61,6 +89,24 @@ def save_element_controls(slug, element_id, form):
style.pop(k, None)
else:
style[k] = v
if (
control["key"] == "visibilite" and chosen == "visible"
and el["widget"] not in LAYOUT_CAPABLE_WIDGETS
):
# "Visible" ne touche volontairement pas "display" (voir
# visibility_control.py) : sur un widget qui A un
# réglage "Disposition interne" (LAYOUT_CONTROLS,
# traité avant celui-ci sur le MÊME enregistrement,
# voir widget_meta.py), ce contrôle réaffirme lui-même
# un "display" non-"none" (flex/etc), donc "Visible" n'a
# rien à faire. Mais un widget SANS ce contrôle (ex.
# "superposition") n'a PERSONNE d'autre pour l'effacer :
# un "display:none" laissé par un "Masqué" précédent (ou
# par le réglage par défaut à la création, voir
# default_style_for_widget.py) restait donc bloqué
# masqué pour toujours, quel que soit le nombre de fois
# où "Visible" était ensuite choisi.
style.pop("display", None)
continue
if ctype == "scale":
+4
View File
@@ -62,6 +62,10 @@ FLOW_NODE_FIELDS = {
# qui reste la première clause) + cond_combinator ('et'/'ou') pour savoir
# comment les combiner. Absents => comportement legacy (une seule clause).
"cond_clauses", "cond_combinator",
# Condition sur une VARIABLE GLOBALE plutôt qu'un champ d'objet — voir
# ensure_flow_schema.py pour le détail des 3 clés (cond_source vaut
# "objet" ou "variable" ; absent => "objet", comportement historique).
"cond_source", "cond_variable", "cond_variable_chemin",
"action_type", "target_screen_id", "target_element_id", "element_property", "element_value",
"target_definition_id", "target_row_id", "target_field", "data_operation", "data_value",
"target_variable",
+16
View File
@@ -56,5 +56,21 @@ def ensure_flow_schema(slug):
# cible une variable globale par NOM plutôt qu'un champ d'objet ;
# réutilise data_operation/data_value, déjà là pour "modifier_donnee".
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN target_variable TEXT")
if "cond_source" not in cols:
# Un nœud Condition peut désormais tester soit un champ d'objet
# (comportement historique, cond_source absent ou "objet"), soit
# une VARIABLE GLOBALE (cond_source="variable" — cond_variable :
# son nom ; cond_variable_chemin : chemin optionnel ".champ"/
# "[index]" si elle est de type objet/tableau, même syntaxe que
# "Chemin dans la variable" de la Condition de visibilité — voir
# visibility_condition_controls.py). Chaque clause de cond_clauses
# (JSON) porte les mêmes 3 clés en plus des siennes. Évalué côté
# CLIENT (templates/play.html, evaluateConditionClause) contre
# gameData.variables, exposé par full_game_payload.py.
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_source TEXT")
if "cond_variable" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable TEXT")
if "cond_variable_chemin" not in cols:
conn.execute("ALTER TABLE _flow_nodes ADD COLUMN cond_variable_chemin TEXT")
conn.commit()
conn.close()
+9
View File
@@ -67,7 +67,16 @@ def full_game_payload(slug):
# modèle est utilisé ailleurs.
element_types = {str(t["id"]): t["template_screen_id"] for t in list_element_types(slug)}
# Instantané des variables globales, pour qu'un nœud Condition puisse en
# tester une côté CLIENT (evaluateConditionClause, templates/play.html) —
# {{$var}} (Répéteur/Condition de visibilité), lui, reste résolu côté
# SERVEUR au rendu (filter_repeater_rows.py) et n'a jamais eu besoin de
# ça. refreshRuntimeData() récupère un payload entier (donc des
# variables à jour) après toute action qui en modifie une.
variables = {v["name"]: {"value": v["value"], "type": v["type"]} for v in db.list_global_variables(slug)}
return {
"screens": payload_screens, "flows": flows, "animations": animations,
"element_types": element_types, "data": data, "fields_meta": fields_meta,
"variables": variables,
}
+25 -13
View File
@@ -3,6 +3,8 @@ import re
import db
from ..clause_list_codec import decode_clauses
# {{NomDeLObjet.nom_du_champ}} : référence à un AUTRE objet de données, pour
# comparer le champ filtré à une valeur qui change en cours de partie (ex:
# le niveau atteint) plutôt qu'à une valeur fixe tapée en dur — même
@@ -154,13 +156,30 @@ def _compare(actual, operator, expected, field_type):
return False
def _apply_one_filter(slug, definition, rows, field_name, operator, raw_value):
def _clause_matches(slug, definition, row, clause):
field_name = (clause.get("champ") or "").strip()
if not field_name:
return rows
expected = _resolve_filter_value(slug, raw_value or "")
return True
operator = clause.get("operateur") or "egal"
expected = _resolve_filter_value(slug, clause.get("valeur") or "")
field_type = next((f["type"] for f in definition["fields"] if f["name"] == field_name), "texte")
col = _field_column(definition, field_name)
return [r for r in rows if _compare(r.get(col), operator, expected, field_type)]
return _compare(row.get(col), operator, expected, field_type)
def filter_rows_by_clauses(slug, definition, rows, clauses, combinator):
"""Ne garde, parmi les lignes, que celles qui satisfont la liste de
clauses données (un nombre quelconque), combinées entre elles par ET
(toutes vraies) ou par OU (au moins une vraie) selon `combinator`
("et"/"ou"). Utilisé aussi bien par le Répéteur de données (clauses
reconstruites depuis _filtre_champ/_filtre2_champ, voir
filter_repeater_rows ci-dessous) que par la "Donnée liée" d'un Texte/
Titre (clauses illimitées, voir resolve_bound_row.py)."""
if not clauses:
return rows
if combinator == "ou":
return [r for r in rows if any(_clause_matches(slug, definition, r, c) for c in clauses)]
return [r for r in rows if all(_clause_matches(slug, definition, r, c) for c in clauses)]
def filter_repeater_rows(slug, definition, rows, attrs):
@@ -172,12 +191,5 @@ def filter_repeater_rows(slug, definition, rows, attrs):
sur un écran unique") : c'est ce qui permet à un seul écran de n'afficher,
par exemple, que le niveau du parcours en cours (2 conditions : le bon
parcours ET le bon numéro de niveau) plutôt que tous les niveaux."""
rows = _apply_one_filter(
slug, definition, rows,
(attrs.get("_filtre_champ") or "").strip(), attrs.get("_filtre_operateur") or "egal", attrs.get("_filtre_valeur"),
)
rows = _apply_one_filter(
slug, definition, rows,
(attrs.get("_filtre2_champ") or "").strip(), attrs.get("_filtre2_operateur") or "egal", attrs.get("_filtre2_valeur"),
)
return rows
clauses, combinator = decode_clauses(attrs, "_filtres_json", "_filtre_combinateur", legacy_prefix="_filtre")
return filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
+81 -6
View File
@@ -18,6 +18,7 @@ from .render_icone import _render_icone
from .resolve_bound_row import _resolve_bound_row_ctx
from .visibility_condition import is_element_visible
from ..element_types.load_template_tree import _load_template_tree
from ..element_types.is_overlay_only import _is_overlay_only_element_type
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
@@ -51,8 +52,27 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
if play_mode and has_visibility_condition and not is_element_visible(slug, attrs_raw, ctx):
return f'<div class="visibilityGated" data-element-id="{el["id"]}" style="display:none;"></div>'
# "Donnée liée" (DATA_BINDING_CONTROLS, Texte/Titre) : son contenu
# ({{champ}}) dépend de la ligne trouvée par ses conditions au moment du
# rendu — potentiellement via une "Variable globale" en valeur de
# comparaison (voir _resolve_filter_value). Comme "visibilityGated"
# juste au-dessus, un marqueur ("dataBound") reste posé sur le HTML pour
# que refreshRuntimeData() (play.html) sache qu'il faut réévaluer cet
# élément après toute action "Modifier une donnée"/"Modifier une
# variable" — sans lui, changer la variable choisie changeait bien QUELLE
# ligne aurait dû s'afficher (recalculé correctement côté serveur à
# chaque rendu), mais le client ne redemandait jamais le nouveau HTML de
# CET élément précis : le texte affiché restait figé sur son ancienne
# ligne jusqu'au prochain rechargement complet de la page.
has_data_binding = bool(attrs_raw.get("_data_definition_id"))
def _mark(html_str):
return html_str + "<!--visibilityGated-->" if has_visibility_condition else html_str
markers = ""
if has_visibility_condition:
markers += "<!--visibilityGated-->"
if has_data_binding:
markers += "<!--dataBound-->"
return html_str + markers
bound_ctx = _resolve_bound_row_ctx(slug, attrs_raw)
if bound_ctx:
@@ -107,9 +127,44 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
return _mark(f"<{tag} {_attr_string(attrs, style)}>")
content = _apply_ctx(el.get("content") or "", ctx)
child_html = _render_element_type_children(el, slug, ctx) if el.get("element_type_id") else None
child_html, is_overlay_template = (
_render_element_type_children(el, slug, ctx) if el.get("element_type_id") else (None, False)
)
if child_html is None:
child_html = _render_children(el, slug, children_map, ctx)
elif is_overlay_template:
# Un exemplaire d'élément de jeu est posé par défaut avec le widget
# générique "conteneur" (add_element.py, "default_widget") — utile
# pour la plupart des modèles, mais QUAND le modèle entier n'est
# qu'une "Superposition / boîte de dialogue", la classe Bulma "box"
# de ce widget (widgets/registry.py "conteneur") lui donnait un
# fond opaque TOUJOURS VISIBLE à l'endroit où l'exemplaire a été
# déposé, alors que la superposition à l'intérieur gère déjà
# entièrement sa propre apparence et son propre masquage
# (position:fixed plein écran, démarre masquée) — vécu comme "un
# conteneur vide apparaît sur la scène, pas la boîte de dialogue"
# (en réalité la boîte de dialogue existe bien, juste masquée comme
# prévu ; c'est le conteneur AUTOUR qui n'aurait jamais dû avoir sa
# propre apparence visuelle).
#
# On ne court-circuite PLUS entièrement cette enveloppe (un essai
# précédent le faisait, en renvoyant `child_html` directement) :
# cette balise porte le SEUL id/data-element-id de CET EXEMPLAIRE
# précis (id=el["id"], distinct de l'id interne du modèle,
# ex. l'overlay du modèle "dialog" garde son propre id 72 alors que
# CET exemplaire est, par ex., 77) — une action "Modifier un
# élément" posée sur LA SCÈNE qui l'accueille (pas sur le modèle
# lui-même) cible cet id d'exemplaire, jamais celui, interne et
# partagé par tous les exemplaires, du modèle. La court-circuiter
# entièrement faisait disparaître cet id du DOM, rendant
# IMPOSSIBLE tout contrôle de la boîte de dialogue depuis la
# logique de la scène qui l'accueille ("j'ai aucun contrôle sur la
# visibilité de la modale depuis la scène"). On garde donc la
# balise (avec son id), mais SANS la classe "box" du widget
# générique — juste une balise transparente, invisible tant
# qu'elle ne porte ni fond ni bordure."""
attrs.pop("class", None)
return _mark(f"<div {_attr_string(attrs, style)}>{child_html}</div>")
if tag in ("ul", "ol"):
items = [line.strip() for line in content.split("\n") if line.strip()]
@@ -128,15 +183,35 @@ def _render_element_type_children(el, slug, ctx):
Répéteur (voir _render_repeater/_load_template_tree). Modifier
l'élément de jeu dans son propre éditeur met donc à jour tous ses
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
retoucher un par un. Renvoie None (pas "") si l'élément de jeu n'a
retoucher un par un. Renvoie (None, False) si l'élément de jeu n'a
plus de modèle valide, pour que l'appelant retombe sur le rendu
générique (d'éventuels enfants en base issus d'une version antérieure
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
vide."""
vide. Le second élément renvoyé indique si le modèle N'EST QUE une
superposition (voir l'appelant : dans ce cas précis, l'enveloppe
générique "conteneur" de l'exemplaire ne doit pas avoir sa propre
apparence visuelle)."""
template_tree = _load_template_tree(slug, el["element_type_id"])
if not template_tree:
return None
return "".join(
return None, False
is_overlay_only = _is_overlay_only_element_type(slug, el["element_type_id"])
if is_overlay_only:
# Le masquage de CET EXEMPLAIRE est entièrement délégué à
# l'enveloppe (voir l'appelant : add_element.py la fait démarrer
# masquée par défaut, exactement comme la superposition elle-même)
# — la superposition INTERNE au modèle ne doit donc plus appliquer
# SON PROPRE réglage "Visibilité" une fois posée comme exemplaire
# ailleurs (sinon double masquage : rendre l'enveloppe visible
# depuis la scène qui l'accueille resterait sans aucun effet
# visible tant que la superposition interne, elle, reste "Masqué"
# dans le modèle). On retire donc "display" de sa copie de style
# avant rendu (copie éphémère, jamais réenregistrée) — seul le
# display:flex de base (render_overlay.py) s'applique alors.
overlay_root = dict(template_tree["top"][0])
overlay_root["style"] = {k: v for k, v in overlay_root["style"].items() if k != "display"}
template_tree["top"][0] = overlay_root
html = "".join(
render_element_html(c, slug, template_tree["children_map"], ctx)
for c in template_tree["top"]
)
return html, is_overlay_only
+64 -23
View File
@@ -7,32 +7,61 @@ from .render_children import _render_children
def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_direction=None):
"""3.4 (Confort) — overlay/modale réutilisable : une boîte de dialogue
prête à l'emploi, par-dessus TOUT le reste de l'écran, fermeture
manuelle uniquement (aucun clic-en-dehors-pour-fermer volontairement,
conformément au manque documenté). Contrairement aux autres widgets, sa
position ne dépend PAS de x/y/width/height (glissé-déposé sur le
canevas) : `position:fixed; inset:0` la fait toujours couvrir tout
l'écran, quel que soit l'endroit où elle a été posée dans l'éditeur —
seul un voile semi-transparent + une boîte centrée, contenant les
éléments posés à l'intérieur (comme un conteneur normal).
prête à l'emploi, par-dessus TOUT le reste de l'écran EN MODE JOUABLE
UNIQUEMENT (`position:fixed; inset:0` + voile semi-transparent, ignore
x/y/width/height) — fermeture manuelle uniquement (aucun
clic-en-dehors-pour-fermer volontairement, conformément au manque
documenté).
Ouverture/fermeture : PAS de mécanisme dédié — elle réutilise l'action
existante "Modifier un élément → Visibilité" (masquer/rendre visible),
exactement comme n'importe quel autre élément. C'est pour ça qu'on
fusionne le style de positionnement fixe ci-dessous AVEC le style normal
de l'élément (_style_string) : si l'élément est réglé "Masqué" dans ses
propriétés, ou si une action "Modifier un élément" le cache plus tard,
ce masquage continue de fonctionner normalement."""
Dans l'ÉDITEUR (ctx sans "_forge_play_mode" — posé uniquement en mode
jouable, voir list_elements.py), ce widget se comporte comme un
CONTENEUR NORMAL : position/taille selon x/y/width/height comme
n'importe quel widget, respect normal de son réglage "Visibilité"
(masqué = invisible dans l'éditeur aussi, comme tout autre widget),
AUCUN voile plein écran. Deux essais précédents corrigés à partir des
retours utilisateur :
- Fond assombri visible pendant l'édition ("je veux que rien ne soit
assombri, l'assombrissement ne se fait que quand la scène est
jouée") : le voile (background:rgba(...)) ne fait donc plus partie
du style de base, il n'est ajouté qu'en mode jouable.
- Visibilité forcée en permanence dans l'éditeur ("je veux avoir la
main sur la visibilité, sinon elle s'affiche toujours sur la scène
et court-circuite ma logique") : le forçage display:flex a donc été
retiré — l'éditeur respecte de nouveau fidèlement le réglage
"Visibilité" (masqué par défaut à la création, pour ne plus couvrir
tout l'écran EN JEU dès qu'on la pose — sans plus aucun rapport avec
l'éditeur, qui ne couvre plus jamais rien).
Ouverture/fermeture (en JEU) : PAS de mécanisme dédié — elle réutilise
l'action existante "Modifier un élément → Visibilité" (masquer/rendre
visible), exactement comme n'importe quel autre élément.
Classes Bulma ("modal is-active" / "box") posées en PLUS du style
inline, jamais à sa place, et UNIQUEMENT en mode jouable (voir
ci-dessus) : tout le positionnement/masquage critique (position:fixed,
z-index, display) reste en inline, qui gagne toujours sur une règle de
classe — si Bulma (chargé depuis un CDN, voir play.html) ne se charge
pas (hors-ligne), la boîte de dialogue continue de fonctionner
exactement pareil. Pas de ".modal-background" séparé : le voile
semi-transparent est déjà posé en inline sur cette même balise
(background:rgba(...)) — un second calque tout aussi transparent
par-dessus n'ajouterait rien."""
is_play_mode = bool(ctx and ctx.get("_forge_play_mode"))
attrs_raw = el.get("attributes") or {}
box_color = attrs_raw.get("_couleur_boite") or "#1f2430"
radius = attrs_raw.get("_arrondi") or "12"
attrs = _visible_attrs(el, meta, ctx)
base_overlay_style = (
"position:fixed; inset:0; z-index:9999; display:flex; "
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);"
)
style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction)
if is_play_mode:
base_overlay_style = (
"position:fixed; inset:0; z-index:9999; display:flex; "
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);"
)
style = base_overlay_style + " " + _style_string(el, parent_flex_direction=parent_flex_direction)
wrapper_class = "modal is-active"
else:
style = _style_string(el, parent_flex_direction=parent_flex_direction)
wrapper_class = ""
attr_parts = [f'style="{html_lib.escape(style)}"']
for k, v in attrs.items():
@@ -49,11 +78,23 @@ def _render_overlay(el, meta, slug, children_map=None, ctx=None, parent_flex_dir
# bord à bord peu lisible comme dialogue — 560px reste une largeur
# de boîte de dialogue confortable, tout en retombant sur 90% sur un
# écran de jeu étroit (mobile/portrait) pour ne jamais déborder.
"padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;"
# color:#e8eaf0 : la classe Bulma ".box" (ajoutée ci-dessous) impose
# elle-même une couleur de texte SOMBRE (pensée pour un fond blanc
# par défaut) — comme aucun widget ne fige de couleur de texte à sa
# création (default_style_for_widget.py), un titre/texte posé dans
# la boîte SANS couleur personnalisée héritait de ce gris sombre
# imposé par Bulma, invisible sur le fond sombre par défaut de cette
# boîte de dialogue (texte "présent mais invisible", pas de bug côté
# utilisateur). On redonne donc ici une couleur claire par défaut,
# que Bulma ne peut plus écraser (élément le plus proche gagne) —
# un texte/titre qui personnalise sa propre couleur reste bien sûr
# prioritaire sur celle-ci.
"color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;"
)
child_html = _render_children(el, slug, children_map, ctx)
wrapper_class_attr = f'class="{wrapper_class}" ' if wrapper_class else ""
return (
f'<div {" ".join(attr_parts)}>'
f'<div class="forgeOverlayBox" style="{html_lib.escape(box_style)}">{child_html}</div>'
f'<div {wrapper_class_attr}{" ".join(attr_parts)}>'
f'<div class="box forgeOverlayBox" style="{html_lib.escape(box_style)}">{child_html}</div>'
f"</div>"
)
+9 -11
View File
@@ -1,13 +1,17 @@
import db
from .filter_repeater_rows import filter_repeater_rows, _field_column
from .filter_repeater_rows import filter_rows_by_clauses, _field_column
from ..clause_list_codec import decode_clauses
def _resolve_bound_row_ctx(slug, attrs):
"""Si cet élément est lié à un objet de données (attr:_data_definition_id —
réglage "Lier à un objet de données" des widgets Texte/Titre, voir
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux 1 ou 2
filtres réglés (_data_filtre*/_data_filtre2*) — même moteur que le
DATA_BINDING_CONTROLS), retrouve LA ligne qui correspond aux conditions
réglées (un nombre quelconque, combinées par ET/OU — attr:
_data_filtres_json/_data_filtre_combinateur, avec repli sur l'ancien
format à 2 filtres fixes _data_filtre*/_data_filtre2* pour les éléments
créés avant l'ajout des clauses illimitées) — même moteur que le
filtre du Répéteur de données, voir filter_repeater_rows.py — et
renvoie ses champs sous forme de {nom_du_champ: valeur en texte},
exploitable par {{champ}} dans le contenu. Permet d'afficher UNE seule
@@ -24,14 +28,8 @@ def _resolve_bound_row_ctx(slug, attrs):
if not definition:
return None
rows = db.list_rows(slug, definition)
rows = filter_repeater_rows(slug, definition, rows, {
"_filtre_champ": attrs.get("_data_filtre_champ"),
"_filtre_operateur": attrs.get("_data_filtre_operateur"),
"_filtre_valeur": attrs.get("_data_filtre_valeur"),
"_filtre2_champ": attrs.get("_data_filtre2_champ"),
"_filtre2_operateur": attrs.get("_data_filtre2_operateur"),
"_filtre2_valeur": attrs.get("_data_filtre2_valeur"),
})
clauses, combinator = decode_clauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", legacy_prefix="_data_filtre")
rows = filter_rows_by_clauses(slug, definition, rows, clauses, combinator)
if not rows:
return None
row = rows[0]
@@ -1,26 +1,20 @@
from ..controls.c_text import c_text
from ..controls.c_select import c_select
from ..controls.c_clause_list import c_clause_list
from ...flow.constants import CONDITION_OPERATORS
# Permet à un Texte/Titre d'afficher directement la valeur d'un champ d'un
# AUTRE objet de données, sans avoir besoin de le poser dans un Répéteur —
# utile pour une valeur unique calculée (ex: l'objectif du niveau en cours)
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux 1 ou 2
# filtres réglés ci-dessous (mêmes réglages, même moteur que ceux du
# Répéteur de données — voir filter_repeater_rows.py) ; sans filtre, c'est la
# ligne la plus récente de l'objet choisi (convention déjà utilisée par la
# Jauge pour un objet "état de partie" qui ne garde qu'une seule ligne).
# plutôt qu'une liste. La ligne affichée est celle qui correspond aux
# conditions réglées ci-dessous (un nombre quelconque, combinées par ET/OU —
# mêmes clauses, même moteur que le Répéteur de données, voir
# filter_repeater_rows.py) ; sans condition, c'est la ligne la plus récente
# de l'objet choisi (convention déjà utilisée par la Jauge pour un objet
# "état de partie" qui ne garde qu'une seule ligne).
DATA_BINDING_CONTROLS = [
c_select("data_definition_id", "Lier à un objet de données (pour {{champ}} ci-dessus)",
"attr:_data_definition_id", [("", "(sélectionner un objet)")], default="", group="Donnée liée"),
c_select("data_filtre_champ", "Trouver la ligne où ce champ",
"attr:_data_filtre_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre_operateur", "...est", "attr:_data_filtre_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre_valeur", "...cette valeur",
"attr:_data_filtre_valeur", placeholder="ex. 3", default="", group="Donnée liée"),
c_select("data_filtre2_champ", "ET (optionnel) : ce 2ᵉ champ",
"attr:_data_filtre2_champ", [("", "(sélectionner un objet d'abord)")], default="", group="Donnée liée"),
c_select("data_filtre2_operateur", "...est", "attr:_data_filtre2_operateur", CONDITION_OPERATORS, default="egal", group="Donnée liée"),
c_text("data_filtre2_valeur", "...cette valeur",
"attr:_data_filtre2_valeur", placeholder="ex. 1", default="", group="Donnée liée"),
c_clause_list("data_filtres", "Trouver la ligne où...",
clauses_attr="_data_filtres_json", combinator_attr="_data_filtre_combinateur",
operators=CONDITION_OPERATORS, legacy_prefix="_data_filtre", group="Donnée liée"),
]
+29 -1
View File
@@ -1,5 +1,7 @@
import re
from ..clause_list_codec import decode_clauses
def _control_value(el, control):
"""Lit la valeur actuelle d'un réglage à partir de l'élément stocké
@@ -7,8 +9,34 @@ def _control_value(el, control):
ctype = control["type"]
style = el.get("style") or {}
if ctype == "clause_list":
attrs = el.get("attributes") or {}
clauses, combinateur = decode_clauses(
attrs, control["clauses_attr"], control["combinator_attr"],
legacy_prefix=control.get("legacy_prefix"),
)
return {"clauses": clauses, "combinateur": combinateur}
if ctype == "preset":
for key, _label, props in control["options"]:
# Trié par nombre de propriétés NON VIDES exigées, décroissant : une
# option comme "Visible normalement" (visibility_control.py), dont
# les critères ({"visibility": ""}) ne portent QUE sur "visibility"
# (par choix assumé, pour ne jamais toucher "display" — voir son
# commentaire), matchait sinon TOUJOURS en premier dès que
# "visibility" est absent, MÊME quand "display:none" (posé par
# "Masqué", un critère plus spécifique à 1 propriété non vide contre
# 0 pour "Visible") est également présent -- le panneau affichait
# alors "Visible normalement" sélectionné sur un élément en réalité
# masqué, et comme un <select> ne redéclenche un enregistrement que
# sur un VRAI changement de valeur, il devenait impossible de le
# rendre visible depuis le panneau (choisir l'option déjà affichée
# ne fait rien). Les options plus spécifiques (plus de critères non
# vides) doivent donc toujours être vérifiées EN PREMIER.
options = sorted(
control["options"],
key=lambda o: -sum(1 for v in o[2].values() if v),
)
for key, _label, props in options:
if all((style.get(k) or "") == (v or "") for k, v in props.items()):
return key
return control.get("default")
+18
View File
@@ -0,0 +1,18 @@
def c_clause_list(key, label, clauses_attr, combinator_attr, operators, legacy_prefix=None, group=None):
"""Réglage "liste de conditions" — un nombre ILLIMITÉ de clauses
(champ/opérateur/valeur), combinées entre elles par ET ou par OU (voir
aussi les nœuds Condition du graphe de logique, qui ont le même besoin
— screens/flow/constants.py, cond_clauses/cond_combinator — même
principe, appliqué ici à la Donnée liée d'un Texte/Titre).
Volontairement PAS de "target" (contrairement aux autres réglages) :
stocké dans DEUX attributs séparés (clauses_attr : JSON de la liste ;
combinator_attr : "et"/"ou"), traités par un code dédié à chaque étape
(control_value.py, save_element_controls.py, controls_with_values.py,
templates/screen_edit.html) plutôt que par le chemin générique "un
réglage = une seule valeur = une seule clé de style/attribut"."""
return {
"type": "clause_list", "key": key, "label": label,
"clauses_attr": clauses_attr, "combinator_attr": combinator_attr,
"operators": operators, "legacy_prefix": legacy_prefix, "default": None, "group": group,
}
+6 -2
View File
@@ -78,8 +78,12 @@ def controls_with_values(slug, el, template_definition_id=None):
control["options"] = [("", empty_label)] + [(f["name"], _field_label(f)) for f in non_relation_fields]
if control["key"] in ("filtre_champ", "filtre2_champ"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in jauge_options["fields"]]
if control["key"] in ("data_filtre_champ", "data_filtre2_champ"):
control["options"] = [("", "(sélectionner un objet d'abord)")] + [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
if control["type"] == "clause_list":
# Options du sélecteur "champ" de CHAQUE ligne de condition —
# les mêmes champs que data_filtre_champ ci-dessus (même objet
# lié, attr:_data_definition_id), exposées une seule fois ici
# plutôt que dupliquées par ligne (nombre de lignes variable).
control["field_options"] = [(f["name"], _field_label(f)) for f in data_binding_options["fields"]]
if control["key"] == "content":
# Une fois "Lier à un objet de données" réglé (voir
# DATA_BINDING_CONTROLS), propose les champs de CET objet en
+31
View File
@@ -0,0 +1,31 @@
/*
* csrf_fetch.js — pose automatiquement le jeton CSRF (voir core/csrf.py/
* core/csrf_guard.py) sur CHAQUE fetch() non-GET de l'appli, sans avoir à
* modifier individuellement les nombreux appels fetch() déjà écrits à la
* main (screen_edit.html, game_dashboard.html, play.html...) ni les
* formulaires classiques (pjax.js les transforme lui-même en fetch(), voir
* son commentaire d'en-tête — cette rustine s'applique donc aussi à eux).
*
* window.CSRF_TOKEN est posé une fois par templates/base.html (valeur
* injectée par Jinja, voir core/csrf.py::get_csrf_token) — chargé AVANT ce
* script pour qu'il soit déjà disponible ici.
*/
(function () {
"use strict";
if (window.fetch.__forgeCsrfPatched) return; // rejoué par pjax.js (runScriptsIn) sans effet une 2e fois
var originalFetch = window.fetch;
function patchedFetch(input, init) {
init = init || {};
var method = (init.method || "GET").toUpperCase();
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
var headers = new Headers(init.headers || {});
if (!headers.has("X-CSRFToken") && window.CSRF_TOKEN) {
headers.set("X-CSRFToken", window.CSRF_TOKEN);
}
init = Object.assign({}, init, { headers: headers });
}
return originalFetch(input, init);
}
patchedFetch.__forgeCsrfPatched = true;
window.fetch = patchedFetch;
})();
+33
View File
@@ -70,6 +70,17 @@ button.danger{ color:var(--danger); border-color:rgba(226,104,95,.4); }
.fieldRowCard{ margin-bottom:10px; }
.relationTarget{ margin-top:6px; }
/* Liste de conditions de "Donnée liée" (voir c_clause_list.py) : un nombre
ILLIMITÉ de lignes peut s'y accumuler — sans limite de hauteur, le
panneau de propriétés entier deviendrait interminable à faire défiler
dès qu'on en pose plus de 3-4. max-height + overflow-y borne cette
LISTE elle-même à une hauteur confortable, le reste du panneau (et le
bouton "+ Ajouter une condition" juste en dessous) reste toujours à
portée sans avoir à chercher le bas de la liste. */
.clauseListWrap{ max-height:260px; overflow-y:auto; padding-right:2px; }
.clauseRow{ margin-bottom:8px; }
.clauseRow:last-child{ margin-bottom:0; }
/* .dataTable : Bulma fournit déjà la mise en page du tableau (classes
"table is-fullwidth is-hoverable", voir data_list.html) — ceci ne fait
plus que tronquer les cellules trop longues avec "...", pour garder une
@@ -558,3 +569,25 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
Tableau n'est qu'une liste de valeurs, voir openVarValuePanel() dans
game_dashboard.html. */
#varValuePanel[data-mode="tableau"] .varValueKeyField{ display:none; }
/* ---------- Inscription : schéma visuel du mot de passe ---------- */
/* Jauge + liste de critères mise à jour en direct (voir register.html) —
les mêmes 5 critères que auth/password_strength.py côté serveur. */
.passwordStrengthBar{ height:6px; border-radius:3px; background:var(--panel2); overflow:hidden; margin-top:8px; }
.passwordStrengthFill{ height:100%; width:0; border-radius:3px; background:var(--danger, #e05252); transition:width .15s ease, background-color .15s ease; }
.passwordStrengthFill.weak{ background:var(--danger, #e05252); }
.passwordStrengthFill.medium{ background:#e0a952; }
.passwordStrengthFill.good{ background:#d7d652; }
.passwordStrengthFill.strong{ background:#4caf50; }
.passwordChecklist{ list-style:none; margin:8px 0 0; padding:0; display:flex; flex-direction:column; gap:2px; }
.passwordChecklist li{ font-size:12.5px; color:var(--text-dim); padding-left:18px; position:relative; }
.passwordChecklist li::before{ content:"○"; position:absolute; left:0; }
.passwordChecklist li.ok{ color:#4caf50; }
.passwordChecklist li.ok::before{ content:"✓"; }
/* ---------- Inscription : QR code de la double authentification ---------- */
.totpQrWrap{ background:#fff; padding:16px; border-radius:8px; display:inline-block; max-width:220px; }
.totpQrWrap svg{ display:block; width:100%; height:auto; }
.recoveryCodesGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.recoveryCode{ display:block; text-align:center; padding:8px; background:#161616; border-radius:6px; letter-spacing:1px; }
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Mot de passe oublié — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Mot de passe oublié</h1>
{% if sent %}
<div class="notification is-success">Si un compte existe avec cette adresse, un email vient de t'être envoyé avec un lien pour choisir un nouveau mot de passe.</div>
<p class="hint mt-3"><a href="{{ url_for('login') }}">Retour à la connexion</a></p>
{% else %}
<p class="hint mb-4">Indique ton adresse email : si un compte y est associé, un lien de réinitialisation te sera envoyé.</p>
<form method="post" action="{{ url_for('forgot_password') }}" autocomplete="off">
<div class="field">
<label class="label">Adresse email</label>
<div class="control"><input class="input" type="email" name="email" required autofocus></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">Envoyer le lien</button>
</div>
</form>
<p class="hint mt-3"><a href="{{ url_for('login') }}">Retour à la connexion</a></p>
{% endif %}
</div>
</section>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}Connexion — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Connexion</h1>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('login') }}" autocomplete="off">
<input type="hidden" name="next" value="{{ next_url }}">
<div class="field">
<label class="label">Adresse email</label>
<div class="control"><input class="input" type="email" name="email" value="{{ email }}" required autofocus></div>
</div>
<div class="field">
<label class="label">Mot de passe</label>
<div class="control"><input class="input" type="password" name="password" required autocomplete="current-password"></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">Continuer</button>
</div>
</form>
<p class="hint mt-3"><a href="{{ url_for('forgot_password') }}">Mot de passe oublié ?</a></p>
<p class="hint mt-1">Pas encore de compte ? <a href="{{ url_for('register') }}">S'inscrire</a></p>
</div>
</section>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}Code de vérification — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:420px;">
<h1 class="title">Code de vérification</h1>
<p class="hint mb-4">Ouvre ton application d'authentification et saisis le code à 6 chiffres affiché, ou un de tes codes de récupération si tu n'y as plus accès.</p>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('login_2fa') }}">
<div class="field">
<label class="label">Code à 6 chiffres ou code de récupération</label>
<div class="control"><input class="input" type="text" name="code" autocomplete="one-time-code" required autofocus></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">Se connecter</button>
</div>
</form>
</div>
</section>
{% endblock %}
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Mon profil — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:560px;">
<h1 class="title">Mon profil</h1>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
{% endif %}
{% if success %}
<div class="notification is-success">{{ success }}</div>
{% endif %}
<div class="box">
<h2 class="title is-5">Informations</h2>
<p class="hint mb-4">Adresse email : <strong>{{ user.email }}</strong> ({{ 'compte administrateur' if user.role == 'admin' else 'compte utilisateur' }})</p>
<form method="post" action="{{ url_for('profile_update_name') }}">
<div class="field">
<label class="label">Prénom</label>
<div class="control"><input class="input" type="text" name="prenom" value="{{ user.prenom }}" required></div>
</div>
<div class="field">
<label class="label">Nom</label>
<div class="control"><input class="input" type="text" name="nom" value="{{ user.nom }}" required></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary">Enregistrer</button>
</div>
</form>
</div>
<div class="box">
<h2 class="title is-5">Changer de mot de passe</h2>
<form method="post" action="{{ url_for('profile_change_password') }}" autocomplete="off">
<div class="field">
<label class="label">Mot de passe actuel</label>
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div>
</div>
<div class="field">
<label class="label">Nouveau mot de passe</label>
<div class="control"><input class="input" type="password" id="password" name="new_password" required autocomplete="new-password"></div>
</div>
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist" id="pwChecklist">
<li data-check="longueur">Au moins 8 caractères</li>
<li data-check="majuscule">Une majuscule</li>
<li data-check="minuscule">Une minuscule</li>
<li data-check="chiffre">Un chiffre</li>
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
</ul>
</div>
<div class="field">
<label class="label">Confirmer le nouveau mot de passe</label>
<div class="control"><input class="input" type="password" name="new_password_confirm" required autocomplete="new-password"></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary">Changer le mot de passe</button>
</div>
</form>
</div>
<div class="box" style="border:1px solid #b33;">
<h2 class="title is-5">Supprimer mon compte</h2>
<p class="hint mb-4">
{% if user.role != 'admin' %}
Action définitive et irréversible : ton compte ET ton jeu ({{ user.project_slug }}) seront supprimés, sans possibilité de récupération.
{% else %}
Action définitive et irréversible : ton compte sera supprimé (tes jeux existants ne sont pas touchés).
{% endif %}
</p>
<form method="post" action="{{ url_for('profile_delete') }}" autocomplete="off">
<div class="field">
<label class="label">Mot de passe actuel</label>
<div class="control"><input class="input" type="password" name="current_password" required autocomplete="current-password"></div>
</div>
<div class="field">
<label class="label">Tape "SUPPRIMER" pour confirmer</label>
<div class="control"><input class="input" type="text" name="confirm_text" required></div>
</div>
<div class="field mt-4">
<button type="submit" class="button is-danger">Supprimer définitivement mon compte</button>
</div>
</form>
</div>
</div>
</section>
<script>
var MIN_SCORE_REQUIRED = {{ min_score_required }};
var PW_CHECKS = {
longueur: function (p) { return p.length >= 8; },
majuscule: function (p) { return /[A-Z]/.test(p); },
minuscule: function (p) { return /[a-z]/.test(p); },
chiffre: function (p) { return /\d/.test(p); },
special: function (p) { return /[^A-Za-z0-9]/.test(p); },
};
(function () {
var input = document.getElementById('password');
var fill = document.getElementById('pwFill');
var items = document.querySelectorAll('#pwChecklist li');
if (!input) return;
function update() {
var value = input.value || '';
var score = 0;
items.forEach(function (li) {
var ok = PW_CHECKS[li.dataset.check](value);
li.classList.toggle('ok', ok);
if (ok) score++;
});
var pct = (score / items.length) * 100;
fill.style.width = pct + '%';
fill.className = 'passwordStrengthFill' +
(score >= items.length ? ' strong' : score >= MIN_SCORE_REQUIRED ? ' good' : score >= 2 ? ' medium' : ' weak');
}
input.addEventListener('input', update);
update();
})();
</script>
{% endblock %}
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}Inscription — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:480px;">
<h1 class="title">Créer un compte</h1>
<p class="hint mb-4">Un compte = un seul projet de jeu (illimité pour un compte administrateur). La double authentification (2FA) est obligatoire — l'étape suivante te fera scanner un QR code avec une application comme Google Authenticator.</p>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
{% endif %}
<form method="post" action="{{ url_for('register') }}" autocomplete="off">
<div class="field">
<label class="label">Prénom</label>
<div class="control"><input class="input" type="text" name="prenom" value="{{ form.prenom }}" required></div>
</div>
<div class="field">
<label class="label">Nom</label>
<div class="control"><input class="input" type="text" name="nom" value="{{ form.nom }}" required></div>
</div>
<div class="field">
<label class="label">Adresse email</label>
<div class="control"><input class="input" type="email" name="email" value="{{ form.email }}" required autocomplete="off"></div>
</div>
<div class="field">
<label class="label">Mot de passe</label>
<div class="control"><input class="input" type="password" id="password" name="password" required autocomplete="new-password"></div>
</div>
<!-- Schéma visuel du mot de passe : une jauge + une liste de
critères qui passent au vert au fur et à mesure, mis à jour à
chaque frappe — jamais une simple règle écrite qu'il faut
deviner comment satisfaire. Les MÊMES règles sont vérifiées
côté serveur (auth/password_strength.py) : un mot de passe
refusé après soumission correspond toujours à une jauge déjà
rouge/incomplète ici, jamais une surprise. -->
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist" id="pwChecklist">
<li data-check="longueur">Au moins 8 caractères</li>
<li data-check="majuscule">Une majuscule</li>
<li data-check="minuscule">Une minuscule</li>
<li data-check="chiffre">Un chiffre</li>
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
</ul>
</div>
<div class="field">
<label class="label">Confirmer le mot de passe</label>
<div class="control"><input class="input" type="password" name="password_confirm" required autocomplete="new-password"></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">S'inscrire</button>
</div>
</form>
<p class="hint mt-3">Déjà un compte ? <a href="{{ url_for('login') }}">Se connecter</a></p>
</div>
</section>
<script>
var MIN_SCORE_REQUIRED = {{ min_score_required }};
var PW_CHECKS = {
longueur: function (p) { return p.length >= 8; },
majuscule: function (p) { return /[A-Z]/.test(p); },
minuscule: function (p) { return /[a-z]/.test(p); },
chiffre: function (p) { return /\d/.test(p); },
special: function (p) { return /[^A-Za-z0-9]/.test(p); },
};
(function () {
var input = document.getElementById('password');
var fill = document.getElementById('pwFill');
var items = document.querySelectorAll('#pwChecklist li');
if (!input) return;
function update() {
var value = input.value || '';
var score = 0;
items.forEach(function (li) {
var ok = PW_CHECKS[li.dataset.check](value);
li.classList.toggle('ok', ok);
if (ok) score++;
});
var pct = (score / items.length) * 100;
fill.style.width = pct + '%';
fill.className = 'passwordStrengthFill' +
(score >= items.length ? ' strong' : score >= MIN_SCORE_REQUIRED ? ' good' : score >= 2 ? ' medium' : ' weak');
}
input.addEventListener('input', update);
update();
})();
</script>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Activer la double authentification — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:480px;">
<h1 class="title">Active la double authentification</h1>
<p class="hint mb-4">Scanne ce QR code avec une application d'authentification (Google Authenticator, Authy...), puis tape le code à 6 chiffres qu'elle affiche pour confirmer.</p>
{% if error %}
<div class="notification is-danger">{{ error }}</div>
{% endif %}
<div class="totpQrWrap">{{ qr_svg|safe }}</div>
<p class="hint">Impossible de scanner ? Saisis cette clé manuellement dans l'application : <code>{{ secret }}</code></p>
<form method="post" action="{{ url_for('register_2fa') }}" class="mt-4">
<div class="field">
<label class="label">Code à 6 chiffres</label>
<div class="control"><input class="input" type="text" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required autofocus></div>
</div>
<div class="field">
<button type="submit" class="button primary is-fullwidth">Confirmer et activer mon compte</button>
</div>
</form>
</div>
</section>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}Nouveau mot de passe — Forge Engine{% endblock %}
{% block content %}
<section class="section">
<div class="container" style="max-width:480px;">
<h1 class="title">Choisir un nouveau mot de passe</h1>
{% if invalid %}
<div class="notification is-danger">Ce lien de réinitialisation est invalide ou a expiré.</div>
<p class="hint mt-3"><a href="{{ url_for('forgot_password') }}">Redemander un lien</a></p>
{% else %}
{% if error %}
<div class="notification is-danger">{{ error }}</div>
{% endif %}
<form method="post" autocomplete="off">
<div class="field">
<label class="label">Nouveau mot de passe</label>
<div class="control"><input class="input" type="password" id="password" name="password" required autocomplete="new-password"></div>
</div>
<div class="field">
<div class="passwordStrengthBar"><div class="passwordStrengthFill" id="pwFill"></div></div>
<ul class="passwordChecklist" id="pwChecklist">
<li data-check="longueur">Au moins 8 caractères</li>
<li data-check="majuscule">Une majuscule</li>
<li data-check="minuscule">Une minuscule</li>
<li data-check="chiffre">Un chiffre</li>
<li data-check="special">Un caractère spécial (ex. ! ? # -)</li>
</ul>
</div>
<div class="field">
<label class="label">Confirmer le mot de passe</label>
<div class="control"><input class="input" type="password" name="password_confirm" required autocomplete="new-password"></div>
</div>
<div class="field mt-4">
<button type="submit" class="button primary is-fullwidth">Réinitialiser mon mot de passe</button>
</div>
</form>
{% endif %}
</div>
</section>
{% if not invalid %}
<script>
var MIN_SCORE_REQUIRED = {{ min_score_required }};
var PW_CHECKS = {
longueur: function (p) { return p.length >= 8; },
majuscule: function (p) { return /[A-Z]/.test(p); },
minuscule: function (p) { return /[a-z]/.test(p); },
chiffre: function (p) { return /\d/.test(p); },
special: function (p) { return /[^A-Za-z0-9]/.test(p); },
};
(function () {
var input = document.getElementById('password');
var fill = document.getElementById('pwFill');
var items = document.querySelectorAll('#pwChecklist li');
if (!input) return;
function update() {
var value = input.value || '';
var score = 0;
items.forEach(function (li) {
var ok = PW_CHECKS[li.dataset.check](value);
li.classList.toggle('ok', ok);
if (ok) score++;
});
var pct = (score / items.length) * 100;
fill.style.width = pct + '%';
fill.className = 'passwordStrengthFill' +
(score >= items.length ? ' strong' : score >= MIN_SCORE_REQUIRED ? ' good' : score >= 2 ? ' medium' : ' weak');
}
input.addEventListener('input', update);
update();
})();
</script>
{% endif %}
{% endblock %}
+26
View File
@@ -6,6 +6,8 @@
<title>{% block title %}Forge Engine{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="{% block body_class %}{% endblock %}">
@@ -20,11 +22,35 @@
ne rechargeait pas la page en entier (Ctrl+F5), et inversement
laissée en place en revenant sur l'accueil (liste des jeux) où elle
n'a pourtant rien à faire. -->
{% set recovery_codes = pop_recovery_codes() %}
{% if recovery_codes %}
<div class="modal is-active" id="recoveryCodesModal">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
<h2 class="title is-4">Tes codes de récupération</h2>
<p class="hint mb-4">Note ces 10 codes en sécurité (gestionnaire de mots de passe, papier...) : chacun ne fonctionne qu'une seule fois et permet de te connecter si tu perds l'accès à ton application d'authentification. Ils ne seront plus jamais affichés.</p>
<div class="recoveryCodesGrid">
{% for code in recovery_codes %}
<code class="recoveryCode">{{ code }}</code>
{% endfor %}
</div>
<button type="button" class="button primary is-fullwidth mt-4" onclick="document.getElementById('recoveryCodesModal').remove()">J'ai noté mes codes</button>
</div>
</div>
</div>
{% endif %}
<div id="pageChrome">
<nav class="navbar topbar" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a href="{{ url_for('index') }}" class="navbar-item brand"><strong>🛠️ Forge Engine</strong></a>
</div>
{% if g.get('current_user') %}
<div class="navbar-end" style="margin-left:auto; display:flex; align-items:center; gap:10px; padding-right:12px;">
<a href="{{ url_for('profile') }}" class="hint" style="text-decoration:underline;">{{ g.current_user.prenom }} {{ g.current_user.nom }}{{ ' (admin)' if g.current_user.role == 'admin' else '' }}</a>
<form method="post" action="{{ url_for('logout') }}"><button type="submit" class="button is-small">Déconnexion</button></form>
</div>
{% endif %}
</nav>
{% if game is defined and game %}
<!-- Barre toujours visible entre les différentes pages d'UN MÊME jeu.
+79
View File
@@ -0,0 +1,79 @@
<!-- Une condition du réglage "Donnée liée" (voir screen_edit.html,
c.type == 'clause_list') — même présentation qu'un réglage normal du
panneau : un "Champ" et un "...est" clairement étiquetés, et le même
sélecteur de valeur fixe/dynamique/variable que partout ailleurs dans
l'outil (jamais la syntaxe "&#123;&#123; &#125;&#125;" brute à lire/taper). Inclus une
fois par condition existante, et une fois de plus (avec une clause
vide) dans le <template> cloné par "+ Ajouter une condition". -->
<div class="fieldRowCard box clauseRow">
<div class="field">
<label class="label is-small">Champ</label>
<div class="control">
<div class="select is-small is-fullwidth">
<select class="clauseChampSelect" name="ctrl_{{ c.key }}_champ[]">
<option value="">(sélectionner un objet d'abord)</option>
{% for fname, flabel in c.field_options %}
<option value="{{ fname }}" {{ 'selected' if clause.champ == fname else '' }}>{{ flabel }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="field">
<label class="label is-small">...est</label>
<div class="control">
<div class="select is-small is-fullwidth">
<select class="clauseOperateurSelect" name="ctrl_{{ c.key }}_operateur[]">
{% for opv, opl in c.operators %}
<option value="{{ opv }}" {{ 'selected' if clause.operateur == opv else '' }}>{{ opl }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="field">
<label class="label is-small">...cette valeur</label>
<div class="control filterValueControl" data-current-value="{{ clause.valeur or '' }}">
<div class="select is-small is-fullwidth">
<select class="filterValueMode" onchange="onFilterValueModeChange(this)">
<option value="fixe">Valeur fixe</option>
<option value="dynamique">Donnée d'un autre objet (change en jeu)</option>
<option value="variable">Variable globale (change en jeu)</option>
</select>
</div>
<div class="filterValueFixe" style="margin-top:6px;">
<input class="input is-small" type="text" name="ctrl_{{ c.key }}_valeur[]" value="{{ clause.valeur or '' }}" placeholder="ex. 3">
</div>
<div class="filterValueDynamique" style="display:none; margin-top:6px;">
<div class="select is-small is-fullwidth">
<select class="filterValueObjSel" onchange="onFilterValueObjChange(this)">
<option value="">— objet —</option>
{% for d in definitions %}
<option value="{{ d.name }}" data-definition-id="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="select is-small is-fullwidth" style="margin-top:6px;">
<select class="filterValueFieldSel" onchange="onFilterValueFieldChange(this)">
<option value="">— champ —</option>
</select>
</div>
</div>
<div class="filterValueVariable" style="display:none; margin-top:6px;">
<div class="select is-small is-fullwidth">
<select class="filterValueVarSel" onchange="onFilterValueVarChange(this)">
<option value="">— variable —</option>
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select>
</div>
<input class="input is-small filterValueVarPath" type="text" placeholder="Chemin optionnel (ex. .nom ou [0].valeur)" style="margin-top:6px;" oninput="updateFilterValueFromVariable(this.closest('.filterValueControl'))">
{% if not global_variables %}
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
{% endif %}
</div>
</div>
</div>
<button type="button" class="button is-small is-danger clauseRemoveBtn">🗑️ Retirer cette condition</button>
</div>
+102 -5
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ game.name }} — Jouer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script>window.CSRF_TOKEN = {{ csrf_token()|tojson }};</script>
<script src="{{ url_for('static', filename='csrf_fetch.js') }}"></script>
<link rel="stylesheet" href="{{ google_fonts_link }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"><style>
@@ -538,8 +540,9 @@
// le reste de la partie, sans le moindre message d'erreur visible.
try {
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
// à une donnée) et "visibilityGated" (condition de visibilité —
// voir render_element_html.py) sont les types d'éléments dont le
// à une donnée), "visibilityGated" (condition de visibilité) et
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
// render_element_html.py) sont les types d'éléments dont le
// contenu/l'affichage dépend de la donnée elle-même plutôt que
// d'une action "Modifier un élément" posée à la main dans le
// graphe — eux seuls doivent être régénérés après un changement
@@ -568,7 +571,12 @@
const byId = {};
screenData.elements.forEach(function(e){ byId[e.id] = e; });
function hasMarker(e) {
return !!(e.rendered_html && (e.rendered_html.indexOf('repeaterItem') !== -1 || e.rendered_html.indexOf('jaugeBar') !== -1 || e.rendered_html.indexOf('visibilityGated') !== -1));
return !!(e.rendered_html && (
e.rendered_html.indexOf('repeaterItem') !== -1 ||
e.rendered_html.indexOf('jaugeBar') !== -1 ||
e.rendered_html.indexOf('visibilityGated') !== -1 ||
e.rendered_html.indexOf('dataBound') !== -1
));
}
function isDescendantOf(e, ancestorId) {
let p = e.parent_id ? byId[e.parent_id] : null;
@@ -578,6 +586,31 @@
}
return false;
}
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
// HTML juste après la balise concernée (voir _mark() dans
// render_element_html.py) — jamais comme une classe CSS, à la
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
// comportement déjà correct pour ces deux-là). Repérer ce
// commentaire permet de retrouver, à l'intérieur d'un fragment
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
// est niché dans un exemplaire d'élément de jeu réutilisable
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
// dialogue), où lui seul porte l'attribut data-element-id qui
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
// de premier niveau existant (l'exemplaire) que parce que son
// HTML est imbriqué dans le sien.
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
function findCommentMarkedDescendants(root) {
const found = [];
root.querySelectorAll('[data-element-id]').forEach(function(node) {
const sib = node.nextSibling;
if (sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1) {
found.push(node);
}
});
return found;
}
const markedCandidates = screenData.elements.filter(hasMarker);
markedCandidates.filter(function(e) {
return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
@@ -587,7 +620,23 @@
const container = document.createElement('div');
container.innerHTML = el.rendered_html;
const newNode = container.firstElementChild;
if (newNode) target.replaceWith(newNode);
if (!newNode) return;
const innerMarked = findCommentMarkedDescendants(newNode);
if (innerMarked.length) {
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
// Texte "Donnée liée" posé dans un élément de jeu
// réutilisable) : on ne patche QUE ceux-là, en préservant
// tout le reste — une boîte de dialogue ne doit ni
// disparaître ni réapparaître juste parce qu'un texte à
// l'intérieur a changé.
innerMarked.forEach(function(innerNode) {
const innerId = innerNode.getAttribute('data-element-id');
const liveInner = target.querySelector('[data-element-id="' + innerId + '"]');
if (liveInner) liveInner.replaceWith(innerNode);
});
} else {
target.replaceWith(newNode);
}
});
applyOpenRowBindings();
} catch (err) {
@@ -835,13 +884,61 @@
}
}
// Lit la valeur ACTUELLE d'une variable globale (gameData.variables,
// exposé par full_game_payload.py, tenu à jour par refreshRuntimeData()
// après toute action qui en modifie une), pour l'évaluation d'une
// condition — équivalent, côté client, de _resolve_filter_value côté
// serveur (screens/rendering/filter_repeater_rows.py), mais SANS la
// syntaxe utilisée par le Répéteur/la Condition de visibilité (jamais
// nécessaire ici : le nom de la variable est choisi dans un menu
// déroulant, voir screen_edit.html).
function readVariableValue(name) {
const v = (gameData.variables || {})[name];
return v ? v.value : undefined;
}
// Navigue dans une valeur JSON (variable de type "objet"/"tableau")
// selon un chemin ".champ"/"[index]" chaînable — équivalent JS de
// _resolve_variable_path (même fichier Python que ci-dessus). Chemin
// vide -> valeur brute inchangée (le cas normal pour une variable
// scalaire). Ne lève jamais : JSON invalide ou chemin qui ne correspond
// à rien -> null, comme côté serveur.
function resolveVariablePath(rawValue, path) {
if (!path) return rawValue;
let current;
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
let m;
while ((m = segmentRe.exec(path)) !== null) {
if (current === null || current === undefined) return null;
current = m[1] !== undefined ? current[m[1]] : current[parseInt(m[2], 10)];
}
return current === undefined ? null : current;
}
// 2.4 — conditions combinées (ET/OU) : un nœud Condition peut porter une
// liste cond_clauses (JSON) en plus de sa clause historique. Un nœud sans
// cond_clauses (tous les nœuds créés avant 2.4, ou un nœud à une seule
// clause) garde EXACTEMENT son ancien comportement — une seule comparaison.
//
// Chaque clause peut tester soit un champ d'objet (source absente/"objet",
// comportement historique), soit une VARIABLE GLOBALE (source
// "variable" — voir ensure_flow_schema.py pour cond_source/cond_variable/
// cond_variable_chemin).
function evaluateConditionClause(clause) {
const source = clause.source ?? clause.cond_source ?? 'objet';
const operator = clause.operator ?? clause.cond_operator;
const expected = clause.value ?? clause.cond_value;
if (source === 'variable') {
const varName = clause.variable ?? clause.cond_variable;
const path = clause.variable_chemin ?? clause.cond_variable_chemin;
const varInfo = (gameData.variables || {})[varName];
const actual = resolveVariablePath(readVariableValue(varName), path);
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
return compareValues(actual, operator, expected, fieldType);
}
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
return compareValues(actual, clause.operator ?? clause.cond_operator, clause.value ?? clause.cond_value, clause.field_type ?? clause.cond_field_type);
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
}
function evaluateConditionNode(node) {
+241 -34
View File
@@ -219,16 +219,40 @@
</div>
<div id="nodeFormCondition" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
{% else %}
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
<label>Source<select id="nf-cond-source" onchange="onFlowConditionSourceChange()">
<option value="objet">Champ d'un objet</option>
<option value="variable">Variable globale</option>
</select></label>
<label>Ligne concernée<select id="nf-cond-row"></select></label>
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
<div id="nf-cond-objet-fields" {% if not definitions %}style="display:none;"{% endif %}>
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select></label>
<label>Ligne concernée<select id="nf-cond-row"></select></label>
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
</div>
{% if not definitions %}
<p class="hint" id="nf-cond-no-objet-hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='objects') }}">crées-en un</a>.</p>
{% endif %}
<div id="nf-cond-variable-fields" style="display:none;">
<label>Variable<select id="nf-cond-variable">
<option value="">(sélectionner une variable)</option>
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select></label>
<!-- Utile seulement si la variable choisie est de type
"objet"/"tableau" : sans chemin, c'est toute la variable
(son JSON) qui est comparée. -->
<label>Chemin (optionnel)<input type="text" id="nf-cond-variable-chemin" placeholder="ex. .nom ou [0].valeur"></label>
{% if not global_variables %}
<p class="hint">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
{% endif %}
</div>
<label>Condition<select id="nf-cond-operator">
{% for key, label in condition_operators %}
<option value="{{ key }}">{{ label }}</option>
@@ -246,7 +270,6 @@
<div id="nf-cond-extra-clauses"></div>
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
</div>
{% endif %}
</div>
<div id="nodeFormAction" style="display:none;">
@@ -770,6 +793,46 @@
oninput="this.nextElementSibling.textContent = 'x' + this.value">
<span class="sliderValue tag">x{{ c.value }}</span>
</div>
{% elif c.type == 'clause_list' %}
<!-- Liste de conditions ILLIMITÉE (champ/opérateur/valeur),
combinées par ET/OU — voir c_clause_list.py. Chaque bloc
reprend EXACTEMENT la présentation d'avant (un "Champ" et un
"...est" clairement étiquetés, et le même sélecteur de valeur
fixe/dynamique/variable — jamais la syntaxe "&#123;&#123; &#125;&#125;" brute à
lire/taper), simplement répété autant de fois que
nécessaire. Chaque ligne existante est rendue ici (repli
côté serveur, notamment après un rechargement de page) ;
"+ Ajouter une condition" clone le <template> ci-dessous en
JS pour en ajouter d'autres à la volée (voir addClauseRow()
plus bas) — initFilterValuePickers() (déjà utilisée par
l'ancien réglage à 2 filtres fixes, entièrement scopée par
classe CSS via .closest('.filterValueControl'), jamais par
id) fonctionne donc ici aussi tel quel, avec un nombre
quelconque de blocs "valeur" sur la même page. -->
<div class="clauseListControl" data-clause-key="{{ c.key }}">
<div class="clauseListWrap" id="clauseListWrap-{{ c.key }}">
{% for clause in c.value.clauses %}
{% include "partials/clause_row.html" with context %}
{% endfor %}
</div>
<button type="button" class="button primary is-fullwidth" style="margin-top:10px;" onclick="addClauseRow('{{ c.key }}')">+ Ajouter une condition</button>
<div class="field clauseCombinatorRow" id="clauseCombinatorRow-{{ c.key }}" style="margin-top:12px; {{ '' if c.value.clauses|length > 1 else 'display:none;' }}">
<label class="label is-small">Combiner ces conditions avec</label>
<div class="control">
<div class="select is-small is-fullwidth">
<select name="ctrl_{{ c.key }}_combinateur">
<option value="et" {{ 'selected' if c.value.combinateur != 'ou' else '' }}>ET — toutes les conditions doivent être vraies</option>
<option value="ou" {{ 'selected' if c.value.combinateur == 'ou' else '' }}>OU — au moins une condition suffit</option>
</select>
</div>
</div>
</div>
<p class="help">Aucune condition = la ligne la plus récente de cet objet.</p>
</div>
{% set clause = {"champ": "", "operateur": "egal", "valeur": ""} %}
<template id="clauseRowTemplate-{{ c.key }}">
{% include "partials/clause_row.html" with context %}
</template>
{% elif c.type == 'shadow' %}
<div class="control">
<div class="fieldRow">
@@ -1621,8 +1684,9 @@ function initBuilderPanel() {
bindPropsAutosave();
bindJaugeDefinitionSelect();
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
bindDefinitionFieldSelects('field-data_definition_id', ['field-data_filtre_champ', 'field-data_filtre2_champ']);
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
bindClauseListDefinitionSelect('field-data_definition_id');
bindClauseListRows();
initFilterValuePickers();
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
@@ -1701,6 +1765,88 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
});
}
// ---------- "Donnée liée" : liste de conditions ILLIMITÉE (champ/opérateur/
// valeur), combinées entre elles par ET/OU (voir c_clause_list.py,
// save_element_controls.py, resolve_bound_row.py). Chaque ligne existante
// est rendue côté serveur (au chargement du panneau) ; "+ Ajouter une
// condition" clone un <template> pour en ajouter de nouvelles à la volée,
// sans aller-retour serveur — même principe que "fieldsList"/"varValueRows"
// ailleurs dans l'outil. ----------
function wireClauseRow(row, key) {
row.querySelectorAll('input, select').forEach(function (field) {
var debounced = field.tagName === 'INPUT';
field.addEventListener(debounced ? 'input' : 'change', function () {
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
});
});
var removeBtn = row.querySelector('.clauseRemoveBtn');
if (removeBtn) {
removeBtn.addEventListener('click', function () {
row.remove();
updateClauseCombinatorVisibility(key);
submitPropsForm();
});
}
}
function updateClauseCombinatorVisibility(key) {
var wrap = document.getElementById('clauseListWrap-' + key);
var combRow = document.getElementById('clauseCombinatorRow-' + key);
if (!wrap || !combRow) return;
combRow.style.display = wrap.querySelectorAll('.clauseRow').length > 1 ? '' : 'none';
}
function addClauseRow(key) {
var tmpl = document.getElementById('clauseRowTemplate-' + key);
var wrap = document.getElementById('clauseListWrap-' + key);
if (!tmpl || !wrap) return;
var clone = tmpl.content.cloneNode(true);
var row = clone.querySelector('.clauseRow');
wireClauseRow(row, key);
wrap.appendChild(clone);
// Repeuple tout de suite son sélecteur "Champ" avec les VRAIS champs de
// l'objet déjà choisi (sans ça, une ligne ajoutée après coup resterait
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
// déjà réglé plus haut dans le panneau).
var defSel = document.getElementById('field-data_definition_id');
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
initFilterValuePickers();
updateClauseCombinatorVisibility(key);
submitPropsForm();
}
// Rebranche les lignes déjà rendues par le serveur (rechargement du
// panneau, ex. sélection d'un autre élément) — les lignes ajoutées EN
// COURS de session par addClauseRow() se branchent déjà elles-mêmes.
function bindClauseListRows() {
document.querySelectorAll('.clauseListControl').forEach(function (control) {
var key = control.dataset.clauseKey;
control.querySelectorAll('.clauseRow').forEach(function (row) { wireClauseRow(row, key); });
});
}
// Repeuple le sélecteur "champ" de TOUTES les lignes de condition actuelles
// (interrogées à chaque changement, donc couvre aussi les lignes ajoutées
// après coup) quand on change l'objet lié — même principe que
// bindDefinitionFieldSelects ci-dessus, généralisé pour un nombre de lignes
// variable plutôt qu'une liste fixe d'ids capturée une fois pour toutes.
function bindClauseListDefinitionSelect(defSelId) {
var defSel = document.getElementById(defSelId);
if (!defSel) return;
defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
document.querySelectorAll('.clauseChampSelect').forEach(function (sel) {
var current = sel.value;
sel.innerHTML = opts;
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
});
});
}
// ---------- Valeur de comparaison d'un filtre : fixe, ou donnée d'un autre
// objet ----------
//
@@ -1816,6 +1962,7 @@ initBuilderPanel();
var DEFINITIONS_DATA = {{ definitions_json|safe }};
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').innerHTML : '';
var GLOBAL_VARIABLES_OPTIONS_HTML = document.getElementById('nf-cond-variable') ? document.getElementById('nf-cond-variable').innerHTML : '';
function toggleActionFields(sel) {
var form = sel.closest('.actionAddForm');
@@ -1952,15 +2099,19 @@ function nodeLabel(node) {
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
}
if (node.node_type === 'condition') {
var mainClause = '❓ SI ' + (node.cond_field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || '');
var clauseSubject = function(c, field, variable) {
return (c.source ?? c.cond_source) === 'variable' ? ('🔧 ' + (variable || c.cond_variable || '?')) : (field || c.cond_field || '?');
};
var mainSubject = clauseSubject(node, node.cond_field, node.cond_variable);
var mainClause = '❓ SI ' + mainSubject + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || '');
if (node.cond_clauses) {
// 2.4 — plusieurs clauses combinées : on affiche chaque champ testé,
// relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
// 2.4 — plusieurs clauses combinées : on affiche chaque champ/variable
// testé, relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
try {
var clauses = JSON.parse(node.cond_clauses);
var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET ';
return '❓ SI ' + clauses.map(function(c) {
return (c.field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
return clauseSubject(c, c.field, c.variable) + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
}).join(combLabel);
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
}
@@ -2160,6 +2311,8 @@ function openNodeForm(type) {
document.getElementById('nodeFormAction').style.display = type === 'action' ? 'block' : 'none';
if (type === 'trigger') toggleFlowTriggerFields();
if (type === 'condition') {
document.getElementById('nf-cond-source').value = 'objet';
onFlowConditionSourceChange();
onFlowDefinitionChange('cond');
flowConditionExtraClauses = [];
renderFlowConditionClauses();
@@ -2217,6 +2370,19 @@ function onFlowFieldChange() {
// directement sur l'option sélectionnée au moment de la création du nœud.
}
// Bascule la clause PRINCIPALE d'un nœud Condition entre "Champ d'un
// objet" (historique) et "Variable globale" — voir ensure_flow_schema.py
// (cond_source) et evaluateConditionClause() dans play.html. Même bascule
// pour une clause SUPPLÉMENTAIRE : voir onFlowClauseSourceChange() plus
// bas, sur le même principe mais scopée par index.
function onFlowConditionSourceChange() {
var isVariable = document.getElementById('nf-cond-source').value === 'variable';
document.getElementById('nf-cond-objet-fields').style.display = isVariable ? 'none' : 'block';
document.getElementById('nf-cond-variable-fields').style.display = isVariable ? 'block' : 'none';
var hint = document.getElementById('nf-cond-no-objet-hint');
if (hint) hint.style.display = isVariable ? 'none' : 'block';
}
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
// Chaque clause supplémentaire réutilise EXACTEMENT le même modèle que la
// clause principale (nf-cond-definition/row/field/operator/value) mais dans
@@ -2226,7 +2392,10 @@ var flowConditionExtraClauses = [];
function addFlowConditionClause() {
var firstDefSel = document.getElementById('nf-cond-definition');
var defaultDefId = firstDefSel && firstDefSel.value ? firstDefSel.value : '';
flowConditionExtraClauses.push({ definition_id: defaultDefId, row_id: '', field: '', field_type: '', operator: 'egal', value: '' });
flowConditionExtraClauses.push({
source: 'objet', definition_id: defaultDefId, row_id: '', field: '', field_type: '',
variable: '', variable_chemin: '', operator: 'egal', value: '',
});
renderFlowConditionClauses();
}
@@ -2235,6 +2404,20 @@ function removeFlowConditionClause(idx) {
renderFlowConditionClauses();
}
// Bascule une clause SUPPLÉMENTAIRE (indexée) entre "Champ d'un objet" et
// "Variable globale" — même principe que onFlowConditionSourceChange() pour
// la clause principale, scopé par index plutôt que par id fixe.
function onFlowClauseSourceChange(idx) {
var sel = document.getElementById('nf-cond-extra-source-' + idx);
if (!sel) return;
flowConditionExtraClauses[idx].source = sel.value;
var isVariable = sel.value === 'variable';
var objetFields = document.getElementById('nf-cond-extra-objet-fields-' + idx);
var varFields = document.getElementById('nf-cond-extra-variable-fields-' + idx);
if (objetFields) objetFields.style.display = isVariable ? 'none' : 'block';
if (varFields) varFields.style.display = isVariable ? 'block' : 'none';
}
function renderFlowConditionClauses() {
var wrap = document.getElementById('nf-cond-extra-clauses');
var combinatorRow = document.getElementById('nf-cond-combinator-row');
@@ -2242,11 +2425,24 @@ function renderFlowConditionClauses() {
combinatorRow.style.display = flowConditionExtraClauses.length ? 'block' : 'none';
var defOptionsHtml = document.getElementById('nf-cond-definition') ? document.getElementById('nf-cond-definition').innerHTML : '';
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
var isVariable = clause.source === 'variable';
return '' +
'<div class="flowConditionClauseRow" style="border-top:1px dashed #ccc; margin-top:8px; padding-top:8px;">' +
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
'<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' +
'<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' +
'<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' +
'</select></label>' +
'<div id="nf-cond-extra-objet-fields-' + idx + '" style="display:' + (isVariable ? 'none' : 'block') + ';">' +
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
'</div>' +
'<div id="nf-cond-extra-variable-fields-' + idx + '" style="display:' + (isVariable ? 'block' : 'none') + ';">' +
'<label>Variable<select id="nf-cond-extra-variable-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].variable = this.value">' +
'<option value="">(sélectionner une variable)</option>' + GLOBAL_VARIABLES_OPTIONS_HTML +
'</select></label>' +
'<label>Chemin (optionnel)<input type="text" id="nf-cond-extra-variable-chemin-' + idx + '" placeholder="ex. .nom ou [0].valeur" value="' + (clause.variable_chemin || '').replace(/"/g, '&quot;') + '" onchange="flowConditionExtraClauses[' + idx + '].variable_chemin = this.value"></label>' +
'</div>' +
'<label>Condition<select id="nf-cond-extra-operator-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].operator = this.value">' +
CONDITION_OPERATORS_HTML +
'</select></label>' +
@@ -2260,6 +2456,8 @@ function renderFlowConditionClauses() {
onFlowClauseDefinitionChange(idx);
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
if (opSel) opSel.value = clause.operator || 'egal';
var varSel = document.getElementById('nf-cond-extra-variable-' + idx);
if (varSel && clause.variable) varSel.value = clause.variable;
});
}
@@ -2362,26 +2560,35 @@ function submitNodeForm() {
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
}
} else if (currentNodeFormType === 'condition') {
var fieldSel = document.getElementById('nf-cond-field');
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
payload.cond_field = fieldSel.value;
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
var condSource = document.getElementById('nf-cond-source').value;
payload.cond_source = condSource;
payload.cond_operator = document.getElementById('nf-cond-operator').value;
payload.cond_value = document.getElementById('nf-cond-value').value;
if (condSource === 'variable') {
payload.cond_variable = document.getElementById('nf-cond-variable').value;
payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value;
} else {
var fieldSel = document.getElementById('nf-cond-field');
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
payload.cond_field = fieldSel.value;
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
}
if (flowConditionExtraClauses.length) {
// 2.4 — clauses combinées : la clause "historique" ci-dessus devient
// la première clause de la liste, suivie des clauses supplémentaires.
var allClauses = [{
definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
field: payload.cond_field, field_type: payload.cond_field_type,
operator: payload.cond_operator, value: payload.cond_value
}].concat(flowConditionExtraClauses.map(function(c) {
return {
definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value
};
var mainClause = condSource === 'variable'
? { source: 'variable', variable: payload.cond_variable, variable_chemin: payload.cond_variable_chemin,
operator: payload.cond_operator, value: payload.cond_value }
: { source: 'objet', definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
field: payload.cond_field, field_type: payload.cond_field_type,
operator: payload.cond_operator, value: payload.cond_value };
var allClauses = [mainClause].concat(flowConditionExtraClauses.map(function(c) {
return c.source === 'variable'
? { source: 'variable', variable: c.variable, variable_chemin: c.variable_chemin, operator: c.operator, value: c.value }
: { source: 'objet', definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value };
}));
payload.cond_clauses = JSON.stringify(allClauses);
payload.cond_combinator = document.getElementById('nf-cond-combinator').value;
+28
View File
@@ -10,16 +10,42 @@ pour ne jamais laisser de données de test dans projects/.
import os
import shutil
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Base de comptes (auth/) et clé de session ISOLÉES du poste réel — posé
# AVANT tout import de core.flask_app/auth (chemin résolu à chaque appel,
# voir auth/connection.py, mais réglé ici une bonne fois pour toute la
# session de tests, jamais data/users.db). Sans ça, faire tourner la
# suite de tests écrirait de vrais comptes dans la vraie base de comptes,
# et la logique "premier compte = admin" (auth/create_user.py) serait déjà
# consommée par un run précédent.
_TEST_AUTH_DIR = tempfile.mkdtemp(prefix="forge_test_auth_")
os.environ["FORGE_USERS_DB_PATH"] = os.path.join(_TEST_AUTH_DIR, "users.db")
os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
import pytest
from core.flask_app import app as flask_app
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname)
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
import auth
import db
# Un seul compte admin pour toute la session de tests : les ~80 routes
# existantes s'attendent (comportement d'avant l'authentification) à un
# accès sans restriction — "admin" est le seul rôle illimité (voir
# core/auth_guard.py). Créé directement en Python (pas via /register) :
# ces tests exercent le RESTE du moteur, pas le parcours d'inscription
# lui-même (qui a ses propres tests dédiés, voir test_auth.py).
_TEST_ADMIN_ID = auth.create_user("admin@pytest.local", "Test1234!", "Test", "Admin")
auth.confirm_totp(_TEST_ADMIN_ID)
@pytest.fixture
def tmp_game_slug_cleanup():
@@ -43,6 +69,8 @@ def tmp_game_slug_cleanup():
def client():
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["user_id"] = _TEST_ADMIN_ID
yield c
+323
View File
@@ -0,0 +1,323 @@
"""Comptes utilisateurs (auth/) : inscription (nom/prénom/email unique,
mot de passe fort, 2FA TOTP obligatoire), connexion, et isolation par
utilisateur d'un SEUL projet (sauf le rôle "admin", illimité — voir
core/auth_guard.py). Le tout premier compte jamais créé devient
automatiquement admin (auth/create_user.py) : la fixture `client` de
conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du
moteur — ces tests-ci utilisent volontairement un client Flask NU (pas la
fixture `client`, déjà connectée) pour exercer le parcours anonyme
(inscription/connexion) de bout en bout."""
import re
import pyotp
import pytest
import auth
import db
from core.flask_app import app as flask_app
@pytest.fixture
def anon_client():
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
yield c
def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!"):
return client.post("/register", data={
"prenom": prenom, "nom": nom, "email": email,
"password": password, "password_confirm": password,
}, follow_redirects=False)
def _confirm_2fa(client):
"""Récupère la clé TOTP affichée sur la page de confirmation et
soumet le code du moment — comme un humain qui vient de scanner le
QR code avec son application."""
html = client.get("/register/2fa").get_data(as_text=True)
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
code = pyotp.TOTP(secret).now()
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
def _cleanup_project(email):
game_dir = db.game_dir(db.slugify(email))
import os
import shutil
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
def test_anonymous_request_redirects_to_login(anon_client):
resp = anon_client.get("/", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"].startswith("/login")
def test_registration_requires_matching_password_confirmation(anon_client):
resp = anon_client.post("/register", data={
"prenom": "A", "nom": "B", "email": "mismatch@example.com",
"password": "Sup3r$ecret!", "password_confirm": "Autre$ecret1",
})
assert resp.status_code == 200
assert "ne correspondent pas" in resp.get_data(as_text=True)
assert auth.get_user_by_email("mismatch@example.com") is None
def test_registration_rejects_a_weak_password(anon_client):
resp = anon_client.post("/register", data={
"prenom": "A", "nom": "B", "email": "weak@example.com",
"password": "abcdefgh", "password_confirm": "abcdefgh",
})
assert resp.status_code == 200
assert "faible" in resp.get_data(as_text=True)
assert auth.get_user_by_email("weak@example.com") is None
def test_registration_rejects_a_duplicate_email(anon_client):
_register(anon_client, "dup@example.com")
resp = _register(anon_client, "dup@example.com")
assert resp.status_code == 200
assert "existe déjà" in resp.get_data(as_text=True)
def test_full_registration_flow_requires_totp_before_account_is_usable(anon_client):
resp = _register(anon_client, "flow@example.com")
assert resp.status_code == 302
assert resp.headers["Location"] == "/register/2fa"
user = auth.get_user_by_email("flow@example.com")
assert user["totp_confirmed"] == 0
# Compte pas encore confirmé -> pas encore une vraie session connectée,
# même après l'étape de mot de passe (voir register.py : la connexion
# ("user_id") n'est posée qu'après confirm_totp(), jamais avant).
resp = anon_client.get("/", follow_redirects=False)
assert resp.status_code == 302 # toujours redirigé vers /login
resp, secret = _confirm_2fa(anon_client)
assert resp.status_code == 302
user = auth.get_user_by_email("flow@example.com")
assert user["totp_confirmed"] == 1
assert user["role"] == "user" # jamais le premier compte de la session de tests
assert user["project_slug"] == db.slugify("flow@example.com")
try:
assert resp.headers["Location"] == "/game/" + user["project_slug"]
# Connecté pour de vrai maintenant.
resp2 = anon_client.get("/", follow_redirects=False)
assert resp2.status_code == 302
assert resp2.headers["Location"] == "/game/" + user["project_slug"]
finally:
_cleanup_project("flow@example.com")
def test_wrong_totp_code_is_rejected(anon_client):
_register(anon_client, "badcode@example.com")
resp = anon_client.post("/register/2fa", data={"code": "000000"})
assert resp.status_code == 200
assert "invalide" in resp.get_data(as_text=True)
user = auth.get_user_by_email("badcode@example.com")
assert user["totp_confirmed"] == 0
def test_login_requires_correct_password_then_totp(anon_client):
_register(anon_client, "login@example.com")
resp, secret = _confirm_2fa(anon_client)
slug = auth.get_user_by_email("login@example.com")["project_slug"]
try:
anon_client.post("/logout")
resp = anon_client.post("/login", data={"email": "login@example.com", "password": "wrong"})
assert resp.status_code == 200
assert "incorrect" in resp.get_data(as_text=True)
resp = anon_client.post("/login", data={"email": "login@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/game/" + slug
finally:
_cleanup_project("login@example.com")
def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
_register(anon_client, "isolated@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("isolated@example.com")
try:
# Un autre projet (celui de l'admin partagé par conftest.py, ou
# n'importe quel autre slug) doit rester inaccessible.
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien")
assert resp.status_code == 403
resp = anon_client.get(f"/game/{user['project_slug']}")
assert resp.status_code == 200
# Impossible de créer un second projet : redirigé vers le sien.
resp = anon_client.post("/games/new", data={"name": "second"}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/game/" + user["project_slug"]
finally:
_cleanup_project("isolated@example.com")
# Filet de sécurité si "second" avait quand même été créé (ne
# devrait jamais arriver, voir l'assertion ci-dessus).
_cleanup_project("second")
def test_non_admin_user_cannot_delete_their_only_project(anon_client):
"""Sans issue de secours (games_new renvoie toujours vers son
project_slug, existant ou non), le supprimer serait un piège sans
retour — bloqué plutôt que risqué."""
_register(anon_client, "nodelete@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("nodelete@example.com")
try:
resp = anon_client.post(f"/game/{user['project_slug']}/delete")
assert resp.status_code == 403
assert db.game_meta(user["project_slug"]) is not None
finally:
_cleanup_project("nodelete@example.com")
def test_lockout_escalation_is_5_10_20_40_capped_at_60():
assert [auth.lockout_minutes_for(n) for n in range(9)] == [0, 0, 0, 0, 5, 10, 20, 40, 60]
def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client):
"""3 tentatives libres, puis un verrouillage qui double à chaque
nouvel échec (5, 10, 20 min...) — voir auth/rate_limit.py."""
_register(anon_client, "bruteforce@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("bruteforce@example.com")
try:
anon_client.post("/logout")
for _ in range(3):
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 3
assert auth.seconds_locked_remaining(auth.get_user_by_email("bruteforce@example.com")) == 0
# 4e échec -> premier verrouillage (5 minutes).
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
body = resp.get_data(as_text=True)
assert "Trop de tentatives" in body
assert "5 minute" in body
locked_user = auth.get_user_by_email("bruteforce@example.com")
assert auth.seconds_locked_remaining(locked_user) > 0
# Même avec le BON mot de passe, verrouillé = verrouillé.
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "Sup3r$ecret!"})
assert "Trop de tentatives" in resp.get_data(as_text=True)
# Un essai bloqué par le verrouillage n'incrémente pas davantage
# le compteur (pas d'escalade supplémentaire tant qu'on est
# encore dans la fenêtre déjà posée).
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 4
finally:
_cleanup_project("bruteforce@example.com")
def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client):
_register(anon_client, "bruteforce2fa@example.com")
resp, secret = _confirm_2fa(anon_client)
user = auth.get_user_by_email("bruteforce2fa@example.com")
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "bruteforce2fa@example.com", "password": "Sup3r$ecret!"})
for _ in range(3):
resp = anon_client.post("/login/2fa", data={"code": "000000"})
assert "Code invalide" in resp.get_data(as_text=True)
resp = anon_client.post("/login/2fa", data={"code": "000000"})
assert "Trop de tentatives" in resp.get_data(as_text=True)
# Le bon code ne débloque rien tant que le verrouillage est actif.
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code})
assert "Trop de tentatives" in resp.get_data(as_text=True)
finally:
_cleanup_project("bruteforce2fa@example.com")
def test_successful_login_resets_the_failed_attempts_counter(anon_client):
_register(anon_client, "resetcounter@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
assert auth.get_user_by_email("resetcounter@example.com")["failed_attempts"] == 2
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "Sup3r$ecret!"})
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
assert resp.status_code == 302
user = auth.get_user_by_email("resetcounter@example.com")
assert user["failed_attempts"] == 0
assert user["locked_until"] is None
finally:
_cleanup_project("resetcounter@example.com")
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
_register(anon_client, "recoveryshown@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
html = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" in html
# Jamais réaffichés sur un chargement suivant (jeton flash à usage
# unique, voir core/recovery_codes_flash.py).
html_again = anon_client.get(resp.headers["Location"]).get_data(as_text=True)
assert "recoveryCodesModal" not in html_again
finally:
_cleanup_project("recoveryshown@example.com")
def test_login_with_a_recovery_code_instead_of_totp(anon_client):
_register(anon_client, "recoverylogin@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("recoverylogin@example.com")
slug = user["project_slug"]
codes = auth.generate_recovery_codes(user["id"])
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/game/" + slug
# Un code déjà utilisé ne fonctionne plus une seconde fois.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[0]})
assert "Code invalide" in resp.get_data(as_text=True)
# Un autre code du même lot, lui, fonctionne toujours.
anon_client.post("/logout")
anon_client.post("/login", data={"email": "recoverylogin@example.com", "password": "Sup3r$ecret!"})
resp = anon_client.post("/login/2fa", data={"code": codes[1]}, follow_redirects=False)
assert resp.status_code == 302
finally:
_cleanup_project("recoverylogin@example.com")
def test_admin_role_is_not_restricted_to_a_single_project(client, game):
"""`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin
de la session de tests — la création d'un second jeu, déjà exercée par
des dizaines d'autres tests via la fixture `game`, doit continuer de
fonctionner sans redirection forcée."""
resp = client.post("/games/new", data={"name": "pytest_second_admin_game"}, follow_redirects=False)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
assert slug != game
import shutil
import os
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
+104
View File
@@ -0,0 +1,104 @@
"""Un nœud Condition du graphe de logique peut désormais tester une
VARIABLE GLOBALE, pas seulement un champ d'objet (voir
ensure_flow_schema.py : cond_source/cond_variable/cond_variable_chemin).
Évalué côté CLIENT (templates/play.html, evaluateConditionClause) contre
gameData.variables — exposé par full_game_payload.py, vérifié ici via
/runtime-payload. La logique JS elle-même (résolution de chemin, lecture
de gameData.variables, repli sur le comportement historique) a été vérifiée
manuellement avec Node.js (fonctions extraites de play.html) : variable
scalaire, objet avec chemin chaîné, tableau par index, variable
introuvable (ne matche rien plutôt que planter), booléen "Oui", et clause
sans "source" (comportement legacy, champ d'objet) — les 7 scénarios
passent."""
import json
import re
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def test_condition_node_stores_variable_source(client, game):
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition", "cond_source": "variable",
"cond_variable": "score", "cond_variable_chemin": "",
"cond_operator": "superieur_egal", "cond_value": "3",
}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
assert node["cond_source"] == "variable"
assert node["cond_variable"] == "score"
assert node["cond_operator"] == "superieur_egal"
# Aucun champ d'objet renseigné pour une clause "variable".
assert node["cond_definition_id"] is None
assert node["cond_field"] is None
def test_condition_node_with_combined_variable_and_object_clauses(client, game):
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Niveau", "field_name[]": ["numero"], "field_type[]": ["nombre_entier"],
"field_relation[]": [""], "field_required[]": ["0"], "field_min[]": [""], "field_max[]": [""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = _create_screen(client, game)
clauses = [
{"source": "variable", "variable": "score", "operator": "superieur_egal", "value": "3"},
{"source": "objet", "definition_id": def_id, "row_id": None, "field": "numero",
"field_type": "nombre_entier", "operator": "egal", "value": "1"},
]
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition", "cond_clauses": json.dumps(clauses), "cond_combinator": "ou",
}),
content_type="application/json",
)
assert resp.status_code == 200
node = resp.get_json()
stored = json.loads(node["cond_clauses"])
assert stored[0]["source"] == "variable"
assert stored[1]["source"] == "objet"
assert node["cond_combinator"] == "ou"
def test_runtime_payload_exposes_global_variables_for_condition_evaluation(client, game):
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
client.post(f"/game/{game}/variables/new", data={"name": "perso", "type": "objet",
"default_value": json.dumps({"nom": "Alice"})})
payload = client.get(f"/game/{game}/runtime-payload").get_json()
assert payload["variables"]["score"] == {"value": "5", "type": "nombre_entier"}
assert payload["variables"]["perso"]["type"] == "objet"
assert json.loads(payload["variables"]["perso"]["value"]) == {"nom": "Alice"}
def test_screen_edit_panel_exposes_variable_source_condition_ui(client, game):
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert 'id="nf-cond-source"' in html
assert 'id="nf-cond-variable-fields"' in html
assert "onFlowConditionSourceChange" in html
assert "onFlowClauseSourceChange" in html
def test_condition_builder_still_available_without_any_object_defined(client, game):
"""Régression : le panneau de condition était entièrement masqué quand
le jeu n'avait AUCUN objet ("Aucun objet défini..."), rendant
impossible de créer une condition sur une variable dans ce cas. Seule
la partie "Champ d'un objet" doit rester masquée ; "Variable globale"
doit rester utilisable."""
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert 'id="nf-cond-source"' in html
assert 'id="nf-cond-variable-fields"' in html
+217 -11
View File
@@ -181,18 +181,205 @@ def test_overlay_widget_renders_fullscreen_fixed_box(client, game):
assert "forgeOverlayBox" in snippet
def test_overlay_box_default_text_color_survives_the_bulma_box_class(client, game):
"""Régression : la classe Bulma ".box" (voir render_overlay.py) impose
elle-même une couleur de texte SOMBRE, pensée pour un fond blanc. Un
texte posé dans la boîte SANS couleur personnalisée (le cas par défaut
— aucun widget ne fige de couleur à sa création, voir
default_style_for_widget.py) héritait donc de ce gris sombre, invisible
sur le fond sombre par défaut de la boîte — "j'ai mis un texte dedans
mais il ne se voit pas", sans la moindre erreur serveur. La boîte doit
donc fixer elle-même une couleur de texte claire par défaut, que Bulma
ne peut plus écraser (élément le plus proche gagne)."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
idx = html.find("forgeOverlayBox")
assert idx != -1
box_tag = html[html.rfind("<div", 0, idx):idx + 200]
assert "color:#e8eaf0" in box_tag
def test_visibility_dropdown_reflects_masque_even_when_visible_would_also_match(client, game):
"""Régression (la vraie cause derrière "je ne vois plus ma modale, ni
même en changeant Visibilité sur Visible") : le panneau de propriétés
détecte la valeur ACTUELLE d'un réglage "preset" (screens/widgets/
control_value.py) en cherchant la PREMIÈRE option de la liste dont les
critères correspondent au style stocké. L'option "Visible normalement"
de la Visibilité (visibility_control.py) ne vérifie QUE "visibility"
(jamais "display", par choix assumé) — un élément "Masqué" (qui ne pose
que "display:none", jamais "visibility") satisfaisait donc TOUJOURS,
trivialement, les critères de "Visible" en premier (testée avant
"Masqué" dans la liste) : le panneau affichait "Visible normalement"
sélectionné sur un élément EN RÉALITÉ masqué. Comme un <select> ne
déclenche un enregistrement que sur un changement RÉEL de valeur,
re-choisir l'option déjà affichée ne faisait RIEN : impossible de
rendre l'élément visible depuis le panneau. Corrigé en vérifiant les
options les plus SPÉCIFIQUES (le plus de propriétés non vides exigées)
en premier."""
from screens.widgets.control_value import _control_value
from screens.widgets.widget_meta import widget_meta
import screens
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
el = screens.get_element(game, overlay_id)
meta = widget_meta(el["widget"])
vis_control = next(c for c in meta["controls"] if c["key"] == "visibilite")
# "Masqué" par défaut à la création (default_style_for_widget.py) : le
# panneau doit détecter "masque", pas "visible".
assert _control_value(el, vis_control) == "masque"
def test_overlay_behaves_like_a_normal_container_in_the_editor(client, game):
"""Régression (deux retours utilisateur successifs) :
1. Un essai précédent forçait "display:flex" dans l'éditeur, quel que
soit le réglage "Visibilité" -> "je veux avoir la main sur la
visibilité de la modale, sinon elle s'affiche toujours sur la
scène [éditeur] et court-circuite ma logique". L'éditeur doit donc
respecter "Visibilité" normalement, EXACTEMENT comme n'importe quel
autre widget (masqué = display:none aussi dans l'éditeur).
2. La boîte gardait son voile plein écran (position:fixed + fond
assombri) même dans l'éditeur -> "je souhaite que rien ne soit
assombri, l'assombrissement ne se fait que quand la scène est
jouée". Le voile plein écran ne doit donc apparaître qu'en mode
JOUABLE — dans l'éditeur, ce widget se comporte comme un conteneur
normal (position/taille selon x/y/width/height, pas de voile)."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
# Par défaut ("Masqué" à la création) : invisible dans l'éditeur aussi,
# comme n'importe quel autre widget masqué (pas de forçage).
edit_html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
idx = edit_html.find(f'id="elt-{overlay_id}"')
assert idx != -1
tag = edit_html[edit_html.rfind("<div", 0, idx):idx + 30]
assert "display:none" in tag
assert "position:fixed" not in tag
assert "rgba(0,0,0,0.6)" not in tag
# Repassé "Visible" à la main (garder la main sur la visibilité) :
# apparaît dans l'éditeur, mais toujours SANS voile plein écran.
client.post(f"/game/{game}/elements/{overlay_id}/save", data={"ctrl_visibilite": "visible"})
edit_html2 = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
idx2 = edit_html2.find(f'id="elt-{overlay_id}"')
tag2 = edit_html2[edit_html2.rfind("<div", 0, idx2):idx2 + 30]
assert "display:none" not in tag2
assert "position:fixed" not in tag2
assert "rgba(0,0,0,0.6)" not in tag2
# En mode JOUABLE, le comportement plein écran + voile reste inchangé.
play_html = client.get(f"/game/{game}/play").data.decode()
idx3 = play_html.find(f'id="elt-{overlay_id}"')
tag3 = play_html[play_html.rfind("<div", 0, idx3):idx3 + 250]
assert "position:fixed" in tag3
assert "rgba(0,0,0,0.6)" in tag3
def test_overlay_element_type_instance_has_no_visible_wrapper_box(client, game):
"""Régression : poser un élément de jeu réutilisable ("Mes éléments de
jeu") dont le MODÈLE n'est qu'une superposition affichait, sur la
vraie scène, une boîte "conteneur" vide et TOUJOURS VISIBLE à
l'endroit où l'exemplaire a été déposé — "un conteneur vide apparaît,
pas la boîte de dialogue" (elle existe bien, masquée comme prévu ;
c'est l'enveloppe générique "conteneur" AUTOUR, posée par défaut pour
tout exemplaire (add_element.py), qui n'aurait jamais dû être visible
pour un modèle qui n'est QUE ça). Corrigé en court-circuitant cette
enveloppe dès que le modèle entier est une superposition."""
import screens
resp = client.post(f"/game/{game}/element-types", data={"name": "Dialogue"}, follow_redirects=False)
assert resp.status_code == 302
et = next(t for t in screens.list_element_types(game) if t["name"] == "Dialogue")
template_screen_id = et["template_screen_id"]
overlay_id = _add_element(client, game, template_screen_id, "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
screen_id = _create_screen(client, game, "Scène")
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
assert resp.status_code == 302
# Un frère ajouté APRÈS -> z_index plus grand, comme dans le scénario
# rapporté (une scène avec d'autres éléments déjà en place).
_add_element(client, game, screen_id, "bouton")
html = client.get(f"/game/{game}/play").data.decode()
assert "forgeOverlayBox" in html
assert 'class="box"' not in html # l'enveloppe "conteneur" par défaut ne doit plus apparaître
idx = html.find('class="modal is-active"')
wrapper_start = html.rfind('<div class="playElement"', 0, idx)
wrapper_style = re.search(r'style="([^"]*)"', html[wrapper_start:idx]).group(1)
assert "z-index" not in wrapper_style
def test_overlay_element_type_instance_is_controllable_from_the_hosting_scene(client, game):
"""Régression : la logique posée sur LA SCÈNE qui accueille un
exemplaire de dialogue (ex. "Modifier un élément → Modale : Visibilité
= Rendre visible", ciblant l'exemplaire par son id SUR CETTE SCÈNE)
n'avait plus aucun effet une fois l'enveloppe "conteneur" entièrement
court-circuitée (un essai précédent) : son id disparaissait du DOM
(impossible à cibler), et même en le gardant, la superposition INTERNE
au modèle restait masquée indépendamment (double masquage — rendre
l'enveloppe visible n'aurait rien changé). Corrigé : l'enveloppe
GARDE son propre id/data-element-id (ciblable depuis la scène), et la
superposition interne au modèle ignore désormais son propre réglage
"Visibilité" une fois posée comme exemplaire — tout le masquage est
délégué à l'enveloppe (qui démarre elle-même masquée par défaut, voir
add_element.py)."""
import screens
resp = client.post(f"/game/{game}/element-types", data={"name": "Dialogue2"}, follow_redirects=False)
et = next(t for t in screens.list_element_types(game) if t["name"] == "Dialogue2")
overlay_id = _add_element(client, game, et["template_screen_id"], "superposition")
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"})
screen_id = _create_screen(client, game, "Scène2")
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
instance_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/play").data.decode()
idx = html.find(f'data-element-id="{instance_id}"')
assert idx != -1
wrapper_tag = html[html.rfind("<div", 0, idx):idx + 40]
assert "display:none" in wrapper_tag # masqué par défaut, sur l'EXEMPLAIRE
# La superposition interne au modèle, elle, ne doit PLUS porter son
# propre display:none une fois rendue comme exemplaire (sinon la
# rendre visible depuis la scène resterait sans effet).
idx_modal = html.find('class="modal is-active"', idx)
modal_tag = html[html.rfind("<div", 0, idx_modal):idx_modal + 250]
assert "display:none" not in modal_tag
trig = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "affichage"}),
content_type="application/json").get_json()
act = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_element",
"target_element_id": instance_id, "element_property": "visibilite", "element_value": "visible",
}), content_type="application/json").get_json()
assert act["target_element_id"] == instance_id
def test_overlay_wrapper_does_not_trap_its_own_z_index(client, game):
"""Régression : le cadre .playElement/.canvasElement partagé par TOUS
les widgets (voir filters/element_style_filter.py) posait quand même
"position:absolute; z-index:<sa place dans le canevas>" sur la
superposition, MÊME SI son propre contenu (render_overlay.py) ignore
x/y/width/height et pose déjà position:fixed + z-index:9999 lui-même.
Un élément positionné avec un z-index explicite crée un NOUVEAU contexte
d'empilement CSS : le 9999 posé plus profond ne se comparait alors plus
qu'AU SEIN de ce contexte, et perdait face au z-index (plus grand) d'un
élément normal ajouté APRÈS l'overlay sur le canevas — qui s'affichait
donc PAR-DESSUS le dialogue censé tout couvrir. Le cadre ne doit donc
plus poser aucune position/z-index pour ce widget."""
"z-index:<sa place dans le canevas>" sur la superposition, MÊME SI son
propre contenu (render_overlay.py) ignore x/y/width/height et pose déjà
position:fixed + z-index:9999 lui-même. Un élément positionné avec un
z-index EXPLICITE crée un NOUVEAU contexte d'empilement CSS : le 9999
posé plus profond ne se comparait alors plus qu'AU SEIN de ce contexte,
et perdait face au z-index (plus grand) d'un élément normal ajouté
APRÈS l'overlay sur le canevas — qui s'affichait donc PAR-DESSUS le
dialogue censé tout couvrir. Le cadre garde position/left/top/width/
height comme tout widget (l'éditeur en a besoin pour glisser-déposer/
redimensionner ce cadre — les retirer a fait planter element_geometry
en régression), mais n'écrit plus DU TOUT de z-index pour ce widget :
position:absolute avec z-index:auto (omis) ne crée pas de contexte
d'empilement, donc le 9999 se compare directement aux autres éléments."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
# Ajouté APRÈS l'overlay -> z_index plus grand que le sien.
@@ -204,8 +391,27 @@ def test_overlay_wrapper_does_not_trap_its_own_z_index(client, game):
assert idx != -1
tag_start = html.rfind("<div", 0, idx)
wrapper_tag = html[tag_start:idx + 200]
assert "position:static" in wrapper_tag
assert "position:absolute" not in wrapper_tag
assert "position:absolute" in wrapper_tag
assert "z-index" not in wrapper_tag
def test_overlay_geometry_can_still_be_saved_from_the_editor(client, game):
"""Régression : la première version du correctif ci-dessus retirait
AUSSI position/left/top/width/height du cadre d'une superposition, ce
qui effondrait ce cadre à 0×0 dans l'ÉDITEUR (son contenu réel est en
position:fixed, hors flux) — le calcul de glisser-déposer/
redimensionnement (screen_edit.html) divise alors par une dimension
nulle, produit NaN, et JSON.stringify(NaN) envoie "null" : le serveur
plantait sur float(None) dans element_geometry.py. Le cadre doit donc
continuer à porter une position/taille en % normale pour ce widget."""
screen_id = _create_screen(client, game)
overlay_id = _add_element(client, game, screen_id, "superposition")
resp = client.post(
f"/game/{game}/elements/{overlay_id}/geometry",
data=json.dumps({"x": 12.5, "y": 20, "width": 55, "height": 45}),
content_type="application/json",
)
assert resp.status_code == 200
def test_overlay_starts_hidden_by_default(client, game):
+96
View File
@@ -0,0 +1,96 @@
"""Vérification du jeton CSRF (core/csrf_guard.py) — DÉSACTIVÉE quand
app.config["TESTING"] est actif (voir son propre commentaire, même
convention que Flask-WTF), pour ne pas avoir à ajouter le jeton aux ~170
autres tests du moteur qui appellent les routes directement via le client
de test Flask. Ces tests-ci réactivent volontairement la vérification
(flask_app.config["TESTING"] = False) pour la mettre à l'épreuve pour de
vrai, puis la remettent dans l'état attendu par le reste de la suite."""
import pytest
from core.flask_app import app as flask_app
@pytest.fixture
def csrf_client():
"""Comme `client` (conftest.py), mais avec la vérification CSRF
RÉACTIVÉE le temps du test — connecté en tant qu'admin de test
(session_transaction), pour isoler la seule variable qui nous
intéresse ici (le jeton), sans se soucier de l'authentification."""
import tests.conftest as conftest_module
flask_app.config["TESTING"] = False
try:
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["user_id"] = conftest_module._TEST_ADMIN_ID
yield c
finally:
flask_app.config["TESTING"] = True
def _get_session_csrf_token(client):
with client.session_transaction() as sess:
return sess.get("csrf_token")
def test_get_requests_are_never_blocked_by_csrf(csrf_client):
resp = csrf_client.get("/")
assert resp.status_code == 200
def test_post_without_any_csrf_token_is_rejected(csrf_client):
resp = csrf_client.post("/games/new", data={"name": "should not be created"})
assert resp.status_code == 400
def test_post_with_a_wrong_csrf_token_is_rejected(csrf_client):
resp = csrf_client.post(
"/games/new", data={"name": "should not be created"},
headers={"X-CSRFToken": "not-the-right-token"},
)
assert resp.status_code == 400
def test_post_with_the_correct_csrf_token_succeeds(csrf_client):
# Une requête GET fait générer/exposer le jeton de CETTE session (voir
# core/csrf.py::get_csrf_token, appelé par base.html au rendu).
csrf_client.get("/")
token = _get_session_csrf_token(csrf_client)
assert token
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_ok"},
headers={"X-CSRFToken": token}, follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
def test_post_with_the_token_in_the_form_field_also_works(csrf_client):
"""Filet de sécurité pour un éventuel formulaire natif qui ne passerait
pas par static/csrf_fetch.js (aucun cas de ce genre actuellement dans
le moteur, voir core/csrf_guard.py) — le champ de formulaire
"csrf_token" reste accepté en plus de l'en-tête."""
csrf_client.get("/")
token = _get_session_csrf_token(csrf_client)
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_form_field", "csrf_token": token},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
+150
View File
@@ -0,0 +1,150 @@
""""Donnée liée" (data_binding_controls.py) : liste de conditions
ILLIMITÉE, combinées par ET ou par OU, plutôt que les 2 filtres fixes
d'avant (toujours combinés en ET) — même principe que les conditions
combinées du graphe de logique (cond_clauses/cond_combinator).
Rétrocompatible avec les anciens éléments (_data_filtre_champ/
_data_filtre2_champ), convertis à la volée en liste de clauses à la
lecture (screens/clause_list_codec.py), sans migration de données."""
import json
import re
import db
import screens
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _create_niveau_object(client, slug):
resp = client.post(f"/game/{slug}/objects/new", data={
"object_name": "Niveau", "field_name[]": ["numero", "parcours"],
"field_type[]": ["nombre_entier", "texte"],
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
"field_min[]": ["", ""], "field_max[]": ["", ""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "1", "parcours": "A"})
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "2", "parcours": "A"})
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"numero": "3", "parcours": "B"})
return def_id
def _rendered_text(client, slug):
payload = client.get(f"/game/{slug}/runtime-payload").get_json()
html = payload["screens"][0]["elements"][0]["rendered_html"]
return re.sub(r"<[^>]+>", "", html)
def test_three_clauses_combined_with_et(client, game):
def_id = _create_niveau_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{el_id}/save", data={
"ctrl_content": "{{numero}}",
"ctrl_data_definition_id": str(def_id),
"ctrl_data_filtres_champ[]": ["parcours", "numero"],
"ctrl_data_filtres_operateur[]": ["egal", "superieur"],
"ctrl_data_filtres_valeur[]": ["A", "1"],
"ctrl_data_filtres_combinateur": "et",
})
# parcours == A ET numero > 1 -> seule la ligne #2 correspond.
assert _rendered_text(client, game) == "2"
def test_clauses_combined_with_ou(client, game):
def_id = _create_niveau_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{el_id}/save", data={
"ctrl_content": "{{numero}}",
"ctrl_data_definition_id": str(def_id),
"ctrl_data_filtres_champ[]": ["parcours", "numero"],
"ctrl_data_filtres_operateur[]": ["egal", "egal"],
"ctrl_data_filtres_valeur[]": ["B", "999"],
"ctrl_data_filtres_combinateur": "ou",
})
# parcours == B OU numero == 999 -> seule la ligne #3 (parcours B) matche,
# la plus récente d'abord (ORDER BY id DESC) donc c'est bien elle.
assert _rendered_text(client, game) == "3"
def test_clauses_stored_as_json_and_empty_rows_ignored(client, game):
def_id = _create_niveau_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{el_id}/save", data={
"ctrl_data_definition_id": str(def_id),
# Une ligne vide (ajoutée par erreur, jamais remplie) ne doit pas
# compter comme une vraie condition.
"ctrl_data_filtres_champ[]": ["numero", ""],
"ctrl_data_filtres_operateur[]": ["egal", "egal"],
"ctrl_data_filtres_valeur[]": ["2", ""],
"ctrl_data_filtres_combinateur": "et",
})
el = screens.get_element(game, el_id)
clauses = json.loads(el["attributes"]["_data_filtres_json"])
assert clauses == [{"champ": "numero", "operateur": "egal", "valeur": "2"}]
# combinateur "et" est la valeur par défaut : pas figé en base.
assert "_data_filtre_combinateur" not in el["attributes"]
def test_no_clause_falls_back_to_most_recent_row(client, game):
def_id = _create_niveau_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{el_id}/save", data={
"ctrl_content": "{{numero}}", "ctrl_data_definition_id": str(def_id),
})
assert _rendered_text(client, game) == "3"
def test_legacy_two_fixed_filters_still_resolve_via_fallback(client, game):
"""Un élément créé AVANT cette fonctionnalité (attr:_data_filtre_champ/
_data_filtre2_champ, jamais _data_filtres_json) doit continuer à
fonctionner sans migration de données."""
def_id = _create_niveau_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
conn = db.connect(game)
conn.execute(
"UPDATE _screen_elements SET attributes = ?, content = ? WHERE id = ?",
(json.dumps({
"_data_definition_id": str(def_id),
"_data_filtre_champ": "parcours", "_data_filtre_operateur": "egal", "_data_filtre_valeur": "A",
"_data_filtre2_champ": "numero", "_data_filtre2_operateur": "egal", "_data_filtre2_valeur": "2",
}), "{{numero}}", el_id),
)
conn.commit()
conn.close()
# Ancien format : toujours combiné en ET -> parcours==A ET numero==2.
assert _rendered_text(client, game) == "2"
# Le panneau reconstruit une clause éditable par ancien filtre non vide.
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={el_id}").get_data(as_text=True)
assert 'name="ctrl_data_filtres_valeur[]" value="A"' in html
assert 'name="ctrl_data_filtres_valeur[]" value="2"' in html
def test_properties_panel_renders_scrollable_clause_list(client, game):
def_id = _create_niveau_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{el_id}/save", data={"ctrl_data_definition_id": str(def_id)})
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={el_id}").get_data(as_text=True)
assert 'id="clauseListWrap-data_filtres"' in html
assert "addClauseRow(" in html
assert "clauseRowTemplate-data_filtres" in html
+100
View File
@@ -0,0 +1,100 @@
"""Régression : changer une variable globale (ou une donnée) pendant la
partie recalculait bien, côté SERVEUR, quelle ligne "Donnée liée" (Texte/
Titre) doit afficher — mais templates/play.html ne régénère, après une
telle action, que les éléments dont le rendered_html porte un marqueur
connu ("repeaterItem"/"jaugeBar"/"visibilityGated" — voir
refreshRuntimeData()/hasMarker()). Un Texte/Titre "Donnée liée" n'en
portait AUCUN : son contenu restait donc figé sur son ancienne ligne
jusqu'au prochain rechargement complet de page, même si le bon contenu
était déjà prêt côté serveur — "la variable change mais pas le contenu de
la modale". Corrigé en ajoutant un marqueur "dataBound" (render_element_html.py),
reconnu par hasMarker()."""
import re
import screens
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def test_data_bound_element_carries_the_refresh_marker(client, game):
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "Niveau", "field_name[]": ["numero", "spiker"],
"field_type[]": ["nombre_entier", "texte"],
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
"field_min[]": ["", ""], "field_max[]": ["", ""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"numero": "1", "spiker": "un"})
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"numero": "2", "spiker": "deux"})
client.post(f"/game/{game}/variables/new", data={"name": "n", "type": "nombre_entier", "default_value": "1"})
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False)
el_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{el_id}/save", data={
"ctrl_content": "{{spiker}}",
"ctrl_data_definition_id": str(def_id),
"ctrl_data_filtres_champ[]": ["numero"],
"ctrl_data_filtres_operateur[]": ["egal"],
"ctrl_data_filtres_valeur[]": ["{{$n}}"],
})
payload = client.get(f"/game/{game}/runtime-payload").get_json()
el = payload["screens"][0]["elements"][0]
assert "dataBound" in el["rendered_html"]
assert "un" in el["rendered_html"]
# Change la variable -> le rendu recalculé (déjà correct côté serveur
# avant ce correctif) doit refléter la nouvelle ligne.
import db
variable = db.get_global_variable(game, "n")
db.update_global_variable(game, variable["id"], "nombre_entier", "2")
payload2 = client.get(f"/game/{game}/runtime-payload").get_json()
el2 = payload2["screens"][0]["elements"][0]
assert "deux" in el2["rendered_html"]
assert "dataBound" in el2["rendered_html"]
def test_element_type_instance_bubbles_up_the_data_bound_marker(client, game):
"""Même vérification, mais pour un Texte "Donnée liée" posé DANS un
élément de jeu réutilisable : le marqueur porté par l'élément interne
doit se retrouver dans le rendered_html de l'EXEMPLAIRE (le seul élément
réellement présent dans la liste plate de la scène qui l'accueille —
voir list_elements.py, les descendants d'un exemplaire sont exclus)."""
resp = client.post(f"/game/{game}/objects/new", data={
"object_name": "dialog", "field_name[]": ["order", "spiker"],
"field_type[]": ["nombre_entier", "texte"],
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
"field_min[]": ["", ""], "field_max[]": ["", ""],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"order": "1", "spiker": "Un"})
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"order": "2", "spiker": "Deux"})
client.post(f"/game/{game}/variables/new", data={"name": "dialog_order", "type": "nombre_entier", "default_value": "1"})
client.post(f"/game/{game}/element-types", data={"name": "dialog"}, follow_redirects=False)
et = next(t for t in screens.list_element_types(game) if t["name"] == "dialog")
resp = client.post(f"/game/{game}/screens/{et['template_screen_id']}/elements/add", data={"widget": "superposition"}, follow_redirects=False)
overlay_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "texte"}, follow_redirects=False)
spiker_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
client.post(f"/game/{game}/elements/{spiker_id}/save", data={
"ctrl_content": "{{spiker}}",
"ctrl_data_definition_id": str(def_id),
"ctrl_data_filtres_champ[]": ["order"],
"ctrl_data_filtres_operateur[]": ["egal"],
"ctrl_data_filtres_valeur[]": ["{{$dialog_order}}"],
})
screen_id = _create_screen(client, game, "Scene")
client.post(f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": et["id"]}, follow_redirects=False)
payload = client.get(f"/game/{game}/runtime-payload").get_json()
instance = payload["screens"][0]["elements"][0]
assert "dataBound" in instance["rendered_html"]
assert "Un" in instance["rendered_html"]
+80
View File
@@ -0,0 +1,80 @@
"""Réinitialisation de mot de passe par email (auth/password_reset.py,
auth/send_email.py) — l'envoi SMTP réel est remplacé (monkeypatch) par une
capture de l'URL qui aurait été envoyée : ces tests ne doivent jamais
essayer de contacter un vrai serveur SMTP."""
import auth
from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401
def _capture_reset_url(monkeypatch):
sent = {}
def fake_send(to_email, reset_url):
sent["to_email"] = to_email
sent["reset_url"] = reset_url
monkeypatch.setattr(auth, "send_password_reset_email", fake_send)
return sent
def test_forgot_password_always_shows_the_same_generic_message(anon_client, monkeypatch):
_capture_reset_url(monkeypatch)
resp_known = anon_client.post("/forgot-password", data={"email": "unknown@example.com"})
resp_unknown_body = resp_known.get_data(as_text=True)
assert "un email vient de t'être envoyé" in resp_unknown_body
def test_full_password_reset_flow(anon_client, monkeypatch):
sent = _capture_reset_url(monkeypatch)
_register(anon_client, "resetflow@example.com")
_confirm_2fa(anon_client)
try:
anon_client.post("/logout")
resp = anon_client.post("/forgot-password", data={"email": "resetflow@example.com"})
assert "un email vient de t'être envoyé" in resp.get_data(as_text=True)
assert sent["to_email"] == "resetflow@example.com"
token = sent["reset_url"].rsplit("/", 1)[-1]
# Le lien affiche bien le formulaire de nouveau mot de passe.
resp = anon_client.get(f"/reset-password/{token}")
assert resp.status_code == 200
assert "invalide" not in resp.get_data(as_text=True)
# Mots de passe qui ne correspondent pas -> refusé.
resp = anon_client.post(f"/reset-password/{token}", data={
"password": "Nouveau$ecret1", "password_confirm": "Autre$ecret2",
})
assert "ne correspondent pas" in resp.get_data(as_text=True)
# Mot de passe trop faible -> refusé.
resp = anon_client.post(f"/reset-password/{token}", data={
"password": "faible", "password_confirm": "faible",
})
assert "faible" in resp.get_data(as_text=True)
# Nouveau mot de passe valide -> accepté, redirige vers /login.
resp = anon_client.post(f"/reset-password/{token}", data={
"password": "Nouveau$ecret1", "password_confirm": "Nouveau$ecret1",
}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login"
# L'ancien mot de passe ne fonctionne plus, le nouveau fonctionne.
resp = anon_client.post("/login", data={"email": "resetflow@example.com", "password": "Sup3r$ecret!"})
assert "incorrect" in resp.get_data(as_text=True)
resp = anon_client.post("/login", data={"email": "resetflow@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
# Le jeton est à usage unique : le réutiliser échoue désormais.
resp = anon_client.get(f"/reset-password/{token}")
assert "invalide" in resp.get_data(as_text=True)
finally:
_cleanup_project("resetflow@example.com")
def test_reset_password_with_invalid_or_unknown_token_shows_invalid(anon_client):
resp = anon_client.get("/reset-password/not-a-real-token")
assert "invalide" in resp.get_data(as_text=True)
+143
View File
@@ -0,0 +1,143 @@
"""Page de profil (routes/auth/profile.py) : modifier nom/prénom, changer
de mot de passe, demander la suppression de son compte (avec mot de passe
+ confirmation textuelle, voir templates/auth/profile.html)."""
import pyotp
import auth
import db
from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401
def test_profile_page_shows_current_user_info(anon_client):
_register(anon_client, "profileinfo@example.com", prenom="Ada", nom="Lovelace")
_confirm_2fa(anon_client)
try:
html = anon_client.get("/profile").get_data(as_text=True)
assert "profileinfo@example.com" in html
assert 'value="Ada"' in html
assert 'value="Lovelace"' in html
finally:
_cleanup_project("profileinfo@example.com")
def test_update_name(anon_client):
_register(anon_client, "updatename@example.com", prenom="Old", nom="Name")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-name", data={"prenom": "New", "nom": "Name2"})
assert "mises à jour" in resp.get_data(as_text=True)
user = auth.get_user_by_email("updatename@example.com")
assert user["prenom"] == "New"
assert user["nom"] == "Name2"
finally:
_cleanup_project("updatename@example.com")
def test_update_name_requires_both_fields(anon_client):
_register(anon_client, "updatenameblank@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-name", data={"prenom": "", "nom": "Test"})
assert "requis" in resp.get_data(as_text=True)
finally:
_cleanup_project("updatenameblank@example.com")
def test_change_password_requires_correct_current_password(anon_client):
_register(anon_client, "changepwwrong@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "wrong", "new_password": "Nouveau$ecret1", "new_password_confirm": "Nouveau$ecret1",
})
assert "incorrect" in resp.get_data(as_text=True)
finally:
_cleanup_project("changepwwrong@example.com")
def test_change_password_rejects_mismatched_confirmation(anon_client):
_register(anon_client, "changepwmismatch@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "Sup3r$ecret!", "new_password": "Nouveau$ecret1", "new_password_confirm": "Autre$ecret2",
})
assert "ne correspondent pas" in resp.get_data(as_text=True)
finally:
_cleanup_project("changepwmismatch@example.com")
def test_change_password_rejects_a_weak_new_password(anon_client):
_register(anon_client, "changepwweak@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "Sup3r$ecret!", "new_password": "faible", "new_password_confirm": "faible",
})
assert "faible" in resp.get_data(as_text=True)
finally:
_cleanup_project("changepwweak@example.com")
def test_change_password_success_lets_login_with_new_password(anon_client):
_register(anon_client, "changepwok@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "Sup3r$ecret!", "new_password": "Nouveau$ecret1", "new_password_confirm": "Nouveau$ecret1",
})
assert "modifié" in resp.get_data(as_text=True)
anon_client.post("/logout")
resp = anon_client.post("/login", data={"email": "changepwok@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
finally:
_cleanup_project("changepwok@example.com")
def test_delete_account_requires_correct_password(anon_client):
_register(anon_client, "deletewrongpw@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/delete", data={"current_password": "wrong", "confirm_text": "SUPPRIMER"})
assert "incorrect" in resp.get_data(as_text=True)
assert auth.get_user_by_email("deletewrongpw@example.com") is not None
finally:
_cleanup_project("deletewrongpw@example.com")
def test_delete_account_requires_the_confirmation_phrase(anon_client):
_register(anon_client, "deletenoconfirm@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/delete", data={"current_password": "Sup3r$ecret!", "confirm_text": "oups"})
assert "annulée" in resp.get_data(as_text=True)
assert auth.get_user_by_email("deletenoconfirm@example.com") is not None
finally:
_cleanup_project("deletenoconfirm@example.com")
def test_delete_account_removes_user_and_their_project(anon_client):
_register(anon_client, "deleteforreal@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("deleteforreal@example.com")
slug = user["project_slug"]
import os
assert os.path.isdir(db.game_dir(slug))
resp = anon_client.post("/profile/delete", data={"current_password": "Sup3r$ecret!", "confirm_text": "SUPPRIMER"}, follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login"
assert auth.get_user_by_email("deleteforreal@example.com") is None
assert not os.path.isdir(db.game_dir(slug))
def test_the_only_admin_account_cannot_be_deleted(client):
"""`client` (conftest.py) est déjà connecté en tant qu'admin unique de
la session de tests."""
resp = client.post("/profile/delete", data={"current_password": "Test1234!", "confirm_text": "SUPPRIMER"})
assert "administrateur" in resp.get_data(as_text=True)
import tests.conftest as conftest_module
assert auth.get_user_by_id(conftest_module._TEST_ADMIN_ID) is not None