diff --git a/README.md b/README.md
index f0e65de..ef2e107 100644
--- a/README.md
+++ b/README.md
@@ -122,12 +122,20 @@ performance — une seule fois par semaine et par onglet, promis.
Chaque personne a son propre compte, ses propres pointages, congés, réglages et jetons de
présence — tout est stocké séparément dans `data/users//`.
-Connexion par email + mot de passe, restreinte au domaine `allowed_email_domain` (voir
-[Configuration](#configuration)). Pour un compte qui n'a pas encore de mot de passe, se
-connecter avec son adresse envoie un lien (valable 24h) pour en définir un — c'est aussi
-le seul moyen d'en obtenir un : il n'y a pas de "mot de passe oublié" en libre-service une
-fois un mot de passe défini. La session est un cookie signé (HMAC), sans base de données
-de sessions côté serveur.
+**Utilisation locale sans aucune config** : `data/config.json` est optionnel (créé
+avec les valeurs par défaut au premier lancement) et la config mail aussi — sans
+relais SMTP, le lien de création de compte s'affiche directement à l'écran au lieu
+d'être envoyé par email. Lance, ouvre [http://localhost:8000](http://localhost:8000), tape ton
+email, clique sur le lien affiché, définis ton mot de passe : c'est tout.
+
+Connexion par email + mot de passe. Si `allowed_email_domain` est renseigné, seules
+les adresses de ce domaine peuvent se connecter (vide = tous les domaines acceptés).
+Pour un compte qui n'a pas encore de mot de passe, se connecter avec son adresse
+génère un lien (valable 24h) pour en définir un — envoyé par email si un serveur
+SMTP est configuré, affiché à l'écran sinon. C'est aussi le seul moyen d'en obtenir
+un : il n'y a pas de "mot de passe oublié" en libre-service une fois un mot de
+passe défini. La session est un cookie signé (HMAC), sans base de données de
+sessions côté serveur.
---
@@ -146,37 +154,44 @@ Seul `data/config.example.json` est versionné.
## Configuration
-`data/config.json` (voir `data/config.example.json`) :
+`data/config.json` (voir `data/config.example.json`) — toutes les clés sont
+optionnelles (le fichier lui-même est créé avec les défauts au premier lancement) :
```json
{
"heures_semaine": 39.0,
"jours_par_semaine": 5,
- "log_level": "DEBUG",
- "allowed_email_domain": "exemple.fr",
- "base_url": "https://pointeuse.exemple.fr",
- "mail_from": "no-reply@exemple.fr",
+ "log_level": "INFO",
+ "allowed_email_domain": "",
+ "base_url": "",
+ "mail_from": "",
"smtp": {
- "host": "smtp.exemple.fr",
+ "host": "",
"port": 587,
- "user": "no-reply@exemple.fr",
- "password": "change-moi",
+ "user": "",
+ "password": "",
"use_tls": true
- }
+ },
+ "plages": { "matin_debut": "09:00", "matin_fin": "12:00", "aprem_debut": "14:00", "aprem_fin": "17:00", "pause_dejeuner_fin": "13:30" },
+ "astreinte_facteurs": []
}
```
- `heures_semaine` — heures hebdomadaires visées (ex: `39.0`).
- `jours_par_semaine` — nombre de jours travaillés dans la semaine (ex: `5`). Le quota quotidien est calculé : `heures_semaine ÷ jours_par_semaine` (39h / 5 = 7h48/jour).
- `log_level` — `DEBUG`, `INFO`, `WARNING` ou `ERROR`. Contrôle la verbosité des logs (`docker logs`).
-- `allowed_email_domain` — seules les adresses de ce domaine peuvent se connecter.
-- `base_url` — schéma + domaine (ex: `https://pointeuse.exemple.fr`, sans `/` final) utilisés
- pour construire les liens envoyés par email (magic link) et les URLs Tasker/Automation
- affichées dans Réglages. Si vide, ces URLs sont dérivées de la requête entrante (Host header)
- quand celui-ci correspond à `localhost` ou à `allowed_email_domain` ; sinon l'envoi d'email est
- refusé par sécurité (anti open-redirect). **Toujours renseigner `base_url` en production.**
-- `mail_from` — adresse expéditrice des emails de connexion.
-- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS).
+- `allowed_email_domain` — **optionnel** : seules les adresses de ce domaine peuvent se
+ connecter ; vide = tous les domaines acceptés (l'email ne sert que d'identifiant).
+- `base_url` — **optionnel** : schéma + domaine (ex: `https://pointeuse.exemple.fr`, sans `/` final)
+ utilisés pour construire les liens envoyés par email. Si vide, ces URLs sont dérivées de
+ la requête entrante (Host header) quand celui-ci correspond à `localhost` ou à
+ `allowed_email_domain` ; sinon l'envoi d'email est refusé par sécurité (anti
+ open-redirect). **À renseigner en production si le mail est activé.**
+- `mail_from` — **optionnel** : adresse expéditrice des emails de connexion (défaut local
+ `no-reply@pointeuse.local`).
+- `smtp` — **optionnel** : serveur relais utilisé pour l'envoi (host, port, identifiants,
+ TLS). Sans `smtp.host`, aucun email n'est envoyé : le lien de création de compte
+ s'affiche directement à l'écran — parfait pour une instance locale perso.
- `force_secure_cookies` (optionnel, `false` par défaut) — force le flag `Secure` sur le
cookie de session même si la requête entrante n'est pas vue comme HTTPS par l'app
(utile derrière un reverse proxy qui termine le TLS).
diff --git a/app/main.py b/app/main.py
index 3423733..b57dd37 100644
--- a/app/main.py
+++ b/app/main.py
@@ -659,8 +659,15 @@ def _htmx_calc_and_soldes(request, date, d, conges, h_jour, user_id):
RESET_TOKEN_VALIDITY_HOURS = 24
-def _login_ctx(request, step, email="", error=None):
- return {"request": request, "step": step, "email": email, "error": error}
+def _login_ctx(request, step, email="", error=None, link=None):
+ return {"request": request, "step": step, "email": email, "error": error, "link": link}
+
+
+def _email_autorise(m, domain: str) -> bool:
+ """True si l'email est acceptable. Domaine configuré → suffixe strict ;
+ domaine vide (config optionnelle, ex. instance locale perso) → tout domaine
+ est accepté, l'email ne sert que d'identifiant."""
+ return bool(m) and (not domain or m.group(2) == domain)
@app.get("/login", response_class=HTMLResponse)
@@ -684,11 +691,11 @@ def login_submit(request: Request, email: str = Form("")):
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:
+ if not _email_autorise(m, 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.",
+ f"Seules les adresses @{domain} sont autorisées." if domain else "Adresse email invalide.",
))
user_id = m.group(1)
@@ -698,17 +705,25 @@ def login_submit(request: Request, email: str = Form("")):
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.",
- ))
+ # Pas encore de compte : générer un lien pour définir le mot de passe. La
+ # config mail est optionnelle : avec un relais SMTP, le lien part par email
+ # (host contrôlé par _safe_base_url, anti open-redirect) ; sans relais
+ # (ex. instance locale mono-utilisateur), il s'affiche directement à
+ # l'écran — construit depuis l'URL en cours, celle que l'utilisateur regarde.
+ smtp = smtp_config()
+ if smtp.get("host"):
+ 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.",
+ ))
+ else:
+ base_url = str(request.base_url).rstrip("/")
token = secrets.token_urlsafe(32)
expires = (datetime.now() + timedelta(hours=RESET_TOKEN_VALIDITY_HOURS)).isoformat()
@@ -716,21 +731,24 @@ def login_submit(request: Request, email: str = Form("")):
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.",
- ))
+ if smtp.get("host"):
+ 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,
+ )
+ 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))
- return templates.TemplateResponse("login.html", _login_ctx(request, "sent", email))
+ logger.warning("SMTP non configuré : lien de définition de mot de passe affiché à l'écran pour %r", email)
+ return templates.TemplateResponse("login.html", _login_ctx(request, "link", email, link=link))
@app.post("/login/password", response_class=HTMLResponse)
@@ -747,7 +765,7 @@ def login_password(request: Request, email: str = Form(""), password: str = Form
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:
+ if not _email_autorise(m, domain):
logger.warning("login/password refusé (domaine invalide): email=%r", email)
return RedirectResponse("/login", status_code=302)
diff --git a/app/templates/base.html b/app/templates/base.html
index aeb326b..6e3d7d1 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -1460,6 +1460,8 @@
}
.login-input:focus { border-color: var(--accent); }
.login-hint { font-size: 11px; color: var(--muted); }
+ .login-link { margin: .6rem 0 0; font-size: 13px; word-break: break-all; }
+ .login-link a { color: var(--accent); }
.login-btn {
margin-top: .5rem;
font-family: var(--mono);
diff --git a/app/templates/login.html b/app/templates/login.html
index 1f93887..56c9fe3 100644
--- a/app/templates/login.html
+++ b/app/templates/login.html
@@ -50,6 +50,14 @@
Un email a été envoyé à {{ email }} avec un lien pour définir ton mot de passe.
Le lien est valable 24h.
+
+ {% elif step == "link" %}
+
+ Aucun serveur SMTP n'est configuré (optionnel) : le lien pour définir le mot de passe
+ de {{ email }} s'affiche ici au lieu d'être envoyé par email.
+ Il est valable 24h.
+
+ {{ link }}
{% endif %}
diff --git a/app/tests/test_security.py b/app/tests/test_security.py
index 2cb1298..bd083c5 100644
--- a/app/tests/test_security.py
+++ b/app/tests/test_security.py
@@ -597,3 +597,80 @@ def test_rate_limit_can_be_disabled_via_config():
cfg["login_rate_limit_per_min"] = main.DEFAULT_LOGIN_MAX_PER_MIN
models.save_config(cfg)
main._LOGIN_ATTEMPTS.clear()
+
+
+# ── Config mail optionnelle : usage local sans relais SMTP ────────────────────
+
+def test_sans_smtp_le_lien_de_compte_s_affiche_a_l_ecran(monkeypatch):
+ """Sans smtp.host, la création de compte doit fonctionner sans email : le
+ lien de définition de mot de passe s'affiche directement dans la page
+ (construit depuis l'URL en cours, pas de vecteur de phishing email)."""
+ cfg = models.load_config()
+ ancien_smtp = cfg.get("smtp")
+ ancien_domaine = cfg.get("allowed_email_domain")
+ cfg["smtp"] = {}
+ cfg["allowed_email_domain"] = ""
+ models.save_config(cfg)
+ try:
+ r = client.post("/login", data={"email": "solo.perso@nimporte.fr"},
+ headers={"Host": "localhost:8000"}, follow_redirects=False)
+ assert r.status_code == 200
+ assert "/set-password/" in r.text # le lien est affiché
+ assert "SMTP" in r.text # avec l'explication
+ # Le lien affiché fonctionne : définir le mot de passe puis se connecter
+ import re as _re
+ token = _re.search(r"/set-password/([A-Za-z0-9_\-]+)", r.text).group(1)
+ r = client.post(f"/set-password/{token}",
+ data={"password": "s" * 12, "password2": "s" * 12},
+ follow_redirects=False)
+ assert r.status_code == 302
+ r = client.post("/login/password",
+ data={"email": "solo.perso@nimporte.fr", "password": "s" * 12},
+ follow_redirects=False)
+ assert r.status_code == 302 # connexion réussie
+ finally:
+ cfg["smtp"] = ancien_smtp
+ cfg["allowed_email_domain"] = ancien_domaine or "x.fr"
+ models.save_config(cfg)
+
+
+def test_domaine_vide_accepte_tous_les_domaines(monkeypatch):
+ """allowed_email_domain vide (défaut, instance locale) : tout domaine accepté
+ à la connexion par mot de passe. Renseigné, il reste strictement appliqué."""
+ cfg = models.load_config()
+ ancien = cfg.get("allowed_email_domain")
+ cfg["allowed_email_domain"] = ""
+ models.save_config(cfg)
+ try:
+ _seed_user("libre.nimportequi")
+ r = client.post("/login/password",
+ data={"email": "libre.nimportequi@ailleurs.org", "password": "x" * 12},
+ follow_redirects=False)
+ assert r.status_code == 302 # accepté malgré @ailleurs.org
+ # Un email sans domaine valide reste refusé
+ r = client.post("/login", data={"email": "pas-un-email"}, follow_redirects=False)
+ assert "invalide" in r.text.lower()
+ finally:
+ cfg["allowed_email_domain"] = ancien or "x.fr"
+ models.save_config(cfg)
+
+def test_sans_smtp_le_lien_utilise_l_url_courante_sans_base_url():
+ """Sans SMTP, aucune config d'URL n'est nécessaire : le lien affiché est
+ construit depuis l'URL en cours (ex. http://192.168.1.10:8000 sur le LAN),
+ même avec base_url vide et allowed_email_domain vide. Le contrôle Host
+ strict (_safe_base_url) ne concerne que le chemin par email."""
+ cfg = models.load_config()
+ ancien_smtp = cfg.get("smtp")
+ ancien_domaine = cfg.get("allowed_email_domain")
+ ancienne_base = cfg.get("base_url")
+ cfg.update({"smtp": {}, "allowed_email_domain": "", "base_url": ""})
+ models.save_config(cfg)
+ try:
+ r = client.post("/login", data={"email": "lan.user@chez.moi"},
+ headers={"Host": "192.168.1.10:8000"}, follow_redirects=False)
+ assert r.status_code == 200
+ assert "http://192.168.1.10:8000/set-password/" in r.text
+ finally:
+ cfg.update({"smtp": ancien_smtp, "allowed_email_domain": ancien_domaine or "x.fr",
+ "base_url": ancienne_base or ""})
+ models.save_config(cfg)
diff --git a/data/config.example.json b/data/config.example.json
index 9747c2f..ebfc0d6 100644
--- a/data/config.example.json
+++ b/data/config.example.json
@@ -1,21 +1,35 @@
{
"heures_semaine": 39.0,
"jours_par_semaine": 5,
+ "log_level": "INFO",
+ "allowed_email_domain": "",
+ "base_url": "",
+ "mail_from": "",
+ "smtp": {
+ "host": "",
+ "port": 587,
+ "user": "",
+ "password": "",
+ "use_tls": true
+ },
+ "force_secure_cookies": false,
+ "login_rate_limit_per_min": 10,
+ "plages": {
+ "matin_debut": "09:00",
+ "matin_fin": "12:00",
+ "aprem_debut": "14:00",
+ "aprem_fin": "17:00",
+ "pause_dejeuner_fin": "13:30",
+ "arrivee_visee": "",
+ "depart_vise": "",
+ "astreinte_forfait": "",
+ "astreinte_active": true,
+ "repos_hebdo_active": false
+ },
"astreinte_facteurs": [
{"nom": "Nuit", "jours": ["semaine", "sam", "dim", "ferie"], "debut": "20:00", "fin": "08:00", "facteur": 1.5},
{"nom": "Journée", "jours": ["semaine"], "debut": "08:00", "fin": "20:00", "facteur": 1.0},
{"nom": "Samedi", "jours": ["sam"], "debut": "08:00", "fin": "20:00", "facteur": 1.5},
{"nom": "Dimanche / férié", "jours": ["dim", "ferie"], "debut": "08:00", "fin": "20:00", "facteur": 2.0}
- ],
- "log_level": "DEBUG",
- "allowed_email_domain": "exemple.fr",
- "base_url": "",
- "mail_from": "no-reply@exemple.fr",
- "smtp": {
- "host": "smtp.exemple.fr",
- "port": 587,
- "user": "no-reply@exemple.fr",
- "password": "change-moi",
- "use_tls": true
- }
+ ]
}