Envoie les emails via un relais SMTP configurable plutôt qu'en direct-to-MX
Ajoute le bloc "smtp" (host, port, identifiants, TLS) dans config.json, plus fiable et plus simple à opérer qu'une livraison directe au MX du destinataire. data/config.json n'est plus versionné (secrets), remplacé par data/config.example.json.
This commit is contained in:
25
app/mail.py
Normal file
25
app/mail.py
Normal file
@ -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)
|
||||
138
app/main.py
138
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
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -11,22 +11,46 @@
|
||||
<div class="login-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if step == "email" %}
|
||||
<form method="post" action="/login" class="login-form">
|
||||
<label class="login-label" for="username">Identifiant</label>
|
||||
<label class="login-label" for="email">Adresse email</label>
|
||||
<input
|
||||
id="username"
|
||||
id="email"
|
||||
class="login-input"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
type="email"
|
||||
name="email"
|
||||
value="{{ email }}"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
placeholder="prenom.nom"
|
||||
pattern="[a-zA-Z0-9_\-]{1,32}"
|
||||
placeholder="prenom.nom@domaine.fr"
|
||||
required
|
||||
>
|
||||
<p class="login-hint">Lettres, chiffres, tirets — 1 à 32 caractères.</p>
|
||||
<button class="login-btn" type="submit">Entrer</button>
|
||||
<button class="login-btn" type="submit">Continuer</button>
|
||||
</form>
|
||||
|
||||
{% elif step == "password" %}
|
||||
<form method="post" action="/login/password" class="login-form">
|
||||
<input type="hidden" name="email" value="{{ email }}">
|
||||
<label class="login-label">{{ email }}</label>
|
||||
<input
|
||||
id="password"
|
||||
class="login-input"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
placeholder="Mot de passe"
|
||||
required
|
||||
>
|
||||
<button class="login-btn" type="submit">Se connecter</button>
|
||||
</form>
|
||||
|
||||
{% elif step == "sent" %}
|
||||
<p class="login-hint">
|
||||
Un email a été envoyé à <b>{{ email }}</b> avec un lien pour définir ton mot de passe.
|
||||
Le lien est valable 24h.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
49
app/templates/set_password.html
Normal file
49
app/templates/set_password.html
Normal file
@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="login-wrap">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<span class="login-eyebrow">Décompte Horaire</span>
|
||||
<h1 class="login-title">Définir ton mot de passe</h1>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="login-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if valid %}
|
||||
<form method="post" action="/set-password/{{ token }}" class="login-form">
|
||||
<label class="login-label" for="password">Nouveau mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
class="login-input"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
autofocus
|
||||
placeholder="8 caractères minimum"
|
||||
minlength="8"
|
||||
required
|
||||
>
|
||||
<label class="login-label" for="password2">Confirmation</label>
|
||||
<input
|
||||
id="password2"
|
||||
class="login-input"
|
||||
type="password"
|
||||
name="password2"
|
||||
autocomplete="new-password"
|
||||
placeholder="Retape le même mot de passe"
|
||||
minlength="8"
|
||||
required
|
||||
>
|
||||
<button class="login-btn" type="submit">Valider</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="login-hint">
|
||||
Ce lien est invalide ou a expiré. Retourne sur la
|
||||
<a href="/login">page de connexion</a> pour en redemander un.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user