Remplace les inputs type=time par des champs texte HH:MM pour supprimer
l'icône d'horloge et le sélecteur natif qui rognaient le texte.
💘 Generated with Crush
Assisted-by: Crush:glm-5.2
989 lines
41 KiB
Python
989 lines
41 KiB
Python
import asyncio
|
|
import hashlib
|
|
import hmac
|
|
import html
|
|
import json
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import time
|
|
from collections import defaultdict
|
|
from datetime import datetime, timedelta
|
|
from itertools import groupby
|
|
from urllib.parse import urlsplit
|
|
|
|
import bcrypt
|
|
from fastapi import FastAPI, Form, Request, Response
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from mail import send_mail
|
|
from models import (
|
|
load_config, load_week_pointages, save_week_pointages, load_conges,
|
|
toggle_conge, list_all_weeks,
|
|
load_notif_config, save_notif_config, find_user_by_token, save_presence,
|
|
regenerate_presence_token,
|
|
load_auth, save_auth, find_user_by_reset_token,
|
|
load_plages, save_plages, heures_jour_min,
|
|
append_log, load_logs, load_or_create_secret,
|
|
)
|
|
from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues
|
|
from stats import compute_all_stats
|
|
from export import export_semaine_ods, export_annee_ods
|
|
from notifications import reminder_loop, send_ntfy, is_safe_ntfy_server
|
|
|
|
logging.basicConfig(
|
|
level=getattr(logging, load_config().get("log_level", "DEBUG").upper(), logging.DEBUG),
|
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
)
|
|
logger = logging.getLogger("pointeuse")
|
|
|
|
app = FastAPI(title="Décompte Horaire")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def csrf_origin_check(request: Request, call_next):
|
|
"""Protection CSRF stateless sur les requêtes mutatives.
|
|
|
|
Stratégie défense-en-profondeur combinée au cookie SameSite=Lax :
|
|
si la requête porte un header Origin ou Referer, on vérifie que son host
|
|
correspond à celui attendu (config.base_url si définie, sinon le Host de
|
|
la requête). Une requête cross-site aura un Origin différent.
|
|
L'absence d'Origin est tolérée (cas des clients non-navigateurs comme
|
|
Tasker ou curl, où SameSite=Lax suffit).
|
|
"""
|
|
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
|
|
origin = request.headers.get("origin") or request.headers.get("referer")
|
|
if origin:
|
|
try:
|
|
parsed = urlsplit(origin)
|
|
except ValueError:
|
|
return JSONResponse({"error": "csrf"}, status_code=403)
|
|
origin_host = (parsed.hostname or "").lower()
|
|
configured_base = (load_config().get("base_url") or "").strip()
|
|
if configured_base:
|
|
try:
|
|
expected = (urlsplit(configured_base).hostname or "").lower()
|
|
except ValueError:
|
|
expected = ""
|
|
else:
|
|
expected = (request.headers.get("host") or "").lower().split(":", 1)[0]
|
|
if origin_host and expected and origin_host != expected:
|
|
logger.warning(
|
|
"CSRF: origin rejettée origin=%r host=%r path=%r",
|
|
origin, request.headers.get("host"), request.url.path,
|
|
)
|
|
return JSONResponse({"error": "csrf"}, status_code=403)
|
|
response = await call_next(request)
|
|
# ── En-têtes de sécurité appliqués à toutes les réponses ──
|
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
response.headers["X-Frame-Options"] = "DENY"
|
|
response.headers["Referrer-Policy"] = "same-origin"
|
|
# CSP relaxée : on autorise Google Fonts (chargé depuis base.html) et les
|
|
# scripts/styles inline (semaine.html et stats.html ont du JS/CSS inline).
|
|
# 'unsafe-inline' reste nécessaire tant que ces ressources ne sont pas
|
|
# extraites dans des fichiers statiques.
|
|
response.headers["Content-Security-Policy"] = (
|
|
"default-src 'self'; "
|
|
"script-src 'self' 'unsafe-inline'; "
|
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
|
"font-src 'self' https://fonts.gstatic.com; "
|
|
"img-src 'self' data:; "
|
|
"connect-src 'self'; "
|
|
"frame-ancestors 'none'; "
|
|
"base-uri 'self'"
|
|
)
|
|
# HSTS uniquement en HTTPS (sinon on risquerait de bloquer un user en HTTP)
|
|
if request.url.scheme == "https":
|
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
|
return response
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def _log_config_at_startup():
|
|
cfg = load_config()
|
|
smtp = cfg.get("smtp", {})
|
|
logger.info(
|
|
"config chargée: heures_jour=%r allowed_email_domain=%r mail_from=%r "
|
|
"smtp.host=%r smtp.port=%r smtp.user=%r smtp.password=%s smtp.use_tls=%r",
|
|
cfg.get("heures_jour"), cfg.get("allowed_email_domain"), cfg.get("mail_from"),
|
|
smtp.get("host"), smtp.get("port"), smtp.get("user"),
|
|
"***" if smtp.get("password") else "(vide)", smtp.get("use_tls"),
|
|
)
|
|
|
|
|
|
@app.on_event("startup")
|
|
async def _start_reminder_loop():
|
|
asyncio.create_task(reminder_loop())
|
|
templates = Jinja2Templates(directory="templates")
|
|
templates.env.filters["zip"] = zip
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
|
|
JOURS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi"]
|
|
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_.\-]{1,64}$")
|
|
_EMAIL_RE = re.compile(r"^([a-zA-Z0-9_.\-]{1,64})@([a-zA-Z0-9.\-]{1,255})$")
|
|
_HHMM_RE = re.compile(r"^([01]\d|2[0-3]):[0-5]\d$")
|
|
|
|
# Échappement HTML pour les fragments construits en f-string hors templates Jinja
|
|
# (autoescape ne s'applique qu'aux templates). Defense-in-depth contre le XSS stocké.
|
|
_e = html.escape
|
|
|
|
|
|
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
|
|
|
def _sign_user_id(user_id: str) -> str:
|
|
"""Return the signed session cookie value: `<user_id>.<hmac_hex>`.
|
|
|
|
The signature authenticates the user_id without storing server-side session
|
|
state. The HMAC key lives in /data/secret.key (see load_or_create_secret).
|
|
"""
|
|
secret = load_or_create_secret()
|
|
sig = hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
|
|
return f"{user_id}.{sig}"
|
|
|
|
|
|
def _verify_signed_cookie(raw: str) -> str | None:
|
|
"""Return the user_id if the cookie carries a valid signature, else None."""
|
|
if not raw or "." not in raw:
|
|
return None
|
|
user_id, _, sig = raw.rpartition(".")
|
|
if not user_id or not sig or not _USERNAME_RE.match(user_id):
|
|
return None
|
|
secret = load_or_create_secret()
|
|
expected = hmac.new(secret, user_id.encode(), hashlib.sha256).hexdigest()
|
|
# Constant-time comparison to avoid leaking signature bytes via timing.
|
|
if hmac.compare_digest(sig, expected):
|
|
return user_id
|
|
return None
|
|
|
|
|
|
def _get_user(request: Request) -> str | None:
|
|
"""Extract current user from signed cookie."""
|
|
return _verify_signed_cookie(request.cookies.get("user_id", ""))
|
|
|
|
|
|
def _set_auth_cookie(resp: Response, user_id: str, request: Request | None = None) -> None:
|
|
"""Set the signed, HttpOnly, SameSite session cookie.
|
|
|
|
Le drapeau `secure` est positionné automatiquement si la requête courante
|
|
est en HTTPS, ou si config.force_secure_cookies est vrai. Cela évite que
|
|
le cookie ne transite en clair sur un downgrade HTTP.
|
|
"""
|
|
secure = False
|
|
if request is not None and request.url.scheme == "https":
|
|
secure = True
|
|
if load_config().get("force_secure_cookies"):
|
|
secure = True
|
|
resp.set_cookie(
|
|
"user_id", _sign_user_id(user_id),
|
|
httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30,
|
|
secure=secure,
|
|
)
|
|
|
|
|
|
def allowed_email_domain() -> str:
|
|
return load_config().get("allowed_email_domain", "")
|
|
|
|
|
|
def mail_from_address() -> str:
|
|
return load_config().get("mail_from") or "no-reply@pointeuse.local"
|
|
|
|
|
|
def smtp_config() -> dict:
|
|
return load_config().get("smtp", {})
|
|
|
|
|
|
# ── Rate-limiting des routes d'authentification ───────────────────────────────
|
|
# Compteur simple en mémoire, fenêtre glissante de LOGIN_WINDOW_S secondes.
|
|
# Suffit pour bloquer le brute-force / mail-bombing sur une app mono-utilisateur.
|
|
# Reset entre tests via main._LOGIN_ATTEMPTS.clear().
|
|
|
|
_LOGIN_ATTEMPTS: dict[str, list[float]] = defaultdict(list)
|
|
LOGIN_WINDOW_S = 60
|
|
DEFAULT_LOGIN_MAX_PER_MIN = 10
|
|
|
|
|
|
def _check_login_rate_limit(key: str, max_per_min: int | None = None) -> bool:
|
|
"""Retourne True si la tentative est autorisée, False sinon.
|
|
|
|
max_per_min <= 0 désactive la limite (utile pour tests ou config).
|
|
"""
|
|
if max_per_min is None:
|
|
max_per_min = int(load_config().get("login_rate_limit_per_min", DEFAULT_LOGIN_MAX_PER_MIN))
|
|
if max_per_min <= 0:
|
|
return True
|
|
now = time.monotonic()
|
|
cutoff = now - LOGIN_WINDOW_S
|
|
recent = [t for t in _LOGIN_ATTEMPTS[key] if t > cutoff]
|
|
_LOGIN_ATTEMPTS[key] = recent
|
|
if len(recent) >= max_per_min:
|
|
return False
|
|
recent.append(now)
|
|
return True
|
|
|
|
|
|
def _client_ip(request: Request) -> str:
|
|
"""Retourne l'IP du client (sans confiance X-Forwarded-For par défaut)."""
|
|
return request.client.host if request.client else "unknown"
|
|
|
|
|
|
def _safe_base_url(request: Request) -> str | None:
|
|
"""Retourne une URL de base fiable pour construire des liens (emails, etc.).
|
|
|
|
Anti open-redirect / phishing : le header Host est contrôlable par le client
|
|
(curl/DevTools), donc `request.base_url` ne doit pas être utilisée telle
|
|
quelle dans un email envoyé à l'utilisateur, sinon un attaquant peut faire
|
|
envoyer un mail légitime contenant `https://phishing.attacker/set-password/<token>`
|
|
avec un token réellement valide.
|
|
|
|
Stratégie :
|
|
- Si `base_url` est défini en config, l'utiliser (schéma http/https requis).
|
|
- Sinon, accepter le Host seulement s'il correspond à `allowed_email_domain`
|
|
(suffixe strict) ou à localhost (utile pour les tests/dev locaux).
|
|
- Sinon, retourner None : l'appelant doit refuser l'envoi.
|
|
"""
|
|
cfg = load_config()
|
|
configured = (cfg.get("base_url") or "").strip().rstrip("/")
|
|
if configured:
|
|
try:
|
|
parts = urlsplit(configured)
|
|
if parts.scheme in ("http", "https") and parts.hostname:
|
|
return configured
|
|
except ValueError:
|
|
pass
|
|
logger.warning("base_url configurée invalide: %r", configured)
|
|
return None
|
|
|
|
host = (request.headers.get("host") or "").strip()
|
|
if not host:
|
|
return None
|
|
host_l = host.lower()
|
|
hostname = host_l.split(":", 1)[0]
|
|
scheme = "https" if request.url.scheme == "https" else "http"
|
|
|
|
if hostname in ("localhost", "127.0.0.1", "::1"):
|
|
return f"{scheme}://{host}"
|
|
|
|
allowed = (cfg.get("allowed_email_domain") or "").strip().lower()
|
|
if allowed and (host_l == allowed or host_l.endswith("." + allowed)):
|
|
return f"{scheme}://{host}"
|
|
|
|
return None
|
|
|
|
|
|
def _require_user(request: Request):
|
|
"""Return user_id or redirect to login."""
|
|
uid = _get_user(request)
|
|
if not uid:
|
|
return None
|
|
return uid
|
|
|
|
|
|
# ── Shared helpers ────────────────────────────────────────────────────────────
|
|
|
|
def week_dates(year: int, week: int) -> list[str]:
|
|
first = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date()
|
|
return [(first + timedelta(days=i)).strftime("%Y-%m-%d") for i in range(5)]
|
|
|
|
|
|
def _weeks_by_year(all_weeks: list) -> dict:
|
|
result = {}
|
|
for yr, wks in groupby(sorted(all_weeks, reverse=True), key=lambda x: x[0]):
|
|
result[yr] = sorted(wks, key=lambda x: x[1], reverse=True)
|
|
return result
|
|
|
|
|
|
def _build_soldes_ctx(year, week, conges, h_jour, user_id):
|
|
dates = week_dates(year, week)
|
|
raw = load_week_pointages(year, week, user_id)
|
|
p_by_date = {p["date"]: p for p in raw}
|
|
pointages = [
|
|
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
|
|
"aprem_entree": None, "aprem_sortie": None})
|
|
for d in dates
|
|
]
|
|
result = compute_week(pointages, conges, h_jour, load_plages(user_id))
|
|
result["heures_jour"] = minutes_to_hhmm(h_jour)
|
|
return {"result": result}
|
|
|
|
|
|
def _build_jour_ctx(date, conges, h_jour, user_id):
|
|
from calcul import heures_travaillees
|
|
d = datetime.strptime(date, "%Y-%m-%d").date()
|
|
iso = d.isocalendar()
|
|
raw = load_week_pointages(iso.year, iso.week, user_id)
|
|
p_by_date = {p["date"]: p for p in raw}
|
|
p = p_by_date.get(date, {"date": date, "matin_entree": None, "matin_sortie": None,
|
|
"aprem_entree": None, "aprem_sortie": None})
|
|
trav = heures_travaillees(p)
|
|
du = heures_dues(date, conges, h_jour)
|
|
delta = trav - du
|
|
return {
|
|
**p,
|
|
"travaille_min": trav, "du_min": du, "delta_min": delta,
|
|
"travaille": minutes_to_hhmm(trav),
|
|
"du": minutes_to_hhmm(du),
|
|
"delta": minutes_to_hhmm(delta),
|
|
"conge": {c["type"] for c in conges if c["date"] == date},
|
|
}
|
|
|
|
|
|
def _oob_calc(date, jour):
|
|
trav_inner = f'<span class="calc-num">{"—" if not jour["travaille_min"] else _e(jour["travaille"])}</span>'
|
|
if jour["travaille_min"]:
|
|
cls = "delta-pos" if jour["delta_min"] > 0 else ("delta-neg" if jour["delta_min"] < 0 else "delta-zero")
|
|
sign = "+" if jour["delta_min"] > 0 else ""
|
|
delta_inner = f'<span class="{cls}">{sign}{_e(jour["delta"])}</span>'
|
|
else:
|
|
delta_inner = '<span class="delta-zero">—</span>'
|
|
return (
|
|
f'<span id="calc-{_e(date)}-trav" hx-swap-oob="true">{trav_inner}</span>'
|
|
f'<span id="calc-{_e(date)}-delta" hx-swap-oob="true">{delta_inner}</span>'
|
|
)
|
|
|
|
|
|
def _oob_week_extras(result):
|
|
html = ""
|
|
for jour in result["jours"]:
|
|
date = jour["date"]
|
|
cm = jour["delta_cumul_min"]
|
|
if jour.get("cumul_visible"):
|
|
if cm == 0:
|
|
cumul_inner = '<span class="delta-zero">0h00</span>'
|
|
else:
|
|
cls = "delta-pos" if cm > 0 else "delta-neg"
|
|
sign = "+" if cm > 0 else "-"
|
|
cumul_inner = f'<span class="{cls}">{sign}{jour["delta_cumul"]}</span>'
|
|
else:
|
|
cumul_inner = '<span class="delta-zero">—</span>'
|
|
html += f'<span id="calc-{_e(date)}-cumul" hx-swap-oob="true">{cumul_inner}</span>'
|
|
parts = []
|
|
if jour.get("cible_matin"):
|
|
parts.append(f'<span class="cible-num">M {_e(jour["cible_matin_entree"])} → {_e(jour["cible_matin"])}</span>')
|
|
if jour.get("cible_aprem"):
|
|
parts.append(f'<span class="cible-num">A {_e(jour["cible_aprem_entree"])} → {_e(jour["cible_aprem"])}</span>')
|
|
cible_inner = " ".join(parts) if parts else '<span class="delta-zero">—</span>'
|
|
html += f'<span id="calc-{_e(date)}-cible" hx-swap-oob="true">{cible_inner}</span>'
|
|
return html
|
|
|
|
|
|
def _oob_time_cells(date, jour, plages):
|
|
cg = jour["conge"]
|
|
matin_cg = "matin" in cg or "jour" in cg
|
|
aprem_cg = "aprem" in cg or "jour" in cg
|
|
matin_warn = not matin_cg and jour["du_min"] > 0 and (
|
|
(jour.get("matin_entree") and jour["matin_entree"] > plages["matin_debut"]) or
|
|
(jour.get("matin_sortie") and jour["matin_sortie"] < plages["matin_fin"])
|
|
)
|
|
aprem_warn = not aprem_cg and jour["du_min"] > 0 and (
|
|
(jour.get("aprem_entree") and jour["aprem_entree"] > plages["aprem_debut"]) or
|
|
(jour.get("aprem_sortie") and jour["aprem_sortie"] < plages["aprem_fin"])
|
|
)
|
|
matin_cls = "td-fill cg" if matin_cg else ("td-fill warn" if matin_warn else "td-fill")
|
|
aprem_cls = "td-fill cg" if aprem_cg else ("td-fill warn" if aprem_warn else "td-fill")
|
|
matin_html = (
|
|
f'<div id="cg-matin-{_e(date)}" hx-swap-oob="true" class="{matin_cls}">'
|
|
f'<div class="time-pair">'
|
|
f'<input class="time-input" type="text" name="matin_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["matin_entree"] or "")}">'
|
|
f'<span class="time-sep">→</span>'
|
|
f'<input class="time-input" type="text" name="matin_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["matin_sortie"] or "")}">'
|
|
f'</div></div>'
|
|
)
|
|
aprem_html = (
|
|
f'<div id="cg-aprem-{_e(date)}" hx-swap-oob="true" class="{aprem_cls}">'
|
|
f'<div class="time-pair">'
|
|
f'<input class="time-input" type="text" name="aprem_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["aprem_entree"] or "")}">'
|
|
f'<span class="time-sep">→</span>'
|
|
f'<input class="time-input" type="text" name="aprem_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" data-date="{_e(date)}" value="{_e(jour["aprem_sortie"] or "")}">'
|
|
f'</div></div>'
|
|
)
|
|
return matin_html + aprem_html
|
|
|
|
|
|
def _htmx_conge_and_soldes(request, date, d, conges, h_jour, user_id):
|
|
iso = d.isocalendar()
|
|
plages = load_plages(user_id)
|
|
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
|
cg = jour["conge"]
|
|
btn_ma_on = " on" if "matin" in cg else ""
|
|
btn_am_on = " on" if "aprem" in cg else ""
|
|
btn_j_on = " jour-on" if "jour" in cg else ""
|
|
btns_html = (
|
|
f'<div id="cg-btn-{_e(date)}" hx-swap-oob="true">'
|
|
f'<div class="conge-btns">'
|
|
f'<button hx-post="/conge/{_e(date)}/matin" hx-swap="none" class="btn-cg{btn_ma_on}" title="Congé matin">MA</button>'
|
|
f'<button hx-post="/conge/{_e(date)}/aprem" hx-swap="none" class="btn-cg{btn_am_on}" title="Congé après-midi">AM</button>'
|
|
f'<button hx-post="/conge/{_e(date)}/jour" hx-swap="none" class="btn-cg btn-jour{btn_j_on}" title="Journée entière">J</button>'
|
|
f'</div></div>'
|
|
)
|
|
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
|
|
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
|
return HTMLResponse(
|
|
_oob_time_cells(date, jour, plages)
|
|
+ btns_html
|
|
+ _oob_calc(date, jour)
|
|
+ _oob_week_extras(soldes_ctx["result"])
|
|
+ soldes_html
|
|
)
|
|
|
|
|
|
def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
|
|
iso = d.isocalendar()
|
|
plages = load_plages(user_id)
|
|
jour = _build_jour_ctx(date, conges, h_jour, user_id)
|
|
soldes_ctx = _build_soldes_ctx(iso.year, iso.week, conges, h_jour, user_id)
|
|
soldes_html = templates.get_template("_soldes.html").render({"request": request, **soldes_ctx})
|
|
return HTMLResponse(
|
|
_oob_calc(date, jour)
|
|
+ _oob_time_cells(date, jour, plages)
|
|
+ _oob_week_extras(soldes_ctx["result"])
|
|
+ soldes_html
|
|
)
|
|
|
|
|
|
# ── Login / logout ────────────────────────────────────────────────────────────
|
|
|
|
RESET_TOKEN_VALIDITY_HOURS = 24
|
|
|
|
|
|
def _login_ctx(request, step, email="", error=None):
|
|
return {"request": request, "step": step, "email": email, "error": error}
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
if _get_user(request):
|
|
return RedirectResponse("/", status_code=302)
|
|
return templates.TemplateResponse("login.html", _login_ctx(request, "email"))
|
|
|
|
|
|
@app.post("/login", response_class=HTMLResponse)
|
|
def login_submit(request: Request, email: str = Form("")):
|
|
# Rate-limit par IP pour empêcher le mail-bombing SMTP et l'énumération.
|
|
ip = _client_ip(request)
|
|
if not _check_login_rate_limit(f"login:{ip}"):
|
|
logger.warning("rate limit /login déclenchée: ip=%r", ip)
|
|
return templates.TemplateResponse("login.html", _login_ctx(
|
|
request, "email", "",
|
|
"Trop de tentatives. Réessaie dans une minute.",
|
|
))
|
|
email = email.strip().lower()
|
|
m = _EMAIL_RE.match(email)
|
|
domain = allowed_email_domain()
|
|
logger.info("POST /login: email=%r domaine_attendu=%r", email, domain)
|
|
if not m or not domain or m.group(2) != domain:
|
|
logger.warning("login refusé (domaine invalide): email=%r", email)
|
|
return templates.TemplateResponse("login.html", _login_ctx(
|
|
request, "email", email,
|
|
f"Seules les adresses @{domain or '?'} sont autorisées.",
|
|
))
|
|
|
|
user_id = m.group(1)
|
|
auth = load_auth(user_id)
|
|
logger.debug("login: user_id=%r compte_existant=%s", user_id, bool(auth.get("password_hash")))
|
|
|
|
if auth.get("password_hash"):
|
|
return templates.TemplateResponse("login.html", _login_ctx(request, "password", email))
|
|
|
|
# No account yet: (re)send a link to set the password
|
|
base_url = _safe_base_url(request)
|
|
if not base_url:
|
|
logger.warning(
|
|
"login refusé (Host non fiable pour générer un lien email): host=%r email=%r",
|
|
request.headers.get("host"), email,
|
|
)
|
|
return templates.TemplateResponse("login.html", _login_ctx(
|
|
request, "email", email,
|
|
"Domaine non autorisé pour générer un lien de connexion.",
|
|
))
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
|
|
save_auth(user_id, email=email, token=token, token_expires=expires)
|
|
logger.info("nouveau token de création de compte pour %r (expire %s)", user_id, expires)
|
|
|
|
link = f"{base_url}/set-password/{token}"
|
|
try:
|
|
send_mail(
|
|
email,
|
|
"Définis ton mot de passe — Décompte Horaire",
|
|
f"Clique sur ce lien pour définir ton mot de passe (valable {RESET_TOKEN_VALIDITY_HOURS}h) :\n\n{link}\n",
|
|
mail_from_address(),
|
|
smtp_config(),
|
|
)
|
|
except Exception:
|
|
logger.exception("échec envoi email d'invitation à %r", email)
|
|
return templates.TemplateResponse("login.html", _login_ctx(
|
|
request, "email", email, "Impossible d'envoyer l'email pour le moment, réessaie plus tard.",
|
|
))
|
|
|
|
return templates.TemplateResponse("login.html", _login_ctx(request, "sent", email))
|
|
|
|
|
|
@app.post("/login/password", response_class=HTMLResponse)
|
|
def login_password(request: Request, email: str = Form(""), password: str = Form("")):
|
|
# Rate-limit par IP+email pour bloquer le brute-force d'un compte précis.
|
|
email = email.strip().lower()
|
|
ip = _client_ip(request)
|
|
if not _check_login_rate_limit(f"pw:{ip}:{email}"):
|
|
logger.warning("rate limit /login/password déclenchée: ip=%r email=%r", ip, email)
|
|
return templates.TemplateResponse("login.html", _login_ctx(
|
|
request, "password", email,
|
|
"Trop de tentatives. Réessaie dans une minute.",
|
|
))
|
|
m = _EMAIL_RE.match(email)
|
|
domain = allowed_email_domain()
|
|
logger.info("POST /login/password: email=%r", email)
|
|
if not m or not domain or m.group(2) != domain:
|
|
logger.warning("login/password refusé (domaine invalide): email=%r", email)
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
user_id = m.group(1)
|
|
ph = load_auth(user_id).get("password_hash")
|
|
if not ph or not bcrypt.checkpw(password.encode(), ph.encode()):
|
|
logger.warning("mot de passe incorrect pour %r", user_id)
|
|
return templates.TemplateResponse("login.html", _login_ctx(
|
|
request, "password", email, "Mot de passe incorrect.",
|
|
))
|
|
|
|
logger.info("connexion réussie: user_id=%r", user_id)
|
|
resp = RedirectResponse("/", status_code=302)
|
|
_set_auth_cookie(resp, user_id, request)
|
|
return resp
|
|
|
|
|
|
def _valid_reset_token(token: str) -> str | None:
|
|
user_id = find_user_by_reset_token(token)
|
|
if not user_id:
|
|
return None
|
|
expires = load_auth(user_id).get("token_expires")
|
|
if not expires or datetime.fromisoformat(expires) < datetime.now():
|
|
return None
|
|
return user_id
|
|
|
|
|
|
@app.get("/set-password/{token}", response_class=HTMLResponse)
|
|
def set_password_page(request: Request, token: str):
|
|
valid = _valid_reset_token(token) is not None
|
|
return templates.TemplateResponse("set_password.html", {
|
|
"request": request, "token": token, "valid": valid, "error": None,
|
|
})
|
|
|
|
|
|
@app.post("/set-password/{token}", response_class=HTMLResponse)
|
|
def set_password_submit(request: Request, token: str, password: str = Form(""), password2: str = Form("")):
|
|
user_id = _valid_reset_token(token)
|
|
if not user_id:
|
|
logger.warning("POST /set-password: token invalide ou expiré")
|
|
return templates.TemplateResponse("set_password.html", {
|
|
"request": request, "token": token, "valid": False, "error": None,
|
|
})
|
|
if len(password) < 8 or password != password2:
|
|
logger.info("POST /set-password: mot de passe rejeté (règles) pour %r", user_id)
|
|
return templates.TemplateResponse("set_password.html", {
|
|
"request": request, "token": token, "valid": True,
|
|
"error": "Les mots de passe ne correspondent pas ou font moins de 8 caractères.",
|
|
})
|
|
|
|
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
save_auth(user_id, password_hash=password_hash, token=None, token_expires=None)
|
|
logger.info("mot de passe défini pour %r", user_id)
|
|
|
|
resp = RedirectResponse("/", status_code=302)
|
|
_set_auth_cookie(resp, user_id, request)
|
|
return resp
|
|
|
|
|
|
@app.get("/logout")
|
|
def logout(request: Request):
|
|
logger.info("déconnexion: user_id=%r", _get_user(request))
|
|
resp = RedirectResponse("/login", status_code=302)
|
|
resp.delete_cookie("user_id")
|
|
return resp
|
|
|
|
|
|
# ── Main routes ───────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def index(request: Request):
|
|
if not _get_user(request):
|
|
return RedirectResponse("/login", status_code=302)
|
|
today = datetime.today()
|
|
iso = today.isocalendar()
|
|
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}")
|
|
|
|
|
|
@app.get("/semaine/{year}/{week}", response_class=HTMLResponse)
|
|
def semaine(request: Request, year: int, week: int):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
h_jour = heures_jour_min()
|
|
plages = load_plages(user_id)
|
|
conges = load_conges(user_id)
|
|
soldes_ctx = _build_soldes_ctx(year, week, conges, h_jour, user_id)
|
|
|
|
dates = week_dates(year, week)
|
|
raw = load_week_pointages(year, week, user_id)
|
|
p_by_date = {p["date"]: p for p in raw}
|
|
pointages = [
|
|
p_by_date.get(d, {"date": d, "matin_entree": None, "matin_sortie": None,
|
|
"aprem_entree": None, "aprem_sortie": None})
|
|
for d in dates
|
|
]
|
|
result = compute_week(pointages, conges, h_jour, plages)
|
|
|
|
prev_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() - timedelta(weeks=1)
|
|
next_d = datetime.strptime(f"{year}-W{week:02d}-1", "%G-W%V-%u").date() + timedelta(weeks=1)
|
|
all_weeks = list_all_weeks(user_id)
|
|
|
|
return templates.TemplateResponse("semaine.html", {
|
|
"request": request,
|
|
"year": year, "week": week,
|
|
"result": result,
|
|
**soldes_ctx,
|
|
"plages": plages,
|
|
"prev_year": prev_d.isocalendar().year, "prev_week": prev_d.isocalendar().week,
|
|
"next_year": next_d.isocalendar().year, "next_week": next_d.isocalendar().week,
|
|
"jours_fr": JOURS_FR,
|
|
"all_weeks": all_weeks,
|
|
"weeks_by_year": _weeks_by_year(all_weeks),
|
|
"current_user": user_id,
|
|
})
|
|
|
|
|
|
@app.post("/pointage/{date}", response_class=HTMLResponse)
|
|
def save_pointage(
|
|
request: Request, date: str,
|
|
matin_entree: str = Form(""), matin_sortie: str = Form(""),
|
|
aprem_entree: str = Form(""), aprem_sortie: str = Form(""),
|
|
):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
def clean(v):
|
|
# Refuse tout ce qui n'est pas un HHMM strict : empêche le XSS stocké car la
|
|
# valeur est réinjectée telle quelle dans des f-strings HTML hors templates.
|
|
v = v.strip()
|
|
return v if v and _HHMM_RE.match(v) else None
|
|
|
|
d = datetime.strptime(date, "%Y-%m-%d").date()
|
|
iso = d.isocalendar()
|
|
raw = load_week_pointages(iso.year, iso.week, user_id)
|
|
p_by_date = {p["date"]: p for p in raw}
|
|
p_by_date[date] = {
|
|
"date": date,
|
|
"matin_entree": clean(matin_entree), "matin_sortie": clean(matin_sortie),
|
|
"aprem_entree": clean(aprem_entree), "aprem_sortie": clean(aprem_sortie),
|
|
}
|
|
save_week_pointages(iso.year, iso.week, sorted(p_by_date.values(), key=lambda x: x["date"]), user_id)
|
|
conges = load_conges(user_id)
|
|
return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
|
|
|
|
|
|
@app.get("/refresh/{date}", response_class=HTMLResponse)
|
|
def refresh_pointage(request: Request, date: str):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
d = datetime.strptime(date, "%Y-%m-%d").date()
|
|
conges = load_conges(user_id)
|
|
return _htmx_calc_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
|
|
|
|
|
|
@app.post("/conge/{date}/{type_conge}", response_class=HTMLResponse)
|
|
def toggle_conge_route(date: str, type_conge: str, request: Request):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
toggle_conge(date, type_conge, user_id)
|
|
d = datetime.strptime(date, "%Y-%m-%d").date()
|
|
if request.headers.get("HX-Request"):
|
|
conges = load_conges(user_id)
|
|
return _htmx_conge_and_soldes(request, date, d, conges, heures_jour_min(), user_id)
|
|
iso = d.isocalendar()
|
|
return RedirectResponse(f"/semaine/{iso.year}/{iso.week}", status_code=303)
|
|
|
|
|
|
@app.get("/stats", response_class=HTMLResponse)
|
|
def stats_page(request: Request):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
stats = compute_all_stats(user_id)
|
|
data_json = json.dumps({
|
|
"time_stats": {
|
|
slot: {
|
|
"mean": s["mean"], "sigma": s["sigma"],
|
|
"mean_hhmm": s["mean_hhmm"], "sigma_min": s["sigma_min"],
|
|
"n": s["n"], "dated_values": s["dated_values"],
|
|
} if s else None
|
|
for slot, s in stats["time_stats"].items()
|
|
},
|
|
"compliance": stats["compliance"],
|
|
"weekly_balances": stats["weekly_balances"],
|
|
"today": stats["today"],
|
|
})
|
|
return templates.TemplateResponse("stats.html", {
|
|
"request": request,
|
|
"stats": stats,
|
|
"data_json": data_json,
|
|
"current_user": user_id,
|
|
})
|
|
|
|
|
|
ODS_MEDIA_TYPE = "application/vnd.oasis.opendocument.spreadsheet"
|
|
|
|
|
|
@app.get("/export/semaine/{year}/{week}")
|
|
def export_semaine(request: Request, year: int, week: int):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
content = export_semaine_ods(year, week, user_id)
|
|
filename = f"pointages-{user_id}-{year}-S{week:02d}.ods"
|
|
logger.info("export ODS semaine: user_id=%r year=%d week=%d", user_id, year, week)
|
|
return Response(
|
|
content=content, media_type=ODS_MEDIA_TYPE,
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@app.get("/export/annee/{year}")
|
|
def export_annee(request: Request, year: int):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
content = export_annee_ods(year, user_id)
|
|
filename = f"pointages-{user_id}-{year}.ods"
|
|
logger.info("export ODS année: user_id=%r year=%d", user_id, year)
|
|
return Response(
|
|
content=content, media_type=ODS_MEDIA_TYPE,
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
|
|
@app.get("/settings", response_class=HTMLResponse)
|
|
def settings_page(request: Request):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
notif = load_notif_config(user_id)
|
|
plages = load_plages(user_id)
|
|
# Les URLs Tasker sont affichées à l'utilisateur lui-même : moins sensible qu'un
|
|
# email, mais on utilise quand même le helper (fallback sur base_url request si
|
|
# non configurée, car l'utilisateur voit sa propre URL).
|
|
base_url = _safe_base_url(request) or str(request.base_url).rstrip("/")
|
|
return templates.TemplateResponse("settings.html", {
|
|
"request": request,
|
|
"current_user": user_id,
|
|
"ntfy_topic": notif.get("ntfy_topic") or "",
|
|
"ntfy_server": notif.get("ntfy_server") or "https://ntfy.sh",
|
|
"ntfy_token": notif.get("ntfy_token") or "",
|
|
"rappel_debut_h": notif.get("rappel_debut_h", 7),
|
|
"rappel_fin_h": notif.get("rappel_fin_h", 20),
|
|
"arrivee_url": f"{base_url}/presence/{notif['token']}/arrivee",
|
|
"depart_url": f"{base_url}/presence/{notif['token']}/depart",
|
|
"plages": plages,
|
|
"saved": request.query_params.get("saved") == "1",
|
|
"plages_saved": request.query_params.get("plages_saved") == "1",
|
|
"plages_error": request.query_params.get("plages_error") == "1",
|
|
})
|
|
|
|
|
|
@app.post("/settings", response_class=HTMLResponse)
|
|
def settings_save(
|
|
request: Request, ntfy_topic: str = Form(""), ntfy_server: str = Form(""),
|
|
rappel_debut_h: int = Form(7), rappel_fin_h: int = Form(20),
|
|
ntfy_token: str = Form(""),
|
|
):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
rappel_debut_h = max(0, min(23, rappel_debut_h))
|
|
rappel_fin_h = max(rappel_debut_h + 1, min(24, rappel_fin_h))
|
|
|
|
ntfy_server = ntfy_server.strip()
|
|
if ntfy_server and not is_safe_ntfy_server(ntfy_server):
|
|
logger.warning("réglages notif rejetés (SSRF): user_id=%r ntfy_server=%r", user_id, ntfy_server)
|
|
return RedirectResponse("/settings?ssrf_error=1", status_code=303)
|
|
|
|
logger.info(
|
|
"réglages notif mis à jour: user_id=%r ntfy_topic=%r ntfy_server=%r plage=%sh-%sh token=%s",
|
|
user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h,
|
|
"(défini)" if ntfy_token.strip() else "(vide)",
|
|
)
|
|
save_notif_config(user_id, ntfy_topic, ntfy_server, rappel_debut_h, rappel_fin_h, ntfy_token)
|
|
append_log(
|
|
user_id, "settings", "Réglages ntfy",
|
|
f"topic={ntfy_topic or '(vide)'} server={ntfy_server} plage={rappel_debut_h}h-{rappel_fin_h}h "
|
|
f"jeton={'défini' if ntfy_token.strip() else '(vide)'}",
|
|
)
|
|
return RedirectResponse("/settings?saved=1", status_code=303)
|
|
|
|
|
|
@app.post("/settings/test-ntfy", response_class=HTMLResponse)
|
|
def settings_test_ntfy(request: Request):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
notif = load_notif_config(user_id)
|
|
topic = notif.get("ntfy_topic")
|
|
server = notif.get("ntfy_server", "https://ntfy.sh")
|
|
|
|
if not topic:
|
|
logger.warning("test ntfy: pas de topic configuré pour %r", user_id)
|
|
return HTMLResponse(
|
|
'<div id="ntfy-test-result" class="settings-error">'
|
|
'Renseigne et enregistre un topic ntfy avant de tester.'
|
|
'</div>'
|
|
)
|
|
|
|
logger.info("test ntfy: envoi de test pour %r sur %r/%r", user_id, server, topic)
|
|
ok = send_ntfy(
|
|
server, topic,
|
|
"Ceci est une notification de test — ta configuration ntfy fonctionne.",
|
|
"Test pointeuse", user_id=user_id, token=notif.get("ntfy_token", ""),
|
|
)
|
|
cls = "settings-saved" if ok else "settings-error"
|
|
msg = ("Notification envoyée. Vérifie ton téléphone, elle doit arriver en quelques secondes."
|
|
if ok else
|
|
"Échec de l'envoi. Vérifie le serveur et le topic, et l'optimisation batterie de l'app ntfy.")
|
|
return HTMLResponse(
|
|
f'<div id="ntfy-test-result" class="{cls}">{msg}</div>'
|
|
)
|
|
|
|
|
|
@app.post("/settings/regenerate-presence-token", response_class=HTMLResponse)
|
|
def settings_regenerate_presence_token(request: Request):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
new_token = regenerate_presence_token(user_id)
|
|
logger.warning("token de présence régénéré pour %r", user_id)
|
|
append_log(user_id, "settings", "Token présence régénéré", "Les URLs Tasker ont été invalidées et remplacées.")
|
|
|
|
base_url = _safe_base_url(request) or str(request.base_url).rstrip("/")
|
|
arrivee_url = f"{base_url}/presence/{new_token}/arrivee"
|
|
depart_url = f"{base_url}/presence/{new_token}/depart"
|
|
return HTMLResponse(
|
|
f'<div id="presence-urls" hx-swap-oob="true">'
|
|
f'<div class="settings-urlrow">'
|
|
f'<span class="settings-urllabel">Arrivée</span>'
|
|
f'<code class="settings-url">{arrivee_url}</code>'
|
|
f'</div>'
|
|
f'<div class="settings-urlrow">'
|
|
f'<span class="settings-urllabel">Départ</span>'
|
|
f'<code class="settings-url">{depart_url}</code>'
|
|
f'</div>'
|
|
f'<div class="settings-saved">Token régénéré. Mets à jour les URLs dans Automation.</div>'
|
|
f'</div>'
|
|
)
|
|
|
|
|
|
@app.post("/settings/plages", response_class=HTMLResponse)
|
|
def settings_plages_save(
|
|
request: Request,
|
|
matin_debut: str = Form(""), matin_fin: str = Form(""),
|
|
aprem_debut: str = Form(""), aprem_fin: str = Form(""),
|
|
pause_dejeuner_fin: str = Form(""),
|
|
arrivee_visee: str = Form(""), depart_vise: str = Form(""),
|
|
):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
fields = {
|
|
"matin_debut": matin_debut, "matin_fin": matin_fin,
|
|
"aprem_debut": aprem_debut, "aprem_fin": aprem_fin,
|
|
"pause_dejeuner_fin": pause_dejeuner_fin,
|
|
# Heures visées, optionnelles (vide = comportement par défaut)
|
|
"arrivee_visee": arrivee_visee, "depart_vise": depart_vise,
|
|
}
|
|
optionnels = {"arrivee_visee", "depart_vise"}
|
|
if not all(_HHMM_RE.match(v) for k, v in fields.items() if v or k not in optionnels):
|
|
logger.warning("réglages plages rejetés (format invalide): user_id=%r valeurs=%r", user_id, fields)
|
|
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
|
if not (fields["matin_debut"] < fields["matin_fin"] < fields["aprem_debut"] < fields["aprem_fin"]):
|
|
logger.warning("réglages plages rejetés (ordre invalide): user_id=%r valeurs=%r", user_id, fields)
|
|
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
|
if (fields["arrivee_visee"] and fields["arrivee_visee"] >= fields["matin_fin"]) or \
|
|
(fields["depart_vise"] and fields["depart_vise"] < fields["aprem_fin"]):
|
|
logger.warning("réglages plages rejetés (heures visées incohérentes): user_id=%r valeurs=%r", user_id, fields)
|
|
return RedirectResponse("/settings?plages_error=1", status_code=303)
|
|
|
|
logger.info("réglages plages mis à jour: user_id=%r plages=%r", user_id, fields)
|
|
save_plages(user_id, fields)
|
|
append_log(
|
|
user_id, "settings", "Plages horaires",
|
|
f"matin {fields['matin_debut']}→{fields['matin_fin']}, "
|
|
f"aprem {fields['aprem_debut']}→{fields['aprem_fin']}, "
|
|
f"reprise {fields['pause_dejeuner_fin']}"
|
|
+ (f", visées {fields['arrivee_visee']}→{fields['depart_vise']}" if fields['arrivee_visee'] or fields['depart_vise'] else ""),
|
|
)
|
|
return RedirectResponse("/settings?plages_saved=1", status_code=303)
|
|
|
|
|
|
@app.post("/presence/{token}/arrivee")
|
|
def presence_arrivee(token: str):
|
|
user_id = find_user_by_token(token)
|
|
if not user_id:
|
|
logger.warning("POST /presence/.../arrivee: token inconnu")
|
|
return JSONResponse({"error": "invalid token"}, status_code=404)
|
|
logger.info("présence: arrivée détectée pour %r", user_id)
|
|
save_presence(user_id, present=True, since=datetime.now().isoformat())
|
|
append_log(user_id, "presence", "arrivée", "Présence détectée par Tasker")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.post("/presence/{token}/depart")
|
|
def presence_depart(token: str):
|
|
user_id = find_user_by_token(token)
|
|
if not user_id:
|
|
logger.warning("POST /presence/.../depart: token inconnu")
|
|
return JSONResponse({"error": "invalid token"}, status_code=404)
|
|
logger.info("présence: départ détecté pour %r", user_id)
|
|
save_presence(user_id, present=False, since=datetime.now().isoformat())
|
|
append_log(user_id, "presence", "départ", "Départ détecté par Tasker")
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/logs", response_class=HTMLResponse)
|
|
def logs_page(request: Request):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
logs = load_logs(user_id)
|
|
return templates.TemplateResponse("logs.html", {
|
|
"request": request,
|
|
"current_user": user_id,
|
|
"logs": logs,
|
|
"filter": request.query_params.get("filter", "all"),
|
|
})
|
|
|
|
|
|
@app.get("/aide", response_class=HTMLResponse)
|
|
def aide_page(request: Request):
|
|
user_id = _get_user(request)
|
|
if not user_id:
|
|
return RedirectResponse("/login", status_code=302)
|
|
return templates.TemplateResponse("aide.html", {
|
|
"request": request,
|
|
"current_user": user_id,
|
|
})
|
|
|
|
|