diff --git a/.gitignore b/.gitignore index a5b8efa..1f6ac60 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Données personnelles — jamais dans le dépôt data/pointages/ data/conges.json +data/config.json +data/users/ *.ods *.csv *.json.bak diff --git a/README.md b/README.md index 893749f..5fb5193 100644 --- a/README.md +++ b/README.md @@ -35,27 +35,40 @@ Les plages qui ne respectent pas les minimums sont signalées en rouge dès la s ## Lancement ```bash +cp data/config.example.json data/config.json # à adapter à ton domaine docker compose up -d ``` Ouvre [http://localhost:8000](http://localhost:8000). -Les données (pointages, congés) sont stockées dans `data/` et ignorées par git.\ -Seul `data/config.json` est versionné. +Les données (pointages, congés, comptes) sont stockées dans `data/` et ignorées par git.\ +Seul `data/config.example.json` est versionné — copie-le en `data/config.json` et adapte-le. --- ## Configuration -`data/config.json` : +`data/config.json` (voir `data/config.example.json`) : ```json { - "heures_jour": 7.8 + "heures_jour": 7.8, + "allowed_email_domain": "exemple.fr", + "mail_from": "no-reply@exemple.fr", + "smtp": { + "host": "smtp.exemple.fr", + "port": 587, + "user": "no-reply@exemple.fr", + "password": "change-moi", + "use_tls": true + } } ``` -`7.8` = 7h48 = 39h ÷ 5 jours. Modifier cette valeur recentre tous les calculs. +- `heures_jour` — `7.8` = 7h48 = 39h ÷ 5 jours. Modifier cette valeur recentre tous les calculs. +- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter. +- `mail_from` — adresse expéditrice des emails de connexion. +- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS). --- diff --git a/app/mail.py b/app/mail.py new file mode 100644 index 0000000..50b8369 --- /dev/null +++ b/app/mail.py @@ -0,0 +1,25 @@ +"""Mail delivery via a configured SMTP relay (host/port/credentials in config.json).""" +import smtplib +from email.mime.text import MIMEText + + +def send_mail(to_addr: str, subject: str, body: str, from_addr: str, smtp_cfg: dict): + host = smtp_cfg.get("host") + if not host: + raise RuntimeError("Aucun serveur SMTP configuré (smtp.host manquant dans config.json).") + port = int(smtp_cfg.get("port", 587)) + user = smtp_cfg.get("user") + password = smtp_cfg.get("password") + use_tls = smtp_cfg.get("use_tls", True) + + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = to_addr + + with smtplib.SMTP(host, port, timeout=10) as smtp: + if use_tls: + smtp.starttls() + if user: + smtp.login(user, password) + smtp.send_message(msg) diff --git a/app/main.py b/app/main.py index 2b2a83f..61f5f16 100644 --- a/app/main.py +++ b/app/main.py @@ -1,18 +1,22 @@ import asyncio import json import re +import secrets from datetime import datetime, timedelta from itertools import groupby +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, needs_migration, migrate_legacy_to_user, + toggle_conge, list_all_weeks, load_notif_config, save_notif_config, find_user_by_token, save_presence, + load_auth, save_auth, find_user_by_reset_token, ) from calcul import compute_week, minutes_to_hhmm, hhmm_to_minutes, heures_dues from stats import compute_all_stats @@ -30,19 +34,30 @@ 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,32}$") +_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})$") # ── Auth helpers ────────────────────────────────────────────────────────────── def _get_user(request: Request) -> str | None: - """Extract current user from cookie. - TODO: replace with SSO token validation when integrating corporate SSO. - """ + """Extract current user from cookie.""" uid = request.cookies.get("user_id", "").strip() return uid if _USERNAME_RE.match(uid) else None +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", {}) + + def _require_user(request: Request): """Return user_id or redirect to login.""" uid = _get_user(request) @@ -217,27 +232,116 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id): # ── 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", {"request": request, "error": None}) + return templates.TemplateResponse("login.html", _login_ctx(request, "email")) @app.post("/login", response_class=HTMLResponse) -def login_submit(request: Request, response: Response, username: str = Form("")): - username = username.strip().lower() - if not _USERNAME_RE.match(username): - return templates.TemplateResponse("login.html", { - "request": request, - "error": "Nom d'utilisateur invalide (lettres, chiffres, - et _ uniquement, 1–32 caractères).", - }) - # First-ever login: migrate legacy flat data to this user's directory - if needs_migration(): - migrate_legacy_to_user(username) +def login_submit(request: Request, email: str = Form("")): + email = email.strip().lower() + m = _EMAIL_RE.match(email) + domain = allowed_email_domain() + if not m or not domain or m.group(2) != domain: + 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) + + 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 + 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) + + base_url = str(request.base_url).rstrip("/") + 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: + 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("")): + email = email.strip().lower() + m = _EMAIL_RE.match(email) + domain = allowed_email_domain() + if not m or not domain or m.group(2) != domain: + 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()): + return templates.TemplateResponse("login.html", _login_ctx( + request, "password", email, "Mot de passe incorrect.", + )) resp = RedirectResponse("/", status_code=302) - resp.set_cookie("user_id", username, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) + resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) + 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: + return templates.TemplateResponse("set_password.html", { + "request": request, "token": token, "valid": False, "error": None, + }) + if len(password) < 8 or password != password2: + 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) + + resp = RedirectResponse("/", status_code=302) + resp.set_cookie("user_id", user_id, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) return resp diff --git a/app/models.py b/app/models.py index 1bd719c..22bf599 100644 --- a/app/models.py +++ b/app/models.py @@ -1,7 +1,6 @@ """Flat-file storage: JSON files per week for pointages, one file for congés.""" import json import secrets -import shutil from pathlib import Path DATA_DIR = Path("/data") @@ -9,17 +8,20 @@ CONFIG_FILE = DATA_DIR / "config.json" DEFAULT_CONFIG = { "heures_jour": 7.8, # 7h48 = 39h/semaine + "allowed_email_domain": "", # ex: "sdv.fr" — seules ces adresses peuvent se connecter + "mail_from": "", # ex: "no-reply@sdv.fr" — expéditeur des emails de connexion + "smtp": { + "host": "", # ex: "smtp.sdv.fr" + "port": 587, + "user": "", + "password": "", + "use_tls": True, + }, } # ── User path helpers ────────────────────────────────────────────────────────── def _user_root(user_id: str) -> Path: - """Return data directory for a user. - - Legacy fallback: if no users/ directory exists yet but /data/pointages/ does, - the first login migrates the data automatically (see migrate_legacy_to_user). - After migration, each user gets /data/users/{user_id}/. - """ return DATA_DIR / "users" / user_id @@ -35,29 +37,6 @@ def _ensure_user_dirs(user_id: str): _pointages_dir(user_id).mkdir(parents=True, exist_ok=True) -# ── Legacy migration (called once on first login) ───────────────────────────── - -def needs_migration() -> bool: - """True when legacy flat data exists and no users/ directory has been created yet.""" - return (DATA_DIR / "pointages").exists() and not (DATA_DIR / "users").exists() - - -def migrate_legacy_to_user(user_id: str): - """Move /data/pointages/ and /data/conges.json into /data/users/{user_id}/.""" - user_root = _user_root(user_id) - user_root.mkdir(parents=True, exist_ok=True) - - legacy_pt = DATA_DIR / "pointages" - dest_pt = user_root / "pointages" - if legacy_pt.exists() and not dest_pt.exists(): - shutil.copytree(legacy_pt, dest_pt) - - legacy_cg = DATA_DIR / "conges.json" - dest_cg = user_root / "conges.json" - if legacy_cg.exists() and not dest_cg.exists(): - shutil.copy2(legacy_cg, dest_cg) - - # ── User list ───────────────────────────────────────────────────────────────── def list_users() -> list[str]: @@ -126,6 +105,33 @@ def save_conges(conges: list[dict], user_id: str = ""): ) +# ── Auth (email + password) ──────────────────────────────────────────────────── + +def _auth_file(user_id: str) -> Path: + return _user_root(user_id) / "auth.json" + + +def load_auth(user_id: str) -> dict: + f = _auth_file(user_id) + if f.exists(): + return json.loads(f.read_text()) + return {"email": None, "password_hash": None, "token": None, "token_expires": None} + + +def save_auth(user_id: str, **fields): + auth = load_auth(user_id) + auth.update(fields) + _ensure_user_dirs(user_id) + _auth_file(user_id).write_text(json.dumps(auth, indent=2, ensure_ascii=False)) + + +def find_user_by_reset_token(token: str) -> str | None: + for user_id in list_users(): + if load_auth(user_id).get("token") == token: + return user_id + return None + + # ── Notifications (ntfy topic + presence tracking) ──────────────────────────── def _notif_file(user_id: str) -> Path: diff --git a/app/templates/login.html b/app/templates/login.html index dfa8d6e..7953ac6 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -11,22 +11,46 @@
{{ error }}
{% endif %} + {% if step == "email" %}
- + -

