Réglages: base_url documentée, boutons copier pour les URLs Tasker, saisie horaire native

Documente la clé base_url (schéma/domaine) déjà supportée par config.json pour les
liens email et URLs de présence, et ajoute un bouton "Copier" à côté de chaque URL
arrivée/départ. Passe aussi les champs horaires du tableau hebdomadaire en <input
type="time"> et journalise l'IP source des appels de présence Tasker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Antoine
2026-08-20 17:13:18 +02:00
parent 3162968bfb
commit c1c063da51
4 changed files with 88 additions and 16 deletions

View File

@ -81,6 +81,7 @@ Seul `data/config.example.json` est versionné.
"jours_par_semaine": 5, "jours_par_semaine": 5,
"log_level": "DEBUG", "log_level": "DEBUG",
"allowed_email_domain": "exemple.fr", "allowed_email_domain": "exemple.fr",
"base_url": "https://pointeuse.exemple.fr",
"mail_from": "no-reply@exemple.fr", "mail_from": "no-reply@exemple.fr",
"smtp": { "smtp": {
"host": "smtp.exemple.fr", "host": "smtp.exemple.fr",
@ -96,6 +97,11 @@ Seul `data/config.example.json` est versionné.
- `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). - `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`). - `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. - `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. - `mail_from` — adresse expéditrice des emails de connexion.
- `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS). - `smtp` — serveur relais utilisé pour l'envoi (host, port, identifiants, TLS).

View File

@ -385,17 +385,17 @@ def _oob_time_cells(date, jour, plages):
matin_html = ( matin_html = (
f'<div id="cg-matin-{_e(date)}" hx-swap-oob="true" class="{matin_cls}">' f'<div id="cg-matin-{_e(date)}" hx-swap-oob="true" class="{matin_cls}">'
f'<div class="time-pair">' 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'<input class="time-input" type="time" name="matin_entree" data-date="{_e(date)}" value="{_e(jour["matin_entree"] or "")}">'
f'<span class="time-sep">→</span>' 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'<input class="time-input" type="time" name="matin_sortie" data-date="{_e(date)}" value="{_e(jour["matin_sortie"] or "")}">'
f'</div></div>' f'</div></div>'
) )
aprem_html = ( aprem_html = (
f'<div id="cg-aprem-{_e(date)}" hx-swap-oob="true" class="{aprem_cls}">' f'<div id="cg-aprem-{_e(date)}" hx-swap-oob="true" class="{aprem_cls}">'
f'<div class="time-pair">' 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'<input class="time-input" type="time" name="aprem_entree" data-date="{_e(date)}" value="{_e(jour["aprem_entree"] or "")}">'
f'<span class="time-sep">→</span>' 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'<input class="time-input" type="time" name="aprem_sortie" data-date="{_e(date)}" value="{_e(jour["aprem_sortie"] or "")}">'
f'</div></div>' f'</div></div>'
) )
return matin_html + aprem_html return matin_html + aprem_html
@ -882,11 +882,13 @@ def settings_regenerate_presence_token(request: Request):
f'<div id="presence-urls" hx-swap-oob="true">' f'<div id="presence-urls" hx-swap-oob="true">'
f'<div class="settings-urlrow">' f'<div class="settings-urlrow">'
f'<span class="settings-urllabel">Arrivée</span>' f'<span class="settings-urllabel">Arrivée</span>'
f'<code class="settings-url">{arrivee_url}</code>' f'<code class="settings-url" id="url-arrivee">{arrivee_url}</code>'
f'<button type="button" class="settings-copy-btn" data-copy-target="url-arrivee">Copier</button>'
f'</div>' f'</div>'
f'<div class="settings-urlrow">' f'<div class="settings-urlrow">'
f'<span class="settings-urllabel">Départ</span>' f'<span class="settings-urllabel">Départ</span>'
f'<code class="settings-url">{depart_url}</code>' f'<code class="settings-url" id="url-depart">{depart_url}</code>'
f'<button type="button" class="settings-copy-btn" data-copy-target="url-depart">Copier</button>'
f'</div>' f'</div>'
f'<div class="settings-saved">Token régénéré. Mets à jour les URLs dans Automation.</div>' f'<div class="settings-saved">Token régénéré. Mets à jour les URLs dans Automation.</div>'
f'</div>' f'</div>'
@ -937,26 +939,26 @@ def settings_plages_save(
@app.post("/presence/{token}/arrivee") @app.post("/presence/{token}/arrivee")
def presence_arrivee(token: str): def presence_arrivee(request: Request, token: str):
user_id = find_user_by_token(token) user_id = find_user_by_token(token)
if not user_id: if not user_id:
logger.warning("POST /presence/.../arrivee: token inconnu") logger.warning("POST /presence/.../arrivee: token inconnu")
return JSONResponse({"error": "invalid token"}, status_code=404) return JSONResponse({"error": "invalid token"}, status_code=404)
logger.info("présence: arrivée détectée pour %r", user_id) logger.info("présence: arrivée détectée pour %r", user_id)
save_presence(user_id, present=True, since=datetime.now().isoformat()) save_presence(user_id, present=True, since=datetime.now().isoformat())
append_log(user_id, "presence", "arrivée", "Présence détectée par Tasker") append_log(user_id, "presence", "arrivée", f"Présence détectée par Tasker (IP {_client_ip(request)})")
return {"ok": True} return {"ok": True}
@app.post("/presence/{token}/depart") @app.post("/presence/{token}/depart")
def presence_depart(token: str): def presence_depart(request: Request, token: str):
user_id = find_user_by_token(token) user_id = find_user_by_token(token)
if not user_id: if not user_id:
logger.warning("POST /presence/.../depart: token inconnu") logger.warning("POST /presence/.../depart: token inconnu")
return JSONResponse({"error": "invalid token"}, status_code=404) return JSONResponse({"error": "invalid token"}, status_code=404)
logger.info("présence: départ détecté pour %r", user_id) logger.info("présence: départ détecté pour %r", user_id)
save_presence(user_id, present=False, since=datetime.now().isoformat()) save_presence(user_id, present=False, since=datetime.now().isoformat())
append_log(user_id, "presence", "départ", "Départ détecté par Tasker") append_log(user_id, "presence", "départ", f"Départ détecté par Tasker (IP {_client_ip(request)})")
return {"ok": True} return {"ok": True}

View File

@ -18,10 +18,10 @@
<td class="c-matin"> <td class="c-matin">
<div id="cg-matin-{{ j.date }}" class="td-fill{% if matin_cg %} cg{% elif matin_warn %} warn{% endif %}"> <div id="cg-matin-{{ j.date }}" class="td-fill{% if matin_cg %} cg{% elif matin_warn %} warn{% endif %}">
<div class="time-pair"> <div class="time-pair">
<input class="time-input" type="text" name="matin_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" <input class="time-input" type="time" name="matin_entree"
data-date="{{ j.date }}" value="{{ j.matin_entree or '' }}"> data-date="{{ j.date }}" value="{{ j.matin_entree or '' }}">
<span class="time-sep">→</span> <span class="time-sep">→</span>
<input class="time-input" type="text" name="matin_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" <input class="time-input" type="time" name="matin_sortie"
data-date="{{ j.date }}" value="{{ j.matin_sortie or '' }}"> data-date="{{ j.date }}" value="{{ j.matin_sortie or '' }}">
</div> </div>
</div> </div>
@ -30,10 +30,10 @@
<td class="c-aprem"> <td class="c-aprem">
<div id="cg-aprem-{{ j.date }}" class="td-fill{% if aprem_cg %} cg{% elif aprem_warn %} warn{% endif %}"> <div id="cg-aprem-{{ j.date }}" class="td-fill{% if aprem_cg %} cg{% elif aprem_warn %} warn{% endif %}">
<div class="time-pair"> <div class="time-pair">
<input class="time-input" type="text" name="aprem_entree" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" <input class="time-input" type="time" name="aprem_entree"
data-date="{{ j.date }}" value="{{ j.aprem_entree or '' }}"> data-date="{{ j.date }}" value="{{ j.aprem_entree or '' }}">
<span class="time-sep">→</span> <span class="time-sep">→</span>
<input class="time-input" type="text" name="aprem_sortie" inputmode="numeric" pattern="[0-9]{2}:[0-9]{2}" maxlength="5" placeholder="--:--" <input class="time-input" type="time" name="aprem_sortie"
data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}"> data-date="{{ j.date }}" value="{{ j.aprem_sortie or '' }}">
</div> </div>
</div> </div>

View File

@ -138,11 +138,13 @@
<div id="presence-urls"> <div id="presence-urls">
<div class="settings-urlrow"> <div class="settings-urlrow">
<span class="settings-urllabel">Arrivée</span> <span class="settings-urllabel">Arrivée</span>
<code class="settings-url">{{ arrivee_url }}</code> <code class="settings-url" id="url-arrivee">{{ arrivee_url }}</code>
<button type="button" class="settings-copy-btn" data-copy-target="url-arrivee">Copier</button>
</div> </div>
<div class="settings-urlrow"> <div class="settings-urlrow">
<span class="settings-urllabel">Départ</span> <span class="settings-urllabel">Départ</span>
<code class="settings-url">{{ depart_url }}</code> <code class="settings-url" id="url-depart">{{ depart_url }}</code>
<button type="button" class="settings-copy-btn" data-copy-target="url-depart">Copier</button>
</div> </div>
</div> </div>
<p class="settings-hint"> <p class="settings-hint">
@ -252,6 +254,26 @@
.settings-regen-btn:hover:not([disabled]) { .settings-regen-btn:hover:not([disabled]) {
background: var(--neg-bg); background: var(--neg-bg);
} }
.settings-copy-btn {
font-family: var(--mono);
font-size: 10px;
letter-spacing: .04em;
color: var(--accent);
background: none;
border: 1px solid var(--rule);
padding: .35rem .6rem;
cursor: pointer;
flex-shrink: 0;
transition: border-color .15s, background .15s;
}
.settings-copy-btn:hover {
border-color: var(--accent);
background: rgba(47,95,160,.06);
}
.settings-copy-btn.settings-copy-btn--done {
color: var(--pos);
border-color: var(--pos);
}
#ntfy-test-result { #ntfy-test-result {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -266,4 +288,46 @@
line-height: 1.45; line-height: 1.45;
} }
</style> </style>
<script>
(function () {
// Délégation sur document : #presence-urls est remplacé par un swap OOB
// htmx quand le token est régénéré, les boutons "Copier" doivent continuer
// à fonctionner après ce remplacement.
document.addEventListener("click", function (e) {
var btn = e.target.closest(".settings-copy-btn");
if (!btn) return;
var target = document.getElementById(btn.dataset.copyTarget);
if (!target) return;
var text = target.textContent;
var onDone = function (ok) {
var previous = btn.textContent;
btn.textContent = ok ? "Copié !" : "Échec";
btn.classList.toggle("settings-copy-btn--done", ok);
setTimeout(function () {
btn.textContent = previous;
btn.classList.remove("settings-copy-btn--done");
}, 1500);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
function () { onDone(true); },
function () { onDone(false); }
);
} else {
var textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
var ok = false;
try { ok = document.execCommand("copy"); } catch (err) { ok = false; }
document.body.removeChild(textarea);
onDone(ok);
}
});
})();
</script>
{% endblock %} {% endblock %}