diff --git a/data/auth.yaml b/data/auth.yaml
index f5637d2..59a2b05 100644
--- a/data/auth.yaml
+++ b/data/auth.yaml
@@ -11,7 +11,7 @@ credentials:
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
role: admin
smtp_password: 17acdfd671d8ab
- totp_secret: null
+ totp_secret: H6QDWOPHK4GBT447VCKI6VDKEEUVFQZY
test:
email: julien@balet-vs.ch
name: test
diff --git a/eptm_dashboard/pages/fiche.py b/eptm_dashboard/pages/fiche.py
index 6c352e8..fd9423d 100644
--- a/eptm_dashboard/pages/fiche.py
+++ b/eptm_dashboard/pages/fiche.py
@@ -4,20 +4,23 @@ import io
import json
import os
import reflex as rx
-from datetime import date, timedelta
+from datetime import date, datetime, timedelta
from pathlib import Path
from sqlalchemy import select
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
+_SETTINGS_FILE = DATA_DIR / "settings.json"
from ..state import AuthState
from ..sidebar import layout
from src.db import (
get_session, Apprenti, Absence, ApprentiFiche,
NotesBulletin, NotesMatu, NotesExamen, ImportBN, ImportMatu,
+ upsert_escada_pending,
)
from src.stats import nb_blocs_absences
from src.parser_bn import sem_short_label
+from src.email_sender import build_template_vars, render_template
MOIS_FR = [
"janvier", "fevrier", "mars", "avril", "mai", "juin",
@@ -33,7 +36,16 @@ _GROUP_LABELS = {
_GROUP_ORDER = {"DUAL": ["CG", "BP"], "EM": ["BP", "TP"]}
-# ── HTML generators (pure Python, no Streamlit dependency) ───────────────────
+def _read_settings() -> dict:
+ if _SETTINGS_FILE.exists():
+ try:
+ return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return {}
+
+
+# ── HTML generators ───────────────────────────────────────────────────────────
def _bn_fmt(v) -> str:
if v is None:
@@ -172,7 +184,16 @@ def _render_notes_html(notes_data: list) -> str:
f'
'
f'{_br_name}'
f'Moyenne : {_moy_html}
'
- "| Date | Examen | Enseignant | "
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ "| Date | Examen | Enseignant | "
"Coeff | Type | Note |
"
)
for _ex in _br.get("examens", []):
@@ -311,8 +332,7 @@ def _absence_pdf_apprenti(sess, apprenti) -> bytes:
buf = io.BytesIO()
doc = SimpleDocTemplate(
- buf,
- pagesize=landscape(A4),
+ buf, pagesize=landscape(A4),
leftMargin=1.5 * cm, rightMargin=1.5 * cm,
topMargin=1.5 * cm, bottomMargin=1.5 * cm,
)
@@ -355,18 +375,21 @@ def _extract_bn_pages(pdf_path, nom: str, prenom: str) -> bytes | None:
# ── State ─────────────────────────────────────────────────────────────────────
class FicheState(AuthState):
+ # ── Apprenti selector ────────────────────────────────────────────────────
apprenti_labels: list[str] = []
apprenti_ids: list[int] = []
selected_label: str = ""
selected_id: int = 0
has_apprentis: bool = False
+ # ── KPIs ─────────────────────────────────────────────────────────────────
kpi_total: int = 0
kpi_excusees: int = 0
kpi_non_excusees: int = 0
kpi_blocs: int = 0
quota_atteint: bool = False
+ # ── Calendar ─────────────────────────────────────────────────────────────
cal_year: int = 0
cal_month: int = 0
cal_month_name: str = ""
@@ -374,6 +397,24 @@ class FicheState(AuthState):
cal_next_name: str = ""
cal_days: list[dict] = []
+ # ── Pending dates (quick excuse) ─────────────────────────────────────────
+ pending_dates: list[dict] = []
+
+ # ── Calendar day edit ─────────────────────────────────────────────────────
+ edit_date: str = ""
+ edit_date_label: str = ""
+ edit_p1: str = "present"
+ edit_p2: str = "present"
+ edit_p3: str = "present"
+ edit_p4: str = "present"
+ edit_p5: str = "present"
+ edit_p6: str = "present"
+ edit_p7: str = "present"
+ edit_p8: str = "present"
+ edit_p9: str = "present"
+ edit_p10: str = "present"
+
+ # ── Escada fiche ─────────────────────────────────────────────────────────
fiche_available: bool = False
fiche_adresse: str = ""
fiche_cp_localite: str = ""
@@ -390,6 +431,7 @@ class FicheState(AuthState):
fiche_formateur_email: str = ""
fiche_updated_at: str = ""
+ # ── Bulletin de notes ─────────────────────────────────────────────────────
has_bn: bool = False
bn_html: str = ""
bn_caption: str = ""
@@ -399,6 +441,41 @@ class FicheState(AuthState):
has_pdf_bn: bool = False
has_pdf_notes: bool = False
+ # ── Email ─────────────────────────────────────────────────────────────────
+ smtp_ok: bool = False
+ email_dest: str = "apprenti"
+ email_custom: str = ""
+ email_subject: str = ""
+ email_body: str = ""
+ email_attach_abs: bool = True
+ email_attach_bn: bool = False
+ email_attach_notes: bool = False
+ email_sending: bool = False
+ email_sent: bool = False
+ email_error: str = ""
+
+ # ── Setters (edit periods) ────────────────────────────────────────────────
+ def set_edit_p1(self, v: str): self.edit_p1 = v
+ def set_edit_p2(self, v: str): self.edit_p2 = v
+ def set_edit_p3(self, v: str): self.edit_p3 = v
+ def set_edit_p4(self, v: str): self.edit_p4 = v
+ def set_edit_p5(self, v: str): self.edit_p5 = v
+ def set_edit_p6(self, v: str): self.edit_p6 = v
+ def set_edit_p7(self, v: str): self.edit_p7 = v
+ def set_edit_p8(self, v: str): self.edit_p8 = v
+ def set_edit_p9(self, v: str): self.edit_p9 = v
+ def set_edit_p10(self, v: str): self.edit_p10 = v
+
+ # ── Setters (email) ───────────────────────────────────────────────────────
+ def set_email_dest(self, v: str): self.email_dest = v
+ def set_email_custom(self, v: str): self.email_custom = v
+ def set_email_subject(self, v: str): self.email_subject = v
+ def set_email_body(self, v: str): self.email_body = v
+ def toggle_attach_abs(self, v: bool): self.email_attach_abs = v
+ def toggle_attach_bn(self, v: bool): self.email_attach_bn = v
+ def toggle_attach_notes(self, v: bool): self.email_attach_notes = v
+
+ # ── Page load ─────────────────────────────────────────────────────────────
def load_data(self):
if not self.authenticated:
return rx.redirect("/login")
@@ -419,7 +496,7 @@ class FicheState(AuthState):
if self.selected_id == 0 or self.selected_id not in self.apprenti_ids:
self.selected_id = self.apprenti_ids[0]
self.selected_label = self.apprenti_labels[0]
- self._reload()
+ self._reload(reset_email=True)
def handle_select(self, label: str):
self.selected_label = label
@@ -428,15 +505,18 @@ class FicheState(AuthState):
self.selected_id = self.apprenti_ids[idx]
except ValueError:
pass
- self._reload()
+ self.edit_date = ""
+ self._reload(reset_email=True)
def navigate_to(self, apprenti_id: int):
if apprenti_id in self.apprenti_ids:
idx = self.apprenti_ids.index(apprenti_id)
self.selected_id = apprenti_id
self.selected_label = self.apprenti_labels[idx]
- self._reload()
+ self.edit_date = ""
+ self._reload(reset_email=True)
+ # ── Calendar navigation ───────────────────────────────────────────────────
def prev_month(self):
if self.cal_month == 1:
self.cal_month = 12
@@ -453,6 +533,112 @@ class FicheState(AuthState):
self.cal_month += 1
self._rebuild_calendar()
+ # ── Calendar day edit ─────────────────────────────────────────────────────
+ def select_day(self, date_str: str):
+ if not date_str:
+ return
+ if self.edit_date == date_str:
+ self.edit_date = ""
+ return
+ sess = get_session()
+ d = date.fromisoformat(date_str)
+ absences = sess.execute(
+ select(Absence).where(
+ Absence.apprenti_id == self.selected_id,
+ Absence.date == d,
+ )
+ ).scalars().all()
+ pm = {ab.periode: ab.statut for ab in absences}
+
+ def _choice(p: int) -> str:
+ s = pm.get(p)
+ if s == "excusee": return "excusee"
+ if s == "a_traiter": return "non_excusee"
+ return "present"
+
+ self.edit_p1 = _choice(1)
+ self.edit_p2 = _choice(2)
+ self.edit_p3 = _choice(3)
+ self.edit_p4 = _choice(4)
+ self.edit_p5 = _choice(5)
+ self.edit_p6 = _choice(6)
+ self.edit_p7 = _choice(7)
+ self.edit_p8 = _choice(8)
+ self.edit_p9 = _choice(9)
+ self.edit_p10 = _choice(10)
+ self.edit_date_label = d.strftime("%d.%m.%Y")
+ self.edit_date = date_str
+
+ def cancel_edit(self):
+ self.edit_date = ""
+
+ def save_day_edit(self):
+ if not self.edit_date:
+ return
+ sess = get_session()
+ d = date.fromisoformat(self.edit_date)
+ existing = sess.execute(
+ select(Absence).where(
+ Absence.apprenti_id == self.selected_id,
+ Absence.date == d,
+ )
+ ).scalars().all()
+ pm = {ab.periode: ab for ab in existing}
+ choices = {
+ 1: self.edit_p1, 2: self.edit_p2, 3: self.edit_p3,
+ 4: self.edit_p4, 5: self.edit_p5, 6: self.edit_p6,
+ 7: self.edit_p7, 8: self.edit_p8, 9: self.edit_p9,
+ 10: self.edit_p10,
+ }
+ for p, choice in choices.items():
+ ab = pm.get(p)
+ if choice == "present":
+ if ab:
+ upsert_escada_pending(sess, self.selected_id, d, p, "clear")
+ sess.delete(ab)
+ else:
+ type_o = "E" if choice == "excusee" else "N"
+ statut = "excusee" if choice == "excusee" else "a_traiter"
+ if ab:
+ if ab.statut != statut:
+ ab.type_origine = type_o
+ ab.statut = statut
+ ab.updated_by = self.username
+ upsert_escada_pending(sess, self.selected_id, d, p, type_o)
+ else:
+ sess.add(Absence(
+ apprenti_id=self.selected_id,
+ date=d, periode=p,
+ type_origine=type_o, statut=statut,
+ updated_by=self.username, import_id=None,
+ ))
+ upsert_escada_pending(sess, self.selected_id, d, p, type_o)
+ sess.commit()
+ self.edit_date = ""
+ self._reload(reset_email=False)
+
+ # ── Quick excuse ──────────────────────────────────────────────────────────
+ def excuse_day(self, date_str: str):
+ sess = get_session()
+ d = date.fromisoformat(date_str)
+ absences = sess.execute(
+ select(Absence).where(
+ Absence.apprenti_id == self.selected_id,
+ Absence.date == d,
+ Absence.statut == "a_traiter",
+ )
+ ).scalars().all()
+ for ab in absences:
+ ab.statut = "excusee"
+ ab.type_origine = "E"
+ ab.updated_by = self.username
+ upsert_escada_pending(sess, self.selected_id, d, ab.periode, "E")
+ sess.commit()
+ if self.edit_date == date_str:
+ self.edit_date = ""
+ self._reload(reset_email=False)
+
+ # ── Downloads ─────────────────────────────────────────────────────────────
def download_abs_pdf(self):
sess = get_session()
apprenti = sess.get(Apprenti, self.selected_id)
@@ -489,7 +675,89 @@ class FicheState(AuthState):
filename = f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"
return rx.download(data=pdf_bytes, filename=filename)
- def _reload(self):
+ # ── Email send (background task) ──────────────────────────────────────────
+ async def send_email_action(self):
+ from src.email_sender import send_email as _send_email
+ async with self:
+ self.email_sending = True
+ self.email_error = ""
+ self.email_sent = False
+ try:
+ s = _read_settings()
+ smtp_host = s.get("smtp_host", "smtp-relay.brevo.com")
+ smtp_port = int(s.get("smtp_port", 587))
+ smtp_login = s.get("smtp_login", "")
+ smtp_password = s.get("smtp_password", "")
+ smtp_sender = s.get("smtp_sender", "")
+
+ if self.email_dest == "apprenti":
+ recipients = [self.fiche_email_val] if self.fiche_email_val else []
+ elif self.email_dest == "formateur":
+ recipients = [self.fiche_formateur_email] if self.fiche_formateur_email else []
+ else:
+ recipients = [e.strip() for e in self.email_custom.split(",") if e.strip()]
+
+ if not recipients:
+ async with self:
+ self.email_error = "Aucune adresse email valide."
+ self.email_sending = False
+ return
+
+ sess = get_session()
+ apprenti = sess.get(Apprenti, self.selected_id)
+ attachments = []
+
+ if self.email_attach_abs and apprenti:
+ pdf_bytes = _absence_pdf_apprenti(sess, apprenti)
+ attachments.append((pdf_bytes, f"Absences_{apprenti.nom}_{apprenti.prenom}.pdf"))
+
+ if self.email_attach_bn and self.has_pdf_bn and apprenti:
+ pdf_path = DATA_DIR / "pdfs" / self.bn_pdf_fichier
+ pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
+ if pdf_bytes:
+ attachments.append((pdf_bytes, f"BN_{apprenti.nom}_{apprenti.prenom}.pdf"))
+
+ if self.email_attach_notes and self.has_pdf_notes and apprenti:
+ notes_fname = f"notes_{apprenti.classe.replace(' ', '_')}.pdf"
+ pdf_path = DATA_DIR / "pdfs" / notes_fname
+ pdf_bytes = _extract_bn_pages(pdf_path, apprenti.nom, apprenti.prenom)
+ if pdf_bytes:
+ attachments.append((pdf_bytes, f"Notes_{apprenti.nom}_{apprenti.prenom}.pdf"))
+
+ if not attachments:
+ async with self:
+ self.email_error = "Sélectionnez au moins un document à joindre."
+ self.email_sending = False
+ return
+
+ errors = []
+ for to in recipients:
+ try:
+ _send_email(
+ smtp_host=smtp_host, smtp_port=smtp_port,
+ smtp_login=smtp_login, smtp_password=smtp_password,
+ smtp_sender=smtp_sender,
+ to_email=to, subject=self.email_subject, body=self.email_body,
+ attachments=attachments,
+ )
+ except Exception as e:
+ errors.append(f"{to}: {e}")
+
+ async with self:
+ if errors:
+ self.email_error = "; ".join(errors)
+ else:
+ self.email_sent = True
+ self.email_sending = False
+ except Exception as e:
+ async with self:
+ self.email_error = str(e)
+ self.email_sending = False
+
+ send_email_action._reflex_background_task = True
+
+ # ── Internal helpers ──────────────────────────────────────────────────────
+ def _reload(self, reset_email: bool = True):
sess = get_session()
absences = sess.execute(
select(Absence)
@@ -497,12 +765,26 @@ class FicheState(AuthState):
.order_by(Absence.date, Absence.periode)
).scalars().all()
- self.kpi_total = len(absences)
- self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
+ self.kpi_total = len(absences)
+ self.kpi_excusees = sum(1 for a in absences if a.statut == "excusee")
self.kpi_non_excusees = sum(1 for a in absences if a.statut == "a_traiter")
- self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
- self.quota_atteint = self.kpi_blocs >= QUOTA
+ self.kpi_blocs = nb_blocs_absences(sess, self.selected_id)
+ self.quota_atteint = self.kpi_blocs >= QUOTA
+ # Pending dates
+ by_date: dict = {}
+ for ab in absences:
+ by_date.setdefault(ab.date, []).append(ab)
+ self.pending_dates = [
+ {
+ "date_str": d.isoformat(),
+ "label": f"{d.strftime('%d.%m')} ({sum(1 for a in al if a.statut == 'a_traiter')})",
+ }
+ for d, al in sorted(by_date.items())
+ if any(a.statut == "a_traiter" for a in al)
+ ]
+
+ # Fiche
fiche = sess.execute(
select(ApprentiFiche).where(ApprentiFiche.apprenti_id == self.selected_id)
).scalar_one_or_none()
@@ -546,14 +828,43 @@ class FicheState(AuthState):
self._build_bn(sess)
if absences:
- self.cal_year = absences[0].date.year
+ self.cal_year = absences[0].date.year
self.cal_month = absences[0].date.month
else:
today = date.today()
- self.cal_year = today.year
+ self.cal_year = today.year
self.cal_month = today.month
self._build_calendar_from(absences)
+ if reset_email:
+ s = _read_settings()
+ self.smtp_ok = bool(
+ s.get("smtp_host") and s.get("smtp_login") and s.get("smtp_password")
+ )
+ if self.smtp_ok:
+ apprenti = sess.get(Apprenti, self.selected_id)
+ if apprenti:
+ tvars = build_template_vars(apprenti, list(absences))
+ _def_subj = "Relevé d'absences — {nom_complet} ({classe})"
+ _def_body = (
+ "Bonjour {prenom},\n\n"
+ "Veuillez trouver ci-joint votre document.\n\n"
+ "Cordialement,\nL'équipe EPTM"
+ )
+ self.email_subject = render_template(
+ s.get("email_subject", _def_subj), tvars
+ )
+ self.email_body = render_template(
+ s.get("email_body", _def_body), tvars
+ )
+ self.email_sent = False
+ self.email_error = ""
+ self.email_dest = "apprenti"
+ self.email_custom = ""
+ self.email_attach_abs = True
+ self.email_attach_bn = False
+ self.email_attach_notes = False
+
def _build_bn(self, sess):
bn_records = sess.execute(
select(NotesBulletin, ImportBN)
@@ -661,10 +972,13 @@ class FicheState(AuthState):
exc = info.get("excusees", 0)
non = info.get("non_excusees", 0)
has_non_exc = non > 0
- if has_abs and has_non_exc:
- lbl = str(day_num) + " !"
- elif has_abs:
- lbl = str(day_num) + " v"
+ if has_abs:
+ parts = [str(day_num)]
+ if non > 0:
+ parts.append(f"⚠{non}")
+ if exc > 0:
+ parts.append(f"✓{exc}")
+ lbl = " ".join(parts)
else:
lbl = str(day_num)
days.append({
@@ -703,44 +1017,293 @@ def _info_line(icon: str, value) -> rx.Component:
def _cal_day_cell(d) -> rx.Component:
+ is_selected = d["date_str"] == FicheState.edit_date
return rx.cond(
d["is_empty"],
- rx.box(min_height="34px", border_radius="4px"),
+ rx.box(min_height="36px", border_radius="4px"),
rx.box(
rx.text(
d["label"],
size="1",
font_weight=rx.cond(d["is_today"], "700", "400"),
color=rx.cond(
- d["has_non_exc"],
- "#c62828",
- rx.cond(d["has_abs"], "#2e7d32", "#333"),
+ is_selected, "#1565c0",
+ rx.cond(
+ d["has_non_exc"], "#c62828",
+ rx.cond(d["has_abs"], "#2e7d32", "#333"),
+ ),
),
text_align="center",
),
- min_height="34px",
+ min_height="36px",
border_radius="4px",
background_color=rx.cond(
- d["has_non_exc"],
- "#ffebee",
+ is_selected, "#dbeafe",
rx.cond(
- d["has_abs"],
- "#e8f5e9",
- rx.cond(d["is_today"], "#e3f2fd", "white"),
+ d["has_non_exc"], "#ffebee",
+ rx.cond(
+ d["has_abs"], "#e8f5e9",
+ rx.cond(d["is_today"], "#e3f2fd", "white"),
+ ),
),
),
border=rx.cond(
- d["is_today"],
- "2px solid #1565c0",
- "1px solid #eee",
+ is_selected, "2px solid #1565c0",
+ rx.cond(d["is_today"], "2px solid #90caf9", "1px solid #eee"),
),
display="flex",
align_items="center",
justify_content="center",
+ cursor=rx.cond(d["has_abs"], "pointer", "default"),
+ on_click=FicheState.select_day(d["date_str"]),
),
)
+def _period_select(p_num: int, val, setter) -> rx.Component:
+ return rx.hstack(
+ rx.text(f"P{p_num}", size="2", weight="medium", color="#555",
+ min_width="28px", text_align="right"),
+ rx.select.root(
+ rx.select.trigger(width="170px"),
+ rx.select.content(
+ rx.select.item("Présent", value="present"),
+ rx.select.item("E — Excusée", value="excusee"),
+ rx.select.item("N — Non excusée", value="non_excusee"),
+ ),
+ value=val,
+ on_change=setter,
+ size="1",
+ ),
+ spacing="2",
+ align="center",
+ )
+
+
+def _edit_panel() -> rx.Component:
+ return rx.box(
+ rx.vstack(
+ rx.hstack(
+ rx.icon("pencil", size=15, color="#1565c0"),
+ rx.text(
+ "Édition du ", FicheState.edit_date_label,
+ size="3", weight="bold", color="#37474f",
+ ),
+ rx.spacer(),
+ rx.button(
+ rx.icon("x", size=14),
+ on_click=FicheState.cancel_edit,
+ variant="ghost", color_scheme="gray", size="1",
+ ),
+ width="100%", align="center",
+ ),
+ rx.divider(),
+ rx.grid(
+ _period_select(1, FicheState.edit_p1, FicheState.set_edit_p1),
+ _period_select(2, FicheState.edit_p2, FicheState.set_edit_p2),
+ _period_select(3, FicheState.edit_p3, FicheState.set_edit_p3),
+ _period_select(4, FicheState.edit_p4, FicheState.set_edit_p4),
+ _period_select(5, FicheState.edit_p5, FicheState.set_edit_p5),
+ _period_select(6, FicheState.edit_p6, FicheState.set_edit_p6),
+ _period_select(7, FicheState.edit_p7, FicheState.set_edit_p7),
+ _period_select(8, FicheState.edit_p8, FicheState.set_edit_p8),
+ _period_select(9, FicheState.edit_p9, FicheState.set_edit_p9),
+ _period_select(10, FicheState.edit_p10, FicheState.set_edit_p10),
+ columns="2",
+ gap="0.4rem",
+ width="100%",
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("save", size=14), "Enregistrer",
+ on_click=FicheState.save_day_edit,
+ color_scheme="blue", size="2",
+ ),
+ rx.button(
+ "Annuler",
+ on_click=FicheState.cancel_edit,
+ variant="outline", color_scheme="gray", size="2",
+ ),
+ spacing="3",
+ ),
+ spacing="3", width="100%",
+ ),
+ padding="1rem",
+ background_color="#f0f7ff",
+ border_radius="8px",
+ border="1px solid #bfdbfe",
+ width="100%",
+ )
+
+
+def _pending_btn(item: dict) -> rx.Component:
+ return rx.button(
+ rx.icon("check", size=13),
+ item["label"],
+ on_click=FicheState.excuse_day(item["date_str"]),
+ color_scheme="green",
+ variant="soft",
+ size="1",
+ )
+
+
+def _email_section() -> rx.Component:
+ return rx.box(
+ rx.vstack(
+ rx.hstack(
+ rx.icon("mail", size=16, color="#37474f"),
+ rx.text("Envoyer par email", size="3", weight="bold", color="#37474f"),
+ spacing="2", align="center",
+ ),
+ rx.divider(),
+ rx.flex(
+ # ── Left: destinataire + pièces jointes ──────────────────────
+ rx.vstack(
+ rx.text("Destinataire", size="2", weight="bold", color="#555"),
+ rx.radio_group.root(
+ rx.vstack(
+ rx.radio_group.item(
+ rx.cond(
+ FicheState.fiche_email_val != "",
+ rx.text("Apprenti — ", FicheState.fiche_email_val, size="2"),
+ rx.text("Apprenti (email inconnu)", size="2", color="#999"),
+ ),
+ value="apprenti",
+ disabled=FicheState.fiche_email_val == "",
+ ),
+ rx.radio_group.item(
+ rx.cond(
+ FicheState.fiche_formateur_email != "",
+ rx.text("Formateur — ", FicheState.fiche_formateur_email, size="2"),
+ rx.text("Formateur (email inconnu)", size="2", color="#999"),
+ ),
+ value="formateur",
+ disabled=FicheState.fiche_formateur_email == "",
+ ),
+ rx.radio_group.item(
+ rx.text("Autre adresse", size="2"),
+ value="autre",
+ ),
+ spacing="2",
+ ),
+ value=FicheState.email_dest,
+ on_change=FicheState.set_email_dest,
+ ),
+ rx.cond(
+ FicheState.email_dest == "autre",
+ rx.input(
+ value=FicheState.email_custom,
+ on_change=FicheState.set_email_custom,
+ placeholder="email1@ex.com, email2@ex.com",
+ size="1", width="100%",
+ ),
+ ),
+ rx.text("Pièces jointes", size="2", weight="bold",
+ color="#555", margin_top="0.5rem"),
+ rx.vstack(
+ rx.checkbox(
+ "Tableau des absences",
+ checked=FicheState.email_attach_abs,
+ on_change=FicheState.toggle_attach_abs,
+ size="1",
+ ),
+ rx.checkbox(
+ rx.cond(
+ FicheState.has_pdf_bn,
+ "Bulletin de notes",
+ "Bulletin de notes (indisponible)",
+ ),
+ checked=FicheState.email_attach_bn,
+ on_change=FicheState.toggle_attach_bn,
+ disabled=~FicheState.has_pdf_bn,
+ size="1",
+ ),
+ rx.checkbox(
+ rx.cond(
+ FicheState.has_pdf_notes,
+ "Notes d'examen",
+ "Notes d'examen (indisponible)",
+ ),
+ checked=FicheState.email_attach_notes,
+ on_change=FicheState.toggle_attach_notes,
+ disabled=~FicheState.has_pdf_notes,
+ size="1",
+ ),
+ spacing="2",
+ ),
+ spacing="2", flex="1", min_width="220px",
+ ),
+ # ── Right: message + envoyer ──────────────────────────────────
+ rx.vstack(
+ rx.vstack(
+ rx.text("Objet", size="2", weight="bold", color="#555"),
+ rx.input(
+ value=FicheState.email_subject,
+ on_change=FicheState.set_email_subject,
+ width="100%", size="1",
+ ),
+ spacing="1", width="100%",
+ ),
+ rx.vstack(
+ rx.text("Corps du message", size="2", weight="bold", color="#555"),
+ rx.text_area(
+ value=FicheState.email_body,
+ on_change=FicheState.set_email_body,
+ rows="7", width="100%",
+ font_family="monospace",
+ font_size="0.82rem",
+ resize="vertical",
+ ),
+ spacing="1", width="100%",
+ ),
+ rx.button(
+ rx.cond(
+ FicheState.email_sending,
+ rx.hstack(
+ rx.spinner(size="1"),
+ rx.text("Envoi en cours…"),
+ spacing="2", align="center",
+ ),
+ rx.hstack(
+ rx.icon("send", size=14),
+ rx.text("Envoyer"),
+ spacing="2", align="center",
+ ),
+ ),
+ on_click=FicheState.send_email_action,
+ color_scheme="blue", size="2", width="100%",
+ disabled=FicheState.email_sending,
+ ),
+ rx.cond(
+ FicheState.email_sent,
+ rx.callout.root(
+ rx.callout.icon(rx.icon("check", size=14)),
+ rx.callout.text("Email envoyé."),
+ color_scheme="green", variant="soft", size="1",
+ ),
+ ),
+ rx.cond(
+ FicheState.email_error != "",
+ rx.callout.root(
+ rx.callout.icon(rx.icon("triangle-alert", size=14)),
+ rx.callout.text(FicheState.email_error),
+ color_scheme="red", variant="soft", size="1",
+ ),
+ ),
+ spacing="3", flex="2", min_width="260px",
+ ),
+ gap="1.5rem", flex_wrap="wrap", width="100%", align="start",
+ ),
+ spacing="3", width="100%",
+ ),
+ padding="1rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ )
+
+
_DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
@@ -761,46 +1324,46 @@ def fiche_page() -> rx.Component:
width="100%",
),
- # ── Alerte quota ──────────────────────────────────────────
- rx.cond(
- FicheState.quota_atteint,
- rx.hstack(
- rx.icon("triangle-alert", size=16, color="#c62828"),
- rx.text(
- "Avis de sanction — ",
- FicheState.kpi_blocs,
- " absences sur 5 autorisees",
- size="2", color="#c62828",
- ),
- padding="0.75rem 1rem",
- background_color="#ffebee",
- border_radius="6px",
- border="1px solid #ffcdd2",
- width="100%", spacing="2", align="center",
- ),
- ),
-
# ── KPI cards ─────────────────────────────────────────────
rx.flex(
- _kpi_card("Total periodes", FicheState.kpi_total),
- _kpi_card("Excusees", FicheState.kpi_excusees, "#2e7d32"),
- _kpi_card("Non excusees", FicheState.kpi_non_excusees, "#c62828"),
- _kpi_card("Absences (blocs)", FicheState.kpi_blocs),
+ _kpi_card("Périodes d'absence", FicheState.kpi_total),
+ _kpi_card("Périodes à excuser", FicheState.kpi_non_excusees, "#c62828"),
+ rx.box(
+ rx.text("Absences", size="1", color="#666"),
+ rx.text(
+ FicheState.kpi_blocs,
+ size="7", font_weight="700",
+ color=rx.cond(FicheState.quota_atteint, "#c62828", "#37474f"),
+ ),
+ rx.cond(
+ FicheState.quota_atteint,
+ rx.text(
+ "Avis de sanction",
+ size="1", weight="bold", color="#c62828",
+ ),
+ ),
+ padding="1rem",
+ background_color=rx.cond(FicheState.quota_atteint, "#fff0f0", "white"),
+ border_radius="8px",
+ border=rx.cond(
+ FicheState.quota_atteint,
+ "1px solid #ffcdd2",
+ "1px solid #e0e0e0",
+ ),
+ flex="1",
+ min_width="120px",
+ ),
gap="1rem", flex_wrap="wrap", width="100%",
),
# ── Fiche détaillée Escada ────────────────────────────────
rx.box(
- rx.text(
- "Fiche detaillee (Escada)", size="3",
- font_weight="700", color="#37474f", margin_bottom="0.75rem",
- ),
rx.cond(
FicheState.fiche_available,
rx.vstack(
rx.flex(
rx.vstack(
- rx.text("Eleve", size="2", font_weight="700", color="#37474f"),
+ rx.text("Élève", size="2", font_weight="700", color="#37474f"),
_info_line("map-pin", FicheState.fiche_adresse),
_info_line("map-pin", FicheState.fiche_cp_localite),
_info_line("phone", FicheState.fiche_telephone),
@@ -827,7 +1390,7 @@ def fiche_page() -> rx.Component:
gap="1.5rem", flex_wrap="wrap", width="100%",
),
rx.text(
- "Mis a jour le ", FicheState.fiche_updated_at, " depuis Escada",
+ "Mis à jour le ", FicheState.fiche_updated_at, " depuis Escada",
size="1", color="#9e9e9e", margin_top="0.5rem",
),
spacing="3", width="100%",
@@ -855,21 +1418,16 @@ def fiche_page() -> rx.Component:
rx.cond(
FicheState.has_bn,
rx.vstack(
- rx.text(
- FicheState.bn_caption,
- size="1", color="#9e9e9e",
- ),
+ rx.text(FicheState.bn_caption, size="1", color="#9e9e9e"),
rx.html(FicheState.bn_html),
spacing="2", width="100%",
),
rx.text(
- "Aucun bulletin de notes importe pour cet(te) apprenti(e).",
+ "Aucun bulletin de notes importé pour cet(te) apprenti(e).",
size="2", color="#666",
),
),
- value="bn",
- width="100%",
- padding_top="1rem",
+ value="bn", width="100%", padding_top="1rem",
),
rx.tabs.content(
rx.cond(
@@ -880,12 +1438,9 @@ def fiche_page() -> rx.Component:
size="2", color="#666",
),
),
- value="notes",
- width="100%",
- padding_top="1rem",
+ value="notes", width="100%", padding_top="1rem",
),
- default_value="bn",
- width="100%",
+ default_value="bn", width="100%",
),
padding="1rem",
background_color="white",
@@ -897,37 +1452,27 @@ def fiche_page() -> rx.Component:
# ── Export PDF ────────────────────────────────────────────
rx.flex(
rx.button(
- rx.icon("download", size=13),
- "PDF absences",
+ rx.icon("download", size=13), "PDF absences",
on_click=FicheState.download_abs_pdf,
- variant="outline",
- color_scheme="gray",
- size="1",
+ variant="outline", color_scheme="gray", size="1",
),
rx.cond(
FicheState.has_pdf_bn,
rx.button(
- rx.icon("file-text", size=13),
- "PDF bulletin",
+ rx.icon("file-text", size=13), "PDF bulletin",
on_click=FicheState.download_bn_pdf,
- variant="outline",
- color_scheme="blue",
- size="1",
+ variant="outline", color_scheme="blue", size="1",
),
),
rx.cond(
FicheState.has_pdf_notes,
rx.button(
- rx.icon("file-text", size=13),
- "PDF notes",
+ rx.icon("file-text", size=13), "PDF notes",
on_click=FicheState.download_notes_pdf,
- variant="outline",
- color_scheme="violet",
- size="1",
+ variant="outline", color_scheme="violet", size="1",
),
),
- flex_wrap="wrap",
- gap="0.5rem",
+ flex_wrap="wrap", gap="0.5rem",
),
# ── Calendrier mensuel ────────────────────────────────────
@@ -936,8 +1481,7 @@ def fiche_page() -> rx.Component:
rx.box(
rx.hstack(
rx.button(
- rx.icon("chevron-left", size=14),
- FicheState.cal_prev_name,
+ rx.icon("chevron-left", size=14), FicheState.cal_prev_name,
on_click=FicheState.prev_month,
variant="outline", color_scheme="gray", size="2",
),
@@ -947,8 +1491,7 @@ def fiche_page() -> rx.Component:
flex="1", text_align="center",
),
rx.button(
- FicheState.cal_next_name,
- rx.icon("chevron-right", size=14),
+ FicheState.cal_next_name, rx.icon("chevron-right", size=14),
on_click=FicheState.next_month,
variant="outline", color_scheme="gray", size="2",
),
@@ -956,10 +1499,8 @@ def fiche_page() -> rx.Component:
),
rx.grid(
*[
- rx.text(
- h, size="1", color="#9e9e9e",
- text_align="center", font_weight="600",
- )
+ rx.text(h, size="1", color="#9e9e9e",
+ text_align="center", font_weight="600")
for h in _DOW
],
columns="7", gap="2px", width="100%", margin_bottom="2px",
@@ -969,27 +1510,82 @@ def fiche_page() -> rx.Component:
columns="7", gap="2px", width="100%",
),
rx.hstack(
- rx.box(
- width="12px", height="12px",
- background_color="#ffebee", border_radius="2px",
- border="1px solid #eee",
- ),
- rx.text("Non excusee", size="1", color="#666"),
- rx.box(
- width="12px", height="12px",
- background_color="#e8f5e9", border_radius="2px",
- border="1px solid #eee",
- ),
- rx.text("Excusee", size="1", color="#666"),
+ rx.box(width="12px", height="12px", background_color="#ffebee",
+ border_radius="2px", border="1px solid #eee"),
+ rx.text("Non excusée", size="1", color="#666"),
+ rx.box(width="12px", height="12px", background_color="#e8f5e9",
+ border_radius="2px", border="1px solid #eee"),
+ rx.text("Excusée", size="1", color="#666"),
+ rx.box(width="12px", height="12px", background_color="#dbeafe",
+ border_radius="2px", border="2px solid #1565c0"),
+ rx.text("Sélectionné", size="1", color="#666"),
spacing="2", align="center", margin_top="0.5rem",
),
+ rx.text(
+ "Cliquez sur un jour avec absences pour éditer les périodes.",
+ size="1", color="#9e9e9e", margin_top="0.25rem",
+ ),
padding="1rem",
background_color="white",
border_radius="8px",
border="1px solid #e0e0e0",
width="100%",
),
- rx.text("Aucune absence enregistree.", size="2", color="#666"),
+ rx.text("Aucune absence enregistrée.", size="2", color="#666"),
+ ),
+
+ # ── Actions rapides ───────────────────────────────────────
+ rx.cond(
+ FicheState.pending_dates.length() > 0,
+ rx.box(
+ rx.vstack(
+ rx.hstack(
+ rx.icon("clock", size=15, color="#b45309"),
+ rx.text(
+ "Absences à traiter",
+ size="2", weight="bold", color="#92400e",
+ ),
+ spacing="2", align="center",
+ ),
+ rx.flex(
+ rx.foreach(FicheState.pending_dates, _pending_btn),
+ flex_wrap="wrap", gap="0.5rem",
+ ),
+ spacing="2", width="100%",
+ ),
+ padding="0.75rem 1rem",
+ background_color="#fffbeb",
+ border_radius="8px",
+ border="1px solid #fcd34d",
+ width="100%",
+ ),
+ ),
+
+ # ── Panneau d'édition ─────────────────────────────────────
+ rx.cond(
+ FicheState.edit_date != "",
+ _edit_panel(),
+ ),
+
+ # ── Email ─────────────────────────────────────────────────
+ rx.cond(
+ FicheState.smtp_ok,
+ _email_section(),
+ rx.box(
+ rx.hstack(
+ rx.icon("mail", size=14, color="#9e9e9e"),
+ rx.text(
+ "Email non configuré. Rendez-vous dans Paramètres.",
+ size="2", color="#9e9e9e",
+ ),
+ spacing="2", align="center",
+ ),
+ padding="0.75rem 1rem",
+ background_color="#f9fafb",
+ border_radius="8px",
+ border="1px solid #e5e7eb",
+ width="100%",
+ ),
),
spacing="4", width="100%",
diff --git a/eptm_dashboard/pages/login.py b/eptm_dashboard/pages/login.py
index 104c9b5..d75da34 100644
--- a/eptm_dashboard/pages/login.py
+++ b/eptm_dashboard/pages/login.py
@@ -2,51 +2,192 @@ import reflex as rx
from ..state import AuthState
-def login_page() -> rx.Component:
+def _logo() -> rx.Component:
return rx.center(
- rx.form(
+ rx.image(src="/logo.png", width="320px", height="auto"),
+ width="100%",
+ )
+
+
+def _error_box(msg) -> rx.Component:
+ return rx.box(
+ rx.text(msg, color="red", size="2"),
+ padding="0.5rem 1rem",
+ background_color="#fff5f5",
+ border="1px solid #ffcccc",
+ border_radius="6px",
+ width="100%",
+ )
+
+
+def _password_form() -> rx.Component:
+ return rx.form(
+ rx.vstack(
+ _logo(),
+ rx.cond(AuthState.login_error != "", _error_box(AuthState.login_error)),
+ rx.input(
+ name="username",
+ placeholder="Identifiant",
+ value=AuthState.login_user,
+ on_change=AuthState.set_login_user,
+ width="100%",
+ ),
+ rx.input(
+ name="password",
+ placeholder="Mot de passe",
+ type="password",
+ value=AuthState.login_pass,
+ on_change=AuthState.set_login_pass,
+ width="100%",
+ ),
+ rx.button(
+ "Se connecter",
+ type="submit",
+ width="100%",
+ color_scheme="indigo",
+ ),
+ spacing="3",
+ width="100%",
+ align="center",
+ ),
+ on_submit=AuthState.handle_login,
+ width="100%",
+ )
+
+
+def _setup_form() -> rx.Component:
+ """Première connexion : QR code à scanner + code de confirmation."""
+ return rx.form(
+ rx.vstack(
+ _logo(),
+ rx.heading("Configuration 2FA", size="4", color="#37474f"),
+ rx.text(
+ "Scanne ce QR code avec ton application Authenticator "
+ "(Google Authenticator, Microsoft Authenticator, Authy…) puis "
+ "saisis le code à 6 chiffres pour confirmer.",
+ size="2",
+ color="#555",
+ text_align="center",
+ ),
+ rx.center(
+ rx.image(
+ src=AuthState.totp_qr_data_url,
+ width="220px",
+ height="220px",
+ ),
+ width="100%",
+ ),
+ rx.text(
+ "Compte : ", AuthState.totp_pending_user,
+ size="1",
+ color="var(--gray-9)",
+ ),
+ rx.cond(AuthState.totp_error != "", _error_box(AuthState.totp_error)),
+ rx.input(
+ name="totp_code",
+ placeholder="Code à 6 chiffres",
+ value=AuthState.totp_code,
+ on_change=AuthState.set_totp_code,
+ width="100%",
+ max_length=6,
+ auto_focus=True,
+ text_align="center",
+ font_size="1.4rem",
+ letter_spacing="0.4rem",
+ ),
rx.vstack(
- rx.center(
- rx.image(src="/logo.png", width="320px", height="auto"),
- width="100%",
- ),
- rx.cond(
- AuthState.login_error != "",
- rx.box(
- rx.text(AuthState.login_error, color="red", size="2"),
- padding="0.5rem 1rem",
- background_color="#fff5f5",
- border="1px solid #ffcccc",
- border_radius="6px",
- width="100%",
- ),
- ),
- rx.input(
- name="username",
- placeholder="Identifiant",
- value=AuthState.login_user,
- on_change=AuthState.set_login_user,
- width="100%",
- ),
- rx.input(
- name="password",
- placeholder="Mot de passe",
- type="password",
- value=AuthState.login_pass,
- on_change=AuthState.set_login_pass,
- width="100%",
- ),
rx.button(
- "Se connecter",
+ "Activer 2FA",
type="submit",
width="100%",
color_scheme="indigo",
),
- spacing="3",
+ rx.button(
+ "Annuler",
+ on_click=AuthState.cancel_totp,
+ type="button",
+ variant="soft",
+ color_scheme="gray",
+ width="100%",
+ ),
width="100%",
- align="center",
+ spacing="2",
+ ),
+ spacing="3",
+ width="100%",
+ align="center",
+ ),
+ on_submit=AuthState.verify_totp,
+ width="100%",
+ )
+
+
+def _verify_form() -> rx.Component:
+ """2FA déjà active : juste demander le code."""
+ return rx.form(
+ rx.vstack(
+ _logo(),
+ rx.heading("Vérification 2FA", size="4", color="#37474f"),
+ rx.text(
+ "Entre le code à 6 chiffres affiché par ton application Authenticator.",
+ size="2",
+ color="#555",
+ text_align="center",
+ ),
+ rx.text(
+ "Compte : ", AuthState.totp_pending_user,
+ size="1",
+ color="var(--gray-9)",
+ ),
+ rx.cond(AuthState.totp_error != "", _error_box(AuthState.totp_error)),
+ rx.input(
+ name="totp_code",
+ placeholder="Code à 6 chiffres",
+ value=AuthState.totp_code,
+ on_change=AuthState.set_totp_code,
+ width="100%",
+ max_length=6,
+ auto_focus=True,
+ text_align="center",
+ font_size="1.4rem",
+ letter_spacing="0.4rem",
+ ),
+ rx.vstack(
+ rx.button(
+ "Valider",
+ type="submit",
+ width="100%",
+ color_scheme="indigo",
+ ),
+ rx.button(
+ "Annuler",
+ on_click=AuthState.cancel_totp,
+ type="button",
+ variant="soft",
+ color_scheme="gray",
+ width="100%",
+ ),
+ width="100%",
+ spacing="2",
+ ),
+ spacing="3",
+ width="100%",
+ align="center",
+ ),
+ on_submit=AuthState.verify_totp,
+ width="100%",
+ )
+
+
+def login_page() -> rx.Component:
+ return rx.center(
+ rx.box(
+ rx.match(
+ AuthState.totp_step,
+ ("setup", _setup_form()),
+ ("verify", _verify_form()),
+ _password_form(),
),
- on_submit=AuthState.handle_login,
width="420px",
padding="2rem",
background_color="white",
diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py
index 74c261f..7270b67 100644
--- a/eptm_dashboard/state.py
+++ b/eptm_dashboard/state.py
@@ -1,10 +1,39 @@
+import base64
+import io
import os
import bcrypt
+import pyotp
+import qrcode
import yaml
import reflex as rx
from pathlib import Path
DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
+TOTP_ISSUER = "EPTM Dashboard"
+
+
+def _load_auth_full() -> dict:
+ """Lit auth.yaml complet (config dict, pas seulement usernames)."""
+ auth_file = DATA_DIR / "auth.yaml"
+ if auth_file.exists():
+ with open(auth_file, encoding="utf-8") as f:
+ return yaml.safe_load(f) or {}
+ return {}
+
+
+def _save_auth_full(cfg: dict) -> None:
+ auth_file = DATA_DIR / "auth.yaml"
+ with open(auth_file, "w", encoding="utf-8") as f:
+ yaml.dump(cfg, f, allow_unicode=True)
+
+
+def _make_totp_qr_data_url(secret: str, label: str) -> str:
+ """Génère un data URL PNG du QR code TOTP."""
+ uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=TOTP_ISSUER)
+ img = qrcode.make(uri)
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
class AuthState(rx.State):
@@ -20,6 +49,15 @@ class AuthState(rx.State):
login_pass: str = ""
login_error: str = ""
+ # 2FA flow (in-memory, ephémère)
+ # totp_step in {"password", "setup", "verify"}
+ totp_step: str = "password"
+ totp_pending_user: str = ""
+ totp_secret_pending: str = "" # secret généré en setup, pas encore sauvé
+ totp_qr_data_url: str = ""
+ totp_code: str = ""
+ totp_error: str = ""
+
sidebar_collapsed: bool = False
mobile_menu_open: bool = False
admin_expanded: bool = True
@@ -55,6 +93,11 @@ class AuthState(rx.State):
def set_login_pass(self, value: str):
self.login_pass = value
+ def set_totp_code(self, value: str):
+ # Garder uniquement les chiffres, max 6
+ self.totp_code = "".join(ch for ch in value if ch.isdigit())[:6]
+ self.totp_error = ""
+
def index_redirect(self):
if self.authenticated:
return rx.redirect("/accueil")
@@ -77,20 +120,104 @@ class AuthState(rx.State):
self.login_error = ""
users = self._load_users()
user = users.get(self.login_user)
- if user:
- try:
- ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode())
- except Exception:
- ok = False
- if ok:
- self.username = self.login_user
- self.name = user.get("name", self.login_user)
- self.role = user.get("role", "user")
- self.photo_url = user.get("avatar_url", "")
- self.login_pass = ""
- return rx.redirect("/accueil")
- self.login_error = "Identifiant ou mot de passe incorrect"
+ if not user:
+ self.login_error = "Identifiant ou mot de passe incorrect"
+ self.login_pass = ""
+ return
+ try:
+ ok = bcrypt.checkpw(self.login_pass.encode(), user["password"].encode())
+ except Exception:
+ ok = False
+ if not ok:
+ self.login_error = "Identifiant ou mot de passe incorrect"
+ self.login_pass = ""
+ return
+
+ # Mot de passe OK — étape 2FA
self.login_pass = ""
+ self.totp_pending_user = self.login_user
+ self.totp_code = ""
+ self.totp_error = ""
+
+ if user.get("totp_secret"):
+ # 2FA déjà configurée — demander le code
+ self.totp_step = "verify"
+ self.totp_secret_pending = ""
+ self.totp_qr_data_url = ""
+ else:
+ # Première connexion 2FA — générer secret + QR
+ secret = pyotp.random_base32()
+ self.totp_secret_pending = secret
+ label = f"{self.login_user}@{TOTP_ISSUER}"
+ self.totp_qr_data_url = _make_totp_qr_data_url(secret, label)
+ self.totp_step = "setup"
+
+ def verify_totp(self, form_data: dict | None = None):
+ """Vérifie le code TOTP saisi. Si setup, sauve le secret. Puis login."""
+ self.totp_error = ""
+ if not self.totp_pending_user:
+ # Session perdue — retour login
+ self.cancel_totp()
+ return rx.redirect("/login")
+
+ if len(self.totp_code) != 6:
+ self.totp_error = "Code à 6 chiffres requis"
+ return
+
+ # Déterminer le secret à valider
+ if self.totp_step == "setup":
+ secret = self.totp_secret_pending
+ else:
+ cfg = _load_auth_full()
+ user = cfg.get("credentials", {}).get("usernames", {}).get(self.totp_pending_user)
+ if not user:
+ self.cancel_totp()
+ return rx.redirect("/login")
+ secret = user.get("totp_secret") or ""
+
+ if not secret:
+ self.totp_error = "Configuration 2FA manquante — contactez un administrateur"
+ return
+
+ # Vérifier code (valid_window=1 → tolère ±30s de dérive d'horloge)
+ totp = pyotp.TOTP(secret)
+ if not totp.verify(self.totp_code, valid_window=1):
+ self.totp_error = "Code invalide"
+ self.totp_code = ""
+ return
+
+ # Code OK
+ cfg = _load_auth_full()
+ users = cfg.get("credentials", {}).get("usernames", {})
+ user = users.get(self.totp_pending_user)
+ if not user:
+ self.cancel_totp()
+ return rx.redirect("/login")
+
+ # Si setup, sauver le secret dans auth.yaml
+ if self.totp_step == "setup":
+ users[self.totp_pending_user]["totp_secret"] = secret
+ _save_auth_full(cfg)
+
+ # Finaliser la connexion
+ self.username = self.totp_pending_user
+ self.name = user.get("name", self.totp_pending_user)
+ self.role = user.get("role", "user")
+ self.photo_url = user.get("avatar_url", "")
+ self._reset_totp_flow()
+ return rx.redirect("/accueil")
+
+ def cancel_totp(self):
+ """Annule le flow 2FA et revient à l'étape password."""
+ self._reset_totp_flow()
+
+ def _reset_totp_flow(self):
+ self.totp_step = "password"
+ self.totp_pending_user = ""
+ self.totp_secret_pending = ""
+ self.totp_qr_data_url = ""
+ self.totp_code = ""
+ self.totp_error = ""
def logout(self):
self._clear_session()
@@ -104,12 +231,8 @@ class AuthState(rx.State):
self.login_user = ""
self.login_pass = ""
self.login_error = ""
+ self._reset_totp_flow()
@staticmethod
def _load_users() -> dict:
- auth_file = DATA_DIR / "auth.yaml"
- if auth_file.exists():
- with open(auth_file) as f:
- data = yaml.safe_load(f) or {}
- return data.get("credentials", {}).get("usernames", {})
- return {}
+ return _load_auth_full().get("credentials", {}).get("usernames", {})
|---|