Lettres, chiffres, tirets — 1 à 32 caractères.

- +
+ + {% elif step == "password" %} +
+ + + + +
+ + {% elif step == "sent" %} +

+ Un email a été envoyé à {{ email }} avec un lien pour définir ton mot de passe. + Le lien est valable 24h. +

+ {% endif %} diff --git a/app/templates/set_password.html b/app/templates/set_password.html new file mode 100644 index 0000000..e0bd8ee --- /dev/null +++ b/app/templates/set_password.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block content %} +
+
+ + + {% if error %} + + {% endif %} + + {% if valid %} + + {% else %} + + {% endif %} +
+
+{% endblock %} diff --git a/data/config.example.json b/data/config.example.json new file mode 100644 index 0000000..05e37ad --- /dev/null +++ b/data/config.example.json @@ -0,0 +1,12 @@ +{ + "heures_jour": 7.8, + "allowed_email_domain": "exemple.fr", + "mail_from": "no-reply@exemple.fr", + "smtp": { + "host": "smtp.exemple.fr", + "port": 587, + "user": "no-reply@exemple.fr", + "password": "change-moi", + "use_tls": true + } +} diff --git a/data/config.json b/data/config.json deleted file mode 100644 index 05bc0af..0000000 --- a/data/config.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "heures_jour": 7.8 -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 00f7d94..e64c13d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.29.0 jinja2==3.1.4 python-multipart==0.0.9 odfpy==1.4.1 +bcrypt==4.2.0