diff --git a/.gitignore b/.gitignore
index a1635f6..c8552ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,7 @@ data/pdfs/
data/sync_*.json
data/debug_*.png
data/*.bak.*
+data/password_tokens.json
# Logs cron (runtime)
logs/
diff --git a/data/auth.yaml b/data/auth.yaml
index f9cec36..dc531db 100644
--- a/data/auth.yaml
+++ b/data/auth.yaml
@@ -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
diff --git a/data/email_templates/reset.html b/data/email_templates/reset.html
new file mode 100644
index 0000000..38c6f39
--- /dev/null
+++ b/data/email_templates/reset.html
@@ -0,0 +1,61 @@
+
+
+
+
+Réinitialisation de votre mot de passe
+
+
+
+
+
+ Bonjour {name},
+ Une demande de réinitialisation de mot de passe a été effectuée pour votre compte EPTM Dashboard.
+ |
+
+
+
+
+
+ |
+ Identifiant : {username}
+ |
+
+
+ |
+
+
+ |
+
+ Pour définir un nouveau mot de passe :
+
+
+
+ Réinitialiser mon mot de passe
+
+
+
+ Ou copiez ce lien dans votre navigateur :
+ {link}
+
+ |
+
+
+ |
+
+ Lien 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
+
+ |
+
+
+
+
diff --git a/data/email_templates/reset.txt b/data/email_templates/reset.txt
new file mode 100644
index 0000000..7beb0dc
--- /dev/null
+++ b/data/email_templates/reset.txt
@@ -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
diff --git a/data/email_templates/welcome.html b/data/email_templates/welcome.html
new file mode 100644
index 0000000..f404c8e
--- /dev/null
+++ b/data/email_templates/welcome.html
@@ -0,0 +1,68 @@
+
+
+
+
+Activation de votre compte EPTM
+
+
+
+
+
+ Bienvenue {name},
+ Un compte a été créé pour vous sur EPTM Dashboard.
+ |
+
+
+
+
+
+ |
+ Identifiant : {username}
+ |
+
+
+ |
+
+
+ |
+
+ Pour activer votre compte, définissez votre mot de passe :
+
+
+
+ Définir mon mot de passe
+
+
+
+ Ou copiez ce lien dans votre navigateur :
+ {link}
+
+ |
+
+
+ |
+
+ Lien 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
+
+ |
+
+
+
+
diff --git a/data/email_templates/welcome.txt b/data/email_templates/welcome.txt
new file mode 100644
index 0000000..0fc3b02
--- /dev/null
+++ b/data/email_templates/welcome.txt
@@ -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
diff --git a/data/settings.json b/data/settings.json
index 08e6616..4df381e 100644
--- a/data/settings.json
+++ b/data/settings.json
@@ -6,5 +6,6 @@
"smtp_sender": "EPTM Automation ",
"escada_username": "julien.balet",
"escada_password": "Lauryne2023!",
- "totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR"
+ "totp_secret": "KQZVCQLXGNAU22KRKNCHCYSUIRAXAUSR",
+ "app_base_url": "https://dev.dashboard.eptm-automation.ch"
}
\ No newline at end of file
diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py
index aceded6..b225038 100644
--- a/eptm_dashboard/eptm_dashboard.py
+++ b/eptm_dashboard/eptm_dashboard.py
@@ -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)
diff --git a/eptm_dashboard/pages/accueil.py b/eptm_dashboard/pages/accueil.py
index cb3386b..dc6de9f 100644
--- a/eptm_dashboard/pages/accueil.py
+++ b/eptm_dashboard/pages/accueil.py
@@ -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(
diff --git a/eptm_dashboard/pages/classe.py b/eptm_dashboard/pages/classe.py
index 57d0f50..d4364e0 100644
--- a/eptm_dashboard/pages/classe.py
+++ b/eptm_dashboard/pages/classe.py
@@ -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 = ""
diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py
index 16982f9..4eb57f4 100644
--- a/eptm_dashboard/pages/fiche.py
+++ b/eptm_dashboard/pages/fiche.py
@@ -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
diff --git a/eptm_dashboard/pages/params.py b/eptm_dashboard/pages/params.py
index a9dab2c..2ea7c78 100644
--- a/eptm_dashboard/pages/params.py
+++ b/eptm_dashboard/pages/params.py
@@ -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(),
diff --git a/eptm_dashboard/pages/password_set.py b/eptm_dashboard/pages/password_set.py
new file mode 100644
index 0000000..b7e5016
--- /dev/null
+++ b/eptm_dashboard/pages/password_set.py
@@ -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",
+ )
diff --git a/eptm_dashboard/pages/profile.py b/eptm_dashboard/pages/profile.py
new file mode 100644
index 0000000..079ed69
--- /dev/null
+++ b/eptm_dashboard/pages/profile.py
@@ -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",
+ )
+ )
diff --git a/eptm_dashboard/pages/users.py b/eptm_dashboard/pages/users.py
index 1b1cb61..d1cdd4e 100644
--- a/eptm_dashboard/pages/users.py
+++ b/eptm_dashboard/pages/users.py
@@ -1,15 +1,24 @@
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.password_tokens import create_token, revoke_all_for_user # noqa: E402
+from src.password_emails import send_password_email # noqa: E402
+from src.logger import app_log # noqa: E402
+from src.db import get_session, Apprenti # noqa: E402
+from sqlalchemy import select # noqa: E402
+
from ..sidebar import layout
from ..state import AuthState
-_ROOT = Path(__file__).resolve().parent.parent.parent
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
AUTH_FILE = DATA_DIR / "auth.yaml"
_AVATARS_DIR = _ROOT / "assets" / "avatars"
@@ -29,6 +38,7 @@ def _save_auth(cfg: dict) -> None:
# ── State ─────────────────────────────────────────────────────────────────────
+
class UsersState(AuthState):
users_list: list[dict] = []
@@ -40,12 +50,6 @@ class UsersState(AuthState):
info_error: str = ""
info_ok: bool = False
- # Change password
- pwd_new: str = ""
- pwd_confirm: str = ""
- pwd_error: str = ""
- pwd_ok: bool = False
-
# 2FA
edit_has_totp: bool = False
totp_ok: bool = False
@@ -54,28 +58,43 @@ class UsersState(AuthState):
edit_avatar_url: str = ""
upload_ok: bool = False
+ # Reset password
+ reset_target: str = "" # username sur lequel un reset est demandé (pour modale)
+
# Add user (admin)
new_uname: str = ""
new_name: str = ""
new_email: str = ""
new_role: str = "user"
- new_pwd1: str = ""
- new_pwd2: str = ""
new_error: str = ""
new_ok: bool = False
+ # Accès aux classes (édition)
+ all_classes: list[str] = []
+ edit_restrict: bool = False # True = restriction active
+ edit_classes: list[str] = [] # classes cochées
+ classes_open: bool = False # popover ouvert
+ classes_search: str = ""
+ access_ok: bool = False
+ access_error: str = ""
+
# ── Setters ───────────────────────────────────────────────────────────────
def set_edit_name(self, v: str): self.edit_name = v
def set_edit_email(self, v: str): self.edit_email = v
def set_edit_role(self, v: str): self.edit_role = v
- def set_pwd_new(self, v: str): self.pwd_new = v
- def set_pwd_confirm(self, v: str): self.pwd_confirm = v
def set_new_uname(self, v: str): self.new_uname = v
def set_new_name(self, v: str): self.new_name = v
def set_new_email(self, v: str): self.new_email = v
def set_new_role(self, v: str): self.new_role = v
- def set_new_pwd1(self, v: str): self.new_pwd1 = v
- def set_new_pwd2(self, v: str): self.new_pwd2 = v
+ def set_classes_search(self, v: str): self.classes_search = v
+ def set_classes_open(self, v: bool): self.classes_open = v
+
+ @rx.var
+ def filtered_all_classes(self) -> list[str]:
+ q = self.classes_search.lower().strip()
+ if not q:
+ return self.all_classes
+ return [c for c in self.all_classes if q in c.lower()]
# ── Helpers ───────────────────────────────────────────────────────────────
def _refresh_list(self):
@@ -102,22 +121,42 @@ class UsersState(AuthState):
self.edit_role = udata.get("role", "user")
self.edit_has_totp = bool(udata.get("totp_secret"))
self.edit_avatar_url = udata.get("avatar_url", "")
+ # Accès aux classes : si la clé est absente → pas de restriction (None)
+ # Si présente (même []) → restriction active
+ allowed = udata.get("allowed_classes")
+ self.edit_restrict = allowed is not None
+ self.edit_classes = list(allowed) if allowed else []
+ self.classes_open = False
+ self.classes_search = ""
self.info_error = ""
self.info_ok = False
- self.pwd_new = ""
- self.pwd_confirm = ""
- self.pwd_error = ""
- self.pwd_ok = False
self.totp_ok = False
self.upload_ok = False
+ self.access_ok = False
+ self.access_error = ""
# ── Handlers ──────────────────────────────────────────────────────────────
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
- self._refresh_list()
if self.role != "admin":
- self._populate_edit(self.username)
+ return rx.redirect("/profile")
+ self._refresh_list()
+ self._refresh_classes()
+
+ def _refresh_classes(self):
+ """Charge la liste des classes disponibles depuis la DB (hors MP/MI)."""
+ sess = get_session()
+ try:
+ cs = sess.execute(
+ select(Apprenti.classe).distinct().order_by(Apprenti.classe)
+ ).scalars().all()
+ self.all_classes = [
+ c for c in cs
+ if c and not c.startswith(("MP", "MI"))
+ ]
+ finally:
+ sess.close()
def select_user(self, uname: str):
if self.edit_target == uname:
@@ -142,30 +181,59 @@ class UsersState(AuthState):
return
users[uname]["name"] = self.edit_name.strip()
users[uname]["email"] = self.edit_email.strip()
- if self.role == "admin" and uname != self.username:
+ if uname != self.username:
users[uname]["role"] = self.edit_role
_save_auth(cfg)
self.info_ok = True
+ app_log(f"[users] {self.username} : informations modifiées pour {uname}")
self._refresh_list()
- def save_password(self):
- self.pwd_error = ""
- self.pwd_ok = False
- if len(self.pwd_new) < 6:
- self.pwd_error = "Minimum 6 caractères."
+ def toggle_restrict(self, v: bool):
+ self.edit_restrict = v
+ self.access_ok = False
+ if not v:
+ self.edit_classes = []
+
+ def toggle_class(self, classe: str):
+ cur = list(self.edit_classes)
+ if classe in cur:
+ cur.remove(classe)
+ else:
+ cur.append(classe)
+ self.edit_classes = cur
+ self.access_ok = False
+
+ def select_all_classes(self):
+ self.edit_classes = list(self.all_classes)
+ self.access_ok = False
+
+ def clear_all_classes(self):
+ self.edit_classes = []
+ self.access_ok = False
+
+ def save_access(self):
+ self.access_error = ""
+ self.access_ok = False
+ cfg = _load_auth()
+ users = cfg.get("credentials", {}).get("usernames", {})
+ uname = self.edit_target
+ if uname not in users:
+ self.access_error = "Utilisateur introuvable."
return
- if self.pwd_new != self.pwd_confirm:
- self.pwd_error = "Les mots de passe ne correspondent pas."
- return
- cfg = _load_auth()
- users = cfg["credentials"]["usernames"]
- users[self.edit_target]["password"] = bcrypt.hashpw(
- self.pwd_new.encode(), bcrypt.gensalt(12)
- ).decode()
+ if self.edit_restrict:
+ users[uname]["allowed_classes"] = list(self.edit_classes)
+ else:
+ users[uname].pop("allowed_classes", None)
_save_auth(cfg)
- self.pwd_new = ""
- self.pwd_confirm = ""
- self.pwd_ok = True
+ nb = len(self.edit_classes) if self.edit_restrict else 0
+ if self.edit_restrict:
+ app_log(
+ f"[users] {self.username} : restriction d'accès pour {uname} → "
+ f"{nb} classe(s)"
+ )
+ else:
+ app_log(f"[users] {self.username} : accès complet rétabli pour {uname}")
+ self.access_ok = True
def reset_totp(self):
cfg = _load_auth()
@@ -175,6 +243,7 @@ class UsersState(AuthState):
self._populate_edit(self.edit_target)
self._refresh_list()
self.totp_ok = True
+ app_log(f"[users] {self.username} : 2FA réinitialisé pour {self.edit_target}")
async def handle_avatar_upload(self, files: list[rx.UploadFile]):
if not files:
@@ -225,8 +294,10 @@ class UsersState(AuthState):
if uname in users:
del users[uname]
_save_auth(cfg)
+ revoke_all_for_user(uname)
if self.edit_target == uname:
self.edit_target = ""
+ app_log(f"[users] {self.username} : suppression du compte {uname}")
self._refresh_list()
def add_user(self):
@@ -234,40 +305,106 @@ class UsersState(AuthState):
self.new_ok = False
uname = self.new_uname.strip().lower()
name = self.new_name.strip()
+ email = self.new_email.strip()
errs = []
if not uname or not name:
errs.append("L'identifiant et le nom sont obligatoires.")
elif " " in uname:
errs.append("L'identifiant ne doit pas contenir d'espaces.")
- if len(self.new_pwd1) < 6:
- errs.append("Mot de passe trop court (6 caractères minimum).")
- if self.new_pwd1 != self.new_pwd2:
- errs.append("Les mots de passe ne correspondent pas.")
+ if not email or "@" not in email:
+ errs.append("L'email est obligatoire et doit être valide.")
if errs:
self.new_error = " — ".join(errs)
return
+
cfg = _load_auth()
- users = cfg["credentials"]["usernames"]
+ users = cfg.setdefault("credentials", {}).setdefault("usernames", {})
if uname in users:
self.new_error = f"L'identifiant « {uname} » est déjà utilisé."
return
+
users[uname] = {
- "email": self.new_email.strip(),
+ "email": email,
"name": name,
"role": self.new_role,
- "password": bcrypt.hashpw(self.new_pwd1.encode(), bcrypt.gensalt(12)).decode(),
+ "password": "", # pas de mot de passe initial — défini via lien email
"totp_secret": None,
}
_save_auth(cfg)
+
+ # Génère token + envoie email
+ try:
+ token, expires_at = create_token(uname, kind="set")
+ send_password_email(
+ kind="welcome",
+ username=uname,
+ name=name,
+ email=email,
+ token=token,
+ expires_at=expires_at,
+ )
+ except Exception as e:
+ # Compte créé mais email échoué : on signale et on rollback la création ?
+ # On garde le compte (admin pourra retenter via reset password).
+ self.new_error = (
+ f"Compte créé, mais l'email n'a pas pu être envoyé : {e}. "
+ f"Utilisez le bouton « Renvoyer le lien » pour retenter."
+ )
+ app_log(f"[users] {self.username} : création {uname} OK mais email KO ({e})")
+ self._refresh_list()
+ return
+
+ app_log(
+ f"[users] {self.username} : création compte {uname} ({email}, {self.new_role}) "
+ f"— email d'activation envoyé"
+ )
+
self.new_uname = ""
self.new_name = ""
self.new_email = ""
self.new_role = "user"
- self.new_pwd1 = ""
- self.new_pwd2 = ""
self.new_ok = True
self._refresh_list()
+ def send_reset_password(self, uname: str):
+ """Génère un token de reset et envoie l'email à l'utilisateur."""
+ cfg = _load_auth()
+ users = cfg.get("credentials", {}).get("usernames", {})
+ u = users.get(uname)
+ if not u:
+ return rx.toast.error(f"Utilisateur {uname!r} introuvable.")
+ email = (u.get("email") or "").strip()
+ name = u.get("name", uname)
+ if not email or "@" not in email:
+ return rx.toast.error(
+ f"Email manquant pour {uname}. Renseignez-le avant de réinitialiser."
+ )
+ try:
+ # Détermine le type : si l'utilisateur n'a pas encore de mot de passe,
+ # c'est un "set" (lien plus long), sinon un "reset" (24h).
+ kind = "set" if not u.get("password") else "reset"
+ email_kind = "welcome" if kind == "set" else "reset"
+ token, expires_at = create_token(uname, kind=kind)
+ send_password_email(
+ kind=email_kind,
+ username=uname,
+ name=name,
+ email=email,
+ token=token,
+ expires_at=expires_at,
+ )
+ except Exception as e:
+ return rx.toast.error(f"Échec d'envoi : {e}")
+
+ app_log(
+ f"[users] {self.username} : envoi lien de "
+ f"{'définition' if kind == 'set' else 'réinitialisation'} à {uname} ({email})"
+ )
+ return rx.toast.success(
+ f"Lien envoyé à {email}. "
+ f"Valide {'7 jours' if kind == 'set' else '24h'}."
+ )
+
# ── UI helpers ────────────────────────────────────────────────────────────────
@@ -293,7 +430,7 @@ def _err_callout(msg: str) -> rx.Component:
return rx.cond(
msg != "",
rx.callout.root(
- rx.callout.icon(rx.icon("alert-circle", size=16)),
+ rx.callout.icon(rx.icon("triangle-alert", size=16)),
rx.callout.text(msg),
color_scheme="red",
variant="soft",
@@ -319,7 +456,7 @@ def _totp_badge(has_totp: bool) -> rx.Component:
)
-# ── User table row ────────────────────────────────────────────────────────────
+# ── Dialogs ───────────────────────────────────────────────────────────────────
def _delete_dialog(user: dict) -> rx.Component:
return rx.alert_dialog.root(
@@ -357,12 +494,62 @@ def _delete_dialog(user: dict) -> rx.Component:
)
+def _reset_pwd_dialog(user: dict) -> rx.Component:
+ return rx.alert_dialog.root(
+ rx.alert_dialog.trigger(
+ rx.button(
+ rx.icon("key-round", size=14),
+ "Reset mdp",
+ color_scheme="orange",
+ variant="outline",
+ size="1",
+ ),
+ ),
+ rx.alert_dialog.content(
+ rx.alert_dialog.title("Envoyer un lien de réinitialisation"),
+ rx.alert_dialog.description(
+ rx.vstack(
+ rx.text(
+ "Un email contenant un lien à usage unique va être envoyé à ",
+ rx.text.strong(user["name"]), " :",
+ ),
+ rx.text(user["email"], color="var(--blue-11)", size="2", weight="medium"),
+ rx.text(
+ "L'utilisateur pourra définir un nouveau mot de passe via ce lien. "
+ "Vous-même n'avez pas accès au mot de passe.",
+ size="1", color="var(--gray-11)",
+ ),
+ spacing="2", align="start",
+ ),
+ ),
+ rx.hstack(
+ rx.alert_dialog.cancel(
+ rx.button("Annuler", variant="soft", color_scheme="gray"),
+ ),
+ rx.alert_dialog.action(
+ rx.button(
+ rx.icon("send", size=14),
+ "Envoyer le lien",
+ color_scheme="orange",
+ on_click=UsersState.send_reset_password(user["username"]),
+ ),
+ ),
+ spacing="3",
+ justify="end",
+ margin_top="1rem",
+ ),
+ max_width="500px",
+ ),
+ )
+
+
+# ── User row ──────────────────────────────────────────────────────────────────
+
def _user_row(user: dict) -> rx.Component:
is_selected = user["username"] == UsersState.edit_target
is_me = user["username"] == UsersState.username
return rx.box(
rx.hstack(
- # Identité : nom + badge rôle + 2FA + identifiant
rx.vstack(
rx.hstack(
rx.text(user["name"], weight="medium", size="2",
@@ -370,19 +557,15 @@ def _user_row(user: dict) -> rx.Component:
_role_badge(user["role"]),
_totp_badge(user["has_totp"]),
rx.cond(is_me, rx.badge("vous", size="1", variant="outline"), rx.fragment()),
- spacing="2",
- align="center",
- overflow="hidden",
- flex_wrap="wrap",
+ spacing="2", align="center", flex_wrap="wrap",
),
- rx.text(user["username"], size="1", color="var(--gray-10)",
- overflow="hidden", text_overflow="ellipsis", white_space="nowrap"),
- spacing="0",
- flex="1",
- min_width="0",
- overflow="hidden",
+ rx.text(
+ user["username"], " · ", user["email"],
+ size="1", color="var(--gray-10)",
+ overflow="hidden", text_overflow="ellipsis", white_space="nowrap",
+ ),
+ spacing="0", flex="1", min_width="0", overflow="hidden",
),
- # Droite : actions uniquement
rx.hstack(
rx.button(
rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)),
@@ -392,27 +575,22 @@ def _user_row(user: dict) -> rx.Component:
color_scheme="blue",
size="1",
),
- rx.cond(
- is_me,
- rx.fragment(),
- _delete_dialog(user),
- ),
- spacing="2",
- align="center",
- flex_shrink="0",
+ _reset_pwd_dialog(user),
+ rx.cond(is_me, rx.fragment(), _delete_dialog(user)),
+ spacing="2", align="center", flex_shrink="0", flex_wrap="wrap",
),
align="center",
justify="between",
width="100%",
padding="0.65rem 0.75rem",
overflow="hidden",
+ flex_wrap="wrap",
+ gap="0.5rem",
),
background_color=rx.cond(is_selected, "var(--blue-2)", "white"),
border_radius="6px",
border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"),
width="100%",
- overflow="hidden",
- cursor="default",
)
@@ -423,24 +601,17 @@ def _edit_panel_avatar() -> rx.Component:
return rx.vstack(
rx.text("Photo de profil", weight="bold", size="3"),
rx.hstack(
- # Preview
rx.cond(
has_photo,
rx.image(
src=UsersState.edit_avatar_url,
- width="64px",
- height="64px",
- border_radius="50%",
- object_fit="cover",
- border="2px solid var(--gray-4)",
- flex_shrink="0",
+ width="64px", height="64px",
+ border_radius="50%", object_fit="cover",
+ border="2px solid var(--gray-4)", flex_shrink="0",
),
rx.image(
src="/default_avatar.svg",
- width="64px",
- height="64px",
- border_radius="50%",
- flex_shrink="0",
+ width="64px", height="64px", border_radius="50%", flex_shrink="0",
),
),
rx.vstack(
@@ -448,17 +619,13 @@ def _edit_panel_avatar() -> rx.Component:
rx.button(
rx.icon("upload", size=15),
rx.cond(has_photo, "Changer la photo", "Choisir une photo"),
- variant="outline",
- color_scheme="blue",
- size="2",
+ variant="outline", color_scheme="blue", size="2",
),
id="avatar_upload",
on_drop=UsersState.handle_avatar_upload,
accept={"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"],
"image/gif": [".gif"], "image/webp": [".webp"]},
- max_files=1,
- multiple=False,
- max_width="100%",
+ max_files=1, multiple=False,
),
rx.cond(
has_photo,
@@ -466,34 +633,21 @@ def _edit_panel_avatar() -> rx.Component:
rx.icon("trash-2", size=14),
"Supprimer",
on_click=UsersState.remove_avatar,
- variant="ghost",
- color_scheme="red",
- size="1",
+ variant="ghost", color_scheme="red", size="1",
),
rx.fragment(),
),
_ok_callout(UsersState.upload_ok, "Photo mise à jour."),
- rx.text(
- "PNG, JPG, GIF ou WebP",
- size="1",
- color="var(--gray-9)",
- ),
- spacing="2",
- align="start",
- min_width="0",
- width="100%",
+ spacing="2", align="start", min_width="0", width="100%",
),
- spacing="4",
- align="center",
- width="100%",
- flex_wrap="wrap",
+ spacing="4", align="center", width="100%", flex_wrap="wrap",
),
- spacing="3",
- width="100%",
+ spacing="3", width="100%",
)
-def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component:
+def _edit_panel_info() -> rx.Component:
+ is_other = UsersState.edit_target != UsersState.username
return rx.vstack(
rx.text("Informations du compte", weight="bold", size="3"),
rx.hstack(
@@ -519,7 +673,7 @@ def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component:
spacing="4", width="100%", flex_wrap="wrap",
),
rx.cond(
- is_admin_editing_other,
+ is_other,
rx.vstack(
_label("Rôle"),
rx.select.root(
@@ -532,8 +686,7 @@ def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component:
on_change=UsersState.set_edit_role,
width="100%",
),
- spacing="1",
- width="100%",
+ spacing="1", width="100%",
),
rx.fragment(),
),
@@ -542,66 +695,218 @@ def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component:
rx.icon("save", size=16),
"Mettre à jour",
on_click=UsersState.save_info,
- color_scheme="blue",
- variant="solid",
- size="2",
+ color_scheme="blue", size="2",
),
_ok_callout(UsersState.info_ok, "Informations mises à jour."),
_err_callout(UsersState.info_error),
- spacing="3",
- align="center",
- flex_wrap="wrap",
+ spacing="3", align="center", flex_wrap="wrap",
),
- spacing="3",
+ spacing="3", width="100%",
+ )
+
+
+def _class_chip(classe: rx.Var) -> rx.Component:
+ """Pill rouge pour une classe sélectionnée."""
+ return rx.flex(
+ rx.text(classe, size="1", color="white", font_weight="500"),
+ rx.icon(
+ "x", size=12, color="white",
+ cursor="pointer",
+ on_click=UsersState.toggle_class(classe).stop_propagation,
+ ),
+ align="center",
+ gap="0.25rem",
+ padding="0.15rem 0.4rem 0.15rem 0.6rem",
+ background_color="var(--blue-9)",
+ border_radius="9999px",
+ flex_shrink="0",
+ )
+
+
+def _class_option(classe: rx.Var) -> rx.Component:
+ is_checked = UsersState.edit_classes.contains(classe)
+ return rx.box(
+ rx.flex(
+ rx.cond(
+ is_checked,
+ rx.icon("check", size=14, color="var(--blue-9)"),
+ rx.box(width="14px", height="14px"),
+ ),
+ rx.text(classe, size="2"),
+ align="center",
+ gap="0.5rem",
+ ),
+ padding="0.45rem 0.75rem",
+ cursor="pointer",
+ on_click=UsersState.toggle_class(classe),
+ _hover={"background_color": "var(--gray-3)"},
width="100%",
)
-def _edit_panel_password() -> rx.Component:
+def _classes_multi_select() -> rx.Component:
+ return rx.popover.root(
+ rx.popover.trigger(
+ rx.box(
+ rx.flex(
+ rx.cond(
+ UsersState.edit_classes.length() == 0,
+ rx.text(
+ "Sélectionner les classes autorisées…",
+ color="var(--gray-9)", size="2",
+ ),
+ rx.foreach(UsersState.edit_classes, _class_chip),
+ ),
+ wrap="wrap",
+ gap="0.3rem",
+ flex="1",
+ min_height="28px",
+ align="center",
+ ),
+ rx.icon("chevron-down", size=18, color="var(--gray-9)"),
+ display="flex",
+ align_items="center",
+ gap="0.5rem",
+ padding="0.45rem 0.6rem",
+ border="1px solid var(--gray-7)",
+ border_radius="6px",
+ background_color="white",
+ cursor="pointer",
+ width="100%",
+ ),
+ ),
+ rx.popover.content(
+ rx.vstack(
+ rx.input(
+ placeholder="Rechercher une classe…",
+ value=UsersState.classes_search,
+ on_change=UsersState.set_classes_search,
+ size="2",
+ width="100%",
+ auto_focus=True,
+ ),
+ rx.flex(
+ rx.button(
+ "Tout cocher",
+ on_click=UsersState.select_all_classes,
+ size="1", variant="soft", color_scheme="blue",
+ ),
+ rx.button(
+ "Tout décocher",
+ on_click=UsersState.clear_all_classes,
+ size="1", variant="soft", color_scheme="gray",
+ ),
+ gap="0.4rem",
+ ),
+ rx.cond(
+ UsersState.filtered_all_classes.length() > 0,
+ rx.box(
+ rx.foreach(UsersState.filtered_all_classes, _class_option),
+ max_height="280px",
+ overflow_y="auto",
+ width="100%",
+ ),
+ rx.box(
+ rx.text("Aucun résultat", size="2", color="var(--gray-9)"),
+ padding="0.5rem 0.75rem",
+ ),
+ ),
+ spacing="2",
+ width="100%",
+ ),
+ min_width="320px",
+ max_width="500px",
+ padding="0.5rem",
+ ),
+ open=UsersState.classes_open,
+ on_open_change=UsersState.set_classes_open,
+ )
+
+
+def _edit_panel_access() -> rx.Component:
return rx.vstack(
- rx.text("Changer le mot de passe", weight="bold", size="3"),
- rx.hstack(
- rx.vstack(
- _label("Nouveau mot de passe"),
- rx.input(
- value=UsersState.pwd_new,
- on_change=UsersState.set_pwd_new,
- type="password",
- placeholder="••••••••",
- width="100%",
+ rx.text("Accès aux classes", weight="bold", size="3"),
+ rx.cond(
+ UsersState.edit_role == "admin",
+ rx.callout.root(
+ rx.callout.icon(rx.icon("shield-check", size=16)),
+ rx.callout.text(
+ "Les administrateurs voient toutes les classes — la restriction "
+ "ne s'applique pas pour ce rôle."
),
- spacing="1", flex="1", min_width="0", width="100%",
+ color_scheme="violet", variant="soft", size="1",
),
rx.vstack(
- _label("Confirmer"),
- rx.input(
- value=UsersState.pwd_confirm,
- on_change=UsersState.set_pwd_confirm,
- type="password",
- placeholder="••••••••",
- width="100%",
+ rx.flex(
+ rx.switch(
+ checked=UsersState.edit_restrict,
+ on_change=UsersState.toggle_restrict,
+ size="2",
+ ),
+ rx.text(
+ "Limiter aux classes sélectionnées",
+ size="2", color="var(--gray-12)",
+ ),
+ gap="0.65rem", align="center",
),
- spacing="1", flex="1", min_width="0", width="100%",
+ rx.cond(
+ UsersState.edit_restrict,
+ rx.vstack(
+ _classes_multi_select(),
+ rx.cond(
+ UsersState.edit_classes.length() == 0,
+ rx.callout.root(
+ rx.callout.icon(rx.icon("triangle-alert", size=16)),
+ rx.callout.text(
+ "Aucune classe sélectionnée — l'utilisateur ne verra aucune donnée."
+ ),
+ color_scheme="orange", variant="soft", size="1",
+ ),
+ rx.text(
+ UsersState.edit_classes.length(),
+ " classe(s) autorisée(s)",
+ size="1", color="var(--gray-10)",
+ ),
+ ),
+ spacing="2", width="100%",
+ ),
+ rx.text(
+ "L'utilisateur voit toutes les classes.",
+ size="1", color="var(--gray-10)",
+ ),
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("save", size=16),
+ "Enregistrer l'accès",
+ on_click=UsersState.save_access,
+ color_scheme="blue", size="2",
+ ),
+ _ok_callout(UsersState.access_ok, "Accès mis à jour."),
+ _err_callout(UsersState.access_error),
+ spacing="3", align="center", flex_wrap="wrap",
+ ),
+ spacing="3", width="100%",
),
- spacing="4", width="100%", flex_wrap="wrap",
),
- rx.hstack(
- rx.button(
- rx.icon("lock", size=16),
- "Enregistrer le mot de passe",
- on_click=UsersState.save_password,
- color_scheme="blue",
- variant="solid",
- size="2",
+ spacing="3", width="100%",
+ )
+
+
+def _edit_panel_password_info() -> rx.Component:
+ """Note explicative remplaçant le formulaire de mot de passe."""
+ return rx.vstack(
+ rx.text("Mot de passe", weight="bold", size="3"),
+ rx.callout.root(
+ rx.callout.icon(rx.icon("info", size=16)),
+ rx.callout.text(
+ "Vous ne pouvez pas modifier directement le mot de passe d'un utilisateur. "
+ "Utilisez le bouton « Reset mdp » dans la liste — l'utilisateur recevra "
+ "un email avec un lien pour définir un nouveau mot de passe."
),
- _ok_callout(UsersState.pwd_ok, "Mot de passe mis à jour."),
- _err_callout(UsersState.pwd_error),
- spacing="3",
- align="center",
- flex_wrap="wrap",
+ color_scheme="blue", variant="soft", size="1",
),
- spacing="3",
- width="100%",
+ spacing="2", width="100%",
)
@@ -619,10 +924,7 @@ def _edit_panel_totp() -> rx.Component:
rx.badge("Non configuré", color_scheme="gray", variant="soft"),
),
),
- spacing="2",
- align="center",
- flex_wrap="wrap",
- width="100%",
+ spacing="2", align="center", flex_wrap="wrap", width="100%",
),
rx.cond(
~UsersState.edit_has_totp & ~UsersState.totp_ok,
@@ -636,60 +938,45 @@ def _edit_panel_totp() -> rx.Component:
UsersState.edit_has_totp & ~UsersState.totp_ok,
rx.button(
rx.icon("rotate-ccw", size=16),
- "Réinitialiser l'authentificateur 2FA",
+ "Réinitialiser le 2FA",
on_click=UsersState.reset_totp,
- color_scheme="orange",
- variant="outline",
- size="2",
+ color_scheme="orange", variant="outline", size="2",
),
rx.fragment(),
),
- spacing="3",
- width="100%",
+ spacing="3", width="100%",
)
-def _edit_panel(is_admin_editing_other: bool) -> rx.Component:
+def _edit_panel() -> rx.Component:
return rx.cond(
UsersState.edit_target != "",
rx.box(
rx.vstack(
rx.hstack(
rx.text(
- rx.cond(
- UsersState.edit_target == UsersState.username,
- "Mon profil",
- rx.el.span("Modifier — ", style={"font_weight": "normal"}),
- ),
- weight="bold",
- size="3",
- ),
- rx.cond(
- UsersState.edit_target != UsersState.username,
- rx.text(UsersState.edit_target, size="3", color="var(--gray-10)"),
- rx.fragment(),
+ "Modifier — ", UsersState.edit_target,
+ weight="bold", size="3",
),
rx.spacer(),
rx.button(
rx.icon("x", size=14),
on_click=UsersState.close_edit,
- variant="ghost",
- color_scheme="gray",
- size="1",
+ variant="ghost", color_scheme="gray", size="1",
),
- width="100%",
- align="center",
+ width="100%", align="center",
),
rx.divider(),
_edit_panel_avatar(),
rx.divider(),
- _edit_panel_info(is_admin_editing_other),
+ _edit_panel_info(),
rx.divider(),
- _edit_panel_password(),
+ _edit_panel_access(),
+ rx.divider(),
+ _edit_panel_password_info(),
rx.divider(),
_edit_panel_totp(),
- spacing="4",
- width="100%",
+ spacing="4", width="100%",
),
padding="1.25rem",
background_color="var(--blue-2)",
@@ -707,6 +994,11 @@ def _add_user_section() -> rx.Component:
return rx.box(
rx.vstack(
rx.text("Ajouter un utilisateur", size="4", weight="bold"),
+ rx.text(
+ "À la création, l'utilisateur reçoit un email avec un lien pour "
+ "définir son mot de passe (valide 7 jours).",
+ size="1", color="var(--gray-11)",
+ ),
rx.divider(),
rx.hstack(
rx.vstack(
@@ -720,7 +1012,7 @@ def _add_user_section() -> rx.Component:
spacing="1", flex="1", min_width="0", width="100%",
),
rx.vstack(
- _label("Prénom / Nom affiché"),
+ _label("Nom affiché"),
rx.input(
value=UsersState.new_name,
on_change=UsersState.set_new_name,
@@ -733,7 +1025,7 @@ def _add_user_section() -> rx.Component:
),
rx.hstack(
rx.vstack(
- _label("Email (optionnel)"),
+ _label("Email"),
rx.input(
value=UsersState.new_email,
on_change=UsersState.set_new_email,
@@ -758,105 +1050,18 @@ def _add_user_section() -> rx.Component:
),
spacing="4", width="100%", flex_wrap="wrap",
),
- rx.hstack(
- rx.vstack(
- _label("Mot de passe"),
- rx.input(
- value=UsersState.new_pwd1,
- on_change=UsersState.set_new_pwd1,
- type="password",
- placeholder="••••••••",
- width="100%",
- ),
- spacing="1", flex="1", min_width="0", width="100%",
- ),
- rx.vstack(
- _label("Confirmer le mot de passe"),
- rx.input(
- value=UsersState.new_pwd2,
- on_change=UsersState.set_new_pwd2,
- type="password",
- placeholder="••••••••",
- width="100%",
- ),
- spacing="1", flex="1", min_width="0", width="100%",
- ),
- spacing="4", width="100%", flex_wrap="wrap",
- ),
rx.hstack(
rx.button(
rx.icon("user-plus", size=16),
- "Créer le compte",
+ "Créer le compte et envoyer le lien",
on_click=UsersState.add_user,
- color_scheme="blue",
- variant="solid",
- size="2",
+ color_scheme="blue", size="2",
),
- _ok_callout(UsersState.new_ok, "Compte créé avec succès."),
+ _ok_callout(UsersState.new_ok, "Compte créé. Email envoyé."),
_err_callout(UsersState.new_error),
- spacing="3",
- align="center",
- flex_wrap="wrap",
+ 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%",
- )
-
-
-# ── Admin view ────────────────────────────────────────────────────────────────
-
-def _admin_view() -> rx.Component:
- is_admin_editing_other = (
- (UsersState.edit_target != "") &
- (UsersState.edit_target != UsersState.username)
- )
- return rx.vstack(
- # User list
- rx.box(
- rx.vstack(
- rx.text("Comptes existants", size="4", weight="bold"),
- rx.divider(),
- rx.foreach(UsersState.users_list, _user_row),
- spacing="2",
- width="100%",
- ),
- padding="1.25rem",
- background_color="white",
- border_radius="8px",
- border="1px solid #e0e0e0",
- width="100%",
- ),
- # Edit panel (shown when a user is selected)
- _edit_panel(is_admin_editing_other),
- # Add user
- _add_user_section(),
- spacing="4",
- width="100%",
- )
-
-
-# ── User view (non-admin) ─────────────────────────────────────────────────────
-
-def _user_view() -> rx.Component:
- return rx.box(
- rx.vstack(
- rx.text("Mon profil", size="4", weight="bold"),
- rx.divider(),
- _edit_panel_avatar(),
- rx.divider(),
- _edit_panel_info(False),
- rx.divider(),
- _edit_panel_password(),
- rx.divider(),
- _edit_panel_totp(),
- spacing="4",
- width="100%",
+ spacing="3", width="100%",
),
padding="1.25rem",
background_color="white",
@@ -871,16 +1076,22 @@ def _user_view() -> rx.Component:
def users_page() -> rx.Component:
return layout(
rx.vstack(
- rx.cond(
- UsersState.role == "admin",
- rx.heading("Gestion des utilisateurs", size="7"),
- rx.heading("Mon profil", size="7"),
- ),
- rx.cond(
- UsersState.role == "admin",
- _admin_view(),
- _user_view(),
+ rx.heading("Gestion des utilisateurs", size="7"),
+ rx.box(
+ rx.vstack(
+ rx.text("Comptes existants", size="4", weight="bold"),
+ rx.divider(),
+ rx.foreach(UsersState.users_list, _user_row),
+ spacing="2", width="100%",
+ ),
+ padding="1.25rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
),
+ _edit_panel(),
+ _add_user_section(),
spacing="5",
width="100%",
max_width="860px",
diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py
index ed8ea17..dcb421b 100644
--- a/eptm_dashboard/sidebar.py
+++ b/eptm_dashboard/sidebar.py
@@ -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",
)
diff --git a/src/email_sender.py b/src/email_sender.py
index 0dc5bc2..f37346e 100644
--- a/src/email_sender.py
+++ b/src/email_sender.py
@@ -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 ' 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")
diff --git a/src/password_emails.py b/src/password_emails.py
new file mode 100644
index 0000000..893dbe8
--- /dev/null
+++ b/src/password_emails.py
@@ -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,
+ )
diff --git a/src/password_tokens.py b/src/password_tokens.py
new file mode 100644
index 0000000..61302db
--- /dev/null
+++ b/src/password_tokens.py
@@ -0,0 +1,126 @@
+"""Tokens à usage unique pour la définition / réinitialisation de mot de passe.
+
+Stockage : data/password_tokens.json (JSON)
+
+Format :
+{
+ "": {
+ "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)
diff --git a/src/user_access.py b/src/user_access.py
new file mode 100644
index 0000000..16274c4
--- /dev/null
+++ b/src/user_access.py
@@ -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]