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:
@ -81,6 +81,7 @@ Seul `data/config.example.json` est versionné.
|
||||
"jours_par_semaine": 5,
|
||||
"log_level": "DEBUG",
|
||||
"allowed_email_domain": "exemple.fr",
|
||||
"base_url": "https://pointeuse.exemple.fr",
|
||||
"mail_from": "no-reply@exemple.fr",
|
||||
"smtp": {
|
||||
"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).
|
||||
- `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).
|
||||
|
||||
|
||||
22
app/main.py
22
app/main.py
@ -385,17 +385,17 @@ def _oob_time_cells(date, jour, plages):
|
||||
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'<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'<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>'
|
||||
)
|
||||
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'<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'<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>'
|
||||
)
|
||||
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 class="settings-urlrow">'
|
||||
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 class="settings-urlrow">'
|
||||
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 class="settings-saved">Token régénéré. Mets à jour les URLs dans Automation.</div>'
|
||||
f'</div>'
|
||||
@ -937,26 +939,26 @@ def settings_plages_save(
|
||||
|
||||
|
||||
@app.post("/presence/{token}/arrivee")
|
||||
def presence_arrivee(token: str):
|
||||
def presence_arrivee(request: Request, 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")
|
||||
append_log(user_id, "presence", "arrivée", f"Présence détectée par Tasker (IP {_client_ip(request)})")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/presence/{token}/depart")
|
||||
def presence_depart(token: str):
|
||||
def presence_depart(request: Request, 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")
|
||||
append_log(user_id, "presence", "départ", f"Départ détecté par Tasker (IP {_client_ip(request)})")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
|
||||
@ -18,10 +18,10 @@
|
||||
<td class="c-matin">
|
||||
<div id="cg-matin-{{ j.date }}" class="td-fill{% if matin_cg %} cg{% elif matin_warn %} warn{% endif %}">
|
||||
<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 '' }}">
|
||||
<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 '' }}">
|
||||
</div>
|
||||
</div>
|
||||
@ -30,10 +30,10 @@
|
||||
<td class="c-aprem">
|
||||
<div id="cg-aprem-{{ j.date }}" class="td-fill{% if aprem_cg %} cg{% elif aprem_warn %} warn{% endif %}">
|
||||
<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 '' }}">
|
||||
<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 '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -138,11 +138,13 @@
|
||||
<div id="presence-urls">
|
||||
<div class="settings-urlrow">
|
||||
<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 class="settings-urlrow">
|
||||
<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>
|
||||
<p class="settings-hint">
|
||||
@ -252,6 +254,26 @@
|
||||
.settings-regen-btn:hover:not([disabled]) {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@ -266,4 +288,46 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
</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 %}
|
||||
|
||||
Reference in New Issue
Block a user