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:
parent
41c050d2d4
commit
43a2196150
20 changed files with 1968 additions and 321 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -14,6 +14,7 @@ data/pdfs/
|
|||
data/sync_*.json
|
||||
data/debug_*.png
|
||||
data/*.bak.*
|
||||
data/password_tokens.json
|
||||
|
||||
# Logs cron (runtime)
|
||||
logs/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
61
data/email_templates/reset.html
Normal file
61
data/email_templates/reset.html
Normal 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>
|
||||
14
data/email_templates/reset.txt
Normal file
14
data/email_templates/reset.txt
Normal 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
|
||||
68
data/email_templates/welcome.html
Normal file
68
data/email_templates/welcome.html
Normal 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>
|
||||
16
data/email_templates/welcome.txt
Normal file
16
data/email_templates/welcome.txt
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
303
eptm_dashboard/pages/password_set.py
Normal file
303
eptm_dashboard/pages/password_set.py
Normal 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",
|
||||
)
|
||||
468
eptm_dashboard/pages/profile.py
Normal file
468
eptm_dashboard/pages/profile.py
Normal 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
|
|
@ -272,22 +272,61 @@ 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(
|
||||
return rx.popover.root(
|
||||
rx.popover.trigger(
|
||||
rx.tooltip(
|
||||
rx.box(
|
||||
_avatar_or_photo(size="2"),
|
||||
rx.icon_button(
|
||||
rx.icon("log-out", size=14),
|
||||
on_click=AuthState.logout,
|
||||
variant="ghost", size="1", cursor="pointer",
|
||||
),
|
||||
spacing="2", align="center", width="100%",
|
||||
cursor="pointer",
|
||||
display="flex",
|
||||
justify_content="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(
|
||||
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",
|
||||
|
|
@ -295,12 +334,22 @@ def _user_widget(collapsed: bool = False) -> rx.Component:
|
|||
rx.text(AuthState.role, size="1", color=_TEXT_MUTED),
|
||||
spacing="0", align="start", overflow="hidden", flex="1",
|
||||
),
|
||||
rx.icon_button(
|
||||
rx.icon("log-out", size=14),
|
||||
on_click=AuthState.logout,
|
||||
variant="ghost", size="1", cursor="pointer",
|
||||
),
|
||||
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.popover.content(
|
||||
_user_menu_items(),
|
||||
min_width="200px",
|
||||
padding="0.4rem",
|
||||
side="top",
|
||||
align="end",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +82,14 @@ def send_email(
|
|||
msg["From"] = f"{_from_name} <{_from_email}>" if _from_name else _from_email
|
||||
msg["To"] = to_email
|
||||
msg["Subject"] = subject
|
||||
|
||||
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 []):
|
||||
|
|
|
|||
128
src/password_emails.py
Normal file
128
src/password_emails.py
Normal 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
126
src/password_tokens.py
Normal 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
60
src/user_access.py
Normal 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]
|
||||
Loading…
Add table
Reference in a new issue