Dev #4
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"):]
|
||||
@@ -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()
|
||||
@@ -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 "")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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)):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
Flask==3.0.3
|
||||
pyotp==2.10.0
|
||||
qrcode==8.2
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password, profile
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ""
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 "{{ }}" 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
@@ -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
@@ -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 "{{ }}" 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, '"') + '" 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user