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