eptm_dashboard/eptm_dashboard/pages/password_set.py
Julien Balet 43a2196150 auth: flow email pour mdp + page profil + restriction d'accès par classe
- création de compte par admin envoie un email avec lien de définition (7j),
  bouton "Reset mdp" pour renvoyer un lien (24h). Plus aucun admin ne peut
  modifier directement le mdp d'un user (tout passe par les liens email).
- nouvelle page /password-set publique (validation token, formulaire, hash
  bcrypt) au style aligné sur /login, avec emails multipart texte+HTML.
- nouvelle page /profile (changement mdp avec ancien, reset 2FA, avatar,
  infos), accessible via dropdown sur le widget user en bas de sidebar.
- restriction d'accès par utilisateur : champ allowed_classes dans auth.yaml,
  multi-select dans la page Users, filtrage cross-page (KPIs, sanctions,
  classes, apprentis, navigations cross-page, génération PDF avis).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:52:10 +02:00

303 lines
9.3 KiB
Python

"""Page /password-set — définition / réinitialisation de mot de passe via token.
Page **publique** : accessible sans authentification, via un lien envoyé par email.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import bcrypt
import yaml
import reflex as rx
_ROOT = Path(__file__).resolve().parent.parent.parent
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from src.password_tokens import validate_token, consume_token # noqa: E402
from src.logger import app_log # noqa: E402
from ..state import AuthState
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
AUTH_FILE = DATA_DIR / "auth.yaml"
def _load_auth() -> dict:
if AUTH_FILE.exists():
with open(AUTH_FILE, encoding="utf-8") as f:
return yaml.safe_load(f) or {}
return {}
def _save_auth(cfg: dict) -> None:
with open(AUTH_FILE, "w", encoding="utf-8") as f:
yaml.dump(cfg, f, allow_unicode=True)
# ── State ─────────────────────────────────────────────────────────────────────
class PasswordSetState(AuthState):
# Lecture token
token: str = ""
token_valid: bool = False
token_username: str = ""
token_name: str = ""
token_kind: str = "" # "set" | "reset"
token_error: str = ""
# Form
new_pwd: str = ""
confirm_pwd: str = ""
form_error: str = ""
success: bool = False
def set_new_pwd(self, v: str): self.new_pwd = v
def set_confirm_pwd(self, v: str): self.confirm_pwd = v
def load_data(self):
# Reset state
self.token_valid = False
self.token_username = ""
self.token_name = ""
self.token_kind = ""
self.token_error = ""
self.new_pwd = ""
self.confirm_pwd = ""
self.form_error = ""
self.success = False
# Récupérer le token depuis les query params (?token=...)
try:
params = self.router.url.query_parameters
token = params.get("token", "") if params else ""
except Exception:
token = ""
self.token = token or ""
if not self.token:
self.token_error = "Lien invalide : token manquant."
return
info = validate_token(self.token)
if not info:
self.token_error = (
"Ce lien n'est plus valide ou a expiré. "
"Demandez à un administrateur de vous en envoyer un nouveau."
)
return
# Récupérer les infos utilisateur
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
username = info["username"]
user = users.get(username)
if not user:
self.token_error = "Utilisateur introuvable."
return
self.token_valid = True
self.token_username = username
self.token_name = user.get("name", username)
self.token_kind = info["type"]
def submit(self, form_data: dict | None = None):
self.form_error = ""
if not self.token_valid:
return
if len(self.new_pwd) < 8:
self.form_error = "Le mot de passe doit faire au moins 8 caractères."
return
if self.new_pwd != self.confirm_pwd:
self.form_error = "Les mots de passe ne correspondent pas."
return
# Re-vérifier le token (peut avoir expiré entretemps)
info = validate_token(self.token)
if not info:
self.token_valid = False
self.token_error = "Ce lien n'est plus valide. Demandez-en un nouveau."
return
username = info["username"]
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
if username not in users:
self.form_error = "Utilisateur introuvable."
return
# Hash + sauvegarde
users[username]["password"] = bcrypt.hashpw(
self.new_pwd.encode(), bcrypt.gensalt(12)
).decode()
_save_auth(cfg)
# Token consommé
consume_token(self.token)
kind_label = "défini" if self.token_kind == "set" else "réinitialisé"
app_log(f"[auth] mot de passe {kind_label} pour {username} via token")
self.success = True
self.new_pwd = ""
self.confirm_pwd = ""
def go_to_login(self):
return rx.redirect("/login")
# ── UI ────────────────────────────────────────────────────────────────────────
def _logo() -> rx.Component:
return rx.center(
rx.image(src="/logo.png", width="320px", height="auto"),
width="100%",
)
def _form_content() -> rx.Component:
return rx.form(
rx.vstack(
_logo(),
rx.heading(
rx.cond(
PasswordSetState.token_kind == "set",
"Activez votre compte",
"Réinitialisation du mot de passe",
),
size="4", color="#37474f",
),
rx.text(
"Compte : ",
rx.text.strong(PasswordSetState.token_username),
"",
PasswordSetState.token_name,
size="2", color="var(--gray-11)", text_align="center",
),
rx.cond(
PasswordSetState.token_kind == "set",
rx.callout.root(
rx.callout.icon(rx.icon("info", size=16)),
rx.callout.text(
"Après définition de votre mot de passe, vous pourrez vous "
"connecter et configurer un second facteur d'authentification "
"(application Authenticator)."
),
color_scheme="blue", variant="soft", size="1",
),
rx.fragment(),
),
rx.input(
name="new_pwd",
value=PasswordSetState.new_pwd,
on_change=PasswordSetState.set_new_pwd,
type="password",
placeholder="Nouveau mot de passe (8 caractères min.)",
width="100%",
auto_focus=True,
),
rx.input(
name="confirm_pwd",
value=PasswordSetState.confirm_pwd,
on_change=PasswordSetState.set_confirm_pwd,
type="password",
placeholder="Confirmer le mot de passe",
width="100%",
),
rx.cond(
PasswordSetState.form_error != "",
rx.box(
rx.text(PasswordSetState.form_error, color="red", size="2"),
padding="0.5rem 1rem",
background_color="#fff5f5",
border="1px solid #ffcccc",
border_radius="6px",
width="100%",
),
rx.fragment(),
),
rx.button(
"Définir mon mot de passe",
type="submit",
width="100%",
color_scheme="indigo",
),
spacing="3",
width="100%",
align="center",
),
on_submit=PasswordSetState.submit,
width="100%",
)
def _success_content() -> rx.Component:
return rx.vstack(
_logo(),
rx.icon("circle-check-big", size=42, color="#15803d"),
rx.heading("Mot de passe enregistré", size="4", color="#15803d"),
rx.text(
"Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.",
size="2", color="var(--gray-11)", text_align="center",
),
rx.button(
"Aller à la page de connexion",
on_click=PasswordSetState.go_to_login,
width="100%",
color_scheme="indigo",
),
spacing="3",
width="100%",
align="center",
)
def _error_content() -> rx.Component:
return rx.vstack(
_logo(),
rx.icon("triangle-alert", size=42, color="#b91c1c"),
rx.heading("Lien invalide", size="4", color="#7f1d1d"),
rx.text(
PasswordSetState.token_error,
size="2", color="var(--gray-11)", text_align="center",
),
rx.button(
"Aller à la page de connexion",
on_click=PasswordSetState.go_to_login,
width="100%",
variant="soft",
color_scheme="gray",
),
spacing="3",
width="100%",
align="center",
)
def password_set_page() -> rx.Component:
return rx.center(
rx.box(
rx.cond(
PasswordSetState.success,
_success_content(),
rx.cond(
PasswordSetState.token_valid,
_form_content(),
_error_content(),
),
),
width="420px",
padding="2rem",
background_color="white",
border_radius="8px",
box_shadow="0 2px 16px rgba(0,0,0,0.08)",
),
width="100%",
height="100vh",
background_color="#f8f9fa",
)