diff --git a/assets/avatars/julbal.jpg b/assets/avatars/julbal.jpg
new file mode 100644
index 0000000..954a0ca
Binary files /dev/null and b/assets/avatars/julbal.jpg differ
diff --git a/assets/default_avatar.svg b/assets/default_avatar.svg
new file mode 100644
index 0000000..4500477
--- /dev/null
+++ b/assets/default_avatar.svg
@@ -0,0 +1,10 @@
+
diff --git a/data/auth.yaml b/data/auth.yaml
index 1385f74..f5637d2 100644
--- a/data/auth.yaml
+++ b/data/auth.yaml
@@ -5,12 +5,13 @@ cookie:
credentials:
usernames:
julbal:
+ avatar_url: /avatars/julbal.jpg?t=1778400300
email: julien.balet@edu.vs.ch
name: Julien Balet
password: $2b$12$kigcAqfs9VIySuVHxenU6uTyk/8ef7DrzybCFCzw.iZOZTpzxVsOi
role: admin
smtp_password: 17acdfd671d8ab
- totp_secret: A572MSDZMOK7WIJD52GXXJXPHN5SB6ZS
+ totp_secret: null
test:
email: julien@balet-vs.ch
name: test
diff --git a/data/class_href_cache.json b/data/class_href_cache.json
index 1b220c3..96f56df 100644
--- a/data/class_href_cache.json
+++ b/data/class_href_cache.json
@@ -1,5 +1,5 @@
{
- "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=84a4e84c-3566-42da-a8c9-3c00687182ff",
- "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=f90a41d2-e507-4687-890a-48c454da583c",
- "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd"
+ "EM-AU 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=38bbf90d-51da-406e-a2af-4d5f8f5958bd",
+ "AUTOMAT 1:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=687fa97d-1032-4078-94ae-1899fc1e6014",
+ "AUTOMAT 2:abs": "https://escadaweb.vs.ch/Lehrpersonen/ViewAbsenzenErweitert.aspx?id=8cb48a35-290c-4488-b98c-437d2c9186a6"
}
\ No newline at end of file
diff --git a/eptm_dashboard/eptm_dashboard.py b/eptm_dashboard/eptm_dashboard.py
index 47d00ec..93462b6 100644
--- a/eptm_dashboard/eptm_dashboard.py
+++ b/eptm_dashboard/eptm_dashboard.py
@@ -6,8 +6,8 @@ from .pages.fiche import fiche_page, FicheState
from .pages.classe import classe_page, ClasseState
from .pages.escada import escada_page, EscadaState
from .pages.logs import logs_page, LogsState
-from .pages.users import users_page
-from .pages.params import params_page
+from .pages.users import users_page, UsersState
+from .pages.params import params_page, ParamsState
TITLE = "EPTM Dashboard"
@@ -30,5 +30,5 @@ app.add_page(fiche_page, route="/fiche", on_load=[AuthState.check_auth,
app.add_page(classe_page, route="/classe", on_load=[AuthState.check_auth, ClasseState.load_data], title=TITLE)
app.add_page(escada_page, route="/escada", on_load=[AuthState.check_auth, EscadaState.load_data], title=TITLE)
app.add_page(logs_page, route="/logs", on_load=[AuthState.check_auth, LogsState.load_data], title=TITLE)
-app.add_page(users_page, route="/users", on_load=AuthState.check_auth, title=TITLE)
-app.add_page(params_page, route="/params", on_load=AuthState.check_auth, title=TITLE)
+app.add_page(users_page, route="/users", on_load=[AuthState.check_auth, UsersState.load_data], title=TITLE)
+app.add_page(params_page, route="/params", on_load=[AuthState.check_auth, ParamsState.load_data], title=TITLE)
diff --git a/eptm_dashboard/pages/params.py b/eptm_dashboard/pages/params.py
index 1d2f9b1..a9dab2c 100644
--- a/eptm_dashboard/pages/params.py
+++ b/eptm_dashboard/pages/params.py
@@ -1,12 +1,444 @@
-import reflex as rx
-from ..sidebar import layout
+import json
+import os
+from pathlib import Path
+import reflex as rx
+
+from ..sidebar import layout
+from ..state import AuthState
+
+_ROOT = Path(__file__).resolve().parent.parent.parent
+DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
+_SETTINGS_FILE = DATA_DIR / "settings.json"
+
+_DEFAULT_SANCTION = (
+ "Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
+)
+_DEFAULT_TEMPLATE_SUBJ = "Document EPTM — {nom_complet} ({classe})"
+_DEFAULT_TEMPLATE_BODY = (
+ "Bonjour {prenom},\n\n"
+ "Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n"
+ "Cordialement,\nL'équipe EPTM"
+)
+
+
+def _read_settings() -> dict:
+ if _SETTINGS_FILE.exists():
+ try:
+ return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+ return {}
+
+
+def _write_settings(data: dict) -> None:
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ _SETTINGS_FILE.write_text(
+ json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+
+
+class ParamsState(AuthState):
+ # ── Sanction ──────────────────────────────────────────────────────────────
+ texte_sanction: str = ""
+ chef_section: str = ""
+ save_ok_sanction: bool = False
+
+ # ── SMTP ──────────────────────────────────────────────────────────────────
+ smtp_host: str = ""
+ smtp_port: str = "587"
+ smtp_login: str = ""
+ smtp_password: str = ""
+ smtp_sender: str = ""
+ save_ok_smtp: bool = False
+
+ # ── Escada ────────────────────────────────────────────────────────────────
+ escada_username: str = ""
+ escada_password: str = ""
+ totp_secret: str = ""
+ save_ok_escada: bool = False
+
+ # ── Template email ────────────────────────────────────────────────────────
+ email_subject: str = ""
+ email_body: str = ""
+ save_ok_template: 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
+ def set_smtp_host(self, v: str): self.smtp_host = v
+ def set_smtp_port(self, v: str): self.smtp_port = v
+ def set_smtp_login(self, v: str): self.smtp_login = v
+ def set_smtp_password(self, v: str): self.smtp_password = v
+ def set_smtp_sender(self, v: str): self.smtp_sender = v
+ def set_escada_username(self, v: str): self.escada_username = v
+ def set_escada_password(self, v: str): self.escada_password = v
+ 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 load_data(self):
+ if not self.authenticated:
+ return rx.redirect("/login")
+ s = _read_settings()
+ self.texte_sanction = s.get("texte_sanction", _DEFAULT_SANCTION)
+ self.chef_section = s.get("chef_section", "Patrick Rausis")
+ self.smtp_host = s.get("smtp_host", "smtp-relay.brevo.com")
+ self.smtp_port = str(s.get("smtp_port", 587))
+ self.smtp_login = s.get("smtp_login", s.get("smtp_email", ""))
+ self.smtp_password = s.get("smtp_password", "")
+ self.smtp_sender = s.get("smtp_sender", "EPTM Automation ")
+ self.escada_username = s.get("escada_username", "")
+ self.escada_password = s.get("escada_password", "")
+ 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.save_ok_sanction = False
+ self.save_ok_smtp = False
+ self.save_ok_escada = False
+ self.save_ok_template = False
+
+ def save_sanctions(self):
+ s = _read_settings()
+ s["texte_sanction"] = self.texte_sanction.strip()
+ s["chef_section"] = self.chef_section.strip()
+ _write_settings(s)
+ self.save_ok_sanction = True
+ self.save_ok_smtp = False
+ self.save_ok_escada = False
+ self.save_ok_template = False
+
+ def save_smtp(self):
+ s = _read_settings()
+ s["smtp_host"] = self.smtp_host.strip()
+ try:
+ s["smtp_port"] = int(self.smtp_port)
+ except Exception:
+ s["smtp_port"] = 587
+ s["smtp_login"] = self.smtp_login.strip()
+ s["smtp_password"] = self.smtp_password.strip()
+ s["smtp_sender"] = self.smtp_sender.strip()
+ s.pop("smtp_email", None)
+ _write_settings(s)
+ self.save_ok_smtp = True
+ self.save_ok_sanction = False
+ self.save_ok_escada = False
+ self.save_ok_template = False
+
+ def save_escada(self):
+ s = _read_settings()
+ s["escada_username"] = self.escada_username.strip()
+ s["escada_password"] = self.escada_password.strip()
+ s["totp_secret"] = self.totp_secret.strip()
+ _write_settings(s)
+ self.save_ok_escada = True
+ self.save_ok_sanction = False
+ self.save_ok_smtp = False
+ self.save_ok_template = False
+
+ def save_template(self):
+ s = _read_settings()
+ s["email_subject"] = self.email_subject
+ s["email_body"] = self.email_body
+ _write_settings(s)
+ self.save_ok_template = True
+ self.save_ok_sanction = False
+ self.save_ok_smtp = False
+ self.save_ok_escada = False
+
+
+# ── UI helpers ────────────────────────────────────────────────────────────────
+
+def _label(text: str) -> rx.Component:
+ return rx.text(text, size="2", weight="medium", color="var(--gray-11)")
+
+
+def _section(title: str, *children) -> rx.Component:
+ return rx.box(
+ rx.vstack(
+ rx.text(title, size="4", weight="bold"),
+ rx.divider(),
+ *children,
+ spacing="3",
+ width="100%",
+ ),
+ padding="1.25rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ )
+
+
+def _save_ok_callout(show: bool) -> rx.Component:
+ return rx.cond(
+ show,
+ rx.callout.root(
+ rx.callout.icon(rx.icon("check", size=16)),
+ rx.callout.text("Paramètres enregistrés."),
+ color_scheme="green",
+ variant="soft",
+ size="1",
+ ),
+ rx.fragment(),
+ )
+
+
+def _input_row(*items) -> rx.Component:
+ return rx.hstack(*items, spacing="4", width="100%", flex_wrap="wrap")
+
+
+def _field(label: str, input_component: rx.Component) -> rx.Component:
+ return rx.vstack(
+ _label(label),
+ input_component,
+ spacing="1",
+ flex="1",
+ min_width="200px",
+ width="100%",
+ )
+
+
+# ── Sections ──────────────────────────────────────────────────────────────────
+
+def _section_sanction() -> rx.Component:
+ return _section(
+ "Avis de sanction",
+ _field(
+ "Texte de description par défaut (champ TexteDescription)",
+ rx.text_area(
+ value=ParamsState.texte_sanction,
+ on_change=ParamsState.set_texte_sanction,
+ rows="4",
+ width="100%",
+ resize="vertical",
+ ),
+ ),
+ _field(
+ "Chef de section (CS)",
+ rx.input(
+ value=ParamsState.chef_section,
+ on_change=ParamsState.set_chef_section,
+ width="100%",
+ ),
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("save", size=16),
+ "Enregistrer sanctions",
+ on_click=ParamsState.save_sanctions,
+ color_scheme="blue",
+ variant="solid",
+ size="2",
+ ),
+ _save_ok_callout(ParamsState.save_ok_sanction),
+ spacing="3",
+ align="center",
+ flex_wrap="wrap",
+ ),
+ )
+
+
+def _section_smtp() -> rx.Component:
+ return _section(
+ "Configuration email",
+ _input_row(
+ _field(
+ "Serveur SMTP",
+ rx.input(
+ value=ParamsState.smtp_host,
+ on_change=ParamsState.set_smtp_host,
+ placeholder="smtp-relay.brevo.com",
+ width="100%",
+ ),
+ ),
+ _field(
+ "Port",
+ rx.input(
+ value=ParamsState.smtp_port,
+ on_change=ParamsState.set_smtp_port,
+ type="number",
+ placeholder="587",
+ width="100%",
+ ),
+ ),
+ ),
+ _input_row(
+ _field(
+ "Login SMTP (authentification)",
+ rx.input(
+ value=ParamsState.smtp_login,
+ on_change=ParamsState.set_smtp_login,
+ placeholder="login@domaine.com",
+ width="100%",
+ ),
+ ),
+ _field(
+ "Mot de passe SMTP",
+ rx.input(
+ value=ParamsState.smtp_password,
+ on_change=ParamsState.set_smtp_password,
+ type="password",
+ placeholder="••••••••",
+ width="100%",
+ ),
+ ),
+ ),
+ _field(
+ "Expéditeur",
+ rx.input(
+ value=ParamsState.smtp_sender,
+ on_change=ParamsState.set_smtp_sender,
+ placeholder="EPTM Automation ",
+ width="100%",
+ ),
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("save", size=16),
+ "Enregistrer configuration SMTP",
+ on_click=ParamsState.save_smtp,
+ color_scheme="blue",
+ variant="solid",
+ size="2",
+ ),
+ _save_ok_callout(ParamsState.save_ok_smtp),
+ spacing="3",
+ align="center",
+ flex_wrap="wrap",
+ ),
+ )
+
+
+def _section_escada() -> rx.Component:
+ return _section(
+ "Connexion Escada (synchronisation automatique)",
+ rx.text(
+ "Si renseignés, identifiant, mot de passe et code 2FA sont saisis automatiquement lors de la synchronisation.",
+ size="2",
+ color="var(--gray-10)",
+ ),
+ _input_row(
+ _field(
+ "Identifiant Escada",
+ rx.input(
+ value=ParamsState.escada_username,
+ on_change=ParamsState.set_escada_username,
+ placeholder="prenom.nom",
+ width="100%",
+ ),
+ ),
+ _field(
+ "Mot de passe Escada",
+ rx.input(
+ value=ParamsState.escada_password,
+ on_change=ParamsState.set_escada_password,
+ type="password",
+ placeholder="••••••••",
+ width="100%",
+ ),
+ ),
+ ),
+ _field(
+ "Clé secrète 2FA (TOTP) — Format Base32",
+ rx.input(
+ value=ParamsState.totp_secret,
+ on_change=ParamsState.set_totp_secret,
+ type="password",
+ placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
+ width="100%",
+ ),
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("save", size=16),
+ "Enregistrer connexion Escada",
+ on_click=ParamsState.save_escada,
+ color_scheme="blue",
+ variant="solid",
+ size="2",
+ ),
+ _save_ok_callout(ParamsState.save_ok_escada),
+ spacing="3",
+ align="center",
+ flex_wrap="wrap",
+ ),
+ )
+
+
+def _section_template() -> rx.Component:
+ return _section(
+ "Template email",
+ rx.box(
+ rx.text(
+ "Variables disponibles : ",
+ rx.code("{prenom}"), ", ",
+ rx.code("{nom}"), ", ",
+ rx.code("{nom_complet}"), ", ",
+ rx.code("{classe}"), ", ",
+ rx.code("{nb_absences}"), ", ",
+ rx.code("{nb_excusees}"), ", ",
+ rx.code("{nb_non_excusees}"), ", ",
+ rx.code("{nb_a_traiter}"), ", ",
+ rx.code("{semestre}"), ", ",
+ rx.code("{date_du_jour}"),
+ size="2",
+ color="var(--gray-10)",
+ ),
+ padding="0.5rem 0.75rem",
+ background_color="var(--gray-2)",
+ border_radius="6px",
+ border="1px solid var(--gray-4)",
+ ),
+ _field(
+ "Objet",
+ rx.input(
+ value=ParamsState.email_subject,
+ on_change=ParamsState.set_email_subject,
+ width="100%",
+ ),
+ ),
+ _field(
+ "Corps du message",
+ rx.text_area(
+ value=ParamsState.email_body,
+ on_change=ParamsState.set_email_body,
+ rows="8",
+ width="100%",
+ resize="vertical",
+ font_family="monospace",
+ font_size="0.85rem",
+ ),
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("save", size=16),
+ "Enregistrer template",
+ on_click=ParamsState.save_template,
+ color_scheme="blue",
+ variant="solid",
+ size="2",
+ ),
+ _save_ok_callout(ParamsState.save_ok_template),
+ spacing="3",
+ align="center",
+ flex_wrap="wrap",
+ ),
+ )
+
+
+# ── Page ──────────────────────────────────────────────────────────────────────
def params_page() -> rx.Component:
return layout(
rx.vstack(
rx.heading("Paramètres", size="7"),
- rx.text("Page en cours de migration..."),
- spacing="4",
+ _section_sanction(),
+ _section_smtp(),
+ _section_escada(),
+ _section_template(),
+ spacing="5",
+ width="100%",
+ max_width="860px",
)
)
diff --git a/eptm_dashboard/pages/users.py b/eptm_dashboard/pages/users.py
index b75818c..1b1cb61 100644
--- a/eptm_dashboard/pages/users.py
+++ b/eptm_dashboard/pages/users.py
@@ -1,12 +1,888 @@
-import reflex as rx
-from ..sidebar import layout
+import os
+import time
+from pathlib import Path
+import bcrypt
+import yaml
+import reflex as rx
+
+from ..sidebar import layout
+from ..state import AuthState
+
+_ROOT = Path(__file__).resolve().parent.parent.parent
+DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
+AUTH_FILE = DATA_DIR / "auth.yaml"
+_AVATARS_DIR = _ROOT / "assets" / "avatars"
+
+
+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 UsersState(AuthState):
+ users_list: list[dict] = []
+
+ # Edit panel
+ edit_target: str = ""
+ edit_name: str = ""
+ edit_email: str = ""
+ edit_role: str = "user"
+ info_error: str = ""
+ info_ok: bool = False
+
+ # Change password
+ pwd_new: str = ""
+ pwd_confirm: str = ""
+ pwd_error: str = ""
+ pwd_ok: bool = False
+
+ # 2FA
+ edit_has_totp: bool = False
+ totp_ok: bool = False
+
+ # Avatar
+ edit_avatar_url: str = ""
+ upload_ok: bool = False
+
+ # Add user (admin)
+ new_uname: str = ""
+ new_name: str = ""
+ new_email: str = ""
+ new_role: str = "user"
+ new_pwd1: str = ""
+ new_pwd2: str = ""
+ new_error: str = ""
+ new_ok: bool = False
+
+ # ── Setters ───────────────────────────────────────────────────────────────
+ def set_edit_name(self, v: str): self.edit_name = v
+ def set_edit_email(self, v: str): self.edit_email = v
+ def set_edit_role(self, v: str): self.edit_role = v
+ def set_pwd_new(self, v: str): self.pwd_new = v
+ def set_pwd_confirm(self, v: str): self.pwd_confirm = v
+ def set_new_uname(self, v: str): self.new_uname = v
+ def set_new_name(self, v: str): self.new_name = v
+ def set_new_email(self, v: str): self.new_email = v
+ def set_new_role(self, v: str): self.new_role = v
+ def set_new_pwd1(self, v: str): self.new_pwd1 = v
+ def set_new_pwd2(self, v: str): self.new_pwd2 = v
+
+ # ── Helpers ───────────────────────────────────────────────────────────────
+ def _refresh_list(self):
+ cfg = _load_auth()
+ users = cfg.get("credentials", {}).get("usernames", {})
+ self.users_list = [
+ {
+ "username": uname,
+ "name": udata.get("name", uname),
+ "email": udata.get("email", ""),
+ "role": udata.get("role", "user"),
+ "has_totp": bool(udata.get("totp_secret")),
+ }
+ for uname, udata in users.items()
+ ]
+
+ def _populate_edit(self, uname: str):
+ cfg = _load_auth()
+ users = cfg.get("credentials", {}).get("usernames", {})
+ udata = users.get(uname, {})
+ self.edit_target = uname
+ self.edit_name = udata.get("name", "")
+ self.edit_email = udata.get("email", "")
+ self.edit_role = udata.get("role", "user")
+ self.edit_has_totp = bool(udata.get("totp_secret"))
+ self.edit_avatar_url = udata.get("avatar_url", "")
+ self.info_error = ""
+ self.info_ok = False
+ self.pwd_new = ""
+ self.pwd_confirm = ""
+ self.pwd_error = ""
+ self.pwd_ok = False
+ self.totp_ok = False
+ self.upload_ok = False
+
+ # ── Handlers ──────────────────────────────────────────────────────────────
+ def load_data(self):
+ if not self.authenticated:
+ return rx.redirect("/login")
+ self._refresh_list()
+ if self.role != "admin":
+ self._populate_edit(self.username)
+
+ def select_user(self, uname: str):
+ if self.edit_target == uname:
+ self.edit_target = ""
+ else:
+ self._populate_edit(uname)
+
+ def close_edit(self):
+ self.edit_target = ""
+
+ def save_info(self):
+ self.info_error = ""
+ self.info_ok = False
+ if not self.edit_name.strip():
+ self.info_error = "Le nom affiché ne peut pas être vide."
+ return
+ cfg = _load_auth()
+ users = cfg["credentials"]["usernames"]
+ uname = self.edit_target
+ if uname not in users:
+ self.info_error = "Utilisateur introuvable."
+ return
+ users[uname]["name"] = self.edit_name.strip()
+ users[uname]["email"] = self.edit_email.strip()
+ if self.role == "admin" and uname != self.username:
+ users[uname]["role"] = self.edit_role
+ _save_auth(cfg)
+ self.info_ok = True
+ self._refresh_list()
+
+ def save_password(self):
+ self.pwd_error = ""
+ self.pwd_ok = False
+ if len(self.pwd_new) < 6:
+ self.pwd_error = "Minimum 6 caractères."
+ return
+ if self.pwd_new != self.pwd_confirm:
+ self.pwd_error = "Les mots de passe ne correspondent pas."
+ return
+ cfg = _load_auth()
+ users = cfg["credentials"]["usernames"]
+ users[self.edit_target]["password"] = bcrypt.hashpw(
+ self.pwd_new.encode(), bcrypt.gensalt(12)
+ ).decode()
+ _save_auth(cfg)
+ self.pwd_new = ""
+ self.pwd_confirm = ""
+ self.pwd_ok = True
+
+ def reset_totp(self):
+ cfg = _load_auth()
+ users = cfg["credentials"]["usernames"]
+ users[self.edit_target]["totp_secret"] = None
+ _save_auth(cfg)
+ self._populate_edit(self.edit_target)
+ self._refresh_list()
+ self.totp_ok = True
+
+ 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.edit_target
+ 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)
+ if uname == self.username:
+ self.photo_url = url
+ self.upload_ok = True
+ self.edit_avatar_url = url
+ self._refresh_list()
+
+ def remove_avatar(self):
+ uname = self.edit_target
+ 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)
+ if uname == self.username:
+ self.photo_url = ""
+ self.edit_avatar_url = ""
+ self.upload_ok = False
+ self._refresh_list()
+
+ def delete_user(self, uname: str):
+ if uname == self.username:
+ return
+ cfg = _load_auth()
+ users = cfg["credentials"]["usernames"]
+ if uname in users:
+ del users[uname]
+ _save_auth(cfg)
+ if self.edit_target == uname:
+ self.edit_target = ""
+ self._refresh_list()
+
+ def add_user(self):
+ self.new_error = ""
+ self.new_ok = False
+ uname = self.new_uname.strip().lower()
+ name = self.new_name.strip()
+ errs = []
+ if not uname or not name:
+ errs.append("L'identifiant et le nom sont obligatoires.")
+ elif " " in uname:
+ errs.append("L'identifiant ne doit pas contenir d'espaces.")
+ if len(self.new_pwd1) < 6:
+ errs.append("Mot de passe trop court (6 caractères minimum).")
+ if self.new_pwd1 != self.new_pwd2:
+ errs.append("Les mots de passe ne correspondent pas.")
+ if errs:
+ self.new_error = " — ".join(errs)
+ return
+ cfg = _load_auth()
+ users = cfg["credentials"]["usernames"]
+ if uname in users:
+ self.new_error = f"L'identifiant « {uname} » est déjà utilisé."
+ return
+ users[uname] = {
+ "email": self.new_email.strip(),
+ "name": name,
+ "role": self.new_role,
+ "password": bcrypt.hashpw(self.new_pwd1.encode(), bcrypt.gensalt(12)).decode(),
+ "totp_secret": None,
+ }
+ _save_auth(cfg)
+ self.new_uname = ""
+ self.new_name = ""
+ self.new_email = ""
+ self.new_role = "user"
+ self.new_pwd1 = ""
+ self.new_pwd2 = ""
+ self.new_ok = True
+ self._refresh_list()
+
+
+# ── UI helpers ────────────────────────────────────────────────────────────────
+
+def _label(text: str) -> rx.Component:
+ return rx.text(text, size="2", weight="medium", color="var(--gray-11)")
+
+
+def _ok_callout(show: bool, text: str) -> rx.Component:
+ return rx.cond(
+ show,
+ rx.callout.root(
+ rx.callout.icon(rx.icon("check", size=16)),
+ rx.callout.text(text),
+ color_scheme="green",
+ variant="soft",
+ size="1",
+ ),
+ rx.fragment(),
+ )
+
+
+def _err_callout(msg: str) -> rx.Component:
+ return rx.cond(
+ msg != "",
+ rx.callout.root(
+ rx.callout.icon(rx.icon("alert-circle", size=16)),
+ rx.callout.text(msg),
+ color_scheme="red",
+ variant="soft",
+ size="1",
+ ),
+ rx.fragment(),
+ )
+
+
+def _role_badge(role: str) -> rx.Component:
+ return rx.cond(
+ role == "admin",
+ rx.badge("Admin", color_scheme="violet", variant="soft", size="1"),
+ rx.badge("User", color_scheme="gray", variant="soft", size="1"),
+ )
+
+
+def _totp_badge(has_totp: bool) -> rx.Component:
+ return rx.cond(
+ has_totp,
+ rx.badge("2FA actif", color_scheme="green", variant="soft", size="1"),
+ rx.badge("2FA —", color_scheme="orange", variant="soft", size="1"),
+ )
+
+
+# ── User table row ────────────────────────────────────────────────────────────
+
+def _delete_dialog(user: dict) -> rx.Component:
+ return rx.alert_dialog.root(
+ rx.alert_dialog.trigger(
+ rx.button(
+ rx.icon("trash-2", size=14),
+ "Supprimer",
+ color_scheme="red",
+ variant="outline",
+ size="1",
+ ),
+ ),
+ rx.alert_dialog.content(
+ rx.alert_dialog.title("Supprimer le compte"),
+ rx.alert_dialog.description(
+ rx.text("Supprimer définitivement le compte de ", rx.text.strong(user["name"]), " ?"),
+ ),
+ rx.hstack(
+ rx.alert_dialog.cancel(
+ rx.button("Annuler", variant="soft", color_scheme="gray"),
+ ),
+ rx.alert_dialog.action(
+ rx.button(
+ "Supprimer",
+ color_scheme="red",
+ on_click=UsersState.delete_user(user["username"]),
+ ),
+ ),
+ spacing="3",
+ justify="end",
+ margin_top="1rem",
+ ),
+ max_width="400px",
+ ),
+ )
+
+
+def _user_row(user: dict) -> rx.Component:
+ is_selected = user["username"] == UsersState.edit_target
+ is_me = user["username"] == UsersState.username
+ return rx.box(
+ rx.hstack(
+ # Identité : nom + badge rôle + 2FA + identifiant
+ rx.vstack(
+ rx.hstack(
+ rx.text(user["name"], weight="medium", size="2",
+ overflow="hidden", text_overflow="ellipsis", white_space="nowrap"),
+ _role_badge(user["role"]),
+ _totp_badge(user["has_totp"]),
+ rx.cond(is_me, rx.badge("vous", size="1", variant="outline"), rx.fragment()),
+ spacing="2",
+ align="center",
+ overflow="hidden",
+ flex_wrap="wrap",
+ ),
+ rx.text(user["username"], size="1", color="var(--gray-10)",
+ overflow="hidden", text_overflow="ellipsis", white_space="nowrap"),
+ spacing="0",
+ flex="1",
+ min_width="0",
+ overflow="hidden",
+ ),
+ # Droite : actions uniquement
+ rx.hstack(
+ rx.button(
+ rx.cond(is_selected, rx.icon("chevron-up", size=14), rx.icon("pencil", size=14)),
+ rx.cond(is_selected, "Fermer", "Éditer"),
+ on_click=UsersState.select_user(user["username"]),
+ variant=rx.cond(is_selected, "solid", "outline"),
+ color_scheme="blue",
+ size="1",
+ ),
+ rx.cond(
+ is_me,
+ rx.fragment(),
+ _delete_dialog(user),
+ ),
+ spacing="2",
+ align="center",
+ flex_shrink="0",
+ ),
+ align="center",
+ justify="between",
+ width="100%",
+ padding="0.65rem 0.75rem",
+ overflow="hidden",
+ ),
+ background_color=rx.cond(is_selected, "var(--blue-2)", "white"),
+ border_radius="6px",
+ border=rx.cond(is_selected, "1px solid var(--blue-6)", "1px solid #e0e0e0"),
+ width="100%",
+ overflow="hidden",
+ cursor="default",
+ )
+
+
+# ── Edit panel ────────────────────────────────────────────────────────────────
+
+def _edit_panel_avatar() -> rx.Component:
+ has_photo = UsersState.edit_avatar_url != ""
+ return rx.vstack(
+ rx.text("Photo de profil", weight="bold", size="3"),
+ rx.hstack(
+ # Preview
+ rx.cond(
+ has_photo,
+ rx.image(
+ src=UsersState.edit_avatar_url,
+ width="64px",
+ height="64px",
+ border_radius="50%",
+ object_fit="cover",
+ border="2px solid var(--gray-4)",
+ flex_shrink="0",
+ ),
+ rx.image(
+ src="/default_avatar.svg",
+ width="64px",
+ height="64px",
+ border_radius="50%",
+ flex_shrink="0",
+ ),
+ ),
+ 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="avatar_upload",
+ on_drop=UsersState.handle_avatar_upload,
+ accept={"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"],
+ "image/gif": [".gif"], "image/webp": [".webp"]},
+ max_files=1,
+ multiple=False,
+ max_width="100%",
+ ),
+ rx.cond(
+ has_photo,
+ rx.button(
+ rx.icon("trash-2", size=14),
+ "Supprimer",
+ on_click=UsersState.remove_avatar,
+ variant="ghost",
+ color_scheme="red",
+ size="1",
+ ),
+ rx.fragment(),
+ ),
+ _ok_callout(UsersState.upload_ok, "Photo mise à jour."),
+ rx.text(
+ "PNG, JPG, GIF ou WebP",
+ size="1",
+ color="var(--gray-9)",
+ ),
+ spacing="2",
+ align="start",
+ min_width="0",
+ width="100%",
+ ),
+ spacing="4",
+ align="center",
+ width="100%",
+ flex_wrap="wrap",
+ ),
+ spacing="3",
+ width="100%",
+ )
+
+
+def _edit_panel_info(is_admin_editing_other: bool) -> rx.Component:
+ return rx.vstack(
+ rx.text("Informations du compte", weight="bold", size="3"),
+ rx.hstack(
+ rx.vstack(
+ _label("Nom affiché"),
+ rx.input(
+ value=UsersState.edit_name,
+ on_change=UsersState.set_edit_name,
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ rx.vstack(
+ _label("Email"),
+ rx.input(
+ value=UsersState.edit_email,
+ on_change=UsersState.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.cond(
+ is_admin_editing_other,
+ rx.vstack(
+ _label("Rôle"),
+ rx.select.root(
+ rx.select.trigger(width="100%"),
+ rx.select.content(
+ rx.select.item("Utilisateur", value="user"),
+ rx.select.item("Administrateur", value="admin"),
+ ),
+ value=UsersState.edit_role,
+ on_change=UsersState.set_edit_role,
+ width="100%",
+ ),
+ spacing="1",
+ width="100%",
+ ),
+ rx.fragment(),
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("save", size=16),
+ "Mettre à jour",
+ on_click=UsersState.save_info,
+ color_scheme="blue",
+ variant="solid",
+ size="2",
+ ),
+ _ok_callout(UsersState.info_ok, "Informations mises à jour."),
+ _err_callout(UsersState.info_error),
+ spacing="3",
+ align="center",
+ flex_wrap="wrap",
+ ),
+ spacing="3",
+ width="100%",
+ )
+
+
+def _edit_panel_password() -> rx.Component:
+ return rx.vstack(
+ rx.text("Changer le mot de passe", weight="bold", size="3"),
+ rx.hstack(
+ rx.vstack(
+ _label("Nouveau mot de passe"),
+ rx.input(
+ value=UsersState.pwd_new,
+ on_change=UsersState.set_pwd_new,
+ type="password",
+ placeholder="••••••••",
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ rx.vstack(
+ _label("Confirmer"),
+ rx.input(
+ value=UsersState.pwd_confirm,
+ on_change=UsersState.set_pwd_confirm,
+ type="password",
+ placeholder="••••••••",
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ spacing="4", width="100%", flex_wrap="wrap",
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("lock", size=16),
+ "Enregistrer le mot de passe",
+ on_click=UsersState.save_password,
+ color_scheme="blue",
+ variant="solid",
+ size="2",
+ ),
+ _ok_callout(UsersState.pwd_ok, "Mot de passe mis à jour."),
+ _err_callout(UsersState.pwd_error),
+ spacing="3",
+ align="center",
+ flex_wrap="wrap",
+ ),
+ spacing="3",
+ width="100%",
+ )
+
+
+def _edit_panel_totp() -> rx.Component:
+ return rx.vstack(
+ rx.text("Authentification à 2 facteurs", weight="bold", size="3"),
+ rx.hstack(
+ rx.text("Statut :", size="2", flex_shrink="0"),
+ rx.cond(
+ UsersState.totp_ok,
+ rx.badge("Réinitialisé", color_scheme="orange", variant="soft"),
+ rx.cond(
+ UsersState.edit_has_totp,
+ rx.badge("Actif", color_scheme="green", variant="soft"),
+ rx.badge("Non configuré", color_scheme="gray", variant="soft"),
+ ),
+ ),
+ spacing="2",
+ align="center",
+ flex_wrap="wrap",
+ width="100%",
+ ),
+ rx.cond(
+ ~UsersState.edit_has_totp & ~UsersState.totp_ok,
+ rx.text(
+ "Un QR code sera demandé à la prochaine connexion.",
+ size="1", color="var(--gray-10)",
+ ),
+ rx.fragment(),
+ ),
+ rx.cond(
+ UsersState.edit_has_totp & ~UsersState.totp_ok,
+ rx.button(
+ rx.icon("rotate-ccw", size=16),
+ "Réinitialiser l'authentificateur 2FA",
+ on_click=UsersState.reset_totp,
+ color_scheme="orange",
+ variant="outline",
+ size="2",
+ ),
+ rx.fragment(),
+ ),
+ spacing="3",
+ width="100%",
+ )
+
+
+def _edit_panel(is_admin_editing_other: bool) -> rx.Component:
+ return rx.cond(
+ UsersState.edit_target != "",
+ rx.box(
+ rx.vstack(
+ rx.hstack(
+ rx.text(
+ rx.cond(
+ UsersState.edit_target == UsersState.username,
+ "Mon profil",
+ rx.el.span("Modifier — ", style={"font_weight": "normal"}),
+ ),
+ weight="bold",
+ size="3",
+ ),
+ rx.cond(
+ UsersState.edit_target != UsersState.username,
+ rx.text(UsersState.edit_target, size="3", color="var(--gray-10)"),
+ rx.fragment(),
+ ),
+ rx.spacer(),
+ rx.button(
+ rx.icon("x", size=14),
+ on_click=UsersState.close_edit,
+ variant="ghost",
+ color_scheme="gray",
+ size="1",
+ ),
+ width="100%",
+ align="center",
+ ),
+ rx.divider(),
+ _edit_panel_avatar(),
+ rx.divider(),
+ _edit_panel_info(is_admin_editing_other),
+ rx.divider(),
+ _edit_panel_password(),
+ rx.divider(),
+ _edit_panel_totp(),
+ spacing="4",
+ width="100%",
+ ),
+ padding="1.25rem",
+ background_color="var(--blue-2)",
+ border_radius="8px",
+ border="1px solid var(--blue-6)",
+ width="100%",
+ ),
+ rx.fragment(),
+ )
+
+
+# ── Add user form ─────────────────────────────────────────────────────────────
+
+def _add_user_section() -> rx.Component:
+ return rx.box(
+ rx.vstack(
+ rx.text("Ajouter un utilisateur", size="4", weight="bold"),
+ rx.divider(),
+ rx.hstack(
+ rx.vstack(
+ _label("Identifiant de connexion"),
+ rx.input(
+ value=UsersState.new_uname,
+ on_change=UsersState.set_new_uname,
+ placeholder="jean.dupont",
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ rx.vstack(
+ _label("Prénom / Nom affiché"),
+ rx.input(
+ value=UsersState.new_name,
+ on_change=UsersState.set_new_name,
+ placeholder="Jean Dupont",
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ spacing="4", width="100%", flex_wrap="wrap",
+ ),
+ rx.hstack(
+ rx.vstack(
+ _label("Email (optionnel)"),
+ rx.input(
+ value=UsersState.new_email,
+ on_change=UsersState.set_new_email,
+ placeholder="jean.dupont@edu.vs.ch",
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ rx.vstack(
+ _label("Rôle"),
+ rx.select.root(
+ rx.select.trigger(width="100%"),
+ rx.select.content(
+ rx.select.item("Utilisateur", value="user"),
+ rx.select.item("Administrateur", value="admin"),
+ ),
+ value=UsersState.new_role,
+ on_change=UsersState.set_new_role,
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ spacing="4", width="100%", flex_wrap="wrap",
+ ),
+ rx.hstack(
+ rx.vstack(
+ _label("Mot de passe"),
+ rx.input(
+ value=UsersState.new_pwd1,
+ on_change=UsersState.set_new_pwd1,
+ type="password",
+ placeholder="••••••••",
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ rx.vstack(
+ _label("Confirmer le mot de passe"),
+ rx.input(
+ value=UsersState.new_pwd2,
+ on_change=UsersState.set_new_pwd2,
+ type="password",
+ placeholder="••••••••",
+ width="100%",
+ ),
+ spacing="1", flex="1", min_width="0", width="100%",
+ ),
+ spacing="4", width="100%", flex_wrap="wrap",
+ ),
+ rx.hstack(
+ rx.button(
+ rx.icon("user-plus", size=16),
+ "Créer le compte",
+ on_click=UsersState.add_user,
+ color_scheme="blue",
+ variant="solid",
+ size="2",
+ ),
+ _ok_callout(UsersState.new_ok, "Compte créé avec succès."),
+ _err_callout(UsersState.new_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%",
+ )
+
+
+# ── Admin view ────────────────────────────────────────────────────────────────
+
+def _admin_view() -> rx.Component:
+ is_admin_editing_other = (
+ (UsersState.edit_target != "") &
+ (UsersState.edit_target != UsersState.username)
+ )
+ return rx.vstack(
+ # User list
+ rx.box(
+ rx.vstack(
+ rx.text("Comptes existants", size="4", weight="bold"),
+ rx.divider(),
+ rx.foreach(UsersState.users_list, _user_row),
+ spacing="2",
+ width="100%",
+ ),
+ padding="1.25rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ ),
+ # Edit panel (shown when a user is selected)
+ _edit_panel(is_admin_editing_other),
+ # Add user
+ _add_user_section(),
+ spacing="4",
+ width="100%",
+ )
+
+
+# ── User view (non-admin) ─────────────────────────────────────────────────────
+
+def _user_view() -> rx.Component:
+ return rx.box(
+ rx.vstack(
+ rx.text("Mon profil", size="4", weight="bold"),
+ rx.divider(),
+ _edit_panel_avatar(),
+ rx.divider(),
+ _edit_panel_info(False),
+ rx.divider(),
+ _edit_panel_password(),
+ rx.divider(),
+ _edit_panel_totp(),
+ spacing="4",
+ width="100%",
+ ),
+ padding="1.25rem",
+ background_color="white",
+ border_radius="8px",
+ border="1px solid #e0e0e0",
+ width="100%",
+ )
+
+
+# ── Page ──────────────────────────────────────────────────────────────────────
def users_page() -> rx.Component:
return layout(
rx.vstack(
- rx.heading("Utilisateurs", size="7"),
- rx.text("Page en cours de migration..."),
- spacing="4",
+ rx.cond(
+ UsersState.role == "admin",
+ rx.heading("Gestion des utilisateurs", size="7"),
+ rx.heading("Mon profil", size="7"),
+ ),
+ rx.cond(
+ UsersState.role == "admin",
+ _admin_view(),
+ _user_view(),
+ ),
+ spacing="5",
+ width="100%",
+ max_width="860px",
)
)
diff --git a/eptm_dashboard/sidebar.py b/eptm_dashboard/sidebar.py
index 57afd2b..d83cefa 100644
--- a/eptm_dashboard/sidebar.py
+++ b/eptm_dashboard/sidebar.py
@@ -165,12 +165,34 @@ def _admin_section(mobile: bool = False) -> rx.Component:
)
+def _avatar_or_photo(size: str = "2") -> rx.Component:
+ img_size = "32px" if size == "2" else "28px"
+ return rx.cond(
+ AuthState.photo_url != "",
+ rx.image(
+ src=AuthState.photo_url,
+ width=img_size,
+ height=img_size,
+ border_radius="50%",
+ object_fit="cover",
+ border="1.5px solid var(--gray-5)",
+ flex_shrink="0",
+ ),
+ rx.image(
+ src="/default_avatar.svg",
+ width=img_size,
+ height=img_size,
+ border_radius="50%",
+ flex_shrink="0",
+ ),
+ )
+
+
def _user_widget(collapsed: bool = False) -> rx.Component:
if collapsed:
return rx.tooltip(
rx.vstack(
- rx.avatar(fallback=AuthState.name_initials, size="2",
- color_scheme="ruby", radius="full"),
+ _avatar_or_photo(size="2"),
rx.icon_button(
rx.icon("log-out", size=14),
on_click=AuthState.logout,
@@ -182,11 +204,10 @@ def _user_widget(collapsed: bool = False) -> rx.Component:
side="right",
)
return rx.hstack(
- rx.avatar(fallback=AuthState.name_initials, size="2",
- color_scheme="ruby", radius="full"),
+ _avatar_or_photo(size="2"),
rx.vstack(
rx.text(AuthState.name, size="2", font_weight="600",
- color="#f3f4f6", white_space="nowrap", overflow="hidden"),
+ color=_TEXT, white_space="nowrap", overflow="hidden"),
rx.text(AuthState.role, size="1", color=_TEXT_MUTED),
spacing="0", align="start", overflow="hidden", flex="1",
),
@@ -363,6 +384,12 @@ def layout(content: rx.Component) -> rx.Component:
f"calc(100% - {RAIL_W})",
f"calc(100% - {FULL_W})",
),
+ max_width=rx.cond(
+ AuthState.sidebar_collapsed,
+ f"calc(100% - {RAIL_W})",
+ f"calc(100% - {FULL_W})",
+ ),
+ overflow_x="hidden",
transition="margin-left 0.22s ease, width 0.22s ease",
box_sizing="border-box",
),
diff --git a/eptm_dashboard/state.py b/eptm_dashboard/state.py
index 8d61b28..74c261f 100644
--- a/eptm_dashboard/state.py
+++ b/eptm_dashboard/state.py
@@ -10,9 +10,10 @@ DATA_DIR = Path(os.getenv("DATA_DIR", "data"))
class AuthState(rx.State):
# Persisted in browser localStorage (survives hot reload / container restart).
# Note: client-side trustable only because re-validated against auth.yaml in check_auth.
- username: str = rx.LocalStorage("", sync=True)
- name: str = rx.LocalStorage("", sync=True)
- role: str = rx.LocalStorage("user", sync=True)
+ username: str = rx.LocalStorage("", sync=True)
+ name: str = rx.LocalStorage("", sync=True)
+ role: str = rx.LocalStorage("user", sync=True)
+ photo_url: str = rx.LocalStorage("", sync=True)
# In-memory only (login form, transient UI state)
login_user: str = ""
@@ -70,6 +71,7 @@ class AuthState(rx.State):
if self.username not in users:
self._clear_session()
return rx.redirect("/login")
+ self.photo_url = users[self.username].get("avatar_url", "")
def handle_login(self, form_data: dict | None = None):
self.login_error = ""
@@ -84,6 +86,7 @@ class AuthState(rx.State):
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"
@@ -97,6 +100,7 @@ class AuthState(rx.State):
self.username = ""
self.name = ""
self.role = "user"
+ self.photo_url = ""
self.login_user = ""
self.login_pass = ""
self.login_error = ""