This commit is contained in:
Julien Balet 2026-05-10 10:07:56 +02:00
parent 096dfd727b
commit 129ca39e2d
9 changed files with 1375 additions and 25 deletions

BIN
assets/avatars/julbal.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

10
assets/default_avatar.svg Normal file
View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<clipPath id="c">
<circle cx="50" cy="50" r="50"/>
</clipPath>
</defs>
<circle cx="50" cy="50" r="50" fill="#a0a0a0"/>
<circle cx="50" cy="36" r="19" fill="#ececec" clip-path="url(#c)"/>
<ellipse cx="50" cy="93" rx="34" ry="27" fill="#ececec" clip-path="url(#c)"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -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

View file

@ -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"
}

View file

@ -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)

View file

@ -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 <noreply@eptm-automation.ch>")
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 <noreply@eptm-automation.ch>",
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",
)
)

View file

@ -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",
)
)

View file

@ -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",
),

View file

@ -13,6 +13,7 @@ class AuthState(rx.State):
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 = ""