diff --git a/auth/__init__.py b/auth/__init__.py
index d8bf8e69..abd5589b 100644
--- a/auth/__init__.py
+++ b/auth/__init__.py
@@ -26,6 +26,9 @@ from .password_reset import (
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",
@@ -40,4 +43,5 @@ __all__ = [
"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",
]
diff --git a/auth/count_admins.py b/auth/count_admins.py
new file mode 100644
index 00000000..43647438
--- /dev/null
+++ b/auth/count_admins.py
@@ -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
diff --git a/auth/delete_user.py b/auth/delete_user.py
new file mode 100644
index 00000000..85801ea4
--- /dev/null
+++ b/auth/delete_user.py
@@ -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()
diff --git a/auth/update_profile.py b/auth/update_profile.py
new file mode 100644
index 00000000..ad9619da
--- /dev/null
+++ b/auth/update_profile.py
@@ -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()
diff --git a/routes/auth/__init__.py b/routes/auth/__init__.py
index 971b94a5..1c6a0c1a 100644
--- a/routes/auth/__init__.py
+++ b/routes/auth/__init__.py
@@ -1 +1 @@
-from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password
+from . import register, register_2fa, login, login_2fa, logout, forgot_password, reset_password, profile
diff --git a/routes/auth/profile.py b/routes/auth/profile.py
new file mode 100644
index 00000000..223bef36
--- /dev/null
+++ b/routes/auth/profile.py
@@ -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"))
diff --git a/templates/auth/profile.html b/templates/auth/profile.html
new file mode 100644
index 00000000..0869d285
--- /dev/null
+++ b/templates/auth/profile.html
@@ -0,0 +1,121 @@
+{% extends "base.html" %}
+{% block title %}Mon profil — Forge Engine{% endblock %}
+{% block content %}
+ Adresse email : {{ user.email }} ({{ 'compte administrateur' if user.role == 'admin' else 'compte utilisateur' }})
+ {% 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 %}
+ Mon profil
+
+ {% if error %}
+ Informations
+ Changer de mot de passe
+
+ Supprimer mon compte
+