eptm_dashboard/src/password_emails.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

128 lines
4 KiB
Python

"""Envoi des emails de définition / réinitialisation de mot de passe."""
from __future__ import annotations
import json
import os
from datetime import datetime
from pathlib import Path
from urllib.parse import quote
from src.email_sender import send_email
_ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
_SETTINGS_PATH = _DATA_DIR / "settings.json"
_TEMPLATES_DIR = _DATA_DIR / "email_templates"
def _load_settings() -> dict:
if _SETTINGS_PATH.exists():
try:
return json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
return {}
def _read_template(name: str) -> str:
path = _TEMPLATES_DIR / name
if not path.exists():
raise FileNotFoundError(
f"Template manquant : {path}. Créez data/email_templates/{name}."
)
return path.read_text(encoding="utf-8")
def _read_optional_template(name: str) -> str | None:
path = _TEMPLATES_DIR / name
if not path.exists():
return None
return path.read_text(encoding="utf-8")
def _format_expiry(dt: datetime) -> str:
return dt.strftime("%d.%m.%Y à %H:%M")
def _format_ttl_human(dt: datetime) -> str:
"""Renvoie 'dans X h' ou 'dans X jours'."""
delta = dt - datetime.now()
total_seconds = max(0, int(delta.total_seconds()))
if total_seconds < 3600:
mins = total_seconds // 60
return f"valable {mins} minute{'s' if mins > 1 else ''}"
hours = total_seconds // 3600
if hours < 48:
return f"valable {hours} heure{'s' if hours > 1 else ''}"
days = hours // 24
return f"valable {days} jour{'s' if days > 1 else ''}"
def send_password_email(
*,
kind: str, # "welcome" | "reset"
username: str,
name: str,
email: str,
token: str,
expires_at: datetime,
) -> None:
"""Envoie un email de définition (kind='welcome') ou de reset (kind='reset').
Lève une exception en cas d'erreur (SMTP, template manquant, base_url manquante).
"""
if kind not in ("welcome", "reset"):
raise ValueError(f"kind must be 'welcome' or 'reset', got {kind!r}")
if not email or "@" not in email:
raise ValueError(f"Email invalide pour {username!r}")
settings = _load_settings()
base_url = (settings.get("app_base_url") or "").rstrip("/")
if not base_url:
raise ValueError(
"URL de base manquante. Configurez `app_base_url` dans Paramètres."
)
smtp_host = settings.get("smtp_host")
smtp_port = int(settings.get("smtp_port") or 587)
smtp_login = settings.get("smtp_login")
smtp_password = settings.get("smtp_password")
smtp_sender = settings.get("smtp_sender")
if not (smtp_host and smtp_login and smtp_password and smtp_sender):
raise ValueError(
"Configuration SMTP incomplète. Vérifiez Paramètres → SMTP."
)
link = f"{base_url}/password-set?token={quote(token)}"
vars_ = {
"name": name,
"username": username,
"link": link,
"expiry": _format_expiry(expires_at),
"ttl_human": _format_ttl_human(expires_at),
}
template_text_name = "welcome.txt" if kind == "welcome" else "reset.txt"
template_html_name = "welcome.html" if kind == "welcome" else "reset.html"
body_text = _read_template(template_text_name).format_map(vars_)
html_raw = _read_optional_template(template_html_name)
body_html = html_raw.format_map(vars_) if html_raw else None
subject = (
"EPTM Dashboard — Définissez votre mot de passe"
if kind == "welcome"
else "EPTM Dashboard — Réinitialisation de votre mot de passe"
)
send_email(
smtp_host=smtp_host,
smtp_port=smtp_port,
smtp_login=smtp_login,
smtp_password=smtp_password,
smtp_sender=smtp_sender,
to_email=email,
subject=subject,
body=body_text,
body_html=body_html,
attachments=None,
)