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>
This commit is contained in:
Julien Balet 2026-05-10 19:52:10 +02:00
parent 41c050d2d4
commit 43a2196150
20 changed files with 1968 additions and 321 deletions

1
.gitignore vendored
View file

@ -14,6 +14,7 @@ data/pdfs/
data/sync_*.json
data/debug_*.png
data/*.bak.*
data/password_tokens.json
# Logs cron (runtime)
logs/

View file

@ -13,8 +13,11 @@ credentials:
smtp_password: 17acdfd671d8ab
totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY
test:
allowed_classes:
- AUTOMAT 1
- EM-AU 1
email: julien@balet-vs.ch
name: test
password: $2b$12$nYZqG/bStQwweDjvR/8RNOqP6AnUDh1Dictx3BCZ2RalIyWDbre42
password: $2b$12$dAvkehPvcU5zokLhUzjswu7APcRi1e4C1IeR6Gc7/51wh9vGTl4MS
role: user
totp_secret: TCH5IQCRIAVPZEFFUABEVXUCV7TOL5XP
totp_secret: SVX56DCKFEOYRDPLUTML2YN6RUCME3AA

View file

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Réinitialisation de votre mot de passe</title>
</head>
<body style="margin:0;padding:24px;background:#f8f9fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1f2937;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:10px;border:1px solid #e5e7eb;">
<tr>
<td style="padding:28px 32px 8px 32px;">
<h1 style="margin:0 0 8px 0;font-size:20px;color:#1e293b;">Bonjour {name},</h1>
<p style="margin:0;color:#475569;font-size:14px;">Une demande de réinitialisation de mot de passe a été effectuée pour votre compte <strong>EPTM Dashboard</strong>.</p>
</td>
</tr>
<tr>
<td style="padding:8px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="background:#f1f5f9;border-radius:6px;width:100%;">
<tr>
<td style="padding:10px 14px;font-size:13px;color:#334155;">
<strong>Identifiant :</strong> {username}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:16px 32px;">
<p style="margin:0 0 16px 0;font-size:14px;color:#475569;">
Pour définir un nouveau mot de passe :
</p>
<p style="margin:0 0 12px 0;text-align:center;">
<a href="{link}" style="display:inline-block;padding:12px 24px;background:#dc000e;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;font-size:14px;">
Réinitialiser mon mot de passe
</a>
</p>
<p style="margin:16px 0 0 0;font-size:12px;color:#94a3b8;text-align:center;">
Ou copiez ce lien dans votre navigateur :<br>
<a href="{link}" style="color:#dc000e;word-break:break-all;">{link}</a>
</p>
</td>
</tr>
<tr>
<td style="padding:0 32px 16px 32px;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">
Lien valable jusqu'au <strong>{expiry}</strong> ({ttl_human}).
</p>
</td>
</tr>
<tr>
<td style="padding:0 32px 28px 32px;border-top:1px solid #e5e7eb;">
<p style="margin:16px 0 4px 0;font-size:12px;color:#94a3b8;">
Si vous n'êtes pas à l'origine de cette demande, ignorez cet email — votre mot de passe actuel reste inchangé.
</p>
<p style="margin:0;font-size:12px;color:#94a3b8;">
Cordialement,<br>L'équipe EPTM
</p>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,14 @@
Bonjour {name},
Une demande de réinitialisation de mot de passe a été effectuée pour votre compte EPTM Dashboard ({username}).
Pour définir un nouveau mot de passe, cliquez sur le lien ci-dessous :
{link}
Ce lien est valable jusqu'au {expiry} ({ttl_human}).
Si vous n'êtes pas à l'origine de cette demande, ignorez cet email — votre mot de passe actuel reste inchangé.
Cordialement,
L'équipe EPTM

View file

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Activation de votre compte EPTM</title>
</head>
<body style="margin:0;padding:24px;background:#f8f9fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1f2937;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="max-width:560px;margin:0 auto;background:#ffffff;border-radius:10px;border:1px solid #e5e7eb;">
<tr>
<td style="padding:28px 32px 8px 32px;">
<h1 style="margin:0 0 8px 0;font-size:20px;color:#1e293b;">Bienvenue {name},</h1>
<p style="margin:0;color:#475569;font-size:14px;">Un compte a été créé pour vous sur <strong>EPTM Dashboard</strong>.</p>
</td>
</tr>
<tr>
<td style="padding:8px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="background:#f1f5f9;border-radius:6px;width:100%;">
<tr>
<td style="padding:10px 14px;font-size:13px;color:#334155;">
<strong>Identifiant :</strong> {username}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:16px 32px;">
<p style="margin:0 0 16px 0;font-size:14px;color:#475569;">
Pour activer votre compte, définissez votre mot de passe :
</p>
<p style="margin:0 0 12px 0;text-align:center;">
<a href="{link}" style="display:inline-block;padding:12px 24px;background:#dc000e;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;font-size:14px;">
Définir mon mot de passe
</a>
</p>
<p style="margin:16px 0 0 0;font-size:12px;color:#94a3b8;text-align:center;">
Ou copiez ce lien dans votre navigateur :<br>
<a href="{link}" style="color:#dc000e;word-break:break-all;">{link}</a>
</p>
</td>
</tr>
<tr>
<td style="padding:0 32px 16px 32px;">
<p style="margin:0;font-size:12px;color:#94a3b8;text-align:center;">
Lien valable jusqu'au <strong>{expiry}</strong> ({ttl_human}).
</p>
</td>
</tr>
<tr>
<td style="padding:8px 32px 24px 32px;">
<p style="margin:0;font-size:13px;color:#475569;background:#fef3c7;padding:12px 14px;border-radius:6px;border:1px solid #fcd34d;">
🔐 À votre prochaine connexion, l'application vous demandera de configurer un second facteur d'authentification (TOTP) via une application comme Google Authenticator, Authy, 1Password ou Microsoft Authenticator.
</p>
</td>
</tr>
<tr>
<td style="padding:0 32px 28px 32px;border-top:1px solid #e5e7eb;">
<p style="margin:16px 0 4px 0;font-size:12px;color:#94a3b8;">
Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.
</p>
<p style="margin:0;font-size:12px;color:#94a3b8;">
Cordialement,<br>L'équipe EPTM
</p>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,16 @@
Bienvenue {name},
Un compte a été créé pour vous sur EPTM Dashboard avec l'identifiant : {username}
Pour activer votre compte, définissez votre mot de passe en cliquant sur le lien ci-dessous :
{link}
Ce lien est valable jusqu'au {expiry} ({ttl_human}).
À votre prochaine connexion, l'application vous demandera de configurer un second facteur d'authentification (TOTP) via une application comme Google Authenticator, Authy, 1Password ou Microsoft Authenticator.
Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.
Cordialement,
L'équipe EPTM

View file

@ -6,5 +6,6 @@
"smtp_sender": "EPTM Automation <noreply@eptm-automation.ch>",
"escada_username": "julien.balet",
"escada_password": "Lauryne2023!",
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR"
"totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
"app_base_url": "https://dev.dashboard.eptm-automation.ch"
}

View file

@ -11,6 +11,8 @@ from .pages.users import users_page, UsersState
from .pages.params import params_page, ParamsState
from .pages.purge import purge_page, PurgeState
from .pages.doc import doc_page, DocState
from .pages.profile import profile_page, ProfileState
from .pages.password_set import password_set_page, PasswordSetState
TITLE = "EPTM Dashboard"
@ -47,3 +49,6 @@ app.add_page(users_page, route="/users", on_load=[AuthState.check_auth,
app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)
app.add_page(purge_page, route="/purge", on_load=[AuthState.check_auth, PurgeState.load_data], title=TITLE)
app.add_page(doc_page, route="/doc", on_load=[AuthState.check_auth, DocState.load_data], title=TITLE)
app.add_page(profile_page, route="/profile", on_load=[AuthState.check_auth, ProfileState.load_data], title=TITLE)
# Page publique (pas de check_auth — accessible via lien email)
app.add_page(password_set_page, route="/password-set", on_load=PasswordSetState.load_data, title=TITLE)

View file

@ -4,10 +4,12 @@ from collections import defaultdict
sys.path.insert(0, "/opt/eptm-dashboard")
import reflex as rx
from src.db import get_session
from src.db import get_session, Apprenti, Absence
from src.stats import kpis, alertes_quota_absences
from src.sanction_pdf import generate_avis_pdf
from src.logger import app_log
from src.user_access import get_allowed_classes, is_class_allowed
from sqlalchemy import select, func
from ..state import AuthState
from ..sidebar import layout
from .fiche import FicheState
@ -30,10 +32,18 @@ class AccueilState(AuthState):
try:
sess = get_session()
try:
k = kpis(sess)
self.kpi_mois = k["total_ce_mois"]
self.kpi_total = k["total_global"]
self.kpi_traiter = k["n_a_traiter"]
allowed = get_allowed_classes(self.username)
# KPIs : recalcul filtré si l'utilisateur est restreint
if allowed is None:
k = kpis(sess)
self.kpi_mois = k["total_ce_mois"]
self.kpi_total = k["total_global"]
self.kpi_traiter = k["n_a_traiter"]
else:
self.kpi_mois, self.kpi_total, self.kpi_traiter = self._filtered_kpis(
sess, allowed,
)
df = alertes_quota_absences(sess, seuil=5)
items = [
@ -46,6 +56,9 @@ class AccueilState(AuthState):
}
for _, row in df.iterrows()
]
# Filtrage selon les classes autorisées
if allowed is not None:
items = [it for it in items if it["classe"] in allowed]
# Groupement par classe (tri alphabétique des classes,
# puis par nom dans chaque classe).
grouped: dict[str, list[dict]] = defaultdict(list)
@ -66,6 +79,38 @@ class AccueilState(AuthState):
except Exception as e:
print(f"[accueil] erreur: {e}")
@staticmethod
def _filtered_kpis(sess, allowed: list[str]) -> tuple[int, int, int]:
"""Recalcule les KPIs sur les apprentis appartenant aux classes autorisées."""
from datetime import date as _date
if not allowed:
return 0, 0, 0
ids = sess.execute(
select(Apprenti.id).where(Apprenti.classe.in_(allowed))
).scalars().all()
ids = list(ids)
if not ids:
return 0, 0, 0
today = _date.today()
first_of_month = today.replace(day=1)
# Total ce mois (même logique : count d'absences sur la période)
total_mois = sess.execute(
select(func.count(Absence.id)).where(
Absence.apprenti_id.in_(ids),
Absence.date >= first_of_month,
)
).scalar() or 0
total_global = sess.execute(
select(func.count(Absence.id)).where(Absence.apprenti_id.in_(ids))
).scalar() or 0
n_traiter = sess.execute(
select(func.count(Absence.id)).where(
Absence.apprenti_id.in_(ids),
Absence.statut == "a_traiter",
)
).scalar() or 0
return int(total_mois), int(total_global), int(n_traiter)
# ── Navigation cross-page (pré-sélection) ────────────────────────────────
def open_fiche(self, apprenti_id: int):
@ -83,6 +128,9 @@ class AccueilState(AuthState):
# ── Téléchargement de l'avis de sanction ─────────────────────────────────
def download_avis(self, apprenti_id: int, nom: str, prenom: str, classe: str):
# Garde-fou : refuse si la classe n'est pas autorisée
if not is_class_allowed(self.username, classe):
return rx.toast.error("Accès refusé pour cette classe.")
sess = get_session()
try:
data = generate_avis_pdf(

View file

@ -17,6 +17,7 @@ from src.db import (
)
from src.stats import nb_blocs_absences, synthese_classe
from src.parser_bn import sem_short_label, sem_year_only
from src.user_access import get_allowed_classes, is_class_allowed
QUOTA = 5
@ -379,6 +380,10 @@ class ClasseState(AuthState):
).scalars().all()
# Filtrer les classes MP / MI (formations maturité, hors scope)
classes = [c for c in classes if c and not c.startswith(("MP", "MI"))]
# Filtrer selon les classes autorisées pour cet utilisateur
allowed = get_allowed_classes(self.username)
if allowed is not None:
classes = [c for c in classes if c in allowed]
if not classes:
self.has_classes = False
self.classes = []
@ -392,6 +397,9 @@ class ClasseState(AuthState):
self._reload()
def set_class(self, classe: str):
# Garde-fou : refuse une classe non autorisée
if not is_class_allowed(self.username, classe):
return
self.selected_class = classe
self.class_select_open = False
self.class_search = ""

View file

@ -22,6 +22,7 @@ from src.stats import nb_blocs_absences
from src.parser_bn import sem_short_label, sem_year_only
from src.email_sender import build_template_vars, render_template
from src.logger import app_log
from src.user_access import get_allowed_classes, is_class_allowed
from ..components import empty_state
MOIS_FR = [
@ -503,9 +504,11 @@ class FicheState(AuthState):
if not self.authenticated:
return rx.redirect("/login")
sess = get_session()
apprentis = sess.execute(
select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom)
).scalars().all()
allowed = get_allowed_classes(self.username)
q = select(Apprenti).order_by(Apprenti.nom, Apprenti.prenom)
if allowed is not None:
q = q.where(Apprenti.classe.in_(allowed))
apprentis = sess.execute(q).scalars().all()
if not apprentis:
self.has_apprentis = False
self.apprenti_labels = []
@ -552,6 +555,18 @@ class FicheState(AuthState):
self.apprenti_search = ""
def navigate_to(self, apprenti_id: int):
# Garde-fou : revérifie que l'apprenti est dans le scope autorisé
sess = get_session()
try:
ap = sess.get(Apprenti, apprenti_id)
if ap is None or not is_class_allowed(self.username, ap.classe):
return
finally:
sess.close()
# Si l'apprenti n'est pas dans la liste actuelle (ex: liste pas encore
# chargée), on la recharge — load_data filtre déjà selon les droits.
if apprenti_id not in self.apprenti_ids:
self.load_data()
if apprenti_id in self.apprenti_ids:
idx = self.apprenti_ids.index(apprenti_id)
self.selected_id = apprenti_id

View file

@ -63,6 +63,10 @@ class ParamsState(AuthState):
email_body: str = ""
save_ok_template: bool = False
# ── App ───────────────────────────────────────────────────────────────────
app_base_url: str = ""
save_ok_app: bool = False
# ── Setters ───────────────────────────────────────────────────────────────
def set_texte_sanction(self, v: str): self.texte_sanction = v
def set_chef_section(self, v: str): self.chef_section = v
@ -76,6 +80,7 @@ class ParamsState(AuthState):
def set_totp_secret(self, v: str): self.totp_secret = v
def set_email_subject(self, v: str): self.email_subject = v
def set_email_body(self, v: str): self.email_body = v
def set_app_base_url(self, v: str): self.app_base_url = v
def load_data(self):
if not self.authenticated:
@ -93,10 +98,12 @@ class ParamsState(AuthState):
self.totp_secret = s.get("totp_secret", "")
self.email_subject = s.get("email_subject", _DEFAULT_TEMPLATE_SUBJ)
self.email_body = s.get("email_body", _DEFAULT_TEMPLATE_BODY)
self.app_base_url = s.get("app_base_url", "https://dev.dashboard.eptm-automation.ch")
self.save_ok_sanction = False
self.save_ok_smtp = False
self.save_ok_escada = False
self.save_ok_template = False
self.save_ok_app = False
def save_sanctions(self):
s = _read_settings()
@ -145,6 +152,17 @@ class ParamsState(AuthState):
self.save_ok_sanction = False
self.save_ok_smtp = False
self.save_ok_escada = False
self.save_ok_app = False
def save_app(self):
s = _read_settings()
s["app_base_url"] = self.app_base_url.strip().rstrip("/")
_write_settings(s)
self.save_ok_app = True
self.save_ok_sanction = False
self.save_ok_smtp = False
self.save_ok_escada = False
self.save_ok_template = False
# ── UI helpers ────────────────────────────────────────────────────────────────
@ -427,12 +445,43 @@ def _section_template() -> rx.Component:
)
def _section_app() -> rx.Component:
return _section(
"Application",
rx.text(
"URL de base de l'application — utilisée pour générer les liens "
"envoyés par email (création de compte, réinitialisation de mot de passe).",
size="1", color="var(--gray-11)",
),
_field(
"URL de base (sans /)",
rx.input(
value=ParamsState.app_base_url,
on_change=ParamsState.set_app_base_url,
placeholder="https://dashboard.eptm-automation.ch",
width="100%",
),
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Enregistrer",
on_click=ParamsState.save_app,
color_scheme="blue", variant="solid", size="2",
),
_save_ok_callout(ParamsState.save_ok_app),
spacing="3", align="center", flex_wrap="wrap",
),
)
# ── Page ──────────────────────────────────────────────────────────────────────
def params_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Paramètres", size="7"),
_section_app(),
_section_sanction(),
_section_smtp(),
_section_escada(),

View file

@ -0,0 +1,303 @@
"""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",
)

View file

@ -0,0 +1,468 @@
"""Page /profile — gestion du compte de l'utilisateur connecté."""
from __future__ import annotations
import os
import sys
import time
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.logger import app_log # noqa: E402
from ..state import AuthState
from ..sidebar import layout
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
AUTH_FILE = DATA_DIR / "auth.yaml"
_AVATARS_DIR = _ROOT / "assets" / "avatars"
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 ProfileState(AuthState):
profile_name: str = ""
profile_email: str = ""
profile_role: str = ""
profile_has_totp: bool = False
profile_avatar: str = ""
# Form: change name/email
edit_name: str = ""
edit_email: str = ""
info_ok: bool = False
info_error: str = ""
# Form: change password
pwd_current: str = ""
pwd_new: str = ""
pwd_confirm: str = ""
pwd_ok: bool = False
pwd_error: str = ""
# 2FA
totp_reset_ok: bool = False
# Avatar
upload_ok: bool = False
def set_edit_name(self, v: str): self.edit_name = v
def set_edit_email(self, v: str): self.edit_email = v
def set_pwd_current(self, v: str): self.pwd_current = v
def set_pwd_new(self, v: str): self.pwd_new = v
def set_pwd_confirm(self, v: str): self.pwd_confirm = v
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
u = users.get(self.username, {})
self.profile_name = u.get("name", self.username)
self.profile_email = u.get("email", "")
self.profile_role = u.get("role", "user")
self.profile_has_totp = bool(u.get("totp_secret"))
self.profile_avatar = u.get("avatar_url", "")
self.edit_name = self.profile_name
self.edit_email = self.profile_email
self.info_ok = False
self.info_error = ""
self.pwd_current = ""
self.pwd_new = ""
self.pwd_confirm = ""
self.pwd_ok = False
self.pwd_error = ""
self.totp_reset_ok = False
self.upload_ok = False
def save_info(self):
self.info_error = ""
self.info_ok = False
if not self.edit_name.strip():
self.info_error = "Le nom ne peut pas être vide."
return
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
u = users.get(self.username)
if not u:
self.info_error = "Compte introuvable."
return
u["name"] = self.edit_name.strip()
u["email"] = self.edit_email.strip()
_save_auth(cfg)
self.profile_name = u["name"]
self.profile_email = u["email"]
self.name = u["name"] # Met à jour le nom dans la sidebar (LocalStorage)
self.info_ok = True
app_log(f"[profile] {self.username} : informations mises à jour")
def save_password(self):
self.pwd_error = ""
self.pwd_ok = False
if len(self.pwd_new) < 8:
self.pwd_error = "Le nouveau mot de passe doit faire au moins 8 caractères."
return
if self.pwd_new != self.pwd_confirm:
self.pwd_error = "Les mots de passe ne correspondent pas."
return
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
u = users.get(self.username)
if not u:
self.pwd_error = "Compte introuvable."
return
try:
ok = bcrypt.checkpw(self.pwd_current.encode(), u.get("password", "").encode())
except Exception:
ok = False
if not ok:
self.pwd_error = "Mot de passe actuel incorrect."
return
u["password"] = bcrypt.hashpw(self.pwd_new.encode(), bcrypt.gensalt(12)).decode()
_save_auth(cfg)
self.pwd_current = ""
self.pwd_new = ""
self.pwd_confirm = ""
self.pwd_ok = True
app_log(f"[profile] {self.username} : mot de passe modifié")
def reset_my_totp(self):
cfg = _load_auth()
users = cfg.get("credentials", {}).get("usernames", {})
u = users.get(self.username)
if not u:
return
u["totp_secret"] = None
_save_auth(cfg)
self.profile_has_totp = False
self.totp_reset_ok = True
app_log(f"[profile] {self.username} : 2FA réinitialisé")
async def handle_avatar_upload(self, files: list[rx.UploadFile]):
if not files:
return
file = files[0]
data = await file.read()
if not data:
return
uname = self.username
fname = (getattr(file, "filename", None) or "photo.jpg").lower()
ext = fname.rsplit(".", 1)[-1] if "." in fname else "jpg"
if ext not in ("jpg", "jpeg", "png", "gif", "webp"):
ext = "jpg"
if ext == "jpeg":
ext = "jpg"
_AVATARS_DIR.mkdir(parents=True, exist_ok=True)
for old in _AVATARS_DIR.glob(f"{uname}.*"):
old.unlink(missing_ok=True)
(_AVATARS_DIR / f"{uname}.{ext}").write_bytes(data)
url = f"/avatars/{uname}.{ext}?t={int(time.time())}"
cfg = _load_auth()
cfg["credentials"]["usernames"][uname]["avatar_url"] = url
_save_auth(cfg)
self.photo_url = url
self.profile_avatar = url
self.upload_ok = True
def remove_avatar(self):
uname = self.username
for old in _AVATARS_DIR.glob(f"{uname}.*"):
old.unlink(missing_ok=True)
cfg = _load_auth()
cfg["credentials"]["usernames"][uname].pop("avatar_url", None)
_save_auth(cfg)
self.photo_url = ""
self.profile_avatar = ""
self.upload_ok = False
# ── UI helpers ────────────────────────────────────────────────────────────────
def _label(text: str) -> rx.Component:
return rx.text(text, size="2", weight="medium", color="var(--gray-11)")
def _ok(show, msg) -> rx.Component:
return rx.cond(
show,
rx.callout.root(
rx.callout.icon(rx.icon("check", size=16)),
rx.callout.text(msg),
color_scheme="green", variant="soft", size="1",
),
rx.fragment(),
)
def _err(msg) -> rx.Component:
return rx.cond(
msg != "",
rx.callout.root(
rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(msg),
color_scheme="red", variant="soft", size="1",
),
rx.fragment(),
)
# ── Sections ──────────────────────────────────────────────────────────────────
def _avatar_section() -> rx.Component:
has_photo = ProfileState.profile_avatar != ""
return rx.box(
rx.vstack(
rx.text("Photo de profil", size="3", weight="bold"),
rx.hstack(
rx.cond(
has_photo,
rx.image(
src=ProfileState.profile_avatar,
width="64px", height="64px",
border_radius="50%", object_fit="cover",
border="2px solid var(--gray-4)",
),
rx.image(
src="/default_avatar.svg",
width="64px", height="64px", border_radius="50%",
),
),
rx.vstack(
rx.upload.root(
rx.button(
rx.icon("upload", size=15),
rx.cond(has_photo, "Changer la photo", "Choisir une photo"),
variant="outline", color_scheme="blue", size="2",
),
id="profile_avatar_upload",
on_drop=ProfileState.handle_avatar_upload,
accept={"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"],
"image/gif": [".gif"], "image/webp": [".webp"]},
max_files=1, multiple=False,
),
rx.cond(
has_photo,
rx.button(
rx.icon("trash-2", size=14),
"Supprimer",
on_click=ProfileState.remove_avatar,
variant="ghost", color_scheme="red", size="1",
),
rx.fragment(),
),
_ok(ProfileState.upload_ok, "Photo mise à jour."),
spacing="2", align="start",
),
spacing="4", align="center", flex_wrap="wrap",
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
)
def _info_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.text("Informations", size="3", weight="bold"),
rx.hstack(
rx.vstack(
_label("Identifiant"),
rx.input(value=ProfileState.username, disabled=True, width="100%"),
spacing="1", flex="1", min_width="0", width="100%",
),
rx.vstack(
_label("Rôle"),
rx.input(value=ProfileState.profile_role, disabled=True, width="100%"),
spacing="1", flex="1", min_width="0", width="100%",
),
spacing="4", width="100%", flex_wrap="wrap",
),
rx.hstack(
rx.vstack(
_label("Nom affiché"),
rx.input(
value=ProfileState.edit_name,
on_change=ProfileState.set_edit_name,
width="100%",
),
spacing="1", flex="1", min_width="0", width="100%",
),
rx.vstack(
_label("Email"),
rx.input(
value=ProfileState.edit_email,
on_change=ProfileState.set_edit_email,
placeholder="email@domaine.ch",
width="100%",
),
spacing="1", flex="1", min_width="0", width="100%",
),
spacing="4", width="100%", flex_wrap="wrap",
),
rx.hstack(
rx.button(
rx.icon("save", size=16),
"Mettre à jour",
on_click=ProfileState.save_info,
color_scheme="blue", size="2",
),
_ok(ProfileState.info_ok, "Informations enregistrées."),
_err(ProfileState.info_error),
spacing="3", align="center", flex_wrap="wrap",
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
)
def _password_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.text("Changer mon mot de passe", size="3", weight="bold"),
rx.vstack(
_label("Mot de passe actuel"),
rx.input(
value=ProfileState.pwd_current,
on_change=ProfileState.set_pwd_current,
type="password", width="100%",
),
spacing="1", width="100%",
),
rx.hstack(
rx.vstack(
_label("Nouveau mot de passe"),
rx.input(
value=ProfileState.pwd_new,
on_change=ProfileState.set_pwd_new,
type="password", placeholder="Minimum 8 caractères",
width="100%",
),
spacing="1", flex="1", min_width="0", width="100%",
),
rx.vstack(
_label("Confirmer"),
rx.input(
value=ProfileState.pwd_confirm,
on_change=ProfileState.set_pwd_confirm,
type="password", width="100%",
),
spacing="1", flex="1", min_width="0", width="100%",
),
spacing="4", width="100%", flex_wrap="wrap",
),
rx.hstack(
rx.button(
rx.icon("lock", size=16),
"Mettre à jour le mot de passe",
on_click=ProfileState.save_password,
color_scheme="blue", size="2",
),
_ok(ProfileState.pwd_ok, "Mot de passe modifié."),
_err(ProfileState.pwd_error),
spacing="3", align="center", flex_wrap="wrap",
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
)
def _totp_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.text("Authentification à 2 facteurs", size="3", weight="bold"),
rx.hstack(
rx.text("Statut :", size="2", flex_shrink="0"),
rx.cond(
ProfileState.totp_reset_ok,
rx.badge("Réinitialisé", color_scheme="orange", variant="soft"),
rx.cond(
ProfileState.profile_has_totp,
rx.badge("Actif", color_scheme="green", variant="soft"),
rx.badge("Non configuré", color_scheme="gray", variant="soft"),
),
),
spacing="2", align="center",
),
rx.cond(
ProfileState.profile_has_totp & ~ProfileState.totp_reset_ok,
rx.vstack(
rx.text(
"Réinitialise ton 2FA si tu as perdu accès à ton application "
"d'authentification. Un nouveau QR code sera affiché à ta prochaine connexion.",
size="1", color="var(--gray-11)",
),
rx.button(
rx.icon("rotate-ccw", size=16),
"Réinitialiser mon 2FA",
on_click=ProfileState.reset_my_totp,
color_scheme="orange", variant="outline", size="2",
),
spacing="2", align="start", width="100%",
),
rx.fragment(),
),
rx.cond(
~ProfileState.profile_has_totp,
rx.text(
"Un QR code sera demandé à ta prochaine connexion pour configurer ton 2FA.",
size="1", color="var(--gray-10)",
),
rx.fragment(),
),
spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
)
def profile_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Mon profil", size="6"),
_avatar_section(),
_info_section(),
_password_section(),
_totp_section(),
spacing="4",
width="100%",
max_width="780px",
)
)

File diff suppressed because it is too large Load diff

View file

@ -272,35 +272,84 @@ def _avatar_or_photo(size: str = "2") -> rx.Component:
)
def _user_menu_items() -> rx.Component:
"""Items du dropdown : Mon profil + Déconnexion."""
return rx.vstack(
rx.link(
rx.flex(
rx.icon("user", size=15, color=_TEXT),
rx.text("Mon profil", size="2"),
gap="0.5rem", align="center", padding="0.4rem 0.75rem",
width="100%", _hover={"background_color": _HOVER_BG},
cursor="pointer", border_radius="4px",
),
href="/profile",
text_decoration="none",
color="inherit",
width="100%",
),
rx.flex(
rx.icon("log-out", size=15, color=_TEXT),
rx.text("Déconnexion", size="2"),
gap="0.5rem", align="center", padding="0.4rem 0.75rem",
width="100%", _hover={"background_color": _HOVER_BG},
cursor="pointer", border_radius="4px",
on_click=AuthState.logout,
),
spacing="0", width="100%",
)
def _user_widget(collapsed: bool = False) -> rx.Component:
if collapsed:
return rx.tooltip(
rx.vstack(
_avatar_or_photo(size="2"),
rx.icon_button(
rx.icon("log-out", size=14),
on_click=AuthState.logout,
variant="ghost", size="1", cursor="pointer",
return rx.popover.root(
rx.popover.trigger(
rx.tooltip(
rx.box(
_avatar_or_photo(size="2"),
cursor="pointer",
display="flex",
justify_content="center",
width="100%",
),
content=AuthState.name,
side="right",
),
spacing="2", align="center", width="100%",
),
content=AuthState.name,
side="right",
rx.popover.content(
_user_menu_items(),
min_width="180px",
padding="0.4rem",
side="right",
align="end",
),
)
return rx.hstack(
_avatar_or_photo(size="2"),
rx.vstack(
rx.text(AuthState.name, size="2", font_weight="600",
color=_TEXT, white_space="nowrap", overflow="hidden"),
rx.text(AuthState.role, size="1", color=_TEXT_MUTED),
spacing="0", align="start", overflow="hidden", flex="1",
return rx.popover.root(
rx.popover.trigger(
rx.hstack(
_avatar_or_photo(size="2"),
rx.vstack(
rx.text(AuthState.name, size="2", font_weight="600",
color=_TEXT, white_space="nowrap", overflow="hidden"),
rx.text(AuthState.role, size="1", color=_TEXT_MUTED),
spacing="0", align="start", overflow="hidden", flex="1",
),
rx.icon("chevron-up", size=14, color=_TEXT_MUTED),
spacing="2", align="center", width="100%", overflow="hidden",
cursor="pointer",
padding="0.25rem 0.5rem",
border_radius="6px",
_hover={"background_color": _HOVER_BG},
class_name="smooth-transition",
),
),
rx.icon_button(
rx.icon("log-out", size=14),
on_click=AuthState.logout,
variant="ghost", size="1", cursor="pointer",
rx.popover.content(
_user_menu_items(),
min_width="200px",
padding="0.4rem",
side="top",
align="end",
),
spacing="2", align="center", width="100%", overflow="hidden",
)

View file

@ -63,11 +63,16 @@ def send_email(
subject: str,
body: str,
attachments: "list[tuple[bytes, str]] | None" = None,
body_html: str | None = None,
) -> None:
"""Envoie un email avec pièces jointes PDF optionnelles.
smtp_login : identifiant d'authentification SMTP (peut différer de l'expéditeur).
smtp_sender : adresse expéditeur, format 'Nom <email>' ou 'email'.
body : version texte (toujours requise pour fallback).
body_html : version HTML optionnelle. Si fournie, l'email est envoyé en
multipart/alternative (les clients modernes affichent le HTML,
les anciens le texte brut).
attachments : liste de (pdf_bytes, filename).
Lève une exception en cas d'échec (SMTPException, OSError).
"""
@ -77,7 +82,15 @@ def send_email(
msg["From"] = f"{_from_name} <{_from_email}>" if _from_name else _from_email
msg["To"] = to_email
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain", "utf-8"))
if body_html:
# Encapsule texte+HTML dans un alternative pour que le client choisisse.
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(body, "plain", "utf-8"))
alt.attach(MIMEText(body_html, "html", "utf-8"))
msg.attach(alt)
else:
msg.attach(MIMEText(body, "plain", "utf-8"))
for pdf_bytes, pdf_filename in (attachments or []):
part = MIMEApplication(pdf_bytes, _subtype="pdf")

128
src/password_emails.py Normal file
View file

@ -0,0 +1,128 @@
"""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,
)

126
src/password_tokens.py Normal file
View file

@ -0,0 +1,126 @@
"""Tokens à usage unique pour la définition / réinitialisation de mot de passe.
Stockage : data/password_tokens.json (JSON)
Format :
{
"<token>": {
"username": "prof.demo",
"type": "set" | "reset",
"created_at": "2026-05-10T18:30:00",
"expires_at": "2026-05-17T18:30:00"
}
}
"""
from __future__ import annotations
import json
import os
import secrets
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
_ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
TOKENS_FILE = _DATA_DIR / "password_tokens.json"
# Durées de validité
TTL_SET = timedelta(days=7) # Création de compte
TTL_RESET = timedelta(hours=24) # Réinitialisation
def _load() -> dict:
if not TOKENS_FILE.exists():
return {}
try:
return json.loads(TOKENS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
def _save(data: dict) -> None:
TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
TOKENS_FILE.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _purge_expired(data: dict) -> dict:
"""Retourne une nouvelle dict sans les tokens expirés."""
now = datetime.now()
out = {}
for tok, info in data.items():
try:
exp = datetime.fromisoformat(info["expires_at"])
except Exception:
continue
if exp > now:
out[tok] = info
return out
def _purge_for_user(data: dict, username: str) -> dict:
"""Supprime tous les tokens existants pour un utilisateur."""
return {t: i for t, i in data.items() if i.get("username") != username}
def create_token(username: str, kind: str = "set") -> tuple[str, datetime]:
"""Crée un nouveau token. Tout token précédent du même utilisateur est révoqué.
Renvoie (token_str, expires_at).
"""
if kind not in ("set", "reset"):
raise ValueError(f"Unknown token kind: {kind!r}")
token = secrets.token_urlsafe(32)
now = datetime.now()
expires_at = now + (TTL_SET if kind == "set" else TTL_RESET)
data = _load()
data = _purge_expired(data)
data = _purge_for_user(data, username)
data[token] = {
"username": username,
"type": kind,
"created_at": now.isoformat(timespec="seconds"),
"expires_at": expires_at.isoformat(timespec="seconds"),
}
_save(data)
return token, expires_at
def validate_token(token: str) -> Optional[dict]:
"""Renvoie {username, type, expires_at} si le token est valide, sinon None."""
if not token:
return None
data = _load()
info = data.get(token)
if not info:
return None
try:
exp = datetime.fromisoformat(info["expires_at"])
except Exception:
return None
if exp <= datetime.now():
# Token expiré : on en profite pour purger
data = _purge_expired(data)
_save(data)
return None
return info
def consume_token(token: str) -> None:
"""Supprime le token (après usage)."""
data = _load()
if token in data:
del data[token]
_save(data)
def revoke_all_for_user(username: str) -> None:
"""Révoque tous les tokens d'un utilisateur (par exemple lors de la suppression)."""
data = _load()
data = _purge_for_user(data, username)
_save(data)

60
src/user_access.py Normal file
View file

@ -0,0 +1,60 @@
"""Gestion des droits d'accès aux classes par utilisateur.
Lecture du fichier `data/auth.yaml`. Les admins ont toujours accès à tout
(le champ `allowed_classes` est ignoré pour eux).
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Optional
import yaml
_ROOT = Path(__file__).resolve().parent.parent
_DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
AUTH_FILE = _DATA_DIR / "auth.yaml"
def _load_user(username: str) -> Optional[dict]:
if not AUTH_FILE.exists():
return None
with open(AUTH_FILE, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
return cfg.get("credentials", {}).get("usernames", {}).get(username)
def get_allowed_classes(username: str) -> Optional[list[str]]:
"""Retourne la liste des classes autorisées pour l'utilisateur.
- None : aucune restriction (admin, ou champ vide / absent)
- [] : restriction explicite à zéro classe (= ne voit rien)
- [...] : restreint à ces classes
"""
user = _load_user(username)
if not user:
return []
if user.get("role") == "admin":
return None
allowed = user.get("allowed_classes")
if allowed is None:
return None
# `allowed_classes: []` (présent mais vide) signifie « aucun accès »
return list(allowed)
def is_class_allowed(username: str, classe: str) -> bool:
"""True si l'utilisateur peut voir cette classe."""
allowed = get_allowed_classes(username)
if allowed is None:
return True
return classe in allowed
def filter_classes(username: str, classes: list[str]) -> list[str]:
"""Filtre une liste de classes selon les droits de l'utilisateur."""
allowed = get_allowed_classes(username)
if allowed is None:
return list(classes)
return [c for c in classes if c in allowed]